[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
isStepHandleNoticeMessage,
mapCattyStreamChunkToAgentEvents,
mapSdkStreamEventToAgentEvents,
} from './agentEventAdapter';
describe('agentEventAdapter', () => {
it('maps tool-output-denied chunks to approval_resolved denied and tool_result', () => {
const events = mapCattyStreamChunkToAgentEvents(
{
type: 'tool-output-denied',
toolCallId: 'call-1',
toolName: 'sftp_write_file',
},
{ sessionId: 'chat-1', turnId: 'turn-1' },
);
assert.equal(events.length, 2);
assert.equal(events[0]?.type, 'approval_resolved');
assert.equal((events[0] as { outcome?: string }).outcome, 'denied');
assert.equal(events[1]?.type, 'tool_result');
assert.equal((events[1] as { isError?: boolean }).isError, true);
});
it('maps tool-error chunks to tool_result with isError', () => {
const events = mapCattyStreamChunkToAgentEvents(
{
type: 'tool-error',
toolCallId: 'call-2',
toolName: 'terminal_execute',
error: new Error('timeout'),
},
{ sessionId: 'chat-1', turnId: 'turn-1' },
);
assert.equal(events.length, 1);
assert.equal(events[0]?.type, 'tool_result');
assert.equal((events[0] as { isError?: boolean }).isError, true);
assert.match(String((events[0] as { result?: string }).result), /timeout/);
});
it('maps denied tool-approval-response with nested toolCall to tool_result', () => {
const events = mapCattyStreamChunkToAgentEvents(
{
type: 'tool-approval-response',
approvalId: 'approval-1',
approved: false,
reason: 'Observer mode blocks write operations.',
toolCall: {
toolCallId: 'call-3',
toolName: 'sftp_write_file',
input: { path: '/tmp/x' },
},
},
{ sessionId: 'chat-1', turnId: 'turn-1' },
);
assert.equal(events.length, 2);
assert.equal(events[0]?.type, 'approval_resolved');
assert.equal((events[0] as { outcome?: string }).outcome, 'denied');
assert.equal(events[1]?.type, 'tool_result');
assert.match(String((events[1] as { result?: string }).result), /Observer mode/);
});
it('detects step handle notice messages for prepareStep dedup', () => {
assert.equal(
isStepHandleNoticeMessage('[step 2] Tool output handles available: tool-output-abc'),
true,
);
assert.equal(isStepHandleNoticeMessage('regular user message'), false);
});
it('maps SDK activity events into the unified trace protocol', () => {
const context = { sessionId: 'chat-1', turnId: 'turn-1' };
const fileChange = mapSdkStreamEventToAgentEvents({
type: 'file-change',
itemId: 'patch-1',
status: 'completed',
changes: [{ path: 'src/app.ts', kind: 'update' }],
}, context);
const webSearch = mapSdkStreamEventToAgentEvents({
type: 'web-search', itemId: 'search-1', query: 'Codex events', status: 'running',
}, context);
const plan = mapSdkStreamEventToAgentEvents({
type: 'plan-update', itemId: 'plan-1', status: 'completed',
items: [{ text: 'Map events', completed: true }],
}, context);
const warning = mapSdkStreamEventToAgentEvents({
type: 'warning', itemId: 'warning-1', message: 'recoverable',
}, context);
assert.equal(fileChange[0]?.type, 'file_change');
assert.equal(webSearch[0]?.type, 'web_search');
assert.equal(plan[0]?.type, 'plan_update');
assert.equal(warning[0]?.type, 'error');
assert.equal((warning[0] as { recoverable?: boolean }).recoverable, true);
});
it('maps actual SDK usage including cached and reasoning tokens', () => {
const events = mapSdkStreamEventToAgentEvents({
type: 'usage',
inputTokens: 100,
cachedInputTokens: 40,
outputTokens: 25,
reasoningTokens: 10,
totalTokens: 125,
}, { sessionId: 'chat-1', turnId: 'turn-1' });
assert.deepEqual(events[0] && {
type: events[0].type,
promptTokens: 'promptTokens' in events[0] ? events[0].promptTokens : undefined,
cachedPromptTokens: 'cachedPromptTokens' in events[0] ? events[0].cachedPromptTokens : undefined,
completionTokens: 'completionTokens' in events[0] ? events[0].completionTokens : undefined,
reasoningTokens: 'reasoningTokens' in events[0] ? events[0].reasoningTokens : undefined,
totalTokens: 'totalTokens' in events[0] ? events[0].totalTokens : undefined,
estimated: 'estimated' in events[0] ? events[0].estimated : undefined,
}, {
type: 'usage',
promptTokens: 100,
cachedPromptTokens: 40,
completionTokens: 25,
reasoningTokens: 10,
totalTokens: 125,
estimated: false,
});
});
});

View File

@@ -0,0 +1,317 @@
import type { AgentEvent, AgentEventListener } from './types';
import { resolveStreamChunkToolCallId } from '../aiChatStreamingSupport';
let eventCounter = 0;
function nextEventId(prefix: string): string {
eventCounter += 1;
return `${prefix}-${Date.now()}-${eventCounter}`;
}
export interface StreamEventContext {
sessionId: string;
chatSessionId?: string;
turnId?: string;
}
export interface CattyStreamChunk {
type: string;
text?: string;
textDelta?: string;
delta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
args?: unknown;
output?: unknown;
result?: unknown;
error?: unknown;
approved?: boolean;
stepNumber?: number;
}
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
function isStepHandleNoticeMessage(content: unknown): boolean {
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
}
function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output) as Record<string, unknown>;
if ('error' in parsed && typeof parsed.error === 'string') return true;
if ('ok' in parsed && parsed.ok === false) return true;
} catch {
return false;
}
}
return false;
}
export function mapSdkStreamEventToAgentEvents(
event: Record<string, unknown>,
ctx: StreamEventContext,
): AgentEvent[] {
const base = {
sessionId: ctx.sessionId,
chatSessionId: ctx.chatSessionId,
backend: 'external-sdk' as const,
timestamp: Date.now(),
turnId: ctx.turnId,
};
switch (event.type) {
case 'text-delta':
return [{
...base,
id: nextEventId('model-delta'),
type: 'model_delta',
text: String(event.text ?? event.textDelta ?? event.delta ?? ''),
}];
case 'thinking-delta':
case 'reasoning-delta':
return [{
...base,
id: nextEventId('reasoning-delta'),
type: 'reasoning_delta',
text: String(event.text ?? event.textDelta ?? event.delta ?? ''),
}];
case 'tool-call':
return [{
...base,
id: nextEventId('tool-call'),
type: 'tool_call',
toolCallId: String(event.toolCallId ?? event.id ?? ''),
toolName: String(event.toolName ?? event.name ?? 'unknown'),
args: (event.args ?? event.input ?? {}) as Record<string, unknown>,
}];
case 'tool-result':
{
const output = event.result ?? event.output ?? '';
return [{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: String(event.toolCallId ?? ''),
toolName: typeof event.toolName === 'string' ? event.toolName : undefined,
result: typeof output === 'string' ? output : JSON.stringify(output),
isError: Boolean(event.isError) || isToolResultError(output),
}];
}
case 'file-change':
return [{
...base,
id: nextEventId('file-change'),
type: 'file_change',
itemId: String(event.itemId ?? ''),
status: event.status === 'failed' ? 'failed' : 'completed',
changes: Array.isArray(event.changes)
? event.changes as Array<{ path: string; kind: 'add' | 'delete' | 'update' }>
: [],
}];
case 'web-search':
return [{
...base,
id: nextEventId('web-search'),
type: 'web_search',
itemId: String(event.itemId ?? ''),
query: String(event.query ?? ''),
status: event.status === 'completed' ? 'completed' : 'running',
}];
case 'plan-update':
return [{
...base,
id: nextEventId('plan-update'),
type: 'plan_update',
itemId: String(event.itemId ?? ''),
status: event.status === 'completed' ? 'completed' : 'running',
items: Array.isArray(event.items)
? event.items as Array<{ text: string; completed: boolean }>
: [],
}];
case 'warning':
return [{
...base,
id: nextEventId('warning'),
type: 'error',
message: String(event.message ?? 'Unknown SDK warning'),
recoverable: true,
}];
case 'usage': {
const promptTokens = Number(event.inputTokens) || 0;
const completionTokens = Number(event.outputTokens) || 0;
return [{
...base,
id: nextEventId('usage'),
type: 'usage',
promptTokens,
cachedPromptTokens: Number(event.cachedInputTokens) || 0,
completionTokens,
reasoningTokens: Number(event.reasoningTokens) || 0,
totalTokens: Number(event.totalTokens) || promptTokens + completionTokens,
estimated: false,
}];
}
case 'error':
return [{
...base,
id: nextEventId('error'),
type: 'error',
message: String(event.error ?? event.message ?? 'Unknown SDK error'),
recoverable: false,
}];
default:
return [];
}
}
export function mapCattyStreamChunkToAgentEvents(
chunk: CattyStreamChunk,
ctx: StreamEventContext,
): AgentEvent[] {
const base = {
sessionId: ctx.sessionId,
chatSessionId: ctx.chatSessionId,
backend: 'catty' as const,
timestamp: Date.now(),
turnId: ctx.turnId,
};
if (chunk.type === 'text' || chunk.type === 'text-delta') {
const text = chunk.text ?? chunk.textDelta ?? '';
if (!text) return [];
return [{ ...base, id: nextEventId('model-delta'), type: 'model_delta', text }];
}
if (chunk.type === 'reasoning' || chunk.type === 'reasoning-start' || chunk.type === 'reasoning-delta') {
const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? '';
if (!text) return [];
return [{ ...base, id: nextEventId('reasoning-delta'), type: 'reasoning_delta', text }];
}
if (chunk.type === 'tool-call' && chunk.toolCallId && chunk.toolName) {
return [{
...base,
id: nextEventId('tool-call'),
type: 'tool_call',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: (chunk.input ?? chunk.args ?? {}) as Record<string, unknown>,
}];
}
if (chunk.type === 'tool-result' && chunk.toolCallId) {
const output = chunk.output ?? chunk.result;
const resultText = typeof output === 'string' ? output : JSON.stringify(output ?? '');
return [{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: chunk.toolCallId,
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
result: resultText,
isError: isToolResultError(output),
}];
}
if (chunk.type === 'tool-error' && chunk.toolCallId) {
const resultText = chunk.error instanceof Error
? JSON.stringify({ error: chunk.error.message })
: typeof chunk.error === 'string'
? JSON.stringify({ error: chunk.error })
: JSON.stringify({ error: String(chunk.error ?? 'Tool execution failed.') });
return [{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: chunk.toolCallId,
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
result: resultText,
isError: true,
}];
}
if (chunk.type === 'error') {
const message = chunk.error instanceof Error
? chunk.error.message
: String(chunk.error ?? 'Unknown stream error');
return [{ ...base, id: nextEventId('error'), type: 'error', message }];
}
if (chunk.type === 'tool-approval-request') {
const toolCallId = resolveStreamChunkToolCallId(chunk);
const toolName = chunk.toolName ?? chunk.toolCall?.toolName;
if (!toolCallId || !toolName) return [];
return [{
...base,
id: nextEventId('approval-requested'),
type: 'approval_requested',
toolCallId,
toolName,
args: (chunk.input ?? chunk.args ?? chunk.toolCall?.input ?? {}) as Record<string, unknown>,
}];
}
if (chunk.type === 'tool-approval-response') {
const toolCallId = resolveStreamChunkToolCallId(chunk);
if (!toolCallId) return [];
const approved = chunk.approved === true;
const events: AgentEvent[] = [{
...base,
id: nextEventId('approval-resolved'),
type: 'approval_resolved',
toolCallId,
toolName: String(chunk.toolName ?? chunk.toolCall?.toolName ?? 'unknown'),
outcome: approved ? 'approved' : 'denied',
}];
if (!approved) {
events.push({
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId,
result: JSON.stringify({ error: chunk.reason ?? 'Tool execution denied.' }),
isError: true,
});
}
return events;
}
if (chunk.type === 'tool-output-denied' && chunk.toolCallId) {
return [
{
...base,
id: nextEventId('approval-resolved'),
type: 'approval_resolved',
toolCallId: chunk.toolCallId,
toolName: String(chunk.toolName ?? 'unknown'),
outcome: 'denied',
},
{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: chunk.toolCallId,
result: JSON.stringify({ error: 'Tool execution denied.' }),
isError: true,
},
];
}
return [];
}
export { isStepHandleNoticeMessage };
export function createHarnessEventSink(
listener: AgentEventListener,
): AgentEventListener {
return (event) => listener(event);
}

View File

@@ -0,0 +1,375 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { AgentRuntime } from './agentRuntime';
import { TraceStore } from './traceStore';
import { SessionStateStore } from './sessionState';
import type { TurnDriver, TurnDriverContext, TurnInput } from './turnDrivers/types';
class MockTurnDriver implements TurnDriver {
readonly backend = 'catty' as const;
readonly runs: TurnInput[] = [];
async run(input: TurnInput, ctx: TurnDriverContext): Promise<void> {
this.runs.push(input);
ctx.emit({
id: 'model-delta-1',
type: 'model_delta',
text: 'hello',
} as import('./types').AgentEvent);
if (input.signal.aborted) return;
}
abort(): void {}
}
test('AgentRuntime clearChatSession releases its trace history', () => {
const traceStore = new TraceStore();
const runtime = new AgentRuntime({ drivers: [new MockTurnDriver()], traceStore });
traceStore.append({
id: 'trace-clear-1',
type: 'turn_start',
sessionId: 'chat-clear',
turnId: 'turn-clear',
startedAt: Date.now(),
} as import('./types').AgentEvent);
runtime.clearChatSession('chat-clear');
assert.equal(traceStore.getEvents('chat-clear').length, 0);
assert.equal(traceStore.getCompactions('chat-clear').length, 0);
});
test('AgentRuntime runTurn emits turn lifecycle and records trace', async () => {
const traceStore = new TraceStore();
const driver = new MockTurnDriver();
const runtime = new AgentRuntime({ drivers: [driver], traceStore });
const events: string[] = [];
runtime.subscribe(event => events.push(event.type));
const controller = new AbortController();
const result = await runtime.runTurn({
backend: 'catty',
chatSessionId: 'chat-1',
sendScopeKey: 'chat-1',
userText: 'hi',
signal: controller.signal,
currentSession: undefined,
assistantMsgId: 'assistant-1',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
maxIterations: 5,
ui: {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
},
});
assert.equal(result.reason, 'completed');
assert.equal(driver.runs.length, 1);
assert.deepEqual(events, ['turn_start', 'model_delta', 'turn_end']);
assert.equal(traceStore.getEvents('chat-1').length, 3);
});
test('AgentRuntime records session state from tool call and result events', async () => {
class ToolTurnDriver implements TurnDriver {
readonly backend = 'catty' as const;
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
ctx.emit({
id: 'secret-call',
type: 'tool_call',
toolCallId: 'call-secret',
toolName: 'terminal_execute',
args: { sessionId: 'sess-1', command: 'curl -H "Authorization: Bearer secret_token_123456" https://example.test --password swordfish' },
} as import('./types').AgentEvent);
ctx.emit({
id: 'tool-call-1',
type: 'tool_call',
toolCallId: 'call-1',
toolName: 'terminal_execute',
args: { sessionId: 'sess-1', command: 'uptime' },
} as import('./types').AgentEvent);
ctx.emit({
id: 'tool-result-1',
type: 'tool_result',
toolCallId: 'call-1',
result: 'ok',
isError: false,
} as import('./types').AgentEvent);
}
abort(): void {}
}
const sessionStateStore = new SessionStateStore();
const runtime = new AgentRuntime({ drivers: [new ToolTurnDriver()], sessionStateStore });
await runtime.runTurn({
backend: 'catty',
chatSessionId: 'chat-tool',
sendScopeKey: 'chat-tool',
userText: 'check uptime',
signal: new AbortController().signal,
currentSession: undefined,
assistantMsgId: 'assistant-1',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
maxIterations: 5,
ui: {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
},
});
const text = sessionStateStore.toReinjectionText('chat-tool');
assert.ok(text?.includes('uptime'));
assert.ok(text?.includes('check uptime'));
});
test('AgentRuntime keeps terminal output when session close reports failure', async () => {
class FailedCloseDriver implements TurnDriver {
readonly backend = 'external-sdk' as const;
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
ctx.emit({
id: 'close-call',
type: 'tool_call',
toolCallId: 'close-1',
toolName: 'session_close',
args: { sessionId: 'terminal-still-open' },
} as import('./types').AgentEvent);
ctx.emit({
id: 'close-result',
type: 'tool_result',
toolCallId: 'close-1',
result: JSON.stringify({ ok: false, error: 'close rejected' }),
isError: false,
} as import('./types').AgentEvent);
}
abort(): void {}
}
const runtime = new AgentRuntime({ drivers: [new FailedCloseDriver()] });
const store = runtime.getToolOutputStore('chat-close-failed');
const handle = store.store({
chatSessionId: 'chat-close-failed',
capabilityId: 'terminal.execute',
sessionId: 'terminal-still-open',
content: 'keep me',
});
await runtime.runTurn({
backend: 'external-sdk',
chatSessionId: 'chat-close-failed',
sendScopeKey: 'chat-close-failed',
userText: 'close it',
signal: new AbortController().signal,
currentSession: undefined,
assistantMsgId: 'assistant-close-failed',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
maxIterations: 5,
ui: {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
},
});
assert.ok(store.get(handle.id, 'chat-close-failed'));
});
test('AgentRuntime redacts secrets before trace and listener fan-out', async () => {
class SecretDriver implements TurnDriver {
readonly backend = 'catty' as const;
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
ctx.emit({
id: 'secret-result',
type: 'tool_result',
toolCallId: 'call-secret',
toolName: 'terminal_execute',
result: 'API_TOKEN=tok_live_1234567890',
} as import('./types').AgentEvent);
}
}
const traceStore = new TraceStore();
const runtime = new AgentRuntime({ drivers: [new SecretDriver()], traceStore });
const heard: string[] = [];
runtime.subscribe(event => {
if (event.type === 'tool_result') heard.push(event.result);
});
await runtime.runTurn({
backend: 'catty',
chatSessionId: 'chat-secret',
sendScopeKey: 'chat-secret',
userText: 'check',
signal: new AbortController().signal,
assistantMsgId: 'assistant-1',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
maxIterations: 1,
ui: {
addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {},
reportStreamError: () => {}, setStreamingForScope: () => {},
},
});
assert.doesNotMatch(JSON.stringify(traceStore.exportTrace('chat-secret')), /tok_live/);
assert.doesNotMatch(JSON.stringify(traceStore.exportTrace('chat-secret')), /secret_token|swordfish/);
assert.deepEqual(heard, ['API_TOKEN=[REDACTED]']);
});
test('AgentRuntime stopTurn delegates to active driver', async () => {
const driver = new MockTurnDriver();
const runtime = new AgentRuntime({ drivers: [driver] });
await runtime.stopTurn('chat-2');
});
test('AgentRuntime delegates steering only while a compatible turn is active', async () => {
let releaseRun: (() => void) | undefined;
class SteeringDriver implements TurnDriver {
readonly backend = 'catty' as const;
async run(): Promise<void> {
await new Promise<void>(resolve => { releaseRun = resolve; });
}
async steer() {
return { status: 'accepted' as const, assistantMessageId: 'assistant-next' };
}
}
const runtime = new AgentRuntime({ drivers: [new SteeringDriver()] });
const run = runtime.runTurn({
backend: 'catty',
chatSessionId: 'chat-steer',
sendScopeKey: 'chat-steer',
userText: 'initial',
signal: new AbortController().signal,
currentSession: undefined,
assistantMsgId: 'assistant-1',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
maxIterations: 5,
ui: {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
},
});
await new Promise<void>(resolve => setImmediate(resolve));
assert.deepEqual(await runtime.steerTurn({
chatSessionId: 'chat-steer',
userMessageId: 'user-1',
userText: 'change',
prompt: 'change',
attachedImages: [],
}), { status: 'accepted', assistantMessageId: 'assistant-next' });
releaseRun?.();
await run;
assert.deepEqual(await runtime.steerTurn({
chatSessionId: 'chat-steer',
userMessageId: 'user-2',
userText: 'too late',
prompt: 'too late',
attachedImages: [],
}), { status: 'inactive' });
});
test('AgentRuntime runTurn rejects a concurrent start for the same session', async () => {
let releaseFirst: (() => void) | undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
class BusyMockDriver implements TurnDriver {
readonly backend = 'catty' as const;
readonly runs: TurnInput[] = [];
async run(input: TurnInput): Promise<void> {
this.runs.push(input);
await firstGate;
}
abort(): void {}
}
const driver = new BusyMockDriver();
const runtime = new AgentRuntime({ drivers: [driver], traceStore: new TraceStore() });
const ui = {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
};
const first = runtime.runTurn({
backend: 'catty',
chatSessionId: 'chat-busy',
sendScopeKey: 'scope',
userText: 'one',
signal: new AbortController().signal,
currentSession: undefined,
assistantMsgId: 'a1',
context: {} as never,
maxIterations: 1,
bridge: null,
ui,
});
await assert.rejects(
() => runtime.runTurn({
backend: 'catty',
chatSessionId: 'chat-busy',
sendScopeKey: 'scope',
userText: 'two',
signal: new AbortController().signal,
currentSession: undefined,
assistantMsgId: 'a2',
context: {} as never,
maxIterations: 1,
bridge: null,
ui,
}),
(err: unknown) => err instanceof Error
&& (err as Error & { code?: string }).code === 'AGENT_TURN_BUSY',
);
releaseFirst?.();
await first;
assert.equal(driver.runs.length, 1);
});

View File

@@ -0,0 +1,274 @@
import { globalTraceStore } from './traceStore';
import { stopAgentTurn } from './agentStop';
import type { AgentBackend, AgentEvent, AgentEventListener } from './types';
import { ToolOutputStore } from './toolOutputStore';
import { ToolResultDedup } from './toolResultDedup';
import { SessionStateStore } from './sessionState';
import type {
TurnDriver,
TurnInput,
TurnResult,
TurnSteerInput,
TurnSteerResult,
} from './turnDrivers/types';
import { globalTwoPassCompactionCache } from './twoPassCompaction';
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
import { globalTerminalMonitorGuard } from './terminalMonitorGuard';
let turnCounter = 0;
function nextTurnId(): string {
turnCounter += 1;
return `turn-${Date.now()}-${turnCounter}`;
}
function isFailedToolResult(result: string): boolean {
try {
const parsed = JSON.parse(result) as Record<string, unknown>;
return parsed?.ok === false || typeof parsed?.error === 'string';
} catch {
return false;
}
}
interface ActiveTurn {
turnId: string;
backend: AgentBackend;
driver: TurnDriver;
}
export interface AgentRuntimeOptions {
drivers: TurnDriver[];
traceStore?: typeof globalTraceStore;
sessionStateStore?: SessionStateStore;
}
export class AgentRuntime {
private readonly drivers = new Map<AgentBackend, TurnDriver>();
private readonly listeners = new Set<AgentEventListener>();
private readonly activeTurns = new Map<string, ActiveTurn>();
private readonly activeTurnPromises = new Map<string, Promise<TurnResult>>();
private readonly toolOutputStore = new ToolOutputStore();
private readonly sessionStateStore: SessionStateStore;
private readonly traceStore: typeof globalTraceStore;
constructor(options: AgentRuntimeOptions) {
for (const driver of options.drivers) {
this.drivers.set(driver.backend, driver);
}
this.traceStore = options.traceStore ?? globalTraceStore;
this.sessionStateStore = options.sessionStateStore ?? new SessionStateStore();
}
getToolOutputStore(chatSessionId: string): ToolOutputStore {
void chatSessionId;
return this.toolOutputStore;
}
getSessionStateStore(): SessionStateStore {
return this.sessionStateStore;
}
clearChatSession(chatSessionId: string): void {
this.toolOutputStore.prune(chatSessionId);
this.sessionStateStore.clear(chatSessionId);
this.traceStore.clear(chatSessionId);
globalTwoPassCompactionCache.clear(chatSessionId);
globalTerminalMonitorGuard.clearPrefix(`${chatSessionId}:`);
}
clearTerminalSession(terminalSessionId: string): void {
this.toolOutputStore.pruneTerminalSessionEverywhere(terminalSessionId);
}
async waitForActiveTurn(chatSessionId: string): Promise<void> {
await this.activeTurnPromises.get(chatSessionId)?.catch(() => {});
}
subscribe(listener: AgentEventListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async runTurn(input: TurnInput): Promise<TurnResult> {
const driver = this.drivers.get(input.backend);
if (!driver) {
throw new Error(`No TurnDriver registered for backend "${input.backend}"`);
}
const chatSessionId = input.chatSessionId;
if (this.activeTurnPromises.has(chatSessionId)) {
const error = new Error(`Agent turn already in progress for session ${chatSessionId}`);
(error as Error & { code?: string }).code = 'AGENT_TURN_BUSY';
throw error;
}
const turnPromise = this.runTurnInternal(input, driver);
this.activeTurnPromises.set(chatSessionId, turnPromise);
try {
return await turnPromise;
} finally {
if (this.activeTurnPromises.get(chatSessionId) === turnPromise) {
this.activeTurnPromises.delete(chatSessionId);
}
}
}
private async runTurnInternal(input: TurnInput, driver: TurnDriver): Promise<TurnResult> {
const turnId = nextTurnId();
const chatSessionId = input.chatSessionId;
const toolOutputStore = this.getToolOutputStore(chatSessionId);
const toolResultDedup = new ToolResultDedup();
toolResultDedup.beginTurn();
const sessionStateStore = this.sessionStateStore;
if (input.backend === 'catty') {
sessionStateStore.mergeFromUserGoal(chatSessionId, input.userText);
}
this.activeTurns.set(chatSessionId, {
turnId,
backend: input.backend,
driver,
});
const toolCallMeta = new Map<string, { toolName: string; args: Record<string, unknown> }>();
const emit = (
partial: Omit<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'>
& Partial<Pick<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'>>,
) => {
let event = {
id: partial.id,
type: partial.type,
sessionId: partial.sessionId ?? chatSessionId,
chatSessionId: partial.chatSessionId ?? chatSessionId,
backend: partial.backend ?? input.backend,
timestamp: partial.timestamp ?? Date.now(),
turnId: partial.turnId ?? turnId,
...partial,
} as AgentEvent;
if (event.type === 'tool_result') {
event = {
...event,
result: redactSecretsForModel(event.result),
};
}
if (event.type === 'tool_call') {
event = {
...event,
args: redactSecretsInValueForModel(event.args),
};
toolCallMeta.set(event.toolCallId, {
toolName: event.toolName,
args: event.args,
});
}
if (event.type === 'tool_result') {
const toolResultIsError = event.isError || isFailedToolResult(event.result);
const meta = toolCallMeta.get(event.toolCallId);
const toolName = event.toolName ?? meta?.toolName;
if (toolName) {
sessionStateStore.updateFromToolResult(
chatSessionId,
toolName,
meta?.args,
event.result,
toolResultIsError,
);
if (
!toolResultIsError
&& (toolName === 'session_close' || toolName === 'session.close')
&& typeof meta?.args.sessionId === 'string'
) {
toolOutputStore.pruneTerminalSessionEverywhere(meta.args.sessionId);
}
}
}
if (event.type === 'model_delta' && event.text) {
sessionStateStore.mergeFromAssistantContent(chatSessionId, event.text);
}
if (event.type === 'file_change' && event.status === 'completed') {
sessionStateStore.mergeFileChanges(
chatSessionId,
event.changes.map(change => change.path),
);
}
if (event.type === 'plan_update') {
sessionStateStore.mergePlan(chatSessionId, event.items);
}
this.traceStore.append(event);
for (const listener of this.listeners) {
listener(event);
}
};
emit({
id: `turn-start-${turnId}`,
type: 'turn_start',
backendLabel: input.backend === 'external-sdk'
? ('agentConfig' in input ? input.agentConfig.name : undefined)
: 'catty',
} as AgentEvent);
let reason: TurnResult['reason'] = 'completed';
try {
await driver.run(input, {
turnId,
chatSessionId,
sessionId: chatSessionId,
backend: input.backend,
signal: input.signal,
emit,
toolOutputStore,
toolResultDedup,
sessionStateStore,
});
if (input.signal.aborted) {
reason = 'aborted';
}
} catch (err) {
reason = 'error';
emit({
id: `turn-error-${turnId}`,
type: 'error',
message: err instanceof Error ? err.message : String(err),
recoverable: false,
} as AgentEvent);
throw err;
} finally {
emit({
id: `turn-end-${turnId}`,
type: 'turn_end',
reason,
} as AgentEvent);
this.activeTurns.delete(chatSessionId);
}
return { turnId, reason };
}
async stopTurn(chatSessionId: string, reason: 'user' | 'slash' = 'user'): Promise<void> {
const active = this.activeTurns.get(chatSessionId);
active?.driver.abort?.(chatSessionId);
await stopAgentTurn({
chatSessionId,
reason,
backend: active?.backend ?? 'catty',
});
await this.waitForActiveTurn(chatSessionId);
}
async steerTurn(input: TurnSteerInput): Promise<TurnSteerResult> {
const active = this.activeTurns.get(input.chatSessionId);
if (!active) return { status: 'inactive' };
if (!active.driver.steer) return { status: 'unsupported' };
return active.driver.steer(input);
}
}

View File

@@ -0,0 +1,40 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { stopAgentTurn } from './agentStop';
import { globalTraceStore } from './traceStore';
describe('stopAgentTurn', () => {
it('aborts, cancels bridge surfaces, and emits turn_end', async () => {
const calls: string[] = [];
const controller = new AbortController();
const sessionId = 'chat-stop-test';
globalTraceStore.clear(sessionId);
await stopAgentTurn({
chatSessionId: sessionId,
abortController: controller,
bridge: {
aiCattyCancelExec: async (id) => { calls.push(`catty:${id}`); },
aiSdkAgentCancel: async (_req, id) => { calls.push(`sdk:${id}`); return { ok: true }; },
aiSetChatSessionCancelled: async (id, cancelled) => {
calls.push(`cancelled:${id}:${cancelled}`);
return { ok: true };
},
},
reason: 'slash',
backend: 'catty',
});
assert.equal(controller.signal.aborted, true);
assert.deepEqual(calls, [
`catty:${sessionId}`,
`sdk:${sessionId}`,
`cancelled:${sessionId}:true`,
]);
const events = globalTraceStore.getEvents(sessionId);
assert.equal(events.at(-1)?.type, 'turn_end');
assert.equal((events.at(-1) as { reason?: string }).reason, 'aborted');
});
});

View File

@@ -0,0 +1,73 @@
import { clearAllPendingApprovals } from '../shared/approvalGate';
import { globalTraceStore } from './traceStore';
import type { AgentBackend } from './types';
export type StopAgentTurnReason = 'user' | 'slash';
export interface AgentStopBridge {
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
aiSdkAgentCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
}
export interface StopAgentTurnParams {
chatSessionId: string;
abortController?: AbortController | null;
bridge?: AgentStopBridge | null;
reason?: StopAgentTurnReason;
backend?: AgentBackend;
}
let stopEventCounter = 0;
function nextStopEventId(): string {
stopEventCounter += 1;
return `turn-end-${Date.now()}-${stopEventCounter}`;
}
/**
* Unified stop entry for Catty, external SDK, and MCP tool surfaces.
*/
export async function stopAgentTurn({
chatSessionId,
abortController,
bridge,
reason = 'user',
backend = 'catty',
}: StopAgentTurnParams): Promise<void> {
abortController?.abort();
clearAllPendingApprovals(chatSessionId);
const tasks: Array<Promise<unknown>> = [];
if (bridge?.aiCattyCancelExec) {
tasks.push(bridge.aiCattyCancelExec(chatSessionId).catch(() => {}));
}
if (bridge?.aiSdkAgentCancel) {
tasks.push(bridge.aiSdkAgentCancel('', chatSessionId).catch(() => {}));
}
if (bridge?.aiSetChatSessionCancelled) {
tasks.push(bridge.aiSetChatSessionCancelled(chatSessionId, true).catch(() => {}));
}
await Promise.all(tasks);
globalTraceStore.append({
id: nextStopEventId(),
type: 'turn_end',
sessionId: chatSessionId,
chatSessionId,
backend,
timestamp: Date.now(),
reason: 'aborted',
...(reason === 'slash' ? { backendLabel: 'slash-stop' } : {}),
});
}
/**
* Clear the main-process cancelled flag when a new agent turn begins.
*/
export async function clearChatSessionCancelled(
chatSessionId: string,
bridge?: AgentStopBridge | null,
): Promise<void> {
await bridge?.aiSetChatSessionCancelled?.(chatSessionId, false).catch(() => {});
}

View File

@@ -0,0 +1,152 @@
/**
* Runner for built-in diagnostic skills.
*
* Fetches the skill by id, filters its steps for the target session's shell
* family, executes each step sequentially via the bridge, and assembles a
* single structured report with labels, stdout/stderr, and exit codes.
*/
import { getBuiltinSkill, filterStepsForShell, type SkillStep } from './builtinSkills';
import type { ToolDeps, ToolExecResult } from '../shared/toolExecutors';
interface RunOptions {
chatSessionId?: string;
}
interface StepResult {
label: string;
command: string;
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number;
ok: boolean;
}
export interface SkillRunReport {
skill: string;
sessionId: string;
shellType?: string;
os?: string;
hostname?: string;
startedAt: string;
completedAt: string;
durationMs: number;
steps: StepResult[];
}
export async function executeSkillRun(
deps: ToolDeps,
args: Record<string, unknown>,
options: RunOptions = {},
): Promise<ToolExecResult<SkillRunReport>> {
const { bridge, context, permissionMode } = deps;
const resolveContext = () => (typeof context === 'function' ? context() : context);
const ctx = resolveContext();
const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
const skillName = typeof args.skillName === 'string' ? args.skillName.trim() : '';
if (!sessionId) {
return { ok: false, error: 'Missing sessionId.' };
}
if (!skillName) {
return { ok: false, error: 'Missing skillName.' };
}
if (permissionMode === 'observer') {
return { ok: false, error: 'Observer mode: skill execution is disabled. Switch to Confirm or Auto mode.' };
}
// Validate session is in scope
const session = ctx.sessions.find(s => s.sessionId === sessionId);
if (!session) {
return { ok: false, error: `Session "${sessionId}" is not in the current AI scope.` };
}
// Resolve skill
const skill = getBuiltinSkill(skillName);
if (!skill) {
const available = Object.keys((await import('./builtinSkills')).BUILTIN_SKILLS).join(', ');
return { ok: false, error: `Unknown skill "${skillName}". Available: ${available}` };
}
// Filter steps for this shell family
const steps = filterStepsForShell(skill, session.shellType);
if (steps.length === 0) {
return {
ok: false,
error: `Skill "${skill.id}" has no steps for shell family "${session.shellType ?? 'unknown'}".`,
};
}
// Run each step sequentially, capture everything
const startedAt = new Date();
const stepResults: StepResult[] = [];
for (const step of steps) {
const stepResult = await runStep(bridge, sessionId, step, options.chatSessionId);
stepResults.push(stepResult);
}
const completedAt = new Date();
const report: SkillRunReport = {
skill: skill.id,
sessionId,
shellType: session.shellType,
os: session.os,
hostname: session.hostname,
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
durationMs: completedAt.getTime() - startedAt.getTime(),
steps: stepResults,
};
return { ok: true, data: report };
}
async function runStep(
bridge: NonNullable<ToolDeps['bridge']>,
sessionId: string,
step: SkillStep,
chatSessionId?: string,
): Promise<StepResult> {
const t0 = Date.now();
try {
const result = await bridge.aiExec(sessionId, step.command, chatSessionId);
const durationMs = Date.now() - t0;
if (!result.ok && result.error) {
return {
label: step.label,
command: step.command,
stdout: result.stdout || '',
stderr: `[exec error] ${result.error}`,
exitCode: null,
durationMs,
ok: false,
};
}
return {
label: step.label,
command: step.command,
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.exitCode ?? -1,
durationMs,
ok: (result.exitCode ?? 0) === 0,
};
} catch (err) {
const durationMs = Date.now() - t0;
return {
label: step.label,
command: step.command,
stdout: '',
stderr: `[exception] ${err instanceof Error ? err.message : String(err)}`,
exitCode: null,
durationMs,
ok: false,
};
}
}

View File

@@ -0,0 +1,131 @@
/**
* Built-in diagnostic skills — pre-crafted multi-step shell command bundles
* targeting specific OS families. The Catty Agent calls `skill_run` with a
* skillName and sessionId; this module expands that into the right commands
* for the host's detected OS/shellType.
*
* Each skill is an ordered list of { label, shell, command } entries. The
* executor runs them sequentially and assembles the outputs into a single
* structured report the LLM can summarize for the user.
*/
export type SkillShellTarget = 'posix' | 'powershell' | 'cmd' | 'any';
export interface SkillStep {
/** Short human label shown in the assembled report. */
label: string;
/** Shell family this step is for. 'any' = run regardless. */
shell: SkillShellTarget;
/** Command to send to the terminal. */
command: string;
}
export interface BuiltinSkill {
id: string;
/** Short description the model sees in the tool's docstring. */
description: string;
/** If true, requires the session to be a remote SSH host. */
requiresRemoteHost?: boolean;
/** Steps grouped by OS family. */
steps: SkillStep[];
}
// ---------------------------------------------------------------------------
// Skill registry — keep alphabetical by id
// ---------------------------------------------------------------------------
export const BUILTIN_SKILLS: Record<string, BuiltinSkill> = {
// ---- diagnose_linux -----------------------------------------------------
diagnose_linux: {
id: 'diagnose_linux',
description:
'Quick Linux health check — CPU, memory, disk, load top processes, listening ports, Docker status, failed systemd services, recent kernel errors.',
requiresRemoteHost: true,
steps: [
{ label: 'OS / kernel / uptime', shell: 'posix', command: 'uname -a; uptime; cat /etc/os-release 2>/dev/null | head -5' },
{ label: 'CPU & memory', shell: 'posix', command: "echo '--- free -h ---'; free -h; echo '--- vmstat 1 2 ---'; vmstat 1 2 | tail -1" },
{ label: 'Disk usage', shell: 'posix', command: "df -h 2>/dev/null; echo '--- inodes ---'; df -i 2>/dev/null | head -10" },
{ label: 'Top processes by CPU', shell: 'posix', command: 'ps aux --sort=-%cpu 2>/dev/null | head -10 || ps aux | sort -k3 -rn | head -10' },
{ label: 'Listening ports', shell: 'posix', command: "ss -tlnp 2>/dev/null | head -20 || netstat -tlnp 2>/dev/null | head -20" },
{ label: 'Docker status (if present)', shell: 'posix', command: 'command -v docker >/dev/null 2>&1 && (docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>&1 | head -15) || echo "docker not installed"' },
{ label: 'Failed systemd services', shell: 'posix', command: "systemctl --failed --no-pager 2>/dev/null | head -20 || echo 'systemd not available'" },
{ label: 'Recent kernel errors', shell: 'posix', command: "dmesg --level=err -n 2>/dev/null | tail -15 || journalctl -p err -n 15 --no-pager 2>/dev/null || echo 'no kernel error log available'" },
],
},
// ---- diagnose_windows ---------------------------------------------------
diagnose_windows: {
id: 'diagnose_windows',
description:
'Quick Windows health check via PowerShell — OS, CPU, memory, disk, top processes, services, network adapters, recent errors.',
requiresRemoteHost: true,
steps: [
{ label: 'OS & uptime', shell: 'powershell', command: '$os = Get-CimInstance Win32_OperatingSystem; "Computer: $($env:COMPUTERNAME)"; "OS: $($os.Caption) $($os.Version)"; "Uptime: $([math]::Round(((Get-Date) - $os.LastBootUpTime).TotalHours, 1)) hours"' },
{ label: 'CPU', shell: 'powershell', command: 'Get-CimInstance Win32_Processor | Select-Object Name, NumberOfCores, LoadPercentage | Format-List' },
{ label: 'Memory', shell: 'powershell', command: '$cs = Get-CimInstance Win32_ComputerSystem; $os = Get-CimInstance Win32_OperatingSystem; $totalGB = [math]::Round($cs.TotalPhysicalMemory/1GB, 1); $freeGB = [math]::Round($os.FreePhysicalMemory/1MB, 1); "Total: ${totalGB} GB, Free: ${freeGB} GB, Used: $([math]::Round(($totalGB - $freeGB)/$totalGB*100, 1))%"' },
{ label: 'Disk', shell: 'powershell', command: 'Get-Volume -DriveLetter * | Where-Object DriveLetter | Select-Object DriveLetter, FileSystemLabel, FileSystem, @{N="TotalGB";E={[math]::Round($_.Size/1GB,1)}}, @{N="FreeGB";E={[math]::Round($_.SizeRemaining/1GB,1)}} | Format-Table -AutoSize' },
{ label: 'Top processes (working set)', shell: 'powershell', command: 'Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10 Name, Id, @{N="MB";E={[math]::Round($_.WorkingSet64/1MB,0)}}, CPU | Format-Table -AutoSize' },
{ label: 'Services running', shell: 'powershell', command: 'Get-Service | Where-Object Status -eq Running | Measure-Object | Select-Object -ExpandProperty Count | ForEach-Object { "$_ services running" }; Get-Service | Where-Object {$_.Status -ne "Running" -and $_.StartType -ne "Disabled"} | Select-Object -First 15 Name, Status, StartType | Format-Table -AutoSize' },
{ label: 'Listening ports', shell: 'powershell', command: 'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize' },
{ label: 'Recent errors (System log)', shell: 'powershell', command: 'Get-WinEvent -LogName System -MaxEvents 50 -ErrorAction SilentlyContinue | Where-Object {$_.LevelDisplayName -eq "Error"} | Select-Object -First 10 TimeCreated, Id, ProviderName, Message | Format-List' },
],
},
// ---- check_ports --------------------------------------------------------
check_ports: {
id: 'check_ports',
description: 'Show all listening TCP/UDP ports with process info (ss/netstat on Linux, netstat/Get-NetTCPConnection on Windows).',
steps: [
{ label: 'Listening ports (POSIX)', shell: 'posix', command: "echo '=== ss ==='; ss -tulnp 2>/dev/null || echo 'ss not available'; echo '=== netstat ==='; netstat -tulnp 2>/dev/null | head -30 || true" },
{ label: 'Listening ports (PowerShell)', shell: 'powershell', command: 'Write-Host "=== TCP Listen ===" ; Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize; Write-Host "=== UDP ===" ; Get-NetUDPEndpoint -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize' },
],
},
// ---- check_docker -------------------------------------------------------
check_docker: {
id: 'check_docker',
description: 'Check Docker daemon health, running containers, disk usage, and recent images.',
requiresRemoteHost: true,
steps: [
{ label: 'Docker version / info', shell: 'any', command: 'docker version 2>&1 | head -15; echo "---"; docker info 2>&1 | head -20' },
{ label: 'Containers (all)', shell: 'any', command: 'docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>&1' },
{ label: 'Docker disk usage', shell: 'any', command: 'docker system df 2>&1' },
{ label: 'Top images', shell: 'any', command: 'docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" 2>&1 | head -15' },
],
},
// ---- security_audit -----------------------------------------------------
security_audit: {
id: 'security_audit',
description: 'Basic Linux security posture check — SSH config, firewall, failed SSH logins, world-writable files, listening ports.',
requiresRemoteHost: true,
steps: [
{ label: 'SSHD config quick check', shell: 'posix', command: "echo '=== PermitRootLogin ==='; grep -i 'PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null || echo '(not set = default)'; echo '=== PasswordAuthentication ==='; grep -i 'PasswordAuthentication' /etc/ssh/sshd_config 2>/dev/null || echo '(not set = default)'" },
{ label: 'Firewall status', shell: 'posix', command: "echo '=== ufw ==='; command -v ufw >/dev/null 2>&1 && ufw status 2>&1 || echo 'ufw not installed'; echo '=== firewalld ==='; command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>&1 || echo 'firewalld not installed'" },
{ label: 'Failed SSH logins (last)', shell: 'posix', command: "echo '=== Recent failed SSH ==='; lastb 2>/dev/null | head -10 || echo 'lastb not available'" },
{ label: 'World-writable files in /tmp (non-sticky)', shell: 'posix', command: 'find /tmp -maxdepth 2 -type f ! -sticky -perm -0002 2>/dev/null | head -10 || echo "ok"' },
{ label: 'Listening ports', shell: 'posix', command: "ss -tlnp 2>/dev/null | head -20 || netstat -tlnp 2>/dev/null | head -20" },
],
},
};
/** All skill ids, exported so callers can validate. */
export const BUILTIN_SKILL_IDS = Object.keys(BUILTIN_SKILLS);
/** Resolve a skill by id (case-insensitive). */
export function getBuiltinSkill(skillId: string): BuiltinSkill | null {
if (!skillId) return null;
return BUILTIN_SKILLS[skillId.toLowerCase()] ?? null;
}
/** Return steps matching the host's shell family (run 'any' on every host). */
export function filterStepsForShell(skill: BuiltinSkill, shellType?: string): SkillStep[] {
const shell = (shellType || 'posix').toLowerCase();
return skill.steps.filter(step => {
if (step.shell === 'any') return true;
if (step.shell === shell) return true;
// Treat 'fish' / unknown POSIX-ish shells as posix for diagnostics.
if (step.shell === 'posix' && (shell === 'fish' || shell === 'unknown' || shell === 'posix')) return true;
return false;
});
}

View 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');
});
});

View File

@@ -0,0 +1,788 @@
import { tool } from 'ai';
import { z } from 'zod';
import type { ExecutorContext, NetcattyBridge } from '../cattyAgent/executor';
import type { TerminalContextReadRange } from '../../../domain/terminalContextRead';
import type { AIPermissionMode } from '../types';
import type { WebSearchConfig } from '../types';
import { isWebSearchReady } from '../types';
import {
executeTerminalExecute,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeWebSearch,
executeUrlFetch,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
import { reserveSessionSlot } from '../shared/sessionExecutionQueue';
import { fitTerminalExecuteResultForModel } from './terminalCompression';
import { fitLargeToolResultForModel } from './toolResultFitting';
import { redactSecretsForModel } from './modelSecretRedaction';
import {
globalTerminalMonitorGuard,
isStreamingMonitorCommand,
} from './terminalMonitorGuard';
import type { ToolOutputStore } from './toolOutputStore';
import { TOOL_OUTPUT_READ_MAX_CHARS } from './toolOutputStore';
import {
buildTerminalWriteFingerprint,
hashScopeKey,
hashToolResult,
previewToolResult,
type ToolResultDedup,
} from './toolResultDedup';
import cattyToolSpecs from './generated/cattyToolSpecs.json';
import {
cattyToolContextSchema,
toolDepsFromContext,
type CattyToolContext,
} from './cattyRuntimeContext';
type FieldShape = {
type: string;
optional?: boolean;
description?: string;
};
type CattyToolSpec = {
capabilityId: string;
toolName: string;
rpcMethod: string | null;
localExecution?: boolean;
description: string;
inputShape: Record<string, FieldShape>;
policy: {
write: boolean;
bypassesApproval: boolean;
};
};
export type CattyToolsBundle = {
tools: Record<string, ReturnType<typeof tool>>;
toolsContext: Record<string, CattyToolContext>;
};
function buildZodObject(shape: Record<string, FieldShape>): z.ZodObject<Record<string, z.ZodTypeAny>> {
const entries: Record<string, z.ZodTypeAny> = {};
for (const [key, field] of Object.entries(shape)) {
let schema: z.ZodTypeAny = field.type === 'number' ? z.number() : z.string();
if (field.description) {
schema = schema.describe(field.description);
}
entries[key] = field.optional ? schema.optional() : schema;
}
return z.object(entries);
}
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
if (r.ok === false) return { error: r.error };
return r.data;
}
async function invokeCapabilityRpc(
bridge: NetcattyBridge,
rpcMethod: string,
params: Record<string, unknown>,
chatSessionId?: string,
): Promise<unknown> {
if (!bridge.aiCapability) {
return { error: 'Capability bridge is unavailable in this environment.' };
}
const result = await bridge.aiCapability(rpcMethod, params, chatSessionId);
if (result && typeof result === 'object' && 'ok' in result && (result as { ok: boolean }).ok === false) {
return { error: (result as { error?: string }).error || 'Capability call failed.' };
}
return result;
}
async function tryFetchHostEnvironment(
bridge: NetcattyBridge,
chatSessionId?: string,
): Promise<Record<string, unknown> | null> {
if (!bridge.aiCapability || !chatSessionId) return null;
try {
const environment = await invokeCapabilityRpc(
bridge,
'netcatty/getContext',
{},
chatSessionId,
);
if (environment && typeof environment === 'object' && !('error' in environment)) {
return environment as Record<string, unknown>;
}
} catch {
// IPC failures must not block read-only harness tools.
}
return null;
}
function applyToolDedup(
toolName: string,
fingerprint: string,
result: unknown,
dedup?: ToolResultDedup,
): unknown {
if (!dedup) return result;
const cached = dedup.check(fingerprint);
if (cached) {
return dedup.buildCachedNotice(cached);
}
dedup.remember(toolName, fingerprint, previewToolResult(result));
return result;
}
function fitCapabilityResultForModel(
result: unknown,
spec: CattyToolSpec,
chatSessionId?: string,
toolOutputStore?: ToolOutputStore,
args?: Record<string, unknown>,
toolResultDedup?: ToolResultDedup,
): unknown {
if (spec.capabilityId === 'harness.tool_output.read') {
return result;
}
const resultRecord = result && typeof result === 'object'
? result as Record<string, unknown>
: undefined;
const jobId = typeof args?.jobId === 'string'
? args.jobId
: typeof resultRecord?.jobId === 'string' ? resultRecord.jobId : undefined;
const terminalSessionId = typeof args?.sessionId === 'string'
? args.sessionId
: typeof resultRecord?.sessionId === 'string'
? resultRecord.sessionId
: jobId ? toolResultDedup?.terminalSessionForJob(jobId) : undefined;
return fitLargeToolResultForModel({
result,
capabilityId: spec.capabilityId,
chatSessionId,
toolOutputStore,
terminalSessionId,
normalizeStrings: spec.capabilityId.startsWith('terminal.')
|| spec.capabilityId === 'harness.terminal.read_context',
});
}
export function applyMonitorStopResult(
poll: Record<string, unknown>,
stopResult: unknown,
suppressedCount: number,
): Record<string, unknown> {
const stopFailed = Boolean(
stopResult && typeof stopResult === 'object'
&& (
(stopResult as { ok?: boolean }).ok === false
|| (
typeof (stopResult as { error?: unknown }).error === 'string'
&& (stopResult as { error: string }).error.trim().length > 0
)
),
);
return stopFailed
? {
...poll,
output: `[automatic monitor stop failed after sustained overload; ${suppressedCount} batches were suppressed. The job may still be running; poll/stop it explicitly and narrow the command before continuing.]`,
}
: {
...poll,
status: 'stopping',
output: `[monitor stop requested after sustained overload; ${suppressedCount} batches were suppressed. Narrow the command with grep/awk before restarting it.]`,
};
}
interface LocalExecutionContext {
deps: ToolDeps;
spec: CattyToolSpec;
args: Record<string, unknown>;
toolOutputStore?: ToolOutputStore;
toolResultDedup?: ToolResultDedup;
chatSessionId?: string;
}
async function executeLocalCattyCapability(ctx: LocalExecutionContext): Promise<unknown> {
const { deps, spec, args, toolOutputStore, toolResultDedup, chatSessionId } = ctx;
const resolveContext = () => (typeof deps.context === 'function' ? deps.context() : deps.context);
switch (spec.capabilityId) {
case 'harness.tool_output.read': {
const { handleId, mode, maxChars, offset, query } = args as {
handleId: string;
mode?: 'head' | 'tail' | 'full' | 'range' | 'search';
maxChars?: number;
offset?: number;
query?: string;
};
if (!toolOutputStore || !chatSessionId) {
return { error: 'Tool output store is unavailable.' };
}
const requestedChars = Math.min(
TOOL_OUTPUT_READ_MAX_CHARS,
typeof maxChars === 'number' && Number.isFinite(maxChars)
? Math.max(1, Math.floor(maxChars))
: TOOL_OUTPUT_READ_MAX_CHARS,
);
const grantedChars = toolResultDedup
? toolResultDedup.takeBudget('tool-output-read', requestedChars, TOOL_OUTPUT_READ_MAX_CHARS * 2)
: requestedChars;
if (grantedChars <= 0) {
return {
error: `This turn has reached its ${TOOL_OUTPUT_READ_MAX_CHARS * 2}-character saved-output read budget. Continue in the next turn or narrow the search.`,
};
}
const result = await toolOutputStore.readChunkAsync(
{ handleId, mode, maxChars: grantedChars, offset, query },
chatSessionId,
);
if (result == null) {
return { error: `Handle "${handleId}" was not found for this chat session.` };
}
return { ...result, content: redactSecretsForModel(result.content) };
}
case 'harness.workspace.get_info': {
const scopeCtx = resolveContext();
const fingerprint = toolResultDedup?.fingerprintFor(
spec.toolName,
hashScopeKey([chatSessionId, scopeCtx.workspaceId, String(scopeCtx.sessions?.length ?? 0)]),
);
const local = executeWorkspaceGetInfo(deps);
if (local.ok === false) {
return unwrap(local);
}
let merged: unknown = local.data;
const environment = await tryFetchHostEnvironment(deps.bridge, chatSessionId);
if (environment) {
const hosts = Array.isArray(environment.hosts)
? (environment.hosts as Array<Record<string, unknown>>)
: [];
const hostBySessionId = new Map(hosts.map((host) => [String(host.sessionId), host]));
merged = {
...local.data,
sessions: local.data.sessions.map((session) => ({
...session,
...(hostBySessionId.get(session.sessionId) ?? {}),
})),
activePortForwardTunnels: environment.activePortForwardTunnels,
};
}
if (fingerprint) {
return applyToolDedup(spec.toolName, fingerprint, merged, toolResultDedup);
}
return merged;
}
case 'harness.workspace.get_session_info': {
const { sessionId } = args as { sessionId: string };
const local = executeWorkspaceGetSessionInfo(deps, { sessionId });
if (local.ok === false) {
return unwrap(local);
}
const environment = await tryFetchHostEnvironment(deps.bridge, chatSessionId);
if (environment) {
const hosts = Array.isArray(environment.hosts)
? (environment.hosts as Array<Record<string, unknown>>)
: [];
const match = hosts.find((host) => String(host.sessionId) === sessionId);
if (match) {
return { ...local.data, ...match };
}
}
return local.data;
}
case 'harness.terminal.read_context': {
const scopeCtx = resolveContext();
const sessions = scopeCtx.sessions ?? [];
const requestedSessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
? args.sessionId.trim()
: undefined;
const sessionId = requestedSessionId
?? (sessions.length === 1 ? sessions[0].sessionId : undefined);
if (!sessionId) {
return {
ok: false,
error: 'sessionId is required because the current AI scope contains multiple terminal sessions.',
};
}
const session = sessions.find((entry) => entry.sessionId === sessionId);
if (!session) {
return {
ok: false,
error: `Terminal session "${sessionId}" is not in the current AI scope.`,
};
}
if (!scopeCtx.readTerminalContext) {
return {
ok: false,
error: 'Terminal context reader is unavailable for this AI scope.',
};
}
const result = await scopeCtx.readTerminalContext({
sessionId,
range: typeof args.range === 'string' ? args.range as TerminalContextReadRange : undefined,
startLine: typeof args.startLine === 'number' ? args.startLine : undefined,
maxLines: typeof args.maxLines === 'number' ? args.maxLines : undefined,
});
if (result.ok === false) return result;
const normalizedResult = result;
const fingerprint = toolResultDedup?.fingerprintFor(
spec.toolName,
hashScopeKey([
sessionId,
String(normalizedResult.startLine),
String(normalizedResult.endLine),
normalizedResult.range,
hashToolResult(normalizedResult.content),
]),
);
return fingerprint
? applyToolDedup(spec.toolName, fingerprint, normalizedResult, toolResultDedup)
: normalizedResult;
}
case 'harness.web.search': {
const { query, maxResults } = args as { query: string; maxResults?: number };
return unwrap(await executeWebSearch(deps, { query, maxResults }));
}
case 'harness.url.fetch': {
const { url, maxLength } = args as { url: string; maxLength?: number };
const fingerprint = toolResultDedup?.fingerprintFor(spec.toolName, url);
const raw = unwrap(await executeUrlFetch(deps, { url, maxLength }));
if (fingerprint) {
return applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup);
}
return raw;
}
case 'harness.skill.run': {
const { executeSkillRun } = await import('./builtinSkillRunner');
const result = await executeSkillRun(deps, args, { chatSessionId });
return unwrap(result);
}
default:
return { error: `No local executor registered for "${spec.capabilityId}".` };
}
}
function resolveSessionQueueKey(
spec: CattyToolSpec,
args: Record<string, unknown>,
chatSessionId?: string,
): string | null {
if (spec.capabilityId.startsWith('harness.') && !spec.policy.write) {
return null;
}
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : undefined;
if (sessionId) {
return `${chatSessionId ?? 'global'}:${sessionId}`;
}
return `${chatSessionId ?? 'global'}:${spec.toolName}`;
}
export function resolveSessionQueueKeyForTests(
spec: Pick<CattyToolSpec, 'capabilityId' | 'toolName' | 'policy'>,
args: Record<string, unknown>,
chatSessionId?: string,
): string | null {
return resolveSessionQueueKey(spec as CattyToolSpec, args, chatSessionId);
}
function createCatalogTool(spec: CattyToolSpec) {
const inputSchema = buildZodObject(spec.inputShape);
return tool({
description: spec.description,
inputSchema,
contextSchema: cattyToolContextSchema,
execute: async (args, { toolCallId: _toolCallId, abortSignal, context }) => {
const toolContext = context as CattyToolContext;
const deps = toolDepsFromContext(toolContext);
const { toolOutputStore, toolResultDedup } = toolContext;
const queueKey = resolveSessionQueueKey(
spec,
args as Record<string, unknown>,
deps.chatSessionId,
);
const slot = queueKey ? reserveSessionSlot(queueKey) : null;
try {
const result = await (async () => {
if (abortSignal?.aborted) {
return { error: 'Tool call cancelled before it could start.' };
}
await slot?.ready;
if (spec.capabilityId === 'terminal.execute') {
const { sessionId: sid, command } = args as { sessionId: string; command: string };
const writeFingerprint = buildTerminalWriteFingerprint(
'terminal_execute',
deps.chatSessionId,
{ sessionId: sid, command },
);
const replay = writeFingerprint
? toolResultDedup?.replayCompletedWrite(writeFingerprint)
: undefined;
if (replay !== undefined) {
return {
...(typeof replay === 'object' && replay !== null ? replay : { result: replay }),
replayedCompletedResult: true,
note: 'The command already executed before request compaction; its recorded result was replayed and the command was not executed again.',
};
}
const cancelOnAbort = () => {
if (deps.chatSessionId) {
void deps.bridge.aiCattyCancelExec?.(deps.chatSessionId);
}
};
abortSignal?.addEventListener('abort', cancelOnAbort, { once: true });
try {
const result = await executeTerminalExecute(deps, { sessionId: sid, command });
if (result.ok === false) {
if (!result.data) return unwrap(result);
const fittedFailure = {
error: fitLargeToolResultForModel({
result: result.error,
capabilityId: 'terminal.execute.error',
chatSessionId: deps.chatSessionId,
toolOutputStore,
terminalSessionId: sid,
normalizeStrings: true,
}),
...fitTerminalExecuteResultForModel({
...result.data,
command,
sessionId: sid,
}, {
chatSessionId: deps.chatSessionId,
toolOutputStore,
}),
};
if (writeFingerprint) toolResultDedup?.rememberCompletedWrite(writeFingerprint, fittedFailure);
return fittedFailure;
}
const fitted = fitTerminalExecuteResultForModel({
...result.data,
command,
sessionId: sid,
}, {
chatSessionId: deps.chatSessionId,
toolOutputStore,
});
if (writeFingerprint) toolResultDedup?.rememberCompletedWrite(writeFingerprint, fitted);
return fitted;
} finally {
abortSignal?.removeEventListener('abort', cancelOnAbort);
}
}
if (spec.localExecution || spec.capabilityId.startsWith('harness.')) {
const result = await executeLocalCattyCapability({
deps,
spec,
args: args as Record<string, unknown>,
toolOutputStore,
toolResultDedup,
chatSessionId: deps.chatSessionId,
});
return fitCapabilityResultForModel(
result,
spec,
deps.chatSessionId,
toolOutputStore,
args as Record<string, unknown>,
toolResultDedup,
);
}
if (!spec.rpcMethod) {
return { error: `Capability "${spec.capabilityId}" has no RPC binding.` };
}
const terminalStartFingerprint = spec.capabilityId === 'terminal.start'
? buildTerminalWriteFingerprint(
'terminal_start',
deps.chatSessionId,
args as { sessionId?: unknown; command?: unknown },
)
: undefined;
const terminalStartReplay = terminalStartFingerprint
? toolResultDedup?.replayCompletedWrite(terminalStartFingerprint)
: undefined;
if (terminalStartReplay !== undefined) {
return {
...(typeof terminalStartReplay === 'object' && terminalStartReplay !== null
? terminalStartReplay
: { result: terminalStartReplay }),
replayedCompletedResult: true,
note: 'The background command already started before request compaction; its recorded job was replayed and no second job was created.',
};
}
let raw = await invokeCapabilityRpc(
deps.bridge,
spec.rpcMethod,
args as Record<string, unknown>,
deps.chatSessionId,
);
if (
spec.capabilityId === 'terminal.start'
&& raw && typeof raw === 'object'
&& typeof (raw as { jobId?: unknown }).jobId === 'string'
&& typeof (args as { sessionId?: unknown }).sessionId === 'string'
) {
toolResultDedup?.rememberTerminalJobSession(
(raw as { jobId: string }).jobId,
(args as { sessionId: string }).sessionId,
);
}
if (
spec.capabilityId === 'terminal.poll'
&& raw
&& typeof raw === 'object'
&& (raw as { ok?: boolean }).ok !== false
) {
let poll = raw as Record<string, unknown>;
const monitorKey = `${deps.chatSessionId ?? 'global'}:${String(poll.jobId ?? args.jobId ?? '')}`;
if (
isStreamingMonitorCommand(poll.command)
&& typeof poll.output === 'string'
&& poll.output.trim().length > 0
) {
const guarded = globalTerminalMonitorGuard.process(monitorKey, poll.output);
if (guarded.action === 'stop') {
const stopResult = await invokeCapabilityRpc(
deps.bridge,
'netcatty/jobStop',
{ jobId: poll.jobId ?? args.jobId },
deps.chatSessionId,
);
poll = applyMonitorStopResult(poll, stopResult, guarded.suppressedCount);
} else if (guarded.action === 'suppress') {
poll = {
...poll,
output: `[monitor batch suppressed by rate limit; suppressed=${guarded.suppressedCount}]`,
};
} else {
let output = guarded.content;
if (guarded.sourceTruncated && toolOutputStore && deps.chatSessionId) {
const jobId = String(poll.jobId ?? args.jobId ?? '');
const handle = toolOutputStore.store({
chatSessionId: deps.chatSessionId,
capabilityId: 'terminal.poll.monitor-batch',
content: poll.output,
sessionId: jobId ? toolResultDedup?.terminalSessionForJob(jobId) : undefined,
});
output += [
'',
`[monitor batch archived before shortening: rawChars=${poll.output.length} handleId=${handle.id}]`,
'Use tool_output_read with this handleId to search/read omitted details; the terminal nextOffset already advances past the raw batch.',
'This saved output is available only until the app closes. Read this handle before closing the app.',
].join('\n');
}
poll = { ...poll, output };
}
}
if (poll.status !== 'running' && poll.status !== 'stopping') {
globalTerminalMonitorGuard.clear(monitorKey);
}
raw = poll;
const fingerprint = toolResultDedup?.fingerprintFor(
spec.toolName,
hashScopeKey([
String(poll.jobId ?? args.jobId ?? ''),
String(poll.outputBaseOffset ?? ''),
String(poll.nextOffset ?? ''),
String(poll.status ?? ''),
hashToolResult(poll.output ?? ''),
]),
);
if (fingerprint) {
return fitCapabilityResultForModel(
applyToolDedup(spec.toolName, fingerprint, poll, toolResultDedup),
spec,
deps.chatSessionId,
toolOutputStore,
args as Record<string, unknown>,
toolResultDedup,
);
}
}
if (spec.toolName === 'get_environment' || spec.capabilityId === 'session.environment') {
const ctx = typeof deps.context === 'function' ? deps.context() : deps.context;
const fingerprint = toolResultDedup?.fingerprintFor(
spec.toolName,
hashScopeKey([deps.chatSessionId, ctx.workspaceId, String(ctx.sessions?.length ?? 0)]),
);
if (fingerprint) {
return fitCapabilityResultForModel(
applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup),
spec,
deps.chatSessionId,
toolOutputStore,
args as Record<string, unknown>,
toolResultDedup,
);
}
}
if (spec.capabilityId.includes('sftp') && spec.capabilityId.includes('read')) {
const { sessionId: sid, path } = args as { sessionId?: string; path?: string };
const fingerprint = toolResultDedup?.fingerprintFor(
spec.toolName,
hashScopeKey([sid, path]),
);
if (fingerprint) {
return fitCapabilityResultForModel(
applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup),
spec,
deps.chatSessionId,
toolOutputStore,
args as Record<string, unknown>,
toolResultDedup,
);
}
if (
raw
&& typeof raw === 'object'
&& 'content' in raw
&& toolOutputStore
&& deps.chatSessionId
) {
const content = String((raw as { content?: string }).content ?? '');
const MAX_LIVE_SFTP_READ_CHARS = 24_000;
if (content.length > MAX_LIVE_SFTP_READ_CHARS) {
const handle = toolOutputStore.store({
chatSessionId: deps.chatSessionId,
capabilityId: spec.capabilityId,
content,
sessionId: sid,
});
return {
ok: true,
path: (raw as { path?: string }).path ?? path,
preview: handle.preview,
totalChars: handle.totalChars,
handleId: handle.id,
note: 'Full file content is available only until the app closes. Use tool_output_read now.',
};
}
}
}
const fittedRaw = fitCapabilityResultForModel(
raw,
spec,
deps.chatSessionId,
toolOutputStore,
args as Record<string, unknown>,
toolResultDedup,
);
if (
terminalStartFingerprint
&& raw && typeof raw === 'object'
&& (raw as { ok?: boolean }).ok !== false
&& typeof (raw as { jobId?: unknown }).jobId === 'string'
) {
toolResultDedup?.rememberCompletedWrite(terminalStartFingerprint, fittedRaw);
}
return fittedRaw;
})();
if (toolOutputStore && deps.chatSessionId) {
await toolOutputStore.flush(deps.chatSessionId);
return toolOutputStore.resolveRestartPersistenceNotices(result, deps.chatSessionId);
}
return result;
} finally {
slot?.release();
}
},
});
}
export function buildCattyToolContext(input: {
bridge: NetcattyBridge;
context: ToolDeps['context'];
commandBlocklist?: string[];
permissionMode: AIPermissionMode;
webSearchConfig?: WebSearchConfig;
chatSessionId?: string;
toolOutputStore?: ToolOutputStore;
toolResultDedup?: ToolResultDedup;
}): CattyToolContext {
return {
bridge: input.bridge,
chatSessionId: input.chatSessionId,
permissionMode: input.permissionMode,
commandBlocklist: input.commandBlocklist,
webSearchConfig: input.webSearchConfig,
getExecutorContext: typeof input.context === 'function'
? input.context as () => ExecutorContext
: () => input.context as ExecutorContext,
toolOutputStore: input.toolOutputStore,
toolResultDedup: input.toolResultDedup,
};
}
export function createCattyToolsFromCatalog(
bridge: NetcattyBridge,
context: ToolDeps['context'],
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
webSearchConfig?: WebSearchConfig,
chatSessionId?: string,
toolOutputStore?: ToolOutputStore,
toolResultDedup?: ToolResultDedup,
): CattyToolsBundle {
const sharedContext = buildCattyToolContext({
bridge,
context,
commandBlocklist,
permissionMode,
webSearchConfig,
chatSessionId,
toolOutputStore,
toolResultDedup,
});
const catalogTools: Record<string, ReturnType<typeof tool>> = {};
const toolsContext: Record<string, CattyToolContext> = {};
for (const rawSpec of cattyToolSpecs as CattyToolSpec[]) {
if (rawSpec.capabilityId === 'harness.web.search' && !isWebSearchReady(webSearchConfig)) {
continue;
}
catalogTools[rawSpec.toolName] = createCatalogTool(rawSpec);
toolsContext[rawSpec.toolName] = sharedContext;
}
return { tools: catalogTools, toolsContext };
}
/** Test helper: attach shared context when calling tool.execute directly. */
export function withCattyToolContext<T extends { execute: (...args: never[]) => unknown }>(
toolInstance: T,
context: CattyToolContext,
toolCallId = 'test-call',
): T {
const original = toolInstance.execute.bind(toolInstance);
return {
...toolInstance,
execute: (input: Parameters<T['execute']>[0], options?: Partial<Parameters<T['execute']>[1]>) =>
original(input, {
toolCallId,
messages: [],
...options,
context,
} as Parameters<T['execute']>[1]),
} as T;
}
export { tryFetchHostEnvironment };

View File

@@ -0,0 +1,281 @@
import type { ModelMessage } from 'ai';
import { generateText, pruneMessages } from 'ai';
import {
CONTEXT_COMPACTION_SYSTEM_PROMPT,
DEFAULT_PROTECT_RECENT_MESSAGES,
formatMessagesForCompaction,
resolveContextWindow,
} from '../contextCompaction';
import type { ProviderConfig } from '../types';
import {
extractLatestUserGoal,
prepareTurnContext,
} from './contextManager';
import type { CompactionTrace } from './types';
import { buildCattyCompactionTimeout } from './streamTimeouts';
import {
COMPACTION_PROMPT_RESERVE,
COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
DEFAULT_MAX_OUTPUT_TOKENS,
resolveEffectiveMaxOutputTokens,
computeCompactionThreshold,
computeTotalInputTokens,
} from './contextBudget';
import { pruneUntilFitsCompaction } from './compactionPruner';
import type { ToolOutputStore } from './toolOutputStore';
import { storeCompactionArchive, storeCompactionArtifact } from './compactionArtifacts';
import { globalTwoPassCompactionCache } from './twoPassCompaction';
export interface CompactCattyMessagesInput {
messages: ModelMessage[];
sessionId: string;
chatSessionId?: string;
provider?: Pick<ProviderConfig, 'contextWindow' | 'modelContextWindows' | 'providerId' | 'advancedParams'> | null;
modelId?: string | null;
reservedTokens?: () => number;
model: Parameters<typeof generateText>[0]['model'];
abortSignal: AbortSignal;
trigger?: 'pre-turn' | '413-retry' | 'force';
force?: boolean;
compressForRequestTooLargeRetry?: boolean;
/** Override protect-recent window (default: DEFAULT_PROTECT_RECENT_MESSAGES). */
protectRecentMessages?: number;
maxOutputTokens?: number;
onCompactionStart?: (trigger: 'pre-turn' | '413-retry' | 'force') => void;
onCompaction?: (trace: CompactionTrace) => void;
toolOutputStore?: ToolOutputStore;
reinjection?: {
permissionMode?: import('../types').AIPermissionMode;
sessionScopeSummary?: string;
sessionStateText?: string;
};
}
export interface CompactCattyMessagesResult {
messages: ModelMessage[];
trace?: CompactionTrace;
summary?: string;
}
export function buildCompactionFailureArchiveNotice(
archiveHandleId: string | undefined,
sourceTruncated: boolean,
): string | undefined {
return archiveHandleId
? `Compaction summary failed. Earlier ${sourceTruncated ? 'bounded' : 'exact'} conversation remains available at tool output handle ${archiveHandleId}; search/read it before guessing missing details.`
: undefined;
}
export async function compactCattyMessages(
input: CompactCattyMessagesInput,
): Promise<CompactCattyMessagesResult> {
const contextWindow = resolveContextWindow({
provider: input.provider,
modelId: input.modelId,
});
const maxOutputTokens = input.maxOutputTokens
?? input.provider?.advancedParams?.maxTokens
?? DEFAULT_MAX_OUTPUT_TOKENS;
const providerId = input.provider?.providerId;
let archiveHandleId: string | undefined;
let archiveSourceTruncated = false;
let artifactHandleId: string | undefined;
let archiveChars: number | undefined;
let twoPassCacheHit = false;
let twoPassPrefixMessages: number | undefined;
let compactionSummary: string | undefined;
const reservedTokens = input.reservedTokens?.() ?? 0;
const threshold = computeCompactionThreshold({ contextWindow, maxOutputTokens });
const estimatedInput = computeTotalInputTokens({
messages: input.messages,
providerId,
reservedTokens,
});
const prewarmThreshold = Math.max(1, threshold - Math.ceil(contextWindow * 0.1));
if (
!input.force
&& input.trigger !== '413-retry'
&& estimatedInput >= prewarmThreshold
&& estimatedInput < threshold
&& input.chatSessionId
&& input.modelId
) {
globalTwoPassCompactionCache.start(
input.chatSessionId,
input.modelId,
input.messages,
async prefix => {
const result = await generateText({
model: input.model,
instructions: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Create the first-pass note for this stable earlier prefix. Preserve exact decisions, paths, commands, errors, and unfinished work:\n\n${formatMessagesForCompaction(prefix)}`,
}],
abortSignal: input.abortSignal,
maxOutputTokens: COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
temperature: 0,
timeout: buildCattyCompactionTimeout(),
});
return result.text;
},
);
}
const summarize = async (messagesToSummarize: ModelMessage[]) => {
const summarizeTrigger = input.trigger === '413-retry' || input.compressForRequestTooLargeRetry
? '413-retry'
: input.trigger === 'force' || input.force
? 'force'
: 'pre-turn';
input.onCompactionStart?.(summarizeTrigger);
const reserved = input.reservedTokens?.() ?? 0;
const compactionOutputTokens = resolveEffectiveMaxOutputTokens(
contextWindow,
COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
);
const availableForInput = Math.max(
1,
contextWindow - compactionOutputTokens - COMPACTION_PROMPT_RESERVE - reserved,
);
const pruned = pruneUntilFitsCompaction({
messages: messagesToSummarize,
availableForInput: Math.max(1, availableForInput),
providerId,
});
const cached = input.chatSessionId && input.modelId
? await globalTwoPassCompactionCache.consume(input.chatSessionId, input.modelId, pruned)
: undefined;
twoPassCacheHit = Boolean(cached);
twoPassPrefixMessages = cached?.prefixLength;
const formattedHistory = cached
? [
`[FIRST-PASS NOTE FOR ${cached.prefixLength} EARLIER MESSAGES]\n${cached.note}`,
`[REMAINING EXACT MESSAGES]\n${formatMessagesForCompaction(pruned.slice(cached.prefixLength))}`,
].join('\n\n')
: formatMessagesForCompaction(pruned);
// Archive the untouched turn input. messagesToSummarize has already passed
// through integrity repair and stale-result pruning, so it is not an exact
// recovery source even though it is the right input for the summary model.
const exactFormattedHistory = formatMessagesForCompaction(input.messages);
archiveChars = exactFormattedHistory.length;
if (input.toolOutputStore && input.chatSessionId) {
const archive = storeCompactionArchive(
input.toolOutputStore,
input.chatSessionId,
exactFormattedHistory,
);
archiveHandleId = archive.id;
archiveSourceTruncated = archive.sourceTruncated;
}
const result = await generateText({
model: input.model,
instructions: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formattedHistory}`,
}],
abortSignal: input.abortSignal,
maxOutputTokens: COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS,
temperature: 0,
timeout: buildCattyCompactionTimeout(),
});
if (input.toolOutputStore && input.chatSessionId) {
artifactHandleId = storeCompactionArtifact(
input.toolOutputStore,
input.chatSessionId,
{
trigger: summarizeTrigger,
modelId: input.modelId,
archiveHandleId,
formattedHistory,
summary: result.text,
},
).id;
}
const archiveNotice = archiveHandleId
? `\n\n[${archiveSourceTruncated ? 'Bounded conversation snapshot (source exceeded the local archive cap)' : 'Exact conversation snapshot'} archived locally: handleId=${archiveHandleId}. Use tool_output_read search/range only when the summary lacks a needed exact detail.]`
: '';
compactionSummary = `${result.text}${archiveNotice}`;
return compactionSummary;
};
const trigger = input.trigger ?? (input.force ? 'force' : 'pre-turn');
try {
const prepared = await prepareTurnContext({
messages: input.messages,
backend: 'catty',
contextWindow,
reservedTokens: input.reservedTokens?.() ?? 0,
maxOutputTokens,
trigger,
force: input.force,
compressForRequestTooLargeRetry: input.compressForRequestTooLargeRetry,
protectRecentMessages: input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES,
summarize,
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
onEvent: undefined,
reinjection: {
...input.reinjection,
userGoal: extractLatestUserGoal(input.messages),
},
providerId,
});
const trace = prepared.trace ? {
...prepared.trace,
archiveHandleId,
artifactHandleId,
archiveChars,
twoPassCacheHit,
twoPassPrefixMessages,
} : undefined;
if (trace) input.onCompaction?.(trace);
return { messages: prepared.messages, trace, summary: compactionSummary };
} catch (err) {
if (input.abortSignal.aborted) throw err;
console.warn('[Harness] Context compaction failed; falling back to recent messages only:', err);
const fallback = await prepareTurnContext({
messages: input.messages,
backend: 'catty',
contextWindow,
trigger: 'force',
force: true,
compressForRequestTooLargeRetry: input.compressForRequestTooLargeRetry,
protectRecentMessages: input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES,
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
onEvent: undefined,
reinjection: {
...input.reinjection,
sessionScopeSummary: [
input.reinjection?.sessionScopeSummary,
buildCompactionFailureArchiveNotice(archiveHandleId, archiveSourceTruncated),
].filter(Boolean).join('\n') || undefined,
userGoal: extractLatestUserGoal(input.messages),
},
});
const trace = fallback.trace ? {
...fallback.trace,
archiveHandleId,
artifactHandleId,
archiveChars,
twoPassCacheHit,
twoPassPrefixMessages,
} : undefined;
if (trace) input.onCompaction?.(trace);
return { messages: fallback.messages, trace, summary: compactionSummary };
}
}
export function prepareCattyMessagesForStream(
messages: ModelMessage[],
options: { preserveReasoning?: boolean } = {},
): ModelMessage[] {
return pruneMessages({
messages,
reasoning: options.preserveReasoning ? 'none' : 'all',
emptyMessages: 'remove',
});
}

View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
import type { NetcattyBridge } from '../cattyAgent/executor';
import type { ExecutorContext } from '../cattyAgent/executor';
import type { AIPermissionMode, WebSearchConfig } from '../types';
import type { ToolOutputStore } from './toolOutputStore';
import type { ToolResultDedup } from './toolResultDedup';
import type { CompactionTrace } from './types';
import type { AgentKind } from '../agentKinds';
import type { PromptContextSnapshot } from './promptContextSnapshot';
export const cattyRuntimeContextSchema = z.object({
chatSessionId: z.string(),
turnId: z.string(),
agentKind: z.enum(['sidebar', 'global']),
providerId: z.string().optional(),
modelId: z.string().optional(),
userGoal: z.string().optional(),
permissionMode: z.enum(['observer', 'confirm', 'auto']),
scopeType: z.enum(['terminal', 'workspace']),
scopeLabel: z.string().optional(),
lastCompaction: z.custom<CompactionTrace>().optional(),
lastStepAdjusted: z.boolean().optional(),
promptContext: z.custom<PromptContextSnapshot>().optional(),
});
export type CattyRuntimeContext = z.infer<typeof cattyRuntimeContextSchema>;
export const cattyToolContextSchema = z.object({
bridge: z.custom<NetcattyBridge>(),
chatSessionId: z.string().optional(),
permissionMode: z.enum(['observer', 'confirm', 'auto']),
commandBlocklist: z.array(z.string()).optional(),
webSearchConfig: z.custom<WebSearchConfig>().optional(),
getExecutorContext: z.custom<() => ExecutorContext>(),
toolOutputStore: z.custom<ToolOutputStore>().optional(),
toolResultDedup: z.custom<ToolResultDedup>().optional(),
});
export type CattyToolContext = z.infer<typeof cattyToolContextSchema>;
export function createInitialCattyRuntimeContext(input: {
chatSessionId: string;
turnId: string;
agentKind?: AgentKind;
providerId?: string;
modelId?: string;
userGoal?: string;
permissionMode: AIPermissionMode;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
promptContext?: PromptContextSnapshot;
}): CattyRuntimeContext {
return {
chatSessionId: input.chatSessionId,
turnId: input.turnId,
agentKind: input.agentKind ?? 'sidebar',
providerId: input.providerId,
modelId: input.modelId,
userGoal: input.userGoal,
permissionMode: input.permissionMode,
scopeType: input.scopeType,
scopeLabel: input.scopeLabel,
promptContext: input.promptContext,
};
}
export function toolDepsFromContext(context: CattyToolContext): import('../shared/toolExecutors').ToolDeps {
return {
bridge: context.bridge,
context: context.getExecutorContext,
commandBlocklist: context.commandBlocklist,
permissionMode: context.permissionMode,
webSearchConfig: context.webSearchConfig,
chatSessionId: context.chatSessionId,
};
}

View File

@@ -0,0 +1,113 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import { processCattyStream, shouldEmitAgentEventsForStreamChunk } from './turnDrivers/cattyStreamProcessor';
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext';
import type { ChatMessage } from '../types';
describe('shouldEmitAgentEventsForStreamChunk', () => {
it('suppresses trace events for SDK internal stream-state errors', () => {
assert.equal(
shouldEmitAgentEventsForStreamChunk({
type: 'error',
error: new Error('reasoning part abc not found'),
}),
false,
);
});
it('still emits trace events for real stream errors', () => {
assert.equal(
shouldEmitAgentEventsForStreamChunk({
type: 'error',
error: new Error('Provider returned HTTP 500'),
}),
true,
);
assert.equal(
shouldEmitAgentEventsForStreamChunk({ type: 'text-delta', text: 'hi' }),
true,
);
});
});
describe('processCattyStream reasoning continuation', () => {
it('persists reasoning encrypted content delivered on reasoning-end', async () => {
const model = new MockLanguageModelV4({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start', warnings: [] },
{
type: 'reasoning-start',
id: 'r1',
providerMetadata: { openai: { itemId: 'rs_1' } },
},
{
type: 'reasoning-delta',
id: 'r1',
delta: 'thinking',
providerMetadata: { openai: { itemId: 'rs_1' } },
},
{
type: 'reasoning-end',
id: 'r1',
providerMetadata: {
openai: { itemId: 'rs_1', reasoningEncryptedContent: 'enc-abc' },
},
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: undefined },
usage: {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
},
},
],
}),
}),
});
const messages = new Map<string, ChatMessage>();
messages.set('assistant-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: 0,
});
const ui = {
addMessageToSession: (sessionId: string, message: ChatMessage) => {
messages.set(message.id, message);
},
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
const message = messages.get(messageId);
if (message) messages.set(messageId, updater(message));
},
};
await processCattyStream({
streamSessionId: 'session-1',
model,
systemPrompt: 'test',
toolsBundle: { tools: {}, toolsContext: {} },
sdkMessages: [{ role: 'user', content: 'hello' }],
signal: new AbortController().signal,
currentAssistantMsgId: 'assistant-1',
maxIterations: 1,
runtimeContext: createInitialCattyRuntimeContext({
chatSessionId: 'session-1',
turnId: 'turn-1',
permissionMode: 'auto',
scopeType: 'terminal',
}),
ui,
});
const continuation = messages.get('assistant-1')?.providerContinuation;
const encryptedContent = continuation?.reasoningParts?.at(-1)?.providerOptions?.openai
?.reasoningEncryptedContent;
assert.equal(encryptedContent, 'enc-abc');
assert.match(continuation?.reasoningParts?.map(part => part.text).join('') ?? '', /thinking/);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildCattyToolApproval } from './cattyToolApproval';
describe('buildCattyToolApproval', () => {
it('auto-approves read-only tools', async () => {
const approval = buildCattyToolApproval({ permissionMode: 'confirm', chatSessionId: 'chat-1' });
const result = await approval({
toolCall: {
toolCallId: 'call-1',
toolName: 'terminal_read_context',
input: { range: 'viewport' },
},
} as Parameters<typeof approval>[0]);
assert.equal(result, undefined);
});
it('allows observer-bypass write tools in observer mode', async () => {
const approval = buildCattyToolApproval({ permissionMode: 'observer', chatSessionId: 'chat-1' });
const result = await approval({
toolCall: {
toolCallId: 'call-stop',
toolName: 'terminal_stop',
input: { jobId: 'job-1' },
},
} as Parameters<typeof approval>[0]);
assert.equal(result, undefined);
});
it('denies write tools in observer mode', async () => {
const approval = buildCattyToolApproval({ permissionMode: 'observer', chatSessionId: 'chat-1' });
const result = await approval({
toolCall: {
toolCallId: 'call-2',
toolName: 'sftp_write_file',
input: { path: '/tmp/x', content: 'hi' },
},
} as Parameters<typeof approval>[0]);
assert.deepEqual(result, {
type: 'denied',
reason: 'Observer mode blocks write operations.',
});
});
it('auto-approves write tools in auto mode', async () => {
const approval = buildCattyToolApproval({ permissionMode: 'auto', chatSessionId: 'chat-1' });
const result = await approval({
toolCall: {
toolCallId: 'call-3',
toolName: 'sftp_write_file',
input: { path: '/tmp/x', content: 'hi' },
},
} as Parameters<typeof approval>[0]);
assert.equal(result, undefined);
});
it('awaits user approval in confirm mode for write tools', async () => {
let approvalRequested = false;
const approval = buildCattyToolApproval({
permissionMode: 'confirm',
chatSessionId: 'chat-1',
requestApproval: async (toolCallId, toolName) => {
approvalRequested = true;
assert.equal(toolCallId, 'call-4');
assert.equal(toolName, 'sftp_write_file');
return true;
},
});
const result = await approval({
toolCall: {
toolCallId: 'call-4',
toolName: 'sftp_write_file',
input: { path: '/tmp/x', content: 'hi' },
},
} as Parameters<typeof approval>[0]);
assert.equal(approvalRequested, true);
assert.deepEqual(result, { type: 'approved' });
});
it('returns denied when confirm-mode approval is rejected', async () => {
const approval = buildCattyToolApproval({
permissionMode: 'confirm',
chatSessionId: 'chat-1',
requestApproval: async () => false,
});
const result = await approval({
toolCall: {
toolCallId: 'call-5',
toolName: 'sftp_write_file',
input: { path: '/tmp/x', content: 'hi' },
},
} as Parameters<typeof approval>[0]);
assert.deepEqual(result, {
type: 'denied',
reason: 'User denied tool execution.',
});
});
});

View File

@@ -0,0 +1,67 @@
import type { ToolApprovalConfiguration } from 'ai';
import type { AIPermissionMode } from '../types';
import { requestApproval as defaultRequestApproval } from '../shared/approvalGate';
import { resolveCapabilityId } from './permissionGrants';
import cattyToolSpecs from './generated/cattyToolSpecs.json';
type CattyToolPolicySpec = {
toolName: string;
capabilityId: string;
policy: {
write: boolean;
bypassesApproval: boolean;
bypassesObserverBlock?: boolean;
};
};
const policyByToolName = new Map<string, CattyToolPolicySpec>(
(cattyToolSpecs as CattyToolPolicySpec[]).map((spec) => [spec.toolName, spec]),
);
function needsUserApproval(
toolName: string,
permissionMode: AIPermissionMode,
): boolean {
if (permissionMode !== 'confirm') return false;
const spec = policyByToolName.get(toolName);
if (!spec) return false;
return spec.policy.write && !spec.policy.bypassesApproval;
}
export function buildCattyToolApproval(input: {
permissionMode: AIPermissionMode;
chatSessionId?: string;
requestApproval?: typeof defaultRequestApproval;
}): ToolApprovalConfiguration<Record<string, never>, import('./cattyRuntimeContext').CattyRuntimeContext> {
const { permissionMode, chatSessionId, requestApproval = defaultRequestApproval } = input;
return async ({ toolCall }) => {
const spec = policyByToolName.get(toolCall.toolName);
if (!spec?.policy.write) {
return undefined;
}
if (permissionMode === 'observer' && !spec.policy.bypassesObserverBlock) {
return { type: 'denied' as const, reason: 'Observer mode blocks write operations.' };
}
if (!needsUserApproval(toolCall.toolName, permissionMode)) {
return undefined;
}
const args = (toolCall.input ?? {}) as Record<string, unknown>;
const approved = await requestApproval(
toolCall.toolCallId,
toolCall.toolName,
args,
chatSessionId,
undefined,
spec.capabilityId ?? resolveCapabilityId(toolCall.toolName),
);
if (approved) {
return { type: 'approved' as const };
}
return { type: 'denied' as const, reason: 'User denied tool execution.' };
};
}

View File

@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CattyTurnDriver } from './turnDrivers/cattyTurnDriver';
import type { TurnDriverContext, TurnInput } from './turnDrivers/types';
const mcpServerBridge = await import('../../../electron/bridges/mcpServerBridge.cjs');
function createTurnContext(): TurnDriverContext {
return {
turnId: 'turn-1',
chatSessionId: 'chat-1',
sessionId: 'chat-1',
backend: 'catty',
signal: new AbortController().signal,
emit: () => {},
toolOutputStore: {
store: () => ({ id: 'handle-1', contentLength: 0 }),
read: () => null,
clearSession: () => {},
},
toolResultDedup: {
fingerprintFor: () => 'fingerprint',
check: () => null,
remember: () => {},
buildCachedNotice: () => ({}),
clearTurn: () => {},
},
sessionStateStore: {
mergeFromUserGoal: () => {},
toReinjectionText: () => undefined,
get: () => ({ decisions: [], activeHosts: {}, blockers: [], updatedAt: Date.now() }),
clear: () => {},
updateFromToolResult: () => {},
mergeFromAssistantContent: () => {},
},
} as TurnDriverContext;
}
test('Catty turn registers current message file attachments for attachment tools', async (t) => {
mcpServerBridge.cleanup();
const csvText = 'label,hostname,username\nprod,prod.example.com,root\n';
const bridge = {
aiSetChatSessionCancelled: async () => ({ ok: true }),
aiMcpUpdateSessions: async () => undefined,
aiMcpUpdateAttachments: async (
attachments: Array<{ base64Data?: string; mediaType?: string; filename?: string; filePath?: string }>,
chatSessionId?: string,
) => {
mcpServerBridge.updateAttachmentMetadata(attachments, chatSessionId);
return { ok: true };
},
};
t.after(() => mcpServerBridge.cleanup());
const controller = new AbortController();
const input: TurnInput = {
backend: 'catty',
chatSessionId: 'chat-1',
sendScopeKey: 'chat-1',
userText: '把这些主机都导入到 vault',
signal: controller.signal,
currentSession: undefined,
assistantMsgId: 'assistant-1',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
attachments: [{
filename: 'hosts_export_2026-06-25.csv',
mediaType: 'text/csv',
base64Data: Buffer.from(csvText).toString('base64'),
filePath: '/tmp/hosts_export_2026-06-25.csv',
}],
maxIterations: 5,
bridge,
ui: {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
},
};
await new CattyTurnDriver().run(input, createTurnContext());
const listed = mcpServerBridge.handleListAttachments({ chatSessionId: 'chat-1' });
assert.equal(listed.ok, true);
assert.deepEqual(listed.attachments, [{
filename: 'hosts_export_2026-06-25.csv',
mediaType: 'text/csv',
filePath: '/tmp/hosts_export_2026-06-25.csv',
sizeBytes: Buffer.byteLength(csvText),
}]);
const read = mcpServerBridge.handleReadAttachment({
chatSessionId: 'chat-1',
filename: 'hosts_export_2026-06-25.csv',
});
assert.equal(read.ok, true);
assert.equal(read.text, csvText);
});

View File

@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { MockLanguageModelV4 } from 'ai/test';
import { ToolOutputStore } from './toolOutputStore';
import { storeCompactionArchive, storeCompactionArtifact } from './compactionArtifacts';
import { buildCompactionFailureArchiveNotice, compactCattyMessages } from './cattyRuntime';
test('compaction artifacts retain exact searchable history and summary output', () => {
const store = new ToolOutputStore();
const archive = storeCompactionArchive(store, 'chat-1', 'exact E_CONN_RESET_7319 evidence');
const artifact = storeCompactionArtifact(store, 'chat-1', {
trigger: '413-retry',
modelId: 'model-1',
archiveHandleId: archive.id,
formattedHistory: 'exact E_CONN_RESET_7319 evidence',
summary: 'network failure found',
});
assert.match(store.read({ handleId: archive.id, mode: 'search', query: 'E_CONN_RESET_7319' }, 'chat-1') ?? '', /E_CONN_RESET_7319/);
assert.match(store.read({ handleId: artifact.id, mode: 'full' }, 'chat-1') ?? '', /network failure found/);
});
test('compaction failure notice keeps the newly created archive discoverable', () => {
assert.match(
buildCompactionFailureArchiveNotice('tool-output-archive', false) ?? '',
/tool-output-archive/,
);
assert.equal(buildCompactionFailureArchiveNotice(undefined, false), undefined);
});
test('compaction archive preserves tool evidence from before stale-result pruning', async () => {
const evidence = 'UNIQUE_OLD_TOOL_EVIDENCE_7319';
const messages: ModelMessage[] = [
{ role: 'user', content: 'inspect the old failure' },
{
role: 'assistant',
content: [{
type: 'tool-call',
toolCallId: 'old-call',
toolName: 'terminal_execute',
input: { sessionId: 'session-1', command: 'inspect failure' },
}],
},
{
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'old-call',
toolName: 'terminal_execute',
output: { type: 'text', value: evidence },
}],
},
...Array.from({ length: 24 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: `later message ${index}`,
} as ModelMessage)),
];
const store = new ToolOutputStore();
const model = new MockLanguageModelV4({
doGenerate: async () => ({
content: [{ type: 'text', text: 'summary' }],
finishReason: { unified: 'stop', raw: undefined },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 2, text: 2, reasoning: undefined },
},
warnings: [],
}),
});
const result = await compactCattyMessages({
messages,
sessionId: 'chat-archive',
chatSessionId: 'chat-archive',
model,
abortSignal: new AbortController().signal,
force: true,
trigger: 'force',
toolOutputStore: store,
});
const handleId = result.trace?.archiveHandleId;
assert.ok(handleId);
assert.match(
store.read({ handleId, mode: 'search', query: evidence }, 'chat-archive') ?? '',
new RegExp(evidence),
);
});

View File

@@ -0,0 +1,37 @@
import type { ToolOutputStore, ToolOutputHandle } from './toolOutputStore';
export function storeCompactionArchive(
store: ToolOutputStore,
chatSessionId: string,
formattedHistory: string,
): ToolOutputHandle {
return store.store({
chatSessionId,
capabilityId: 'conversation.archive',
content: formattedHistory,
});
}
export function storeCompactionArtifact(
store: ToolOutputStore,
chatSessionId: string,
input: {
trigger: string;
modelId?: string | null;
archiveHandleId?: string;
formattedHistory: string;
summary: string;
},
): ToolOutputHandle {
return store.store({
chatSessionId,
capabilityId: 'compaction.artifact',
content: [
`trigger: ${input.trigger}`,
`model: ${input.modelId ?? 'unknown'}`,
`archiveHandleId: ${input.archiveHandleId ?? 'unavailable'}`,
`\n[compaction input]\n${input.formattedHistory}`,
`\n[compaction output]\n${input.summary}`,
].join('\n'),
});
}

View File

@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { pruneFirstModelMessage, pruneLastModelMessage, pruneUntilFitsCompaction } from './compactionPruner.ts';
test('pruneLastModelMessage removes trailing user and assistant pair', () => {
const messages: ModelMessage[] = [
{ role: 'user', content: 'old' },
{
role: 'assistant',
content: [{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'terminal_execute',
input: { command: 'pwd' },
}],
},
{
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'terminal_execute',
output: { type: 'text', value: '/tmp' },
}],
},
{ role: 'user', content: 'recent' },
{ role: 'assistant', content: 'acknowledged' },
];
const pruned = pruneLastModelMessage(messages);
assert.equal(pruned.length, 3);
assert.equal(pruned[0]?.content, 'old');
assert.equal(pruned.at(-1)?.role, 'tool');
});
test('message pruning removes complete parallel tool-result batches', () => {
const calls = ['a', 'b', 'c'].map((toolCallId) => ({
type: 'tool-call' as const,
toolCallId,
toolName: 'terminal_poll',
input: { jobId: toolCallId },
}));
const results = calls.map((call) => ({
role: 'tool' as const,
content: [{
type: 'tool-result' as const,
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'text' as const, value: `result ${call.toolCallId}` },
}],
}));
const batch: ModelMessage[] = [
{ role: 'assistant', content: calls },
...results,
];
assert.deepEqual(pruneFirstModelMessage([...batch, { role: 'user', content: 'next' }]), [
{ role: 'user', content: 'next' },
]);
assert.deepEqual(pruneLastModelMessage([{ role: 'user', content: 'before' }, ...batch]), [
{ role: 'user', content: 'before' },
]);
});
test('pruneUntilFitsCompaction shrinks history to fit budget', () => {
const messages: ModelMessage[] = Array.from({ length: 20 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: 'word '.repeat(500),
})) as ModelMessage[];
const pruned = pruneUntilFitsCompaction({
messages,
availableForInput: 2_000,
providerId: 'openai',
});
assert.ok(pruned.length < messages.length);
});
test('pruneUntilFitsCompaction drops oldest messages first', () => {
const messages: ModelMessage[] = [
{ role: 'user', content: `oldest ${'x'.repeat(5_000)}` },
...Array.from({ length: 16 }, (_, index) => ({
role: index % 2 === 0 ? 'assistant' : 'user',
content: 'middle context',
})) as ModelMessage[],
{ role: 'user', content: 'newest goal for current task' },
{ role: 'assistant', content: 'latest reply before tail split' },
];
const pruned = pruneUntilFitsCompaction({
messages,
availableForInput: 800,
providerId: 'openai',
});
const serialized = JSON.stringify(pruned);
assert.match(serialized, /newest goal for current task/);
assert.doesNotMatch(serialized, /oldest/);
});

View File

@@ -0,0 +1,107 @@
import type { ModelMessage } from 'ai';
import { estimateModelMessagesTokensWithKind } from './tokenEstimator';
import { COMPACTION_PROMPT_RESERVE } from './contextBudget';
function endsWithToolCall(message: ModelMessage | undefined): boolean {
if (!message || message.role !== 'assistant' || !Array.isArray(message.content)) return false;
return message.content.some((part) => {
return part && typeof part === 'object' && (part as { type?: string }).type === 'tool-call';
});
}
function startsWithToolResult(message: ModelMessage | undefined): boolean {
if (!message || message.role !== 'tool') return false;
if (!Array.isArray(message.content)) return true;
return message.content.some((part) => {
return part && typeof part === 'object' && (part as { type?: string }).type === 'tool-result';
});
}
function skipToolResultsForward(messages: ModelMessage[], startIndex: number): number {
let index = startIndex;
while (index < messages.length && startsWithToolResult(messages[index])) index += 1;
return index;
}
function findToolResultsStart(messages: ModelMessage[]): number {
let index = messages.length;
while (index > 0 && startsWithToolResult(messages[index - 1])) index -= 1;
return index;
}
/** Prune from the tail while preserving valid tool-call/tool-result pairing. */
export function pruneLastModelMessage(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return messages;
if (messages.length === 1) return [];
const trailingToolStart = findToolResultsStart(messages);
if (trailingToolStart < messages.length) {
const preceding = messages[trailingToolStart - 1];
return preceding?.role === 'assistant' && endsWithToolCall(preceding)
? messages.slice(0, trailingToolStart - 1)
: messages.slice(0, trailingToolStart);
}
const secondToLastIndex = messages.length - 2;
const secondToLast = messages[secondToLastIndex];
if (secondToLast.role === 'assistant' && endsWithToolCall(secondToLast)) {
return messages.slice(0, -2);
}
if (secondToLast.role === 'user') {
return messages.slice(0, -2);
}
return messages.slice(0, -1);
}
/** Prune from the head while preserving valid tool-call/tool-result pairing. */
export function pruneFirstModelMessage(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return messages;
if (messages.length === 1) return [];
const first = messages[0];
const second = messages[1];
if (first.role === 'assistant' && endsWithToolCall(first)) {
return messages.slice(skipToolResultsForward(messages, 1));
}
if (first.role === 'user' && second?.role === 'assistant' && endsWithToolCall(second)) {
return messages.slice(skipToolResultsForward(messages, 2));
}
if (first.role === 'user' && second?.role === 'assistant') {
return messages.slice(2);
}
if (startsWithToolResult(first)) {
return messages.slice(skipToolResultsForward(messages, 0));
}
return messages.slice(1);
}
export function countMessagesTokens(messages: ModelMessage[], providerId?: string | null): number {
return estimateModelMessagesTokensWithKind({ messages, providerId }).tokens;
}
export interface PruneUntilFitsCompactionInput {
messages: ModelMessage[];
availableForInput: number;
providerId?: string | null;
compactionPromptTokens?: number;
}
export function pruneUntilFitsCompaction(input: PruneUntilFitsCompactionInput): ModelMessage[] {
const reserve = input.compactionPromptTokens ?? COMPACTION_PROMPT_RESERVE;
let working = input.messages;
while (working.length > 0) {
const tokens = countMessagesTokens(working, input.providerId) + reserve;
if (tokens <= input.availableForInput) {
return working;
}
const pruned = pruneFirstModelMessage(working);
if (pruned.length === working.length) break;
working = pruned;
}
return working;
}

View File

@@ -0,0 +1,9 @@
/** i18n keys for compaction status — resolved in useAgentCompactionUi. */
export const CATTY_COMPACTION_STATUS_KEYS = {
preTurn: 'ai.chat.compactingContext',
step: 'ai.chat.compactingStep',
retry: 'ai.chat.compactionRetry',
} as const;
export type CattyCompactionStatusKey =
typeof CATTY_COMPACTION_STATUS_KEYS[keyof typeof CATTY_COMPACTION_STATUS_KEYS];

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
computeCompactionBuffer,
computeCompactionThreshold,
computeTotalInputTokens,
DEFAULT_MAX_OUTPUT_TOKENS,
shouldCompactByBudget,
} from './contextBudget.ts';
import type { ModelMessage } from 'ai';
test('computeCompactionThreshold reserves output and buffer', () => {
const threshold = computeCompactionThreshold({
contextWindow: 128_000,
maxOutputTokens: 4096,
});
const buffer = computeCompactionBuffer(128_000, 4096);
assert.equal(threshold, 128_000 - 4096 - buffer - 150);
assert.ok(threshold < 128_000 * 0.85);
});
test('shouldCompactByBudget triggers when total input exceeds threshold', () => {
const messages: ModelMessage[] = [{ role: 'user', content: 'x'.repeat(400_000) }];
assert.equal(shouldCompactByBudget({
messages,
contextWindow: 128_000,
maxOutputTokens: 4096,
providerId: 'openai',
}), true);
});
test('computeTotalInputTokens includes system and tool names', () => {
const messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
const withExtras = computeTotalInputTokens({
messages,
systemPrompt: 'system prompt',
toolNames: ['terminal_execute', 'sftp_read'],
providerId: 'anthropic',
});
const base = computeTotalInputTokens({ messages, providerId: 'anthropic' });
assert.ok(withExtras > base);
});
test('computeCompactionThreshold keeps a reasonable threshold for small context windows', () => {
const threshold8k = computeCompactionThreshold({
contextWindow: 8_192,
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
});
assert.ok(threshold8k > 1_000);
assert.ok(threshold8k < 8_192 * 0.85);
const threshold4k = computeCompactionThreshold({
contextWindow: 4_096,
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
});
assert.ok(threshold4k > 500);
assert.ok(threshold4k < 4_096 * 0.85);
});

View File

@@ -0,0 +1,98 @@
import type { ModelMessage } from 'ai';
import {
estimateModelMessagesTokensWithKind,
estimateTextTokens,
estimateUnknownTokens,
} from './tokenEstimator';
export const COMPACTION_PROMPT_RESERVE = 150;
export const AUTO_COMPACT_BUFFER_CAP = 15_000;
export const AUTO_COMPACT_BUFFER_RATIO = 0.8;
export const COMPACTION_SUMMARY_MAX_OUTPUT_TOKENS = 1600;
export const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
const MIN_OUTPUT_RESERVE = 256;
const MAX_OUTPUT_SHARE_OF_WINDOW = 0.25;
export function resolveEffectiveMaxOutputTokens(
contextWindow: number,
maxOutputTokens: number = DEFAULT_MAX_OUTPUT_TOKENS,
): number {
if (contextWindow <= 0) return maxOutputTokens;
const cappedByWindow = Math.max(
MIN_OUTPUT_RESERVE,
Math.floor(contextWindow * MAX_OUTPUT_SHARE_OF_WINDOW),
);
return Math.min(maxOutputTokens, cappedByWindow);
}
export interface ComputeCompactionThresholdInput {
contextWindow: number;
maxOutputTokens?: number;
compactionPromptTokens?: number;
}
export function computeCompactionBuffer(contextWindow: number, maxOutputTokens: number): number {
const remaining = Math.max(0, contextWindow - maxOutputTokens);
const ratioCompactionBuffer = Math.ceil((1 - AUTO_COMPACT_BUFFER_RATIO) * remaining);
const safeCompactionBuffer = Math.max(maxOutputTokens, ratioCompactionBuffer);
return Math.min(safeCompactionBuffer, AUTO_COMPACT_BUFFER_CAP);
}
/** Continue-style threshold: compact before the next turn would exceed the window. */
export function computeCompactionThreshold(input: ComputeCompactionThresholdInput): number {
const maxOutputTokens = resolveEffectiveMaxOutputTokens(
input.contextWindow,
input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
);
const compactionPromptTokens = input.compactionPromptTokens ?? COMPACTION_PROMPT_RESERVE;
const buffer = computeCompactionBuffer(input.contextWindow, maxOutputTokens);
const threshold = input.contextWindow - maxOutputTokens - buffer - compactionPromptTokens;
return Math.max(1, threshold);
}
export interface ComputeTotalInputTokensInput {
messages: ModelMessage[];
providerId?: string | null;
systemPrompt?: string;
toolNames?: string[];
reservedTokens?: number;
}
export function computeTotalInputTokens(input: ComputeTotalInputTokensInput): number {
const messageTokens = estimateModelMessagesTokensWithKind({
messages: input.messages,
providerId: input.providerId,
}).tokens;
const systemTokens = input.systemPrompt
? estimateTextTokens(input.systemPrompt, input.providerId)
: 0;
const toolTokens = input.toolNames?.length
? estimateUnknownTokens({ tools: input.toolNames }, input.providerId)
: 0;
const reserved = Math.max(0, Math.ceil(input.reservedTokens ?? 0));
return messageTokens + systemTokens + toolTokens + reserved;
}
export function shouldCompactByBudget(input: {
messages: ModelMessage[];
contextWindow: number;
maxOutputTokens?: number;
providerId?: string | null;
systemPrompt?: string;
toolNames?: string[];
reservedTokens?: number;
forceThreshold?: number;
}): boolean {
const total = computeTotalInputTokens({
messages: input.messages,
providerId: input.providerId,
systemPrompt: input.systemPrompt,
toolNames: input.toolNames,
reservedTokens: input.reservedTokens,
});
const threshold = input.forceThreshold ?? computeCompactionThreshold({
contextWindow: input.contextWindow,
maxOutputTokens: input.maxOutputTokens,
});
return total >= threshold;
}

View File

@@ -0,0 +1,351 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { ModelMessage } from 'ai';
import { prepareTurnContext, prepareStepContext } from './contextManager.ts';
import { TraceStore } from './traceStore.ts';
import { ToolOutputStore } from './toolOutputStore.ts';
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext.ts';
test('normal turn and step preparation preserve exact user literals alongside noisy tool output', async () => {
const literal = "printf '%s\\n' 'CANONICAL_GUI_" + 'x'.repeat(1200) + "_END'";
const content = `Execute exactly: ${literal}\n\n\n\n${'keep this line\n'.repeat(5)}`;
for (const userContent of [content, [{ type: 'text' as const, text: content }]]) {
const messages: ModelMessage[] = [
{ role: 'user', content: userContent },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'noise', toolName: 'terminal_execute', input: {} }] },
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'noise', toolName: 'terminal_execute', output: { type: 'text', value: 'log '.repeat(10000) } }] },
];
const turn = await prepareTurnContext({ messages, backend: 'catty', contextWindow: 1_300_000, trigger: 'pre-turn' });
const step = await prepareStepContext({ messages: turn.messages, contextWindow: 1_300_000, stepNumber: 0 });
for (const prepared of [turn, step]) {
assert.deepEqual(prepared.messages.find(message => message.role === 'user')?.content, userContent);
assert.ok(JSON.stringify(prepared.messages).length < JSON.stringify(messages).length);
}
}
});
test('prepareTurnContext applies typed compression before LLM summarize threshold', async () => {
const longOutput = 'line\n'.repeat(20_000);
const messages: ModelMessage[] = [
{
role: 'user',
content: 'Check nginx error logs on prod-web-01 and summarize failures.',
},
{
role: 'assistant',
content: [{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'terminal_execute',
input: { sessionId: 'sess-1', command: 'tail -n 500 /var/log/nginx/error.log' },
}],
},
{
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'terminal_execute',
output: { type: 'text', value: longOutput },
}],
},
{
role: 'assistant',
content: 'Found repeated upstream timeout errors.',
},
{
role: 'user',
content: 'Fix only the upstream timeout issue, do not restart nginx yet.',
},
];
const traces: string[] = [];
const prepared = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 128_000,
trigger: 'pre-turn',
sessionId: 'chat-1',
onEvent: (event) => {
if (event.type === 'compaction') traces.push(event.trace.trigger);
},
reinjection: {
permissionMode: 'confirm',
userGoal: 'Fix upstream timeout without restarting nginx.',
},
});
assert.ok(prepared.messages.length >= messages.length);
const serialized = JSON.stringify(prepared.messages);
assert.match(serialized, /Fix only the upstream timeout issue/);
assert.match(serialized, /Permission mode: confirm/);
assert.ok(serialized.length < JSON.stringify(messages).length);
});
test('prepareTurnContext skips reinjection when no compaction occurred', async () => {
const messages: ModelMessage[] = [
{ role: 'user', content: 'List running containers on prod-web-01.' },
{ role: 'assistant', content: 'I will check docker ps.' },
];
const events: string[] = [];
const prepared = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 128_000,
trigger: 'pre-turn',
sessionId: 'chat-no-compact',
onEvent: (event) => {
if (event.type === 'compaction') events.push(event.trace.trigger);
},
reinjection: {
permissionMode: 'confirm',
userGoal: 'List running containers on prod-web-01.',
},
});
assert.equal(prepared.didAdjust, false);
assert.equal(events.length, 0);
const serialized = JSON.stringify(prepared.messages);
assert.doesNotMatch(serialized, /Netcatty session context/);
assert.doesNotMatch(serialized, /Permission mode: confirm/);
});
test('prepareTurnContext force trigger retains recent user goal in replay', async () => {
const messages: ModelMessage[] = Array.from({ length: 40 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: index === 38
? 'SSH into db-01 and inspect /var/log/postgresql/postgresql.log for crash signatures.'
: `filler message ${index}`,
})) as ModelMessage[];
const prepared = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 128_000,
trigger: 'force',
force: true,
sessionId: 'chat-2',
});
const serialized = JSON.stringify(prepared.messages);
assert.match(serialized, /SSH into db-01/);
assert.match(serialized, /postgresql\.log/);
});
test('TraceStore records compaction events for export', async () => {
const store = new TraceStore();
await prepareTurnContext({
messages: [{ role: 'user', content: 'x'.repeat(500_000) }],
backend: 'catty',
contextWindow: 1000,
trigger: 'force',
force: true,
sessionId: 'chat-3',
onEvent: (event) => store.append(event),
});
const exported = store.exportTrace('chat-3');
assert.ok(exported.compactions.length >= 1);
assert.equal(exported.compactions[0]?.trigger, 'force');
});
test('TraceStore bounds compaction history independently from events', () => {
const store = new TraceStore(20, 3);
for (let index = 0; index < 10; index += 1) {
store.append({
id: `compaction-${index}`,
type: 'compaction',
sessionId: 'chat-bounded-compactions',
trace: { trigger: 'force' },
} as import('./types').AgentEvent);
}
assert.equal(store.getCompactions('chat-bounded-compactions').length, 3);
});
test('prepareStepContext replaces prior step handle notices under v7 carry-forward semantics', async () => {
const store = new ToolOutputStore();
store.store({
chatSessionId: 'chat-4',
capabilityId: 'sftp.read',
content: 'large payload',
});
const runtimeContext = createInitialCattyRuntimeContext({
chatSessionId: 'chat-4',
turnId: 'turn-1',
permissionMode: 'confirm',
scopeType: 'terminal',
});
const priorNotice: ModelMessage = {
role: 'user',
content: '[step 1] Tool output handles available: tool-output-old',
};
const prepared = await prepareStepContext({
messages: [priorNotice, { role: 'user', content: 'continue' }],
stepNumber: 2,
sessionId: 'chat-4',
chatSessionId: 'chat-4',
toolOutputStore: store,
runtimeContext,
});
const notices = prepared.messages.filter(
(message) => message.role === 'user'
&& typeof message.content === 'string'
&& message.content.includes('Tool output handles available'),
);
assert.equal(notices.length, 1);
assert.match(String(notices[0]?.content), /\[step 2\]/);
assert.doesNotMatch(String(notices[0]?.content), /tool-output-old/);
});
test('prepareStepContext emits step compaction trace when over budget', async () => {
const messages: ModelMessage[] = Array.from({ length: 30 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: 'payload '.repeat(2_000),
})) as ModelMessage[];
const events: string[] = [];
const prepared = await prepareStepContext({
messages,
stepNumber: 3,
sessionId: 'chat-5',
chatSessionId: 'chat-5',
contextWindow: 4_000,
reservedTokens: 500,
maxOutputTokens: 512,
providerId: 'anthropic',
runtimeContext: createInitialCattyRuntimeContext({
chatSessionId: 'chat-5',
turnId: 'turn-2',
permissionMode: 'confirm',
scopeType: 'terminal',
}),
onEvent: (event) => {
if (event.type === 'compaction') events.push(event.trace.trigger);
},
});
assert.equal(prepared.didAdjust, true);
assert.equal(prepared.trace?.trigger, 'step');
assert.ok(events.includes('step'));
});
test('prepareStepContext retains handle notice after step budget guard', async () => {
const store = new ToolOutputStore();
store.store({
chatSessionId: 'chat-handle',
capabilityId: 'sftp.read',
content: 'large payload',
});
const messages: ModelMessage[] = Array.from({ length: 30 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: 'payload '.repeat(2_000),
})) as ModelMessage[];
const prepared = await prepareStepContext({
messages,
stepNumber: 2,
sessionId: 'chat-handle',
chatSessionId: 'chat-handle',
contextWindow: 4_000,
reservedTokens: 500,
maxOutputTokens: 512,
toolOutputStore: store,
runtimeContext: createInitialCattyRuntimeContext({
chatSessionId: 'chat-handle',
turnId: 'turn-handle',
permissionMode: 'confirm',
scopeType: 'terminal',
}),
});
const notices = prepared.messages.filter(
(message) => message.role === 'user'
&& typeof message.content === 'string'
&& message.content.includes('Tool output handles available'),
);
assert.equal(notices.length, 1);
assert.match(String(notices[0]?.content), /\[step 2\]/);
});
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
const toolCallIds = ['a', 'b', 'c', 'd', 'e', 'f'];
const messages: ModelMessage[] = [
{
role: 'assistant',
content: toolCallIds.map((toolCallId) => ({
type: 'tool-call' as const,
toolCallId,
toolName: 'terminal_poll',
input: { jobId: toolCallId },
})),
},
...toolCallIds.map((toolCallId) => ({
role: 'tool' as const,
content: [{
type: 'tool-result' as const,
toolCallId,
toolName: 'terminal_poll',
output: { type: 'text' as const, value: `${toolCallId}:${'output '.repeat(4_000)}` },
}],
})),
{ role: 'user', content: 'summarize the completed jobs' },
];
const prepared = await prepareStepContext({
messages,
stepNumber: 4,
sessionId: 'chat-parallel',
chatSessionId: 'chat-parallel',
contextWindow: 8_000,
reservedTokens: 500,
maxOutputTokens: 512,
runtimeContext: createInitialCattyRuntimeContext({
chatSessionId: 'chat-parallel',
turnId: 'turn-parallel',
permissionMode: 'confirm',
scopeType: 'terminal',
}),
});
const knownCalls = new Set<string>();
for (const message of prepared.messages) {
if (message.role === 'assistant' && Array.isArray(message.content)) {
for (const part of message.content as Array<{ type?: string; toolCallId?: string }>) {
if (part.type === 'tool-call' && part.toolCallId) knownCalls.add(part.toolCallId);
}
}
if (message.role === 'tool' && Array.isArray(message.content)) {
for (const part of message.content as Array<{ type?: string; toolCallId?: string }>) {
if (part.type === 'tool-result') assert.equal(knownCalls.has(part.toolCallId ?? ''), true);
}
}
}
});
test('prepareTurnContext calls summarize when over dynamic threshold', async () => {
let summarizeCalls = 0;
const messages: ModelMessage[] = Array.from({ length: 24 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: 'history '.repeat(3_000),
})) as ModelMessage[];
const prepared = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 8_000,
maxOutputTokens: 512,
trigger: 'pre-turn',
sessionId: 'chat-6',
providerId: 'openai',
summarize: async () => {
summarizeCalls += 1;
return 'summary of earlier work';
},
});
assert.equal(summarizeCalls, 1);
assert.equal(prepared.trace?.didLlmSummarize, true);
assert.match(JSON.stringify(prepared.messages), /summary of earlier work/);
});

View File

@@ -0,0 +1,524 @@
import type { ModelMessage } from 'ai';
import type { ChatMessage, AIPermissionMode } from '../types';
import { isStepHandleNoticeMessage } from './agentEventAdapter';
import {
DEFAULT_CONTEXT_WINDOW_TOKENS,
DEFAULT_PROTECT_RECENT_MESSAGES,
findSafeCompactionSplitIndex,
keepRecentContextMessages,
prepareContextCompaction,
} from '../contextCompaction';
import { compressMessagesForRequestTooLargeRetry } from '../requestPayloadCompression';
import {
computeCompactionThreshold,
computeTotalInputTokens,
DEFAULT_MAX_OUTPUT_TOKENS,
shouldCompactByBudget,
} from './contextBudget';
import { estimateModelMessagesTokensWithKind } from './tokenEstimator';
import { pruneStaleToolContext } from './staleContextPruner';
import type { PrepareStepContextInput } from './turnDrivers/types';
import type {
AgentEventListener,
CompactionTrace,
ContextPrepareResult,
ContextPrepareTrigger,
ExternalBridgeHistoryMessage,
} from './types';
import { buildExternalBridgeContextMessages } from './externalBridgeContext';
import { repairToolMessageIntegrity } from './toolMessageIntegrity';
import { pruneFirstModelMessage } from './compactionPruner';
export interface PrepareTurnContextInput {
messages: ModelMessage[];
backend: 'catty' | 'external-bridge';
contextWindow?: number;
reservedTokens?: number;
maxOutputTokens?: number;
trigger: ContextPrepareTrigger;
protectRecentMessages?: number;
force?: boolean;
compressForRequestTooLargeRetry?: boolean;
summarize?: (messagesToSummarize: ModelMessage[]) => Promise<string>;
onEvent?: AgentEventListener;
sessionId?: string;
chatSessionId?: string;
providerId?: string | null;
reinjection?: PostCompactReinjection;
}
export interface PostCompactReinjection {
permissionMode?: AIPermissionMode;
sessionScopeSummary?: string;
sessionStateText?: string;
userGoal?: string;
pendingToolHandleIds?: string[];
}
function emitCompactionEvent(
onEvent: AgentEventListener | undefined,
input: {
sessionId?: string;
chatSessionId?: string;
backend: PrepareTurnContextInput['backend'];
},
trace: CompactionTrace,
): void {
if (!onEvent || !input.sessionId) return;
onEvent({
id: `compaction-${Date.now()}`,
type: 'compaction',
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
backend: input.backend === 'catty' ? 'catty' : 'external-sdk',
timestamp: Date.now(),
trace,
});
}
function emitCompactionStart(
onEvent: AgentEventListener | undefined,
input: {
sessionId?: string;
chatSessionId?: string;
backend: PrepareTurnContextInput['backend'];
},
trigger: ContextPrepareTrigger,
): void {
if (!onEvent || !input.sessionId) return;
onEvent({
id: `compaction-start-${Date.now()}`,
type: 'compaction_start',
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
backend: input.backend === 'catty' ? 'catty' : 'external-sdk',
timestamp: Date.now(),
trigger,
});
}
function applyTypedMessageCompression(messages: ModelMessage[]): {
messages: ModelMessage[];
didAdjust: boolean;
} {
const compressed = compressMessagesForRequestTooLargeRetry(messages);
return { messages: compressed.messages, didAdjust: compressed.didAdjust };
}
function buildReinjectionMessages(reinjection?: PostCompactReinjection): ModelMessage[] {
if (!reinjection) return [];
const lines: string[] = ['[Netcatty session context — preserved after compaction]'];
if (reinjection.permissionMode) {
lines.push(`Permission mode: ${reinjection.permissionMode}`);
}
if (reinjection.sessionStateText) {
lines.push(reinjection.sessionStateText);
}
if (reinjection.sessionScopeSummary) {
lines.push(reinjection.sessionScopeSummary);
}
if (reinjection.userGoal) {
lines.push(`Current user goal: ${reinjection.userGoal}`);
}
if (reinjection.pendingToolHandleIds?.length) {
lines.push(`Unresolved tool output handles: ${reinjection.pendingToolHandleIds.join(', ')}`);
}
if (lines.length <= 1) return [];
return [{
role: 'user',
content: lines.join('\n'),
}];
}
function buildCompactionTrace(input: {
trigger: ContextPrepareTrigger;
tokensBefore: number;
tokensAfter: number;
messagesBefore: number;
messagesAfter: number;
compressedMessageCount: number;
retainedTailCount: number;
summaryLength?: number;
didTypedCompression: boolean;
didLlmSummarize: boolean;
did413Fallback: boolean;
estimatorKind?: CompactionTrace['estimatorKind'];
}): CompactionTrace {
return {
trigger: input.trigger,
estimatedTokensBefore: input.tokensBefore,
estimatedTokensAfter: input.tokensAfter,
messagesBefore: input.messagesBefore,
messagesAfter: input.messagesAfter,
compressedMessageCount: input.compressedMessageCount,
retainedTailCount: input.retainedTailCount,
summaryLength: input.summaryLength,
didTypedCompression: input.didTypedCompression,
didLlmSummarize: input.didLlmSummarize,
did413Fallback: input.did413Fallback,
estimatorKind: input.estimatorKind,
};
}
function isContextUnderBudgetPressure(input: {
messages: ModelMessage[];
contextWindow: number;
maxOutputTokens: number;
providerId?: string | null;
reservedTokens?: number;
force?: boolean;
trigger?: ContextPrepareTrigger;
}): boolean {
if (input.force || input.trigger === '413-retry' || input.trigger === 'force') {
return true;
}
return shouldCompactByBudget({
messages: input.messages,
contextWindow: input.contextWindow,
maxOutputTokens: input.maxOutputTokens,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
});
}
export async function prepareTurnContext(
input: PrepareTurnContextInput,
): Promise<ContextPrepareResult> {
const contextWindow = input.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS;
const protectRecent = input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES;
const maxOutputTokens = input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
const messagesBeforeCount = input.messages.length;
const integrity = repairToolMessageIntegrity(input.messages);
const underBudgetPressure = isContextUnderBudgetPressure({
messages: integrity.messages,
contextWindow,
maxOutputTokens,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
force: input.force,
trigger: input.trigger,
});
const stale = pruneStaleToolContext(integrity.messages, {
underBudgetPressure,
});
let working = stale.messages;
let didAdjust = integrity.didAdjust || stale.didAdjust;
const tokensBeforeResult = estimateModelMessagesTokensWithKind({
messages: working,
providerId: input.providerId,
});
const tokensBefore = tokensBeforeResult.tokens;
const estimatorKind = tokensBeforeResult.estimatorKind;
let didTypedCompression = false;
let didLlmSummarize = false;
let did413Fallback = false;
let summaryLength: number | undefined;
let compressedMessageCount = 0;
let retainedTailCount = working.length;
if (input.compressForRequestTooLargeRetry || input.trigger === '413-retry') {
const typed = applyTypedMessageCompression(working);
working = typed.messages;
didTypedCompression = typed.didAdjust;
did413Fallback = typed.didAdjust;
didAdjust = didAdjust || typed.didAdjust;
} else {
const typed = applyTypedMessageCompression(working);
if (typed.didAdjust) {
working = typed.messages;
didTypedCompression = true;
didAdjust = true;
}
}
if (input.summarize) {
const compacted = await prepareContextCompaction({
messages: working,
contextWindow,
reservedTokens: input.reservedTokens ?? 0,
thresholdRatio: input.force || input.trigger === 'force' ? 0 : undefined,
maxOutputTokens,
providerId: input.providerId,
protectRecentMessages: protectRecent,
summarize: input.summarize,
});
if (compacted.didCompact) {
working = compacted.messages;
didLlmSummarize = true;
didAdjust = true;
summaryLength = compacted.summary?.length;
compressedMessageCount = Math.max(0, messagesBeforeCount - protectRecent);
retainedTailCount = protectRecent;
} else if (input.force || input.trigger === '413-retry' || input.trigger === 'force') {
working = keepRecentContextMessages(working, protectRecent);
didAdjust = true;
retainedTailCount = working.length;
}
} else if (input.force || input.trigger === '413-retry' || input.trigger === 'force') {
working = keepRecentContextMessages(working, protectRecent);
didAdjust = true;
retainedTailCount = working.length;
}
const reinjection = buildReinjectionMessages(input.reinjection);
if (reinjection.length > 0 && didAdjust) {
const reinjectionTokens = estimateModelMessagesTokensWithKind({
messages: reinjection,
providerId: input.providerId,
}).tokens;
const finalGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: (input.reservedTokens ?? 0) + reinjectionTokens,
maxOutputTokens,
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = [...reinjection, ...finalGuard.messages];
didAdjust = didAdjust || finalGuard.didAdjust;
}
const tokensAfter = estimateModelMessagesTokensWithKind({
messages: working,
providerId: input.providerId,
}).tokens;
if (didAdjust) {
const trace = buildCompactionTrace({
trigger: input.trigger,
tokensBefore,
tokensAfter,
messagesBefore: messagesBeforeCount,
messagesAfter: working.length,
compressedMessageCount,
retainedTailCount,
summaryLength,
didTypedCompression,
didLlmSummarize,
did413Fallback,
estimatorKind,
});
emitCompactionEvent(input.onEvent, input, trace);
return { messages: working, didAdjust: true, trace };
}
return { messages: working, didAdjust: false };
}
export function buildExternalBridgeContext(
messages: ChatMessage[],
): ExternalBridgeHistoryMessage[] {
return buildExternalBridgeContextMessages(messages);
}
export function extractLatestUserGoal(messages: ModelMessage[] | ChatMessage[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.role !== 'user') continue;
const content = typeof message.content === 'string'
? message.content.trim()
: '';
if (content && !content.startsWith('[Netcatty session context')) return content.slice(0, 500);
}
return undefined;
}
function applyStepBudgetGuard(
messages: ModelMessage[],
input: {
contextWindow: number;
reservedTokens: number;
maxOutputTokens: number;
providerId?: string | null;
protectRecentMessages: number;
},
): { messages: ModelMessage[]; didAdjust: boolean; didTypedCompression: boolean } {
const threshold = computeCompactionThreshold({
contextWindow: input.contextWindow,
maxOutputTokens: input.maxOutputTokens,
});
const total = computeTotalInputTokens({
messages,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
});
if (total < threshold) {
return { messages, didAdjust: false, didTypedCompression: false };
}
const splitAt = findSafeCompactionSplitIndex(messages, input.protectRecentMessages);
const head = messages.slice(0, splitAt);
const tail = messages.slice(splitAt);
const compressedHead = applyTypedMessageCompression(head);
let next = [...compressedHead.messages, ...tail];
let didAdjust = compressedHead.didAdjust;
let afterTotal = computeTotalInputTokens({
messages: next,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
});
if (afterTotal >= threshold && splitAt > 0) {
next = keepRecentContextMessages(next, input.protectRecentMessages);
didAdjust = true;
afterTotal = computeTotalInputTokens({
messages: next,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
});
}
while (afterTotal >= threshold && next.length > 2) {
const pruned = pruneFirstModelMessage(next);
if (pruned.length === next.length) break;
next = pruned;
didAdjust = true;
afterTotal = computeTotalInputTokens({
messages: next,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
});
}
return {
messages: next,
didAdjust: didAdjust || next.length !== messages.length,
didTypedCompression: compressedHead.didAdjust,
};
}
/** Step-level typed pruning — no LLM summarize (reserved for pre-turn / 413). */
export async function prepareStepContext(
input: PrepareStepContextInput,
): Promise<ContextPrepareResult & { runtimeContext?: import('./cattyRuntimeContext').CattyRuntimeContext }> {
const contextWindow = input.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS;
const maxOutputTokens = input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
const protectRecent = input.protectRecentMessages ?? DEFAULT_PROTECT_RECENT_MESSAGES;
const messagesBeforeCount = input.messages.length;
const integrity = repairToolMessageIntegrity(input.messages);
const underBudgetPressure = isContextUnderBudgetPressure({
messages: integrity.messages,
contextWindow,
maxOutputTokens,
providerId: input.providerId,
reservedTokens: input.reservedTokens,
});
const stale = pruneStaleToolContext(integrity.messages, {
underBudgetPressure,
});
let working = stale.messages;
const typed = compressMessagesForRequestTooLargeRetry(working);
working = typed.messages;
const pendingHandles = input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? [];
let didHandleNotice = false;
if (pendingHandles.length > 0 && input.stepNumber > 0) {
working = working.filter((message) => {
if (message.role !== 'user') return true;
const content = typeof message.content === 'string' ? message.content : '';
return !isStepHandleNoticeMessage(content);
});
const notice: ModelMessage = {
role: 'user',
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
};
working = [notice, ...working];
didHandleNotice = true;
}
const budgetGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: input.reservedTokens ?? 0,
maxOutputTokens,
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = budgetGuard.messages;
if (didHandleNotice && pendingHandles.length > 0) {
const hasHandleNotice = working.some(
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
);
if (!hasHandleNotice) {
working = [{
role: 'user',
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
}, ...working];
}
}
if (didHandleNotice) {
const notices = working.filter(
message => message.role === 'user' && isStepHandleNoticeMessage(message.content),
);
const body = working.filter(
message => !(message.role === 'user' && isStepHandleNoticeMessage(message.content)),
);
const noticeTokens = estimateModelMessagesTokensWithKind({
messages: notices,
providerId: input.providerId,
}).tokens;
const finalGuard = applyStepBudgetGuard(body, {
contextWindow,
reservedTokens: (input.reservedTokens ?? 0) + noticeTokens,
maxOutputTokens,
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = [...notices, ...finalGuard.messages];
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
}
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
const didAdjust = didBudgetAdjust || didHandleNotice;
const before = estimateModelMessagesTokensWithKind({
messages: input.messages,
providerId: input.providerId,
});
const after = estimateModelMessagesTokensWithKind({
messages: working,
providerId: input.providerId,
});
const trace = didBudgetAdjust ? buildCompactionTrace({
trigger: 'step',
tokensBefore: before.tokens,
tokensAfter: after.tokens,
messagesBefore: messagesBeforeCount,
messagesAfter: working.length,
compressedMessageCount: Math.max(0, messagesBeforeCount - working.length),
retainedTailCount: working.length,
didTypedCompression: typed.didAdjust || budgetGuard.didTypedCompression,
didLlmSummarize: false,
did413Fallback: false,
estimatorKind: before.estimatorKind,
}) : undefined;
if (trace && didBudgetAdjust && input.onEvent && input.sessionId) {
emitCompactionStart(input.onEvent, {
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
backend: 'catty',
}, 'step');
emitCompactionEvent(input.onEvent, {
sessionId: input.sessionId,
chatSessionId: input.chatSessionId,
backend: 'catty',
}, trace);
}
const runtimeContext = {
...input.runtimeContext,
...(trace ? { lastCompaction: trace, lastStepAdjusted: didBudgetAdjust } : {}),
...(didAdjust ? { lastStepAdjusted: true } : {}),
};
return {
messages: working,
didAdjust,
trace,
runtimeContext,
};
}

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { prepareTurnContext, extractLatestUserGoal } from './contextManager';
const fixtureMessages: ModelMessage[] = [
{ role: 'user', content: 'Deploy nginx on prod-web-01 and verify port 443.' },
{ role: 'assistant', content: 'I will check the server first.' },
{ role: 'user', content: 'Command failed: systemctl restart nginx returned exit code 1.' },
{ role: 'assistant', content: 'The error log shows missing ssl_certificate path.' },
];
test('context replay compaction retains user goal and recent tail', async () => {
const goal = extractLatestUserGoal(fixtureMessages);
assert.ok(goal?.includes('Command failed'));
const prepared = await prepareTurnContext({
messages: [...fixtureMessages],
backend: 'catty',
contextWindow: 500,
reservedTokens: 100,
trigger: 'force',
force: true,
protectRecentMessages: 2,
providerId: 'anthropic',
reinjection: {
userGoal: goal,
permissionMode: 'confirm',
sessionStateText: 'User goal: Deploy nginx\nActive hosts: sess-1 (last: systemctl status nginx)',
},
});
assert.equal(prepared.didAdjust, true);
assert.ok(prepared.messages.length <= fixtureMessages.length + 1);
const joined = JSON.stringify(prepared.messages);
assert.match(joined, /Deploy nginx|Command failed|ssl_certificate/);
assert.ok(prepared.trace?.estimatorKind);
});

View File

@@ -0,0 +1,453 @@
import type { ChatMessage } from '../types';
import type { ExternalBridgeHistoryMessage } from './types';
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from '../../../components/ai/cattyHistoryReplay';
import { formatVaultNoteReferences, isVaultNoteAttachment } from '../../../application/state/vaultNoteAttachment';
type ExternalAgentHistoryMessage = ExternalBridgeHistoryMessage;
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
type DurableUserLine = {
line: string;
messageIndex: number;
priority: number;
};
const MAX_RECENT_RAW_MESSAGES = 6;
const MAX_MESSAGES_TO_SCAN = 20;
// Bound the scan by user turns, not raw message count: a tool-heavy external agent
// chat can produce 5+ messages per logical turn (user + assistant +
// several tool_results + follow-up assistant), so a plain
// message-count cap ages out early constraints much sooner than intended.
const MAX_DURABLE_SCAN_TURNS = 100;
const MAX_COMPACT_CONTEXT_CHARS = 3000;
const MAX_RAW_MESSAGE_CHARS = 2000;
const MAX_TOOL_SUMMARY_CHARS = 500;
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
const MAX_TOOL_CALL_LABEL_CHARS = 200;
type ToolCallInfo = { name: string; arguments: unknown };
const IMPORTANT_PATTERNS = [
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
];
const DURABLE_CONSTRAINT_PATTERNS = [
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
];
const TRIVIAL_USER_MESSAGE_PATTERNS = [
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
];
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
];
function truncateText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
}
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function isImportantText(value: string): boolean {
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
}
function isDurableConstraintText(value: string): boolean {
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
}
function getUserHistoryContent(message: ChatMessage): string {
if (message.role !== "user") return message.content || "";
return buildHistoricalUserReplayContent(
message.content || "",
message.attachments ?? [],
);
}
function isTrivialUserMessage(value: string): boolean {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
// Don't blanket-drop short messages — short user turns are often
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
// enumerate. The trivial-phrase regex already catches actual filler
// ("ok", "yes", "thanks", "继续").
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
}
function getDurableUserPriority(value: string): number {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
return 1;
}
function isSubstantiveAssistantMessage(value: string): boolean {
const normalized = normalizeWhitespace(value);
if (!normalized) return false;
// Mirror the user-side loosening: don't blanket-drop short assistant
// messages just because they're under 40 chars or don't match the small
// English keyword list. Short but load-bearing decisions ("Use ssh2",
// "rebase instead", "中文输出") aren't realistically enumerable and
// they're the exact things a later "do what you suggested" references.
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
// ("ok", "ack", "got it", "明白").
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
}
function getDurableAssistantPriority(value: string): number {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized)) return 2;
return 1;
}
function appendUniqueLine(
target: string[],
seen: Set<string>,
line: string,
maxSectionChars: number,
sectionCharsRef: { value: number },
): void {
const normalized = normalizeWhitespace(line);
if (!normalized || seen.has(normalized)) return;
const nextChars = sectionCharsRef.value + normalized.length;
if (nextChars > maxSectionChars) return;
seen.add(normalized);
target.push(normalized);
sectionCharsRef.value = nextChars;
}
function summarizeToolMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): string[] {
if (!message.toolResults?.length) return [];
return message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
// Same provenance problem as the raw-window path: once a tool result
// lands in the compact section (older than the 6-item raw window),
// its paired assistant tool_call is almost always gone. Without the
// call label, multiple older results collapse into indistinguishable
// "Tool result (callN): ..." lines and follow-ups like "use the
// resolv.conf output" can't be resolved. Inline the name+args here
// the same way toRawHistoryMessage does.
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
: undefined);
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(normalizeWhitespace(replayContent), MAX_TOOL_SUMMARY_CHARS)}`;
});
}
function summarizeMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): string[] {
if (message.role === "system") return [];
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
const lines: string[] = [];
const content = getUserHistoryContent(message);
if (content && isImportantText(content)) {
const label = message.role === "user" ? "User" : "Assistant";
lines.push(`${label}: ${truncateText(normalizeWhitespace(content), MAX_TOOL_SUMMARY_CHARS)}`);
}
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
const args = JSON.stringify(toolCall.arguments ?? {});
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
if (isImportantText(summary)) lines.push(summary);
}
}
return lines;
}
function summarizeDurableUserMessage(message: ChatMessage): string | null {
if (message.role !== "user") return null;
const notes = message.attachments?.filter(isVaultNoteAttachment) ?? [];
if (notes.length) {
// Budget prose separately: the reference instruction must not consume the request's 280 characters.
const nonNotes = message.attachments?.filter((attachment) => !isVaultNoteAttachment(attachment));
const request = truncateText(normalizeWhitespace(buildHistoricalUserReplayContent(message.content || '', nonNotes)), MAX_DURABLE_USER_MESSAGE_CHARS);
return `User request: ${request}\n${formatVaultNoteReferences(notes)}`;
}
const content = getUserHistoryContent(message);
if (!content) return null;
if (isTrivialUserMessage(content)) return null;
return `User request: ${truncateText(normalizeWhitespace(content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
}
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
if (message.role !== "assistant" || !message.content) return null;
if (!isSubstantiveAssistantMessage(message.content)) return null;
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
}
/**
* Build a per-tool-result provenance index. Keys are
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
* cause later calls to overwrite older ones in the lookup — each
* tool_result resolves to the most recent assistant tool_call that
* preceded it with matching id, which preserves historical correctness
* when rebuilding older compact summaries.
*/
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
const provenance = new Map<string, ToolCallInfo>();
// Rolling map of the latest tool_call seen (by id) up to the current
// point in the message stream.
const latestByCallId = new Map<string, ToolCallInfo>();
for (const message of messages) {
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (!toolCall.id) continue;
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
}
continue;
}
if (message.role === "tool" && message.toolResults?.length) {
for (const result of message.toolResults) {
const info = latestByCallId.get(result.toolCallId);
if (info) {
provenance.set(`${message.id}:${result.toolCallId}`, info);
}
}
}
}
return provenance;
}
function lookupToolCallInfo(
index: Map<string, ToolCallInfo>,
toolMessageId: string,
toolCallId: string,
): ToolCallInfo | undefined {
return index.get(`${toolMessageId}:${toolCallId}`);
}
function toRawHistoryMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): RawHistoryMessage[] {
if (message.role === "user") {
const content = getUserHistoryContent(message);
return content
? [{ sourceId: message.id, role: "user", content: truncateText(content, MAX_RAW_MESSAGE_CHARS) }]
: [];
}
if (message.role === "assistant") {
const parts: string[] = [];
if (message.content) parts.push(message.content);
if (message.toolCalls?.length) {
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
return parts.length
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
: [];
}
if (message.role === "tool" && message.toolResults?.length) {
// Keep recent tool results self-describing while replacing terminal
// output with placeholders, so stale-session recovery doesn't replay
// bulky command output on every follow-up. External agent replay only
// supports user/assistant roles, so we flatten to "assistant" — the
// tool results were produced during the assistant's turn.
//
// Inline the originating tool_call's name+args. Tool calls and their
// results live in separate messages; if the last six raw items start
// in the middle of a tool interaction, the preceding assistant tool
// call can be outside the window. Without the call label the result
// is opaque bytes and "use that output" becomes ambiguous.
const parts = message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
: undefined);
return `${prefix}${callLabel} (${result.toolCallId}): ${replayContent}`;
});
return [{
sourceId: message.id,
role: "assistant",
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
}];
}
return [];
}
function buildCompactContext(
messages: ChatMessage[],
durableScanStart: number,
recentRawSourceIds: Set<string>,
toolCallIndex: Map<string, ToolCallInfo>,
): ExternalAgentHistoryMessage[] {
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
const summaryLines: string[] = [];
const durableUserCandidates: DurableUserLine[] = [];
const selectedDurableUserLines: DurableUserLine[] = [];
const durableAssistantCandidates: DurableUserLine[] = [];
const selectedDurableAssistantLines: DurableUserLine[] = [];
const seen = new Set<string>();
const durableChars = { value: 0 };
const durableAssistantChars = { value: 0 };
const summaryChars = { value: 0 };
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
const message = messages[messageIndex];
if (recentRawSourceIds.has(message.id)) continue;
const durableUserLine = summarizeDurableUserMessage(message);
if (durableUserLine) {
durableUserCandidates.push({
line: durableUserLine,
messageIndex,
priority: getDurableUserPriority(getUserHistoryContent(message)),
});
}
const durableAssistantLine = summarizeDurableAssistantMessage(message);
if (durableAssistantLine) {
durableAssistantCandidates.push({
line: durableAssistantLine,
messageIndex,
priority: getDurableAssistantPriority(message.content || ""),
});
}
}
durableUserCandidates
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
.forEach((candidate) => {
const normalized = normalizeWhitespace(candidate.line);
if (!normalized || seen.has(normalized)) return;
const nextChars = durableChars.value + normalized.length;
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
seen.add(normalized);
selectedDurableUserLines.push(candidate);
durableChars.value = nextChars;
});
durableAssistantCandidates
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
.forEach((candidate) => {
const normalized = normalizeWhitespace(candidate.line);
if (!normalized || seen.has(normalized)) return;
const nextChars = durableAssistantChars.value + normalized.length;
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
seen.add(normalized);
selectedDurableAssistantLines.push(candidate);
durableAssistantChars.value = nextChars;
});
const durableUserLines = selectedDurableUserLines
.sort((left, right) => left.messageIndex - right.messageIndex)
.map((candidate) => candidate.line);
const durableAssistantLines = selectedDurableAssistantLines
.sort((left, right) => left.messageIndex - right.messageIndex)
.map((candidate) => candidate.line);
for (const line of [...durableUserLines, ...durableAssistantLines]) {
seen.add(normalizeWhitespace(line));
}
// Skip messages that are already appended verbatim in the raw window —
// otherwise the same last-6 turns get summarized here AND re-sent as
// raw, doubling the budget cost of important user turns / large tool
// output and crowding out older durable context the replay is meant
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
for (const message of scanned) {
if (recentRawSourceIds.has(message.id)) continue;
for (const line of summarizeMessage(message, toolCallIndex)) {
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
}
}
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
const contentLines = [
"[Compact prior Netcatty UI context]",
"The external SDK agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
];
if (durableUserLines.length) {
contentLines.push("Earlier user requests that may still apply:");
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
}
if (durableAssistantLines.length) {
contentLines.push("Earlier assistant context that may still matter:");
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
}
if (summaryLines.length) {
contentLines.push("Recent noteworthy context:");
contentLines.push(...summaryLines.map((line) => `- ${line}`));
}
return [{
role: "user",
content: truncateText(
contentLines.join("\n"),
MAX_COMPACT_CONTEXT_CHARS,
),
}];
}
/**
* Find the index of the first message to include in the scan window,
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
* Walking backwards stops at the target turn count, so the cost is
* bounded even when the transcript is huge.
*/
function computeDurableScanStart(messages: ChatMessage[]): number {
let userTurns = 0;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === "user") {
userTurns += 1;
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
}
}
return 0;
}
export function buildExternalBridgeContextMessages(messages: ChatMessage[]): ExternalBridgeHistoryMessage[] {
// Compute the scan start once, then do all subsequent work over the
// already-sliced tail. This avoids O(N) walks over the whole transcript
// on every send — previously buildToolCallIndex + the flatMap-to-take-
// last-6 raw history both traversed every message in the chat.
const durableScanStart = computeDurableScanStart(messages);
const scannedTail = messages.slice(durableScanStart);
// The tool-call provenance index only needs entries for tool_results
// that might appear in our output. Building from the scanned tail is
// correct for any tool_result whose paired assistant tool_call is
// also within the window, which covers >99% of realistic patterns
// (tool_calls and tool_results are always adjacent or near-adjacent).
// If an ancient tool_call's result stays within the window while the
// call itself is outside, that single result loses its [from X(Y)]
// label — an acceptable trade for eliminating the per-send O(N) walk.
const toolCallIndex = buildToolCallIndex(scannedTail);
const rawHistory = scannedTail
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
.slice(-MAX_RECENT_RAW_MESSAGES);
const compactContext = buildCompactContext(
messages,
durableScanStart,
new Set(rawHistory.map((message) => message.sourceId)),
toolCallIndex,
);
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
return [...compactContext, ...recentRaw];
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import { AgentRuntime } from './agentRuntime';
import { cattyTurnDriver } from './turnDrivers/cattyTurnDriver';
import { externalSdkTurnDriver } from './turnDrivers/externalSdkTurnDriver';
export const globalAgentRuntime = new AgentRuntime({
drivers: [cattyTurnDriver, externalSdkTurnDriver],
});
export function getAgentRuntime(): AgentRuntime {
return globalAgentRuntime;
}

View File

@@ -0,0 +1,147 @@
export type {
AgentBackend,
AgentEvent,
AgentEventListener,
AgentEventType,
ApprovalOutcome,
CompactionEvent,
CompactionTrace,
ContextPrepareResult,
ContextPrepareTrigger,
ExternalBridgeHistoryMessage,
TokenEstimatorKind,
UsageEvent,
PerformanceEvent,
ModelCallStartEvent,
StepEndEvent,
} from './types';
export type {
TurnSteerFailureReason,
TurnSteerInput,
TurnSteerResult,
} from './turnDrivers/types';
export { TraceStore, globalTraceStore } from './traceStore';
export type { TraceExport } from './traceStore';
export { stopAgentTurn, clearChatSessionCancelled } from './agentStop';
export type { AgentStopBridge, StopAgentTurnParams, StopAgentTurnReason } from './agentStop';
export { AgentRuntime } from './agentRuntime';
export { globalAgentRuntime, getAgentRuntime } from './globalAgentRuntime';
export {
estimateModelMessagesTokens,
estimateModelMessagesTokensWithKind,
estimateTextTokens,
estimateUnknownTokens,
} from './tokenEstimator';
export type { EstimateModelMessagesTokensInput, EstimateModelMessagesTokensResult } from './tokenEstimator';
export { ToolOutputStore, globalToolOutputStore } from './toolOutputStore';
export type {
ToolOutputHandle,
StoreToolOutputInput,
ReadToolOutputInput,
ToolOutputReadResult,
ToolOutputPersistence,
ToolOutputStoreOptions,
} from './toolOutputStore';
export { ToolResultDedup, hashScopeKey, previewToolResult } from './toolResultDedup';
export type { ToolResultDedupEntry } from './toolResultDedup';
export { cattyTurnDriver } from './turnDrivers/cattyTurnDriver';
export { externalSdkTurnDriver } from './turnDrivers/externalSdkTurnDriver';
export type {
TurnInput,
TurnResult,
TurnDriver,
TurnDriverContext,
TurnUiCallbacks,
CattyTurnInput,
ExternalTurnInput,
} from './turnDrivers/types';
export {
prepareTurnContext,
prepareStepContext,
buildExternalBridgeContext,
extractLatestUserGoal,
} from './contextManager';
export type { PrepareTurnContextInput, PostCompactReinjection } from './contextManager';
export {
computeCompactionThreshold,
computeTotalInputTokens,
shouldCompactByBudget,
DEFAULT_MAX_OUTPUT_TOKENS,
} from './contextBudget';
export { SessionStateStore, globalSessionStateStore } from './sessionState';
export type { CattySessionState } from './sessionState';
export { repairToolMessageIntegrity } from './toolMessageIntegrity';
export { redactSecretsForModel } from './modelSecretRedaction';
export { buildPromptContextSnapshot } from './promptContextSnapshot';
export type { PromptContextSnapshot } from './promptContextSnapshot';
export { pruneStaleToolContext } from './staleContextPruner';
export { pruneUntilFitsCompaction } from './compactionPruner';
export { CATTY_COMPACTION_STATUS_KEYS } from './compactionStatusKeys';
export { buildExternalBridgeContextMessages } from './externalBridgeContext';
export {
fitTerminalExecuteResultForModel,
MAX_LIVE_TERMINAL_STDOUT_CHARS,
MAX_LIVE_TERMINAL_STDERR_CHARS,
} from './terminalCompression';
export type { TerminalExecuteResult, TerminalOutputHandle } from './terminalCompression';
export {
encodeSdkSessionIdentity,
parseSdkSessionIdentity,
SDK_SESSION_ID_PREFIX,
} from './sdkSessionIdentity';
export type { SdkSessionIdentityPayload } from './sdkSessionIdentity';
export {
mapCattyStreamChunkToAgentEvents,
mapSdkStreamEventToAgentEvents,
createHarnessEventSink,
} from './agentEventAdapter';
export type { StreamEventContext } from './agentEventAdapter';
export {
compactCattyMessages,
prepareCattyMessagesForStream,
} from './cattyRuntime';
export type { CompactCattyMessagesInput, CompactCattyMessagesResult } from './cattyRuntime';
export { createCattyToolsFromCatalog } from './capabilityTools';
export type { CattyToolsBundle } from './capabilityTools';
export {
createInitialCattyRuntimeContext,
cattyRuntimeContextSchema,
cattyToolContextSchema,
} from './cattyRuntimeContext';
export type { CattyRuntimeContext, CattyToolContext } from './cattyRuntimeContext';
export { buildCattyStreamTimeouts, buildCattyCompactionTimeout } from './streamTimeouts';
export { buildCattyToolApproval } from './cattyToolApproval';
export {
PermissionGrantStore,
buildGrantFromApproval,
createPermissionGrantId,
getActivePermissionGrants,
matchPermissionGrant,
patternMatches,
resolveCapabilityId,
sanitizePermissionGrants,
setActivePermissionGrants,
} from './permissionGrants';
export type { PermissionGrantMatchContext, PermissionGrantRule } from './permissionGrants';

View File

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ToolOutputStore } from './toolOutputStore';
import { fitLargeUserInputForModel } from './largeUserInput';
import {
buildCattySdkMessages,
createContinuationContext,
} from './turnDrivers/cattyMessageBuilder';
test('fitLargeUserInputForModel keeps both ends and stores the full prompt', () => {
const store = new ToolOutputStore();
const input = `START-${'middle '.repeat(8_000)}-FINAL QUESTION`;
const fitted = fitLargeUserInputForModel(input, 'chat-1', store);
assert.match(fitted, /^START-/);
assert.match(fitted, /FINAL QUESTION/);
assert.match(fitted, /handleId=tool-output-/);
assert.ok(fitted.length < input.length);
const handle = store.listPendingHandles('chat-1')[0];
assert.equal(handle.fullContent, input);
});
test('fitLargeUserInputForModel reuses one stable handle across history replays', () => {
const store = new ToolOutputStore();
const input = `START-${'history '.repeat(8_000)}-FINAL QUESTION`;
const firstReplay = fitLargeUserInputForModel(input, 'chat-1', store);
const secondReplay = fitLargeUserInputForModel(input, 'chat-1', store);
assert.equal(secondReplay, firstReplay);
assert.equal(store.listPendingHandles('chat-1').length, 1);
});
test('a large user message remains bounded with the same handle on the next turn', () => {
const store = new ToolOutputStore();
const input = `START-${'history '.repeat(8_000)}-FINAL QUESTION`;
const firstTurnContent = fitLargeUserInputForModel(input, 'chat-1', store);
const firstHandleId = firstTurnContent.match(/handleId=(tool-output-[A-Za-z0-9-]+)/)?.[1];
assert.ok(firstHandleId);
const buildHistory = () => buildCattySdkMessages({
allMessages: [{
id: 'user-1',
role: 'user',
content: input,
timestamp: 1,
}],
includeCurrentUserMessage: true,
trimmed: 'continue',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1'),
chatSessionId: 'chat-1',
toolOutputStore: store,
fieldsByMessage: new Map(),
});
const secondTurn = buildHistory();
const retry = buildHistory();
const replayContent = secondTurn[0]?.content;
const retryContent = retry[0]?.content;
assert.ok(typeof replayContent === 'string');
assert.ok(typeof retryContent === 'string');
assert.equal(replayContent, retryContent);
assert.match(replayContent, new RegExp(`handleId=${firstHandleId}`));
assert.ok(replayContent.length < input.length);
assert.equal(store.listPendingHandles('chat-1').length, 1);
});
test('a persisted compaction boundary replaces older history with its summary', () => {
const store = new ToolOutputStore();
const messages = buildCattySdkMessages({
allMessages: [
{ id: 'old-1', role: 'user', content: 'old secret question', timestamp: 1 },
{ id: 'old-2', role: 'assistant', content: 'old answer', timestamp: 2 },
{ id: 'recent-1', role: 'user', content: 'recent question', timestamp: 3 },
],
contextCompaction: {
summary: 'The earlier question was answered.',
compactedMessageCount: 2,
},
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1'),
chatSessionId: 'chat-1',
toolOutputStore: store,
fieldsByMessage: new Map(),
});
assert.equal(messages.length, 3);
assert.match(String(messages[0]?.content), /The earlier question was answered/);
assert.doesNotMatch(JSON.stringify(messages), /old secret question|old answer/);
assert.match(JSON.stringify(messages), /recent question/);
});

View File

@@ -0,0 +1,50 @@
import type { ToolOutputStore } from './toolOutputStore';
const LARGE_USER_INPUT_THRESHOLD_CHARS = 25_000;
const LARGE_USER_INPUT_HEAD_CHARS = 12_000;
const LARGE_USER_INPUT_TAIL_CHARS = 4_000;
const handlesByStore = new WeakMap<ToolOutputStore, Map<string, string>>();
function hashInput(input: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function getStableHandleId(
input: string,
chatSessionId: string,
toolOutputStore: ToolOutputStore,
): string {
const handles = handlesByStore.get(toolOutputStore) ?? new Map<string, string>();
handlesByStore.set(toolOutputStore, handles);
const key = `${chatSessionId}:${input.length}:${hashInput(input)}`;
const existingId = handles.get(key);
if (existingId && toolOutputStore.get(existingId, chatSessionId)) return existingId;
const handleId = toolOutputStore.store({
chatSessionId,
capabilityId: 'user.input',
content: input,
}).id;
handles.set(key, handleId);
return handleId;
}
export function fitLargeUserInputForModel(
input: string,
chatSessionId: string,
toolOutputStore: ToolOutputStore,
): string {
if (input.length <= LARGE_USER_INPUT_THRESHOLD_CHARS) return input;
const handleId = getStableHandleId(input, chatSessionId, toolOutputStore);
return [
input.slice(0, LARGE_USER_INPUT_HEAD_CHARS),
`\n\n[... large user input moved to saved output: ${input.length} chars, handleId=${handleId}. Use tool_output_read with range or search for omitted details ...]\n\n`,
input.slice(-LARGE_USER_INPUT_TAIL_CHARS),
].join('');
}

View File

@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fitTerminalExecuteResultForModel } from './terminalCompression';
import { fitLargeToolResultForModel } from './toolResultFitting';
import { ToolOutputStore } from './toolOutputStore';
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
test('redactSecretsForModel removes common terminal secrets', () => {
const input = [
'API_TOKEN=tok_live_1234567890',
'Authorization: Bearer abc.def.very-secret',
'postgres://admin:p4ssw0rd@db.internal/app',
'-----BEGIN PRIVATE KEY-----',
'super-secret-private-key-body',
'-----END PRIVATE KEY-----',
].join('\n');
const output = redactSecretsForModel(input);
assert.doesNotMatch(output, /tok_live|abc\.def|p4ssw0rd|private-key-body/);
assert.match(output, /\[REDACTED\]/);
});
test('redactSecretsInValueForModel recursively redacts tool arguments', () => {
const value = redactSecretsInValueForModel({
command: 'curl --password swordfish',
nested: [
'Authorization: Bearer secret_token_123456',
{
password: 'short',
telnetPassword: 'telnet-secret',
passphrase: 'key-passphrase',
privateKey: 'not-a-pem-but-still-private',
apiKey: 'tiny-api-key',
clientSecret: 'client-secret',
authToken: 'auth-token',
username: 'operator',
},
],
});
const serialized = JSON.stringify(value);
assert.doesNotMatch(serialized, /swordfish|secret_token|short|telnet-secret|key-passphrase|not-a-pem|tiny-api-key|client-secret|auth-token/);
assert.match(serialized, /operator/);
});
test('redactSecretsForModel redacts JSON and quoted shell assignments', () => {
const input = [
'{"password":"short value","apiKey":"tiny","username":"operator"}',
"passphrase='two words'",
'client_secret = "quoted secret"',
'PRIVATE_KEY=one-line-key',
].join('\n');
const output = redactSecretsForModel(input);
assert.doesNotMatch(output, /short value|tiny|two words|quoted secret|one-line-key/);
assert.match(output, /"username":"operator"/);
assert.equal((output.match(/\[REDACTED\]/g) ?? []).length, 5);
});
test('terminal fitting keeps raw output in the local handle but redacts model-visible text', () => {
const store = new ToolOutputStore();
const secret = 'API_TOKEN=tok_live_1234567890';
const fitted = fitTerminalExecuteResultForModel({
stdout: `${secret}\n${'build line\n'.repeat(10_000)}`,
stderr: '',
exitCode: 1,
command: `deploy --token tok_live_1234567890`,
sessionId: 'session-1',
}, {
chatSessionId: 'chat-1',
toolOutputStore: store,
});
assert.doesNotMatch(fitted.stdout, /tok_live/);
assert.doesNotMatch(fitted.command ?? '', /tok_live/);
assert.match(fitted.stdout, /restartPersistence=unavailable \(read before closing the app\)/);
const handle = store.listPendingHandles('chat-1')[0];
assert.match(handle.fullContent, /tok_live/);
});
test('generic tool fitting redacts short strings even when truncation is unnecessary', () => {
const fitted = fitLargeToolResultForModel({
result: { message: 'password=hunter2' },
capabilityId: 'example.read',
}) as { message: string };
assert.equal(fitted.message, 'password=[REDACTED]');
});

View File

@@ -0,0 +1,43 @@
const REDACTED = '[REDACTED]';
const PRIVATE_KEY_PATTERN = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g;
const BEARER_PATTERN = /\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
const URL_CREDENTIAL_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@]+)(@)/gi;
const QUOTED_SECRET_ASSIGNMENT_PATTERN = /(["'])([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|token|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]*)\1\s*([:=])\s*(["'])((?:\\.|(?!\4)[\s\S])*?)\4/gi;
const QUOTED_VALUE_SECRET_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|token|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]*)\s*([:=])\s*(["'])((?:\\.|(?!\3)[\s\S])*?)\3/gi;
const SECRET_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|token|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]*)\s*([:=])\s*(["']?)([^\s"',;]+)\3/gi;
const SECRET_CLI_FLAG_PATTERN = /(--(?:password|passwd|passphrase|token|secret|api-key|access-key|secret-key|private-key))\s+(?:["']([^"']+)["']|([^\s]+))/gi;
const WELL_KNOWN_TOKEN_PATTERN = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g;
function isSecretKey(key: string): boolean {
const normalized = key.replace(/[^A-Za-z0-9]/g, '').toLocaleLowerCase();
return /(?:password|passwd|passphrase|pwd|token|secret|apikey|accesskey|secretkey|privatekey|credential|credentials)$/.test(normalized);
}
/** Redacts likely credentials only on data that is about to become model-visible. */
export function redactSecretsForModel(value: string): string {
if (!value) return value;
return value
.replace(PRIVATE_KEY_PATTERN, REDACTED)
.replace(BEARER_PATTERN, '$1 [REDACTED]')
.replace(URL_CREDENTIAL_PATTERN, `$1${REDACTED}$3`)
.replace(QUOTED_SECRET_ASSIGNMENT_PATTERN, '$1$2$1$3$4[REDACTED]$4')
.replace(QUOTED_VALUE_SECRET_ASSIGNMENT_PATTERN, '$1$2$3[REDACTED]$3')
.replace(SECRET_ASSIGNMENT_PATTERN, '$1$2[REDACTED]')
.replace(SECRET_CLI_FLAG_PATTERN, '$1 [REDACTED]')
.replace(WELL_KNOWN_TOKEN_PATTERN, REDACTED);
}
/** Recursively redacts model-visible event arguments without mutating the source value. */
export function redactSecretsInValueForModel<T>(value: T): T {
if (typeof value === 'string') return redactSecretsForModel(value) as T;
if (Array.isArray(value)) return value.map(redactSecretsInValueForModel) as T;
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.map(([key, entry]) => [
key,
isSecretKey(key) ? REDACTED : redactSecretsInValueForModel(entry),
]),
) as T;
}

View File

@@ -0,0 +1,418 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
buildGrantFromApproval,
buildGrantsFromApproval,
listGrantableCapabilityIds,
matchPermissionGrant,
patternMatches,
type PermissionGrantRule,
} from './permissionGrants';
const baseRule = (overrides: Partial<PermissionGrantRule>): PermissionGrantRule => ({
id: 'grant-1',
capabilityId: 'terminal.execute',
sessionPattern: 'session-a',
createdAt: Date.now(),
...overrides,
});
describe('permissionGrants', () => {
it('matches wildcard session and command patterns', () => {
const rules = [baseRule({ sessionPattern: '*', commandPattern: 'ls *' })];
const matched = matchPermissionGrant(rules, {
capabilityId: 'terminal.execute',
sessionId: 'any-session',
args: { command: 'ls -la /tmp' },
});
assert.ok(matched);
});
it('ignores sessionPattern and matches globally by capability and command', () => {
const rules = [baseRule({ sessionPattern: 'old-session-uuid', commandPattern: 'ls *' })];
const matched = matchPermissionGrant(rules, {
capabilityId: 'terminal.execute',
sessionId: 'different-session',
args: { command: 'ls -la /tmp' },
});
assert.ok(matched);
});
it('does not match a different capability', () => {
const rules = [baseRule({ sessionPattern: '*' })];
const matched = matchPermissionGrant(rules, {
capabilityId: 'terminal.start',
sessionId: 'session-a',
args: { command: 'make' },
});
assert.equal(matched, null);
});
it('buildGrantFromApproval uses global scope and OpenCode-style command prefix patterns', () => {
const grant = buildGrantFromApproval('terminal.execute', {
sessionId: 'ssh-1',
command: 'systemctl status nginx',
}, 'chat-1');
assert.ok(grant);
assert.equal(grant.sessionPattern, '*');
assert.equal(grant.commandPattern, 'systemctl status *');
});
it('buildGrantFromApproval returns null when no command grant is produced', () => {
const grant = buildGrantFromApproval('terminal.execute', {
sessionId: 'ssh-1',
command: 'cd /tmp',
}, 'chat-1');
assert.equal(grant, null);
});
it('buildGrantsFromApproval emits one rule per chained command segment', () => {
const grants = buildGrantsFromApproval('terminal.execute', {
sessionId: 'ssh-1',
command: 'cd /tmp && lscpu',
}, 'chat-1');
assert.equal(grants.length, 1);
assert.equal(grants[0]?.commandPattern, 'lscpu *');
});
it('does not let a comment grant approve a multiline command', () => {
const rules = [baseRule({ sessionPattern: '*', commandPattern: '# *' })];
const matched = matchPermissionGrant(rules, {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: {
command: [
'# 1a) clear the kernel_options_post profile field',
'cobbler profile edit --name=openEuler-22.03-aarch64 --kernel-options-post=""',
].join('\n'),
},
});
assert.equal(matched, null);
});
it('requires every grantable command segment to be covered', () => {
const rules = [
baseRule({ id: 'grant-lscpu', sessionPattern: '*', commandPattern: 'lscpu *' }),
baseRule({ id: 'grant-grep', sessionPattern: '*', commandPattern: 'grep *' }),
];
const matched = matchPermissionGrant(rules, {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command: 'cd /tmp && lscpu | grep CPU' },
});
assert.ok(matched);
const missingPipeSegment = matchPermissionGrant([rules[0]!], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command: 'cd /tmp && lscpu | grep CPU' },
});
assert.equal(missingPipeSegment, null);
});
it('requires every background command segment to be covered', () => {
const command = 'cd /tmp; sleep 1 & rm -rf demo';
const matchedBySleepOnly = matchPermissionGrant([
baseRule({ id: 'grant-sleep', sessionPattern: '*', commandPattern: 'sleep *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedBySleepOnly, null);
const matchedByBoth = matchPermissionGrant([
baseRule({ id: 'grant-sleep', sessionPattern: '*', commandPattern: 'sleep *' }),
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByBoth);
});
it('requires cwd segments with shell substitutions to be covered', () => {
const command = 'cd "$(pwd)"; ls -la';
const matchedByLsOnly = matchPermissionGrant([
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByLsOnly, null);
const matchedByBoth = matchPermissionGrant([
baseRule({ id: 'grant-cd', sessionPattern: '*', commandPattern: 'cd *' }),
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByBoth);
});
it('does not let a here-doc body grant approve the wrapping command', () => {
const command = [
"cat <<'EOF'",
'rm -rf /tmp/demo',
'EOF',
].join('\n');
const matchedByBody = matchPermissionGrant([
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByBody, null);
const matchedByWrapper = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByWrapper);
});
it('does not let a piped here-doc body grant approve the command', () => {
const command = [
'cat <<EOF | grep needle',
'rm -rf /tmp/demo',
'EOF',
].join('\n');
const matchedByBody = matchPermissionGrant([
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByBody, null);
const matchedByPipeline = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
baseRule({ id: 'grant-grep', sessionPattern: '*', commandPattern: 'grep *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByPipeline);
});
it('does not let an fd-prefixed here-doc body grant approve the command', () => {
const command = [
'cat 0<<EOF',
'rm -rf /tmp/demo',
'EOF',
].join('\n');
const matchedByBody = matchPermissionGrant([
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByBody, null);
const matchedByWrapper = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByWrapper);
});
it('does not let quoted here-doc operator text hide later commands', () => {
const command = [
"cd /tmp; echo '<<EOF'",
'rm -rf demo',
'EOF',
].join('\n');
const matchedByEchoOnly = matchPermissionGrant([
baseRule({ id: 'grant-echo', sessionPattern: '*', commandPattern: 'echo *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByEchoOnly, null);
const matchedByAll = matchPermissionGrant([
baseRule({ id: 'grant-echo', sessionPattern: '*', commandPattern: 'echo *' }),
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
baseRule({ id: 'grant-eof', sessionPattern: '*', commandPattern: 'EOF *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByAll);
});
it('keeps commands after mixed-quoted here-doc delimiters grantable', () => {
const command = [
'cat <<E"OF"',
'body text',
'EOF',
'ls -la',
].join('\n');
const matchedByCatOnly = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByCatOnly, null);
const matchedByBoth = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByBoth);
});
it('keeps commands after dollar-quoted here-doc delimiters grantable', () => {
const command = [
"cat <<$'EOF'",
'body text',
'EOF',
'rm -rf demo',
].join('\n');
const matchedByCatOnly = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByCatOnly, null);
const matchedByBoth = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByBoth);
});
it('keeps commands after ANSI-C quoted here-doc delimiters grantable', () => {
const command = [
"cat <<$'E\\x4fF'",
'body text',
'EOF',
'rm -rf demo',
].join('\n');
const matchedByCatOnly = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByCatOnly, null);
const matchedByBoth = matchPermissionGrant([
baseRule({ id: 'grant-cat', sessionPattern: '*', commandPattern: 'cat *' }),
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByBoth);
});
it('does not let arithmetic shifts hide following commands', () => {
const command = [
'ls $((1 << 2))',
'rm -rf demo',
].join('\n');
const matchedByLsOnly = matchPermissionGrant([
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.equal(matchedByLsOnly, null);
const matchedByBoth = matchPermissionGrant([
baseRule({ id: 'grant-ls', sessionPattern: '*', commandPattern: 'ls *' }),
baseRule({ id: 'grant-rm', sessionPattern: '*', commandPattern: 'rm *' }),
], {
capabilityId: 'terminal.execute',
sessionId: 'session-a',
args: { command },
});
assert.ok(matchedByBoth);
});
it('OpenCode wildcard allows optional args after prefix', () => {
assert.equal(patternMatches('lscpu *', 'lscpu'), true);
assert.equal(patternMatches('lscpu *', 'lscpu -e'), true);
assert.equal(patternMatches('git checkout *', 'git checkout main'), true);
assert.equal(patternMatches('git checkout *', 'git commit'), false);
});
it('lists grantable capability ids from catalog policy', () => {
const ids = listGrantableCapabilityIds();
assert.ok(ids.includes('terminal.execute'));
assert.ok(ids.includes('sftp.write'));
assert.ok(!ids.includes('terminal.poll'));
});
it('patternMatches supports regex literals', () => {
assert.equal(patternMatches('/^ls\\b/', 'ls -la'), true);
assert.equal(patternMatches('/^ls\\b/', 'cat file'), false);
});
});

View File

@@ -0,0 +1,316 @@
import cattyToolSpecs from './generated/cattyToolSpecs.json';
import {
buildAlwaysAllowCommandPatterns,
extractGrantableShellCommandSegments,
} from '../shared/shellCommandGrant';
export interface PermissionGrantRule {
id: string;
capabilityId: string;
sessionPattern: string;
commandPattern?: string;
argsPattern?: Record<string, string>;
createdAt: number;
note?: string;
}
export interface PermissionGrantMatchContext {
capabilityId: string;
sessionId?: string;
chatSessionId?: string;
hostname?: string;
args?: Record<string, unknown>;
}
type CattyToolSpecRef = {
capabilityId: string;
toolName: string;
rpcMethod: string | null;
policy?: {
write?: boolean;
bypassesApproval?: boolean;
};
};
const TOOL_NAME_TO_CAPABILITY = new Map<string, string>();
const RPC_METHOD_TO_CAPABILITY = new Map<string, string>();
for (const spec of cattyToolSpecs as CattyToolSpecRef[]) {
TOOL_NAME_TO_CAPABILITY.set(spec.toolName, spec.capabilityId);
if (spec.rpcMethod) {
RPC_METHOD_TO_CAPABILITY.set(spec.rpcMethod, spec.capabilityId);
}
}
export function resolveCapabilityId(toolOrRpcName: string): string {
return TOOL_NAME_TO_CAPABILITY.get(toolOrRpcName)
?? RPC_METHOD_TO_CAPABILITY.get(toolOrRpcName)
?? toolOrRpcName;
}
export function patternMatches(pattern: string, value: string): boolean {
if (!pattern) return false;
if (pattern === '*') return true;
if (pattern.startsWith('host:')) {
return globOrRegexMatch(pattern.slice('host:'.length), value);
}
return globOrRegexMatch(pattern, value);
}
function globOrRegexMatch(pattern: string, value: string): boolean {
if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) {
const lastSlash = pattern.lastIndexOf('/');
const body = pattern.slice(1, lastSlash);
const flags = pattern.slice(lastSlash + 1);
try {
return new RegExp(body, flags).test(value);
} catch {
return false;
}
}
if (!pattern.includes('*') && !pattern.includes('?')) {
return value === pattern;
}
// OpenCode Wildcard.match semantics (trailing " *" allows optional args).
let escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
if (escaped.endsWith(' .*')) {
escaped = `${escaped.slice(0, -3)}( .*)?`;
}
return new RegExp(`^${escaped}$`, 's').test(value);
}
function argsPatternMatches(
argsPattern: Record<string, string> | undefined,
args: Record<string, unknown> | undefined,
): boolean {
if (!argsPattern) return true;
if (!args) return false;
for (const [key, pattern] of Object.entries(argsPattern)) {
const argValue = args[key];
if (typeof argValue === 'undefined') return false;
if (!patternMatches(pattern, String(argValue))) return false;
}
return true;
}
export function matchPermissionGrant(
rules: readonly PermissionGrantRule[],
ctx: PermissionGrantMatchContext,
): PermissionGrantRule | null {
if (rules.length === 0) return null;
const args = ctx.args ?? {};
const command = typeof args.command === 'string' ? args.command : '';
const commandGrantMatch = command ? matchCommandPatternGrants(rules, ctx, command, args) : null;
if (commandGrantMatch) return commandGrantMatch;
for (const rule of rules) {
if (rule.capabilityId !== ctx.capabilityId) continue;
if (rule.commandPattern) continue;
if (!argsPatternMatches(rule.argsPattern, args)) continue;
return rule;
}
return null;
}
function matchCommandPatternGrants(
rules: readonly PermissionGrantRule[],
ctx: PermissionGrantMatchContext,
command: string,
args: Record<string, unknown>,
): PermissionGrantRule | null {
const commandSegments = extractGrantableShellCommandSegments(command);
if (commandSegments.length === 0) return null;
const eligibleRules = rules.filter((rule) => (
rule.capabilityId === ctx.capabilityId
&& Boolean(rule.commandPattern)
&& argsPatternMatches(rule.argsPattern, args)
));
if (eligibleRules.length === 0) return null;
let firstMatch: PermissionGrantRule | null = null;
for (const segment of commandSegments) {
const matched = eligibleRules.find((rule) => patternMatches(rule.commandPattern!, segment));
if (!matched) return null;
firstMatch ??= matched;
}
return firstMatch;
}
export function sanitizePermissionGrants(raw: unknown): PermissionGrantRule[] {
if (!Array.isArray(raw)) return [];
const result: PermissionGrantRule[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const record = entry as Record<string, unknown>;
const capabilityId = typeof record.capabilityId === 'string' ? record.capabilityId.trim() : '';
if (!capabilityId) continue;
const rule: PermissionGrantRule = {
id: typeof record.id === 'string' && record.id.trim()
? record.id.trim().slice(0, 64)
: createPermissionGrantId(),
capabilityId,
sessionPattern: typeof record.sessionPattern === 'string' && record.sessionPattern.trim()
? record.sessionPattern.trim()
: '*',
createdAt: typeof record.createdAt === 'number' && Number.isFinite(record.createdAt)
? record.createdAt
: Date.now(),
};
if (typeof record.commandPattern === 'string' && record.commandPattern.trim()) {
rule.commandPattern = record.commandPattern.trim();
}
if (record.argsPattern && typeof record.argsPattern === 'object' && !Array.isArray(record.argsPattern)) {
const argsPattern: Record<string, string> = {};
for (const [key, value] of Object.entries(record.argsPattern as Record<string, unknown>)) {
if (typeof value === 'string' && value.trim()) {
argsPattern[key] = value.trim();
}
}
if (Object.keys(argsPattern).length > 0) {
rule.argsPattern = argsPattern;
}
}
if (typeof record.note === 'string' && record.note.trim()) {
rule.note = record.note.trim().slice(0, 240);
}
result.push(rule);
}
return result;
}
export function createPermissionGrantId(): string {
return `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
const COMMAND_GRANT_CAPABILITIES = new Set([
'terminal.execute',
'terminal.start',
]);
const GRANTABLE_CAPABILITY_IDS: readonly string[] = Object.freeze(
[...new Set(
(cattyToolSpecs as CattyToolSpecRef[])
.filter((spec) => spec.policy?.write && !spec.policy?.bypassesApproval)
.map((spec) => spec.capabilityId),
)].sort(),
);
export function listGrantableCapabilityIds(): readonly string[] {
return GRANTABLE_CAPABILITY_IDS;
}
export function capabilitySupportsCommandPatternGrant(capabilityId: string): boolean {
return COMMAND_GRANT_CAPABILITIES.has(capabilityId);
}
function resolveCommandGrantPatterns(
capabilityId: string,
args: Record<string, unknown>,
): string[] | undefined {
if (!COMMAND_GRANT_CAPABILITIES.has(capabilityId)) return undefined;
const command = typeof args.command === 'string' ? args.command.trim() : '';
if (!command) return undefined;
return buildAlwaysAllowCommandPatterns(command);
}
export function buildGrantsFromApproval(
capabilityId: string,
args: Record<string, unknown>,
_chatSessionId?: string,
): PermissionGrantRule[] {
// OpenCode-style always-allow: global scope (not bound to a terminal session UUID).
const sessionPattern = '*';
const commandPatterns = resolveCommandGrantPatterns(capabilityId, args);
const createdAt = Date.now();
if (!commandPatterns) {
return [{
id: createPermissionGrantId(),
capabilityId,
sessionPattern,
createdAt,
}];
}
if (commandPatterns.length === 0) return [];
return commandPatterns.map((commandPattern) => ({
id: createPermissionGrantId(),
capabilityId,
sessionPattern,
commandPattern,
createdAt,
}));
}
export function buildGrantFromApproval(
capabilityId: string,
args: Record<string, unknown>,
chatSessionId?: string,
): PermissionGrantRule | null {
return buildGrantsFromApproval(capabilityId, args, chatSessionId)[0] ?? null;
}
let activeRules: PermissionGrantRule[] = [];
export function setActivePermissionGrants(rules: PermissionGrantRule[]): void {
activeRules = [...rules];
}
export function getActivePermissionGrants(): readonly PermissionGrantRule[] {
return activeRules;
}
export class PermissionGrantStore {
private rules: PermissionGrantRule[];
constructor(rules: PermissionGrantRule[] = []) {
this.rules = [...rules];
}
getRules(): readonly PermissionGrantRule[] {
return this.rules;
}
setRules(rules: PermissionGrantRule[]): void {
this.rules = [...rules];
}
addRule(rule: PermissionGrantRule): void {
this.rules = [...this.rules, rule];
}
updateRule(id: string, updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>>): void {
this.rules = this.rules.map((rule) => (
rule.id === id ? { ...rule, ...updates } : rule
));
}
removeRule(id: string): void {
this.rules = this.rules.filter((rule) => rule.id !== id);
}
match(ctx: PermissionGrantMatchContext): PermissionGrantRule | null {
return matchPermissionGrant(this.rules, ctx);
}
}

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildPromptContextSnapshot } from './promptContextSnapshot';
test('buildPromptContextSnapshot records inspectable prompt inputs without credentials', () => {
const snapshot = buildPromptContextSnapshot({
providerId: 'openai',
modelId: 'gpt-test',
permissionMode: 'confirm',
scopeType: 'terminal',
scopeLabel: 'production',
toolNames: ['terminal_execute', 'terminal_poll'],
selectedSkillSlugs: ['diagnosing-bugs'],
systemPrompt: 'secret-free rendered prompt',
webSearchEnabled: false,
hostSessionIds: ['session-1'],
builtAt: 123,
});
assert.equal(snapshot.version, 2);
assert.deepEqual(snapshot.toolNames, ['terminal_execute', 'terminal_poll']);
assert.equal(snapshot.systemPromptChars, 27);
assert.match(snapshot.systemPromptHash, /^fnv1a-/);
assert.deepEqual(snapshot.injections.map(item => item.source), [
'system-prompt', 'capability-catalog', 'user-skills', 'terminal-scope', 'web-search',
]);
assert.equal(JSON.stringify(snapshot).includes('secret-free rendered prompt'), false);
});

View File

@@ -0,0 +1,85 @@
import type { AIPermissionMode } from '../types';
export interface PromptContextSnapshot {
version: 2;
audience: 'sidebar';
providerId?: string;
modelId?: string;
permissionMode: AIPermissionMode;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
toolNames: string[];
selectedSkillSlugs: string[];
systemPromptChars: number;
systemPromptHash: string;
injections: Array<{
order: number;
source: 'system-prompt' | 'capability-catalog' | 'user-skills' | 'terminal-scope' | 'web-search';
itemCount: number;
chars?: number;
hash: string;
}>;
webSearchEnabled: boolean;
hostSessionIds: string[];
/** Dynamic request estimate used by the Catty context meter. */
contextWindow?: number;
estimatedInputTokens?: number;
builtAt: number;
}
export function buildPromptContextSnapshot(input: {
providerId?: string;
modelId?: string;
permissionMode: AIPermissionMode;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
toolNames: string[];
selectedSkillSlugs?: string[];
systemPrompt: string;
webSearchEnabled: boolean;
hostSessionIds: string[];
builtAt?: number;
}): PromptContextSnapshot {
const toolNames = [...input.toolNames].sort();
const selectedSkillSlugs = [...(input.selectedSkillSlugs ?? [])].sort();
const hostSessionIds = [...input.hostSessionIds];
const injectionValues = [
{ source: 'system-prompt' as const, values: [input.systemPrompt], chars: input.systemPrompt.length },
{ source: 'capability-catalog' as const, values: toolNames },
{ source: 'user-skills' as const, values: selectedSkillSlugs },
{ source: 'terminal-scope' as const, values: hostSessionIds },
{ source: 'web-search' as const, values: [String(input.webSearchEnabled)] },
];
return {
version: 2,
audience: 'sidebar',
providerId: input.providerId,
modelId: input.modelId,
permissionMode: input.permissionMode,
scopeType: input.scopeType,
scopeLabel: input.scopeLabel,
toolNames,
selectedSkillSlugs,
systemPromptChars: input.systemPrompt.length,
systemPromptHash: hashPromptPart(input.systemPrompt),
injections: injectionValues.map((entry, order) => ({
order,
source: entry.source,
itemCount: entry.values.length,
...(entry.chars != null ? { chars: entry.chars } : {}),
hash: hashPromptPart(entry.values.join('\n')),
})),
webSearchEnabled: input.webSearchEnabled,
hostSessionIds,
builtAt: input.builtAt ?? Date.now(),
};
}
function hashPromptPart(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
}

View File

@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeSdkSessionIdentity, parseSdkSessionIdentity } from './sdkSessionIdentity';
test('SDK session identities preserve Codex runtime and default legacy values to sdk', () => {
const encoded = encodeSdkSessionIdentity('thread-1', 'codex', '/bin/codex', 'app-server');
assert.deepEqual(parseSdkSessionIdentity(encoded), {
v: 1,
id: 'thread-1',
backend: 'codex',
binPath: '/bin/codex',
runtime: 'app-server',
});
const legacy = encodeSdkSessionIdentity('thread-2', 'codex', '/bin/codex');
const payload = JSON.parse(decodeURIComponent(legacy.slice('netcatty-sdk-session:'.length)));
delete payload.runtime;
const legacyWithoutRuntime = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify(payload))}`;
assert.equal(parseSdkSessionIdentity(legacyWithoutRuntime)?.runtime, 'sdk');
});
test('SDK session identities preserve Cursor auth mode', () => {
const encoded = encodeSdkSessionIdentity(
'61668441-bfcb-4795-a575-c46d70ad01fe',
'cursor',
'/usr/bin/agent',
'sdk',
'cli-login',
'agent',
);
assert.deepEqual(parseSdkSessionIdentity(encoded), {
v: 1,
id: '61668441-bfcb-4795-a575-c46d70ad01fe',
backend: 'cursor',
binPath: '/usr/bin/agent',
runtime: 'sdk',
authMode: 'cli-login',
cliMode: 'agent',
});
});
test('SDK session identities preserve Grok ACP and streaming-json runtimes', () => {
const acp = encodeSdkSessionIdentity('sess-acp', 'grok', '/usr/bin/grok', 'acp');
assert.deepEqual(parseSdkSessionIdentity(acp), {
v: 1,
id: 'sess-acp',
backend: 'grok',
binPath: '/usr/bin/grok',
runtime: 'acp',
});
const headless = encodeSdkSessionIdentity(
'sess-json',
'grok',
'/usr/bin/grok',
'streaming-json',
);
assert.deepEqual(parseSdkSessionIdentity(headless), {
v: 1,
id: 'sess-json',
backend: 'grok',
binPath: '/usr/bin/grok',
runtime: 'streaming-json',
});
// Aliases normalize to streaming-json.
const alias = encodeSdkSessionIdentity('sess-cli', 'grok', '/usr/bin/grok', 'headless');
assert.equal(parseSdkSessionIdentity(alias)?.runtime, 'streaming-json');
});

View File

@@ -0,0 +1,80 @@
export const SDK_SESSION_ID_PREFIX = 'netcatty-sdk-session:';
export type CursorAuthModeIdentity = 'api-key' | 'cli-login';
export type CursorCliModeIdentity = 'ask' | 'agent';
/** Codex dual runtime + Grok dual runtime. */
export type SdkRuntimeIdentity = 'sdk' | 'app-server' | 'acp' | 'streaming-json';
export interface SdkSessionIdentityPayload {
v: 1;
id: string;
backend: string;
binPath: string;
runtime?: SdkRuntimeIdentity;
authMode?: CursorAuthModeIdentity;
cliMode?: CursorCliModeIdentity;
}
export function normalizeCursorAuthMode(
authMode: string | undefined | null,
): CursorAuthModeIdentity | undefined {
return authMode === 'cli-login' ? 'cli-login' : authMode === 'api-key' ? 'api-key' : undefined;
}
export function normalizeCursorCliMode(
cliMode: string | undefined | null,
): CursorCliModeIdentity | undefined {
return cliMode === 'ask' ? 'ask' : cliMode === 'agent' ? 'agent' : undefined;
}
export function normalizeSdkRuntime(
runtime: string | undefined | null,
): SdkRuntimeIdentity {
const raw = String(runtime || '').trim().toLowerCase();
if (raw === 'app-server') return 'app-server';
if (raw === 'acp') return 'acp';
if (raw === 'streaming-json' || raw === 'cli' || raw === 'headless') return 'streaming-json';
return 'sdk';
}
export function encodeSdkSessionIdentity(
sessionId: string,
sdkBackend?: string,
binPath?: string,
runtime: string = 'sdk',
authMode?: string,
cliMode?: string,
): string {
if (!sessionId || !sdkBackend) return sessionId;
const payload: SdkSessionIdentityPayload = {
v: 1,
id: sessionId,
backend: sdkBackend,
binPath: binPath || '',
runtime: normalizeSdkRuntime(runtime),
};
const normalizedAuthMode = normalizeCursorAuthMode(authMode);
if (normalizedAuthMode) payload.authMode = normalizedAuthMode;
const normalizedCliMode = normalizeCursorCliMode(cliMode);
if (normalizedCliMode) payload.cliMode = normalizedCliMode;
return `${SDK_SESSION_ID_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`;
}
export function parseSdkSessionIdentity(value: string | undefined | null): SdkSessionIdentityPayload | null {
const raw = String(value || '').trim();
if (!raw.startsWith(SDK_SESSION_ID_PREFIX)) return null;
try {
const parsed = JSON.parse(decodeURIComponent(raw.slice(SDK_SESSION_ID_PREFIX.length))) as SdkSessionIdentityPayload;
if (parsed?.v !== 1 || !parsed.id || !parsed.backend) return null;
const authMode = normalizeCursorAuthMode(parsed.authMode);
const cliMode = normalizeCursorCliMode(parsed.cliMode);
return {
...parsed,
runtime: normalizeSdkRuntime(parsed.runtime),
...(authMode ? { authMode } : {}),
...(cliMode ? { cliMode } : {}),
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SessionStateStore } from './sessionState.ts';
test('SessionStateStore tracks terminal commands and reinjection text', () => {
const store = new SessionStateStore();
store.mergeFromUserGoal('chat-1', 'Fix nginx upstream timeout');
store.updateFromToolResult(
'chat-1',
'terminal_execute',
{ sessionId: 'sess-1', command: 'tail -n 100 /var/log/nginx/error.log' },
'upstream timed out',
false,
);
const text = store.toReinjectionText('chat-1');
assert.ok(text?.includes('Fix nginx upstream timeout'));
assert.ok(text?.includes('sess-1'));
assert.ok(text?.includes('tail -n 100'));
});
test('SessionStateStore records tool errors as blockers', () => {
const store = new SessionStateStore();
store.updateFromToolResult(
'chat-1',
'terminal_execute',
{ sessionId: 'sess-1', command: 'systemctl restart nginx' },
'{ "error": "Job failed" }',
true,
);
const text = store.toReinjectionText('chat-1');
assert.ok(text?.includes('Open blockers'));
});
test('SessionStateStore restores active background jobs and poll cursors', () => {
const store = new SessionStateStore();
store.updateFromToolResult(
'chat-1',
'terminal_start',
{ sessionId: 'sess-1', command: 'npm run dev' },
JSON.stringify({ ok: true, jobId: 'job-1', status: 'running', nextOffset: 0 }),
);
store.updateFromToolResult(
'chat-1',
'terminal_poll',
{ jobId: 'job-1', offset: 0 },
JSON.stringify({ ok: true, jobId: 'job-1', status: 'running', nextOffset: 420 }),
);
const state = store.get('chat-1');
assert.equal(state.version, 1);
assert.equal(state.activeJobs['job-1'].nextOffset, 420);
const text = store.toReinjectionText('chat-1') ?? '';
assert.match(text, /job-1/);
assert.match(text, /offset=420/);
assert.match(text, /poll the existing job/i);
assert.match(text, /do not restart/i);
assert.match(text, /unverified after compaction/i);
});
test('SessionStateStore drops a remembered job after poll reports it missing', () => {
const store = new SessionStateStore();
store.updateFromToolResult(
'chat-1', 'terminal_start', { sessionId: 'sess-1', command: 'npm run dev' },
JSON.stringify({ jobId: 'job-lost', status: 'running' }), false,
);
store.updateFromToolResult(
'chat-1', 'terminal_poll', { jobId: 'job-lost', offset: 0 },
JSON.stringify({ error: 'Job not found' }), true,
);
assert.equal(store.get('chat-1').activeJobs['job-lost'], undefined);
assert.doesNotMatch(store.toReinjectionText('chat-1') ?? '', /Remembered terminal jobs/);
});
test('SessionStateStore preserves a running job after a transient poll error', () => {
const store = new SessionStateStore();
store.updateFromToolResult(
'chat-1', 'terminal_start', { sessionId: 'sess-1', command: 'npm run dev' },
JSON.stringify({ jobId: 'job-running', status: 'running', nextOffset: 420 }), false,
);
store.updateFromToolResult(
'chat-1', 'terminal_poll', { jobId: 'job-running', offset: 420 },
JSON.stringify({ error: 'temporary IPC timeout' }), true,
);
const job = store.get('chat-1').activeJobs['job-running'];
assert.equal(job.status, 'unverified');
assert.equal(job.nextOffset, 420);
assert.match(store.toReinjectionText('chat-1') ?? '', /job-running/);
assert.match(store.toReinjectionText('chat-1') ?? '', /do not restart/i);
});
test('SessionStateStore drops a job after poll confirms cancellation', () => {
const store = new SessionStateStore();
store.updateFromToolResult(
'chat-1', 'terminal_start', { sessionId: 'sess-1', command: 'npm run dev' },
JSON.stringify({ jobId: 'job-cancelled', status: 'running' }), false,
);
store.updateFromToolResult(
'chat-1', 'terminal_poll', { jobId: 'job-cancelled', offset: 0 },
JSON.stringify({ jobId: 'job-cancelled', status: 'cancelled', error: 'Cancelled' }), true,
);
assert.equal(store.get('chat-1').activeJobs['job-cancelled'], undefined);
});
test('SessionStateStore records the last terminal screen range read', () => {
const store = new SessionStateStore();
store.updateFromToolResult(
'chat-1',
'terminal_read_context',
{ sessionId: 'sess-1', range: 'tail', startLine: 80, maxLines: 20 },
JSON.stringify({ ok: true, sessionId: 'sess-1', startLine: 80, endLine: 99 }),
);
assert.deepEqual(store.get('chat-1').terminalReadCursors['sess-1'], {
range: 'tail',
startLine: 80,
endLine: 99,
});
});
test('SessionStateStore reinjects edited files and unfinished plan items', () => {
const store = new SessionStateStore();
store.mergeFileChanges('chat-1', ['/repo/src/a.ts', '/repo/src/b.ts']);
store.mergePlan('chat-1', [
{ text: 'inspect failure', completed: true },
{ text: 'run regression tests', completed: false },
]);
const text = store.toReinjectionText('chat-1') ?? '';
assert.match(text, /\/repo\/src\/a\.ts/);
assert.match(text, /\[done\] inspect failure/);
assert.match(text, /\[todo\] run regression tests/);
});

View File

@@ -0,0 +1,294 @@
import { redactSecretsForModel } from './modelSecretRedaction';
const MAX_DECISIONS = 15;
const MAX_BLOCKERS = 10;
export interface ActiveTerminalJobState {
sessionId?: string;
command?: string;
status: string;
nextOffset: number;
}
export interface TerminalReadCursorState {
range: string;
startLine?: number;
endLine?: number;
}
export interface CattySessionState {
version: 1;
userGoal?: string;
decisions: string[];
activeHosts: Record<string, { hostname?: string; lastCommand?: string }>;
activeJobs: Record<string, ActiveTerminalJobState>;
terminalReadCursors: Record<string, TerminalReadCursorState>;
editedFiles: string[];
planItems: Array<{ text: string; completed: boolean }>;
blockers: string[];
updatedAt: number;
}
function emptyState(): CattySessionState {
return {
version: 1,
decisions: [],
activeHosts: {},
activeJobs: {},
terminalReadCursors: {},
editedFiles: [],
planItems: [],
blockers: [],
updatedAt: Date.now(),
};
}
function pushUnique(list: string[], value: string, cap: number): string[] {
const trimmed = value.trim();
if (!trimmed || list.includes(trimmed)) return list;
return [...list, trimmed].slice(-cap);
}
function parseResultObject(resultText: string): Record<string, unknown> | undefined {
try {
const parsed = JSON.parse(resultText);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : undefined;
} catch {
return undefined;
}
}
function terminalJobDefinitelyGone(result: Record<string, unknown> | undefined, resultText: string): boolean {
const status = typeof result?.status === 'string' ? result.status.toLowerCase() : '';
if (['completed', 'failed', 'stopped', 'exited', 'cancelled', 'canceled', 'not_found'].includes(status)) return true;
const error = typeof result?.error === 'string' ? result.error : resultText;
return /\b(?:job|task)\b.{0,40}\b(?:not found|does not exist|no longer exists|already (?:finished|completed|exited|stopped))\b/i.test(error)
|| /\b(?:unknown|no such)\s+(?:job|task)\b/i.test(error);
}
export class SessionStateStore {
private readonly bySession = new Map<string, CattySessionState>();
get(chatSessionId: string): CattySessionState {
return this.bySession.get(chatSessionId) ?? emptyState();
}
clear(chatSessionId: string): void {
this.bySession.delete(chatSessionId);
}
mergeFromUserGoal(chatSessionId: string, goal: string | undefined): void {
if (!goal?.trim()) return;
const state = { ...this.get(chatSessionId) };
state.userGoal = goal.trim().slice(0, 500);
state.updatedAt = Date.now();
this.bySession.set(chatSessionId, state);
}
mergeFromAssistantContent(chatSessionId: string, content: string): void {
const decisionPatterns = [
/\bdecided to\b[:\s]+(.{10,200})/i,
/\bwill use\b[:\s]+(.{10,200})/i,
/\bconstraint[:\s]+(.{10,200})/i,
];
let state = this.get(chatSessionId);
for (const pattern of decisionPatterns) {
const match = content.match(pattern);
if (match?.[1]) {
state = {
...state,
decisions: pushUnique(state.decisions, match[1].trim(), MAX_DECISIONS),
updatedAt: Date.now(),
};
}
}
this.bySession.set(chatSessionId, state);
}
mergeFileChanges(chatSessionId: string, paths: string[]): void {
const state = { ...this.get(chatSessionId) };
state.editedFiles = paths.reduce(
(files, path) => pushUnique(files, path, 50),
state.editedFiles,
);
state.updatedAt = Date.now();
this.bySession.set(chatSessionId, state);
}
mergePlan(chatSessionId: string, items: Array<{ text: string; completed: boolean }>): void {
const state = { ...this.get(chatSessionId) };
state.planItems = items.slice(-30).map(item => ({
text: item.text.slice(0, 300),
completed: item.completed,
}));
state.updatedAt = Date.now();
this.bySession.set(chatSessionId, state);
}
updateFromToolResult(
chatSessionId: string,
toolName: string,
args: Record<string, unknown> | undefined,
resultText: string,
isError?: boolean,
): void {
const state = { ...this.get(chatSessionId) };
const name = toolName.toLowerCase();
const result = parseResultObject(resultText);
if (name === 'terminal_execute' || name === 'terminal.execute') {
const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : undefined;
const command = typeof args?.command === 'string' ? args.command : undefined;
if (sessionId) {
state.activeHosts = {
...state.activeHosts,
[sessionId]: {
...state.activeHosts[sessionId],
lastCommand: command,
},
};
}
}
if (name === 'terminal_start' || name === 'terminal.start') {
const jobId = typeof result?.jobId === 'string' ? result.jobId : undefined;
if (jobId && !isError) {
state.activeJobs = {
...state.activeJobs,
[jobId]: {
sessionId: typeof args?.sessionId === 'string' ? args.sessionId : undefined,
command: typeof args?.command === 'string' ? args.command : undefined,
status: typeof result?.status === 'string' ? result.status : 'running',
nextOffset: typeof result?.nextOffset === 'number' ? result.nextOffset : 0,
},
};
}
}
if (name === 'terminal_poll' || name === 'terminal.poll') {
const jobId = typeof args?.jobId === 'string'
? args.jobId
: typeof result?.jobId === 'string' ? result.jobId : undefined;
if (jobId && !isError) {
const status = typeof result?.status === 'string' ? result.status : 'running';
if (status === 'running' || status === 'stopping') {
state.activeJobs = {
...state.activeJobs,
[jobId]: {
...state.activeJobs[jobId],
status,
nextOffset: typeof result?.nextOffset === 'number'
? result.nextOffset
: state.activeJobs[jobId]?.nextOffset ?? 0,
},
};
} else if (state.activeJobs[jobId]) {
state.activeJobs = { ...state.activeJobs };
delete state.activeJobs[jobId];
}
} else if (jobId && state.activeJobs[jobId]) {
if (terminalJobDefinitelyGone(result, resultText)) {
state.activeJobs = { ...state.activeJobs };
delete state.activeJobs[jobId];
} else {
state.activeJobs = {
...state.activeJobs,
[jobId]: { ...state.activeJobs[jobId], status: 'unverified' },
};
}
}
}
if (name === 'terminal_stop' || name === 'terminal.stop') {
const jobId = typeof args?.jobId === 'string' ? args.jobId : undefined;
if (jobId && state.activeJobs[jobId]) {
state.activeJobs = {
...state.activeJobs,
[jobId]: { ...state.activeJobs[jobId], status: 'stopping' },
};
}
}
if (name === 'terminal_read_context' || name === 'terminal.read_context') {
const sessionId = typeof args?.sessionId === 'string'
? args.sessionId
: typeof result?.sessionId === 'string' ? result.sessionId : undefined;
if (sessionId && !isError) {
state.terminalReadCursors = {
...state.terminalReadCursors,
[sessionId]: {
range: typeof args?.range === 'string' ? args.range : 'viewport',
startLine: typeof result?.startLine === 'number'
? result.startLine
: typeof args?.startLine === 'number' ? args.startLine : undefined,
endLine: typeof result?.endLine === 'number' ? result.endLine : undefined,
},
};
}
}
if ((name === 'session_close' || name === 'session.close') && !isError) {
const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : undefined;
if (sessionId) {
state.activeHosts = { ...state.activeHosts };
state.terminalReadCursors = { ...state.terminalReadCursors };
delete state.activeHosts[sessionId];
delete state.terminalReadCursors[sessionId];
state.activeJobs = Object.fromEntries(
Object.entries(state.activeJobs).filter(([, job]) => job.sessionId !== sessionId),
);
}
}
if (isError) {
const preview = resultText.slice(0, 160).replace(/\s+/g, ' ').trim();
if (preview) {
state.blockers = pushUnique(state.blockers, `${toolName}: ${preview}`, MAX_BLOCKERS);
}
}
state.updatedAt = Date.now();
this.bySession.set(chatSessionId, state);
}
toReinjectionText(chatSessionId: string): string | undefined {
const state = this.get(chatSessionId);
const lines: string[] = [];
if (state.userGoal) lines.push(`User goal: ${state.userGoal}`);
if (state.decisions.length) {
lines.push(`Decisions: ${state.decisions.slice(-5).join('; ')}`);
}
const hosts = Object.entries(state.activeHosts);
if (hosts.length) {
const hostSummary = hosts
.slice(-5)
.map(([id, host]) => `${id}${host.lastCommand ? ` (last: ${redactSecretsForModel(host.lastCommand)})` : ''}`)
.join(', ');
lines.push(`Active hosts: ${hostSummary}`);
}
const jobs = Object.entries(state.activeJobs);
if (jobs.length) {
const jobSummary = jobs.slice(-5).map(([jobId, job]) => (
`${jobId} (status=${job.status}, offset=${job.nextOffset}${job.sessionId ? `, session=${job.sessionId}` : ''}${job.command ? `, command=${redactSecretsForModel(job.command)}` : ''})`
)).join('; ');
lines.push(`Remembered terminal jobs (status is unverified after compaction): ${jobSummary}. Poll the existing job from its saved offset to verify current status; do not restart its command.`);
}
const cursors = Object.entries(state.terminalReadCursors);
if (cursors.length) {
lines.push(`Terminal read cursors: ${cursors.slice(-5).map(([id, cursor]) => `${id} (${cursor.range}, lines=${cursor.startLine ?? '?'}-${cursor.endLine ?? '?'})`).join(', ')}`);
}
if (state.editedFiles.length) {
lines.push(`Edited files: ${state.editedFiles.slice(-20).join(', ')}`);
}
if (state.planItems.length) {
lines.push(`Plan: ${state.planItems.map(item => `${item.completed ? '[done]' : '[todo]'} ${item.text}`).join('; ')}`);
}
if (state.blockers.length) {
lines.push(`Open blockers: ${state.blockers.slice(-3).join('; ')}`);
}
if (lines.length === 0) return undefined;
return lines.join('\n');
}
}
export const globalSessionStateStore = new SessionStateStore();

View File

@@ -0,0 +1,466 @@
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/);
});

View File

@@ -0,0 +1,284 @@
import type { ModelMessage } from 'ai';
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
const SUPERSEDED_READ_PREFIX = '[superseded read:';
const EARLIER_TERMINAL_PREFIX = '[earlier terminal output omitted:';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function getToolCallMap(messages: ModelMessage[]): Map<string, { toolName: string; input: unknown }> {
const map = new Map<string, { toolName: string; input: unknown }>();
for (const message of messages) {
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
for (const part of message.content as unknown[]) {
if (!isRecord(part) || part.type !== 'tool-call') continue;
const toolCallId = typeof part.toolCallId === 'string' ? part.toolCallId : '';
const toolName = typeof part.toolName === 'string' ? part.toolName : '';
if (toolCallId) {
map.set(toolCallId, { toolName, input: part.input });
}
}
}
return map;
}
function getToolResultParts(message: ModelMessage): Array<Record<string, unknown>> {
if (message.role !== 'tool' || !Array.isArray(message.content)) return [];
return (message.content as unknown[]).filter((part) => {
return isRecord(part) && part.type === 'tool-result';
}) as Array<Record<string, unknown>>;
}
function getToolResultText(part: Record<string, unknown>): string {
const output = part.output;
if (isRecord(output) && output.type === 'text' && typeof output.value === 'string') {
return output.value;
}
if (typeof output === 'string') return output;
return '';
}
function isToolResultError(part: Record<string, unknown>, text: string): boolean {
if (part.isError === true) return true;
if (text.toLowerCase().includes('error')) return true;
return isToolResultErrorOutput(part.output);
}
function isToolResultErrorOutput(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
if (obj.type === 'json' || obj.type === 'object') {
return isToolResultErrorOutput(obj.value);
}
}
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output) as Record<string, unknown>;
if ('error' in parsed && typeof parsed.error === 'string') return true;
if ('ok' in parsed && parsed.ok === false) return true;
} catch {
return false;
}
}
return false;
}
function isCachedOrSuperseded(text: string): boolean {
return text.includes('[cached]')
|| text.startsWith(SUPERSEDED_READ_PREFIX)
|| text.startsWith(EARLIER_TERMINAL_PREFIX);
}
function isSftpReadTool(toolName: string): boolean {
return toolName === 'sftp_read'
|| toolName === 'sftp.read'
|| toolName === 'sftp_read_file';
}
function isSafeReadOnlyToolName(toolName: string): boolean {
const segments = toolName.toLowerCase().split(/[._-]+/);
const readMarkers = new Set(['read', 'get', 'list', 'search', 'fetch', 'inspect', 'status', 'info', 'poll']);
const writeMarkers = new Set([
'write', 'set', 'update', 'create', 'delete', 'remove', 'start', 'stop', 'close',
'execute', 'exec', 'run', 'upload', 'download', 'move', 'copy', 'rename', 'kill',
]);
return segments.some(segment => readMarkers.has(segment))
&& !segments.some(segment => writeMarkers.has(segment));
}
function readFingerprint(toolName: string, args: unknown): string | null {
if (!isRecord(args)) return null;
if (toolName === 'terminal_poll' || toolName === 'terminal.poll') {
const jobId = args.jobId;
if (typeof jobId !== 'string') return null;
return `terminal-poll:${jobId}:${String(args.offset ?? 0)}`;
}
if (toolName === 'terminal_read_context' || toolName === 'terminal.read_context') {
const sessionId = args.sessionId;
if (typeof sessionId !== 'string') return null;
return [
'terminal-context',
sessionId,
String(args.range ?? 'viewport'),
String(args.startLine ?? ''),
String(args.maxLines ?? ''),
].join(':');
}
if (isSftpReadTool(toolName)) {
const path = args.path ?? args.remotePath;
if (typeof path !== 'string') return null;
const sessionId = args.sessionId;
const sessionPart = typeof sessionId === 'string' ? sessionId : '';
return `read:${sessionPart}:${path}`;
}
if (toolName === 'read_attachment' || toolName === 'harness.read_attachment') {
const id = args.attachmentId ?? args.id ?? args.filename ?? args.name;
return id != null ? `attachment:${String(id)}` : null;
}
return null;
}
function terminalFingerprint(toolName: string, args: unknown): string | null {
if (toolName !== 'terminal_execute' && toolName !== 'terminal.execute') return null;
if (!isRecord(args)) return null;
const sessionId = args.sessionId;
return typeof sessionId === 'string' ? `terminal:${sessionId}` : null;
}
function replaceToolResultText(part: Record<string, unknown>, text: string): Record<string, unknown> {
const output = part.output;
if (isRecord(output) && output.type === 'text') {
return { ...part, output: { ...output, value: text } };
}
return { ...part, output: { type: 'text', value: text } };
}
function redactToolCallInputs(message: ModelMessage): ModelMessage {
if (message.role !== 'assistant' || !Array.isArray(message.content)) return message;
let changed = false;
const content = (message.content as unknown[]).map(part => {
if (!isRecord(part) || part.type !== 'tool-call' || !('input' in part)) return part;
const redacted = redactSecretsInValueForModel(part.input);
if (JSON.stringify(redacted) === JSON.stringify(part.input)) return part;
changed = true;
return { ...part, input: redacted };
});
return changed ? ({ ...message, content } as ModelMessage) : message;
}
function compressMessageToolResults(
message: ModelMessage,
updater: (toolName: string, args: unknown, text: string, isError: boolean) => string | null,
toolCallMap: Map<string, { toolName: string; input: unknown }>,
): ModelMessage {
const parts = getToolResultParts(message);
if (parts.length === 0) return message;
let changed = false;
const nextContent = (message.content as unknown[]).map((part) => {
if (!isRecord(part) || part.type !== 'tool-result') return part;
const toolCallId = typeof part.toolCallId === 'string' ? part.toolCallId : '';
const meta = toolCallMap.get(toolCallId);
const toolName = meta?.toolName ?? (typeof part.toolName === 'string' ? part.toolName : '');
const args = meta?.input;
const text = getToolResultText(part);
const isError = isToolResultError(part, text);
if (isCachedOrSuperseded(text)) return part;
const replacement = updater(toolName, args, text, isError);
if (replacement == null || replacement === text) return part;
changed = true;
return replaceToolResultText(part, replacement);
});
return changed ? ({ ...message, content: nextContent } as ModelMessage) : message;
}
export interface PruneStaleToolContextOptions {
/** Supersede stale reads and omit older terminal output only under context budget pressure. */
underBudgetPressure?: boolean;
}
export function pruneStaleToolContext(
messages: ModelMessage[],
options: PruneStaleToolContextOptions = {},
): {
messages: ModelMessage[];
didAdjust: boolean;
} {
const toolCallMap = getToolCallMap(messages);
const latestReadByKey = new Map<string, number>();
const terminalExecutionsBySession = new Map<string, Array<{ index: number; command?: string }>>();
const underBudgetPressure = options.underBudgetPressure === true;
const userTurnsAfter = new Array<number>(messages.length).fill(0);
let laterUserTurns = 0;
for (let index = messages.length - 1; index >= 0; index -= 1) {
userTurnsAfter[index] = laterUserTurns;
if (messages[index].role === 'user') laterUserTurns += 1;
}
messages.forEach((message, index) => {
for (const part of getToolResultParts(message)) {
const toolCallId = typeof part.toolCallId === 'string' ? part.toolCallId : '';
const meta = toolCallMap.get(toolCallId);
const toolName = meta?.toolName ?? (typeof part.toolName === 'string' ? part.toolName : '');
const args = meta?.input;
const readKey = readFingerprint(toolName, args);
if (readKey) {
const text = getToolResultText(part);
const isError = isToolResultError(part, text);
if (!isError) {
latestReadByKey.set(readKey, index);
}
}
const termKey = terminalFingerprint(toolName, args);
if (underBudgetPressure && termKey) {
const callArgs = isRecord(args) ? args : {};
const entries = terminalExecutionsBySession.get(termKey) ?? [];
entries.push({
index,
command: typeof callArgs.command === 'string' ? callArgs.command : undefined,
});
terminalExecutionsBySession.set(termKey, entries);
}
}
});
const keepTerminalIndices = new Set<number>();
if (underBudgetPressure) {
for (const entries of terminalExecutionsBySession.values()) {
for (const entry of entries.slice(-2)) {
keepTerminalIndices.add(entry.index);
}
}
}
const terminalOmitByIndex = new Map<number, string>();
if (underBudgetPressure) {
for (const entries of terminalExecutionsBySession.values()) {
for (const entry of entries) {
if (keepTerminalIndices.has(entry.index)) continue;
terminalOmitByIndex.set(
entry.index,
`${EARLIER_TERMINAL_PREFIX} command=${redactSecretsForModel(entry.command ?? 'unknown')}]`,
);
}
}
}
let didAdjust = false;
const next = messages.map((message, index) => {
const redactedMessage = redactToolCallInputs(message);
const updated = compressMessageToolResults(redactedMessage, (toolName, args, text, isError) => {
if (isError) return null;
const readKey = readFingerprint(toolName, args);
if (underBudgetPressure && readKey) {
const latestIndex = latestReadByKey.get(readKey);
if (latestIndex != null && latestIndex !== index) {
return `${SUPERSEDED_READ_PREFIX} ${readKey}]`;
}
}
const termKey = terminalFingerprint(toolName, args);
if (underBudgetPressure && termKey && terminalOmitByIndex.has(index)) {
return terminalOmitByIndex.get(index)!;
}
if (underBudgetPressure) {
const age = userTurnsAfter[index];
if (age > 10 && !isError && isSafeReadOnlyToolName(toolName)) {
return `[older tool result omitted: tool=${toolName || 'unknown'}, chars=${text.length}]`;
}
if (age >= 3 && text.length > 4_000) {
return `${text.slice(0, 1_500)}\n\n[... tool result shortened: ${text.length - 3_000} chars omitted ...]\n\n${text.slice(-1_500)}`;
}
}
return null;
}, toolCallMap);
if (updated !== message) didAdjust = true;
return updated;
});
return { messages: next, didAdjust };
}

View File

@@ -0,0 +1,111 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildCattyStreamTimeouts } from './streamTimeouts';
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
import {
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeResponseIdleTimeoutSeconds,
} from '../types';
describe('normalizeResponseIdleTimeoutSeconds', () => {
it('keeps stored response wait values within the supported range', () => {
assert.equal(normalizeResponseIdleTimeoutSeconds(Number.NaN), DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS);
assert.equal(normalizeResponseIdleTimeoutSeconds(0), 1);
assert.equal(
normalizeResponseIdleTimeoutSeconds(MAX_RESPONSE_IDLE_TIMEOUT_SECONDS + 1),
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
);
});
});
describe('buildCattyStreamTimeouts', () => {
it('uses the configured response idle timeout for inactive model streams', () => {
const responseIdleTimeoutMs = 12 * 60 * 1000;
const timeouts = buildCattyStreamTimeouts({ responseIdleTimeoutMs });
assert.equal(timeouts.chunkMs, responseIdleTimeoutMs);
assert.ok(timeouts.stepMs > responseIdleTimeoutMs);
assert.ok(timeouts.totalMs != null);
assert.ok(timeouts.totalMs > responseIdleTimeoutMs);
});
it('scales the total stream budget so every step can use the configured response wait', () => {
const responseIdleTimeoutMs = 20 * 60 * 1000;
const timeouts = buildCattyStreamTimeouts({
responseIdleTimeoutMs,
maxIterations: 3,
});
assert.ok(timeouts.totalMs != null);
assert.ok(timeouts.totalMs > responseIdleTimeoutMs * 3);
});
it('keeps stream budgets from undercutting the configured command timeout', () => {
const oneDayMs = 86_400 * 1000;
const timeouts = buildCattyStreamTimeouts({
commandTimeoutMs: oneDayMs,
});
assert.ok(timeouts.chunkMs > oneDayMs);
assert.ok(timeouts.toolMs > oneDayMs);
assert.ok(timeouts.stepMs > oneDayMs);
assert.ok(timeouts.totalMs > oneDayMs);
});
it('budgets sequential response waiting and command execution in each step', () => {
const responseIdleTimeoutMs = 20 * 60 * 1000;
const commandTimeoutMs = 10 * 60 * 1000;
const timeouts = buildCattyStreamTimeouts({
responseIdleTimeoutMs,
commandTimeoutMs,
maxIterations: 2,
});
assert.ok(timeouts.stepMs > responseIdleTimeoutMs + commandTimeoutMs);
assert.ok(timeouts.totalMs != null);
assert.ok(timeouts.totalMs >= timeouts.stepMs * 2);
});
it('includes confirm-mode hard approval deadline in long command stream budgets', () => {
const commandTimeoutMs = 60 * 1000;
const expectedMinimum = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
const timeouts = buildCattyStreamTimeouts({
permissionMode: 'confirm',
commandTimeoutMs,
});
assert.ok(timeouts.chunkMs >= expectedMinimum);
assert.ok(timeouts.toolMs >= expectedMinimum);
assert.ok(timeouts.stepMs >= expectedMinimum);
assert.ok(timeouts.totalMs >= expectedMinimum);
});
it('scales the total stream budget for multi-step long command turns', () => {
const commandTimeoutMs = 10 * 60 * 1000;
const singleStepBudgetMs = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
const timeouts = buildCattyStreamTimeouts({
permissionMode: 'confirm',
commandTimeoutMs,
maxIterations: 2,
});
assert.ok(timeouts.chunkMs >= singleStepBudgetMs);
assert.ok(timeouts.toolMs >= singleStepBudgetMs);
assert.ok(timeouts.stepMs >= singleStepBudgetMs);
assert.ok(timeouts.totalMs != null);
assert.ok(timeouts.totalMs >= singleStepBudgetMs * 2);
});
it('omits total timeout when the multi-step budget exceeds timer limits', () => {
const timeouts = buildCattyStreamTimeouts({
commandTimeoutMs: 86_400 * 1000,
maxIterations: 100,
});
assert.equal(timeouts.totalMs, undefined);
assert.ok(timeouts.chunkMs > 86_400 * 1000);
assert.ok(timeouts.toolMs > 86_400 * 1000);
assert.ok(timeouts.stepMs > 86_400 * 1000);
});
});

View File

@@ -0,0 +1,54 @@
import type { AIPermissionMode } from '../types';
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
const TEN_MINUTES_MS = 10 * 60 * 1000;
const TWO_MINUTES_MS = 2 * 60 * 1000;
const NINETY_SECONDS_MS = 90 * 1000;
const COMPACTION_TIMEOUT_MS = 90 * 1000;
const MAX_ABORT_TIMEOUT_MS = 2_147_483_647;
export interface BuildCattyStreamTimeoutsInput {
permissionMode?: AIPermissionMode;
commandTimeoutMs?: number;
responseIdleTimeoutMs?: number;
maxIterations?: number;
}
/** v7 streamText timeout profile for Catty multi-step agent turns. */
export function buildCattyStreamTimeouts(
input: BuildCattyStreamTimeoutsInput = {},
) {
// Budget the hard approval deadline so a mid-review re-arm is not cut off by toolMs.
const approvalBudgetMs = input.permissionMode === 'confirm' ? CATTY_APPROVAL_HARD_DEADLINE_MS : 0;
const stepCount =
Number.isFinite(input.maxIterations) && input.maxIterations != null && input.maxIterations > 0
? Math.max(1, Math.floor(input.maxIterations))
: 1;
const commandTimeoutBudgetMs =
Number.isFinite(input.commandTimeoutMs) && input.commandTimeoutMs > 0
? input.commandTimeoutMs + approvalBudgetMs + NINETY_SECONDS_MS
: 0;
const responseIdleTimeoutMs =
Number.isFinite(input.responseIdleTimeoutMs) && input.responseIdleTimeoutMs > 0
? input.responseIdleTimeoutMs
: TWO_MINUTES_MS;
const responseStepBudgetMs = responseIdleTimeoutMs + NINETY_SECONDS_MS;
const stepBudgetMs = Math.max(
TEN_MINUTES_MS,
responseStepBudgetMs + commandTimeoutBudgetMs,
);
const totalBudgetMs = Math.max(THIRTY_MINUTES_MS, stepBudgetMs * stepCount);
const totalMs = totalBudgetMs <= MAX_ABORT_TIMEOUT_MS ? totalBudgetMs : undefined;
return {
totalMs,
stepMs: stepBudgetMs,
chunkMs: Math.max(responseIdleTimeoutMs, commandTimeoutBudgetMs),
toolMs: Math.max(CATTY_APPROVAL_HARD_DEADLINE_MS + NINETY_SECONDS_MS, commandTimeoutBudgetMs),
};
}
/** Shorter timeout for LLM compaction summarize calls. */
export function buildCattyCompactionTimeout() {
return COMPACTION_TIMEOUT_MS;
}

View File

@@ -0,0 +1,100 @@
import { compressVerboseText, truncateTextWithHeadAndTail } from '../requestPayloadCompression';
import type { ToolOutputStore } from './toolOutputStore';
import { redactSecretsForModel } from './modelSecretRedaction';
export const MAX_LIVE_TERMINAL_STDOUT_CHARS = 24_000;
export const MAX_LIVE_TERMINAL_STDERR_CHARS = 12_000;
export interface TerminalExecuteResult {
stdout: string;
stderr: string;
exitCode: number | null;
command?: string;
sessionId?: string;
}
export interface TerminalOutputHandle {
kind: 'terminal-output';
sessionId: string;
command?: string;
totalStdoutChars: number;
totalStderrChars: number;
handleId?: string;
restartPersistenceAvailable?: boolean;
}
export interface FitTerminalExecuteResultOptions {
chatSessionId?: string;
toolOutputStore?: ToolOutputStore;
}
export function fitTerminalExecuteResultForModel(
result: TerminalExecuteResult,
options?: FitTerminalExecuteResultOptions,
): TerminalExecuteResult {
const stdout = truncateTextWithHeadAndTail(
redactSecretsForModel(compressVerboseText(result.stdout)),
MAX_LIVE_TERMINAL_STDOUT_CHARS,
);
const stderr = truncateTextWithHeadAndTail(
redactSecretsForModel(compressVerboseText(result.stderr)),
MAX_LIVE_TERMINAL_STDERR_CHARS,
);
const fitted: TerminalExecuteResult = {
...result,
command: result.command ? redactSecretsForModel(result.command) : result.command,
stdout,
stderr,
};
if (
stdout.length < result.stdout.length
|| stderr.length < result.stderr.length
) {
const fullContent = [
result.command ? `command: ${result.command}` : '',
result.stdout ? `stdout:\n${result.stdout}` : '',
result.stderr ? `stderr:\n${result.stderr}` : '',
].filter(Boolean).join('\n\n');
let handleId: string | undefined;
if (options?.toolOutputStore && options.chatSessionId && fullContent) {
handleId = options.toolOutputStore.store({
chatSessionId: options.chatSessionId,
capabilityId: 'terminal.execute',
sessionId: result.sessionId,
content: fullContent,
}).id;
}
const handle: TerminalOutputHandle = {
kind: 'terminal-output',
sessionId: result.sessionId ?? 'unknown',
command: result.command ? redactSecretsForModel(result.command) : result.command,
totalStdoutChars: result.stdout.length,
totalStderrChars: result.stderr.length,
handleId,
restartPersistenceAvailable: false,
};
fitted.stdout = appendOutputHandleNotice(stdout, handle, 'stdout');
if (result.stderr) {
fitted.stderr = appendOutputHandleNotice(stderr, handle, 'stderr');
}
}
return fitted;
}
function appendOutputHandleNotice(
truncated: string,
handle: TerminalOutputHandle,
stream: 'stdout' | 'stderr',
): string {
const totalChars = stream === 'stdout' ? handle.totalStdoutChars : handle.totalStderrChars;
const handleSuffix = handle.handleId ? ` handleId=${handle.handleId}` : '';
const restartSuffix = handle.handleId && handle.restartPersistenceAvailable === false
? ' restartPersistence=unavailable (read before closing the app)'
: '';
return `${truncated}\n\n[output handle: session=${handle.sessionId}${handle.command ? ` command=${handle.command}` : ''} ${stream}=${totalChars} chars truncated for model context${handleSuffix}${restartSuffix}]`;
}

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isStreamingMonitorCommand, TerminalMonitorGuard } from './terminalMonitorGuard';
import { applyMonitorStopResult } from './capabilityTools';
test('TerminalMonitorGuard bounds lines and batches', () => {
const guard = new TerminalMonitorGuard();
const result = guard.process('chat:job', `${'x'.repeat(800)}\n${'line\n'.repeat(1_000)}`);
assert.equal(result.action, 'deliver');
assert.ok((result.content?.length ?? Infinity) <= 3_000);
assert.ok((result.content?.split('\n')[0].length ?? Infinity) <= 500);
});
test('TerminalMonitorGuard suppresses bursts and stops a sustained overload', () => {
let now = 0;
const guard = new TerminalMonitorGuard({ now: () => now });
for (let index = 0; index < 10; index += 1) {
assert.equal(guard.process('chat:job', `line ${index}`).action, 'deliver');
}
assert.equal(guard.process('chat:job', 'burst').action, 'suppress');
let action = guard.process('chat:job', 'still flooding').action;
for (now = 1_000; now <= 31_000 && action !== 'stop'; now += 1_000) {
action = guard.process('chat:job', 'still flooding').action;
}
assert.equal(action, 'stop');
});
test('monitor stop result does not claim success when the backend stop failed', () => {
const failed = applyMonitorStopResult(
{ jobId: 'job-1', status: 'running' },
{ ok: false, error: 'lost worker' },
12,
);
assert.equal(failed.status, 'running');
assert.match(String(failed.output), /stop failed/);
assert.match(String(failed.output), /may still be running/);
const accepted = applyMonitorStopResult(
{ jobId: 'job-1', status: 'running' },
{ ok: true },
12,
);
assert.equal(accepted.status, 'stopping');
assert.match(String(accepted.output), /stop requested/);
});
test('isStreamingMonitorCommand requires an actual follow option', () => {
assert.equal(isStreamingMonitorCommand('tail app-file.log'), false);
assert.equal(isStreamingMonitorCommand('tail -n 10 foo.log'), false);
assert.equal(isStreamingMonitorCommand('journalctl --since 5m foo'), false);
assert.equal(isStreamingMonitorCommand('tail -f app.log'), true);
assert.equal(isStreamingMonitorCommand('tail -n0F app.log'), true);
assert.equal(isStreamingMonitorCommand('journalctl --follow -u nginx'), true);
assert.equal(isStreamingMonitorCommand('docker logs -f api'), true);
assert.equal(isStreamingMonitorCommand('kubectl logs --follow pod/api'), true);
assert.equal(isStreamingMonitorCommand('cd /srv && watch npm test'), true);
});
test('isStreamingMonitorCommand recognizes common wrappers and compose logs', () => {
assert.equal(isStreamingMonitorCommand('sudo journalctl -f -u nginx'), true);
assert.equal(isStreamingMonitorCommand('sudo -u root tail -f /var/log/syslog'), true);
assert.equal(isStreamingMonitorCommand('env LANG=C tail --follow app.log'), true);
assert.equal(isStreamingMonitorCommand('stdbuf -oL kubectl logs -f pod/api'), true);
assert.equal(isStreamingMonitorCommand('timeout 60s tail -f app.log'), true);
assert.equal(isStreamingMonitorCommand('docker compose logs -f api'), true);
assert.equal(isStreamingMonitorCommand('sudo -H journalctl -f -u nginx'), true);
assert.equal(isStreamingMonitorCommand('kubectl -n production logs -f pod/api'), true);
assert.equal(isStreamingMonitorCommand('kubectl --context prod logs --follow pod/api'), true);
assert.equal(isStreamingMonitorCommand('docker --context prod logs -f api'), true);
assert.equal(isStreamingMonitorCommand('docker compose -f compose.prod.yml logs -f api'), true);
assert.equal(isStreamingMonitorCommand('docker compose -p demo logs -f api'), true);
assert.equal(isStreamingMonitorCommand('sudo tail -n 10 app.log'), false);
assert.equal(isStreamingMonitorCommand('timeout 60s journalctl --since 5m'), false);
});

View File

@@ -0,0 +1,208 @@
import { compressVerboseText } from '../requestPayloadCompression';
const MONITOR_LINE_MAX_CHARS = 500;
const MONITOR_BATCH_MAX_CHARS = 3_000;
const MONITOR_BURST = 10;
const MONITOR_REFILL_MS = 2_000;
const MONITOR_OVERLOAD_STOP_MS = 30_000;
interface MonitorState {
tokens: number;
lastRefillAt: number;
overloadedAt?: number;
lastSuppressedAt?: number;
suppressedCount: number;
}
export type MonitorGuardResult =
| { action: 'deliver'; content: string; suppressedCount: number; sourceTruncated: boolean }
| { action: 'suppress'; suppressedCount: number }
| { action: 'stop'; suppressedCount: number };
export class TerminalMonitorGuard {
private readonly states = new Map<string, MonitorState>();
private readonly now: () => number;
constructor(options: { now?: () => number } = {}) {
this.now = options.now ?? Date.now;
}
process(key: string, output: string): MonitorGuardResult {
const now = this.now();
const state = this.states.get(key) ?? {
tokens: MONITOR_BURST,
lastRefillAt: now,
suppressedCount: 0,
};
if (state.lastSuppressedAt != null && now - state.lastSuppressedAt > MONITOR_REFILL_MS * 2) {
state.overloadedAt = undefined;
state.lastSuppressedAt = undefined;
state.suppressedCount = 0;
}
if (state.overloadedAt != null && now - state.overloadedAt >= MONITOR_OVERLOAD_STOP_MS) {
this.states.delete(key);
return { action: 'stop', suppressedCount: state.suppressedCount + 1 };
}
const refill = Math.floor((now - state.lastRefillAt) / MONITOR_REFILL_MS);
if (refill > 0) {
state.tokens = Math.min(MONITOR_BURST, state.tokens + refill);
state.lastRefillAt += refill * MONITOR_REFILL_MS;
}
if (state.tokens <= 0) {
state.suppressedCount += 1;
state.overloadedAt ??= now;
state.lastSuppressedAt = now;
this.states.set(key, state);
return { action: 'suppress', suppressedCount: state.suppressedCount };
}
state.tokens -= 1;
const suppressedCount = state.suppressedCount;
state.suppressedCount = 0;
this.states.set(key, state);
const prefix = suppressedCount > 0 ? `[${suppressedCount} monitor batches suppressed]\n` : '';
const fitted = fitMonitorBatch(`${prefix}${output}`);
return {
action: 'deliver',
content: fitted.content,
suppressedCount,
sourceTruncated: fitted.sourceTruncated,
};
}
clear(key: string): void {
this.states.delete(key);
}
clearPrefix(prefix: string): void {
for (const key of this.states.keys()) {
if (key.startsWith(prefix)) this.states.delete(key);
}
}
}
export function isStreamingMonitorCommand(command: unknown): boolean {
if (typeof command !== 'string') return false;
return command.split(/[;&|]+/).some((rawSegment) => {
const tokens = unwrapMonitorCommandPrefixes(tokenizeCommandSegment(rawSegment));
if (tokens.length === 0) return false;
if (tokens[0]?.toLowerCase() === 'watch') return true;
const argsStart = findMonitorArgsStart(tokens);
if (argsStart < 0) return false;
return tokens.slice(argsStart).some(arg => /^--follow(?:=.+)?$/i.test(arg) || /^-[^-\s]*[fF]/.test(arg));
});
}
function findMonitorArgsStart(tokens: string[]): number {
const command = tokens[0]?.toLowerCase();
if (command === 'tail' || command === 'journalctl') return 1;
if (command === 'kubectl') {
const logsIndex = skipCommandOptions(tokens, 1, new Set([
'-n', '--namespace', '--context', '--kubeconfig', '--cluster', '--user',
'--request-timeout', '-s', '--server', '--token', '--as', '--as-group',
'--cache-dir', '--certificate-authority', '--client-certificate', '--client-key',
'--tls-server-name',
]));
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
}
if (command === 'docker') {
const subcommandIndex = skipCommandOptions(tokens, 1, new Set([
'--config', '-c', '--context', '-H', '--host', '-l', '--log-level',
]));
const subcommand = tokens[subcommandIndex]?.toLowerCase();
if (subcommand === 'logs') return subcommandIndex + 1;
if (subcommand !== 'compose') return -1;
const logsIndex = skipCommandOptions(tokens, subcommandIndex + 1, new Set([
'-f', '--file', '-p', '--project-name', '--profile', '--project-directory',
'--env-file', '--parallel', '--progress', '--ansi',
]));
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
}
return -1;
}
function skipCommandOptions(tokens: string[], start: number, optionsWithValues: ReadonlySet<string>): number {
let index = start;
while (tokens[index]?.startsWith('-')) {
const option = tokens[index]!;
index += 1;
if (option === '--') break;
const optionName = canonicalOptionName(option);
if (optionsWithValues.has(optionName) && !option.includes('=') && index < tokens.length) index += 1;
}
return index;
}
function tokenizeCommandSegment(segment: string): string[] {
return segment.trim().match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/g) ?? [];
}
function unwrapMonitorCommandPrefixes(input: string[]): string[] {
const tokens = [...input];
for (let pass = 0; pass < 8 && tokens.length > 0; pass += 1) {
const wrapper = tokens[0]?.toLowerCase();
if (wrapper === 'sudo') {
tokens.shift();
consumeWrapperOptions(tokens, new Set([
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt',
'-C', '--close-from', '-R', '--chroot', '-D', '--chdir', '-T', '--command-timeout',
'-r', '--role', '-t', '--type',
]));
continue;
}
if (wrapper === 'env') {
tokens.shift();
consumeWrapperOptions(tokens, new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']));
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
continue;
}
if (wrapper === 'stdbuf') {
tokens.shift();
consumeWrapperOptions(tokens, new Set(['-i', '--input', '-o', '--output', '-e', '--error']));
continue;
}
if (wrapper === 'timeout') {
tokens.shift();
consumeWrapperOptions(tokens, new Set(['-k', '--kill-after', '-s', '--signal']));
if (tokens.length > 0) tokens.shift();
continue;
}
break;
}
return tokens;
}
function consumeWrapperOptions(tokens: string[], optionsWithValues: ReadonlySet<string>): void {
while (tokens[0]?.startsWith('-')) {
const option = tokens.shift()!;
if (option === '--') break;
const optionName = canonicalOptionName(option);
if (optionsWithValues.has(optionName) && !option.includes('=') && tokens.length > 0) tokens.shift();
}
}
function canonicalOptionName(option: string): string {
const optionName = option.split('=', 1)[0]!;
return optionName.startsWith('--') ? optionName.toLowerCase() : optionName;
}
function fitMonitorBatch(output: string): { content: string; sourceTruncated: boolean } {
const normalized = compressVerboseText(output);
let sourceTruncated = false;
const lines = normalized.split('\n').map(line => {
if (line.length <= MONITOR_LINE_MAX_CHARS) return line;
sourceTruncated = true;
return `${line.slice(0, MONITOR_LINE_MAX_CHARS - 28)}[... line shortened ...]`;
});
const content = lines.join('\n');
if (content.length <= MONITOR_BATCH_MAX_CHARS) return { content, sourceTruncated };
const marker = '\n[... monitor batch shortened ...]';
return {
content: `${content.slice(0, MONITOR_BATCH_MAX_CHARS - marker.length)}${marker}`,
sourceTruncated: true,
};
}
export const globalTerminalMonitorGuard = new TerminalMonitorGuard();

View File

@@ -0,0 +1,81 @@
import type { ModelMessage } from 'ai';
import type { TokenEstimatorKind } from './types';
const CHARS_PER_TOKEN_FALLBACK = 4;
function estimateChars(value: unknown): number {
if (value == null) return 0;
if (typeof value === 'string') return value.length;
if (Array.isArray(value)) {
return value.reduce((sum, item) => sum + estimateChars(item), 0);
}
if (typeof value === 'object') {
try {
return JSON.stringify(value).length;
} catch {
return String(value).length;
}
}
return String(value).length;
}
export function resolveEstimatorKind(providerId?: string | null): TokenEstimatorKind {
const id = (providerId ?? '').toLowerCase();
if (id.includes('openai') || id.includes('gpt')) return 'openai-heuristic';
if (id.includes('anthropic') || id.includes('claude')) return 'anthropic-heuristic';
if (id.includes('google') || id.includes('gemini')) return 'google-heuristic';
return 'chars-div-4';
}
function applyHeuristicMultiplier(chars: number, kind: TokenEstimatorKind): number {
switch (kind) {
case 'openai-heuristic':
return Math.ceil(chars / 3.5);
case 'anthropic-heuristic':
return Math.ceil(chars / 3.2);
case 'google-heuristic':
return Math.ceil(chars / 3.8);
default:
return Math.ceil(chars / CHARS_PER_TOKEN_FALLBACK);
}
}
export function estimateTextTokens(text: string, providerId?: string | null): number {
const kind = resolveEstimatorKind(providerId);
return applyHeuristicMultiplier(text.length, kind);
}
export function estimateUnknownTokens(value: unknown, providerId?: string | null): number {
const kind = resolveEstimatorKind(providerId);
return applyHeuristicMultiplier(estimateChars(value), kind);
}
export interface EstimateModelMessagesTokensInput {
messages: ModelMessage[];
providerId?: string | null;
}
export interface EstimateModelMessagesTokensResult {
tokens: number;
estimatorKind: TokenEstimatorKind;
}
export function estimateModelMessagesTokensWithKind(
input: EstimateModelMessagesTokensInput,
): EstimateModelMessagesTokensResult {
const estimatorKind = resolveEstimatorKind(input.providerId);
const chars = input.messages.reduce((total, message) => {
return total + estimateChars(message.role) + estimateChars(message.content);
}, 0);
return {
tokens: applyHeuristicMultiplier(chars, estimatorKind),
estimatorKind,
};
}
export function estimateModelMessagesTokens(
messages: ModelMessage[],
providerId?: string | null,
): number {
return estimateModelMessagesTokensWithKind({ messages, providerId }).tokens;
}

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { repairToolMessageIntegrity } from './toolMessageIntegrity';
test('repairToolMessageIntegrity drops orphan and duplicate tool results', () => {
const messages: ModelMessage[] = [
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'terminal_poll', input: {} }],
},
{
role: 'tool',
content: [
{ type: 'tool-result', toolCallId: 'call-1', toolName: 'terminal_poll', output: { type: 'text', value: 'first' } },
{ type: 'tool-result', toolCallId: 'orphan', toolName: 'terminal_poll', output: { type: 'text', value: 'orphan' } },
],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'call-1', toolName: 'terminal_poll', output: { type: 'text', value: 'duplicate' } }],
},
];
const result = repairToolMessageIntegrity(messages);
const serialized = JSON.stringify(result.messages);
assert.equal(result.didAdjust, true);
assert.match(serialized, /first/);
assert.doesNotMatch(serialized, /orphan|duplicate/);
});
test('repairToolMessageIntegrity completes interrupted tool calls with a synthetic result', () => {
const messages: ModelMessage[] = [{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'terminal_execute', input: { command: 'deploy' } }],
}];
const result = repairToolMessageIntegrity(messages);
assert.equal(result.messages.length, 2);
assert.match(JSON.stringify(result.messages[1]), /interrupted before a result was recorded/);
});
test('repairToolMessageIntegrity pairs reused tool call ids by occurrence order', () => {
const messages: ModelMessage[] = [
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_poll', input: { jobId: 'first' } }],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'reused', toolName: 'terminal_poll', output: { type: 'text', value: 'first result' } }],
},
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_poll', input: { jobId: 'second' } }],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'reused', toolName: 'terminal_poll', output: { type: 'text', value: 'second result' } }],
},
];
const result = repairToolMessageIntegrity(messages);
assert.equal(result.didAdjust, false);
assert.equal(result.messages, messages);
assert.match(JSON.stringify(result.messages), /first result/);
assert.match(JSON.stringify(result.messages), /second result/);
});
test('repairToolMessageIntegrity pairs a reused id with the nearest preceding call', () => {
const messages: ModelMessage[] = [
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_execute', input: { command: 'old' } }],
},
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'reused', toolName: 'terminal_execute', input: { command: 'new' } }],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'reused', toolName: 'terminal_execute', output: { type: 'text', value: 'new result' } }],
},
];
const result = repairToolMessageIntegrity(messages);
assert.equal(result.didAdjust, true);
assert.deepEqual(result.messages.map(message => message.role), ['assistant', 'tool', 'assistant', 'tool']);
assert.match(JSON.stringify(result.messages[1]), /interrupted before a result was recorded/);
assert.match(JSON.stringify(result.messages[3]), /new result/);
});

View File

@@ -0,0 +1,84 @@
import type { ModelMessage } from 'ai';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function repairToolMessageIntegrity(messages: ModelMessage[]): {
messages: ModelMessage[];
didAdjust: boolean;
} {
const pendingCalls = new Map<string, Array<Record<string, unknown>>>();
const matchedCalls = new Set<Record<string, unknown>>();
let didAdjust = false;
const sanitized: ModelMessage[] = [];
for (const message of messages) {
if (message.role === 'assistant' && Array.isArray(message.content)) {
for (const part of message.content as unknown[]) {
if (!isRecord(part) || part.type !== 'tool-call' || typeof part.toolCallId !== 'string') continue;
const pending = pendingCalls.get(part.toolCallId) ?? [];
pending.push(part);
pendingCalls.set(part.toolCallId, pending);
}
sanitized.push(message);
continue;
}
if (message.role !== 'tool' || !Array.isArray(message.content)) {
sanitized.push(message);
continue;
}
const content = (message.content as unknown[]).filter(part => {
if (!isRecord(part) || part.type !== 'tool-result' || typeof part.toolCallId !== 'string') {
return true;
}
const pending = pendingCalls.get(part.toolCallId);
// Reused provider IDs are paired with the nearest preceding unresolved
// call. This also handles an older interrupted call followed by a new
// call that reused the same ID.
const matchingCall = pending?.pop();
if (!matchingCall) {
didAdjust = true;
return false;
}
matchedCalls.add(matchingCall);
return true;
});
if (content.length === 0) {
didAdjust = true;
continue;
}
sanitized.push(content.length === message.content.length ? message : {
...message,
content,
} as ModelMessage);
}
const repaired: ModelMessage[] = [];
for (const message of sanitized) {
repaired.push(message);
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
const missing = (message.content as unknown[]).filter(part => (
isRecord(part)
&& part.type === 'tool-call'
&& typeof part.toolCallId === 'string'
&& !matchedCalls.has(part)
)) as Array<Record<string, unknown>>;
if (missing.length === 0) continue;
repaired.push({
role: 'tool',
content: missing.map(part => ({
type: 'tool-result' as const,
toolCallId: String(part.toolCallId),
toolName: typeof part.toolName === 'string' ? part.toolName : 'unknown',
output: {
type: 'text' as const,
value: '[Tool call interrupted before a result was recorded. Do not assume it succeeded or repeat a write automatically; verify current state first.]',
},
isError: true,
})),
});
didAdjust = true;
}
return { messages: didAdjust ? repaired : messages, didAdjust };
}

View File

@@ -0,0 +1,988 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS,
TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS,
TOOL_OUTPUT_READ_MAX_CHARS,
type PersistedToolOutputRecord,
type ToolOutputPersistence,
ToolOutputStore,
} from './toolOutputStore';
import { ToolResultDedup } from './toolResultDedup';
test('ToolOutputStore stores and reads truncated output by handle', () => {
const store = new ToolOutputStore();
const handle = store.store({
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
sessionId: 'sess-1',
content: 'A'.repeat(50_000),
});
assert.ok(handle.id.startsWith('tool-output-'));
assert.equal(handle.totalChars, 50_000);
const head = store.read({ handleId: handle.id, mode: 'head', maxChars: 100 }, 'chat-1');
assert.equal(head?.length, 100);
const tail = store.read({ handleId: handle.id, mode: 'tail', maxChars: 50 }, 'chat-1');
assert.equal(tail?.length, 50);
assert.equal(tail, 'A'.repeat(50));
store.prune('chat-1');
assert.equal(store.read({ handleId: handle.id }, 'chat-1'), null);
});
test('ToolOutputStore pages large output with a hard per-read cap', () => {
const store = new ToolOutputStore();
const content = `${'0123456789'.repeat(3_000)}END`;
const handle = store.store({
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
content,
});
const first = store.readChunk({
handleId: handle.id,
mode: 'range',
maxChars: content.length,
}, 'chat-1');
assert.equal(first?.content.length, TOOL_OUTPUT_READ_MAX_CHARS);
assert.equal(first?.nextOffset, TOOL_OUTPUT_READ_MAX_CHARS);
assert.equal(first?.hasMore, true);
const second = store.readChunk({
handleId: handle.id,
mode: 'range',
offset: first?.nextOffset,
}, 'chat-1');
assert.equal(second?.startOffset, first?.nextOffset);
});
test('ToolOutputStore searches stored output without returning the whole body', () => {
const store = new ToolOutputStore();
const handle = store.store({
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
content: `${'noise\n'.repeat(10_000)}Unique Failure Marker\n${'more noise\n'.repeat(10_000)}`,
});
const result = store.readChunk({
handleId: handle.id,
mode: 'search',
query: 'unique failure marker',
}, 'chat-1');
assert.deepEqual(result?.matchOffsets.length, 1);
assert.match(result?.content ?? '', /Unique Failure Marker/);
assert.ok((result?.content.length ?? Infinity) < TOOL_OUTPUT_READ_MAX_CHARS);
});
test('ToolOutputStore search advances only past matches included in the response', () => {
const store = new ToolOutputStore();
const handle = store.store({
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
content: 'match middle match tail',
});
const first = store.readChunk({
handleId: handle.id,
mode: 'search',
query: 'match',
maxChars: 1,
}, 'chat-1');
assert.doesNotMatch(first?.content ?? '', /No matches found/);
assert.deepEqual(first?.matchOffsets, [0]);
assert.equal(first?.nextOffset, 5);
assert.equal(first?.hasMore, true);
const second = store.readChunk({
handleId: handle.id,
mode: 'search',
query: 'match',
offset: first?.nextOffset,
maxChars: 30,
}, 'chat-1');
assert.deepEqual(second?.matchOffsets, [13]);
});
test('ToolOutputStore never splits a Unicode surrogate pair at page boundaries', () => {
const store = new ToolOutputStore();
const content = `${'a'.repeat(11_999)}😀中文结尾`;
const handle = store.store({
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
content,
});
const first = store.readChunk({ handleId: handle.id, mode: 'range' }, 'chat-1');
assert.equal(first?.content.endsWith('\ud83d'), false);
const second = store.readChunk({
handleId: handle.id,
mode: 'range',
offset: first?.nextOffset,
}, 'chat-1');
assert.equal(`${first?.content}${second?.content}`, content);
});
test('ToolOutputStore enforces per-handle, session count, and TTL limits', () => {
let now = 1_000;
const store = new ToolOutputStore({
maxHandleChars: 20,
maxHandlesPerSession: 2,
maxCharsPerSession: 30,
ttlMs: 100,
now: () => now,
});
const first = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'a'.repeat(15) });
const second = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'b'.repeat(15) });
const third = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'c'.repeat(100) });
assert.equal(store.get(first.id, 'chat-1'), undefined);
assert.equal(store.get(second.id, 'chat-1'), undefined);
assert.equal(store.get(third.id, 'chat-1')?.storedChars, 20);
assert.equal(store.get(third.id, 'chat-1')?.sourceTruncated, true);
now += 101;
assert.equal(store.get(third.id, 'chat-1'), undefined);
});
test('ToolOutputStore spills retained output through its persistence adapter', async () => {
const files = new Map<string, string>();
const deleted: string[] = [];
const store = new ToolOutputStore({
spillThresholdChars: 10,
persistence: {
write: async (_record, content) => {
files.set('/netcatty/tool-output.log', content);
return '/netcatty/tool-output.log';
},
read: async (path, input) => {
const content = files.get(path);
if (content == null) return null;
const startOffset = input.mode === 'tail'
? Math.max(0, content.length - (input.maxChars ?? 12_000))
: Math.max(0, input.offset ?? 0);
const selected = content.slice(startOffset, startOffset + (input.maxChars ?? 12_000));
const endOffset = startOffset + selected.length;
return {
mode: input.mode ?? 'head',
content: selected,
totalChars: content.length,
startOffset,
endOffset,
nextOffset: endOffset,
hasMore: endOffset < content.length,
};
},
delete: async path => {
deleted.push(path);
files.delete(path);
},
},
});
const handle = store.store({
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
content: 'persist this terminal output',
});
const result = await store.readChunkAsync({ handleId: handle.id, mode: 'full' }, 'chat-1');
assert.equal(result?.content, 'persist this terminal output');
assert.equal(store.get(handle.id, 'chat-1')?.fullContent, undefined);
store.prune('chat-1');
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deleted, ['/netcatty/tool-output.log']);
});
test('ToolOutputStore restores a durable handle after a runtime restart', async () => {
const files = new Map<string, { record: PersistedToolOutputRecord; content: string }>();
const persistence: ToolOutputPersistence = {
write: async (record, content) => {
const path = `/netcatty/${record.handleId}.log`;
files.set(path, { record, content });
return path;
},
restore: async (handleId, chatSessionId) => {
for (const [path, entry] of files) {
if (entry.record.handleId !== handleId || entry.record.chatSessionId !== chatSessionId) continue;
return { path, record: entry.record };
}
return null;
},
read: async (path, input) => {
const content = files.get(path)?.content;
if (content == null) return null;
const startOffset = input.mode === 'tail'
? Math.max(0, content.length - (input.maxChars ?? 12_000))
: Math.max(0, input.offset ?? 0);
const selected = content.slice(startOffset, startOffset + (input.maxChars ?? 12_000));
const endOffset = startOffset + selected.length;
return {
mode: input.mode ?? 'head',
content: selected,
totalChars: content.length,
startOffset,
endOffset,
nextOffset: endOffset,
hasMore: endOffset < content.length,
};
},
delete: async path => {
files.delete(path);
},
};
const beforeRestart = new ToolOutputStore({ spillThresholdChars: 0, persistence });
const saved = beforeRestart.store({
chatSessionId: 'chat-restart',
capabilityId: 'terminal.execute',
sessionId: 'terminal-1',
content: 'restart evidence in the middle',
});
await saved.spillPromise;
const afterRestart = new ToolOutputStore({ spillThresholdChars: 0, persistence });
const restored = await afterRestart.readChunkAsync({
handleId: saved.id,
mode: 'search',
query: 'evidence',
}, 'chat-restart');
assert.match(restored?.content ?? '', /restart evidence/);
assert.equal(afterRestart.get(saved.id, 'chat-restart')?.sessionId, 'terminal-1');
assert.equal(await afterRestart.readChunkAsync({ handleId: saved.id }, 'chat-other'), null);
});
test('ToolOutputStore drops a restored handle when its durable file is missing', async () => {
const record: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-missing',
chatSessionId: 'chat-1',
capabilityId: 'terminal.execute',
totalChars: 100,
storedChars: 100,
sourceTruncated: false,
preview: 'preview',
storedAt: 1,
accessedAt: 1,
};
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => ({ path: '/missing.log', record }),
read: async () => null,
delete: async () => {},
},
});
assert.equal(await store.readChunkAsync({ handleId: record.handleId }, 'chat-1'), null);
assert.equal(store.listPendingHandles('chat-1').length, 0);
});
test('ToolOutputStore can delete durable handles for a chat that was never restored', async () => {
const deletedSessions: string[] = [];
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => null,
read: async () => null,
delete: async () => {},
deleteSession: async chatSessionId => {
deletedSessions.push(chatSessionId);
},
},
});
store.prune('chat-after-restart');
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletedSessions, ['chat-after-restart']);
});
test('ToolOutputStore can delete durable terminal handles that were never restored', async () => {
const deletedTerminalSessions: Array<[string, string]> = [];
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => null,
read: async () => null,
delete: async () => {},
deleteTerminalSession: async (chatSessionId, terminalSessionId) => {
deletedTerminalSessions.push([chatSessionId, terminalSessionId]);
},
},
});
store.pruneTerminalSession('chat-after-restart', 'terminal-closed');
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletedTerminalSessions, [['chat-after-restart', 'terminal-closed']]);
});
test('ToolOutputStore deletes unopened durable handles when a terminal closes', async () => {
const deletedTerminals: string[] = [];
const store = new ToolOutputStore({
persistence: {
write: async () => '/tmp/unused',
read: async () => null,
delete: async () => {},
deleteTerminalEverywhere: async terminalSessionId => {
deletedTerminals.push(terminalSessionId);
},
},
});
store.pruneTerminalSessionEverywhere('terminal-unopened-after-restart');
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletedTerminals, ['terminal-unopened-after-restart']);
});
test('ToolOutputStore does not persist output that arrives after its terminal closed', async () => {
let writes = 0;
const store = new ToolOutputStore({
persistence: {
write: async () => {
writes += 1;
return '/late.log';
},
read: async () => null,
delete: async () => {},
deleteTerminalSession: async () => {},
},
});
store.pruneTerminalSessionEverywhere('terminal-closed-before-output');
const lateHandle = store.store({
chatSessionId: 'chat-late-output',
capabilityId: 'terminal.execute',
sessionId: 'terminal-closed-before-output',
content: 'late output',
});
await store.flush('chat-late-output');
assert.equal(writes, 0);
assert.equal(store.listPendingHandles('chat-late-output').length, 0);
assert.equal(await store.readChunkAsync({ handleId: lateHandle.id }, 'chat-late-output'), null);
});
test('ToolOutputStore does not resurrect a handle when its chat is deleted during restore', async () => {
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
finishRestore = resolve;
});
const deletedPaths: string[] = [];
const record: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-racing-restore',
chatSessionId: 'chat-racing-restore',
capabilityId: 'terminal.execute',
totalChars: 7,
storedChars: 7,
sourceTruncated: false,
preview: 'private',
storedAt: 1,
accessedAt: 1,
};
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => restoreFinished,
read: async () => ({
mode: 'head', content: 'private', totalChars: 7, startOffset: 0, endOffset: 7, nextOffset: 7, hasMore: false,
}),
delete: async path => {
deletedPaths.push(path);
},
deleteSession: async () => {},
},
});
const reading = store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId);
store.prune(record.chatSessionId);
finishRestore({ path: '/netcatty/racing.log', record });
assert.equal(await reading, null);
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletedPaths, ['/netcatty/racing.log']);
assert.equal(store.listPendingHandles(record.chatSessionId).length, 0);
});
test('ToolOutputStore waits for chat deletion before starting a later restore', async () => {
let finishDeletion!: () => void;
const deletionFinished = new Promise<void>(resolve => {
finishDeletion = resolve;
});
let restoreCalls = 0;
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => {
restoreCalls += 1;
return null;
},
read: async () => null,
delete: async () => {},
deleteSession: async () => deletionFinished,
},
});
store.prune('chat-delete-window');
const reading = store.readChunkAsync({ handleId: 'old-handle' }, 'chat-delete-window');
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(restoreCalls, 0);
finishDeletion();
assert.equal(await reading, null);
assert.equal(restoreCalls, 1);
});
test('ToolOutputStore does not resurrect an old handle when its terminal is deleted during restore', async () => {
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
finishRestore = resolve;
});
const deletedPaths: string[] = [];
const record: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-terminal-race',
chatSessionId: 'chat-terminal-race',
capabilityId: 'terminal.execute',
terminalSessionId: 'terminal-race',
totalChars: 7,
storedChars: 7,
sourceTruncated: false,
preview: 'private',
storedAt: 1,
accessedAt: 1,
};
const store = new ToolOutputStore({
persistence: {
write: async () => '/new-output.log',
restore: async () => restoreFinished,
read: async () => null,
delete: async path => {
deletedPaths.push(path);
},
deleteTerminalSession: async () => {},
},
});
const reading = store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId);
store.pruneTerminalSessionEverywhere(record.terminalSessionId!);
store.store({
chatSessionId: record.chatSessionId,
capabilityId: 'terminal.execute',
sessionId: record.terminalSessionId,
content: 'new output',
});
finishRestore({ path: '/netcatty/old-output.log', record });
assert.equal(await reading, null);
await new Promise(resolve => setTimeout(resolve, 0));
assert.ok(deletedPaths.includes('/netcatty/old-output.log'));
assert.equal(store.get(record.handleId, record.chatSessionId), undefined);
});
test('ToolOutputStore keeps restoring one terminal when a different terminal is deleted', async () => {
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
finishRestore = resolve;
});
const record: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-terminal-b',
chatSessionId: 'chat-two-terminals',
capabilityId: 'terminal.execute',
terminalSessionId: 'terminal-b',
totalChars: 8,
storedChars: 8,
sourceTruncated: false,
preview: 'terminal',
storedAt: 1,
accessedAt: 1,
};
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => restoreFinished,
read: async () => ({
mode: 'head', content: 'terminal', totalChars: 8, startOffset: 0, endOffset: 8, nextOffset: 8, hasMore: false,
}),
delete: async () => {},
deleteTerminalSession: async () => {},
},
});
const reading = store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId);
store.pruneTerminalSession(record.chatSessionId, 'terminal-a');
finishRestore({ path: '/netcatty/terminal-b.log', record });
assert.equal((await reading)?.content, 'terminal');
});
test('ToolOutputStore can restore a durable handle after its in-memory cache expires', async () => {
let now = 1_000;
const files = new Map<string, { record: PersistedToolOutputRecord; content: string }>();
const persistence: ToolOutputPersistence = {
write: async (record, content) => {
const path = `/netcatty/${record.handleId}.log`;
files.set(path, { record, content });
return path;
},
restore: async (handleId, chatSessionId) => {
for (const [path, entry] of files) {
if (entry.record.handleId === handleId && entry.record.chatSessionId === chatSessionId) {
return { path, record: entry.record };
}
}
return null;
},
read: async (path, input) => {
const content = files.get(path)?.content;
if (content == null) return null;
return {
mode: input.mode ?? 'head',
content,
totalChars: content.length,
startOffset: 0,
endOffset: content.length,
nextOffset: content.length,
hasMore: false,
};
},
delete: async path => {
files.delete(path);
},
};
const store = new ToolOutputStore({ ttlMs: 100, now: () => now, persistence });
const handle = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'durable' });
await handle.spillPromise;
now += 101;
const restored = await store.readChunkAsync({ handleId: handle.id }, 'chat-1');
assert.equal(restored?.content, 'durable');
});
test('ToolOutputStore flush waits until a handle is durable before a tool can return it', async () => {
let finishWrite!: (path: string) => void;
const writeFinished = new Promise<string>(resolve => {
finishWrite = resolve;
});
const store = new ToolOutputStore({
persistence: {
write: async () => writeFinished,
restore: async () => null,
read: async () => null,
delete: async () => {},
},
});
const handle = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'persist me' });
let flushed = false;
const flushing = store.flush('chat-1').then(() => {
flushed = true;
});
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(flushed, false);
finishWrite('/netcatty/durable.log');
await flushing;
assert.equal(handle.filePath, '/netcatty/durable.log');
});
test('ToolOutputStore reports restart persistence only after the individual spill succeeds', async () => {
let failWrite = true;
const store = new ToolOutputStore({
persistence: {
write: async () => {
if (failWrite) throw new Error('disk full');
return '/netcatty/durable.log';
},
restore: async () => null,
read: async () => null,
delete: async () => {},
},
});
const failed = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'memory only' });
const failedNotice = `[output handle: handleId=${failed.id} restartPersistence=unavailable (read before closing the app)]`;
await store.flush('chat-1');
assert.equal(store.resolveRestartPersistenceNotices(failedNotice, 'chat-1'), failedNotice);
failWrite = false;
const durable = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'saved' });
const durableNotice = `[output handle: handleId=${durable.id} restartPersistence=unavailable (read before closing the app)]`;
await store.flush('chat-1');
assert.equal(
store.resolveRestartPersistenceNotices(durableNotice, 'chat-1'),
`[output handle: handleId=${durable.id}]`,
);
});
test('ToolOutputStore enforces a shared quota across chat sessions', () => {
const store = new ToolOutputStore({
maxCharsGlobal: 25,
maxHandlesGlobal: 2,
});
const first = store.store({ chatSessionId: 'chat-1', capabilityId: 'test', content: 'a'.repeat(15) });
const second = store.store({ chatSessionId: 'chat-2', capabilityId: 'test', content: 'b'.repeat(15) });
assert.equal(store.get(first.id, 'chat-1'), undefined);
assert.ok(store.get(second.id, 'chat-2'));
});
test('ToolOutputStore rejects cross-chat handle reads', async () => {
const store = new ToolOutputStore();
const handle = store.store({
chatSessionId: 'chat-owner',
capabilityId: 'terminal.execute',
content: 'private output',
});
assert.equal(await store.readChunkAsync({ handleId: handle.id }, 'chat-other'), null);
});
test('ToolOutputStore reclaims chat generations after deletion churn settles', () => {
const store = new ToolOutputStore();
for (let index = 0; index < 2_000; index += 1) {
store.prune(`chat-deleted-${index}`);
}
assert.equal(store.getLifecycleMetadataStatsForTests().sessionGenerations, 0);
});
test('ToolOutputStore rejects output that arrives after its chat was deleted', () => {
const store = new ToolOutputStore();
store.prune('chat-deleted-before-late-output');
const lateHandle = store.store({
chatSessionId: 'chat-deleted-before-late-output',
capabilityId: 'terminal.execute',
content: 'late output',
});
assert.equal(lateHandle.evicted, true);
assert.equal(lateHandle.storedChars, 0);
assert.equal(store.listPendingHandles('chat-deleted-before-late-output').length, 0);
});
test('ToolOutputStore reclaims per-chat terminal deletion metadata after churn settles', () => {
const store = new ToolOutputStore();
for (let index = 0; index < 2_000; index += 1) {
store.pruneTerminalSession(`chat-${index}`, `terminal-${index}`);
}
const stats = store.getLifecycleMetadataStatsForTests();
assert.equal(stats.terminalMutationGenerations, 0);
assert.equal(stats.deletedTerminalSessions, 0);
});
test('ToolOutputStore bounds closed-terminal tombstones while isolating recent late writes', async () => {
const store = new ToolOutputStore();
const churnCount = TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS + 2_000;
for (let index = 0; index < churnCount; index += 1) {
store.pruneTerminalSessionEverywhere(`terminal-closed-${index}`);
}
assert.equal(
store.getLifecycleMetadataStatsForTests().closedTerminalSessions,
TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS,
);
const lateHandle = store.store({
chatSessionId: 'chat-late-after-churn',
capabilityId: 'terminal.execute',
sessionId: `terminal-closed-${churnCount - 1}`,
content: 'late output after heavy churn',
});
assert.equal(lateHandle.evicted, true);
assert.equal(store.listPendingHandles('chat-late-after-churn').length, 0);
assert.equal(await store.readChunkAsync({ handleId: lateHandle.id }, 'chat-late-after-churn'), null);
const oldestLateHandle = store.store({
chatSessionId: 'chat-oldest-late-after-churn',
capabilityId: 'terminal.execute',
sessionId: 'terminal-closed-0',
content: 'very late output after exact tombstone eviction',
});
assert.equal(oldestLateHandle.evicted, true);
assert.equal(store.listPendingHandles('chat-oldest-late-after-churn').length, 0);
});
test('ToolOutputStore bounds fallback terminal metadata when durable deletion is unavailable', () => {
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => null,
read: async () => null,
delete: async () => {},
},
});
for (let index = 0; index < 2_000; index += 1) {
store.pruneTerminalSession(`chat-fallback-${index}`, `terminal-fallback-${index}`);
}
const stats = store.getLifecycleMetadataStatsForTests();
assert.equal(stats.terminalMutationGenerations, TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
assert.equal(stats.deletedTerminalSessions, TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
});
test('ToolOutputStore keeps an in-flight restore tombstone protected during metadata churn', async () => {
const chatSessionId = 'chat-protected-restore';
const terminalSessionId = 'terminal-protected-restore';
const handleId = 'tool-output-protected-restore';
let finishRestore!: (value: { path: string; record: PersistedToolOutputRecord }) => void;
const restoreFinished = new Promise<{ path: string; record: PersistedToolOutputRecord }>(resolve => {
finishRestore = resolve;
});
const deletedPaths: string[] = [];
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async (requestedHandleId, requestedChatSessionId) => {
if (requestedHandleId !== handleId || requestedChatSessionId !== chatSessionId) return null;
return restoreFinished;
},
read: async () => null,
delete: async path => { deletedPaths.push(path); },
},
});
const reading = store.readChunkAsync({ handleId }, chatSessionId);
store.pruneTerminalSession(chatSessionId, terminalSessionId);
for (let index = 0; index < 2_000; index += 1) {
store.pruneTerminalSession(`chat-churn-${index}`, `terminal-churn-${index}`);
}
finishRestore({
path: '/netcatty/protected-old-output.log',
record: {
schemaVersion: 1,
handleId,
chatSessionId,
capabilityId: 'terminal.execute',
terminalSessionId,
totalChars: 3,
storedChars: 3,
sourceTruncated: false,
preview: 'old',
storedAt: 1,
accessedAt: 1,
},
});
assert.equal(await reading, null);
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletedPaths, ['/netcatty/protected-old-output.log']);
const stats = store.getLifecycleMetadataStatsForTests();
assert.ok(stats.terminalMutationGenerations <= TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
assert.ok(stats.deletedTerminalSessions <= TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS);
});
test('ToolOutputStore retains a bounded tombstone when durable terminal deletion fails', async () => {
const record: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-delete-failed',
chatSessionId: 'chat-delete-failed',
capabilityId: 'terminal.execute',
terminalSessionId: 'terminal-delete-failed',
totalChars: 3,
storedChars: 3,
sourceTruncated: false,
preview: 'old',
storedAt: 1,
accessedAt: 1,
};
const deletedPaths: string[] = [];
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => ({ path: '/netcatty/delete-failed.log', record }),
read: async () => ({
mode: 'head',
content: 'old',
totalChars: 3,
startOffset: 0,
endOffset: 3,
nextOffset: 3,
hasMore: false,
}),
delete: async path => { deletedPaths.push(path); },
deleteTerminalSession: async () => { throw new Error('disk busy'); },
},
});
store.pruneTerminalSession(record.chatSessionId, record.terminalSessionId!);
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(await store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId), null);
assert.deepEqual(deletedPaths, ['/netcatty/delete-failed.log']);
const stats = store.getLifecycleMetadataStatsForTests();
assert.equal(stats.terminalMutationGenerations, 1);
assert.equal(stats.deletedTerminalSessions, 1);
});
test('ToolOutputStore ignores an older terminal deletion failure after a newer deletion succeeds', async () => {
let rejectFirstDeletion!: (error: Error) => void;
let finishSecondDeletion!: () => void;
const firstDeletion = new Promise<void>((_resolve, reject) => {
rejectFirstDeletion = reject;
});
const secondDeletion = new Promise<void>(resolve => {
finishSecondDeletion = resolve;
});
let deletionCalls = 0;
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => null,
read: async () => null,
delete: async () => {},
deleteTerminalSession: async () => {
deletionCalls += 1;
return deletionCalls === 1 ? firstDeletion : secondDeletion;
},
},
});
store.pruneTerminalSession('chat-repeat-delete', 'terminal-repeat-delete');
store.pruneTerminalSession('chat-repeat-delete', 'terminal-repeat-delete');
finishSecondDeletion();
await new Promise(resolve => setTimeout(resolve, 0));
rejectFirstDeletion(new Error('older deletion failed'));
await new Promise(resolve => setTimeout(resolve, 0));
const stats = store.getLifecycleMetadataStatsForTests();
assert.equal(stats.terminalMutationGenerations, 0);
assert.equal(stats.deletedTerminalSessions, 0);
assert.equal(stats.failedTerminalDeletions, 0);
});
test('ToolOutputStore does not restore old output after durable chat deletion fails', async () => {
const record: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-chat-delete-failed',
chatSessionId: 'chat-delete-failed',
capabilityId: 'terminal.execute',
totalChars: 3,
storedChars: 3,
sourceTruncated: false,
preview: 'old',
storedAt: 1,
accessedAt: 1,
};
const deletedPaths: string[] = [];
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async () => ({ path: '/netcatty/chat-delete-failed.log', record }),
read: async () => ({
mode: 'head',
content: 'old',
totalChars: 3,
startOffset: 0,
endOffset: 3,
nextOffset: 3,
hasMore: false,
}),
delete: async path => { deletedPaths.push(path); },
deleteSession: async () => { throw new Error('disk busy'); },
},
});
store.prune(record.chatSessionId);
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(await store.readChunkAsync({ handleId: record.handleId }, record.chatSessionId), null);
assert.deepEqual(deletedPaths, ['/netcatty/chat-delete-failed.log']);
});
test('ToolOutputStore bounds failed chat deletion tombstones after heavy churn', async () => {
const oldRecord: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-old-failed-chat',
chatSessionId: 'chat-delete-failure-0',
capabilityId: 'terminal.execute',
totalChars: 3,
storedChars: 3,
sourceTruncated: false,
preview: 'old',
storedAt: 1,
accessedAt: 1,
};
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async (handleId, chatSessionId) => (
handleId === oldRecord.handleId && chatSessionId === oldRecord.chatSessionId
? { path: '/old-failed-chat.log', record: oldRecord }
: null
),
read: async () => ({
mode: 'head', content: 'old', totalChars: 3, startOffset: 0, endOffset: 3, nextOffset: 3, hasMore: false,
}),
delete: async () => {},
deleteSession: async () => { throw new Error('disk busy'); },
},
});
for (let index = 0; index < 2_000; index += 1) {
store.prune(`chat-delete-failure-${index}`);
}
await new Promise(resolve => setTimeout(resolve, 0));
const stats = store.getLifecycleMetadataStatsForTests();
assert.equal(stats.failedSessionDeletions, TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS);
assert.equal(stats.sessionGenerations, TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS);
assert.equal(
await store.readChunkAsync({ handleId: oldRecord.handleId }, oldRecord.chatSessionId),
null,
);
});
test('ToolOutputStore never restores a failed terminal deletion after exact tombstone churn', async () => {
const oldRecord: PersistedToolOutputRecord = {
schemaVersion: 1,
handleId: 'tool-output-old-failed-terminal',
chatSessionId: 'chat-terminal-failure-0',
capabilityId: 'terminal.execute',
terminalSessionId: 'terminal-failure-0',
totalChars: 3,
storedChars: 3,
sourceTruncated: false,
preview: 'old',
storedAt: 1,
accessedAt: 1,
};
const store = new ToolOutputStore({
persistence: {
write: async () => '/unused',
restore: async (handleId, chatSessionId) => (
handleId === oldRecord.handleId && chatSessionId === oldRecord.chatSessionId
? { path: '/old-failed-terminal.log', record: oldRecord }
: null
),
read: async () => ({
mode: 'head', content: 'old', totalChars: 3, startOffset: 0, endOffset: 3, nextOffset: 3, hasMore: false,
}),
delete: async () => {},
deleteTerminalSession: async () => { throw new Error('disk busy'); },
},
});
for (let index = 0; index < 2_000; index += 1) {
store.pruneTerminalSession(`chat-terminal-failure-${index}`, `terminal-failure-${index}`);
}
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(
await store.readChunkAsync({ handleId: oldRecord.handleId }, oldRecord.chatSessionId),
null,
);
assert.ok(
store.getLifecycleMetadataStatsForTests().deletedTerminalSessions
<= TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS,
);
});
test('saved-output read budgets reset at the start of each turn', () => {
const dedup = new ToolResultDedup();
dedup.beginTurn();
assert.equal(dedup.takeBudget('read', 24_000, 24_000), 24_000);
assert.equal(dedup.takeBudget('read', 1, 24_000), 0);
dedup.beginTurn();
assert.equal(dedup.takeBudget('read', 24_000, 24_000), 24_000);
});

View File

@@ -0,0 +1,870 @@
export interface ToolOutputHandle {
id: string;
chatSessionId: string;
capabilityId: string;
sessionId?: string;
totalChars: number;
storedChars: number;
sourceTruncated: boolean;
preview: string;
storedAt: number;
accessedAt: number;
fullContent?: string;
filePath?: string;
spillPromise?: Promise<void>;
evicted?: boolean;
}
export interface PersistedToolOutputRecord {
schemaVersion: 1;
handleId: string;
chatSessionId: string;
capabilityId: string;
terminalSessionId?: string;
totalChars: number;
storedChars: number;
sourceTruncated: boolean;
preview: string;
storedAt: number;
accessedAt: number;
}
export interface StoreToolOutputInput {
chatSessionId: string;
capabilityId: string;
content: string;
sessionId?: string;
previewChars?: number;
}
export interface ReadToolOutputInput {
handleId: string;
mode?: 'head' | 'tail' | 'full' | 'range' | 'search';
maxChars?: number;
offset?: number;
query?: string;
}
export interface ToolOutputReadResult {
handleId: string;
mode: NonNullable<ReadToolOutputInput['mode']>;
content: string;
totalChars: number;
storedChars: number;
sourceTruncated: boolean;
startOffset: number;
endOffset: number;
nextOffset: number;
hasMore: boolean;
matchOffsets?: number[];
}
export const TOOL_OUTPUT_READ_MAX_CHARS = 12_000;
export const TOOL_OUTPUT_MAX_HANDLE_CHARS = 4_000_000;
export const TOOL_OUTPUT_MAX_HANDLES_PER_SESSION = 64;
export const TOOL_OUTPUT_MAX_CHARS_PER_SESSION = 8_000_000;
export const TOOL_OUTPUT_MAX_HANDLES_GLOBAL = 256;
export const TOOL_OUTPUT_MAX_CHARS_GLOBAL = 32_000_000;
export const TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS = 1_024;
export const TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS = 1_024;
export const TOOL_OUTPUT_TTL_MS = 30 * 60 * 1_000;
export const TOOL_OUTPUT_SPILL_THRESHOLD_CHARS = 0;
const TOOL_OUTPUT_SEARCH_CONTEXT_CHARS = 320;
const TOOL_OUTPUT_SEARCH_MAX_MATCHES = 20;
const TOOL_OUTPUT_LIFECYCLE_BLOOM_BITS = 1 << 22;
const TOOL_OUTPUT_LIFECYCLE_BLOOM_HASHES = 4;
/**
* Fixed-memory deny set with no false negatives. Lifecycle ids are UUID-like
* and never intentionally reused, so rare false positives are safer than
* accepting output for a deleted chat/terminal after exact tombstone churn.
*/
class FixedStringBloomFilter {
private readonly words = new Uint32Array(TOOL_OUTPUT_LIFECYCLE_BLOOM_BITS >>> 5);
add(value: string): void {
for (const bit of this.bitsFor(value)) {
this.words[bit >>> 5] |= 1 << (bit & 31);
}
}
has(value: string): boolean {
for (const bit of this.bitsFor(value)) {
if ((this.words[bit >>> 5] & (1 << (bit & 31))) === 0) return false;
}
return true;
}
private bitsFor(value: string): number[] {
let first = 0x811c9dc5;
let second = 0x9e3779b9;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 0x01000193);
second = Math.imul(second ^ (code + index), 0x85ebca6b);
}
second |= 1;
const mask = TOOL_OUTPUT_LIFECYCLE_BLOOM_BITS - 1;
return Array.from({ length: TOOL_OUTPUT_LIFECYCLE_BLOOM_HASHES }, (_, index) => (
(first + Math.imul(index, second)) >>> 0
) & mask);
}
}
export interface ToolOutputPersistence {
write(record: PersistedToolOutputRecord, content: string): Promise<string>;
restore?(
handleId: string,
chatSessionId: string,
): Promise<{ path: string; record: PersistedToolOutputRecord } | null>;
read(path: string, input: ReadToolOutputInput): Promise<Omit<ToolOutputReadResult, 'handleId' | 'storedChars' | 'sourceTruncated'> | null>;
delete(path: string): Promise<void>;
deleteSession?(chatSessionId: string): Promise<void>;
deleteTerminalSession?(chatSessionId: string, terminalSessionId: string): Promise<void>;
deleteTerminalEverywhere?(terminalSessionId: string): Promise<void>;
}
export interface ToolOutputStoreOptions {
maxHandleChars?: number;
maxHandlesPerSession?: number;
maxCharsPerSession?: number;
maxHandlesGlobal?: number;
maxCharsGlobal?: number;
ttlMs?: number;
spillThresholdChars?: number;
now?: () => number;
persistence?: ToolOutputPersistence;
}
function nextHandleId(): string {
const randomId = globalThis.crypto?.randomUUID?.()
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `tool-output-${randomId}`;
}
function isHighSurrogate(value: number): boolean {
return value >= 0xd800 && value <= 0xdbff;
}
function isLowSurrogate(value: number): boolean {
return value >= 0xdc00 && value <= 0xdfff;
}
function safeSliceBounds(content: string, requestedStart: number, requestedEnd: number): [number, number] {
let start = Math.min(content.length, Math.max(0, requestedStart));
let end = Math.min(content.length, Math.max(start, requestedEnd));
if (start > 0 && start < content.length && isLowSurrogate(content.charCodeAt(start))) {
start -= 1;
}
if (end > start && end < content.length && isHighSurrogate(content.charCodeAt(end - 1))) {
end -= 1;
}
return [start, end];
}
export class ToolOutputStore {
private readonly bySession = new Map<string, Map<string, ToolOutputHandle>>();
private readonly maxHandleChars: number;
private readonly maxHandlesPerSession: number;
private readonly maxCharsPerSession: number;
private readonly maxHandlesGlobal: number;
private readonly maxCharsGlobal: number;
private readonly ttlMs: number;
private readonly spillThresholdChars: number;
private readonly now: () => number;
private readonly restorePromises = new Map<string, Promise<ToolOutputHandle | undefined>>();
private readonly sessionGenerations = new Map<string, number>();
private readonly sessionDeletionPromises = new Map<string, Promise<void>>();
private readonly failedSessionDeletions = new Set<string>();
private readonly terminalMutationGenerations = new Map<string, number>();
private readonly terminalDeletionPromises = new Map<string, Promise<void>>();
private readonly failedTerminalDeletions = new Set<string>();
private readonly deletedTerminalSessions = new Map<string, string>();
private readonly closedTerminalSessions = new Set<string>();
private readonly lifecycleDenyFilter = new FixedStringBloomFilter();
private persistence?: ToolOutputPersistence;
constructor(options: ToolOutputStoreOptions = {}) {
this.maxHandleChars = options.maxHandleChars ?? TOOL_OUTPUT_MAX_HANDLE_CHARS;
this.maxHandlesPerSession = options.maxHandlesPerSession ?? TOOL_OUTPUT_MAX_HANDLES_PER_SESSION;
this.maxCharsPerSession = options.maxCharsPerSession ?? TOOL_OUTPUT_MAX_CHARS_PER_SESSION;
this.maxHandlesGlobal = options.maxHandlesGlobal ?? TOOL_OUTPUT_MAX_HANDLES_GLOBAL;
this.maxCharsGlobal = options.maxCharsGlobal ?? TOOL_OUTPUT_MAX_CHARS_GLOBAL;
this.ttlMs = options.ttlMs ?? TOOL_OUTPUT_TTL_MS;
this.spillThresholdChars = options.spillThresholdChars ?? TOOL_OUTPUT_SPILL_THRESHOLD_CHARS;
this.now = options.now ?? Date.now;
this.persistence = options.persistence;
}
setPersistence(persistence: ToolOutputPersistence | undefined): void {
this.persistence = persistence;
}
resolveRestartPersistenceNotices<T>(value: T, chatSessionId: string): T {
return this.resolveRestartPersistenceNoticesValue(value, chatSessionId) as T;
}
getLifecycleMetadataStatsForTests(): {
sessionGenerations: number;
failedSessionDeletions: number;
terminalMutationGenerations: number;
failedTerminalDeletions: number;
deletedTerminalSessions: number;
closedTerminalSessions: number;
} {
return {
sessionGenerations: this.sessionGenerations.size,
failedSessionDeletions: this.failedSessionDeletions.size,
terminalMutationGenerations: this.terminalMutationGenerations.size,
failedTerminalDeletions: this.failedTerminalDeletions.size,
deletedTerminalSessions: this.deletedTerminalSessions.size,
closedTerminalSessions: this.closedTerminalSessions.size,
};
}
store(input: StoreToolOutputInput): ToolOutputHandle {
const previewChars = input.previewChars ?? 240;
const now = this.now();
const lifecycleDenied = this.lifecycleDenyFilter.has(`chat:${input.chatSessionId}`)
|| Boolean(
input.sessionId
&& (
this.closedTerminalSessions.has(input.sessionId)
|| this.lifecycleDenyFilter.has(`closed-terminal:${input.sessionId}`)
)
);
if (lifecycleDenied) {
return {
id: nextHandleId(),
chatSessionId: input.chatSessionId,
capabilityId: input.capabilityId,
sessionId: input.sessionId,
totalChars: input.content.length,
storedChars: 0,
sourceTruncated: input.content.length > 0,
preview: "",
storedAt: now,
accessedAt: now,
evicted: true,
};
}
const retainedContent = retainBoundedContent(input.content, this.maxHandleChars);
const handle: ToolOutputHandle = {
id: nextHandleId(),
chatSessionId: input.chatSessionId,
capabilityId: input.capabilityId,
sessionId: input.sessionId,
totalChars: input.content.length,
storedChars: retainedContent.length,
sourceTruncated: retainedContent.length < input.content.length,
preview: retainedContent.slice(0, previewChars),
storedAt: now,
accessedAt: now,
fullContent: retainedContent,
};
const sessionMap = this.bySession.get(input.chatSessionId) ?? new Map<string, ToolOutputHandle>();
sessionMap.set(handle.id, handle);
this.bySession.set(input.chatSessionId, sessionMap);
this.enforceSessionLimits(input.chatSessionId, sessionMap);
this.enforceGlobalLimits();
if (sessionMap.has(handle.id)) this.startSpill(handle);
return handle;
}
get(handleId: string, chatSessionId?: string): ToolOutputHandle | undefined {
this.pruneExpired();
if (chatSessionId) {
const handle = this.bySession.get(chatSessionId)?.get(handleId);
if (handle) handle.accessedAt = this.now();
return handle;
}
for (const sessionMap of this.bySession.values()) {
const handle = sessionMap.get(handleId);
if (handle) {
handle.accessedAt = this.now();
return handle;
}
}
return undefined;
}
listPendingHandles(chatSessionId: string): ToolOutputHandle[] {
this.pruneExpired();
return [...(this.bySession.get(chatSessionId)?.values() ?? [])];
}
async flush(chatSessionId: string): Promise<void> {
const handles = [...(this.bySession.get(chatSessionId)?.values() ?? [])];
await Promise.allSettled(handles.map(handle => handle.spillPromise));
}
read(input: ReadToolOutputInput, chatSessionId?: string): string | null {
return this.readChunk(input, chatSessionId)?.content ?? null;
}
readChunk(input: ReadToolOutputInput, chatSessionId?: string): ToolOutputReadResult | null {
const handle = this.get(input.handleId, chatSessionId);
if (!handle) return null;
if (handle.fullContent == null) return null;
return buildReadResult(handle, handle.fullContent, input);
}
async readChunkAsync(input: ReadToolOutputInput, chatSessionId?: string): Promise<ToolOutputReadResult | null> {
let handle = this.get(input.handleId, chatSessionId);
if (!handle && chatSessionId) {
handle = await this.restoreHandle(input.handleId, chatSessionId);
}
if (!handle) return null;
await handle.spillPromise;
if (handle.fullContent != null) return buildReadResult(handle, handle.fullContent, input);
if (!handle.filePath || !this.persistence) return null;
const persisted = await this.persistence.read(handle.filePath, input);
if (!persisted) {
this.removeHandle(handle);
return null;
}
return {
...persisted,
handleId: handle.id,
totalChars: handle.totalChars,
storedChars: handle.storedChars,
sourceTruncated: handle.sourceTruncated,
};
}
prune(chatSessionId: string): void {
this.lifecycleDenyFilter.add(`chat:${chatSessionId}`);
this.failedSessionDeletions.delete(chatSessionId);
this.sessionGenerations.set(chatSessionId, (this.sessionGenerations.get(chatSessionId) ?? 0) + 1);
for (const key of this.deletedTerminalSessions.keys()) {
if (key.startsWith(`${chatSessionId}:`)) {
this.deletedTerminalSessions.delete(key);
this.terminalMutationGenerations.delete(key);
this.failedTerminalDeletions.delete(key);
}
}
const sessionMap = this.bySession.get(chatSessionId);
if (sessionMap) {
for (const handle of sessionMap.values()) this.evictHandle(handle);
}
this.bySession.delete(chatSessionId);
let deletionSucceeded = false;
const deletion = this.persistence?.deleteSession?.(chatSessionId)
.then(
() => { deletionSucceeded = true; },
() => {},
);
if (deletion) {
this.sessionDeletionPromises.set(chatSessionId, deletion);
void deletion.finally(() => {
if (this.sessionDeletionPromises.get(chatSessionId) !== deletion) {
return;
}
this.sessionDeletionPromises.delete(chatSessionId);
if (deletionSucceeded) {
this.cleanupSessionGeneration(chatSessionId);
} else {
this.failedSessionDeletions.add(chatSessionId);
this.enforceSessionDeletionMetadataLimit();
}
});
} else if (this.persistence?.restore && !this.persistence.deleteSession) {
this.failedSessionDeletions.add(chatSessionId);
this.enforceSessionDeletionMetadataLimit();
}
this.cleanupSessionGeneration(chatSessionId);
}
pruneTerminalSession(chatSessionId: string, terminalSessionId: string): void {
const terminalKey = `${chatSessionId}:${terminalSessionId}`;
this.lifecycleDenyFilter.add(`terminal-key:${terminalKey}`);
this.deletedTerminalSessions.delete(terminalKey);
this.deletedTerminalSessions.set(terminalKey, chatSessionId);
this.failedTerminalDeletions.delete(terminalKey);
this.terminalMutationGenerations.set(
terminalKey,
(this.terminalMutationGenerations.get(terminalKey) ?? 0) + 1,
);
const sessionMap = this.bySession.get(chatSessionId);
if (sessionMap) {
for (const [handleId, handle] of sessionMap) {
if (handle.sessionId !== terminalSessionId) continue;
sessionMap.delete(handleId);
this.evictHandle(handle);
}
if (sessionMap.size === 0) this.bySession.delete(chatSessionId);
}
let deletionSucceeded = false;
const deletion = this.persistence?.deleteTerminalSession?.(chatSessionId, terminalSessionId)
.then(
() => { deletionSucceeded = true; },
() => {},
);
if (deletion) {
this.terminalDeletionPromises.set(terminalKey, deletion);
void deletion.finally(() => {
if (this.terminalDeletionPromises.get(terminalKey) !== deletion) {
return;
}
this.terminalDeletionPromises.delete(terminalKey);
if (deletionSucceeded) {
this.cleanupTerminalMutationMetadata(terminalKey, chatSessionId);
} else {
this.failedTerminalDeletions.add(terminalKey);
this.enforceTerminalMutationMetadataLimit();
}
});
}
this.cleanupTerminalMutationMetadata(terminalKey, chatSessionId);
this.enforceTerminalMutationMetadataLimit();
}
pruneTerminalSessionEverywhere(terminalSessionId: string): void {
this.lifecycleDenyFilter.add(`closed-terminal:${terminalSessionId}`);
this.closedTerminalSessions.delete(terminalSessionId);
this.closedTerminalSessions.add(terminalSessionId);
while (this.closedTerminalSessions.size > TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS) {
const oldestTerminalSessionId = this.closedTerminalSessions.values().next().value;
if (oldestTerminalSessionId === undefined) break;
this.closedTerminalSessions.delete(oldestTerminalSessionId);
}
const chatSessionIds = [...this.bySession.keys()];
for (const chatSessionId of chatSessionIds) {
this.pruneTerminalSession(chatSessionId, terminalSessionId);
}
void this.persistence?.deleteTerminalEverywhere?.(terminalSessionId).catch(() => {});
}
private startSpill(handle: ToolOutputHandle): void {
if (!this.persistence || (handle.fullContent?.length ?? 0) < this.spillThresholdChars) return;
const persistence = this.persistence;
const content = handle.fullContent!;
handle.spillPromise = persistence.write(toPersistedRecord(handle), content).then(async path => {
if (handle.evicted) {
await persistence.delete(path);
return;
}
handle.filePath = path;
handle.fullContent = undefined;
}).catch(() => {
// Keep the in-memory copy if persistence is temporarily unavailable.
});
}
private resolveRestartPersistenceNoticesValue(value: unknown, chatSessionId: string): unknown {
if (typeof value === 'string') {
return this.resolveRestartPersistenceNoticeString(value, chatSessionId);
}
if (Array.isArray(value)) {
return value.map(entry => this.resolveRestartPersistenceNoticesValue(entry, chatSessionId));
}
if (!value || typeof value !== 'object') return value;
const record = value as Record<string, unknown>;
const handleId = typeof record.handleId === 'string' ? record.handleId : undefined;
return Object.fromEntries(Object.entries(record).map(([key, entry]) => {
if (typeof entry === 'string' && handleId && this.isHandleRestartPersistent(handleId, chatSessionId)) {
return [key, removeRestartPersistenceWarning(entry)];
}
return [key, this.resolveRestartPersistenceNoticesValue(entry, chatSessionId)];
}));
}
private resolveRestartPersistenceNoticeString(value: string, chatSessionId: string): string {
const handleIds = [...value.matchAll(/handleId=(tool-output-[A-Za-z0-9-]+)/g)]
.map(match => match[1]);
if (!handleIds.length) return value;
return handleIds.every(handleId => this.isHandleRestartPersistent(handleId, chatSessionId))
? removeRestartPersistenceWarning(value)
: value;
}
private isHandleRestartPersistent(handleId: string, chatSessionId: string): boolean {
return Boolean(this.bySession.get(chatSessionId)?.get(handleId)?.filePath);
}
private enforceSessionLimits(chatSessionId: string, sessionMap: Map<string, ToolOutputHandle>): void {
const totalChars = () => [...sessionMap.values()].reduce((sum, item) => sum + item.storedChars, 0);
while (
sessionMap.size > this.maxHandlesPerSession
|| totalChars() > this.maxCharsPerSession
) {
const oldest = [...sessionMap.values()].sort((a, b) => a.accessedAt - b.accessedAt)[0];
if (!oldest) break;
sessionMap.delete(oldest.id);
this.evictHandle(oldest);
}
if (sessionMap.size === 0) this.bySession.delete(chatSessionId);
}
private pruneExpired(): void {
const cutoff = this.now() - this.ttlMs;
for (const [chatSessionId, sessionMap] of this.bySession) {
for (const [handleId, handle] of sessionMap) {
if (handle.accessedAt > cutoff) continue;
sessionMap.delete(handleId);
if (!handle.filePath) this.evictHandle(handle);
}
if (sessionMap.size === 0) this.bySession.delete(chatSessionId);
}
}
private enforceGlobalLimits(): void {
const allHandles = () => [...this.bySession.entries()].flatMap(([chatSessionId, sessionMap]) => (
[...sessionMap.values()].map(handle => ({ chatSessionId, sessionMap, handle }))
));
while (true) {
const entries = allHandles();
const totalChars = entries.reduce((sum, entry) => sum + entry.handle.storedChars, 0);
if (entries.length <= this.maxHandlesGlobal && totalChars <= this.maxCharsGlobal) break;
const oldest = entries.sort((a, b) => a.handle.accessedAt - b.handle.accessedAt)[0];
if (!oldest) break;
oldest.sessionMap.delete(oldest.handle.id);
this.evictHandle(oldest.handle);
if (oldest.sessionMap.size === 0) this.bySession.delete(oldest.chatSessionId);
}
}
private evictHandle(handle: ToolOutputHandle): void {
handle.evicted = true;
if (handle.filePath && this.persistence) {
void this.persistence.delete(handle.filePath).catch(() => {});
}
}
private async restoreHandle(handleId: string, chatSessionId: string): Promise<ToolOutputHandle | undefined> {
if (!this.persistence?.restore) return undefined;
const pendingDeletion = this.sessionDeletionPromises.get(chatSessionId);
if (pendingDeletion) await pendingDeletion;
const key = `${chatSessionId}:${handleId}`;
const pending = this.restorePromises.get(key);
if (pending) return pending;
const generation = this.sessionGenerations.get(chatSessionId) ?? 0;
const terminalMutationGenerations = new Map(this.terminalMutationGenerations);
const restorePromise = this.restoreHandleImpl(
handleId,
chatSessionId,
generation,
terminalMutationGenerations,
).finally(() => {
this.restorePromises.delete(key);
this.cleanupSessionGeneration(chatSessionId);
this.enforceSessionDeletionMetadataLimit();
this.cleanupTerminalMutationMetadataForChat(chatSessionId);
});
this.restorePromises.set(key, restorePromise);
return restorePromise;
}
private async restoreHandleImpl(
handleId: string,
chatSessionId: string,
generation: number,
terminalMutationGenerations: Map<string, number>,
): Promise<ToolOutputHandle | undefined> {
const restored = await this.persistence?.restore?.(handleId, chatSessionId);
if (!restored || !isValidPersistedRecord(restored.record, handleId, chatSessionId)) return undefined;
const restoredTerminalKey = restored.record.terminalSessionId
? `${chatSessionId}:${restored.record.terminalSessionId}`
: undefined;
if (
this.failedSessionDeletions.has(chatSessionId)
|| this.lifecycleDenyFilter.has(`chat:${chatSessionId}`)
|| (this.sessionGenerations.get(chatSessionId) ?? 0) !== generation
|| (
restoredTerminalKey
&& (this.terminalMutationGenerations.get(restoredTerminalKey) ?? 0)
!== (terminalMutationGenerations.get(restoredTerminalKey) ?? 0)
)
|| (
restoredTerminalKey
&& (
this.deletedTerminalSessions.has(restoredTerminalKey)
|| this.lifecycleDenyFilter.has(`terminal-key:${restoredTerminalKey}`)
)
)
|| (
restored.record.terminalSessionId
&& (
this.closedTerminalSessions.has(restored.record.terminalSessionId)
|| this.lifecycleDenyFilter.has(`closed-terminal:${restored.record.terminalSessionId}`)
)
)
) {
void this.persistence?.delete(restored.path).catch(() => {});
return undefined;
}
const record = restored.record;
const handle: ToolOutputHandle = {
id: record.handleId,
chatSessionId: record.chatSessionId,
capabilityId: record.capabilityId,
sessionId: record.terminalSessionId,
totalChars: record.totalChars,
storedChars: record.storedChars,
sourceTruncated: record.sourceTruncated,
preview: record.preview,
storedAt: record.storedAt,
accessedAt: this.now(),
filePath: restored.path,
};
const sessionMap = this.bySession.get(chatSessionId) ?? new Map<string, ToolOutputHandle>();
const existing = sessionMap.get(handleId);
if (existing) return existing;
sessionMap.set(handleId, handle);
this.bySession.set(chatSessionId, sessionMap);
this.enforceSessionLimits(chatSessionId, sessionMap);
this.enforceGlobalLimits();
return sessionMap.get(handleId);
}
private removeHandle(handle: ToolOutputHandle): void {
const sessionMap = this.bySession.get(handle.chatSessionId);
sessionMap?.delete(handle.id);
if (sessionMap?.size === 0) this.bySession.delete(handle.chatSessionId);
this.evictHandle(handle);
}
private cleanupSessionGeneration(chatSessionId: string): void {
if (this.sessionDeletionPromises.has(chatSessionId)) return;
if (this.failedSessionDeletions.has(chatSessionId)) return;
if (this.hasPendingRestoreForChat(chatSessionId)) return;
this.sessionGenerations.delete(chatSessionId);
}
private enforceSessionDeletionMetadataLimit(): void {
while (this.failedSessionDeletions.size > TOOL_OUTPUT_MAX_FAILED_SESSION_DELETIONS) {
let removed = false;
for (const chatSessionId of this.failedSessionDeletions) {
if (
this.sessionDeletionPromises.has(chatSessionId)
|| this.hasPendingRestoreForChat(chatSessionId)
) {
continue;
}
this.failedSessionDeletions.delete(chatSessionId);
this.sessionGenerations.delete(chatSessionId);
removed = true;
break;
}
if (!removed) break;
}
}
private cleanupTerminalMutationMetadata(terminalKey: string, chatSessionId: string): void {
if (this.terminalDeletionPromises.has(terminalKey)) return;
if (this.failedTerminalDeletions.has(terminalKey)) return;
if (this.hasPendingRestoreForChat(chatSessionId)) return;
if (this.persistence?.restore && !this.persistence.deleteTerminalSession) return;
this.terminalMutationGenerations.delete(terminalKey);
this.deletedTerminalSessions.delete(terminalKey);
}
private cleanupTerminalMutationMetadataForChat(chatSessionId: string): void {
const terminalPrefix = `${chatSessionId}:`;
for (const terminalKey of this.terminalMutationGenerations.keys()) {
if (terminalKey.startsWith(terminalPrefix)) {
this.cleanupTerminalMutationMetadata(terminalKey, chatSessionId);
}
}
this.enforceTerminalMutationMetadataLimit();
}
private hasPendingRestoreForChat(chatSessionId: string): boolean {
const restorePrefix = `${chatSessionId}:`;
return [...this.restorePromises.keys()].some(key => key.startsWith(restorePrefix));
}
private enforceTerminalMutationMetadataLimit(): void {
while (this.deletedTerminalSessions.size > TOOL_OUTPUT_MAX_CLOSED_TERMINAL_SESSIONS) {
let removed = false;
for (const [terminalKey, chatSessionId] of this.deletedTerminalSessions) {
if (
this.terminalDeletionPromises.has(terminalKey)
|| this.hasPendingRestoreForChat(chatSessionId)
) {
continue;
}
this.deletedTerminalSessions.delete(terminalKey);
this.terminalMutationGenerations.delete(terminalKey);
this.failedTerminalDeletions.delete(terminalKey);
removed = true;
break;
}
if (!removed) break;
}
}
}
function removeRestartPersistenceWarning(value: string): string {
return value
.replace(' restartPersistence=unavailable (read before closing the app)', '')
.replace('This saved output is available only until the app closes. Read this handle before closing the app.', '')
.replace(
'Full file content is available only until the app closes. Use tool_output_read now.',
'Full file content stored. Use tool_output_read with this handleId to read more.',
)
.replace(/\n{3,}/g, '\n\n')
.trimEnd();
}
function toPersistedRecord(handle: ToolOutputHandle): PersistedToolOutputRecord {
return {
schemaVersion: 1,
handleId: handle.id,
chatSessionId: handle.chatSessionId,
capabilityId: handle.capabilityId,
terminalSessionId: handle.sessionId,
totalChars: handle.totalChars,
storedChars: handle.storedChars,
sourceTruncated: handle.sourceTruncated,
preview: handle.preview,
storedAt: handle.storedAt,
accessedAt: handle.accessedAt,
};
}
function isValidPersistedRecord(
record: PersistedToolOutputRecord,
handleId: string,
chatSessionId: string,
): boolean {
return record?.schemaVersion === 1
&& record.handleId === handleId
&& record.chatSessionId === chatSessionId
&& typeof record.capabilityId === 'string'
&& record.capabilityId.length > 0
&& Number.isFinite(record.totalChars)
&& record.totalChars >= 0
&& Number.isFinite(record.storedChars)
&& record.storedChars >= 0
&& record.storedChars <= TOOL_OUTPUT_MAX_HANDLE_CHARS
&& typeof record.sourceTruncated === 'boolean'
&& typeof record.preview === 'string'
&& Number.isFinite(record.storedAt)
&& Number.isFinite(record.accessedAt);
}
function retainBoundedContent(content: string, maxChars: number): string {
if (content.length <= maxChars) return content;
const marker = `\n\n[... source output exceeded local handle limit; ${content.length - maxChars} chars omitted ...]\n\n`;
if (marker.length >= maxChars) return content.slice(0, maxChars);
const budget = Math.max(0, maxChars - marker.length);
const head = Math.floor(budget / 2);
const tail = budget - head;
return `${content.slice(0, head)}${marker}${content.slice(-tail)}`;
}
function buildReadResult(
handle: ToolOutputHandle,
content: string,
input: ReadToolOutputInput,
): ToolOutputReadResult {
const requestedMax = Number.isFinite(input.maxChars)
? Math.floor(input.maxChars!)
: TOOL_OUTPUT_READ_MAX_CHARS;
const maxChars = Math.min(TOOL_OUTPUT_READ_MAX_CHARS, Math.max(1, requestedMax));
const mode = input.mode ?? 'head';
if (mode === 'search') {
const query = input.query ?? '';
if (!query) {
return {
handleId: handle.id,
mode,
content: 'Search query is required.',
totalChars: handle.totalChars,
storedChars: handle.storedChars,
sourceTruncated: handle.sourceTruncated,
startOffset: 0,
endOffset: 0,
nextOffset: 0,
hasMore: false,
matchOffsets: [],
};
}
const haystack = content.toLocaleLowerCase();
const needle = query.toLocaleLowerCase();
const offsets: number[] = [];
let cursor = Math.max(0, Math.floor(input.offset ?? 0));
while (offsets.length < TOOL_OUTPUT_SEARCH_MAX_MATCHES) {
const match = haystack.indexOf(needle, cursor);
if (match < 0) break;
offsets.push(match);
cursor = match + Math.max(1, needle.length);
}
const excerpts: string[] = [];
const renderedOffsets: number[] = [];
let renderedChars = 0;
for (const match of offsets) {
const [start, end] = safeSliceBounds(
content,
match - TOOL_OUTPUT_SEARCH_CONTEXT_CHARS,
match + query.length + TOOL_OUTPUT_SEARCH_CONTEXT_CHARS,
);
const excerpt = `[match offset=${match}]\n${content.slice(start, end)}`;
const separator = excerpts.length > 0 ? '\n\n' : '';
const available = maxChars - renderedChars - separator.length;
if (available <= 0) break;
if (excerpt.length > available) {
if (excerpts.length > 0) break;
const [, safeEnd] = safeSliceBounds(excerpt, 0, available);
excerpts.push(excerpt.slice(0, safeEnd));
renderedOffsets.push(match);
renderedChars += safeEnd;
break;
}
excerpts.push(excerpt);
renderedOffsets.push(match);
renderedChars += separator.length + excerpt.length;
}
const rendered = excerpts.join('\n\n');
const nextOffset = renderedOffsets.length > 0
? renderedOffsets[renderedOffsets.length - 1] + Math.max(1, query.length)
: content.length;
return {
handleId: handle.id,
mode,
content: rendered || `No matches found for "${query}".`,
totalChars: handle.totalChars,
storedChars: handle.storedChars,
sourceTruncated: handle.sourceTruncated,
startOffset: Math.max(0, Math.floor(input.offset ?? 0)),
endOffset: nextOffset,
nextOffset,
hasMore: haystack.indexOf(needle, nextOffset) >= 0,
matchOffsets: renderedOffsets,
};
}
let startOffset = 0;
if (mode === 'tail') {
startOffset = Math.max(0, content.length - maxChars);
} else if (mode === 'range') {
startOffset = Math.min(content.length, Math.max(0, Math.floor(input.offset ?? 0)));
}
const [safeStartOffset, safeEndOffset] = safeSliceBounds(
content,
startOffset,
startOffset + maxChars,
);
startOffset = safeStartOffset;
const selected = content.slice(startOffset, safeEndOffset);
const endOffset = safeEndOffset;
return {
handleId: handle.id,
mode,
content: selected,
totalChars: handle.totalChars,
storedChars: handle.storedChars,
sourceTruncated: handle.sourceTruncated,
startOffset,
endOffset,
nextOffset: endOffset,
hasMore: endOffset < content.length,
};
}
export const globalToolOutputStore = new ToolOutputStore();

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ToolResultDedup } from './toolResultDedup';
test('completed write replay is ordered, one-shot, and scoped to the current turn', () => {
const dedup = new ToolResultDedup();
dedup.beginTurn();
dedup.rememberCompletedWrite('same-command', 'first');
dedup.rememberCompletedWrite('same-command', 'second');
dedup.enableWriteReplay();
assert.equal(dedup.replayCompletedWrite('same-command'), 'first');
assert.equal(dedup.replayCompletedWrite('same-command'), 'second');
assert.equal(dedup.replayCompletedWrite('same-command'), undefined);
dedup.rememberCompletedWrite('later-command', 'later');
assert.equal(dedup.replayCompletedWrite('later-command'), undefined);
dedup.beginTurn();
dedup.enableWriteReplay();
assert.equal(dedup.replayCompletedWrite('same-command'), undefined);
assert.equal(dedup.replayCompletedWrite('later-command'), undefined);
});
test('completed writes already present in retry history are not replayed', () => {
const dedup = new ToolResultDedup();
dedup.beginTurn();
dedup.rememberCompletedWrite('preserved-command', 'old result');
dedup.enableWriteReplay(['preserved-command']);
assert.equal(dedup.replayCompletedWrite('preserved-command'), undefined);
});

View File

@@ -0,0 +1,136 @@
export interface ToolResultDedupEntry {
fingerprint: string;
toolName: string;
turnNumber: number;
preview: string;
}
export class ToolResultDedup {
private turnNumber = 0;
private readonly cache = new Map<string, ToolResultDedupEntry>();
private readonly consumedBudgets = new Map<string, number>();
private readonly completedWrites = new Map<string, unknown[]>();
private replayableWrites = new Map<string, unknown[]>();
private readonly terminalJobSessions = new Map<string, string>();
private writeReplayEnabled = false;
beginTurn(): void {
this.turnNumber += 1;
this.consumedBudgets.clear();
this.completedWrites.clear();
this.replayableWrites.clear();
this.writeReplayEnabled = false;
}
reset(): void {
this.cache.clear();
this.turnNumber = 0;
this.consumedBudgets.clear();
this.completedWrites.clear();
this.replayableWrites.clear();
this.terminalJobSessions.clear();
this.writeReplayEnabled = false;
}
rememberCompletedWrite(fingerprint: string, result: unknown): void {
const results = this.completedWrites.get(fingerprint) ?? [];
results.push(result);
this.completedWrites.set(fingerprint, results);
}
rememberTerminalJobSession(jobId: string, sessionId: string): void {
this.terminalJobSessions.set(jobId, sessionId);
}
terminalSessionForJob(jobId: string): string | undefined {
return this.terminalJobSessions.get(jobId);
}
enableWriteReplay(preservedFingerprints: Iterable<string> = []): void {
this.replayableWrites = new Map(
Array.from(this.completedWrites, ([fingerprint, results]) => [fingerprint, [...results]]),
);
this.writeReplayEnabled = true;
for (const fingerprint of preservedFingerprints) {
this.consumeReplay(fingerprint);
}
}
replayCompletedWrite(fingerprint: string): unknown | undefined {
if (!this.writeReplayEnabled) return undefined;
return this.consumeReplay(fingerprint);
}
private consumeReplay(fingerprint: string): unknown | undefined {
const results = this.replayableWrites.get(fingerprint);
const result = results?.shift();
if (results?.length === 0) this.replayableWrites.delete(fingerprint);
return result;
}
takeBudget(key: string, requested: number, limit: number): number {
const consumed = this.consumedBudgets.get(key) ?? 0;
const granted = Math.max(0, Math.min(requested, limit - consumed));
this.consumedBudgets.set(key, consumed + granted);
return granted;
}
fingerprintFor(toolName: string, key: string): string {
return `${toolName}:${key}`;
}
check(fingerprint: string): ToolResultDedupEntry | undefined {
return this.cache.get(fingerprint);
}
remember(toolName: string, fingerprint: string, preview: string): void {
this.cache.set(fingerprint, {
fingerprint,
toolName,
turnNumber: this.turnNumber,
preview: preview.slice(0, 160),
});
}
buildCachedNotice(entry: ToolResultDedupEntry): string {
return `[cached] same as turn ${entry.turnNumber} for ${entry.toolName}`;
}
}
export function hashScopeKey(parts: Array<string | undefined>): string {
return parts.filter(Boolean).join('|');
}
export function buildTerminalWriteFingerprint(
toolName: 'terminal_execute' | 'terminal_start',
chatSessionId: string | undefined,
args: { sessionId?: unknown; command?: unknown },
): string | undefined {
if (typeof args.sessionId !== 'string' || typeof args.command !== 'string') return undefined;
const fingerprintToolName = toolName === 'terminal_start' ? 'terminal.start:write' : toolName;
return `${fingerprintToolName}:${hashScopeKey([chatSessionId, args.sessionId, args.command])}`;
}
export function previewToolResult(result: unknown): string {
if (typeof result === 'string') return result.slice(0, 160);
try {
return JSON.stringify(result).slice(0, 160);
} catch {
return String(result).slice(0, 160);
}
}
export function hashToolResult(result: unknown): string {
let value: string;
try {
value = typeof result === 'string' ? result : JSON.stringify(result);
} catch {
value = String(result);
}
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}

View File

@@ -0,0 +1,59 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { fitLargeToolResultForModel } from './toolResultFitting';
import { ToolOutputStore } from './toolOutputStore';
describe('fitLargeToolResultForModel', () => {
it('truncates large nested string fields and stores the full content behind a handle', () => {
const store = new ToolOutputStore();
const body = `${'alpha\n'.repeat(1000)}important-tail`;
const fitted = fitLargeToolResultForModel({
result: {
ok: true,
note: {
id: 'note-1',
title: 'Runbook',
content: body,
},
},
capabilityId: 'vault.note.get',
chatSessionId: 'chat-1',
toolOutputStore: store,
maxStringChars: 500,
}) as {
note: { content: string };
};
assert.notEqual(fitted.note.content, body);
assert.match(fitted.note.content, /tool output handle/);
assert.match(fitted.note.content, /capability=vault\.note\.get/);
assert.match(fitted.note.content, /field=note\.content/);
assert.match(fitted.note.content, /handleId=tool-output-/);
const handleId = fitted.note.content.match(/handleId=(tool-output-[^\]\s]+)/)?.[1];
assert.ok(handleId);
assert.equal(store.read({ handleId, mode: 'full', maxChars: body.length + 100 }, 'chat-1'), body);
});
it('leaves small results unchanged', () => {
const result = {
ok: true,
note: {
id: 'note-1',
content: 'short note',
},
};
const fitted = fitLargeToolResultForModel({
result,
capabilityId: 'vault.note.get',
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
maxStringChars: 500,
});
assert.equal(fitted, result);
});
});

View File

@@ -0,0 +1,140 @@
import { compressVerboseText, truncateTextWithHeadAndTail } from '../requestPayloadCompression';
import type { ToolOutputStore } from './toolOutputStore';
import { redactSecretsForModel } from './modelSecretRedaction';
export const MAX_LIVE_TOOL_STRING_CHARS = 8_000;
export interface FitLargeToolResultForModelInput {
result: unknown;
capabilityId: string;
chatSessionId?: string;
toolOutputStore?: ToolOutputStore;
terminalSessionId?: string;
maxStringChars?: number;
normalizeStrings?: boolean;
}
export function fitLargeToolResultForModel({
result,
capabilityId,
chatSessionId,
toolOutputStore,
terminalSessionId,
maxStringChars = MAX_LIVE_TOOL_STRING_CHARS,
normalizeStrings = false,
}: FitLargeToolResultForModelInput): unknown {
return fitValue(result, {
capabilityId,
chatSessionId,
toolOutputStore,
terminalSessionId,
maxStringChars,
normalizeStrings,
path: [],
});
}
interface FitValueContext {
capabilityId: string;
chatSessionId?: string;
toolOutputStore?: ToolOutputStore;
terminalSessionId?: string;
maxStringChars: number;
normalizeStrings: boolean;
path: string[];
}
function fitValue(value: unknown, ctx: FitValueContext): unknown {
if (typeof value === 'string') {
return fitString(value, ctx);
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((entry, index) => {
const fitted = fitValue(entry, {
...ctx,
path: [...ctx.path, `[${index}]`],
});
if (fitted !== entry) changed = true;
return fitted;
});
return changed ? next : value;
}
if (!value || typeof value !== 'object') {
return value;
}
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const fitted = fitValue(entry, {
...ctx,
path: [...ctx.path, key],
});
if (fitted !== entry) changed = true;
next[key] = fitted;
}
return changed ? next : value;
}
function fitString(value: string, ctx: FitValueContext): string {
const safeValue = redactSecretsForModel(value);
const normalizedValue = ctx.normalizeStrings ? compressVerboseText(safeValue) : safeValue;
if (safeValue.length <= ctx.maxStringChars && normalizedValue.length <= ctx.maxStringChars) {
return normalizedValue;
}
const fitted = truncateTextWithHeadAndTail(
ctx.normalizeStrings ? normalizedValue : compressVerboseText(safeValue),
ctx.maxStringChars,
);
if (fitted === safeValue) return safeValue;
let handleId: string | undefined;
if (ctx.toolOutputStore && ctx.chatSessionId) {
handleId = ctx.toolOutputStore.store({
chatSessionId: ctx.chatSessionId,
capabilityId: ctx.capabilityId,
content: value,
sessionId: ctx.terminalSessionId,
}).id;
}
return appendToolOutputHandleNotice(fitted, {
capabilityId: ctx.capabilityId,
fieldPath: formatFieldPath(ctx.path),
totalChars: value.length,
handleId,
restartPersistenceAvailable: false,
});
}
function formatFieldPath(path: string[]): string {
if (path.length === 0) return '$';
return path
.map((part, index) => {
if (part.startsWith('[')) return part;
return index === 0 ? part : `.${part}`;
})
.join('');
}
function appendToolOutputHandleNotice(
fitted: string,
details: {
capabilityId: string;
fieldPath: string;
totalChars: number;
handleId?: string;
restartPersistenceAvailable?: boolean;
},
): string {
const handleSuffix = details.handleId ? ` handleId=${details.handleId}` : '';
const restartSuffix = details.handleId && details.restartPersistenceAvailable === false
? ' restartPersistence=unavailable (read before closing the app)'
: '';
return `${fitted}\n\n[tool output handle: capability=${details.capabilityId} field=${details.fieldPath} chars=${details.totalChars} truncated for model context${handleSuffix}${restartSuffix}]`;
}

View File

@@ -0,0 +1,69 @@
import type { AgentEvent, CompactionTrace } from './types';
const DEFAULT_MAX_EVENTS = 2_000;
const DEFAULT_MAX_COMPACTIONS = 500;
export interface TraceExport {
sessionId: string;
events: AgentEvent[];
compactions: CompactionTrace[];
exportedAt: number;
}
export class TraceStore {
private readonly events = new Map<string, AgentEvent[]>();
private readonly compactions = new Map<string, CompactionTrace[]>();
private readonly maxEvents: number;
private readonly maxCompactions: number;
constructor(
maxEvents = DEFAULT_MAX_EVENTS,
maxCompactions = Math.min(DEFAULT_MAX_COMPACTIONS, maxEvents),
) {
this.maxEvents = Math.max(1, Math.floor(maxEvents));
this.maxCompactions = Math.max(1, Math.floor(maxCompactions));
}
append(event: AgentEvent): void {
const list = this.events.get(event.sessionId) ?? [];
list.push(event);
if (list.length > this.maxEvents) {
list.splice(0, list.length - this.maxEvents);
}
this.events.set(event.sessionId, list);
if (event.type === 'compaction') {
const traces = this.compactions.get(event.sessionId) ?? [];
traces.push(event.trace);
if (traces.length > this.maxCompactions) {
traces.splice(0, traces.length - this.maxCompactions);
}
this.compactions.set(event.sessionId, traces);
}
}
getEvents(sessionId: string): readonly AgentEvent[] {
return this.events.get(sessionId) ?? [];
}
getCompactions(sessionId: string): readonly CompactionTrace[] {
return this.compactions.get(sessionId) ?? [];
}
exportTrace(sessionId: string): TraceExport {
return {
sessionId,
events: [...(this.events.get(sessionId) ?? [])],
compactions: [...(this.compactions.get(sessionId) ?? [])],
exportedAt: Date.now(),
};
}
clear(sessionId: string): void {
this.events.delete(sessionId);
this.compactions.delete(sessionId);
}
}
/** Process-wide trace store for harness debugging. */
export const globalTraceStore = new TraceStore();

View File

@@ -0,0 +1,575 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ToolOutputStore } from '../toolOutputStore';
import {
buildCattySdkMessages,
createContinuationContext,
} from './cattyMessageBuilder';
import type { ChatMessage } from '../../types';
import { prepareCattyMessagesForStream } from '../cattyRuntime';
function buildHistory(messages: ChatMessage[]) {
return buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', true),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
}
test('legacy reasoning parts with an rs_ item id but no encrypted content are dropped from replay', () => {
const messages: ChatMessage[] = [{
id: 'assistant-1',
role: 'assistant',
content: 'Done.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: 'legacy reasoning',
providerOptions: { openai: { itemId: 'rs_legacy' } },
},
],
},
}];
const sdkMessages = buildHistory(messages);
assert.equal(sdkMessages.length, 1);
// With the legacy reasoning part dropped, only text remains, so the
// assistant content collapses to a plain string.
assert.equal(sdkMessages[0].content, 'Done.');
});
test('reasoning parts with encrypted content and non-OpenAI reasoning parts survive replay', () => {
const messages: ChatMessage[] = [{
id: 'assistant-1',
role: 'assistant',
content: 'Done.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: 'replayable reasoning',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
},
{
text: 'plain reasoning',
},
],
},
}];
const sdkMessages = buildHistory(messages);
assert.equal(sdkMessages.length, 1);
const content = sdkMessages[0].content;
assert.ok(Array.isArray(content));
assert.deepEqual(
content.map((part) => (part as { type: string; text?: string }).text),
['replayable reasoning', 'plain reasoning', 'Done.'],
);
});
test('an unreplayable reasoning item discards the whole tool-call exchange from replay', () => {
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
const toolResult = {
toolCallId: 'call-1',
content: 'output',
};
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: 'legacy reasoning',
providerOptions: { openai: { itemId: 'rs_legacy' } },
},
],
},
toolCalls: [toolCall],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [toolResult],
},
{
id: 'assistant-2',
role: 'assistant',
content: 'All done.',
timestamp: 4,
},
];
const sdkMessages = buildHistory(messages);
// The tool-call/tool-result exchange is discarded entirely; only the plain
// assistant text messages survive replay.
assert.equal(sdkMessages.length, 2);
assert.equal(sdkMessages[0].content, 'Running it.');
assert.equal(sdkMessages[1].content, 'All done.');
const chatMessages = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', false),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.equal(chatMessages.length, 3);
assert.equal(chatMessages[1].role, 'tool');
});
test('a Responses model switch discards a reasoning-backed tool exchange from the old source', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [{
text: 'replayable only for the original model',
providerOptions: {
openai: { itemId: 'rs_old_model', reasoningEncryptedContent: 'enc-old-model' },
},
}],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
},
];
const buildForModel = (usesOpenAIResponses: boolean) => buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext(
'provider-1',
'openai',
'model-2',
usesOpenAIResponses,
),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
const responsesMessages = buildForModel(true);
assert.deepEqual(responsesMessages, [{ role: 'assistant', content: 'Running it.' }]);
// Chat Completions does not require a Responses reasoning item alongside
// the generic call/result pair, so keep the existing cross-model behavior.
const chatMessages = buildForModel(false);
assert.equal(chatMessages.length, 2);
assert.equal(chatMessages[1].role, 'tool');
});
test('a Responses provider switch keeps a non-OpenAI reasoning tool exchange', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude' },
reasoningParts: [{
text: 'prior Anthropic thinking',
providerOptions: { anthropic: { signature: 'sig-1' } },
}],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
},
];
const sdkMessages = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('openai-1', 'openai', 'gpt-5', true),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.equal(sdkMessages.length, 2);
const assistantContent = sdkMessages[0].content;
assert.ok(Array.isArray(assistantContent));
assert.deepEqual(
assistantContent.map(part => part.type),
['text', 'tool-call'],
);
assert.equal(sdkMessages[1].role, 'tool');
});
test('metadata-free Responses reasoning discards its paired tool exchange', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [{ text: 'relay reasoning without replay metadata' }],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
},
];
const responsesMessages = buildHistory(messages);
assert.deepEqual(responsesMessages, [{ role: 'assistant', content: 'Running it.' }]);
const responsesAfterModelSwitch = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-2', true),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.deepEqual(
responsesAfterModelSwitch,
[{ role: 'assistant', content: 'Running it.' }],
);
const chatMessages = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', false),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.equal(chatMessages.length, 2);
assert.equal(chatMessages[1].role, 'tool');
});
test('same-provider Chat to Responses switches keep the generic tool exchange', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [{ text: 'OpenAI Chat reasoning' }],
openAIChatAssistantFields: { reasoning_content: 'OpenAI Chat reasoning' },
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
},
];
for (const modelId of ['model-1', 'model-2']) {
const responsesMessages = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', modelId, true),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.equal(responsesMessages.length, 2);
const assistantContent = responsesMessages[0].content;
assert.ok(Array.isArray(assistantContent));
assert.deepEqual(
assistantContent.map(part => part.type),
['text', 'tool-call'],
);
assert.equal(responsesMessages[1].role, 'tool');
}
});
test('a durable summary survives when storage trimming shifts its boundary to zero', () => {
const sdkMessages = buildCattySdkMessages({
allMessages: [{
id: 'recent-user',
role: 'user',
content: 'What should I do next?',
timestamp: 1,
}],
contextCompaction: {
summary: 'Earlier work completed the deployment.',
compactedMessageCount: 0,
},
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('provider-1', 'openai', 'model-1', true),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.equal(sdkMessages.length, 3);
assert.match(String(sdkMessages[0].content), /Earlier work completed the deployment/);
assert.equal(sdkMessages[2].content, 'What should I do next?');
});
test('tool exchanges with replayable reasoning are kept intact', () => {
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
const toolResult = {
toolCallId: 'call-1',
content: 'output',
};
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: 'replayable reasoning',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
},
],
},
toolCalls: [toolCall],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [toolResult],
},
];
const sdkMessages = buildHistory(messages);
assert.equal(sdkMessages.length, 2);
const assistantContent = sdkMessages[0].content;
assert.ok(Array.isArray(assistantContent));
assert.deepEqual(
assistantContent.map((part) => (part as { type: string }).type),
['reasoning', 'text', 'tool-call'],
);
assert.equal(sdkMessages[1].role, 'tool');
});
test('final Responses preparation preserves encrypted reasoning for a tool exchange', () => {
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [{
text: 'replayable reasoning',
providerOptions: {
openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' },
},
}],
},
toolCalls: [toolCall],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
},
];
const built = buildHistory(messages);
const prepared = prepareCattyMessagesForStream(built, { preserveReasoning: true });
const assistantContent = prepared[0].content;
assert.ok(Array.isArray(assistantContent));
const reasoning = assistantContent.find(part => part.type === 'reasoning');
assert.deepEqual(reasoning?.providerOptions?.openai, {
itemId: 'rs_new',
reasoningEncryptedContent: 'enc-abc',
});
const defaultPrepared = prepareCattyMessagesForStream(built);
const defaultAssistantContent = defaultPrepared[0].content;
assert.ok(Array.isArray(defaultAssistantContent));
assert.equal(defaultAssistantContent.some(part => part.type === 'reasoning'), false);
});
test('freshly streamed reasoning fragments with a null start payload keep the tool exchange', () => {
// Mirrors a live `@ai-sdk/openai` Responses stream (store: false): the
// reasoning-start fragment carries only the item id with
// `reasoningEncryptedContent: null`, deltas omit the key, and the ciphertext
// arrives on the reasoning-end fragment. The merge keeps both fragments for
// the same item, which must still replay.
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
const toolResult = {
toolCallId: 'call-1',
content: 'output',
};
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: '',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: null } },
},
{
text: 'replayable reasoning',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
},
],
},
toolCalls: [toolCall],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [toolResult],
},
];
const sdkMessages = buildHistory(messages);
assert.equal(sdkMessages.length, 2);
const assistantContent = sdkMessages[0].content;
assert.ok(Array.isArray(assistantContent));
// The empty ID-only start fragment is dropped from the replayed content;
// the encrypted fragment for the same item carries the ciphertext.
assert.deepEqual(
assistantContent.map((part) => (part as { type: string }).type),
['reasoning', 'text', 'tool-call'],
);
assert.equal(sdkMessages[1].role, 'tool');
});
test('every text fragment of a multi-fragment replayable reasoning item is kept', () => {
// A Responses reasoning item with several summary parts gives every
// fragment the same item id; the ciphertext arrives only on the final
// `output_item.done` fragment. Replayability is per item id, so all text
// fragments of the item must survive the replay filter.
const messages: ChatMessage[] = [{
id: 'assistant-1',
role: 'assistant',
content: 'Done.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: 'first summary',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: null } },
},
{
text: 'second summary',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: null } },
},
{
text: '',
providerOptions: { openai: { itemId: 'rs_new', reasoningEncryptedContent: 'enc-abc' } },
},
],
},
}];
const sdkMessages = buildHistory(messages);
assert.equal(sdkMessages.length, 1);
const content = sdkMessages[0].content;
assert.ok(Array.isArray(content));
assert.deepEqual(
content.map((part) => (part as { type: string; text?: string }).text),
['first summary', 'second summary', '', 'Done.'],
);
});
test('an item id whose every fragment lacks ciphertext still discards the tool exchange', () => {
const toolCall = { id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } };
const toolResult = {
toolCallId: 'call-1',
content: 'output',
};
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'provider-1', providerType: 'openai', modelId: 'model-1' },
reasoningParts: [
{
text: 'legacy reasoning',
providerOptions: { openai: { itemId: 'rs_legacy', reasoningEncryptedContent: null } },
},
],
},
toolCalls: [toolCall],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [toolResult],
},
];
const sdkMessages = buildHistory(messages);
assert.equal(sdkMessages.length, 1);
assert.equal(sdkMessages[0].content, 'Running it.');
});

View File

@@ -0,0 +1,495 @@
import type { ModelMessage } from 'ai';
import type {
AISessionContextCompaction,
ChatMessage,
ChatMessageAttachment,
ToolResult,
} from '../../types';
import { buildTerminalWriteFingerprint } from '../toolResultDedup';
import {
buildHistoricalToolReplayMaps,
buildHistoricalToolResultReplayText,
buildHistoricalUserReplayContent,
} from '../../../../components/ai/cattyHistoryReplay';
import {
buildPromptWithTerminalSelectionAttachments,
isInlineTextAttachment,
} from '../../../../application/state/terminalSelectionAttachment';
import {
getOpenAIChatAssistantFieldsForHistoryMessage,
isProviderContinuationForSource,
type OpenAIChatAssistantFields,
type ProviderContinuation,
type ProviderContinuationReasoningPart,
} from '../../providerContinuation';
import {
toAssistantModelContent,
type AssistantContentPart,
type CattyProviderContinuationContext,
} from '../../aiChatStreamingSupport';
import { redactSecretsInValueForModel } from '../modelSecretRedaction';
import { fitLargeUserInputForModel } from '../largeUserInput';
import type { ToolOutputStore } from '../toolOutputStore';
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
type ModelMessageWithOpenAIChatFields = ModelMessage & {
[OPENAI_CHAT_ASSISTANT_FIELDS]?: OpenAIChatAssistantFields;
};
function rememberOpenAIChatAssistantFields(
message: ModelMessage,
fields: OpenAIChatAssistantFields | undefined,
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
): void {
fieldsByMessage.set(message, fields);
(message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS] = fields;
}
function getRememberedOpenAIChatAssistantFields(
message: ModelMessage,
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
): OpenAIChatAssistantFields | undefined {
if (fieldsByMessage.has(message)) return fieldsByMessage.get(message);
return (message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS];
}
function modelMessageHasToolCall(message: ModelMessage): boolean {
if (message.role !== 'assistant' || !Array.isArray(message.content)) return false;
return message.content.some((part) => part && typeof part === 'object' && (part as { type?: string }).type === 'tool-call');
}
/**
* Legacy Responses histories — recorded before `reasoning-end` capture — store
* reasoning parts that carry only the server-side `rs_…` item id and no
* `reasoningEncryptedContent`. Replaying them against a stateless
* (`store: false`) Responses turn makes the SDK emit a `reasoning` item
* referencing an id that was never persisted, which the API rejects
* ("Item with id 'rs_…' not found"), leaving the conversation unable to
* continue. Dropping just the reasoning part is not enough: OpenAI Responses
* stateless tool loops require the reasoning item to accompany its
* function-call output, so replaying the paired `fc_…` call/result without it
* is also rejected. Discard the entire incompatible call/result exchange
* before replay (the assistant's plain text is still replayed); tool results
* that reference the discarded call ids are skipped as well. Reasoning parts
* with real ciphertext (or no OpenAI item id at all) are kept untouched.
*/
function getReasoningOpenAIItemId(
part: ProviderContinuationReasoningPart,
): string | undefined {
const openaiOptions = part.providerOptions?.openai as
| { itemId?: unknown }
| undefined;
const itemId = openaiOptions?.itemId;
return typeof itemId === 'string' && itemId ? itemId : undefined;
}
function partHasReasoningEncryptedContent(
part: ProviderContinuationReasoningPart,
): boolean {
const openaiOptions = part.providerOptions?.openai as
| { reasoningEncryptedContent?: unknown }
| undefined;
return typeof openaiOptions?.reasoningEncryptedContent === 'string'
&& openaiOptions.reasoningEncryptedContent.length > 0;
}
function hasOpenAIResponsesReasoningMetadata(
parts: readonly ProviderContinuationReasoningPart[],
): boolean {
return parts.some(part => (
getReasoningOpenAIItemId(part) !== undefined
|| partHasReasoningEncryptedContent(part)
));
}
/**
* A single Responses reasoning item is streamed as several fragments
* (`reasoning-start`/`reasoning-delta`/`reasoning-end`): the initial fragment
* carries only the item id (with `reasoningEncryptedContent: null`), deltas
* omit the key, and the ciphertext arrives on the final fragment. The merge
* therefore keeps an ID-only fragment next to the encrypted one for the *same*
* item, so replayability must be decided per item id: an item is unreplayable
* statelessly only when *no* fragment for that id carries ciphertext (the
* legacy case where only the id was recorded). Fragments without an OpenAI
* item id are always replayable.
*/
function hasUnreplayableReasoningItems(
parts: readonly ProviderContinuationReasoningPart[],
): boolean {
const itemIds = new Set<string>();
const itemIdsWithCiphertext = new Set<string>();
let hasCiphertextWithoutItemId = false;
for (const part of parts) {
const itemId = getReasoningOpenAIItemId(part);
if (!itemId) {
if (partHasReasoningEncryptedContent(part)) {
hasCiphertextWithoutItemId = true;
}
continue;
}
itemIds.add(itemId);
if (partHasReasoningEncryptedContent(part)) {
itemIdsWithCiphertext.add(itemId);
}
}
for (const itemId of itemIds) {
if (!itemIdsWithCiphertext.has(itemId)) return true;
}
// The Responses converter also skips reasoning that has neither an item id
// nor encrypted content. If that is the only reasoning attached to a tool
// exchange, replaying the call/result without it is unsafe. Plain delta
// fragments are still accepted when another fragment supplies the item's
// ciphertext.
return parts.length > 0 && itemIds.size === 0 && !hasCiphertextWithoutItemId;
}
/**
* Replayability is decided per reasoning item id (see
* {@link hasUnreplayableReasoningItems}): when any fragment of an item carries
* ciphertext, every fragment of that item is replayable. Filtering fragments
* independently would drop the earlier text fragments of a multi-fragment
* item whose ciphertext arrives only on the final fragment, truncating the
* reasoning item sent on later turns.
*/
function collectReplayableReasoningParts(
continuation: ProviderContinuation | undefined,
): ProviderContinuationReasoningPart[] {
const parts = continuation?.reasoningParts ?? [];
const itemIdsWithCiphertext = new Set<string>();
for (const part of parts) {
const itemId = getReasoningOpenAIItemId(part);
if (itemId && partHasReasoningEncryptedContent(part)) {
itemIdsWithCiphertext.add(itemId);
}
}
return parts.filter((part) => {
const itemId = getReasoningOpenAIItemId(part);
if (!itemId) return true;
if (!itemIdsWithCiphertext.has(itemId)) return false;
// The empty ID-only start fragment of a replayable item is redundant: the
// encrypted fragment for the same item already identifies it, and
// replaying both would duplicate the item id.
return part.text.length > 0 || partHasReasoningEncryptedContent(part);
});
}
export function collectOpenAIChatAssistantFieldsForMessages(
messages: ModelMessage[],
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
): Array<OpenAIChatAssistantFields | undefined> {
const fields: Array<OpenAIChatAssistantFields | undefined> = [];
let previousMessageWasTool = false;
for (const message of messages) {
const needsContinuationFields = message.role === 'assistant'
&& (modelMessageHasToolCall(message) || previousMessageWasTool);
if (needsContinuationFields) {
fields.push(getRememberedOpenAIChatAssistantFields(message, fieldsByMessage));
}
previousMessageWasTool = message.role === 'tool';
}
return fields;
}
export interface BuildCattySdkMessagesInput {
allMessages: ChatMessage[];
contextCompaction?: AISessionContextCompaction;
includeCurrentUserMessage: boolean;
trimmed: string;
attachments?: ChatMessageAttachment[];
continuationContext: CattyProviderContinuationContext;
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
chatSessionId: string;
toolOutputStore: ToolOutputStore;
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
}
export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelMessage[] {
const {
allMessages,
contextCompaction,
includeCurrentUserMessage,
trimmed,
attachments,
continuationContext,
preserveTerminalToolResults = new Set<ToolResult>(),
chatSessionId,
toolOutputStore,
fieldsByMessage,
} = input;
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
const nextFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const sdkMessages: ModelMessage[] = [];
// Call ids whose exchange was discarded because the paired reasoning item is
// not replayable statelessly; their tool results must not be replayed either.
const discardedToolCallIds = new Set<string>();
let previousHistoryMessageWasToolResult = false;
const compactedMessageCount = Math.min(
allMessages.length,
Math.max(0, contextCompaction?.compactedMessageCount ?? 0),
);
// The boundary can become zero when storage trims messages that were all
// covered by the durable summary. Keep injecting that summary even though
// no remaining persisted message needs to be skipped.
if (contextCompaction?.summary) {
sdkMessages.push({
role: 'user',
content: `[Previous conversation summary]\n\n${contextCompaction.summary}\n\n[Continue with the recent messages below.]`,
});
sdkMessages.push({
role: 'assistant',
content: 'I understand the previous conversation summary and will continue from the recent messages.',
});
}
for (const m of allMessages.slice(compactedMessageCount)) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
const messageAttachments = m.attachments ?? m.images;
const boundedContent = fitLargeUserInputForModel(m.content, chatSessionId, toolOutputStore);
sdkMessages.push({
role: 'user',
content: buildHistoricalUserReplayContent(boundedContent, messageAttachments ?? []),
});
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const hasStoredOpenAIChatAssistantFields = Object.keys(
m.providerContinuation?.openAIChatAssistantFields ?? {},
).length > 0;
// Provider/model identity alone cannot distinguish a Chat history from
// a Responses history when the user changes only the API format. Chat
// continuation fields are explicit evidence that its provider-specific
// reasoning must not be replayed as a Responses reasoning item.
const replayContinuation = continuationContext.usesOpenAIResponses
&& hasStoredOpenAIChatAssistantFields
? undefined
: activeContinuation;
const openAIChatAssistantFields = continuationContext.usesOpenAIResponses
? undefined
: getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
const resolvedToolCalls = resolvedToolCallsByAssistant.get(m);
const resolvedCalls = resolvedToolCalls
? m.toolCalls.filter(tc => resolvedToolCalls.has(tc))
: [];
// An unreplayable (id-only, never encrypted) reasoning item poisons
// the whole Responses tool exchange: without it the paired
// function-call output is rejected, so discard the calls instead of
// replaying them orphaned. The same applies when a model switch makes
// reasoning metadata belong to a different source: it cannot be sent
// to the active Responses model, so its tool exchange must not be sent
// without it. Freshly streamed items whose ciphertext arrived on a
// later fragment stay replayable.
const storedReasoningParts = m.providerContinuation?.reasoningParts ?? [];
const storedSource = m.providerContinuation?.source;
const sameProviderConfig = storedSource?.providerConfigId
=== continuationContext.source.providerConfigId
&& storedSource?.providerType === continuationContext.source.providerType;
const hasSourceMismatchedReasoning = !replayContinuation
&& (
hasOpenAIResponsesReasoningMetadata(storedReasoningParts)
// A model change within the same Responses configuration is also
// enough evidence that metadata-free reasoning came from this
// wire format. Cross-provider Anthropic/Google reasoning remains
// a generic, replayable call/result exchange. OpenAI Chat history
// is also generic when its captured assistant fields identify the
// original wire format.
|| (
sameProviderConfig
&& storedReasoningParts.length > 0
&& !hasStoredOpenAIChatAssistantFields
)
);
const hasUnreplayableReasoning = resolvedCalls.length > 0
&& continuationContext.usesOpenAIResponses
&& (
hasSourceMismatchedReasoning
|| hasUnreplayableReasoningItems(replayContinuation?.reasoningParts ?? [])
);
if (hasUnreplayableReasoning) {
for (const tc of resolvedCalls) discardedToolCallIds.add(tc.id);
}
const replayedCalls = hasUnreplayableReasoning ? [] : resolvedCalls;
const contentParts: AssistantContentPart[] = [];
if (replayedCalls.length > 0) {
for (const part of collectReplayableReasoningParts(replayContinuation)) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
if (m.content) {
contentParts.push({
type: 'text' as const,
text: m.content,
...(replayContinuation?.textProviderOptions ? { providerOptions: replayContinuation.textProviderOptions } : {}),
});
}
for (const tc of replayedCalls) {
const providerOptions = replayContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: redactSecretsInValueForModel(tc.arguments ?? {}),
...(providerOptions ? { providerOptions } : {}),
});
}
if (contentParts.length > 0) {
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
sdkMessages.push(message);
if (replayedCalls.length > 0) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
}
}
} else if (m.content) {
const contentParts: AssistantContentPart[] = [];
for (const part of collectReplayableReasoningParts(replayContinuation)) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
contentParts.push({
type: 'text' as const,
text: m.content,
...(replayContinuation?.textProviderOptions ? { providerOptions: replayContinuation.textProviderOptions } : {}),
});
const message: ModelMessage = {
role: 'assistant',
content: toAssistantModelContent(contentParts),
};
sdkMessages.push(message);
if (currentMessageFollowsToolResult) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
const replayableResults = m.toolResults.filter(
(tr) => !discardedToolCallIds.has(tr.toolCallId),
);
if (replayableResults.length > 0) {
sdkMessages.push({
role: 'tool',
content: replayableResults.map(tr => {
const toolCall = toolCallByToolResult.get(tr);
return {
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: toolCall?.name ?? 'unknown',
output: {
type: 'text' as const,
value: buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
}),
},
};
}),
});
}
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length
&& m.toolResults.some((tr) => !discardedToolCallIds.has(tr.toolCallId));
}
if (includeCurrentUserMessage) {
if (attachments?.length) {
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter(
(attachment) => !isInlineTextAttachment(attachment),
);
if (!modelAttachments.length) {
sdkMessages.push({ role: 'user', content: modelText });
} else {
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: modelText });
for (const att of modelAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
}
} else {
sdkMessages.push({ role: 'user', content: trimmed });
}
}
for (const [message, fields] of nextFieldsByMessage.entries()) {
fieldsByMessage.set(message, fields);
}
return sdkMessages;
}
export function collectToolResultsAfterMessage(
messages: ChatMessage[],
messageId: string,
): Set<ToolResult> {
const results = new Set<ToolResult>();
let afterMessage = false;
for (const message of messages) {
if (message.id === messageId) {
afterMessage = true;
continue;
}
if (!afterMessage || message.role !== 'tool' || !message.toolResults?.length) continue;
for (const result of message.toolResults) {
results.add(result);
}
}
return results;
}
export function collectPreservedTerminalWriteFingerprints(
messages: ChatMessage[],
messageId: string,
chatSessionId: string,
): string[] {
const preservedResults = collectToolResultsAfterMessage(messages, messageId);
const { toolCallByToolResult } = buildHistoricalToolReplayMaps(messages);
const fingerprints: string[] = [];
for (const result of preservedResults) {
const call = toolCallByToolResult.get(result);
if (call?.name !== 'terminal_execute' && call?.name !== 'terminal_start') continue;
const fingerprint = buildTerminalWriteFingerprint(call.name, chatSessionId, call.arguments);
if (fingerprint) fingerprints.push(fingerprint);
}
return fingerprints;
}
export function createContinuationContext(
providerConfigId: string,
providerType: string,
modelId: string,
usesOpenAIResponses = false,
): CattyProviderContinuationContext {
return {
source: {
providerConfigId,
providerType,
modelId,
},
usesOpenAIResponses,
openAIChatAssistantFields: [],
};
}
export type { CattyProviderContinuationContext, ProviderContinuation };

View File

@@ -0,0 +1,585 @@
import { streamText, isStepCount, type ModelMessage } from 'ai';
import { classifyError } from '../../errorClassifier';
import { isRequestTooLargeError } from '../../errorClassifier';
import { isSdkStreamStateError } from '../../shared/streamStateErrors';
import {
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from '../../cattyRequestTooLargeRetry';
import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
import type { AgentEvent } from '../types';
import type { ProviderAdvancedParams } from '../../types';
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
import { createModelFromConfig } from '../../sdk/providers';
import type { CattyToolsBundle } from '../capabilityTools';
import { buildCattyToolApproval } from '../cattyToolApproval';
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
import { buildCattyStreamTimeouts } from '../streamTimeouts';
import {
extractProviderContinuationFromRawChunk,
mergeProviderContinuation,
normalizeProviderContinuationOptions,
withProviderContinuationSource,
type ProviderContinuation,
} from '../../providerContinuation';
import {
formatToolErrorContent,
generateId,
isToolResultError,
resolveStreamChunkToolCallId,
type CattyProviderContinuationContext,
type ErrorChunk,
type RawChunk,
type ReasoningChunk,
type StreamChunk,
type TextDeltaChunk,
type ToolApprovalResponseChunk,
type ToolCallChunk,
type ToolErrorChunk,
type ToolOutputDeniedChunk,
type ToolResultChunk,
} from '../../aiChatStreamingSupport';
import type { ChatMessage } from '../../types';
export type CattyModel = ReturnType<typeof createModelFromConfig>;
export interface CattyStreamUiSink {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
}
export interface ProcessCattyStreamInput {
streamSessionId: string;
model: CattyModel;
systemPrompt: string;
toolsBundle: CattyToolsBundle;
sdkMessages: ModelMessage[];
signal: AbortSignal;
currentAssistantMsgId: string;
maxIterations: number;
advancedParams?: ProviderAdvancedParams;
reasoningProviderOptions?: CattyReasoningProviderOptions;
continuationContext?: CattyProviderContinuationContext;
turnId?: string;
commandTimeoutMs?: number;
responseIdleTimeoutMs?: number;
runtimeContext: CattyRuntimeContext;
onAgentEvent?: (event: AgentEvent) => void;
prepareStep?: (args: {
stepNumber: number;
messages: ModelMessage[];
runtimeContext: CattyRuntimeContext;
}) => Promise<{ messages: ModelMessage[]; runtimeContext?: CattyRuntimeContext } | undefined>;
ui: CattyStreamUiSink;
}
export interface ProcessCattyStreamResult {
usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
performance?: {
responseTimeMs?: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
};
}
/** Skip trace emission for SDK-internal stream bookkeeping errors we suppress in UI. */
export function shouldEmitAgentEventsForStreamChunk(chunk: StreamChunk): boolean {
if (chunk.type !== 'error') return true;
return !isSdkStreamStateError((chunk as ErrorChunk).error);
}
/**
* Detect provider metadata that actually carries replayable reasoning
* ciphertext (e.g. `openai.reasoningEncryptedContent`). A null/absent payload
* must not be persisted: merging it over a previously captured ciphertext
* would invalidate the stateless reasoning replay.
*/
function hasReasoningEncryptedContent(options: Record<string, Record<string, unknown>>): boolean {
return Object.values(options).some(providerOptions =>
typeof providerOptions?.reasoningEncryptedContent === 'string'
&& providerOptions.reasoningEncryptedContent.length > 0,
);
}
export async function processCattyStream(input: ProcessCattyStreamInput): Promise<ProcessCattyStreamResult> {
const {
streamSessionId,
model,
systemPrompt,
toolsBundle,
sdkMessages,
signal,
currentAssistantMsgId,
maxIterations,
advancedParams,
reasoningProviderOptions,
continuationContext,
turnId,
commandTimeoutMs,
responseIdleTimeoutMs,
runtimeContext: initialRuntimeContext,
onAgentEvent,
prepareStep,
ui,
} = input;
let runtimeContext = initialRuntimeContext;
const { tools, toolsContext } = toolsBundle;
const result = streamText({
model,
messages: sdkMessages,
instructions: systemPrompt,
tools,
toolsContext,
runtimeContext,
toolApproval: buildCattyToolApproval({
permissionMode: runtimeContext.permissionMode,
chatSessionId: runtimeContext.chatSessionId,
}),
stopWhen: isStepCount(maxIterations),
abortSignal: signal,
include: { rawChunks: true },
timeout: buildCattyStreamTimeouts({
permissionMode: runtimeContext.permissionMode,
commandTimeoutMs,
responseIdleTimeoutMs,
maxIterations,
}),
telemetry: {
functionId: `catty-${runtimeContext.agentKind}`,
metadata: {
chatSessionId: runtimeContext.chatSessionId,
turnId: runtimeContext.turnId,
},
},
onStart: ({ callId, modelId, runtimeContext: startContext }) => {
onAgentEvent?.({
id: `model-call-start-${callId}`,
type: 'model_call_start',
sessionId: streamSessionId,
chatSessionId: startContext.chatSessionId,
backend: 'catty',
timestamp: Date.now(),
turnId,
callId,
modelId,
providerId: startContext.providerId,
} as AgentEvent);
},
onStepEnd: (step) => {
const usage = step.usage;
onAgentEvent?.({
id: `step-end-${step.callId}-${step.stepNumber}`,
type: 'step_end',
sessionId: streamSessionId,
chatSessionId: step.runtimeContext.chatSessionId,
backend: 'catty',
timestamp: Date.now(),
turnId,
callId: step.callId,
stepNumber: step.stepNumber,
modelId: step.model.modelId,
finishReason: step.finishReason,
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
} as AgentEvent);
},
onEnd: ({ callId, usage, runtimeContext: endContext }) => {
if (usage) {
onAgentEvent?.({
id: `usage-${callId}`,
type: 'usage',
sessionId: streamSessionId,
chatSessionId: endContext.chatSessionId,
backend: 'catty',
timestamp: Date.now(),
turnId,
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
estimated: false,
} as AgentEvent);
}
},
...(prepareStep ? {
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
const prepared = await prepareStep({
stepNumber,
messages,
runtimeContext: stepRuntimeContext as CattyRuntimeContext,
});
if (prepared?.runtimeContext) {
runtimeContext = prepared.runtimeContext;
}
return prepared ?? { messages };
},
} : {}),
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
...(reasoningProviderOptions ? { providerOptions: reasoningProviderOptions } : {}),
});
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
let hadToolProgress = false;
const reader = result.stream.getReader();
let pendingText = '';
let rafId: number | null = null;
const clearCompactionStatusFromAssistant = (messageId: string) => {
ui.updateMessageById(streamSessionId, messageId, msg =>
msg.role === 'assistant' && msg.statusText
? { ...msg, statusText: undefined }
: msg,
);
};
const ensureAssistantMessage = (): string => {
if (lastAddedRole !== 'tool') return activeMsgId;
clearCompactionStatusFromAssistant(activeMsgId);
const newId = generateId();
ui.addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
return activeMsgId;
};
const updateAssistantContinuation = (
messageId: string,
continuation: ProviderContinuation | undefined,
thinkingText = '',
) => {
if (!continuation && !thinkingText) return;
const sourcedContinuation = withProviderContinuationSource(continuation, continuationContext?.source);
ui.updateMessageById(streamSessionId, messageId, msg => {
const providerContinuation = mergeProviderContinuation(msg.providerContinuation, sourcedContinuation);
return {
...msg,
...(providerContinuation ? { providerContinuation } : {}),
...(thinkingText ? { thinking: (msg.thinking || '') + thinkingText } : {}),
};
});
};
const getOpenAIReasoningText = (continuation: ProviderContinuation | undefined): string => {
const reasoningContent = continuation?.openAIChatAssistantFields?.reasoning_content;
return typeof reasoningContent === 'string' ? reasoningContent : '';
};
const flushText = () => {
if (pendingText) {
const text = pendingText;
pendingText = '';
if (lastAddedRole === 'tool') {
clearCompactionStatusFromAssistant(activeMsgId);
const newId = generateId();
ui.addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: text,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
content: msg.content + text,
...(msg.statusText ? { statusText: undefined } : {}),
}));
}
}
rafId = null;
};
const cancelPendingFlush = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
const appendToolResultToUi = (toolCallId: string, content: string, isError: boolean) => {
cancelPendingFlush();
flushText();
hadToolProgress = true;
ui.updateMessageById(streamSessionId, activeMsgId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
ui.addMessageToSession(streamSessionId, {
id: generateId(),
role: 'tool',
content: '',
toolResults: [{
toolCallId,
content,
isError,
}],
timestamp: Date.now(),
executionStatus: 'completed',
});
lastAddedRole = 'tool';
};
const deniedToolResultIds = new Set<string>();
const appendDeniedToolResultToUi = (toolCallId: string, reason?: unknown) => {
if (deniedToolResultIds.has(toolCallId)) return;
deniedToolResultIds.add(toolCallId);
appendToolResultToUi(
toolCallId,
formatToolErrorContent(reason, 'Tool execution denied.'),
true,
);
};
try {
while (true) {
let readResult: ReadableStreamReadResult<unknown>;
try {
readResult = await reader.read();
} catch (readErr) {
if (isRequestTooLargeError(readErr)) {
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
}
throw readErr;
}
const { done, value } = readResult;
if (done) break;
const chunk = value as StreamChunk;
if (shouldEmitAgentEventsForStreamChunk(chunk)) {
for (const agentEvent of mapCattyStreamChunkToAgentEvents(chunk, {
sessionId: streamSessionId,
chatSessionId: streamSessionId,
turnId,
})) {
onAgentEvent?.(agentEvent);
}
}
switch (chunk.type) {
case 'text':
case 'text-delta': {
const typedChunk = chunk as TextDeltaChunk;
const text = typedChunk.text ?? typedChunk.textDelta;
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
if (providerOptions) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, { textProviderOptions: providerOptions });
}
if (text) {
pendingText += text;
if (rafId === null) {
rafId = requestAnimationFrame(flushText);
}
}
break;
}
case 'reasoning':
case 'reasoning-start':
case 'reasoning-delta': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ReasoningChunk;
const rText = typedChunk.text ?? typedChunk.textDelta ?? typedChunk.delta ?? '';
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
const continuation = rText || providerOptions
? {
reasoningParts: [{
text: rText,
...(providerOptions ? { providerOptions } : {}),
}],
} satisfies ProviderContinuation
: undefined;
if (continuation || rText) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, rText);
}
break;
}
case 'raw': {
const typedChunk = chunk as RawChunk;
const continuation = extractProviderContinuationFromRawChunk(typedChunk.rawValue);
if (continuation) {
cancelPendingFlush();
flushText();
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, getOpenAIReasoningText(continuation));
}
break;
}
case 'reasoning-end': {
// With Responses `store: false`, the SDK delivers the reasoning
// item's encrypted content on the reasoning-end chunk (via
// output_item.done). It must be merged into the persisted reasoning
// parts or the stateless replay silently drops the reasoning item.
// Skip metadata without ciphertext (e.g. store:true item ids) so a
// null payload cannot overwrite a previously captured one.
const typedChunk = chunk as { providerMetadata?: unknown };
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
if (providerOptions && hasReasoningEncryptedContent(providerOptions)) {
cancelPendingFlush();
flushText();
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, {
reasoningParts: [{ text: '', providerOptions }],
});
}
break;
}
case 'text-start':
case 'text-end':
case 'start':
case 'finish':
case 'start-step':
case 'finish-step':
case 'tool-approval-request':
break;
case 'tool-approval-response': {
const typedChunk = chunk as ToolApprovalResponseChunk;
if (typedChunk.approved === false) {
const toolCallId = resolveStreamChunkToolCallId(typedChunk);
if (toolCallId) {
appendDeniedToolResultToUi(toolCallId, typedChunk.reason);
}
}
break;
}
case 'tool-call': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
hadToolProgress = true;
const messageId = ensureAssistantMessage();
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
ui.updateMessageById(streamSessionId, messageId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), {
id: typedChunk.toolCallId,
name: typedChunk.toolName,
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
}],
executionStatus: 'running',
statusText: undefined,
}));
if (providerOptions) {
updateAssistantContinuation(messageId, {
toolCallProviderOptionsById: {
[typedChunk.toolCallId]: providerOptions,
},
});
}
break;
}
case 'tool-result': {
const typedChunk = chunk as ToolResultChunk;
const toolOutput = typedChunk.output ?? typedChunk.result;
appendToolResultToUi(
typedChunk.toolCallId,
typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput),
isToolResultError(toolOutput),
);
break;
}
case 'tool-error': {
const typedChunk = chunk as ToolErrorChunk;
appendToolResultToUi(
typedChunk.toolCallId,
formatToolErrorContent(typedChunk.error),
true,
);
break;
}
case 'tool-output-denied': {
const typedChunk = chunk as ToolOutputDeniedChunk;
appendDeniedToolResultToUi(typedChunk.toolCallId);
break;
}
case 'error': {
const typedChunk = chunk as ErrorChunk;
if (isSdkStreamStateError(typedChunk.error)) {
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
break;
}
if (isRequestTooLargeError(typedChunk.error)) {
cancelPendingFlush();
flushText();
throw createCattyRequestTooLargeRetryError(
typedChunk.error,
hadToolProgress,
);
}
cancelPendingFlush();
flushText();
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
ui.addMessageToSession(streamSessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(typedChunk.error),
timestamp: Date.now(),
});
break;
}
default:
break;
}
}
} finally {
cancelPendingFlush();
flushText();
reader.releaseLock();
}
const usage = await result.usage;
const finalStep = await result.finalStep;
const performance = finalStep?.performance;
if (performance) {
onAgentEvent?.({
id: `performance-${turnId ?? Date.now()}`,
type: 'performance',
sessionId: streamSessionId,
chatSessionId: runtimeContext.chatSessionId,
backend: 'catty',
timestamp: Date.now(),
turnId,
responseTimeMs: performance.responseTimeMs,
timeToFirstOutputMs: performance.timeToFirstOutputMs,
outputTokensPerSecond: performance.outputTokensPerSecond,
} as AgentEvent);
}
return {
usage: usage ? {
promptTokens: usage.inputTokens,
completionTokens: usage.outputTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
} : undefined,
performance: performance ? {
responseTimeMs: performance.responseTimeMs,
timeToFirstOutputMs: performance.timeToFirstOutputMs,
outputTokensPerSecond: performance.outputTokensPerSecond,
} : undefined,
};
}
export { hadToolProgressBeforeRequestTooLarge };

View File

@@ -0,0 +1,549 @@
import type { ModelMessage } from 'ai';
import type { OpenAIChatAssistantFields } from '../../providerContinuation';
import {
DEFAULT_CONTEXT_WINDOW_TOKENS,
DEFAULT_PROTECT_RECENT_MESSAGES,
estimateUnknownTokens,
findSafeChatMessageCompactionSplitIndex,
resolveContextWindow,
} from '../../contextCompaction';
import { buildSystemPrompt } from '../../cattyAgent/systemPrompt';
import {
isWebSearchReady,
normalizeCommandTimeoutSeconds,
normalizeResponseIdleTimeoutSeconds,
resolveOpenAIApi,
resolveProviderStyle,
} from '../../types';
import {
applyResponsesApiStatelessStoreOption,
buildCattyReasoningProviderOptions,
estimateReasoningOutputReserve,
} from '../../cattyReasoning';
import { createModelFromConfig } from '../../sdk/providers';
import { createCattyToolsFromCatalog } from '../capabilityTools';
import { createInitialCattyRuntimeContext } from '../cattyRuntimeContext';
import { prepareStepContext, extractLatestUserGoal } from '../contextManager';
import {
compactCattyMessages,
prepareCattyMessagesForStream,
} from '../cattyRuntime';
import { computeTotalInputTokens, DEFAULT_MAX_OUTPUT_TOKENS } from '../contextBudget';
import { clearChatSessionCancelled } from '../agentStop';
import { isRequestTooLargeError } from '../../errorClassifier';
import { getNetcattyBridge, generateId, resolveUserSkillsContext } from '../../aiChatStreamingSupport';
import {
buildCattySdkMessages,
collectOpenAIChatAssistantFieldsForMessages,
collectPreservedTerminalWriteFingerprints,
collectToolResultsAfterMessage,
createContinuationContext,
} from './cattyMessageBuilder';
import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './cattyStreamProcessor';
import type { CattyTurnInput, TurnDriver, TurnDriverContext } from './types';
import { fitLargeUserInputForModel } from '../largeUserInput';
import { buildPromptContextSnapshot } from '../promptContextSnapshot';
export class CattyTurnDriver implements TurnDriver {
readonly backend = 'catty' as const;
async run(input: import('./types').TurnInput, ctx: TurnDriverContext): Promise<void> {
if (input.backend !== 'catty') {
throw new Error('CattyTurnDriver received non-catty input');
}
await runCattyTurn(input, ctx);
}
abort(): void {
// Abort is handled via AbortSignal on the turn input.
}
}
async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Promise<void> {
const {
chatSessionId: sessionId,
userText: trimmed,
signal,
currentSession,
assistantMsgId,
context,
attachments,
maxIterations,
bridge,
ui,
} = input;
const netcattyBridge = (bridge ?? getNetcattyBridge()) as NonNullable<ReturnType<typeof getNetcattyBridge>>;
const toolOutputTempBridge = netcattyBridge as typeof netcattyBridge & {
getToolOutputPersistenceStatus?: () => Promise<{ durable: boolean; reason?: string }>;
writeToolOutputTemp?: (
record: import('../toolOutputStore').PersistedToolOutputRecord,
content: string,
) => Promise<{ ok: boolean; path?: string; error?: string }>;
restoreToolOutputTemp?: (
handleId: string,
chatSessionId: string,
) => Promise<{ path: string; record: import('../toolOutputStore').PersistedToolOutputRecord } | null>;
readToolOutputTemp?: (
path: string,
request: import('../toolOutputStore').ReadToolOutputInput,
) => Promise<Omit<import('../toolOutputStore').ToolOutputReadResult, 'handleId' | 'storedChars' | 'sourceTruncated'> | null>;
deleteToolOutputTemp?: (path: string) => Promise<{ ok: boolean }>;
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
deleteTerminalToolOutputsTemp?: (
chatSessionId: string,
terminalSessionId: string,
) => Promise<{ deletedCount: number }>;
};
const persistenceStatus = await toolOutputTempBridge.getToolOutputPersistenceStatus?.()
.catch(() => ({ durable: false }));
if (
toolOutputTempBridge.writeToolOutputTemp
&& toolOutputTempBridge.readToolOutputTemp
&& toolOutputTempBridge.deleteToolOutputTemp
) {
ctx.toolOutputStore.setPersistence?.({
write: async (record, content) => {
if (!persistenceStatus?.durable) {
throw new Error(persistenceStatus?.reason || 'Secure local storage is unavailable.');
}
const result = await toolOutputTempBridge.writeToolOutputTemp!(record, content);
if (!result.ok || !result.path) {
throw new Error(result.error || 'Unable to persist tool output.');
}
return result.path;
},
restore: persistenceStatus?.durable && toolOutputTempBridge.restoreToolOutputTemp
? (handleId, chatSessionId) => toolOutputTempBridge.restoreToolOutputTemp!(handleId, chatSessionId)
: undefined,
read: (path, request) => toolOutputTempBridge.readToolOutputTemp!(path, request),
delete: async path => {
await toolOutputTempBridge.deleteToolOutputTemp!(path);
},
deleteSession: toolOutputTempBridge.deleteChatToolOutputsTemp
? async chatSessionId => {
await toolOutputTempBridge.deleteChatToolOutputsTemp!(chatSessionId);
}
: undefined,
deleteTerminalSession: toolOutputTempBridge.deleteTerminalToolOutputsTemp
? async (chatSessionId, terminalSessionId) => {
await toolOutputTempBridge.deleteTerminalToolOutputsTemp!(chatSessionId, terminalSessionId);
}
: undefined,
deleteTerminalEverywhere: toolOutputTempBridge.deleteTerminalToolOutputsEverywhereTemp
? async terminalSessionId => {
await toolOutputTempBridge.deleteTerminalToolOutputsEverywhereTemp!(terminalSessionId);
}
: undefined,
});
} else {
ctx.toolOutputStore.setPersistence?.(undefined);
}
await clearChatSessionCancelled(sessionId, netcattyBridge);
if (netcattyBridge.aiMcpUpdateSessions) {
await netcattyBridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
if (attachments?.length && netcattyBridge.aiMcpUpdateAttachments) {
await netcattyBridge.aiMcpUpdateAttachments(attachments, sessionId);
}
const userSkillsContext = await resolveUserSkillsContext(
netcattyBridge,
trimmed,
context.selectedUserSkillSlugs,
);
const modelUserText = fitLargeUserInputForModel(trimmed, sessionId, ctx.toolOutputStore);
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
}));
const toolsBundle = createCattyToolsFromCatalog(
netcattyBridge,
getExecutorContext,
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
sessionId,
ctx.toolOutputStore,
ctx.toolResultDedup,
);
const { tools } = toolsBundle;
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
hosts: context.terminalSessions,
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
userSkillsContext,
});
if (!context.activeProvider) {
ui.reportStreamError(sessionId, signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
return;
}
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
const promptContext = buildPromptContextSnapshot({
providerId: context.activeProvider.providerId,
modelId: activeModelId,
permissionMode: context.permissionMode ?? context.globalPermissionMode,
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
toolNames: Object.keys(tools),
selectedSkillSlugs: context.selectedUserSkillSlugs,
systemPrompt,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
hostSessionIds: context.terminalSessions.map(session => session.sessionId),
});
ctx.emit({
id: `context-snapshot-${ctx.turnId}`,
type: 'context_snapshot',
snapshot: promptContext,
} as import('../types').AgentEvent);
const continuationContext = createContinuationContext(
context.activeProvider.id,
context.activeProvider.providerId,
activeModelId,
resolveProviderStyle(context.activeProvider) === 'openai'
&& resolveOpenAIApi(context.activeProvider) === 'responses',
);
ui.setStreamingForScope(sessionId, true);
try {
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const buildSdkMessages = (
allMessages: import('../../types').ChatMessage[],
includeCurrentUserMessage: boolean,
options: { preserveTerminalToolResults?: ReadonlySet<import('../../types').ToolResult> } = {},
sessionContextCompaction = currentSession?.contextCompaction,
) => buildCattySdkMessages({
allMessages,
contextCompaction: sessionContextCompaction,
includeCurrentUserMessage,
trimmed: modelUserText,
attachments: includeCurrentUserMessage ? attachments : undefined,
continuationContext,
preserveTerminalToolResults: options.preserveTerminalToolResults,
chatSessionId: sessionId,
toolOutputStore: ctx.toolOutputStore,
fieldsByMessage: openAIChatAssistantFieldsByMessage,
});
const responseIdleTimeoutSeconds = normalizeResponseIdleTimeoutSeconds(
context.responseIdleTimeout ?? Number.NaN,
);
const responseIdleTimeoutMs = responseIdleTimeoutSeconds * 1000;
let model;
try {
model = createModelFromConfig(
{
...context.activeProvider,
defaultModel: activeModelId,
},
{
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
streamIdleTimeoutMs: responseIdleTimeoutMs,
},
);
} catch (e) {
console.error('[Catty] Model creation failed:', e);
ui.reportStreamError(sessionId, signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
const contextWindow = resolveContextWindow({
provider: context.activeProvider,
modelId: activeModelId,
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
});
const maxOutputTokens = context.activeProvider.advancedParams?.maxTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
const reasoningProviderOptions = applyResponsesApiStatelessStoreOption(
context.activeProvider,
buildCattyReasoningProviderOptions(
context.activeProvider,
context.reasoningEffort,
activeModelId,
),
);
const preserveStatelessResponsesReasoning = reasoningProviderOptions?.openai?.store === false;
const reasoningReserveTokens = estimateReasoningOutputReserve(reasoningProviderOptions);
// Fold thinking budget into compaction maxOutput only. reservedTokens is
// added to estimated input separately, so adding the budget there too
// would count the same 10k/20k twice.
const compactionMaxOutputTokens = maxOutputTokens + reasoningReserveTokens;
const providerId = context.activeProvider.providerId;
const outputReserveTokens = Math.min(maxOutputTokens, Math.ceil(contextWindow * 0.05));
const getRequestReserveTokens = () => outputReserveTokens + estimateUnknownTokens({
systemPrompt,
toolNames: Object.keys(tools),
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
}, providerId);
const prepareMessagesForStream = (messages: ModelMessage[]): ModelMessage[] => {
const pruned = prepareCattyMessagesForStream(messages, {
preserveReasoning: preserveStatelessResponsesReasoning,
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
pruned,
openAIChatAssistantFieldsByMessage,
);
return pruned;
};
const compactMessages = async (
messages: ModelMessage[],
options: {
force?: boolean;
compressForRequestTooLargeRetry?: boolean;
protectRecentMessages?: number;
},
) => {
const pendingHandles = ctx.toolOutputStore.listPendingHandles(sessionId);
const sessionStateText = ctx.sessionStateStore.toReinjectionText(sessionId);
const result = await compactCattyMessages({
messages,
sessionId,
chatSessionId: sessionId,
provider: context.activeProvider,
modelId: activeModelId || context.activeProvider?.defaultModel,
reservedTokens: getRequestReserveTokens,
maxOutputTokens: compactionMaxOutputTokens,
model,
toolOutputStore: ctx.toolOutputStore,
abortSignal: signal,
trigger: options.force ? 'force' : options.compressForRequestTooLargeRetry ? '413-retry' : 'pre-turn',
force: options.force,
compressForRequestTooLargeRetry: options.compressForRequestTooLargeRetry,
protectRecentMessages: options.protectRecentMessages,
onCompactionStart: (trigger) => {
ctx.emit({
id: `compaction-start-${Date.now()}`,
type: 'compaction_start',
sessionId,
chatSessionId: sessionId,
backend: 'catty',
timestamp: Date.now(),
trigger,
} as import('../types').AgentEvent);
},
onCompaction: (trace) => {
ctx.emit({
id: `compaction-${Date.now()}`,
type: 'compaction',
trace,
} as import('../types').AgentEvent);
if (options.compressForRequestTooLargeRetry && trace.did413Fallback) {
console.warn('[Catty] Request content compressed after forced context compaction.');
}
},
reinjection: {
permissionMode: context.permissionMode ?? context.globalPermissionMode,
sessionStateText,
sessionScopeSummary: pendingHandles.length
? `Pending tool output handles: ${pendingHandles.map(h => h.id).join(', ')}`
: undefined,
},
});
return result;
};
const emitContextSnapshot = (messages: ModelMessage[]) => {
ctx.emit({
id: `context-snapshot-${Date.now()}-${ctx.turnId}`,
type: 'context_snapshot',
snapshot: {
...promptContext,
contextWindow,
estimatedInputTokens: computeTotalInputTokens({
messages,
providerId,
systemPrompt,
toolNames: Object.keys(tools),
}),
},
} as import('../types').AgentEvent);
};
if (context.forceCompaction) {
// Persist a tool-safe UI-message boundary and summarize only that head
// slice so the durable compact coordinate system matches buildCattySdkMessages.
const uiMessages = currentSession?.messages ?? [];
const compactedMessageCount = findSafeChatMessageCompactionSplitIndex(
uiMessages,
DEFAULT_PROTECT_RECENT_MESSAGES,
);
if (compactedMessageCount <= 0) {
emitContextSnapshot(prepareMessagesForStream(buildSdkMessages(uiMessages, false)));
return;
}
const headSdkMessages = buildSdkMessages(
uiMessages.slice(0, compactedMessageCount),
false,
{},
undefined,
);
const compacted = await compactMessages(headSdkMessages, {
force: true,
// Summarize the entire head; the protected tail lives in UI messages.
protectRecentMessages: 0,
});
if (compacted.summary) {
const nextCompaction = {
summary: compacted.summary,
compactedMessageCount,
};
ui.persistContextCompaction?.(sessionId, nextCompaction);
emitContextSnapshot(prepareMessagesForStream(
buildSdkMessages(uiMessages, false, {}, nextCompaction),
));
} else {
// Non-durable outcome: keep the meter honest (full current context).
emitContextSnapshot(prepareMessagesForStream(buildSdkMessages(uiMessages, false)));
}
return;
}
let messagesForStream = buildSdkMessages(currentSession?.messages ?? [], true);
messagesForStream = (await compactMessages(messagesForStream, {})).messages;
messagesForStream = prepareMessagesForStream(messagesForStream);
emitContextSnapshot(messagesForStream);
const runtimeContext = createInitialCattyRuntimeContext({
chatSessionId: sessionId,
turnId: ctx.turnId,
providerId: context.activeProvider?.providerId,
modelId: activeModelId,
permissionMode: context.permissionMode ?? context.globalPermissionMode,
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
userGoal: extractLatestUserGoal(messagesForStream),
promptContext,
});
const commandTimeoutSeconds =
Number.isFinite(context.commandTimeout) && context.commandTimeout > 0
? normalizeCommandTimeoutSeconds(context.commandTimeout)
: undefined;
const commandTimeoutMs =
commandTimeoutSeconds != null
? commandTimeoutSeconds * 1000
: undefined;
const runStream = async (streamMessages: ModelMessage[], streamAssistantMsgId: string) => {
await processCattyStream({
streamSessionId: sessionId,
model,
systemPrompt,
toolsBundle,
sdkMessages: streamMessages,
signal,
currentAssistantMsgId: streamAssistantMsgId,
maxIterations,
advancedParams: context.activeProvider?.advancedParams,
reasoningProviderOptions,
continuationContext,
turnId: ctx.turnId,
commandTimeoutMs,
responseIdleTimeoutMs,
runtimeContext,
onAgentEvent: (event) => ctx.emit(event),
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
const prepared = await prepareStepContext({
messages,
stepNumber,
sessionId,
chatSessionId: sessionId,
providerId: context.activeProvider?.providerId,
modelId: activeModelId,
contextWindow,
reservedTokens: getRequestReserveTokens(),
maxOutputTokens: compactionMaxOutputTokens,
toolOutputStore: ctx.toolOutputStore,
runtimeContext: stepRuntimeContext,
onEvent: (event) => ctx.emit(event),
});
emitContextSnapshot(prepared.messages);
return {
messages: prepared.messages,
runtimeContext: prepared.runtimeContext,
};
},
ui: {
addMessageToSession: ui.addMessageToSession,
updateMessageById: ui.updateMessageById,
},
});
};
try {
await runStream(messagesForStream, assistantMsgId);
} catch (streamErr) {
if (signal.aborted || !isRequestTooLargeError(streamErr)) {
throw streamErr;
}
console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
preserveTerminalToolResults: collectToolResultsAfterMessage(
latestSession.messages,
assistantMsgId,
),
}, latestSession.contextCompaction);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
} else {
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
}
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
force: true,
compressForRequestTooLargeRetry: true,
})).messages);
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
}
} catch (err) {
console.error('[Catty] streamText error:', err);
ui.reportStreamError(sessionId, signal, err);
} finally {
ui.updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
ui.setStreamingForScope(sessionId, false);
context.autoTitleSession(sessionId, context.titleText ?? trimmed);
}
}
export const cattyTurnDriver = new CattyTurnDriver();

View File

@@ -0,0 +1,25 @@
import type { AgentActivity, AgentUsage } from '../../types';
export function upsertAgentActivity(
activities: AgentActivity[] | undefined,
nextActivity: AgentActivity,
): AgentActivity[] {
const current = activities ?? [];
const existingIndex = current.findIndex((activity) => activity.id === nextActivity.id);
if (existingIndex < 0) return [...current, nextActivity];
return current.map((activity, index) => index === existingIndex ? nextActivity : activity);
}
export function resolveEstimatedUsageFallback(
prompt: string,
actualUsageReported: boolean,
): AgentUsage | null {
if (actualUsageReported) return null;
const estimatedTokens = Math.ceil(prompt.length / 4);
return {
inputTokens: estimatedTokens,
outputTokens: 0,
totalTokens: estimatedTokens,
estimated: true,
};
}

View File

@@ -0,0 +1,491 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AISession, AgentActivity, ChatMessage, ExternalAgentConfig } from '../../types';
import { SessionStateStore } from '../sessionState';
import { ToolOutputStore } from '../toolOutputStore';
import { ToolResultDedup } from '../toolResultDedup';
import { externalSdkTurnDriver } from './externalSdkTurnDriver';
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
import type { TurnUiCallbacks } from './types';
async function waitFor(predicate: () => boolean): Promise<void> {
for (let index = 0; index < 50; index += 1) {
if (predicate()) return;
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error('condition not reached');
}
test('upsertAgentActivity replaces streaming updates by stable item id', () => {
const running: AgentActivity = {
id: 'plan-1',
type: 'plan_update',
status: 'running',
items: [{ text: 'Map events', completed: false }],
};
const completed: AgentActivity = {
...running,
status: 'completed',
items: [{ text: 'Map events', completed: true }],
};
const first = upsertAgentActivity(undefined, running);
const second = upsertAgentActivity(first, completed);
assert.equal(second.length, 1);
assert.deepEqual(second[0], completed);
});
test('upsertAgentActivity preserves the order of unrelated activities', () => {
const search: AgentActivity = {
id: 'search-1',
type: 'web_search',
status: 'completed',
query: 'Codex events',
};
const warning: AgentActivity = {
id: 'warning-1',
type: 'warning',
status: 'completed',
message: 'Search result unavailable',
};
assert.deepEqual(upsertAgentActivity([search], warning), [search, warning]);
});
test('estimated usage is used only when the SDK did not report actual usage', () => {
assert.deepEqual(resolveEstimatedUsageFallback('12345678', false), {
inputTokens: 2,
outputTokens: 0,
totalTokens: 2,
estimated: true,
});
assert.equal(resolveEstimatedUsageFallback('12345678', true), null);
});
test('plan updates replace the same activity across tool message boundaries', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => {
queueMicrotask(() => {
onEvent?.({
type: 'plan-update',
itemId: 'plan-1',
status: 'running',
items: [{ text: 'Run command', completed: false }],
});
onEvent?.({
type: 'tool-call',
toolName: 'shell',
toolCallId: 'tool-1',
args: { command: 'true' },
});
onEvent?.({
type: 'tool-result',
toolName: 'shell',
toolCallId: 'tool-1',
output: 'ok',
});
onEvent?.({
type: 'plan-update',
itemId: 'plan-1',
status: 'completed',
items: [{ text: 'Run command', completed: true }],
});
onDone?.();
});
return { ok: true };
},
};
const session: AISession = {
id: 'chat-1',
title: 'Test',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-1', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => {
session.messages.push(message);
},
updateLastMessage: (_sessionId, updater) => {
const lastIndex = session.messages.length - 1;
session.messages[lastIndex] = updater(session.messages[lastIndex]);
},
updateMessageById: (_sessionId, messageId, updater) => {
const messageIndex = session.messages.findIndex((message) => message.id === messageId);
if (messageIndex >= 0) {
session.messages[messageIndex] = updater(session.messages[messageIndex]);
}
},
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
};
const agentConfig: ExternalAgentConfig = {
id: 'codex',
name: 'Codex',
command: 'codex',
enabled: true,
sdkBackend: 'codex',
};
const controller = new AbortController();
await externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-1',
userText: 'Run the plan',
signal: controller.signal,
agentConfig,
attachedImages: [],
context: {
terminalSessions: [],
providers: [],
toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [],
permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-1',
chatSessionId: session.id,
sessionId: session.id,
backend: 'external-sdk',
signal: controller.signal,
emit: () => {},
toolOutputStore: new ToolOutputStore(),
toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
const planActivities = session.messages
.flatMap((message: ChatMessage) => message.agentActivities ?? [])
.filter((activity) => activity.id === 'plan-1');
assert.equal(planActivities.length, 1);
assert.equal(planActivities[0].status, 'completed');
assert.deepEqual(planActivities[0].type === 'plan_update' ? planActivities[0].items : [], [
{ text: 'Run command', completed: true },
]);
});
test('accepted steering persists a user bubble and routes buffered output to a continuation', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
let releaseSteer: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => ({ ok: true }),
aiSdkAgentSteer: async () => {
onEvent?.({ type: 'text-delta', textDelta: 'after steer' });
await new Promise<void>(resolve => { releaseSteer = resolve; });
return { status: 'accepted' };
},
};
const session: AISession = {
id: 'chat-steer',
title: 'Steer',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => session.messages.push(message),
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
updateMessageById: (_sessionId, messageId, updater) => {
const index = session.messages.findIndex(message => message.id === messageId);
if (index >= 0) session.messages[index] = updater(session.messages[index]);
},
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
};
const controller = new AbortController();
const run = externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-initial',
userText: 'initial prompt',
signal: controller.signal,
agentConfig: {
id: 'codex',
name: 'Codex',
command: 'codex',
enabled: true,
sdkBackend: 'codex',
codexRuntime: 'app-server',
},
attachedImages: [],
context: {
terminalSessions: [],
providers: [],
toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [],
permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-steer',
chatSessionId: session.id,
sessionId: session.id,
backend: 'external-sdk',
signal: controller.signal,
emit: () => {},
toolOutputStore: new ToolOutputStore(),
toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
await waitFor(() => Boolean(onEvent));
onEvent?.({ type: 'text-delta', textDelta: 'before steer' });
const steer = externalSdkTurnDriver.steer({
chatSessionId: session.id,
userMessageId: 'user-steer',
userText: 'change direction',
prompt: 'change direction',
attachments: [],
attachedImages: [],
});
await waitFor(() => Boolean(releaseSteer));
assert.equal(session.messages.length, 1);
releaseSteer?.();
const result = await steer;
assert.equal(result.status, 'accepted');
assert.deepEqual(session.messages.map(message => message.role), ['assistant', 'user', 'assistant']);
assert.equal(session.messages[0].content, 'before steer');
assert.equal(session.messages[1].content, 'change direction');
assert.equal(session.messages[2].content, 'after steer');
onDone?.();
await run;
});
test('accepted steering keeps buffered results for existing tool calls on their original assistant message', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
let releaseSteer: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => ({ ok: true }),
aiSdkAgentSteer: async () => {
onEvent?.({
type: 'tool-result',
toolName: 'shell',
toolCallId: 'tool-before-steer',
output: 'completed before steer acceptance',
});
await new Promise<void>(resolve => { releaseSteer = resolve; });
return { status: 'accepted' };
},
};
const session: AISession = {
id: 'chat-steer-tool-result',
title: 'Steer tool result',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => session.messages.push(message),
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
updateMessageById: (_sessionId, messageId, updater) => {
const index = session.messages.findIndex(message => message.id === messageId);
if (index >= 0) session.messages[index] = updater(session.messages[index]);
},
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
};
const controller = new AbortController();
const run = externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-initial',
userText: 'initial prompt',
signal: controller.signal,
agentConfig: {
id: 'codex', name: 'Codex', command: 'codex', enabled: true,
sdkBackend: 'codex', codexRuntime: 'app-server',
},
attachedImages: [],
context: {
terminalSessions: [], providers: [], toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [], permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-steer-tool-result', chatSessionId: session.id, sessionId: session.id,
backend: 'external-sdk', signal: controller.signal, emit: () => {},
toolOutputStore: new ToolOutputStore(), toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
await waitFor(() => Boolean(onEvent));
onEvent?.({
type: 'tool-call',
toolName: 'shell',
toolCallId: 'tool-before-steer',
args: { command: 'sleep 1' },
});
assert.equal(session.messages[0].executionStatus, 'running');
const steer = externalSdkTurnDriver.steer({
chatSessionId: session.id,
userMessageId: 'user-steer-tool-result',
userText: 'change direction',
prompt: 'change direction',
attachments: [],
attachedImages: [],
});
await waitFor(() => Boolean(releaseSteer));
assert.equal(session.messages.length, 1);
assert.equal(session.messages[0].executionStatus, 'running');
releaseSteer?.();
const result = await steer;
assert.equal(result.status, 'accepted');
assert.deepEqual(
session.messages.map(message => message.role),
['assistant', 'tool', 'user', 'assistant'],
);
assert.equal(session.messages[0].executionStatus, 'completed');
assert.equal(session.messages[0].toolCalls?.[0]?.id, 'tool-before-steer');
assert.equal(session.messages[1].toolResults?.[0]?.toolCallId, 'tool-before-steer');
assert.equal(session.messages[2].content, 'change direction');
assert.equal(session.messages[3].content, '');
onEvent?.({ type: 'text-delta', textDelta: 'continued after steer' });
// Text deltas are rAF-batched like Catty; wait one frame before asserting.
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve());
return;
}
setTimeout(resolve, 0);
});
assert.equal(session.messages[3].content, 'continued after steer');
onDone?.();
await run;
});
test('failed steering keeps buffered output on the original assistant message', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => ({ ok: true }),
aiSdkAgentSteer: async () => {
onEvent?.({ type: 'text-delta', textDelta: 'kept output' });
return { status: 'not-steerable', turnKind: 'compact' };
},
};
const session: AISession = {
id: 'chat-steer-failed',
title: 'Steer failed',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => session.messages.push(message),
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
updateMessageById: (_sessionId, messageId, updater) => {
const index = session.messages.findIndex(message => message.id === messageId);
if (index >= 0) session.messages[index] = updater(session.messages[index]);
},
reportStreamError: () => {},
setStreamingForScope: () => {},
};
const controller = new AbortController();
const run = externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-initial',
userText: 'initial prompt',
signal: controller.signal,
agentConfig: {
id: 'codex', name: 'Codex', command: 'codex', enabled: true,
sdkBackend: 'codex', codexRuntime: 'app-server',
},
attachedImages: [],
context: {
terminalSessions: [], providers: [], toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [], permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-steer-failed', chatSessionId: session.id, sessionId: session.id,
backend: 'external-sdk', signal: controller.signal, emit: () => {},
toolOutputStore: new ToolOutputStore(), toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
await waitFor(() => Boolean(onEvent));
const result = await externalSdkTurnDriver.steer({
chatSessionId: session.id,
userMessageId: 'user-not-added',
userText: 'cannot apply',
prompt: 'cannot apply',
attachments: [],
attachedImages: [],
});
assert.deepEqual(result, { status: 'not-steerable', turnKind: 'compact' });
assert.equal(session.messages.length, 1);
assert.equal(session.messages[0].content, 'kept output');
onDone?.();
await run;
});

View File

@@ -0,0 +1,495 @@
import { getExternalAgentSdkBackend } from '../../managedAgents';
import {
runSdkAgentTurn,
steerSdkAgentTurn,
type SdkAgentCallbacks,
} from '../../sdkAgentAdapter';
import {
getNetcattyBridge,
generateId,
resolveUserSkillsContext,
isToolResultError,
} from '../../aiChatStreamingSupport';
import type { AgentActivity, AgentUsage, ChatMessage } from '../../types';
import type {
ExternalTurnInput,
TurnDriver,
TurnDriverContext,
TurnSteerInput,
TurnSteerResult,
} from './types';
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
import {
clearCodebuddyElicitationsForChat,
completeCodebuddyElicitation,
registerCodebuddyElicitation,
} from '../../shared/codebuddyElicitations';
interface LiveExternalTurn {
requestId: string;
sessionId: string;
signal: AbortSignal;
agentConfig: ExternalTurnInput['agentConfig'];
steer(input: TurnSteerInput): Promise<TurnSteerResult>;
ended: boolean;
}
export class ExternalSdkTurnDriver implements TurnDriver {
readonly backend = 'external-sdk' as const;
private readonly liveTurns = new Map<string, LiveExternalTurn>();
async run(input: import('./types').TurnInput, ctx: TurnDriverContext): Promise<void> {
if (input.backend !== 'external-sdk') {
throw new Error('ExternalSdkTurnDriver received non-external input');
}
try {
await runExternalTurn(input, ctx, (liveTurn) => {
this.liveTurns.set(input.chatSessionId, liveTurn);
});
} finally {
this.liveTurns.delete(input.chatSessionId);
}
}
async steer(input: TurnSteerInput): Promise<TurnSteerResult> {
const liveTurn = this.liveTurns.get(input.chatSessionId);
if (!liveTurn || liveTurn.ended) return { status: 'inactive' };
if (
getExternalAgentSdkBackend(liveTurn.agentConfig) !== 'codex'
|| liveTurn.agentConfig.codexRuntime !== 'app-server'
) {
return { status: 'unsupported' };
}
return liveTurn.steer(input);
}
abort(): void {
// Abort is handled via AbortSignal on the turn input.
}
}
async function runExternalTurn(
input: ExternalTurnInput,
ctx: TurnDriverContext,
registerLiveTurn: (liveTurn: LiveExternalTurn) => void,
): Promise<void> {
const {
chatSessionId: sessionId,
assistantMsgId,
userText: trimmed,
signal,
agentConfig,
attachedImages,
context,
bridge,
ui,
} = input;
const netcattyBridge = bridge ?? getNetcattyBridge();
const sdkBackend = getExternalAgentSdkBackend(agentConfig);
if (!sdkBackend || !netcattyBridge) {
ui.reportStreamError(
sessionId,
signal,
'This agent has no SDK backend configured. Re-discover it in Settings -> AI.',
);
ui.setStreamingForScope(sessionId, false);
return;
}
const userSkillsContext = await resolveUserSkillsContext(
netcattyBridge,
trimmed,
context.selectedUserSkillSlugs,
);
const requestId = ctx.turnId;
ui.setStreamingForScope(sessionId, true);
if (netcattyBridge.aiMcpUpdateSessions) {
await netcattyBridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
let needsNewAssistantMsg = false;
let activeAssistantMessageId = assistantMsgId;
let steerInFlight = false;
let ended = false;
interface BufferedUiOperation {
operation: () => void;
flushBeforeSteerBoundary: boolean;
}
const bufferedUiOperations: BufferedUiOperation[] = [];
const runOrBufferUiOperation = (
operation: () => void,
options: { flushBeforeSteerBoundary?: boolean } = {},
) => {
if (steerInFlight) {
bufferedUiOperations.push({
operation,
flushBeforeSteerBoundary: options.flushBeforeSteerBoundary === true,
});
return;
}
operation();
};
const flushBufferedUiOperations = (
shouldFlush: (entry: BufferedUiOperation) => boolean = () => true,
) => {
const operations = bufferedUiOperations.splice(0);
operations.forEach((entry) => {
if (shouldFlush(entry)) {
entry.operation();
} else {
bufferedUiOperations.push(entry);
}
});
};
const maybeCreateAssistantMsg = () => {
if (!needsNewAssistantMsg) return;
needsNewAssistantMsg = false;
activeAssistantMessageId = generateId();
ui.addMessageToSession(sessionId, {
id: activeAssistantMessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: agentConfig.name || 'external',
});
};
const updateActiveAssistant = (updater: (message: ChatMessage) => ChatMessage) => {
maybeCreateAssistantMsg();
ui.updateMessageById(sessionId, activeAssistantMessageId, updater);
};
let pendingText = '';
let rafId: number | null = null;
const appendTextToActiveAssistant = (textChunk: string) => {
updateActiveAssistant(msg => ({
...msg,
content: msg.content + textChunk,
statusText: undefined,
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
}));
};
const flushPendingText = () => {
if (pendingText) {
const textChunk = pendingText;
pendingText = '';
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
}
rafId = null;
};
const scheduleFrame = (cb: () => void): number => (
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(cb)
: setTimeout(cb, 0) as unknown as number
);
const cancelFrame = (id: number): void => {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(id);
return;
}
clearTimeout(id);
};
const cancelPendingTextFlush = () => {
if (rafId !== null) {
cancelFrame(rafId);
rafId = null;
}
};
const enqueueTextDelta = (textChunk: string) => {
if (!textChunk) return;
// While steering buffers UI ops, skip rAF so text enters the buffer immediately.
if (steerInFlight) {
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
return;
}
pendingText += textChunk;
if (rafId === null) {
rafId = scheduleFrame(flushPendingText);
}
};
const flushTextBeforeNonTextEvent = () => {
cancelPendingTextFlush();
flushPendingText();
};
const toolNamesByCallId = new Map<string, string>();
const toolCallMessageIds = new Map<string, string>();
const activityMessageIds = new Map<string, string>();
let actualUsageReported = false;
const updateActivity = (activity: AgentActivity) => {
const activityMessageId = activityMessageIds.get(activity.id);
if (activityMessageId) {
ui.updateMessageById(sessionId, activityMessageId, msg => ({
...msg,
agentActivities: upsertAgentActivity(msg.agentActivities, activity),
statusText: undefined,
}));
return;
}
maybeCreateAssistantMsg();
activityMessageIds.set(activity.id, activeAssistantMessageId);
ui.updateMessageById(sessionId, activeAssistantMessageId, msg => ({
...msg,
agentActivities: upsertAgentActivity(msg.agentActivities, activity),
statusText: undefined,
}));
};
const updateUsage = (usage: AgentUsage) => {
updateActiveAssistant(msg => ({ ...msg, usage }));
};
const callbacks: SdkAgentCallbacks = {
onTextDelta: (text: string) => {
enqueueTextDelta(text);
},
onThinkingDelta: (text: string) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
thinking: (msg.thinking || '') + text,
}));
});
},
onThinkingDone: () => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
});
},
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
const id = toolCallId || `tc_${Date.now()}`;
maybeCreateAssistantMsg();
toolNamesByCallId.set(id, toolName);
toolCallMessageIds.set(id, activeAssistantMessageId);
ui.updateMessageById(sessionId, activeAssistantMessageId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), { id, name: toolName, arguments: args }],
executionStatus: 'running',
statusText: undefined,
}));
});
},
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
flushTextBeforeNonTextEvent();
const existingToolCallMessageId = toolCallMessageIds.get(toolCallId);
runOrBufferUiOperation(() => {
const effectiveToolName = toolName ?? toolNamesByCallId.get(toolCallId);
const toolCallMessageId = existingToolCallMessageId
?? toolCallMessageIds.get(toolCallId);
const updateToolCallOwner = (msg: ChatMessage) => {
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
const updatedToolCalls = effectiveToolName && !effectiveToolName.includes('sdk_agent_dynamic_tool') && msg.toolCalls
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: effectiveToolName } : tc)
: msg.toolCalls;
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
};
if (toolCallMessageId) {
ui.updateMessageById(sessionId, toolCallMessageId, updateToolCallOwner);
} else {
updateActiveAssistant(updateToolCallOwner);
}
ui.addMessageToSession(sessionId, {
id: generateId(),
role: 'tool',
content: '',
toolResults: [{
toolCallId,
toolName: effectiveToolName,
content: result,
isError: isToolResultError(result),
}],
timestamp: Date.now(),
executionStatus: 'completed',
});
needsNewAssistantMsg = true;
}, {
// A result for a tool call already rendered before steering belongs to
// that original assistant segment. Commit it before adding the steer
// user/continuation boundary so tool-call history stays contiguous.
flushBeforeSteerBoundary: existingToolCallMessageId !== undefined,
});
},
onFileChange: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onWebSearch: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onPlanUpdate: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onWarning: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onUsage: (usage: AgentUsage) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
actualUsageReported = true;
updateUsage(usage);
});
},
onStatus: (message: string) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
});
},
onHook: (hookEvent: string, payload: Record<string, unknown>) => {
// Surface lifecycle hooks as status text so the user sees tool activity.
const toolName = (payload.toolName as string) || '';
if (hookEvent === 'PreToolUse' && toolName) {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: `Running ${toolName}` }));
});
} else if (hookEvent === 'Notification') {
const message = (payload.message as string) || '';
if (message) {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
});
}
}
},
onElicitationCreate: (elicitationId: string, request: Record<string, unknown>) => {
registerCodebuddyElicitation({
elicitationId,
chatSessionId: sessionId,
request,
});
},
onElicitationComplete: (notification) => {
completeCodebuddyElicitation(notification);
},
onSessionId: (externalSessionId: string) => {
context.updateExternalSessionId?.(sessionId, externalSessionId);
},
onError: (error: string) => {
flushTextBeforeNonTextEvent();
ui.reportStreamError(sessionId, signal, error);
ui.setStreamingForScope(sessionId, false);
},
onDone: () => {
flushTextBeforeNonTextEvent();
},
};
const liveTurn: LiveExternalTurn = {
requestId,
sessionId,
signal,
agentConfig,
ended: false,
async steer(steerInput) {
if (steerInFlight) return { status: 'busy' };
if (ended || signal.aborted) return { status: 'cancelled' };
// Commit any rAF-batched text before steering buffers UI ops.
flushTextBeforeNonTextEvent();
steerInFlight = true;
const result = await steerSdkAgentTurn(
netcattyBridge,
requestId,
sessionId,
steerInput.prompt,
steerInput.attachedImages.length > 0 ? steerInput.attachedImages : undefined,
steerInput.userMessageId,
);
if (result.status === 'accepted' && !ended && !signal.aborted) {
flushBufferedUiOperations(entry => entry.flushBeforeSteerBoundary);
ui.addMessageToSession(sessionId, {
id: steerInput.userMessageId,
role: 'user',
content: steerInput.userText,
...(steerInput.attachments?.length ? { attachments: steerInput.attachments } : {}),
timestamp: Date.now(),
});
const continuationMessageId = generateId();
ui.addMessageToSession(sessionId, {
id: continuationMessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: agentConfig.name || 'external',
});
activeAssistantMessageId = continuationMessageId;
needsNewAssistantMsg = false;
steerInFlight = false;
flushBufferedUiOperations();
return { status: 'accepted', assistantMessageId: continuationMessageId };
}
steerInFlight = false;
flushBufferedUiOperations();
if (result.status === 'accepted') return { status: 'cancelled' };
return result;
},
};
registerLiveTurn(liveTurn);
try {
await runSdkAgentTurn(
netcattyBridge,
requestId,
sessionId,
agentConfig,
trimmed,
callbacks,
signal,
undefined,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
context.toolIntegrationMode,
context.defaultTargetSession,
userSkillsContext,
context.permissionMode,
{
traceSink: (event) => ctx.emit(event),
skipHarnessTrace: true,
},
);
const estimatedUsage = resolveEstimatedUsageFallback(trimmed, actualUsageReported);
if (estimatedUsage) {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateUsage(estimatedUsage));
ctx.emit({
id: `usage-${ctx.turnId}`,
type: 'usage',
promptTokens: estimatedUsage.inputTokens,
completionTokens: estimatedUsage.outputTokens,
totalTokens: estimatedUsage.totalTokens,
estimated: true,
} as import('../types').AgentEvent);
}
} finally {
ended = true;
liveTurn.ended = true;
flushTextBeforeNonTextEvent();
clearCodebuddyElicitationsForChat(sessionId);
if (steerInFlight) {
steerInFlight = false;
flushBufferedUiOperations();
}
ui.setStreamingForScope(sessionId, false);
}
}
export const externalSdkTurnDriver = new ExternalSdkTurnDriver();

View File

@@ -0,0 +1,187 @@
import type { ModelMessage } from 'ai';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../types';
import type { ExecutorContext } from '../../cattyAgent/executor';
import type { AgentBackend, AgentEvent, AgentEventListener } from '../types';
import type { ToolOutputStore } from '../toolOutputStore';
import type { ToolResultDedup } from '../toolResultDedup';
import type { SessionStateStore } from '../sessionState';
import type { DefaultTargetSessionHint } from '../../sdkAgentAdapter';
import type { AgentStopBridge } from '../agentStop';
export interface TerminalSessionInfo {
sessionId: string;
hostId?: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{
ruleId: string;
label?: string;
type?: string;
localPort?: number;
status?: string;
}>;
}
export interface TurnUiCallbacks {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
setStreamingForScope: (key: string, val: boolean) => void;
getLatestSession?: (sessionId: string) => AISession | undefined;
persistContextCompaction?: (
sessionId: string,
compaction: import('../../types').AISessionContextCompaction,
) => void;
}
export interface CattyTurnContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
reasoningEffort?: string;
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
permissionMode?: AIPermissionMode;
commandBlocklist?: string[];
commandTimeout?: number;
responseIdleTimeout?: number;
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
titleText?: string;
selectedUserSkillSlugs?: string[];
forceCompaction?: boolean;
}
export interface ExternalTurnContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
defaultTargetSession?: DefaultTargetSessionHint;
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
permissionMode: AIPermissionMode;
}
export interface CattyTurnInput {
backend: 'catty';
chatSessionId: string;
sendScopeKey: string;
userText: string;
signal: AbortSignal;
currentSession: AISession | undefined;
assistantMsgId: string;
context: CattyTurnContext;
attachments?: ChatMessageAttachment[];
maxIterations: number;
bridge?: AgentStopBridge | null;
ui: TurnUiCallbacks;
}
export interface ExternalTurnInput {
backend: 'external-sdk';
chatSessionId: string;
assistantMsgId: string;
userText: string;
signal: AbortSignal;
agentConfig: ExternalAgentConfig;
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>;
context: ExternalTurnContext;
bridge?: Record<string, (...args: unknown[]) => unknown> | null;
ui: TurnUiCallbacks;
}
export type TurnInput = CattyTurnInput | ExternalTurnInput;
export interface TurnResult {
turnId: string;
reason: 'completed' | 'aborted' | 'error';
}
export type TurnSteerFailureReason =
| 'not-steerable'
| 'busy'
| 'inactive'
| 'unsupported'
| 'cancelled'
| 'failed';
export type TurnSteerResult =
| { status: 'accepted'; assistantMessageId: string }
| {
status: TurnSteerFailureReason;
message?: string;
turnKind?: 'review' | 'compact';
};
export interface TurnSteerInput {
chatSessionId: string;
userMessageId: string;
userText: string;
prompt: string;
attachments?: ChatMessageAttachment[];
attachedImages: Array<{
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string;
}>;
}
export interface TurnDriverContext {
turnId: string;
chatSessionId: string;
sessionId: string;
backend: AgentBackend;
signal: AbortSignal;
emit: (event: Omit<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'> & Partial<Pick<AgentEvent, 'turnId' | 'sessionId' | 'chatSessionId' | 'backend' | 'timestamp'>>) => void;
toolOutputStore: ToolOutputStore;
toolResultDedup: ToolResultDedup;
sessionStateStore: SessionStateStore;
onEvent?: AgentEventListener;
}
export interface TurnDriver {
readonly backend: AgentBackend;
run(input: TurnInput, ctx: TurnDriverContext): Promise<void>;
steer?(input: TurnSteerInput): Promise<TurnSteerResult>;
abort?(chatSessionId: string): void;
}
export interface PrepareStepContextInput {
messages: ModelMessage[];
stepNumber: number;
sessionId: string;
chatSessionId?: string;
providerId?: string | null;
modelId?: string | null;
contextWindow?: number;
reservedTokens?: number;
maxOutputTokens?: number;
protectRecentMessages?: number;
toolOutputStore?: ToolOutputStore;
runtimeContext: import('../cattyRuntimeContext').CattyRuntimeContext;
onEvent?: AgentEventListener;
}

View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { TwoPassCompactionCache, fingerprintMessages } from './twoPassCompaction';
test('fingerprintMessages includes complete tool arguments canonically', () => {
const a: ModelMessage[] = [{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: '1', toolName: 'terminal_execute', input: { b: 2, a: 1 } }],
}];
const reordered: ModelMessage[] = [{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: '1', toolName: 'terminal_execute', input: { a: 1, b: 2 } }],
}];
const changed: ModelMessage[] = [{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: '1', toolName: 'terminal_execute', input: { a: 9, b: 2 } }],
}];
assert.equal(fingerprintMessages(a), fingerprintMessages(reordered));
assert.notEqual(fingerprintMessages(a), fingerprintMessages(changed));
});
test('TwoPassCompactionCache reuses only an unchanged model and prefix', async () => {
const cache = new TwoPassCompactionCache();
const messages = Array.from({ length: 20 }, (_, index) => ({
role: index % 2 ? 'assistant' : 'user',
content: `message ${index}`,
})) as ModelMessage[];
cache.start('chat-1', 'model-a', messages, async () => 'NOTE1');
const hit = await cache.consume('chat-1', 'model-a', messages);
assert.equal(hit?.note, 'NOTE1');
assert.ok((hit?.prefixLength ?? 0) < messages.length);
assert.equal(await cache.consume('chat-1', 'model-b', messages), undefined);
});

View File

@@ -0,0 +1,95 @@
import type { ModelMessage } from 'ai';
import { findSafeCompactionSplitIndex } from '../contextCompaction';
interface CacheEntry {
modelId: string;
prefixLength: number;
fingerprint: string;
notePromise: Promise<string>;
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, canonicalize(entry)]),
);
}
export function fingerprintMessages(messages: ModelMessage[]): string {
const value = JSON.stringify(canonicalize(messages));
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
export class TwoPassCompactionCache {
private readonly entries = new Map<string, CacheEntry>();
start(
chatSessionId: string,
modelId: string,
messages: ModelMessage[],
producer: (prefix: ModelMessage[]) => Promise<string>,
): boolean {
const current = this.entries.get(chatSessionId);
if (
current
&& current.modelId === modelId
&& messages.length >= current.prefixLength
&& fingerprintMessages(messages.slice(0, current.prefixLength)) === current.fingerprint
) {
return false;
}
const protectedTail = Math.max(10, Math.ceil(messages.length * 0.05));
const prefixLength = findSafeCompactionSplitIndex(messages, protectedTail);
if (prefixLength <= 0) return false;
const prefix = messages.slice(0, prefixLength);
const fingerprint = fingerprintMessages(prefix);
if (
current
&& current.modelId === modelId
&& current.prefixLength === prefixLength
&& current.fingerprint === fingerprint
) {
return false;
}
this.entries.set(chatSessionId, {
modelId,
prefixLength,
fingerprint,
notePromise: producer(prefix).then(note => note.slice(0, 12_000)).catch(() => ''),
});
return true;
}
async consume(
chatSessionId: string,
modelId: string,
messages: ModelMessage[],
): Promise<{ note: string; prefixLength: number } | undefined> {
const entry = this.entries.get(chatSessionId);
if (!entry || entry.modelId !== modelId || messages.length < entry.prefixLength) {
this.entries.delete(chatSessionId);
return undefined;
}
const prefix = messages.slice(0, entry.prefixLength);
if (fingerprintMessages(prefix) !== entry.fingerprint) {
this.entries.delete(chatSessionId);
return undefined;
}
const note = await entry.notePromise;
return note ? { note, prefixLength: entry.prefixLength } : undefined;
}
clear(chatSessionId: string): void {
this.entries.delete(chatSessionId);
}
}
export const globalTwoPassCompactionCache = new TwoPassCompactionCache();

View File

@@ -0,0 +1,224 @@
/**
* Unified agent harness event protocol.
* Catty (Vercel AI SDK) and external SDK drivers emit the same shapes.
*/
export type AgentEventType =
| 'turn_start'
| 'model_delta'
| 'reasoning_delta'
| 'tool_call'
| 'tool_result'
| 'file_change'
| 'web_search'
| 'plan_update'
| 'approval_requested'
| 'approval_resolved'
| 'compaction'
| 'compaction_start'
| 'usage'
| 'performance'
| 'model_call_start'
| 'step_end'
| 'context_snapshot'
| 'error'
| 'turn_end';
export type AgentBackend = 'catty' | 'external-sdk';
export type ApprovalOutcome = 'approved' | 'denied' | 'timeout';
export type ContextPrepareTrigger = 'pre-turn' | '413-retry' | 'force' | 'step';
export interface AgentEventBase {
id: string;
sessionId: string;
chatSessionId?: string;
backend: AgentBackend;
timestamp: number;
turnId?: string;
}
export interface TurnStartEvent extends AgentEventBase {
type: 'turn_start';
backendLabel?: string;
}
export interface ContextSnapshotEvent extends AgentEventBase {
type: 'context_snapshot';
snapshot: import('./promptContextSnapshot').PromptContextSnapshot;
}
export interface ModelDeltaEvent extends AgentEventBase {
type: 'model_delta';
text: string;
}
export interface ReasoningDeltaEvent extends AgentEventBase {
type: 'reasoning_delta';
text: string;
}
export interface ToolCallEvent extends AgentEventBase {
type: 'tool_call';
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}
export interface ToolResultEvent extends AgentEventBase {
type: 'tool_result';
toolCallId: string;
toolName?: string;
result: string;
isError?: boolean;
}
export interface FileChangeEvent extends AgentEventBase {
type: 'file_change';
itemId: string;
status: 'completed' | 'failed';
changes: Array<{ path: string; kind: 'add' | 'delete' | 'update' }>;
}
export interface WebSearchEvent extends AgentEventBase {
type: 'web_search';
itemId: string;
query: string;
status: 'running' | 'completed';
}
export interface PlanUpdateEvent extends AgentEventBase {
type: 'plan_update';
itemId: string;
status: 'running' | 'completed';
items: Array<{ text: string; completed: boolean }>;
}
export interface ApprovalRequestedEvent extends AgentEventBase {
type: 'approval_requested';
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}
export interface ApprovalResolvedEvent extends AgentEventBase {
type: 'approval_resolved';
toolCallId: string;
toolName: string;
outcome: ApprovalOutcome;
persistedGrantId?: string;
}
export type TokenEstimatorKind = 'chars-div-4' | 'openai-heuristic' | 'anthropic-heuristic' | 'google-heuristic';
export interface CompactionTrace {
trigger: ContextPrepareTrigger;
estimatedTokensBefore: number;
estimatedTokensAfter: number;
messagesBefore: number;
messagesAfter: number;
compressedMessageCount: number;
retainedTailCount: number;
summaryLength?: number;
didTypedCompression: boolean;
didLlmSummarize: boolean;
did413Fallback: boolean;
estimatorKind?: TokenEstimatorKind;
archiveHandleId?: string;
artifactHandleId?: string;
archiveChars?: number;
twoPassCacheHit?: boolean;
twoPassPrefixMessages?: number;
}
export interface CompactionEvent extends AgentEventBase {
type: 'compaction';
trace: CompactionTrace;
}
export interface CompactionStartEvent extends AgentEventBase {
type: 'compaction_start';
trigger: ContextPrepareTrigger;
}
export interface ErrorEvent extends AgentEventBase {
type: 'error';
message: string;
code?: string;
recoverable?: boolean;
}
export interface UsageEvent extends AgentEventBase {
type: 'usage';
promptTokens: number;
cachedPromptTokens?: number;
completionTokens: number;
reasoningTokens?: number;
totalTokens: number;
estimated?: boolean;
}
export interface PerformanceEvent extends AgentEventBase {
type: 'performance';
responseTimeMs?: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
}
export interface ModelCallStartEvent extends AgentEventBase {
type: 'model_call_start';
callId: string;
modelId: string;
providerId?: string;
}
export interface StepEndEvent extends AgentEventBase {
type: 'step_end';
callId: string;
stepNumber: number;
modelId?: string;
finishReason?: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface TurnEndEvent extends AgentEventBase {
type: 'turn_end';
reason?: 'completed' | 'aborted' | 'error';
}
export type AgentEvent =
| TurnStartEvent
| ContextSnapshotEvent
| ModelDeltaEvent
| ReasoningDeltaEvent
| ToolCallEvent
| ToolResultEvent
| FileChangeEvent
| WebSearchEvent
| PlanUpdateEvent
| ApprovalRequestedEvent
| ApprovalResolvedEvent
| CompactionEvent
| CompactionStartEvent
| UsageEvent
| PerformanceEvent
| ModelCallStartEvent
| StepEndEvent
| ErrorEvent
| TurnEndEvent;
export type AgentEventListener = (event: AgentEvent) => void;
export interface ContextPrepareResult {
messages: import('ai').ModelMessage[];
didAdjust: boolean;
trace?: CompactionTrace;
}
export interface ExternalBridgeHistoryMessage {
role: 'user' | 'assistant';
content: string;
}