1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
// node modules/test/test-chat-return.js
// test-chat-return: chat() must resolve the worker's answer and must survive a tool-using turn.
//
// Why this file exists: 32608bc shipped a log line that referenced an undeclared `recent`
// inside chat()'s finally block. Every tool-using turn threw ReferenceError there, so chat()
// rejected instead of returning, and delegateTask published "completed" (or "agent failed").
// Nothing tested chat() itself, so it reached production. This is that test.
const fs = require('fs'), path = require('path');
const ROOT = __dirname + '/../core';
const store = {};
global.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; }
};
global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
global.window = global;
global.addEventListener = () => {};
let AI;
try {
// module-aiassist.js is a browser script (it assigns a global, it does not module.exports),
// so evaluate it the same way test-tool-context-bound.js does.
AI = new Function(fs.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')();
} catch (e) {
console.log('FAIL could not load module-aiassist.js — ' + e.message);
process.exit(1);
}
if (typeof AI !== 'function') {
console.log('FAIL module-aiassist.js did not expose WebX11AIAssistantModule (got ' + typeof AI + ')');
process.exit(1);
}
const stub = {
print: () => {}, log: () => {}, setAiBusy: () => {}, markDirty: () => {},
setPrompt: () => {}, clearInput: () => {}, scrollToBottom: () => {}, refreshPrompt: () => {},
ollamaManager: null,
fs: { mkdirRecursive: () => {}, writeFile: () => true, readFile: () => null, exists: () => false, list: () => [] },
addEventListener: () => {}, removeEventListener: () => {}
};
function mk(toolTurns, answer) {
const ai = new AI();
ai.terminal = stub;
ai._saveContext = () => {};
ai._archiveSession = () => {};
ai._messageCount = 0;
ai.agenticLoop = async () => {
for (let i = 0; i < toolTurns; i++) {
ai._toolCallLog.push({ name: 'read_file', returnedBytes: 16000, clippedBytes: 10000,
truncated: true, durationMs: 12, ok: true });
}
return { finalAnswer: answer, toolTurns };
};
return ai;
}
let fails = 0, passes = 0;
function ok(name, cond, detail) {
if (cond) { passes++; return; }
fails++;
console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
}
const msgs = () => [{ role: 'user', content: 'go' }];
const orch = res => (typeof res === 'string' ? res : (res && res.content) || 'completed');
(async () => {
// G1: a tool-using turn must not throw (the 32608bc regression)
let r1 = null, err = null;
try { r1 = await mk(2, 'WORKER ANSWER').chat(msgs()); } catch (e) { err = e; }
ok('G1 tool-using turn does not throw', !err, err && err.message);
ok('G2 it returns the worker answer', !!r1 && r1.content === 'WORKER ANSWER', JSON.stringify(r1));
ok('G3 it reports toolTurns', !!r1 && r1.toolTurns === 2, r1 && String(r1.toolTurns));
ok('G4 the orchestrator projection keeps the answer', orch(r1) === 'WORKER ANSWER', String(orch(r1)));
// G5: a tool-free turn also returns the answer (used to resolve undefined)
const r2 = await mk(0, 'PLAIN ANSWER').chat(msgs());
ok('G5 tool-free turn returns the answer', !!r2 && r2.content === 'PLAIN ANSWER', JSON.stringify(r2));
// G6: the lock is released even when a turn ends badly
const ai = mk(1, 'X');
ai.agenticLoop = async () => { throw new Error('boom'); };
await ai.chat(msgs()).catch(() => {});
ok('G6 _chatLock released after a failed turn', ai._chatLock === false, String(ai._chatLock));
// G7: consecutive turns still work (the log line must not depend on stale state)
const ai3 = mk(1, 'A1');
await ai3.chat(msgs());
ai3.agenticLoop = async () => ({ finalAnswer: 'A2', toolTurns: 0 });
const r3 = await ai3.chat(msgs());
ok('G7 second turn returns its own answer', !!r3 && r3.content === 'A2', JSON.stringify(r3));
console.log('test-chat-return: ' + passes + ' passed, ' + fails + ' failed');
process.exit(fails ? 1 : 0);
})();
|