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