// 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); })();