aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-chat-concurrency.js
blob: 19b7e453c7f5236588589c9f5e267c6efdf95245 (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
// node modules/test/test-chat-concurrency.js
// Fan-out safety for chat(): delegated runs must not serialise, the main turn must stay
// serialised, a waiter must never clear a lock it does not own, and tool-log entries must
// be attributable to the run that produced them.
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 = () => {};

const AI = new Function(fs.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')();

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(delay, opts = {}) {
    const ai = new AI();
    ai.terminal = stub;
    ai._saveContext = () => {};
    ai._archiveSession = () => {};
    ai._messageCount = 0;
    if (opts.lockTimeout) ai._chatLockTimeoutMs = opts.lockTimeout;
    ai.agenticLoop = async (messages, o) => {
        await new Promise(r => setTimeout(r, delay));
        ai._recordToolCall({ name: 'read_file', turn: 1, args: {}, durationMs: delay, ok: true,
                             returnedBytes: 12000, clippedBytes: 6000, truncated: true,
                             tag: (o && o.agentTag) || 'main' });
        return { finalAnswer: 'A-' + ((o && o.agentTag) || 'main'), toolTurns: 1 };
    };
    return ai;
}

let fails = 0, passes = 0;
const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); };
const msgs = () => [{ role: 'user', content: 'go' }];
const time = async fn => { const t = Date.now(); await fn(); return Date.now() - t; };

(async () => {
    // H1: two delegated runs overlap
    const ai = mk(300);
    const t1 = await time(() => Promise.all([
        ai.chat(msgs(), { agentTag: 'agent-a' }),
        ai.chat(msgs(), { agentTag: 'agent-b' })
    ]));
    ok('H1 delegated runs run concurrently', t1 < 520, t1 + 'ms for 2x300ms');

    // H2: the main conversation still queues (user turns must not interleave)
    const ai2 = mk(200);
    const t2 = await time(() => Promise.all([ai2.chat(msgs()), ai2.chat(msgs())]));
    ok('H2 main-path turns stay serialised', t2 >= 380, t2 + 'ms for 2x200ms');

    // H3: a waiter must not steal/clear a lock it does not own
    const ai3 = mk(400, { lockTimeout: 80 });
    const first = ai3.chat(msgs());               // holds the main lock for 400ms
    await new Promise(r => setTimeout(r, 40));
    let secondErr = null;
    try { await ai3.chat(msgs()); } catch (e) { secondErr = e; }
    ok('H3 waiter fails instead of force-releasing', !!secondErr, secondErr ? secondErr.message : 'resolved anyway');
    await first.catch(() => {});
    ok('H4 the owner still released cleanly afterwards', ai3._chatLock === false, String(ai3._chatLock));

    // H5: tool-log entries are attributable
    const ai4 = mk(120);
    await Promise.all([ai4.chat(msgs(), { agentTag: 'agent-x' }), ai4.chat(msgs(), { agentTag: 'agent-y' })]);
    const tags = ai4._toolCallLog.map(e => e.tag);
    ok('H5 concurrent runs tag their own tool entries', tags.includes('agent-x') && tags.includes('agent-y'), JSON.stringify(tags));
    ok('H6 per-run slice returns only that run', ai4._recentToolLog('agent-x', 5).every(e => e.tag === 'agent-x'),
       JSON.stringify(ai4._recentToolLog('agent-x', 5).map(e => e.tag)));

    // H7: an agent run must not clear a cancel that the user pressed for the main turn
    const ai5 = mk(50);
    ai5.cancelAi();
    await ai5.chat(msgs(), { agentTag: 'agent-z' }).catch(() => {});
    ok('H7 agent run leaves the user cancel flag alone', ai5._cancelled === true, String(ai5._cancelled));

    console.log('test-chat-concurrency: ' + passes + ' passed, ' + fails + ' failed');
    process.exit(fails ? 1 : 0);
})();