aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-tool-context-bound.js
blob: 0b42eebee2832b803dbe1c16f0f0c30334e939dc (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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// 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));
}

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