aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 20:51:14 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 20:51:14 +0100
commitaeded980e3d998640db8a84ac0c8d189387467b2 (patch)
treecfd75f83b848263918bc2af911362b7acb6b6d77
parentd6eee520bc26631455135d1f2229d4013721e5b9 (diff)
test(newos4): guard chat()'s return and tool-turn survival
The regression in 32608bc shipped because nothing exercised chat() itself. test-chat-return.js runs 7 assertions against the real module: a tool-using turn must not throw, must return {content, toolTurns}, the orchestrator projection must keep the answer, a tool-free turn must return too, the lock must be released after a failed turn, and a second turn must return its own answer. Green 7/7 on 8a9333b; against 32608bc it exits 1 (ReferenceError: recent). Auto-discovered by newos4-maintain (23/23 node tests).
-rw-r--r--modules/test/test-chat-return.js97
1 files changed, 97 insertions, 0 deletions
diff --git a/modules/test/test-chat-return.js b/modules/test/test-chat-return.js
new file mode 100644
index 0000000..f942982
--- /dev/null
+++ b/modules/test/test-chat-return.js
@@ -0,0 +1,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);
+})();