aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-chat-return.js
blob: f9429829a9b9cfc15cac708aff8cc978be8fea4d (plain)
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);
})();