diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-02 20:51:14 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-02 20:51:14 +0100 |
| commit | aeded980e3d998640db8a84ac0c8d189387467b2 (patch) | |
| tree | cfd75f83b848263918bc2af911362b7acb6b6d77 | |
| parent | d6eee520bc26631455135d1f2229d4013721e5b9 (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.js | 97 |
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); +})(); |