[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
643
infrastructure/ai/harness/capabilityTools.test.ts
Normal file
643
infrastructure/ai/harness/capabilityTools.test.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCattyToolsFromCatalog,
|
||||
resolveSessionQueueKeyForTests,
|
||||
withCattyToolContext,
|
||||
} from './capabilityTools';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { buildTerminalWriteFingerprint, ToolResultDedup } from './toolResultDedup';
|
||||
import { collectPreservedTerminalWriteFingerprints } from './turnDrivers/cattyMessageBuilder';
|
||||
|
||||
describe('capabilityTools session queue keys', () => {
|
||||
it('does not queue read-only harness tools behind terminal session writes', () => {
|
||||
const key = resolveSessionQueueKeyForTests(
|
||||
{
|
||||
capabilityId: 'harness.workspace.get_session_info',
|
||||
toolName: 'workspace_get_session_info',
|
||||
policy: { write: false, bypassesApproval: true },
|
||||
},
|
||||
{ sessionId: 'session-a' },
|
||||
'chat-1',
|
||||
);
|
||||
assert.equal(key, null);
|
||||
});
|
||||
|
||||
it('still serializes terminal.execute on the same session', () => {
|
||||
const key = resolveSessionQueueKeyForTests(
|
||||
{
|
||||
capabilityId: 'terminal.execute',
|
||||
toolName: 'terminal_execute',
|
||||
policy: { write: true, bypassesApproval: false },
|
||||
},
|
||||
{ sessionId: 'session-a', command: 'ls' },
|
||||
'chat-1',
|
||||
);
|
||||
assert.equal(key, 'chat-1:session-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityTools result fitting', () => {
|
||||
it('bounds failed terminal output and stores the original partial output behind a handle', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const partialOutput = `${'build output\n'.repeat(20_000)}FATAL_MID=E_CONN_RESET_7319`;
|
||||
const longError = `API_TOKEN=tok_live_1234567890 ${'diagnostic '.repeat(2_000)}`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiExec: async () => ({
|
||||
ok: false,
|
||||
error: longError,
|
||||
stdout: partialOutput,
|
||||
stderr: '',
|
||||
exitCode: -1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
protocol: 'ssh',
|
||||
connected: true,
|
||||
}],
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_execute,
|
||||
toolsContext.terminal_execute,
|
||||
'call-1',
|
||||
).execute({ sessionId: 'session-1', command: 'npm test' }) as {
|
||||
error: string;
|
||||
stdout?: string;
|
||||
};
|
||||
|
||||
assert.ok(result.error.length < 10_000);
|
||||
assert.doesNotMatch(result.error, /tok_live/);
|
||||
assert.match(result.error, /tool output handle/);
|
||||
assert.ok((result.stdout?.length ?? 0) < 30_000);
|
||||
assert.match(result.stdout ?? '', /output handle/);
|
||||
const handleId = result.stdout?.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
assert.match(
|
||||
store.read({ handleId, mode: 'tail', maxChars: 1_000 }, 'chat-1') ?? '',
|
||||
/FATAL_MID=E_CONN_RESET_7319/,
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates large vault note content and stores the full note body behind a handle', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'note line\n'.repeat(1000)}important ending`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
note: {
|
||||
id: 'note-1',
|
||||
title: 'Long note',
|
||||
content: body,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.vault_notes_get,
|
||||
toolsContext.vault_notes_get,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ noteId: 'note-1' },
|
||||
) as { note: { content: string } };
|
||||
|
||||
assert.notEqual(result.note.content, body);
|
||||
assert.match(result.note.content, /tool output handle/);
|
||||
const handleId = result.note.content.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
const recovered = store.readChunk({
|
||||
handleId,
|
||||
mode: 'search',
|
||||
query: 'important ending',
|
||||
}, 'chat-1');
|
||||
assert.match(recovered?.content ?? '', /important ending/);
|
||||
});
|
||||
|
||||
it('hard-caps explicit tool output reads and returns a continuation cursor', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'full note line\n'.repeat(1000)}important ending`;
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'vault.notes.get',
|
||||
content: body,
|
||||
});
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.tool_output_read,
|
||||
toolsContext.tool_output_read,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ handleId: handle.id, mode: 'full', maxChars: body.length + 100 },
|
||||
) as { content: string; nextOffset: number; hasMore: boolean; totalChars: number };
|
||||
|
||||
assert.ok(result.content.length <= 12_000);
|
||||
assert.equal(result.nextOffset, result.content.length);
|
||||
assert.equal(result.hasMore, true);
|
||||
assert.equal(result.totalChars, body.length);
|
||||
});
|
||||
|
||||
it('enforces a shared saved-output read budget across one turn', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const handle = store.store({
|
||||
chatSessionId: 'chat-1',
|
||||
capabilityId: 'terminal.execute',
|
||||
content: 'x'.repeat(50_000),
|
||||
});
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{}, { sessions: [] }, [], 'auto', undefined, 'chat-1', store, dedup,
|
||||
);
|
||||
const reader = withCattyToolContext(tools.tool_output_read, toolsContext.tool_output_read);
|
||||
|
||||
const first = await reader.execute({ handleId: handle.id, mode: 'range', offset: 0, maxChars: 12_000 });
|
||||
const second = await reader.execute({ handleId: handle.id, mode: 'range', offset: 12_000, maxChars: 12_000 });
|
||||
const third = await reader.execute({ handleId: handle.id, mode: 'range', offset: 24_000, maxChars: 12_000 }) as { error?: string };
|
||||
|
||||
assert.equal((first as { content: string }).content.length, 12_000);
|
||||
assert.equal((second as { content: string }).content.length, 12_000);
|
||||
assert.match(third.error ?? '', /read budget/);
|
||||
});
|
||||
|
||||
it('replays a completed terminal command instead of executing it again after retry compaction', async () => {
|
||||
let executions = 0;
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiExec: async () => {
|
||||
executions += 1;
|
||||
return { ok: true, stdout: 'deployed once', stderr: '', exitCode: 0 };
|
||||
},
|
||||
},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
},
|
||||
[], 'auto', undefined, 'chat-1', undefined, dedup,
|
||||
);
|
||||
const execute = withCattyToolContext(tools.terminal_execute, toolsContext.terminal_execute);
|
||||
await execute.execute({ sessionId: 'session-1', command: 'deploy production' });
|
||||
dedup.enableWriteReplay();
|
||||
const replay = await execute.execute({ sessionId: 'session-1', command: 'deploy production' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(executions, 1);
|
||||
assert.equal(replay.replayedCompletedResult, true);
|
||||
|
||||
const intentionalRepeat = await execute.execute({ sessionId: 'session-1', command: 'deploy production' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
};
|
||||
assert.equal(executions, 2);
|
||||
assert.equal(intentionalRepeat.replayedCompletedResult, undefined);
|
||||
});
|
||||
|
||||
it('executes an intentional repeat when the completed result is already in retry history', async () => {
|
||||
let executions = 0;
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiExec: async () => {
|
||||
executions += 1;
|
||||
return { ok: true, stdout: `run ${executions}`, stderr: '', exitCode: 0 };
|
||||
},
|
||||
},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
},
|
||||
[], 'auto', undefined, 'chat-1', undefined, dedup,
|
||||
);
|
||||
const execute = withCattyToolContext(tools.terminal_execute, toolsContext.terminal_execute);
|
||||
const args = { sessionId: 'session-1', command: 'npm test' };
|
||||
await execute.execute(args);
|
||||
const retryHistory = [
|
||||
{
|
||||
id: 'assistant-progress',
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: args }],
|
||||
},
|
||||
{
|
||||
id: 'tool-progress',
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'run 1' }],
|
||||
},
|
||||
];
|
||||
dedup.enableWriteReplay(collectPreservedTerminalWriteFingerprints(
|
||||
retryHistory,
|
||||
'assistant-progress',
|
||||
'chat-1',
|
||||
));
|
||||
|
||||
const repeat = await execute.execute(args) as { replayedCompletedResult?: boolean };
|
||||
assert.equal(executions, 2);
|
||||
assert.equal(repeat.replayedCompletedResult, undefined);
|
||||
});
|
||||
|
||||
it('pairs reused tool call IDs with the nearest preceding terminal command', () => {
|
||||
const commandA = { sessionId: 'session-1', command: 'npm test a' };
|
||||
const commandB = { sessionId: 'session-1', command: 'npm test b' };
|
||||
const retryHistory = [
|
||||
{
|
||||
id: 'assistant-a', role: 'assistant' as const, content: '', timestamp: 1,
|
||||
toolCalls: [{ id: 'reused-call', name: 'terminal_execute', arguments: commandA }],
|
||||
},
|
||||
{
|
||||
id: 'tool-a', role: 'tool' as const, content: '', timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'reused-call', content: 'result a' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-b', role: 'assistant' as const, content: '', timestamp: 3,
|
||||
toolCalls: [{ id: 'reused-call', name: 'terminal_execute', arguments: commandB }],
|
||||
},
|
||||
{
|
||||
id: 'tool-b', role: 'tool' as const, content: '', timestamp: 4,
|
||||
toolResults: [{ toolCallId: 'reused-call', content: 'result b' }],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
collectPreservedTerminalWriteFingerprints(retryHistory, 'assistant-a', 'chat-1'),
|
||||
[
|
||||
buildTerminalWriteFingerprint('terminal_execute', 'chat-1', commandA),
|
||||
buildTerminalWriteFingerprint('terminal_execute', 'chat-1', commandB),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('replays a started background job instead of starting it twice after retry compaction', async () => {
|
||||
let starts = 0;
|
||||
const dedup = new ToolResultDedup();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{ aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: `job-${++starts}`,
|
||||
status: 'running',
|
||||
command: 'deploy --password swordfish',
|
||||
output: 'x'.repeat(30_000),
|
||||
}) },
|
||||
{ sessions: [] }, [], 'auto', undefined, 'chat-start', undefined, dedup,
|
||||
);
|
||||
const start = withCattyToolContext(tools.terminal_start, toolsContext.terminal_start);
|
||||
await start.execute({ sessionId: 'session-1', command: 'npm run build' });
|
||||
dedup.enableWriteReplay();
|
||||
const replay = await start.execute({ sessionId: 'session-1', command: 'npm run build' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
jobId?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
};
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(replay.jobId, 'job-1');
|
||||
assert.equal(replay.replayedCompletedResult, true);
|
||||
assert.doesNotMatch(replay.command ?? '', /swordfish/);
|
||||
assert.match(replay.output ?? '', /tool output handle/);
|
||||
|
||||
const intentionalRestart = await start.execute({ sessionId: 'session-1', command: 'npm run build' }) as {
|
||||
replayedCompletedResult?: boolean;
|
||||
jobId?: string;
|
||||
};
|
||||
assert.equal(starts, 2);
|
||||
assert.equal(intentionalRestart.jobId, 'job-2');
|
||||
assert.equal(intentionalRestart.replayedCompletedResult, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityTools terminal context reader', () => {
|
||||
it('reads terminal context from the only scoped terminal when sessionId is omitted', async () => {
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
readTerminalContext: async (request) => ({
|
||||
ok: true,
|
||||
sessionId: request.sessionId,
|
||||
label: 'prod',
|
||||
range: request.range ?? 'viewport',
|
||||
content: 'line-a\nline-b',
|
||||
totalLines: 2,
|
||||
startLine: 0,
|
||||
endLine: 1,
|
||||
returnedLines: 2,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
}),
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ range: 'tail', maxLines: 20 },
|
||||
) as { sessionId: string; content: string; range: string };
|
||||
|
||||
assert.equal(result.sessionId, 'session-1');
|
||||
assert.equal(result.range, 'tail');
|
||||
assert.equal(result.content, 'line-a\nline-b');
|
||||
});
|
||||
|
||||
it('fits large terminal context reads through the shared tool output store', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const body = `${'terminal line output '.repeat(900)}important ending`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
readTerminalContext: async (request) => ({
|
||||
ok: true,
|
||||
sessionId: request.sessionId,
|
||||
label: 'prod',
|
||||
range: request.range ?? 'viewport',
|
||||
content: body,
|
||||
totalLines: 1,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
returnedLines: 1,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
}),
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
store,
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ range: 'viewport' },
|
||||
) as { content: string };
|
||||
|
||||
assert.notEqual(result.content, body);
|
||||
assert.match(result.content, /tool output handle/);
|
||||
const handleId = result.content.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
const recovered = store.readChunk({
|
||||
handleId,
|
||||
mode: 'search',
|
||||
query: 'important ending',
|
||||
}, 'chat-1');
|
||||
assert.match(recovered?.content ?? '', /important ending/);
|
||||
});
|
||||
|
||||
it('asks for sessionId when multiple scoped terminals are available', async () => {
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [
|
||||
{ sessionId: 'session-1', hostId: 'host-1', hostname: 'a', label: 'a', connected: true },
|
||||
{ sessionId: 'session-2', hostId: 'host-2', hostname: 'b', label: 'b', connected: true },
|
||||
],
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
);
|
||||
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
'call-1',
|
||||
).execute(
|
||||
{ range: 'viewport' },
|
||||
) as { error?: string };
|
||||
|
||||
assert.match(result.error ?? '', /sessionId/);
|
||||
});
|
||||
|
||||
it('returns a small cached notice for an unchanged terminal context range', async () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{},
|
||||
{
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
hostId: 'host-1',
|
||||
hostname: 'prod.internal',
|
||||
label: 'prod',
|
||||
connected: true,
|
||||
}],
|
||||
readTerminalContext: async () => ({
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
range: 'tail',
|
||||
content: 'same terminal screen',
|
||||
totalLines: 1,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
returnedLines: 1,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
}),
|
||||
},
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
undefined,
|
||||
dedup,
|
||||
);
|
||||
|
||||
const reader = withCattyToolContext(
|
||||
tools.terminal_read_context,
|
||||
toolsContext.terminal_read_context,
|
||||
);
|
||||
const first = await reader.execute({ sessionId: 'session-1', range: 'tail' });
|
||||
const second = await reader.execute({ sessionId: 'session-1', range: 'tail' });
|
||||
|
||||
assert.equal(typeof first, 'object');
|
||||
assert.match(String(second), /^\[cached\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityTools terminal polling', () => {
|
||||
it('tags polled output handles with the owning terminal for close cleanup', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const dedup = new ToolResultDedup();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async (method: string) => method.includes('jobStart')
|
||||
? { ok: true, jobId: 'job-owned', status: 'running', nextOffset: 0 }
|
||||
: { ok: true, jobId: 'job-owned', status: 'running', output: 'x'.repeat(30_000), nextOffset: 30_000 },
|
||||
},
|
||||
{ sessions: [] }, [], 'auto', undefined, 'chat-owned', store, dedup,
|
||||
);
|
||||
await withCattyToolContext(tools.terminal_start, toolsContext.terminal_start)
|
||||
.execute({ sessionId: 'session-owned', command: 'npm run dev' });
|
||||
const result = await withCattyToolContext(tools.terminal_poll, toolsContext.terminal_poll)
|
||||
.execute({ jobId: 'job-owned', offset: 0 }) as { output: string };
|
||||
const handleId = result.output.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
store.pruneTerminalSession('chat-owned', 'session-owned');
|
||||
assert.equal(store.get(handleId, 'chat-owned'), undefined);
|
||||
});
|
||||
|
||||
it('deduplicates an unchanged job output range', async () => {
|
||||
const dedup = new ToolResultDedup();
|
||||
dedup.beginTurn();
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: 'job-1',
|
||||
sessionId: 'session-1',
|
||||
status: 'running',
|
||||
output: 'same build output',
|
||||
outputBaseOffset: 0,
|
||||
nextOffset: 17,
|
||||
totalOutputChars: 17,
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-1',
|
||||
undefined,
|
||||
dedup,
|
||||
);
|
||||
|
||||
const poll = withCattyToolContext(tools.terminal_poll, toolsContext.terminal_poll);
|
||||
const first = await poll.execute({ jobId: 'job-1', offset: 0 });
|
||||
const second = await poll.execute({ jobId: 'job-1', offset: 0 });
|
||||
|
||||
assert.equal(typeof first, 'object');
|
||||
assert.match(String(second), /^\[cached\]/);
|
||||
});
|
||||
|
||||
it('bounds follow-style monitor output before it reaches the model', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
const rawOutput = `${'x '.repeat(400)}\n${'log line\n'.repeat(500)}MONITOR_MIDDLE_EVIDENCE_7319\n${'tail line\n'.repeat(500)}`;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: 'monitor-job-unique',
|
||||
command: 'tail -f /var/log/app.log',
|
||||
status: 'running',
|
||||
output: rawOutput,
|
||||
nextOffset: 9_000,
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-monitor',
|
||||
store,
|
||||
);
|
||||
const result = await withCattyToolContext(
|
||||
tools.terminal_poll,
|
||||
toolsContext.terminal_poll,
|
||||
).execute({ jobId: 'monitor-job-unique', offset: 0 }) as { output: string };
|
||||
|
||||
assert.ok(result.output.length < 3_500);
|
||||
assert.ok(result.output.split('\n')[0].length <= 500);
|
||||
const handleId = result.output.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
|
||||
assert.ok(handleId);
|
||||
assert.match(
|
||||
store.read({ handleId, mode: 'search', query: 'MONITOR_MIDDLE_EVIDENCE_7319' }, 'chat-monitor') ?? '',
|
||||
/MONITOR_MIDDLE_EVIDENCE_7319/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not count empty monitor polls as output bursts', async () => {
|
||||
let polls = 0;
|
||||
const { tools, toolsContext } = createCattyToolsFromCatalog(
|
||||
{
|
||||
aiCapability: async () => ({
|
||||
ok: true,
|
||||
jobId: 'quiet-monitor-job',
|
||||
command: 'tail -f /var/log/app.log',
|
||||
status: 'running',
|
||||
output: polls++ < 12 ? '' : 'first new line',
|
||||
nextOffset: 0,
|
||||
}),
|
||||
},
|
||||
{ sessions: [] },
|
||||
[],
|
||||
'auto',
|
||||
undefined,
|
||||
'chat-quiet-monitor',
|
||||
);
|
||||
const poll = withCattyToolContext(tools.terminal_poll, toolsContext.terminal_poll);
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await poll.execute({ jobId: 'quiet-monitor-job', offset: 0 });
|
||||
}
|
||||
const result = await poll.execute({ jobId: 'quiet-monitor-job', offset: 0 }) as { output: string };
|
||||
|
||||
assert.equal(result.output, 'first new line');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user