// modules/test/test-agent-loop-ceiling.js — run: node // MAINT-006: the hard stop used to be reachable only when EVERY call in a batch was blocked, so a // turn that mixed one repeated call with one novel call ran to maxTurns. This drives the real // agenticLoop with a deterministic model stub and asserts the mixed batch ends at the ceiling. const fsMod = require('fs'), path = require('path'); const ROOT = path.join(__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; const load = (file, cls) => new Function(fsMod.readFileSync(path.join(ROOT, file), 'utf8') + '\nreturn ' + cls + ';')(); const AI = load('module-aiassist.js', 'WebX11AIAssistantModule'); const VFS = load('virtual-fs.js', 'VirtualFileSystem'); let fails = 0, passes = 0; const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); }; async function runLoop(shape, maxTurns) { const out = []; const vfs = new VFS(); vfs.saveToStorage = () => true; vfs.writeFile('/probe.txt', 'alpha\nbeta\ngamma'); const ai = new AI(); // real constructor: the loop reads _commandFreq/_toolCallLog let turn = 0; ai.terminal = { fs: vfs, print: (m) => out.push(String(m)), log: () => {}, moduleManager: { modules: {}, getAllCommands: () => ({ read_file: 'AI', list_files: 'AI' }) }, ollamaManager: { chatWithTools: async () => { turn++; const calls = shape(turn); return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls }; }, }, }; ai._debugPanelEnabled = false; let res = null, err = null; try { res = await ai.agenticLoop([{ role: 'user', content: 'loop ceiling probe' }], { maxTurns, agent: 'probe' }); } catch (e) { err = e.message; } return { out, res, err, turn }; } // A. mixed batch: one identical call every turn + one novel call every turn (async () => { const { out, res, err, turn } = await runLoop((t) => ([ { id: 'rep-' + t, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/probe.txt' }) } }, { id: 'nov-' + t, type: 'function', function: { name: 'list_files', arguments: JSON.stringify({ path: '/novel-' + t }) } }, ]), 20); const joined = out.join('\n'); ok('A1 no exception', err === null, String(err)); ok('A2 the ceiling fires', /no progress after \d+ identical calls/.test(joined), joined.slice(-200)); ok('A3 it stops well before maxTurns', turn < 20, 'ran ' + turn + ' turns'); ok('A4 it returns a partial answer', !!res && typeof res.finalAnswer === 'string', JSON.stringify(res && Object.keys(res))); })(); // B. all-identical batch still stops (the original path must not regress) setTimeout(async () => { const { out, err, turn } = await runLoop((t) => ([ { id: 'r' + t, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/probe.txt' }) } }, ]), 20); const joined = out.join('\n'); ok('B1 all-blocked batch has no exception', err === null, String(err)); ok('B2 all-blocked batch stops early', turn < 20, 'ran ' + turn + ' turns'); ok('B3 all-blocked batch reports the ceiling', /no progress after \d+ identical calls/.test(joined), joined.slice(-200)); // C. a genuinely productive loop is NOT stopped early: every call differs const third = await runLoop((t) => ([ { id: 'u' + t, type: 'function', function: { name: 'list_files', arguments: JSON.stringify({ path: '/dir-' + t }) } }, ]), 3); ok('C1 a productive loop runs to maxTurns', third.turn >= 3, 'ran ' + third.turn + ' turns'); ok('C2 no ceiling message on a productive loop', !/no progress after/.test(third.out.join('\n')), third.out.join('|').slice(-160)); if (fails === 0) console.log('test-agent-loop-ceiling: ' + passes + ' passed, 0 failed'); else { console.log('test-agent-loop-ceiling: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); } }, 50);