// modules/test/test-tool-context-bound.js — run: node // MAINT-023: one agentic turn must not grow without limit. Measures the same growth the // reviewer measured (6 -> 112 705 tokens across 8 model calls) against the new bounds. 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 AI = new Function(fsMod.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')(); function mk() { const ai = Object.create(AI.prototype); ai.terminal = { print: () => {}, log: () => {}, fs: null }; return ai; } const approxTokens = s => Math.ceil(s / 4); let fails = 0, passes = 0; function ok(name, cond, detail) { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); } // A. a single oversized observation is clipped with a note { const ai = mk(); const big = 'x'.repeat(20000); const clipped = ai._clipToolResult(big); ok('A1 oversized result is clipped', clipped.length < 7000, 'len=' + clipped.length); ok('A2 the model is told it was clipped', /clipped:/.test(clipped) && /offset\/limit/.test(clipped), clipped.slice(-90)); ok('A3 short results pass through untouched', ai._clipToolResult('ok') === 'ok'); ok('A4 non-string results are stringified', ai._clipToolResult({ a: 1 }) === '{"a":1}'); } // B. the running total of tool bodies stays inside the budget { const ai = mk(); const messages = [{ role: 'user', content: 'go' }]; let worst = 0; for (let turn = 0; turn < 8; turn++) { messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'c' + turn }] }); // the reviewer's shape: each read_file page arrives at 16 000 chars messages.push({ role: 'tool', tool_call_id: 'c' + turn, content: ai._clipToolResult('y'.repeat(16000)) }); ai._boundToolContext(messages); const toolChars = messages.filter(m => m.role === 'tool').reduce((s, m) => s + String(m.content).length, 0); worst = Math.max(worst, toolChars); } // The ceiling is keepVerbatim clipped results plus tiny stubs — it must not grow with turns. ok('B1 total tool context stays within the ceiling', worst <= 27000, 'worst=' + worst); ok('B2 8 turns stay far below the old 112k-token blowup', approxTokens(worst) < 8000, 'worst≈' + approxTokens(worst) + ' tokens'); ok('B3 the newest 4 results are verbatim', messages.filter(m => m.role === 'tool').slice(-4).every(m => !/elided/.test(m.content))); ok('B4 older results carry an elision stub', messages.filter(m => m.role === 'tool').slice(0, 4).every(m => /elided/.test(m.content))); } // B2. the ceiling is turn-independent: 20 turns cost the same as 8 { const ai = mk(); const messages = [{ role: 'user', content: 'go' }]; for (let turn = 0; turn < 20; turn++) { messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'c' + turn }] }); messages.push({ role: 'tool', tool_call_id: 'c' + turn, content: ai._clipToolResult('y'.repeat(16000)) }); ai._boundToolContext(messages); } const toolChars = messages.filter(m => m.role === 'tool').reduce((s, m) => s + String(m.content).length, 0); ok('B5 20 turns cost the same ceiling as 8', toolChars <= 27000, 'toolChars=' + toolChars); } // C. no tool messages -> nothing is touched { const ai = mk(); const messages = [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]; const before = JSON.stringify(messages); ai._boundToolContext(messages); ok('C1 non-tool messages are untouched', JSON.stringify(messages) === before); } // D. a small turn is never rewritten { const ai = mk(); const messages = [{ role: 'user', content: 'go' }, { role: 'tool', tool_call_id: 'c1', content: 'small result' }]; ai._boundToolContext(messages); ok('D1 small tool results stay verbatim', messages[1].content === 'small result', messages[1].content); } // E. MAINT-023b: the clip must preserve envelope keys. A read_file page is a JSON string; // clipping it blind deletes `truncated` and `next_offset`, so the model cannot page. { const ai = mk(); const page = JSON.stringify({ path: '/big.txt', offset: 0, returned: 16000, total_chars: 40000, truncated: true, next_offset: 16000, content: 'x'.repeat(16000), }); const clipped = ai._clipToolResult(page); let parsed = null; try { parsed = JSON.parse(clipped); } catch (e) {} ok('E1 clip keeps JSON parseable', !!parsed, clipped.slice(-80)); ok('E2 clip keeps next_offset', parsed && parsed.next_offset === 16000); ok('E3 clip keeps truncated flag', parsed && parsed.truncated === true); ok('E4 clip shortens content', parsed && parsed.content.length < 16000 && parsed.content.length >= 3000, parsed && String(parsed.content.length)); ok('E5 clipped envelope fits the limit', clipped.length <= 6000 + 400, String(clipped.length)); // E6-E8: the last page of a file has next_offset=null. If the clip removes its tail, // telling the caller "truncated: false" hides the remainder with no way to ask for it // (measured 2026-11-02: 8 109 of 19 999 chars withheld, returned claimed 19 999). const lastPage = JSON.stringify({ path: '/big.txt', offset: 0, returned: 19999, total_chars: 19999, truncated: false, next_offset: null, content: 'y'.repeat(19999), }); const lc = ai._clipToolResult(lastPage); let lp = null; try { lp = JSON.parse(lc); } catch (e) {} ok('E6 a clipped last page reports truncated=true', lp && lp.truncated === true, lp && String(lp.truncated)); ok('E7 it gives a resumable next_offset', lp && lp.next_offset === lp.returned_chars, lp && String(lp.next_offset) + ' vs ' + String(lp.returned_chars)); ok('E8 returned no longer claims the whole file', lp && lp.returned === lp.returned_chars, lp && String(lp.returned)); } // F. budgets are resolvable per model class, with precedence call arg > profile > ai-config > default { let PROF = null, profErr = ''; try { PROF = require(path.join(__dirname, '..', 'core', 'model-profiles.js')); } catch (e) { profErr = e.message; } ok('F0 model-profiles.js loads', !!PROF, profErr); const ai = mk(); ai.modelProfiles = PROF; ai.terminal.ollamaManager = { currentModel: 'gemma4:31b-cloud' }; ok('F1 cloud profile read page', ai._toolLimit('readPageChars') === 32000, String(ai._toolLimit('readPageChars'))); ai.terminal.ollamaManager.currentModel = 'qwen3:0.6b'; ok('F2 tiny profile read page', ai._toolLimit('readPageChars') === 4000, String(ai._toolLimit('readPageChars'))); ai.terminal.ollamaManager.currentModel = 'llama3.1:8b'; ok('F3 small profile result clip', ai._toolLimit('toolResultChars') === 4000, String(ai._toolLimit('toolResultChars'))); ai.maxToolResultChars = 1234; ok('F4 ai-config overrides the model default', ai._toolLimit('toolResultChars') === 1234, String(ai._toolLimit('toolResultChars'))); // an id with no size token is unclassifiable -> mid; "unknown:1b" is NOT unknown, it is a 1B model ok('F5 unclassifiable model falls back to mid', (function () { ai.terminal.ollamaManager.currentModel = 'mystery-model'; return ai._toolLimit('readPageChars') === 16000; })()); ok('F6 a 1b id is classified tiny, not mid', (function () { ai.terminal.ollamaManager.currentModel = 'unknown:1b'; return ai._toolLimit('readPageChars') === 4000; })()); } if (fails === 0) { console.log('test-tool-context-bound: ' + passes + ' passed, 0 failed'); } else { console.log('test-tool-context-bound: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); }