467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
|
|
import assert from 'node:assert/strict';
|
||
|
|
import test from 'node:test';
|
||
|
|
import type { ModelMessage } from 'ai';
|
||
|
|
import { pruneStaleToolContext } from './staleContextPruner.ts';
|
||
|
|
|
||
|
|
test('pruneStaleToolContext supersedes older sftp reads for same path', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read',
|
||
|
|
output: { type: 'text', value: 'old config body' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read',
|
||
|
|
output: { type: 'text', value: 'new config body' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, true);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /superseded read/);
|
||
|
|
assert.match(serialized, /new config body/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext supersedes older sftp_read_file reads for same path', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'old config body' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'new config body' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, true);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /superseded read/);
|
||
|
|
assert.match(serialized, /new config body/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext keeps sftp reads for same path on different sessions', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'host-a config' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-b', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'host-b config' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, false);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /host-a config/);
|
||
|
|
assert.match(serialized, /host-b config/);
|
||
|
|
assert.doesNotMatch(serialized, /superseded read/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext supersedes repeated terminal polls and context reads', () => {
|
||
|
|
const pair = (callId: string, toolName: string, input: Record<string, unknown>, output: string): ModelMessage[] => [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{ type: 'tool-call', toolCallId: callId, toolName, input }],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: callId,
|
||
|
|
toolName,
|
||
|
|
output: { type: 'text', value: output },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
...pair('p1', 'terminal_poll', { jobId: 'job-1', offset: 0 }, 'old poll'),
|
||
|
|
...pair('p2', 'terminal_poll', { jobId: 'job-1', offset: 0 }, 'new poll'),
|
||
|
|
...pair('r1', 'terminal_read_context', { sessionId: 's1', range: 'tail', maxLines: 20 }, 'old screen'),
|
||
|
|
...pair('r2', 'terminal_read_context', { sessionId: 's1', range: 'tail', maxLines: 20 }, 'new screen'),
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.equal(result.didAdjust, true);
|
||
|
|
assert.doesNotMatch(serialized, /old poll|old screen/);
|
||
|
|
assert.match(serialized, /new poll/);
|
||
|
|
assert.match(serialized, /new screen/);
|
||
|
|
});
|
||
|
|
|
||
|
|
function terminalExecutePair(
|
||
|
|
callId: string,
|
||
|
|
sessionId: string,
|
||
|
|
command: string,
|
||
|
|
output: string,
|
||
|
|
): ModelMessage[] {
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: callId,
|
||
|
|
toolName: 'terminal_execute',
|
||
|
|
input: { sessionId, command },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: callId,
|
||
|
|
toolName: 'terminal_execute',
|
||
|
|
output: { type: 'text', value: output },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
test('pruneStaleToolContext keeps last two terminal outputs per session', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
...terminalExecutePair('t1', 'sess-1', 'uptime', 'uptime-1'),
|
||
|
|
...terminalExecutePair('t2', 'sess-1', 'df -h', 'df-2'),
|
||
|
|
...terminalExecutePair('t3', 'sess-1', 'free -m', 'free-3'),
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, true);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /df-2/);
|
||
|
|
assert.match(serialized, /free-3/);
|
||
|
|
assert.doesNotMatch(serialized, /uptime-1/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext omits terminal outputs per session independently', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
...terminalExecutePair('a1', 'sess-a', 'uptime', 'a-uptime'),
|
||
|
|
...terminalExecutePair('a2', 'sess-a', 'df -h', 'a-df'),
|
||
|
|
...terminalExecutePair('a3', 'sess-a', 'free -m', 'a-free'),
|
||
|
|
...terminalExecutePair('b1', 'sess-b', 'uptime', 'b-uptime'),
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, true);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /a-df/);
|
||
|
|
assert.match(serialized, /a-free/);
|
||
|
|
assert.match(serialized, /b-uptime/);
|
||
|
|
assert.doesNotMatch(serialized, /a-uptime/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext preserves repeated sftp reads without budget pressure', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'before edit' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'after edit' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages);
|
||
|
|
assert.equal(result.didAdjust, false);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /before edit/);
|
||
|
|
assert.match(serialized, /after edit/);
|
||
|
|
assert.doesNotMatch(serialized, /superseded read/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext keeps last successful read when a later read fails', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'valid config body' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'Permission denied error' },
|
||
|
|
isError: true,
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, false);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /valid config body/);
|
||
|
|
assert.doesNotMatch(serialized, /superseded read/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext keeps last successful read when a later JSON read fails', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c1',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { type: 'text', value: 'valid config body' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
input: { sessionId: 'host-a', path: '/etc/nginx/nginx.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'c2',
|
||
|
|
toolName: 'sftp_read_file',
|
||
|
|
output: { error: 'Permission denied' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
assert.equal(result.didAdjust, false);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /valid config body/);
|
||
|
|
assert.doesNotMatch(serialized, /superseded read/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext preserves terminal output without budget pressure flag', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
...terminalExecutePair('t1', 'sess-1', 'uptime', 'uptime-1'),
|
||
|
|
...terminalExecutePair('t2', 'sess-1', 'df -h', 'df-2'),
|
||
|
|
...terminalExecutePair('t3', 'sess-1', 'free -m', 'free-3'),
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages);
|
||
|
|
assert.equal(result.didAdjust, false);
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /uptime-1/);
|
||
|
|
assert.match(serialized, /df-2/);
|
||
|
|
assert.match(serialized, /free-3/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext tiers generic old tool results while protecting recent turns', () => {
|
||
|
|
const messages: ModelMessage[] = [];
|
||
|
|
for (let turn = 0; turn < 12; turn += 1) {
|
||
|
|
const callId = `call-${turn}`;
|
||
|
|
messages.push(
|
||
|
|
{ role: 'user', content: `turn ${turn}` },
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{ type: 'tool-call', toolCallId: callId, toolName: 'custom_read', input: { turn } }],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: callId,
|
||
|
|
toolName: 'custom_read',
|
||
|
|
output: { type: 'text', value: `result-${turn}-${'x'.repeat(5_000)}-tail-${turn}` },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /older tool result omitted/);
|
||
|
|
assert.match(serialized, /tool result shortened/);
|
||
|
|
assert.match(serialized, /result-11-/);
|
||
|
|
assert.match(serialized, /tail-11/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext retains old successful write outcomes', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-call',
|
||
|
|
toolCallId: 'write-1',
|
||
|
|
toolName: 'sftp_write',
|
||
|
|
input: { sessionId: 'sess-1', path: '/etc/app.conf', content: 'enabled=true' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'tool',
|
||
|
|
content: [{
|
||
|
|
type: 'tool-result',
|
||
|
|
toolCallId: 'write-1',
|
||
|
|
toolName: 'sftp_write',
|
||
|
|
output: { type: 'text', value: 'write succeeded: /etc/app.conf' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
...Array.from({ length: 11 }, (_, index) => ({ role: 'user' as const, content: `later ${index}` })),
|
||
|
|
];
|
||
|
|
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.match(serialized, /write succeeded: \/etc\/app\.conf/);
|
||
|
|
assert.doesNotMatch(serialized, /older tool result omitted/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleToolContext redacts commands in old terminal placeholders', () => {
|
||
|
|
const messages: ModelMessage[] = [
|
||
|
|
...terminalExecutePair('t1', 'sess-1', 'curl --password swordfish', 'old-output'),
|
||
|
|
...terminalExecutePair('t2', 'sess-1', 'uptime', 'newer-output'),
|
||
|
|
...terminalExecutePair('t3', 'sess-1', 'df -h', 'latest-output'),
|
||
|
|
];
|
||
|
|
const result = pruneStaleToolContext(messages, { underBudgetPressure: true });
|
||
|
|
const serialized = JSON.stringify(result.messages);
|
||
|
|
assert.doesNotMatch(serialized, /swordfish/);
|
||
|
|
assert.match(serialized, /REDACTED/);
|
||
|
|
});
|