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
|
// node modules/test/test-aiasm-rejection.js
// test-aiasm-rejection — the wx / webxai / webx aliases must not turn the AI chat-lock timeout
// into an unhandled promise rejection (RPT-20261107-150726-bf727247).
//
// Why this file exists: forwardToAI() used to call the assistant's chat(args) with no await and no
// .catch() inside a non-async function. When the assistant was already mid-turn, chat() waited
// _chatLockTimeoutMs (10s) and then rejected with "AI is still busy with another turn…". Nothing
// was attached to that promise, so it surfaced on window.unhandledrejection and the debug
// supervisor auto-filed a bug report for what is normal "the user typed twice" UX.
const fs = require('fs');
const path = require('path');
const SRC = path.join(__dirname, '..', 'core', 'module-aiasm.js');
const Alias = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebXAIAliasModule;')();
const BUSY = 'AI is still busy with another turn (waited 10s). Wait for it to finish, or run '
+ "'ai-stop' and retry — the running turn's lock is left alone.";
const unhandled = [];
process.on('unhandledRejection', (e) => unhandled.push(e));
let passes = 0, fails = 0;
function ok(name, cond, detail) {
if (cond) { passes++; return; }
fails++;
console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
}
function mk(chatImpl) {
const printed = [];
const term = {
print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }),
moduleManager: { modules: { WebX11AIAssistantModule: chatImpl ? { chat: chatImpl } : null } },
ollamaManager: { chat: () => Promise.resolve('ok') }
};
const m = new Alias();
m.terminal = term;
m.registerCommands();
return { m, printed, term };
}
const tick = (ms) => new Promise(r => setTimeout(r, ms || 25));
(async () => {
// A1-A5: the busy rejection is printed, the returned promise settles, nothing escapes
const a = mk(() => Promise.reject(new Error(BUSY)));
const r = a.m.forwardToAI(['hi']);
ok('A1 forwardToAI returns a promise (callers can await it)', !!r && typeof r.then === 'function', typeof r);
let awaited;
try { awaited = await r; } catch (e) { awaited = 'REJECTED:' + e.message; }
ok('A2 the returned promise settles instead of rejecting', String(awaited).indexOf('REJECTED') !== 0, String(awaited));
const errLine = a.printed.find(p => /^AI error: /.test(p.msg));
ok('A3 the busy timeout is printed to the terminal', !!errLine && /still busy/.test(errLine.msg),
JSON.stringify(a.printed));
ok('A4 it is printed as an error line', !!errLine && errLine.kind === 'error', errLine && errLine.kind);
await tick();
ok('A5 no unhandledRejection escaped', unhandled.length === 0,
unhandled.map(e => (e && e.message) || String(e)).join('; '));
// A6-A7: the same through the command map the terminal dispatcher actually awaits
const b = mk(() => Promise.reject(new Error(BUSY)));
let cmdErr = null;
try { await b.m.commands['wx'](['second question']); } catch (e) { cmdErr = e; }
ok('A6 `wx` command does not reject', !cmdErr, cmdErr && cmdErr.message);
await tick();
ok('A7 `wx` prints the busy error', b.printed.some(p => /AI error: .*still busy/.test(p.msg)),
JSON.stringify(b.printed));
// A8-A10: happy path — a resolving chat must print nothing at all
const c = mk(() => Promise.resolve({ role: 'assistant', content: 'pong' }));
let res;
try { res = await c.m.forwardToAI(['ping']); } catch (e) { res = 'REJECTED:' + e.message; }
await tick();
ok('A8 a resolving chat prints nothing', c.printed.length === 0, JSON.stringify(c.printed));
ok('A9 happy path does not throw', String(res).indexOf('REJECTED') !== 0, String(res));
ok('A10 happy path leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length));
// A11-A12: ollama fallback (no assistant module) is handled the same way
const d = mk(null);
d.term.ollamaManager.chat = () => Promise.reject(new Error('ollama offline'));
await d.m.forwardToAI(['hi']);
await tick();
ok('A11 ollama fallback failure is printed', d.printed.some(p => /AI error: ollama offline/.test(p.msg)),
JSON.stringify(d.printed));
ok('A12 ollama fallback leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length));
// A13: usage is still printed when the question is missing
const e = mk(null);
e.m.forwardToAI([]);
ok('A13 usage printed when args are empty', e.printed.some(p => /Usage: webxai/.test(p.msg)),
JSON.stringify(e.printed));
console.log('test-aiasm-rejection: ' + passes + ' passed, ' + fails + ' failed');
process.exit(fails ? 1 : 0);
})();
|