[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,12 @@
/** Where an in-app agent runs — distinct from RPC/MCP/CLI capability surfaces. */
export const AGENT_KINDS = {
/** Chat side panel (Catty). */
SIDEBAR: 'sidebar',
/** Future app-wide agent (cross-window / proactive). */
GLOBAL: 'global',
} as const;
export type AgentKind = (typeof AGENT_KINDS)[keyof typeof AGENT_KINDS];
export const SIDEBAR_AGENT_KIND: AgentKind = AGENT_KINDS.SIDEBAR;
export const GLOBAL_AGENT_KIND: AgentKind = AGENT_KINDS.GLOBAL;

View File

@@ -0,0 +1,289 @@
import type { NetcattyBridge } from './cattyAgent/executor';
import type {
OpenAIChatAssistantFields,
ProviderContinuationOptions,
ProviderContinuationSource,
} from './providerContinuation';
/** Shape of a text/text-delta chunk from the Vercel AI SDK stream. */
export interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
providerMetadata?: unknown;
}
/** Shape of a reasoning chunk from the Vercel AI SDK stream. */
export interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
textDelta?: string;
delta?: string;
providerMetadata?: unknown;
}
/** Shape of a raw provider chunk from the Vercel AI SDK stream. */
export interface RawChunk {
type: 'raw';
rawValue: unknown;
}
/** Shape of a tool-call chunk from the Vercel AI SDK stream. */
export interface ToolCallChunk {
type: 'tool-call';
toolCallId: string;
toolName: string;
input?: unknown;
args?: unknown;
providerMetadata?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK stream. */
export interface ToolResultChunk {
type: 'tool-result';
toolCallId: string;
output?: unknown;
result?: unknown;
}
/** Shape of a tool-error chunk from the Vercel AI SDK stream. */
export interface ToolErrorChunk {
type: 'tool-error';
toolCallId: string;
toolName?: string;
error?: unknown;
}
/** Shape of a tool-output-denied chunk from the Vercel AI SDK stream. */
export interface ToolOutputDeniedChunk {
type: 'tool-output-denied';
toolCallId: string;
toolName?: string;
}
/** Nested tool call reference on approval stream chunks. */
export interface StreamChunkToolCallRef {
toolCallId: string;
toolName: string;
input?: unknown;
args?: unknown;
}
/** Shape of a tool-approval-response chunk from the Vercel AI SDK stream. */
export interface ToolApprovalResponseChunk {
type: 'tool-approval-response';
approvalId?: string;
approved?: boolean;
reason?: string;
toolCallId?: string;
toolName?: string;
toolCall?: StreamChunkToolCallRef;
}
/** Resolve toolCallId from flat or nested approval/tool chunks. */
export function resolveStreamChunkToolCallId(chunk: {
toolCallId?: string;
toolCall?: { toolCallId?: string };
}): string | undefined {
return chunk.toolCallId ?? chunk.toolCall?.toolCallId;
}
/** Format tool execution failures for model/UI consumption. */
export function formatToolErrorContent(error: unknown, fallback = 'Tool execution failed.'): string {
if (error instanceof Error) return JSON.stringify({ error: error.message });
if (typeof error === 'string') return JSON.stringify({ error });
if (error != null && typeof error === 'object' && 'error' in error) {
return JSON.stringify(error);
}
return JSON.stringify({ error: fallback });
}
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
export function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
// Check for explicit error objects
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
// Check stringified JSON (common for tool result wrapping)
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed === 'object') {
const parsedObj = parsed as Record<string, unknown>;
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
if ('ok' in parsedObj && parsedObj.ok === false) return true;
}
} catch { /* not JSON, not an error */ }
}
return false;
}
/** Shape of an error chunk from the Vercel AI SDK stream. */
export interface ErrorChunk {
type: 'error';
error: unknown;
}
/** Union of all stream chunk shapes we handle. */
export type StreamChunk =
| TextDeltaChunk
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolErrorChunk
| ToolOutputDeniedChunk
| ToolApprovalResponseChunk
| ErrorChunk
| RawChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request'; approvalId?: string; toolCallId?: string; toolName?: string; approved?: boolean; toolCall?: StreamChunkToolCallRef; input?: unknown; args?: unknown; providerMetadata?: unknown };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiMcpUpdateAttachments?: (
attachments: Array<{ base64Data?: string; mediaType?: string; filename?: string; filePath?: string }>,
chatSessionId?: string,
) => Promise<unknown>;
aiSdkAgentListModels?: (
sdkBackend: string,
cwd?: string,
providerId?: string,
chatSessionId?: string,
agentEnv?: Record<string, string>,
agentCommand?: string,
codexRuntime?: 'sdk' | 'app-server',
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; error?: string }>;
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
aiMcpSyncPermissionGrants?(grants: Array<Record<string, unknown>>): Promise<{ ok: boolean; count?: number; error?: string }>;
aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>;
aiSdkAgentSteer?: (
requestId: string,
chatSessionId: string,
prompt: string,
images: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }> | undefined,
clientUserMessageId: string,
) => Promise<{
status: 'accepted' | 'not-steerable' | 'busy' | 'inactive' | 'unsupported' | 'cancelled' | 'failed';
message?: string;
turnKind?: 'review' | 'compact';
}>;
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}>;
}>;
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
/** Terminal session info used throughout the streaming hooks. */
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 DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
export interface CattyProviderContinuationContext {
source: ProviderContinuationSource;
usesOpenAIResponses: boolean;
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
}
export type AssistantContentPart =
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
export function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
return parts[0].text;
}
return parts;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).netcatty as PanelBridge | undefined;
}
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
// inside the tool's execute function via the approvalGate module.
export function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
interface UserSkillsContextResult {
ok: boolean;
context?: string;
error?: string;
}
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
if (!selectedUserSkillSlugs?.length) return '';
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
}
export async function resolveUserSkillsContext(
bridge: PanelBridge | undefined,
prompt: string,
selectedUserSkillSlugs?: string[],
): Promise<string> {
if (!bridge?.aiUserSkillsBuildContext) {
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
.catch(() => ({ ok: false, context: '' }));
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
const result = hasExplicitSelections
? await buildContextPromise
: await Promise.race([
buildContextPromise,
new Promise<UserSkillsContextResult>((resolve) =>
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
),
]);
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}

View File

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
anthropicBaseIncludesV1,
isBareOriginBaseURL,
normalizeAnthropicSdkBaseURL,
stripTrailingSlashes,
} from "./anthropicCompatBaseUrl";
test("stripTrailingSlashes trims and drops trailing slashes", () => {
assert.equal(stripTrailingSlashes(" https://host/v1/ "), "https://host/v1");
assert.equal(stripTrailingSlashes(""), "");
});
test("anthropicBaseIncludesV1 detects AI SDK style bases", () => {
assert.equal(anthropicBaseIncludesV1("https://api.anthropic.com/v1"), true);
assert.equal(anthropicBaseIncludesV1("https://api.anthropic.com/v1/"), true);
assert.equal(anthropicBaseIncludesV1("https://gateway.example/api/v1"), true);
assert.equal(anthropicBaseIncludesV1("https://api.anthropic.com"), false);
assert.equal(anthropicBaseIncludesV1("https://gateway.example/"), false);
});
test("isBareOriginBaseURL only matches scheme+host with no path", () => {
assert.equal(isBareOriginBaseURL("https://api.anthropic.com"), true);
assert.equal(isBareOriginBaseURL("https://gateway.example/"), true);
assert.equal(isBareOriginBaseURL("http://localhost:8080"), true);
assert.equal(isBareOriginBaseURL("https://gateway.example/v1"), false);
assert.equal(isBareOriginBaseURL("https://proxy.example/anthropic"), false);
assert.equal(isBareOriginBaseURL("https://gateway.example/api"), false);
assert.equal(isBareOriginBaseURL(""), false);
});
test("normalizeAnthropicSdkBaseURL accepts Claude Code and AI SDK conventions", () => {
assert.equal(
normalizeAnthropicSdkBaseURL("https://api.anthropic.com"),
"https://api.anthropic.com/v1",
);
assert.equal(
normalizeAnthropicSdkBaseURL("https://gateway.example/"),
"https://gateway.example/v1",
);
assert.equal(
normalizeAnthropicSdkBaseURL("https://gateway.example/v1"),
"https://gateway.example/v1",
);
assert.equal(
normalizeAnthropicSdkBaseURL("https://gateway.example/v1/"),
"https://gateway.example/v1",
);
assert.equal(normalizeAnthropicSdkBaseURL(" "), "");
});
test("normalizeAnthropicSdkBaseURL preserves custom non-/v1 path prefixes", () => {
// Proxy that already completes the SDK base: …/anthropic/messages, not …/anthropic/v1/messages.
assert.equal(
normalizeAnthropicSdkBaseURL("https://proxy.example/anthropic"),
"https://proxy.example/anthropic",
);
assert.equal(
normalizeAnthropicSdkBaseURL("https://proxy.example/anthropic/"),
"https://proxy.example/anthropic",
);
assert.equal(
normalizeAnthropicSdkBaseURL("https://gateway.example/api"),
"https://gateway.example/api",
);
// Nested AI SDK style still ends in /v1 and stays as-is.
assert.equal(
normalizeAnthropicSdkBaseURL("https://gateway.example/api/v1"),
"https://gateway.example/api/v1",
);
});

View File

@@ -0,0 +1,51 @@
/**
* Anthropic-compatible gateways disagree on Base URL shape:
* - Claude Code / official host: bare origin (…/host), paths are /v1/messages, /v1/models
* - @ai-sdk/anthropic: base already includes /v1, then appends /messages, /models
* - Custom proxies: complete SDK prefix that is not /v1 (e.g. …/anthropic → /anthropic/messages)
*
* Netcatty accepts Claude Code bare hosts and AI SDK /v1 bases at chat / probe
* boundaries. Custom non-/v1 path prefixes are left unchanged so previously
* working proxy bases keep working.
*/
/** Strip trailing slashes; empty input stays empty. */
export function stripTrailingSlashes(url: string): string {
return url.trim().replace(/\/+$/, "");
}
/** True when the URL path already ends with /v1 (AI SDK style). */
export function anthropicBaseIncludesV1(baseURL: string): boolean {
return /\/v1$/i.test(stripTrailingSlashes(baseURL));
}
/**
* True when the Base URL is only a scheme + host (optional port), with no path.
* Claude Code style ANTHROPIC_BASE_URL values are bare origins.
*/
export function isBareOriginBaseURL(baseURL: string): boolean {
const trimmed = stripTrailingSlashes(baseURL);
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return !parsed.pathname || parsed.pathname === "/";
} catch {
// Non-absolute strings: treat as bare only when there is no path segment.
return !trimmed.includes("/") || /^[a-z][a-z0-9+.-]*:\/\/[^/]+$/i.test(trimmed);
}
}
/**
* Normalize a stored Anthropic-compat Base URL for @ai-sdk/anthropic.
* - Bare hosts gain a /v1 suffix (Claude Code style → SDK style).
* - Bases that already end in /v1 are left unchanged.
* - Other path prefixes (custom proxies) are left unchanged so chat keeps
* requesting `{prefix}/messages` rather than `{prefix}/v1/messages`.
*/
export function normalizeAnthropicSdkBaseURL(baseURL: string): string {
const trimmed = stripTrailingSlashes(baseURL);
if (!trimmed) return trimmed;
if (anthropicBaseIncludesV1(trimmed)) return trimmed;
if (!isBareOriginBaseURL(trimmed)) return trimmed;
return `${trimmed}/v1`;
}

View File

@@ -0,0 +1,192 @@
import type { ToolCall, ToolResult, AIPermissionMode, WebSearchConfig } from '../types';
import type {
TerminalContextReader,
} from '../../../domain/terminalContextRead';
import {
executeTerminalExecute,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeWebSearch,
executeUrlFetch,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
import { fitTerminalExecuteResultForModel } from '../harness/terminalCompression';
/**
* Bridge interface for Catty Agent to interact with the Electron main process.
* This mirrors the AI-related subset of window.netcatty from electron/preload.cjs.
*/
export interface NetcattyBridge {
aiExec(
sessionId: string,
command: string,
chatSessionId?: string,
): Promise<{
ok: boolean;
stdout?: string;
stderr?: string;
exitCode?: number;
error?: string;
}>;
/**
* Cancel any in-flight Catty Agent command execution scoped to the
* given chat session. Idempotent — safe to call when nothing is
* running. Used by tools to re-issue cancel during the IPC transit
* window if the user clicks Stop after we've already dispatched
* `aiExec` but before the main process has registered it.
*/
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
aiCapability?(
rpcMethod: string,
params: Record<string, unknown>,
chatSessionId?: string,
): Promise<unknown>;
}
// Workspace context provided to the executor
export interface ExecutorContext {
// Available sessions in scope
sessions: Array<{
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
// Workspace info
workspaceId?: string;
workspaceName?: string;
readTerminalContext?: TerminalContextReader;
}
/** Convert a shared ToolExecResult into the executor's ToolResult format. */
function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
if (r.ok === false) {
if (
typeof r.data === 'object'
&& r.data !== null
&& 'stdout' in r.data
&& 'stderr' in r.data
&& 'exitCode' in r.data
) {
const fitted = fitTerminalExecuteResultForModel(r.data as {
stdout: string;
stderr: string;
exitCode: number | null;
});
const output = [
r.error,
fitted.stdout ? `Partial output:\n${fitted.stdout}` : '',
fitted.stderr ? `Stderr:\n${fitted.stderr}` : '',
].filter(Boolean).join('\n\n');
return { toolCallId, content: output, isError: true };
}
return { toolCallId, content: r.error, isError: true };
}
// For terminal_execute, format as the legacy STDOUT/STDERR/exitCode text block
if (
typeof r.data === 'object' &&
r.data !== null &&
'stdout' in r.data &&
'stderr' in r.data &&
'exitCode' in r.data
) {
const d = r.data as { stdout: string; stderr: string; exitCode: number };
const output = [
d.stdout ? `STDOUT:\n${d.stdout}` : '',
d.stderr ? `STDERR:\n${d.stderr}` : '',
`Exit code: ${d.exitCode === -1 ? 'unknown' : d.exitCode}`,
]
.filter(Boolean)
.join('\n\n');
return { toolCallId, content: output || 'Command completed (no output)' };
}
// Default: JSON-serialize the data
return { toolCallId, content: JSON.stringify(r.data, null, 2) };
}
/**
* Create a tool executor function for the Catty Agent.
* This bridges tool calls to the netcatty Electron IPC layer.
*/
export function createToolExecutor(
bridge: NetcattyBridge | undefined,
context: ExecutorContext,
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
webSearchConfig?: WebSearchConfig,
chatSessionId?: string,
): (toolCall: ToolCall) => Promise<ToolResult> {
return async (toolCall: ToolCall): Promise<ToolResult> => {
if (!bridge) {
return {
toolCallId: toolCall.id,
content: 'Netcatty bridge is not available',
isError: true,
};
}
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
const args = toolCall.arguments;
try {
switch (toolCall.name) {
case 'terminal_execute': {
const r = await executeTerminalExecute(deps, {
sessionId: String(args.sessionId || ''),
command: String(args.command || ''),
});
return toToolResult(toolCall.id, r);
}
case 'workspace_get_info': {
const r = executeWorkspaceGetInfo(deps);
return toToolResult(toolCall.id, r);
}
case 'workspace_get_session_info': {
const r = executeWorkspaceGetSessionInfo(deps, {
sessionId: String(args.sessionId || ''),
});
return toToolResult(toolCall.id, r);
}
case 'web_search': {
const r = await executeWebSearch(deps, {
query: String(args.query || ''),
maxResults: Number(args.maxResults) || 5,
});
return toToolResult(toolCall.id, r);
}
case 'url_fetch': {
const r = await executeUrlFetch(deps, {
url: String(args.url || ''),
maxLength: Number(args.maxLength) || 50000,
});
return toToolResult(toolCall.id, r);
}
default:
return {
toolCallId: toolCall.id,
content: `Unknown tool: ${toolCall.name}`,
isError: true,
};
}
} catch (err) {
return {
toolCallId: toolCall.id,
content: `Tool execution error: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
};
}

View File

@@ -0,0 +1,163 @@
import commandBlocklistTable from '../../../lib/commandBlocklist.json';
import { DEFAULT_COMMAND_BLOCKLIST } from '../types';
/**
* Check if a regex pattern is safe from ReDoS attacks.
*
* Rejects patterns with nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`
* which can cause catastrophic backtracking / CPU exhaustion.
*/
function isSafeRegex(pattern: string): boolean {
// Detect nested quantifiers: a group containing a quantifier, followed by another quantifier.
// Matches patterns like (x+)+, (x*)+, (x+)*, (x{2,})+ etc.
const nestedQuantifier = /\([^)]*[+*}]\)[+*?{]/;
if (nestedQuantifier.test(pattern)) {
return false;
}
// Also catch overlapping alternations with quantifiers inside quantified groups
// e.g. (a|a)+ — not always dangerous but a common ReDoS vector
const overlappingAlt = /\([^)]*\|[^)]*\)[+*]{/;
if (overlappingAlt.test(pattern)) {
return false;
}
return true;
}
/**
* Pre-compiled RegExp cache for default blocklist patterns, grouped by the
* shell family the pattern targets.
*
* The blocklist is a best-effort defense-in-depth measure. It is NOT a
* security boundary — determined users or sophisticated prompt injection
* can bypass regex-based filtering. The primary security boundary is the
* permission / confirmation system and OS-level sandboxing.
*/
interface CompiledPattern { pattern: string; regex: RegExp }
const compileGroup = (patterns: string[]): CompiledPattern[] =>
patterns.flatMap((pattern) => {
try {
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping default blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
return [];
}
return [{ pattern, regex: new RegExp(pattern, 'i') }];
} catch {
return [];
}
});
const compiledCommonGroup = compileGroup(commandBlocklistTable.common);
const compiledPosixNativeGroup = compileGroup(commandBlocklistTable.posixNative);
const compiledPosixGroup = compileGroup(commandBlocklistTable.posix);
const compiledPowershellGroup = compileGroup(commandBlocklistTable.powershell);
const compiledGroups = {
common: compiledCommonGroup,
posixNative: compiledPosixNativeGroup,
posix: compiledPosixGroup,
powershell: compiledPowershellGroup,
};
const compiledAllGroups = [
compiledCommonGroup,
compiledPosixNativeGroup,
compiledPosixGroup,
compiledPowershellGroup,
];
const DEFAULT_PATTERN_SET = new Set(DEFAULT_COMMAND_BLOCKLIST);
/**
* Default-blocklist groups that apply for a shell kind, from common
* (shell-independent) patterns to per-family ones. Unknown / empty kinds
* intentionally fall back to every group so callers that cannot classify a
* session keep the strict behavior.
*/
function selectDefaultGroups(shellKind?: string): CompiledPattern[][] {
const groupNames = commandBlocklistTable.shellGroups[
String(shellKind ?? '').toLowerCase() as keyof typeof commandBlocklistTable.shellGroups
];
if (!groupNames) return compiledAllGroups;
return groupNames.map((name) => compiledGroups[name as keyof typeof compiledGroups]);
}
function checkCommandAgainstGroups(
command: string,
blocklist: string[],
groups: CompiledPattern[][],
): { blocked: boolean; matchedPattern?: string } {
const enabledPatterns = new Set(blocklist);
// Settings entries that are not built-in defaults are user patterns and
// remain shell-independent.
for (const pattern of blocklist) {
if (DEFAULT_PATTERN_SET.has(pattern)) continue;
const regex = getCompiledPattern(pattern);
if (regex && regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
// Shell selection narrows the built-in entries that are enabled in the
// configured list. It must not restore a default the user removed/edited.
for (const group of groups) {
for (const { pattern, regex } of group) {
if (enabledPatterns.has(pattern) && regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
}
return { blocked: false };
}
/** Cache for user-provided (non-default) blocklist patterns. */
const userPatternCache = new Map<string, RegExp | null>();
function getCompiledPattern(pattern: string): RegExp | null {
if (userPatternCache.has(pattern)) {
return userPatternCache.get(pattern)!;
}
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping user blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
userPatternCache.set(pattern, null);
return null;
}
try {
const regex = new RegExp(pattern, 'i');
userPatternCache.set(pattern, regex);
return regex;
} catch {
userPatternCache.set(pattern, null);
return null;
}
}
/**
* Check if a command matches any pattern in the blocklist.
* Returns the matching pattern if blocked, null if safe.
*
* The caller's list remains authoritative. User patterns apply on every shell,
* while enabled default patterns are narrowed by shell kind. Unknown shell
* kinds fall back to every enabled default group.
*
* Default blocklist patterns are pre-compiled at module load time.
* User-provided patterns are compiled once and cached.
*/
export function checkCommandSafety(
command: string,
blocklist: string[] = DEFAULT_COMMAND_BLOCKLIST,
shellKind?: string,
): { blocked: boolean; matchedPattern?: string } {
return checkCommandAgainstGroups(command, blocklist, selectDefaultGroups(shellKind));
}
/**
* Apply user patterns and enabled shell-independent defaults only. This is the
* safe pre-filter for renderer metadata that does not yet know the remote shell;
* the live bridge performs the final shell-selected check after probing.
*/
export function checkCommandSafetyCommonOnly(
command: string,
blocklist: string[] = DEFAULT_COMMAND_BLOCKLIST,
): { blocked: boolean; matchedPattern?: string } {
return checkCommandAgainstGroups(command, blocklist, [compiledCommonGroup]);
}

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSystemPrompt } from './systemPrompt';
test('system prompt tells Catty how to import unknown attached host lists safely', () => {
const prompt = buildSystemPrompt({
scopeType: 'terminal',
hosts: [],
permissionMode: 'confirm',
});
assert.match(prompt, /list_attachments/i);
assert.match(prompt, /read_attachment/i);
assert.match(prompt, /unknown/i);
assert.match(prompt, /vault_hosts_create/i);
assert.match(prompt, /tool_output_read/i);
assert.match(prompt, /compressed|truncated/i);
});
test('system prompt prefers explicit script wait APIs', () => {
const prompt = buildSystemPrompt({
scopeType: 'terminal',
hosts: [],
permissionMode: 'confirm',
});
assert.match(prompt, /waitForText/);
assert.match(prompt, /waitForRegex/);
assert.doesNotMatch(prompt, /sendLine`,\s*`waitFor`,\s*dialogs/);
});
test('system prompt does not tell Catty to call host_open', () => {
const prompt = buildSystemPrompt({
scopeType: 'workspace',
hosts: [],
permissionMode: 'confirm',
});
assert.doesNotMatch(prompt, /host_open/);
assert.match(prompt, /cannot open new terminal sessions yourself/i);
assert.match(prompt, /ask them to open/i);
});

View File

@@ -0,0 +1,244 @@
export interface SystemPromptContext {
scopeType: 'terminal' | 'workspace' | 'global';
scopeLabel?: string;
hosts: Array<{
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;
}>;
}>;
permissionMode: 'observer' | 'confirm' | 'auto';
webSearchEnabled?: boolean;
userSkillsContext?: string;
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled, userSkillsContext } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
const permissionRules = buildPermissionRules(permissionMode);
const shellGuidance = buildShellGuidance(hosts);
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users operate terminal sessions managed by Netcatty, including remote hosts and the user's local terminal.
## Current Scope
${scopeDescription}
## Available Sessions
${hostList}
${shellGuidance}
## Permission Mode: ${permissionMode}
${permissionRules}
## Guidelines
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing.
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\`. SFTP read/write, vault snippets, port forwarding, vault notes, and vault host tools are available when listed in your tool set — prefer them over manual shell workarounds.
**Prefer built-in diagnostic skills over hand-rolled command chains.** Use \`skill_run\` with \`skillName\` set to one of:
- \`diagnose_linux\` — CPU, memory, disk, Docker, systemd, kernel errors (Linux)
- \`diagnose_windows\` — CPU, memory, disk, top processes, services, ports, event log (Windows PowerShell)
- \`check_ports\` — All listening TCP/UDP ports with process info (auto-adapts to OS)
- \`check_docker\` — Docker daemon health, containers, disk usage
- \`security_audit\` — SSH config, firewall, failed logins, world-writable files (Linux)
These skills auto-select the correct commands for the host shell and return a structured report — much more reliable than building chains yourself.
**Vault → Hosts (SSH connections):** When the user asks to **add/create/import a host** (创建主机、添加主机、保存服务器连接凭据), use \`vault_hosts_create\` — NOT \`vault_notes_create\`. Extract \`hostname\`, \`username\`, \`password\` or local \`keyPath\`, \`port\`, \`group\`, \`tags\`, and \`label\` from the user's text; put long admin tables or remarks in the host's \`notes\` field (Host Details metadata). Call with \`dryRun: true\` first to preview, then write. Only use \`vault_hosts_import\` for known export formats (PuTTY, MobaXterm, CSV, SecureCRT, ssh_config). Use \`vault_hosts_list\` to check existing hosts and resolve \`hostId\` before \`vault_hosts_update\` or \`vault_hosts_delete\`.
**Open / connect a host:** You cannot open new terminal sessions yourself. Stay within the sessions listed under Available Sessions. If the user wants work on a saved host that is not already open in your scope, ask them to open that host (or add it to the current workspace) in the Netcatty UI, then continue once it appears in scope.
**Attached host files:** When the user asks to import attached host/server data, call \`list_attachments\` then \`read_attachment\`. If the attachment is a known export format, pass the exact text to \`vault_hosts_import\`. If the format is unknown or \`vault_hosts_import\` cannot detect it, do not search a terminal or remote filesystem; read the attached text, extract host fields yourself, and call \`vault_hosts_create\` with \`dryRun: true\` first. If a tool result is truncated or compressed and includes a \`tool_output_read\` handle, use \`tool_output_read\` to recover the needed original text before extracting fields.
**Vault → Notes (sidebar markdown docs):** When the user explicitly wants documentation saved to **Vault → Notes** (the notes sidebar / 保险箱笔记), use \`vault_notes_create\` or \`vault_notes_update\` — **not** \`host_notes_set\` (Host Details only) and **not** as a substitute for creating a host. When a message references a Vault note, use \`vault_notes_get\` with its exact \`noteId\` to read the latest content before summarizing or editing. Use that same ID for updates; do not substitute a title search. If the note no longer exists, tell the user.
**Snippets vs automation scripts:** Use \`snippets_*\` for shell command text (paste/execute with optional \`{{variables}}\`). Use \`scripts_*\` for multi-step terminal automation written in JavaScript with the \`nct.*\` API (\`await nct.screen.sendLine\`, \`waitForText\` / \`waitForRegex\`, dialogs, progress). Call \`scripts_reference\` before authoring or editing scripts. Run scripts with \`scripts_run\` (set \`wait: true\` to block until done); use \`scripts_runs_list\`, \`scripts_run_stop\`, \`scripts_run_pause\`, and \`scripts_run_resume\` for lifecycle control. Create/update/delete vault entries with \`snippets_create/update/delete\` (any kind) or \`scripts_create/update/delete\` (scripts only).
**Script triggers and hosts:** \`trigger: manual\` runs on demand; \`onConnect\` runs after SSH connect (global \`targetsAllHosts\`, dynamic \`targetGroups\`, then per-host \`connectScriptIds\` queue); \`onOutput\` runs when terminal output matches \`triggerPattern\` (regex). Link scripts to host IDs or dynamic group paths with \`scripts_targets_set\`, or manage per-host connect order with \`host_connect_scripts_list\` / \`host_connect_scripts_set\`.
**Never fallback:** If \`vault_hosts_create\` or \`vault_hosts_import\` fails, report the error to the user. Do **not** silently create a Vault note instead of the requested host.
When the user pastes unstructured text with host/server info, **you** extract fields and call \`vault_hosts_create\`. When operating on multiple sessions, call \`terminal_execute\` for each target session.
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
4. **Explain before executing.** Before running any command, briefly explain what it does and why.
5. **Handle errors gracefully.** If a command fails, analyze the error output, explain what went wrong, and suggest alternatives or corrective actions. Do not retry the same failing command without modification.
6. **Stay focused.** Keep responses concise and relevant to terminal and server operations. Avoid unrelated commentary.
7. **Respect connection status.** Only attempt operations on sessions that are currently connected and listed in your scope. If a session is disconnected, ask the user to reconnect it in the Netcatty UI. If the needed host is not open in your scope, ask the user to open it (or join it into the current workspace) rather than inventing a workaround.
8. **Be careful with file operations.** When writing files via shell commands, prefer appending or targeted edits over full file overwrites when possible.
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}
${userSkillsContext ? `\n\n## User Skills\n\n${userSkillsContext}` : ''}`;
}
function buildShellGuidance(
hosts: SystemPromptContext['hosts'],
): string {
const shells = new Set<string>();
for (const h of hosts) {
if (h.shellType) shells.add(h.shellType.toLowerCase());
}
const blocks: string[] = [];
if (shells.has('powershell')) {
blocks.push([
'### PowerShell (Windows) sessions — MUST follow these rules:',
'',
'- Command separator: use `;` NOT `&&` (PowerShell 5 does not support `&&`)',
'- HTTP: use `Invoke-RestMethod` / `Invoke-WebRequest`, NOT `curl` (PowerShell aliases curl to Invoke-WebRequest with incompatible params)',
'- Environment variables: use `$env:VARNAME`, NOT `$VARNAME`',
'- Processes: `Get-Process`, `Stop-Process -Id <pid>` — NOT `ps aux`, `kill -9`',
'- Services: `Get-Service`, `Start-Service`, `Stop-Service`',
'- File ops: `Get-ChildItem` (alias `dir`), `Remove-Item -Recurse -Force` (alias `rm -r -fo`)',
'- Run as admin: `Start-Process -Verb RunAs powershell`',
'- DO NOT use `<` input redirect (not supported), `&&` chaining, bash-style `$(...)` substitution (PowerShell uses `$()`)',
'- Use `Get-Content file | Select-String pattern` instead of `grep`',
].join('\n'));
}
if (shells.has('cmd')) {
blocks.push([
'### cmd.exe (Windows) sessions — MUST follow these rules:',
'',
'- Command separator: `&` or `&&`. Prefer separate lines or `&` when chaining.',
'- Environment variables: `%VARNAME%` (NOT `$VARNAME`, NOT `$env:VARNAME`)',
'- Process kill: `taskkill /PID <pid> /F`',
'- Service: `net start <svc>`, `net stop <svc>`',
'- Pipe `|` works but subshells `$()` are not available. No bash/PowerShell syntax.',
].join('\n'));
}
if (blocks.length === 0) return '';
return `\n## Shell-Specific Guidance\n\n${blocks.join('\n\n')}\n`;
}
function buildScopeDescription(
scopeType: 'terminal' | 'workspace' | 'global',
scopeLabel?: string,
): string {
switch (scopeType) {
case 'terminal':
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific session.`;
case 'workspace':
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any session within this workspace.`;
case 'global':
return `You have global scope and can operate on any connected session across all workspaces.`;
}
}
function formatHostChain(
hostChain: SystemPromptContext['hosts'][number]['hostChain'],
): string | null {
if (!hostChain?.length) return null;
return hostChain
.map((hop) => hop.label || hop.hostname || hop.hostId)
.join(' → ');
}
function formatActivePortForwards(
activePortForwards: SystemPromptContext['hosts'][number]['activePortForwards'],
): string | null {
if (!activePortForwards?.length) return null;
return activePortForwards
.map((rule) => {
const label = rule.label || rule.ruleId;
const port = rule.localPort != null ? `:${rule.localPort}` : '';
const status = rule.status ? ` (${rule.status})` : '';
return `${label}${port}${status}`;
})
.join(', ');
}
function buildHostList(
hosts: SystemPromptContext['hosts'],
): string {
if (hosts.length === 0) {
return '_No terminal sessions are currently available. The user needs to open or connect a terminal first._';
}
const lines = hosts.map(host => {
const status = host.connected ? 'connected' : 'disconnected';
const hostChain = formatHostChain(host.hostChain);
const portForwards = formatActivePortForwards(host.activePortForwards);
const details = [
`hostname: ${host.hostname}`,
`label: ${host.label}`,
host.protocol ? `protocol: ${host.protocol}` : null,
host.os ? `os: ${host.os}` : null,
host.username ? `user: ${host.username}` : null,
host.shellType ? `shell: ${host.shellType}` : null,
host.deviceType ? `deviceType: ${host.deviceType}` : null,
hostChain ? `hostChain: ${hostChain}` : null,
portForwards ? `portForwards: ${portForwards}` : null,
`status: ${status}`,
]
.filter(Boolean)
.join(', ');
return `- \`${host.sessionId}\` - ${details}`;
});
return lines.join('\n');
}
function buildPermissionRules(
permissionMode: 'observer' | 'confirm' | 'auto',
): string {
switch (permissionMode) {
case 'observer':
return [
'You are in **observer** mode. You may only perform read-only operations:',
'- Getting workspace and session info (`workspace_get_info`, `workspace_get_session_info`)',
'- Fetching URLs (`url_fetch`)',
'- Searching the web (`web_search`)',
'',
'All write and execute operations are denied. If the user asks you to run a command or modify a file, explain that observer mode does not allow it and suggest switching to confirm or auto mode.',
].join('\n');
case 'confirm':
return [
'You are in **confirm** mode. The system will automatically show an approval prompt to the user for write and execute operations:',
'- Command execution (`terminal_execute`) will pause and show approval buttons in the UI automatically.',
'',
'You do NOT need to ask the user for confirmation in your text responses. Just call the tool directly — the approval system handles it. Read-only operations are allowed without any approval.',
].join('\n');
case 'auto':
return [
'You are in **auto** mode. You may execute commands and write files without explicit per-action approval, as long as they are not on the blocklist.',
'',
'Even in auto mode:',
'- Always present a plan for multi-step tasks before starting.',
'- Blocked commands are still denied regardless of mode.',
'- Exercise caution with destructive or irreversible operations.',
].join('\n');
}
}

View File

@@ -0,0 +1,136 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
clearProviderModelCatalogCache,
fetchProviderModelCatalog,
providerModelCacheKey,
resolveProviderDiscoveryBaseURL,
seedProviderModelCatalog,
} from './cattyProviderModels';
import type { ProviderConfig } from './types';
const provider: ProviderConfig = {
id: 'p1',
providerId: 'deepseek',
name: 'DeepSeek',
defaultModel: 'deepseek-chat',
baseURL: 'https://api.deepseek.com/v1',
apiKey: 'test-key',
enabled: true,
};
test('resolveProviderDiscoveryBaseURL falls back to the built-in preset host', () => {
assert.equal(
resolveProviderDiscoveryBaseURL({
id: 'openai-1',
providerId: 'openai',
name: 'OpenAI',
enabled: true,
}),
'https://api.openai.com/v1',
);
assert.equal(
resolveProviderDiscoveryBaseURL({
id: 'ollama-1',
providerId: 'ollama',
name: 'Ollama',
enabled: true,
baseURL: 'https://ollama.com',
}),
'https://ollama.com/v1',
);
});
test('fetchProviderModelCatalog discovers models when baseURL is omitted', async () => {
clearProviderModelCatalogCache();
const requested: string[] = [];
const catalog = await fetchProviderModelCatalog(
{
id: 'openai-legacy',
providerId: 'openai',
name: 'OpenAI',
defaultModel: 'gpt-4o',
apiKey: 'sk-test',
enabled: true,
},
{
aiFetch: async (url) => {
requested.push(url);
return {
ok: true,
data: JSON.stringify({ data: [{ id: 'gpt-4o' }, { id: 'gpt-5.5', context_length: 200000 }] }),
};
},
},
);
assert.deepEqual(requested, ['https://api.openai.com/v1/models']);
assert.equal(catalog.fetched, true);
assert.ok(catalog.models.some((model) => model.id === 'gpt-5.5'));
assert.equal(catalog.models.find((model) => model.id === 'gpt-5.5')?.contextWindow, 200000);
});
test('providerModelCacheKey changes when the stored API key changes', () => {
const base = { ...provider };
const before = providerModelCacheKey({ ...base, apiKey: 'enc-old' });
const after = providerModelCacheKey({ ...base, apiKey: 'enc-new' });
const empty = providerModelCacheKey({ ...base, apiKey: undefined });
assert.notEqual(before, after);
assert.notEqual(before, empty);
});
test('seedProviderModelCatalog includes the default and curated models', () => {
const seed = seedProviderModelCatalog(provider);
assert.equal(seed.fetched, false);
assert.ok(seed.models.some((model) => model.id === 'deepseek-chat'));
assert.ok(seed.models.some((model) => model.id === 'deepseek-v4-pro'));
});
test('fetchProviderModelCatalog merges discovered models and caches them', async () => {
clearProviderModelCatalogCache();
const catalog = await fetchProviderModelCatalog(provider, {
aiFetch: async () => ({
ok: true,
data: JSON.stringify({ data: [{ id: 'deepseek-reasoner', name: 'Reasoner' }] }),
}),
});
assert.equal(catalog.fetched, true);
assert.ok(catalog.models.some((model) => model.id === 'deepseek-reasoner'));
assert.ok(catalog.models.some((model) => model.id === 'deepseek-chat'));
const cached = await fetchProviderModelCatalog(provider, {
aiFetch: async () => {
throw new Error('should not refetch');
},
});
assert.equal(cached.fetched, true);
assert.ok(cached.models.some((model) => model.id === 'deepseek-reasoner'));
});
test('fetchProviderModelCatalog appends /v1 when listing Ollama Cloud from a bare origin', async () => {
clearProviderModelCatalogCache();
const requested: string[] = [];
const catalog = await fetchProviderModelCatalog(
{
id: 'ollama-cloud',
providerId: 'ollama',
name: 'Ollama',
defaultModel: 'deepseek-v4-flash:0731',
baseURL: 'https://ollama.com',
apiKey: 'cloud-key',
enabled: true,
},
{
aiFetch: async (url) => {
requested.push(url);
return {
ok: true,
data: JSON.stringify({ data: [{ id: 'deepseek-v4-flash:0731' }] }),
};
},
},
);
assert.deepEqual(requested, ['https://ollama.com/v1/models']);
assert.equal(catalog.fetched, true);
assert.ok(catalog.models.some((model) => model.id === 'deepseek-v4-flash:0731'));
});

View File

@@ -0,0 +1,169 @@
import { decryptField } from '../persistence/secureFieldAdapter';
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from './modelDiscoveryHeaders';
import { normalizeOllamaSdkBaseURL } from './ollamaCompatBaseUrl';
import { buildProviderProbeUrl } from './providerConnectionProbe';
import { sanitizeContextWindow } from './contextCompaction';
import { PROVIDER_PRESETS, resolveProviderStyle, type ProviderConfig } from './types';
import {
buildProviderSeedModels,
mergeComposerModels,
type ComposerPickerModel,
} from './composerPicker';
export interface ProviderModelCatalog {
models: ComposerPickerModel[];
fetched: boolean;
error?: string;
}
type FetchBridge = {
aiFetch?: (
url: string,
method?: string,
headers?: Record<string, string>,
body?: string,
providerId?: string,
skipHostCheck?: boolean,
followRedirects?: boolean,
skipTLSVerify?: boolean,
) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
};
const catalogCache = new Map<string, { models: ComposerPickerModel[]; expiresAt: number }>();
const CATALOG_TTL_MS = 5 * 60 * 1000;
/** Length + FNV-1a of the stored secret so a key rotation busts the catalog cache. */
function credentialFingerprint(value: string | undefined): string {
const raw = String(value || '');
if (!raw) return '0';
let hash = 2166136261;
for (let i = 0; i < raw.length; i += 1) {
hash ^= raw.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return `${raw.length}:${(hash >>> 0).toString(16)}`;
}
export function readCachedProviderModelCatalog(
provider: ProviderConfig,
): ComposerPickerModel[] | null {
const cached = catalogCache.get(providerModelCacheKey(provider));
if (!cached || cached.expiresAt <= Date.now()) return null;
return cached.models;
}
export function providerModelCacheKey(provider: ProviderConfig): string {
return [
provider.id,
provider.providerId,
provider.style ?? '',
provider.baseURL ?? '',
provider.skipTLSVerify ? '1' : '0',
credentialFingerprint(provider.apiKey),
].join('|');
}
export function resolveProviderDiscoveryBaseURL(provider: ProviderConfig): string {
const raw = provider.baseURL || PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL || '';
if (!raw) return '';
return provider.providerId === 'ollama' ? normalizeOllamaSdkBaseURL(raw) : raw;
}
export function seedProviderModelCatalog(provider: ProviderConfig): ProviderModelCatalog {
return {
models: buildProviderSeedModels(provider),
fetched: false,
};
}
export function clearProviderModelCatalogCache(): void {
catalogCache.clear();
}
function parseDiscoveredModels(parsed: unknown): ComposerPickerModel[] {
const record = parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
const rawModels = Array.isArray(record.data)
? record.data
: Array.isArray(record.models)
? record.models
: [];
return rawModels
.map((raw): ComposerPickerModel | null => {
if (!raw || typeof raw !== 'object') return null;
const model = raw as Record<string, unknown>;
if (typeof model.id !== 'string' || !model.id) return null;
const contextWindow = sanitizeContextWindow(
model.context_length
?? model.context_window
?? model.contextWindow
?? model.context
?? model.max_context_tokens,
);
return {
id: model.id,
name: typeof model.name === 'string' && model.name ? model.name : model.id,
...(contextWindow != null ? { contextWindow } : {}),
};
})
.filter((model): model is ComposerPickerModel => model != null)
.sort((a, b) => a.name.localeCompare(b.name));
}
export async function fetchProviderModelCatalog(
provider: ProviderConfig,
bridge: FetchBridge | undefined,
): Promise<ProviderModelCatalog> {
const seed = seedProviderModelCatalog(provider);
const cacheKey = providerModelCacheKey(provider);
const cached = catalogCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return {
models: mergeComposerModels(seed.models, cached.models),
fetched: true,
};
}
const style = resolveProviderStyle(provider);
const endpoint = resolveModelsDiscoveryEndpoint(style, undefined);
const baseURL = resolveProviderDiscoveryBaseURL(provider);
if (!endpoint || !baseURL || !bridge?.aiFetch) {
return seed;
}
try {
const apiKey = await decryptField(provider.apiKey);
if (provider.providerId !== 'ollama' && !apiKey) {
return seed;
}
if (bridge.aiAllowlistAddHost) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, endpoint);
const headers = buildModelDiscoveryHeaders(style, apiKey);
const result = await bridge.aiFetch(
url,
'GET',
headers,
undefined,
undefined,
undefined,
undefined,
provider.skipTLSVerify,
);
if (!result.ok) {
return { ...seed, error: result.error || 'Failed to fetch models' };
}
const fetched = parseDiscoveredModels(JSON.parse(result.data) as unknown);
catalogCache.set(cacheKey, { models: fetched, expiresAt: Date.now() + CATALOG_TTL_MS });
return {
models: mergeComposerModels(seed.models, fetched),
fetched: true,
};
} catch (error) {
return {
...seed,
error: error instanceof Error ? error.message : 'Failed to fetch models',
};
}
}

View File

@@ -0,0 +1,339 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyResponsesApiStatelessStoreOption,
buildCattyReasoningProviderOptions,
cattyReasoningLevelsForSelection,
estimateReasoningOutputReserve,
openaiModelLikelySupportsReasoning,
openaiModelSupportsNoneReasoning,
resolveVisibleCattyThinkingLevel,
} from './cattyReasoning';
test('buildCattyReasoningProviderOptions is omitted when effort is off', () => {
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'off'),
undefined,
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, undefined),
undefined,
);
});
test('buildCattyReasoningProviderOptions maps OpenAI-compatible effort', () => {
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'deepseek' }, 'high'),
{ openai: { reasoningEffort: 'high' } },
);
});
test('buildCattyReasoningProviderOptions omits reasoningEffort for non-reasoning OpenAI models', () => {
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'high', 'gpt-4o'),
undefined,
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'high', 'gpt-5.5'),
{ openai: { reasoningEffort: 'high' } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'off', 'o3-mini'),
{ openai: { reasoningEffort: 'low' } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'off', 'gpt-5'),
{ openai: { reasoningEffort: 'minimal' } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'off', 'gpt-5.5'),
{ openai: { reasoningEffort: 'none' } },
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, undefined, 'gpt-5.5'),
undefined,
);
assert.equal(openaiModelSupportsNoneReasoning('o4-mini'), false);
assert.equal(openaiModelSupportsNoneReasoning('gpt-5.1-codex'), true);
assert.equal(openaiModelSupportsNoneReasoning('gpt-5.1-chat-latest'), false);
});
test('cattyReasoningLevelsForSelection hides the chip unless the model can take effort', () => {
assert.equal(openaiModelLikelySupportsReasoning('gpt-4o'), false);
assert.equal(openaiModelLikelySupportsReasoning('gpt-5-chat-latest'), false);
assert.equal(openaiModelLikelySupportsReasoning('gpt-5.1-chat-latest'), false);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'high', 'gpt-5-chat-latest'),
undefined,
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'off', 'gpt-5.1-chat-latest'),
undefined,
);
assert.equal(openaiModelLikelySupportsReasoning('gpt-5.5'), true);
assert.deepEqual(cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-4o'), []);
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.5').includes('high'));
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.5').includes('off'));
assert.equal(
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.5'),
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.6'),
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5'),
['minimal', 'low', 'medium', 'high'],
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'o3-mini'),
['low', 'medium', 'high'],
);
assert.deepEqual(cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-1.5-flash'), []);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3-flash'),
['minimal', 'low', 'medium', 'high'],
);
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3-flash').includes('minimal'));
assert.ok(!cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3-flash').includes('off'));
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3.7-flash'),
['low', 'medium', 'high'],
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3.1-flash-lite-image'),
['minimal', 'high'],
);
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-opus-4-6').includes('high'));
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-sonnet-5').includes('high'));
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-fable-5'),
['low', 'medium', 'high'],
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-3-haiku-20240307'),
[],
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'high', 'claude-3-haiku-20240307'),
undefined,
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3-pro'),
['low', 'high'],
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-3.1-pro-preview'),
['low', 'medium', 'high'],
);
assert.deepEqual(
cattyReasoningLevelsForSelection({ providerId: 'google' }, 'gemini-2.5-pro'),
['low', 'medium', 'high'],
);
});
test('resolveVisibleCattyThinkingLevel drops stale levels after a model switch', () => {
assert.equal(
resolveVisibleCattyThinkingLevel(['low', 'medium', 'high'], 'minimal'),
'low',
);
assert.equal(
resolveVisibleCattyThinkingLevel(['minimal', 'low', 'medium', 'high'], 'off'),
'minimal',
);
assert.equal(
resolveVisibleCattyThinkingLevel(['off', 'low', 'medium', 'high'], 'high'),
'high',
);
assert.equal(
resolveVisibleCattyThinkingLevel(['low', 'high'], 'medium'),
'high',
);
assert.equal(
resolveVisibleCattyThinkingLevel(['low', 'medium', 'high'], 'off'),
'low',
);
});
test('estimateReasoningOutputReserve folds thinking budgets into the output reserve', () => {
assert.equal(estimateReasoningOutputReserve(undefined), 0);
assert.equal(
estimateReasoningOutputReserve(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'high', 'claude-sonnet-4-5'),
),
20_000,
);
assert.equal(
estimateReasoningOutputReserve(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'medium', 'claude-3-7-sonnet-20250219'),
),
10_000,
);
assert.equal(
estimateReasoningOutputReserve(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'high', 'claude-opus-4-6'),
),
0,
);
assert.equal(
estimateReasoningOutputReserve(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'high', 'gemini-2.5-pro'),
),
16_384,
);
assert.equal(
estimateReasoningOutputReserve(
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'high', 'gpt-5.5'),
),
0,
);
});
test('buildCattyReasoningProviderOptions maps Anthropic thinking budgets', () => {
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'medium'),
{ anthropic: { thinking: { type: 'enabled', budgetTokens: 10_000 } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'medium', 'claude-sonnet-4-5'),
{ anthropic: { thinking: { type: 'enabled', budgetTokens: 10_000 } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'high', 'claude-opus-4-20250514'),
{ anthropic: { thinking: { type: 'enabled', budgetTokens: 20_000 } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'high', 'claude-opus-4-6'),
{ anthropic: { thinking: { type: 'adaptive' }, effort: 'high' } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'low', 'claude-sonnet-5'),
{ anthropic: { thinking: { type: 'adaptive' }, effort: 'low' } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'off', 'claude-sonnet-5'),
{ anthropic: { thinking: { type: 'disabled' } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'off', 'claude-fable-5'),
{ anthropic: { thinking: { type: 'adaptive' }, effort: 'low' } },
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'off', 'claude-sonnet-4-5'),
undefined,
);
});
test('buildCattyReasoningProviderOptions respects an explicit style override', () => {
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'custom', style: 'openai' }, 'low'),
{ openai: { reasoningEffort: 'low' } },
);
});
test('buildCattyReasoningProviderOptions maps Gemini thinking levels', () => {
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'high', 'gemini-3-pro'),
{ google: { thinkingConfig: { thinkingLevel: 'high', includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'off', 'gemini-3-pro'),
{ google: { thinkingConfig: { thinkingLevel: 'low', includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'medium', 'gemini-3-pro'),
{ google: { thinkingConfig: { thinkingLevel: 'high', includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'medium', 'gemini-3.1-pro-preview'),
{ google: { thinkingConfig: { thinkingLevel: 'medium', includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'off', 'gemini-3-flash'),
{ google: { thinkingConfig: { thinkingLevel: 'minimal', includeThoughts: false } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'minimal', 'gemini-3.7-flash'),
{ google: { thinkingConfig: { thinkingLevel: 'low', includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'off', 'gemini-3.7-flash'),
{ google: { thinkingConfig: { thinkingLevel: 'low', includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'low', 'gemini-3.1-flash-lite-image'),
{ google: { thinkingConfig: { thinkingLevel: 'high', includeThoughts: true } } },
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'google' }, undefined, 'gemini-3-flash'),
undefined,
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'high', 'gemini-2.5-pro'),
{ google: { thinkingConfig: { thinkingBudget: 16_384, includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'off', 'gemini-2.5-pro'),
{ google: { thinkingConfig: { thinkingBudget: 1_024, includeThoughts: true } } },
);
assert.deepEqual(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'off', 'gemini-2.5-flash'),
{ google: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'google' }, undefined, 'gemini-2.5-flash'),
undefined,
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'high'),
undefined,
);
assert.equal(
buildCattyReasoningProviderOptions({ providerId: 'google' }, 'high', 'gemini-1.5-flash'),
undefined,
);
});
test('applyResponsesApiStatelessStoreOption sets store:false for OpenAI Responses providers', () => {
const responsesProvider = { providerId: 'openai' as const, openaiApi: 'responses' as const };
assert.deepEqual(
applyResponsesApiStatelessStoreOption(responsesProvider, { openai: { reasoningEffort: 'high' } }),
{ openai: { reasoningEffort: 'high', store: false, include: ['reasoning.encrypted_content'] } },
);
// Even with no reasoning options, Responses turns stay stateless
// and request replayable (encrypted) reasoning.
assert.deepEqual(
applyResponsesApiStatelessStoreOption(responsesProvider, undefined),
{ openai: { store: false, include: ['reasoning.encrypted_content'] } },
);
});
test('applyResponsesApiStatelessStoreOption is a no-op for Chat Completions and other styles', () => {
assert.deepEqual(
applyResponsesApiStatelessStoreOption({ providerId: 'openai' as const }, { openai: { reasoningEffort: 'high' } }),
{ openai: { reasoningEffort: 'high' } },
);
const chatProvider = { providerId: 'openai' as const, openaiApi: 'chat' as const };
assert.deepEqual(
applyResponsesApiStatelessStoreOption(chatProvider, { openai: { reasoningEffort: 'low' } }),
{ openai: { reasoningEffort: 'low' } },
);
const anthropicProvider = { providerId: 'openai' as const, openaiApi: 'responses' as const, style: 'anthropic' as const };
assert.deepEqual(
applyResponsesApiStatelessStoreOption(anthropicProvider, { anthropic: { thinking: { type: 'adaptive' } } }),
{ anthropic: { thinking: { type: 'adaptive' } } },
);
assert.equal(applyResponsesApiStatelessStoreOption(undefined, undefined), undefined);
});
test('applyResponsesApiStatelessStoreOption always requests encrypted reasoning on stateless Responses turns', () => {
const responsesProvider = { providerId: 'custom' as const, openaiApi: 'responses' as const };
// Every stateless Responses turn gets the include, covering known reasoner
// IDs (`deepseek-r1`, `gpt-oss-120b`, `grok-4`, `o3`, `gpt-5.1`) as before,
// always-thinking relay models whose IDs match no classifier (e.g.
// DeepSeek's default `deepseek-v4-flash`), and plain chat models — for the
// latter the include is a no-op (no reasoning items to encrypt).
assert.deepEqual(
applyResponsesApiStatelessStoreOption(responsesProvider, undefined),
{ openai: { store: false, include: ['reasoning.encrypted_content'] } },
'deepseek-v4-flash',
);
});

View File

@@ -0,0 +1,358 @@
import type { ProviderStyle } from './types';
import { resolveOpenAIApi, resolveProviderStyle, type ProviderConfig } from './types';
import { CATTY_REASONING_LEVELS } from './composerPicker';
const ANTHROPIC_THINKING_BUDGET: Record<'low' | 'medium' | 'high', number> = {
low: 4_000,
medium: 10_000,
high: 20_000,
};
const GEMINI_25_THINKING_BUDGET: Record<'low' | 'medium' | 'high', number> = {
low: 1_024,
medium: 8_192,
high: 16_384,
};
const REASONING_RANK = ['off', 'minimal', 'low', 'medium', 'high'] as const;
const LEVELS_LOW_MEDIUM_HIGH = ['low', 'medium', 'high'] as const;
const LEVELS_LOW_HIGH = ['low', 'high'] as const;
const LEVELS_MINIMAL_LOW_MEDIUM_HIGH = ['minimal', 'low', 'medium', 'high'] as const;
const LEVELS_MINIMAL_HIGH = ['minimal', 'high'] as const;
export type CattyReasoningProviderOptions = Record<string, Record<string, unknown>>;
/** Extra completion tokens the SDK will add on top of maxTokens for thinking. */
export function estimateReasoningOutputReserve(
options: CattyReasoningProviderOptions | undefined,
): number {
if (!options) return 0;
const anthropicThinking = options.anthropic?.thinking as { budgetTokens?: unknown } | undefined;
if (typeof anthropicThinking?.budgetTokens === 'number' && anthropicThinking.budgetTokens > 0) {
return Math.ceil(anthropicThinking.budgetTokens);
}
const googleConfig = options.google?.thinkingConfig as { thinkingBudget?: unknown } | undefined;
if (typeof googleConfig?.thinkingBudget === 'number' && googleConfig.thinkingBudget > 0) {
return Math.ceil(googleConfig.thinkingBudget);
}
return 0;
}
/**
* OpenAI Responses turns must run stateless (`store: false`).
*
* The Responses API defaults to `store: true`, which makes the SDK replay
* prior reasoning turns as server-side item references (`rs_…` ids).
* Relays — the main reason Responses mode is opt-in here — frequently do not
* persist items, so the next turn fails with "Item with id 'rs_…' not found.
* Items are not persisted when `store` is set to false." With `store: false`
* the SDK instead requests `reasoning.encrypted_content` and replays the full
* reasoning items inline, which works against both relays and OpenAI directly.
*
* The installed `@ai-sdk/openai` only auto-adds the encrypted-content include
* for model IDs its own capability detector recognizes (o-series / gpt-5).
* Relay reasoners such as `deepseek-r1`, `gpt-oss`, or `grok-4` pass
* Netcatty's broader classifier but not the SDK's, so the include must be
* requested explicitly or the provider returns no replayable ciphertext and
* the reasoning items get dropped from subsequent turns. The SDK dedupes the
* include when it would have added it anyway.
*
* The include is therefore requested for *every* stateless Responses turn,
* not gated on a reasoning classifier: relay model IDs are arbitrary, and
* always-thinking models whose IDs match no known pattern (e.g. DeepSeek's
* default `deepseek-v4-flash`, which streams thinking deltas) would silently
* lose replayable reasoning. Models that emit no reasoning simply have no
* reasoning items to encrypt, so the extra include is a no-op for them.
*/
const REASONING_ENCRYPTED_CONTENT_INCLUDE = 'reasoning.encrypted_content';
export function applyResponsesApiStatelessStoreOption(
provider: Pick<ProviderConfig, 'openaiApi' | 'providerId' | 'style'> | null | undefined,
options: CattyReasoningProviderOptions | undefined,
): CattyReasoningProviderOptions | undefined {
if (!provider || resolveProviderStyle(provider) !== 'openai') return options;
if (resolveOpenAIApi(provider) !== 'responses') return options;
const openaiOptions: Record<string, unknown> = {
...options?.openai,
store: false,
};
const existingInclude = Array.isArray(openaiOptions.include) ? openaiOptions.include : [];
if (!existingInclude.includes(REASONING_ENCRYPTED_CONTENT_INCLUDE)) {
openaiOptions.include = [...existingInclude, REASONING_ENCRYPTED_CONTENT_INCLUDE];
}
return {
...options,
openai: openaiOptions,
};
}
export function buildCattyReasoningProviderOptions(
provider: Pick<ProviderConfig, 'providerId' | 'style'> | null | undefined,
effort: string | null | undefined,
modelId?: string,
): CattyReasoningProviderOptions | undefined {
if (!provider) return undefined;
const rawEffort = typeof effort === 'string' ? effort.trim() : '';
if (!rawEffort) return undefined;
const style: ProviderStyle = resolveProviderStyle(provider);
const advertised = cattyReasoningLevelsForSelection(provider, modelId);
const resolved = advertised.length
? resolveVisibleCattyThinkingLevel(advertised, rawEffort)
: rawEffort;
if (!resolved) return undefined;
if (style === 'openai') {
if (modelId && !openaiModelLikelySupportsReasoning(modelId)) return undefined;
if (resolved === 'off') {
if (modelId && openaiModelSupportsNoneReasoning(modelId)) {
return { openai: { reasoningEffort: 'none' } };
}
return undefined;
}
return { openai: { reasoningEffort: resolved } };
}
if (style === 'anthropic') {
if (modelId && !anthropicModelLikelySupportsThinking(modelId)) return undefined;
if (resolved === 'off') {
if (modelId && anthropicUsesAdaptiveThinking(modelId) && anthropicAllowsDisabledThinking(modelId)) {
return { anthropic: { thinking: { type: 'disabled' } } };
}
return undefined;
}
if (resolved !== 'low' && resolved !== 'medium' && resolved !== 'high') return undefined;
if (modelId && anthropicUsesAdaptiveThinking(modelId)) {
return {
anthropic: {
thinking: { type: 'adaptive' },
effort: resolved,
},
};
}
return {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: ANTHROPIC_THINKING_BUDGET[resolved],
},
},
};
}
if (style === 'google') {
if (!modelId || !googleModelLikelySupportsThinking(modelId)) return undefined;
if (isGemini3Model(modelId)) {
if (resolved !== 'minimal' && resolved !== 'low' && resolved !== 'medium' && resolved !== 'high') {
return undefined;
}
return {
google: {
thinkingConfig: {
thinkingLevel: resolved,
includeThoughts: resolved !== 'minimal',
},
},
};
}
if (resolved === 'off') {
if (!googleModelAllowsDisabledThinking(modelId)) return undefined;
return {
google: {
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
},
},
};
}
if (resolved !== 'low' && resolved !== 'medium' && resolved !== 'high') return undefined;
return {
google: {
thinkingConfig: {
thinkingBudget: GEMINI_25_THINKING_BUDGET[resolved],
includeThoughts: true,
},
},
};
}
return undefined;
}
/** Extended thinking is Claude 3.7+ / 4+ / 5+; original Claude 3 Haiku/Sonnet reject it. */
export function anthropicModelLikelySupportsThinking(modelId: string): boolean {
const parsed = parseClaudeModel(modelId);
if (!parsed) return false;
if (parsed.major >= 4) return true;
return parsed.major === 3 && parsed.minor >= 7;
}
export function googleModelLikelySupportsThinking(modelId: string): boolean {
const id = modelId.trim().toLowerCase();
return /gemini-3|gemini-2\.5|gemini-2\.0-flash-thinking|thinking/.test(id);
}
/**
* `reasoning_effort: "none"` is only valid on GPT-5.1+ (and later minors).
* Bare gpt-5 / o3 / o4-mini accept low|medium|high (and sometimes minimal),
* but reject none. Chat snapshots are not reasoners.
*/
export function openaiModelSupportsNoneReasoning(modelId: string): boolean {
const id = modelId.trim().toLowerCase();
if (!id || openaiModelIsChatSnapshot(id)) return false;
return /gpt-5\.(?:[1-9]\d*)/.test(id);
}
/** Original GPT-5 (not 5.1+) accepts `minimal` as the floor instead of `none`. */
export function openaiModelSupportsMinimalReasoning(modelId: string): boolean {
const id = modelId.trim().toLowerCase();
if (!id || openaiModelIsChatSnapshot(id)) return false;
if (openaiModelSupportsNoneReasoning(id)) return false;
return /gpt-5/.test(id);
}
/** OpenAI-compat models that accept `reasoning_effort` (o-series, GPT-5, reasoners). */
export function openaiModelLikelySupportsReasoning(modelId: string): boolean {
const id = modelId.trim().toLowerCase();
if (!id) return false;
if (openaiModelIsChatSnapshot(id)) return false;
return (
/(^|[^a-z0-9])o[1-4]([^a-z0-9]|$)/.test(id)
|| /gpt-5/.test(id)
|| /gpt-oss/.test(id)
|| /reasoner|reasoning/.test(id)
|| /deepseek-r1/.test(id)
|| /grok-4/.test(id)
);
}
/** Levels shown on the Catty thinking chip, or empty when the model cannot take them. */
export function cattyReasoningLevelsForSelection(
provider: Pick<ProviderConfig, 'providerId' | 'style'> | null | undefined,
modelId?: string,
): readonly string[] {
if (!provider) return [];
const style = resolveProviderStyle(provider);
if (style === 'anthropic') {
if (!modelId || !anthropicModelLikelySupportsThinking(modelId)) return [];
if (!anthropicAllowsDisabledThinking(modelId)) return LEVELS_LOW_MEDIUM_HIGH;
return CATTY_REASONING_LEVELS;
}
if (style === 'google') {
if (!modelId || !googleModelLikelySupportsThinking(modelId)) return [];
if (isGemini3Model(modelId)) return gemini3AdvertisedLevels(modelId);
if (!googleModelAllowsDisabledThinking(modelId)) return LEVELS_LOW_MEDIUM_HIGH;
return CATTY_REASONING_LEVELS;
}
if (style === 'openai') {
if (!modelId || !openaiModelLikelySupportsReasoning(modelId)) return [];
if (openaiModelSupportsNoneReasoning(modelId)) return CATTY_REASONING_LEVELS;
if (openaiModelSupportsMinimalReasoning(modelId)) return LEVELS_MINIMAL_LOW_MEDIUM_HIGH;
return LEVELS_LOW_MEDIUM_HIGH;
}
return [];
}
/**
* Pick a level the current model actually advertises; never keep a stale chip value.
* Missing mid-levels prefer the next higher advertised value (Gemini 3 Pro medium → high)
* so the chip, persisted pref, and request payload stay aligned.
*/
export function resolveVisibleCattyThinkingLevel(
levels: readonly string[],
selected: string | undefined,
): string | undefined {
if (!levels.length) return undefined;
const raw = selected?.trim().toLowerCase();
if (raw && levels.includes(raw)) return raw;
if (raw === 'off') {
if (levels.includes('minimal')) return 'minimal';
return levels[0];
}
const selectedRank = raw ? REASONING_RANK.indexOf(raw as typeof REASONING_RANK[number]) : -1;
if (selectedRank < 0) return levels[0];
for (let i = selectedRank + 1; i < REASONING_RANK.length; i += 1) {
if (levels.includes(REASONING_RANK[i])) return REASONING_RANK[i];
}
for (let i = selectedRank - 1; i >= 0; i -= 1) {
if (levels.includes(REASONING_RANK[i])) return REASONING_RANK[i];
}
return levels[0];
}
function openaiModelIsChatSnapshot(modelId: string): boolean {
return /gpt-5(?:\.\d+)?-chat/.test(modelId);
}
function isGemini3Model(modelId: string): boolean {
return modelId.trim().toLowerCase().includes('gemini-3');
}
function gemini3AdvertisedLevels(modelId: string): readonly string[] {
const id = modelId.trim().toLowerCase();
if (/flash-lite-image/.test(id)) return LEVELS_MINIMAL_HIGH;
if (/gemini-3\.7/.test(id) && /flash/.test(id)) return LEVELS_LOW_MEDIUM_HIGH;
if (/pro/.test(id) && !/flash/.test(id)) {
return /gemini-3\.1/.test(id) ? LEVELS_LOW_MEDIUM_HIGH : LEVELS_LOW_HIGH;
}
if (/flash/.test(id)) return LEVELS_MINIMAL_LOW_MEDIUM_HIGH;
return LEVELS_LOW_MEDIUM_HIGH;
}
function googleModelAllowsDisabledThinking(modelId: string): boolean {
const id = modelId.trim().toLowerCase();
return /flash-lite|flash/.test(id) && !/pro/.test(id);
}
type ClaudeModel = {
family: string;
major: number;
minor: number;
};
function parseClaudeMinor(raw: string | undefined): number {
if (!raw) return 0;
const minor = Number.parseInt(raw, 10);
// Date suffixes like 20250514 are not minor versions.
return Number.isFinite(minor) && minor < 100 ? minor : 0;
}
function parseClaudeModel(modelId: string): ClaudeModel | null {
const id = modelId.trim().toLowerCase();
if (!id) return null;
const familyMatch = id.match(
/claude-(opus|sonnet|haiku|fable|mythos)(?:-preview)?(?:[-.](\d+)(?:[-.](\d+))?)?/,
);
if (familyMatch) {
return {
family: familyMatch[1],
major: familyMatch[2] ? Number.parseInt(familyMatch[2], 10) : 5,
minor: parseClaudeMinor(familyMatch[3]),
};
}
const threeSeven = id.match(/claude-3[-.]7/);
if (threeSeven) return { family: 'sonnet', major: 3, minor: 7 };
const bare = id.match(/claude-(\d+)(?:[-.](\d+))?/);
if (bare) {
return {
family: 'claude',
major: Number.parseInt(bare[1], 10),
minor: parseClaudeMinor(bare[2]),
};
}
return null;
}
/** 4.6+ and Claude 5 reject or deprecate manual `type: "enabled"`. */
function anthropicUsesAdaptiveThinking(modelId: string): boolean {
const parsed = parseClaudeModel(modelId);
if (!parsed) return false;
if (parsed.major >= 5) return true;
return parsed.major === 4 && parsed.minor >= 6;
}
/** Fable 5 / Mythos 5 are always-on and reject `thinking.type: "disabled"`. */
function anthropicAllowsDisabledThinking(modelId: string): boolean {
const parsed = parseClaudeModel(modelId);
if (!parsed) return true;
return parsed.family !== 'fable' && parsed.family !== 'mythos';
}

View File

@@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from "./cattyRequestTooLargeRetry.ts";
test("createCattyRequestTooLargeRetryError marks 413 retry errors after tool progress", () => {
const source = Object.assign(new Error("HTTP 413 Request Entity Too Large"), {
status: 413,
responseBody: "<html>too large</html>",
});
const retryError = createCattyRequestTooLargeRetryError(source, true);
assert.equal(retryError.statusCode, 413);
assert.equal(retryError.status, 413);
assert.equal(retryError.responseBody, "<html>too large</html>");
assert.equal(retryError.cause, source);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
});
test("hadToolProgressBeforeRequestTooLarge is false when no tool progress was recorded", () => {
const retryError = createCattyRequestTooLargeRetryError("HTTP 413", false);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
assert.equal(hadToolProgressBeforeRequestTooLarge(new Error("HTTP 413")), false);
});

View File

@@ -0,0 +1,34 @@
export type CattyRequestTooLargeRetryError = Error & {
cattyHadToolProgress?: boolean;
statusCode?: number;
status?: number;
responseBody?: string;
};
export function createCattyRequestTooLargeRetryError(
error: unknown,
hadToolProgress: boolean,
): CattyRequestTooLargeRetryError {
const message = error instanceof Error
? error.message
: String(error ?? 'Request too large');
const retryError = new Error(message) as CattyRequestTooLargeRetryError;
retryError.name = 'CattyRequestTooLargeRetryError';
retryError.cause = error;
retryError.cattyHadToolProgress = hadToolProgress;
retryError.statusCode = 413;
if (error && typeof error === 'object') {
const source = error as Record<string, unknown>;
if (typeof source.status === 'number') retryError.status = source.status;
if (typeof source.responseBody === 'string') retryError.responseBody = source.responseBody;
}
return retryError;
}
export function hadToolProgressBeforeRequestTooLarge(error: unknown): boolean {
return !!(
error &&
typeof error === 'object' &&
(error as { cattyHadToolProgress?: boolean }).cattyHadToolProgress
);
}

View File

@@ -0,0 +1,162 @@
import { createRequire } from "node:module";
import assert from "node:assert/strict";
import test from "node:test";
import { checkCommandSafety, checkCommandSafetyCommonOnly } from "./cattyAgent/safety";
import { DEFAULT_COMMAND_BLOCKLIST } from "./types";
const require = createRequire(import.meta.url);
const blocklistTable = require("../../lib/commandBlocklist.json") as {
common: string[];
posixNative: string[];
posix: string[];
powershell: string[];
};
const cjsBlocklist = require("../../lib/commandBlocklist.cjs");
const flatTable = [
...blocklistTable.common,
...blocklistTable.posixNative,
...blocklistTable.posix,
...blocklistTable.powershell,
];
test("AI command blocklist uses the shared JSON source", () => {
assert.deepEqual(DEFAULT_COMMAND_BLOCKLIST, flatTable);
assert.deepEqual(Array.from(cjsBlocklist.DEFAULT_COMMAND_BLOCKLIST), flatTable);
assert.deepEqual(
[
...cjsBlocklist.COMMON_PATTERNS,
...cjsBlocklist.POSIX_NATIVE_PATTERNS,
...cjsBlocklist.POSIX_PATTERNS,
...cjsBlocklist.POWERSHELL_PATTERNS,
],
flatTable,
);
});
test("shared default command blocklist covers bypass-style shell execution", () => {
assert.equal(checkCommandSafety("rm -rf /").blocked, true);
assert.equal(checkCommandSafety("rm -r -f /tmp/cache").blocked, true);
assert.equal(checkCommandSafety("rm --recursive --force /tmp/cache").blocked, true);
assert.equal(checkCommandSafety("echo ZWNobyBoaQ== | base64 -d | bash").blocked, true);
assert.equal(checkCommandSafety("eval $payload").blocked, true);
assert.equal(checkCommandSafety("echo $(whoami)").blocked, true);
});
test("default command blocklist reports the pattern that matched", () => {
const result = checkCommandSafety("mkfs.ext4 /dev/sda");
assert.equal(result.blocked, true);
assert.equal(result.matchedPattern, "\\bmkfs\\.");
});
test("unknown shell kinds keep the strict full default table", () => {
assert.equal(checkCommandSafety("echo $(whoami)", DEFAULT_COMMAND_BLOCKLIST, "").blocked, true);
assert.equal(checkCommandSafety("echo $(whoami)", DEFAULT_COMMAND_BLOCKLIST, undefined).blocked, true);
assert.equal(checkCommandSafety("echo $(whoami)", DEFAULT_COMMAND_BLOCKLIST, "unknown").blocked, true);
});
test("posix shell kinds keep the POSIX command-substitution rules", () => {
for (const shellKind of ["posix", "fish"]) {
assert.equal(checkCommandSafety("echo $(whoami)", DEFAULT_COMMAND_BLOCKLIST, shellKind).blocked, true);
assert.equal(checkCommandSafety("echo `whoami`", DEFAULT_COMMAND_BLOCKLIST, shellKind).blocked, true);
assert.equal(checkCommandSafety("rm -rf /", DEFAULT_COMMAND_BLOCKLIST, shellKind).blocked, true);
}
});
test("powershell sessions allow command substitution but keep common guards", () => {
assert.equal(checkCommandSafety('Write-Host "now: $(Get-Date)"', DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked, false);
assert.equal(checkCommandSafety("Write-Host 'a`tb'", DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked, false);
assert.equal(checkCommandSafety("Get-ChildItem $(Join-Path $env:USERPROFILE docs)", DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked, false);
assert.equal(checkCommandSafety("rm -Recurse -Force C:\\temp", DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked, true);
assert.equal(checkCommandSafety("shutdown /r /t 0", DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked, true);
});
test("powershell sessions gain PowerShell-specific dangerous command rules", () => {
for (const command of [
"Remove-Item -Recurse -Force C:\\important",
"Remove-Item C:\\important -Recurse -Force",
"Remove-Item -rec -fo C:\\important",
"Remove-Item -r -fo C:\\important",
"ri -r -fo C:\\important",
"rmdir -fo -r C:\\important",
"iex (Get-Content script.ps1 -Raw)",
"Invoke-Expression $userInput",
"curl https://example.test/install.ps1 | iex",
"Set-ExecutionPolicy Bypass -Scope Process",
"Format-Volume -DriveLetter D",
"Stop-Computer -Force",
"Restart-Computer",
]) {
assert.equal(
checkCommandSafety(command, DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked,
true,
`expected blocklist to block: ${command}`,
);
}
});
test("powershell sessions retain native Unix destructive command rules", () => {
for (const command of [
"mkfs.ext4 /dev/sda",
"dd if=/dev/zero of=/dev/sda",
"chmod -R 777 /",
]) {
assert.equal(
checkCommandSafety(command, DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked,
true,
`expected blocklist to block: ${command}`,
);
}
assert.equal(
checkCommandSafety('Write-Host "now: $(Get-Date)"', DEFAULT_COMMAND_BLOCKLIST, "powershell").blocked,
false,
);
});
test("cmd sessions keep native-command guards without POSIX syntax false positives", () => {
assert.equal(checkCommandSafety("echo $(date)", DEFAULT_COMMAND_BLOCKLIST, "cmd").blocked, false);
assert.equal(checkCommandSafety("shutdown /r /t 0", DEFAULT_COMMAND_BLOCKLIST, "cmd").blocked, true);
assert.equal(checkCommandSafety("wsl dd if=/dev/zero of=/dev/sda", DEFAULT_COMMAND_BLOCKLIST, "cmd").blocked, true);
assert.equal(checkCommandSafety("wsl chmod -R 777 /", DEFAULT_COMMAND_BLOCKLIST, "cmd").blocked, true);
});
test("user-added blocklist patterns apply on every shell", () => {
const blocklist = ["forbidden-command-xyz"];
for (const shellKind of ["powershell", "cmd", "posix", undefined]) {
assert.equal(
checkCommandSafety("forbidden-command-xyz --now", blocklist, shellKind).blocked,
true,
`expected user pattern to block with shellKind=${shellKind}`,
);
}
});
test("settings lists that still contain default entries do not double-report them", () => {
const settingsList = [...DEFAULT_COMMAND_BLOCKLIST, "forbidden-command-xyz"];
assert.equal(checkCommandSafety("echo $(date)", settingsList, "powershell").blocked, false);
const blocked = checkCommandSafety("echo $(date)", settingsList, "posix");
assert.equal(blocked.blocked, true);
assert.equal(blocked.matchedPattern, "\\$\\(");
assert.equal(checkCommandSafety("forbidden-command-xyz", settingsList, "powershell").blocked, true);
});
test("configured removal or editing of a default pattern remains authoritative", () => {
const withoutRm = DEFAULT_COMMAND_BLOCKLIST.filter((pattern) => !pattern.startsWith("\\brm\\s+"));
assert.equal(checkCommandSafety("rm -rf /", withoutRm, "posix").blocked, false);
assert.equal(checkCommandSafety("rm -rf /", [], "posix").blocked, false);
const edited = [...withoutRm, "\\brm\\s+-rf\\s+/tmp/allowed-test-only"];
assert.equal(checkCommandSafety("rm -rf /", edited, "posix").blocked, false);
assert.equal(checkCommandSafety("rm -rf /tmp/allowed-test-only", edited, "powershell").blocked, true);
});
test("common-only prefilter defers shell-specific defaults but keeps configured rules", () => {
assert.equal(
checkCommandSafetyCommonOnly('Write-Host "now: $(Get-Date)"', DEFAULT_COMMAND_BLOCKLIST).blocked,
false,
);
assert.equal(checkCommandSafetyCommonOnly("rm -rf /", DEFAULT_COMMAND_BLOCKLIST).blocked, true);
assert.equal(checkCommandSafetyCommonOnly("rm -rf /", []).blocked, false);
assert.equal(checkCommandSafetyCommonOnly("forbidden-command-xyz", ["forbidden-command-xyz"]).blocked, true);
});

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
rememberComposerRecentModel,
subscribeComposerModelPrefs,
} from './composerModelPrefs';
test('subscribeComposerModelPrefs notifies every listener after a write', () => {
let hits = 0;
const unsubscribe = subscribeComposerModelPrefs(() => {
hits += 1;
});
rememberComposerRecentModel('catty', { modelId: 'gpt-5.5' });
assert.equal(hits, 1);
unsubscribe();
rememberComposerRecentModel('catty', { modelId: 'gpt-5.4' });
assert.equal(hits, 1);
});

View File

@@ -0,0 +1,87 @@
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import { STORAGE_KEY_AI_COMPOSER_MODEL_PREFS } from '../config/storageKeys';
import {
COMPOSER_PINNED_MODEL_LIMIT,
COMPOSER_RECENT_MODEL_LIMIT,
parseComposerModelPrefs,
toggleComposerPinnedPref,
upsertComposerPrefFront,
type ComposerModelPrefEntry,
type ComposerModelPrefs,
} from './composerPicker';
type PrefsByScope = Record<string, ComposerModelPrefs>;
function emptyPrefs(): ComposerModelPrefs {
return { recent: [], pinned: [] };
}
function readAllPrefs(): PrefsByScope {
try {
const raw = localStorageAdapter.read<PrefsByScope>(STORAGE_KEY_AI_COMPOSER_MODEL_PREFS);
if (!raw || typeof raw !== 'object') return {};
const next: PrefsByScope = {};
for (const [scope, value] of Object.entries(raw)) {
next[scope] = parseComposerModelPrefs(value);
}
return next;
} catch {
return {};
}
}
const prefsListeners = new Set<() => void>();
function notifyComposerModelPrefsChanged(): void {
for (const listener of prefsListeners) listener();
}
export function subscribeComposerModelPrefs(listener: () => void): () => void {
prefsListeners.add(listener);
return () => {
prefsListeners.delete(listener);
};
}
function writeAllPrefs(prefs: PrefsByScope): void {
try {
localStorageAdapter.write(STORAGE_KEY_AI_COMPOSER_MODEL_PREFS, prefs);
} catch {
// Tests and SSR have no storage. Recent/pinned stay in-memory only.
}
notifyComposerModelPrefsChanged();
}
export function readComposerModelPrefs(scope: string): ComposerModelPrefs {
return parseComposerModelPrefs(readAllPrefs()[scope] ?? emptyPrefs());
}
export function rememberComposerRecentModel(
scope: string,
entry: ComposerModelPrefEntry,
): ComposerModelPrefs {
const all = readAllPrefs();
const current = parseComposerModelPrefs(all[scope]);
const next: ComposerModelPrefs = {
...current,
recent: upsertComposerPrefFront(current.recent, entry, COMPOSER_RECENT_MODEL_LIMIT),
};
all[scope] = next;
writeAllPrefs(all);
return next;
}
export function toggleComposerPinnedModel(
scope: string,
entry: ComposerModelPrefEntry,
): ComposerModelPrefs {
const all = readAllPrefs();
const current = parseComposerModelPrefs(all[scope]);
const next: ComposerModelPrefs = {
...current,
pinned: toggleComposerPinnedPref(current.pinned, entry, COMPOSER_PINNED_MODEL_LIMIT),
};
all[scope] = next;
writeAllPrefs(all);
return next;
}

View File

@@ -0,0 +1,212 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { ProviderConfig } from './types';
import {
buildProviderSeedModels,
filterComposerModels,
mergeComposerModels,
normalizeCattyReasoningLevel,
parseComposerModelPrefs,
resolveComposerEnterModelId,
resolveModelSelectionWithThinking,
resolvePinnedAndRecentModels,
resolveThinkingSelection,
toggleComposerPinnedPref,
upsertComposerPrefFront,
} from './composerPicker';
test('normalizeCattyReasoningLevel falls back to off', () => {
assert.equal(normalizeCattyReasoningLevel('high'), 'high');
assert.equal(normalizeCattyReasoningLevel('nope'), 'off');
assert.equal(normalizeCattyReasoningLevel(undefined), 'off');
});
test('resolveComposerEnterModelId prefers exact id, then the visible custom row', () => {
const models = [
{ id: 'gpt-5.5', name: 'GPT-5.5' },
{ id: 'gpt-5', name: 'GPT-5' },
{ id: 'llama3', name: 'Llama 3' },
];
const grouped = {
pinned: [{ id: 'gpt-5.5', name: 'GPT-5.5' }],
recent: [],
rest: [{ id: 'gpt-5', name: 'GPT-5' }, { id: 'llama3', name: 'Llama 3' }],
};
assert.equal(
resolveComposerEnterModelId({
query: 'gpt-5',
models,
grouped,
filtered: models,
showCustom: false,
}),
'gpt-5',
);
assert.equal(
resolveComposerEnterModelId({
query: 'gpt-5',
models: models.filter((model) => model.id !== 'gpt-5'),
grouped,
filtered: [{ id: 'gpt-5.5', name: 'GPT-5.5' }],
showCustom: true,
}),
'gpt-5',
);
assert.equal(
resolveComposerEnterModelId({
query: 'gpt',
models,
grouped,
filtered: [{ id: 'gpt-5.5', name: 'GPT-5.5' }, { id: 'gpt-5', name: 'GPT-5' }],
showCustom: false,
}),
'gpt-5.5',
);
});
test('filterComposerModels matches id, name, and description', () => {
const models = [
{ id: 'gpt-5.5', name: 'GPT-5.5', description: 'Balanced' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
];
assert.deepEqual(filterComposerModels(models, '5.5').map((m) => m.id), ['gpt-5.5']);
assert.deepEqual(filterComposerModels(models, 'v4').map((m) => m.id), ['deepseek-v4-pro']);
assert.deepEqual(filterComposerModels(models, 'balanced').map((m) => m.id), ['gpt-5.5']);
});
test('buildProviderSeedModels includes default plus preset ids', () => {
const provider: ProviderConfig = {
id: 'p1',
providerId: 'deepseek',
name: 'DeepSeek',
defaultModel: 'deepseek-chat',
enabled: true,
};
const ids = buildProviderSeedModels(provider).map((model) => model.id);
assert.ok(ids.includes('deepseek-chat'));
assert.ok(ids.includes('deepseek-v4-pro'));
});
test('mergeComposerModels prefers a human name over a raw id', () => {
const merged = mergeComposerModels(
[{ id: 'gpt-5.5', name: 'gpt-5.5' }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', description: 'Latest' }],
);
assert.deepEqual(merged, [{ id: 'gpt-5.5', name: 'GPT-5.5', description: 'Latest' }]);
});
test('resolveThinkingSelection keeps slashy model ids unless they match a declared effort', () => {
const presets = [
{ id: 'qwen/qwen3.6-plus', name: 'Qwen 3.6' },
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
];
assert.deepEqual(resolveThinkingSelection('qwen/qwen3.6-plus', presets), {
preset: presets[0],
});
assert.deepEqual(resolveThinkingSelection('gpt-5.5/high', presets), {
preset: presets[1],
thinking: 'high',
});
assert.deepEqual(resolveThinkingSelection('gpt-5.5?effort=high', presets), {
preset: presets[1],
thinking: 'high',
});
});
test('resolveModelSelectionWithThinking keeps the current effort when still valid', () => {
const preset = {
id: 'gpt-5.5',
name: 'GPT-5.5',
thinkingLevels: ['low', 'medium', 'high'],
defaultThinkingLevel: 'medium',
};
assert.equal(resolveModelSelectionWithThinking(preset, 'high'), 'gpt-5.5/high');
assert.equal(resolveModelSelectionWithThinking(preset, 'ultra'), 'gpt-5.5/medium');
assert.equal(resolveModelSelectionWithThinking({ id: 'haiku', name: 'Haiku' }, 'high'), 'haiku');
assert.equal(
resolveModelSelectionWithThinking({
id: 'glm-5.1',
name: 'GLM 5.1',
thinkingLevels: ['low', 'medium', 'high'],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
}),
'glm-5.1',
);
});
test('recent and pinned grouping keeps pinned first and drops duplicates from recent', () => {
const models = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' },
];
const grouped = resolvePinnedAndRecentModels({
models,
providerId: 'p1',
prefs: {
pinned: [{ providerId: 'p1', modelId: 'b' }],
recent: [
{ providerId: 'p1', modelId: 'b' },
{ providerId: 'p1', modelId: 'a' },
],
},
});
assert.deepEqual(grouped.pinned.map((m) => m.id), ['b']);
assert.deepEqual(grouped.recent.map((m) => m.id), ['a']);
assert.deepEqual(grouped.rest.map((m) => m.id), ['c']);
});
test('external agent prefs do not resurrect models missing from the catalog', () => {
const grouped = resolvePinnedAndRecentModels({
models: [{ id: 'gpt-5.5', name: 'GPT-5.5' }],
prefs: {
pinned: [{ modelId: 'stale-model' }],
recent: [{ modelId: 'gpt-5.5' }, { modelId: 'also-gone' }],
},
});
assert.deepEqual(grouped.pinned.map((m) => m.id), []);
assert.deepEqual(grouped.recent.map((m) => m.id), ['gpt-5.5']);
});
test('Catty may keep a custom model that is not in the live catalog', () => {
const grouped = resolvePinnedAndRecentModels({
models: [{ id: 'deepseek-chat', name: 'DeepSeek Chat' }],
providerId: 'p1',
allowMissing: true,
prefs: {
pinned: [{ providerId: 'p1', modelId: 'my-custom' }],
recent: [],
},
});
assert.deepEqual(grouped.pinned.map((m) => m.id), ['my-custom']);
});
test('pref helpers upsert and toggle without duplicating keys', () => {
const recent = upsertComposerPrefFront(
[{ modelId: 'a' }, { modelId: 'b' }],
{ modelId: 'b' },
3,
);
assert.deepEqual(recent, [{ modelId: 'b' }, { modelId: 'a' }]);
const pinned = toggleComposerPinnedPref([{ modelId: 'a' }], { modelId: 'a' });
assert.deepEqual(pinned, []);
});
test('parseComposerModelPrefs drops empty and duplicate entries', () => {
const parsed = parseComposerModelPrefs({
recent: [
{ providerId: 'p1', modelId: 'a' },
{ providerId: 'p1', modelId: 'a' },
{ modelId: ' ' },
{ modelId: 'b' },
],
pinned: [{ modelId: 'c' }],
});
assert.deepEqual(parsed.recent, [
{ providerId: 'p1', modelId: 'a' },
{ modelId: 'b' },
]);
assert.deepEqual(parsed.pinned, [{ modelId: 'c' }]);
});

View File

@@ -0,0 +1,265 @@
import type { AgentModelPreset, ProviderConfig } from './types';
import { formatThinkingLabel, PROVIDER_PRESETS, resolveAgentModelSelection } from './types';
export const CATTY_REASONING_LEVELS = ['off', 'low', 'medium', 'high'] as const;
export type CattyReasoningLevel = (typeof CATTY_REASONING_LEVELS)[number];
export const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const;
export const COMPOSER_RECENT_MODEL_LIMIT = 6;
export const COMPOSER_PINNED_MODEL_LIMIT = 8;
export interface ComposerPickerModel {
id: string;
name: string;
description?: string;
contextWindow?: number;
}
export interface ComposerModelPrefEntry {
providerId?: string;
modelId: string;
}
export interface ComposerModelPrefs {
recent: ComposerModelPrefEntry[];
pinned: ComposerModelPrefEntry[];
}
export function isCattyReasoningLevel(value: string | null | undefined): value is CattyReasoningLevel {
return CATTY_REASONING_LEVELS.some((level) => level === value);
}
export function normalizeCattyReasoningLevel(
value: string | null | undefined,
): CattyReasoningLevel {
return isCattyReasoningLevel(value) ? value : 'off';
}
export function composerModelPrefKey(entry: ComposerModelPrefEntry): string {
return entry.providerId ? `${entry.providerId}::${entry.modelId}` : entry.modelId;
}
export function sameComposerModelPref(
left: ComposerModelPrefEntry,
right: ComposerModelPrefEntry,
): boolean {
return left.modelId === right.modelId && (left.providerId ?? '') === (right.providerId ?? '');
}
export function upsertComposerPrefFront(
entries: ComposerModelPrefEntry[],
next: ComposerModelPrefEntry,
limit: number,
): ComposerModelPrefEntry[] {
const filtered = entries.filter((entry) => !sameComposerModelPref(entry, next));
return [next, ...filtered].slice(0, limit);
}
export function toggleComposerPinnedPref(
entries: ComposerModelPrefEntry[],
target: ComposerModelPrefEntry,
limit = COMPOSER_PINNED_MODEL_LIMIT,
): ComposerModelPrefEntry[] {
if (entries.some((entry) => sameComposerModelPref(entry, target))) {
return entries.filter((entry) => !sameComposerModelPref(entry, target));
}
return upsertComposerPrefFront(entries, target, limit);
}
export function parseComposerModelPrefs(value: unknown): ComposerModelPrefs {
if (!value || typeof value !== 'object') {
return { recent: [], pinned: [] };
}
const record = value as Record<string, unknown>;
return {
recent: parsePrefEntries(record.recent).slice(0, COMPOSER_RECENT_MODEL_LIMIT),
pinned: parsePrefEntries(record.pinned).slice(0, COMPOSER_PINNED_MODEL_LIMIT),
};
}
function parsePrefEntries(value: unknown): ComposerModelPrefEntry[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const entries: ComposerModelPrefEntry[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const modelId = typeof (item as { modelId?: unknown }).modelId === 'string'
? (item as { modelId: string }).modelId.trim()
: '';
if (!modelId) continue;
const providerId = typeof (item as { providerId?: unknown }).providerId === 'string'
? (item as { providerId: string }).providerId.trim()
: undefined;
const entry: ComposerModelPrefEntry = providerId ? { providerId, modelId } : { modelId };
const key = composerModelPrefKey(entry);
if (seen.has(key)) continue;
seen.add(key);
entries.push(entry);
}
return entries;
}
export function resolveComposerEnterModelId(input: {
query: string;
models: ComposerPickerModel[];
grouped: {
pinned: ComposerPickerModel[];
recent: ComposerPickerModel[];
rest: ComposerPickerModel[];
};
filtered: ComposerPickerModel[];
showCustom: boolean;
}): string | undefined {
const trimmed = input.query.trim();
if (!trimmed) return undefined;
const exact = input.models.find((model) => model.id.toLowerCase() === trimmed.toLowerCase());
if (exact) return exact.id;
if (input.showCustom) return trimmed;
return input.grouped.pinned[0]?.id
?? input.grouped.recent[0]?.id
?? input.grouped.rest[0]?.id
?? input.filtered[0]?.id;
}
export function filterComposerModels(
models: ComposerPickerModel[],
query: string,
): ComposerPickerModel[] {
const q = query.trim().toLowerCase();
if (!q) return models;
return models.filter((model) => (
model.id.toLowerCase().includes(q)
|| model.name.toLowerCase().includes(q)
|| (model.description ?? '').toLowerCase().includes(q)
));
}
export function buildProviderSeedModels(provider: ProviderConfig): ComposerPickerModel[] {
const byId = new Map<string, ComposerPickerModel>();
const defaultModel = provider.defaultModel?.trim();
if (defaultModel) {
byId.set(defaultModel, { id: defaultModel, name: defaultModel });
}
const presetModels = PROVIDER_PRESETS[provider.providerId]?.defaultModels ?? [];
for (const modelId of presetModels) {
const id = modelId.trim();
if (!id || byId.has(id)) continue;
byId.set(id, { id, name: id });
}
return Array.from(byId.values());
}
export function mergeComposerModels(
...lists: Array<Iterable<ComposerPickerModel> | undefined>
): ComposerPickerModel[] {
const byId = new Map<string, ComposerPickerModel>();
for (const list of lists) {
if (!list) continue;
for (const model of list) {
const id = model.id.trim();
if (!id) continue;
const existing = byId.get(id);
if (!existing) {
byId.set(id, {
id,
name: model.name || id,
...(model.description ? { description: model.description } : {}),
...(model.contextWindow != null ? { contextWindow: model.contextWindow } : {}),
});
continue;
}
const contextWindow = existing.contextWindow ?? model.contextWindow;
byId.set(id, {
id,
name: existing.name === existing.id && model.name ? model.name : existing.name,
...(existing.description || model.description
? { description: existing.description || model.description }
: {}),
...(contextWindow != null ? { contextWindow } : {}),
});
}
}
return Array.from(byId.values());
}
export function canonicalizeEffortEncodedModelId(modelId: string): string {
const queryIndex = modelId.indexOf('?');
if (queryIndex < 0) return modelId;
const id = modelId.slice(0, queryIndex);
const params = new URLSearchParams(modelId.slice(queryIndex + 1));
const keys = [...params.keys()];
const effort = params.get('effort');
if (keys.length === 1 && keys[0] === 'effort' && effort) {
return `${id}/${effort}`;
}
return modelId;
}
export function resolveThinkingSelection(
selectedModelId: string | undefined,
presets: AgentModelPreset[],
): { preset?: AgentModelPreset; thinking?: string } {
if (!selectedModelId) return {};
const canonical = canonicalizeEffortEncodedModelId(selectedModelId);
const direct = presets.find((preset) => preset.id === canonical);
if (direct) return { preset: direct };
const viaThinking = presets.find(
(preset) => preset.thinkingLevels?.some((level) => `${preset.id}/${level}` === canonical),
);
if (!viaThinking) return {};
return {
preset: viaThinking,
thinking: canonical.slice(viaThinking.id.length + 1),
};
}
export function resolveModelSelectionWithThinking(
preset: AgentModelPreset,
preferredThinking?: string,
): string {
const levels = preset.thinkingLevels;
if (!levels?.length) return preset.id;
if (preferredThinking && levels.includes(preferredThinking)) {
return `${preset.id}/${preferredThinking}`;
}
return resolveAgentModelSelection(preset);
}
export function formatComposerThinkingLabel(level: string): string {
if (level === 'off') return 'Off';
return formatThinkingLabel(level);
}
export function resolvePinnedAndRecentModels(input: {
models: ComposerPickerModel[];
prefs: ComposerModelPrefs;
providerId?: string;
/** Catty custom IDs only. External catalogs must not resurrect stale prefs. */
allowMissing?: boolean;
}): {
pinned: ComposerPickerModel[];
recent: ComposerPickerModel[];
rest: ComposerPickerModel[];
} {
const byId = new Map(input.models.map((model) => [model.id, model]));
const matchesScope = (entry: ComposerModelPrefEntry) => (
!input.providerId || !entry.providerId || entry.providerId === input.providerId
);
const allowMissing = input.allowMissing ?? false;
const resolve = (entries: ComposerModelPrefEntry[]) => (
entries
.filter(matchesScope)
.map((entry) => byId.get(entry.modelId) ?? (allowMissing ? { id: entry.modelId, name: entry.modelId } : undefined))
.filter((model): model is ComposerPickerModel => model != null)
);
const pinned = resolve(input.prefs.pinned);
const pinnedIds = new Set(pinned.map((model) => model.id));
const recent = resolve(input.prefs.recent).filter((model) => !pinnedIds.has(model.id));
const reservedIds = new Set([...pinnedIds, ...recent.map((model) => model.id)]);
return {
pinned,
recent,
rest: input.models.filter((model) => !reservedIds.has(model.id)),
};
}

View File

@@ -0,0 +1,350 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { ModelMessage } from "ai";
import {
buildCompactedMessages,
estimateModelMessagesTokens,
estimateUnknownTokens,
findSafeChatMessageCompactionSplitIndex,
findSafeCompactionSplitIndex,
formatMessagesForCompaction,
prepareContextCompaction,
resolveContextWindow,
shouldCompactContext,
} from "./contextCompaction.ts";
test("shouldCompactContext waits until the prompt approaches the context window", () => {
assert.equal(shouldCompactContext({ promptTokens: 70, contextWindow: 100, thresholdRatio: 0.85 }), false);
assert.equal(shouldCompactContext({ promptTokens: 85, contextWindow: 100, thresholdRatio: 0.85 }), true);
});
test("shouldCompactContext uses dynamic threshold when ratio omitted", () => {
assert.equal(shouldCompactContext({ promptTokens: 70, contextWindow: 100 }), true);
});
test("findSafeCompactionSplitIndex keeps recent messages intact", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "old 1" },
{ role: "assistant", content: "old 2" },
{ role: "user", content: "recent 1" },
{ role: "assistant", content: "recent 2" },
];
assert.equal(findSafeCompactionSplitIndex(messages, 2), 2);
});
test("findSafeCompactionSplitIndex avoids orphaning a tool result", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "old" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "run_command",
input: { command: "pwd" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "run_command",
output: { type: "text", value: "/tmp" },
},
],
},
{ role: "user", content: "recent" },
{ role: "assistant", content: "answer" },
];
assert.equal(findSafeCompactionSplitIndex(messages, 3), 1);
});
test("findSafeChatMessageCompactionSplitIndex does not cut tool call/result pairs", () => {
const messages = [
{ role: "user" },
{ role: "assistant", toolCalls: [{ id: "c1" }] },
{ role: "tool" },
{ role: "user" },
{ role: "assistant" },
{ role: "user" },
{ role: "assistant" },
{ role: "user" },
{ role: "assistant" },
{ role: "user" },
{ role: "assistant" },
{ role: "user" },
{ role: "assistant", toolCalls: [{ id: "c2" }] },
{ role: "tool" },
];
// protect last 3 would land inside the final tool pair; pull boundary before assistant toolCalls
assert.equal(findSafeChatMessageCompactionSplitIndex(messages, 3), 11);
assert.equal(findSafeChatMessageCompactionSplitIndex(messages.slice(0, 8), 10), 0);
});
test("buildCompactedMessages places the summary before recent messages", () => {
const recentMessages: ModelMessage[] = [
{ role: "user", content: "what next?" },
];
const compacted = buildCompactedMessages({
summary: "Earlier work is summarized here.",
recentMessages,
});
assert.deepEqual(compacted, [
{
role: "user",
content: "[Previous conversation summary]\n\nEarlier work is summarized here.\n\n[Continue with the recent messages below.]",
},
{
role: "assistant",
content: "I understand the previous conversation summary and will continue from the recent messages.",
},
{ role: "user", content: "what next?" },
]);
});
test("prepareContextCompaction summarizes old messages and returns compacted context", async () => {
const messages: ModelMessage[] = [
{ role: "user", content: "old ".repeat(40) },
{ role: "assistant", content: "older ".repeat(40) },
{ role: "user", content: "recent question" },
{ role: "assistant", content: "recent answer" },
];
const result = await prepareContextCompaction({
messages,
contextWindow: 100,
protectRecentMessages: 2,
summarize: async (messagesToSummarize) => {
assert.deepEqual(messagesToSummarize, messages.slice(0, 2));
return "Summary of old messages.";
},
});
assert.equal(result.didCompact, true);
assert.equal(result.summary, "Summary of old messages.");
assert.deepEqual(result.messages.slice(-2), messages.slice(-2));
});
test("prepareContextCompaction includes reserved request tokens in the compaction check", async () => {
const messages: ModelMessage[] = [
{ role: "user", content: "short prompt" },
{ role: "assistant", content: "short answer" },
{ role: "user", content: "recent question" },
];
const result = await prepareContextCompaction({
messages,
contextWindow: 40,
reservedTokens: estimateUnknownTokens("large system prompt ".repeat(20)),
protectRecentMessages: 1,
summarize: async (messagesToSummarize) => {
assert.deepEqual(messagesToSummarize, messages.slice(0, 2));
return "System prompt forced compaction.";
},
});
assert.equal(result.didCompact, true);
assert.equal(result.summary, "System prompt forced compaction.");
});
test("prepareContextCompaction summarizes older tool results instead of dropping them first", async () => {
const messages: ModelMessage[] = [
{ role: "user", content: "check disk usage" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "run_command",
input: { command: "df -h" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "run_command",
output: { type: "text", value: "/dev/disk1 81% full" },
},
],
},
{ role: "assistant", content: "Disk is 81% full." },
{ role: "user", content: "old follow-up ".repeat(80) },
{ role: "assistant", content: "old answer ".repeat(80) },
{ role: "user", content: "recent question" },
{ role: "assistant", content: "recent answer" },
];
const result = await prepareContextCompaction({
messages,
contextWindow: 120,
protectRecentMessages: 2,
summarize: async (messagesToSummarize) => {
assert.match(formatMessagesForCompaction(messagesToSummarize), /81% full/);
return "Earlier disk check showed /dev/disk1 was 81% full.";
},
});
assert.equal(result.didCompact, true);
assert.match(result.messages[0]?.content as string, /81% full/);
});
test("formatMessagesForCompaction redacts image and file payloads", () => {
const imagePayload = "iVBORw0KGgo".repeat(200);
const filePayload = "JVBERi0xLjQK".repeat(200);
const formatted = formatMessagesForCompaction([
{
role: "user",
content: [
{ type: "text", text: "Please inspect these attachments." },
{
type: "image",
image: imagePayload,
mediaType: "image/png",
},
{
type: "file",
data: filePayload,
filename: "report.pdf",
mediaType: "application/pdf",
},
],
},
]);
assert.match(formatted, /Please inspect these attachments/);
assert.match(formatted, /redacted image payload/);
assert.match(formatted, /mediaType=image\/png/);
assert.match(formatted, /redacted file payload/);
assert.match(formatted, /filename=report\.pdf/);
assert.doesNotMatch(formatted, new RegExp(imagePayload.slice(0, 40)));
assert.doesNotMatch(formatted, new RegExp(filePayload.slice(0, 40)));
});
test("formatMessagesForCompaction keeps non-attachment data fields", () => {
const formatted = formatMessagesForCompaction([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "read_json",
output: {
type: "json",
value: {
data: {
host: "prod-1",
status: "healthy",
},
},
},
},
],
},
]);
assert.match(formatted, /prod-1/);
assert.match(formatted, /healthy/);
assert.doesNotMatch(formatted, /redacted data payload/);
});
test("formatMessagesForCompaction omits encrypted provider continuation metadata", () => {
const ciphertext = "gAAAA".repeat(20_000);
const formatted = formatMessagesForCompaction([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "Checked the deployment state.",
providerOptions: {
openai: {
itemId: "rs_secret",
reasoningEncryptedContent: ciphertext,
},
},
},
{
type: "tool-call",
toolCallId: "call-1",
toolName: "run_command",
input: { command: "docker service ls" },
},
],
},
]);
assert.match(formatted, /Checked the deployment state/);
assert.match(formatted, /docker service ls/);
assert.doesNotMatch(formatted, /reasoningEncryptedContent/);
assert.doesNotMatch(formatted, /rs_secret/);
assert.doesNotMatch(formatted, new RegExp(ciphertext.slice(0, 40)));
assert.ok(formatted.length < 2_000);
});
test("estimateModelMessagesTokens counts multimodal and tool content", () => {
const tokens = estimateModelMessagesTokens([
{ role: "user", content: [{ type: "text", text: "hello world" }] },
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "run_command",
output: { type: "text", value: "result text" },
},
],
},
]);
assert.ok(tokens >= 5);
});
test("resolveContextWindow prefers manual override, then fetched model metadata, then default", () => {
assert.equal(
resolveContextWindow({
provider: {
contextWindow: 262144,
modelContextWindows: { "qwen/test": 131072 },
},
modelId: "qwen/test",
defaultContextWindow: 128000,
}),
262144,
);
assert.equal(
resolveContextWindow({
provider: {
modelContextWindows: { "qwen/test": 131072 },
},
modelId: "qwen/test",
defaultContextWindow: 128000,
}),
131072,
);
assert.equal(
resolveContextWindow({
provider: {},
modelId: "unknown",
defaultContextWindow: 128000,
}),
128000,
);
});

View File

@@ -0,0 +1,351 @@
import type { ModelMessage } from "ai";
import type { ProviderConfig } from "./types";
import {
computeCompactionThreshold,
DEFAULT_MAX_OUTPUT_TOKENS,
} from "./harness/contextBudget";
import {
estimateModelMessagesTokensWithKind,
estimateUnknownTokens,
} from "./harness/tokenEstimator";
import { redactSecretsForModel } from "./harness/modelSecretRedaction";
const REDACTED_PAYLOAD_PREVIEW_CHARS = 80;
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
export const DEFAULT_PROTECT_RECENT_MESSAGES = 10;
export const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are summarizing a long Netcatty agent conversation so it can continue without exceeding the model context window.
Create a concise but complete summary that preserves:
- the user's current goal and requirements
- important decisions and constraints
- terminal hosts, paths, commands, files, errors, and results that still matter
- what has already been tried
- unresolved tasks or blockers
Do not add new advice. Only summarize what happened.`;
export interface ShouldCompactContextInput {
promptTokens: number;
contextWindow: number;
thresholdRatio?: number;
maxOutputTokens?: number;
}
export interface PrepareContextCompactionInput {
messages: ModelMessage[];
contextWindow?: number;
reservedTokens?: number;
thresholdRatio?: number;
maxOutputTokens?: number;
providerId?: string | null;
protectRecentMessages?: number;
summarize: (messagesToSummarize: ModelMessage[]) => Promise<string>;
}
export interface PrepareContextCompactionResult {
messages: ModelMessage[];
summary?: string;
didCompact: boolean;
}
export interface ResolveContextWindowInput {
provider?: Pick<ProviderConfig, "contextWindow" | "modelContextWindows"> | null;
modelId?: string | null;
defaultContextWindow?: number;
}
export function shouldCompactContext({
promptTokens,
contextWindow,
thresholdRatio,
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
}: ShouldCompactContextInput): boolean {
if (contextWindow <= 0) return false;
const threshold = thresholdRatio != null
? contextWindow * thresholdRatio
: computeCompactionThreshold({ contextWindow, maxOutputTokens });
return promptTokens >= threshold;
}
export function resolveContextWindow({
provider,
modelId,
defaultContextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
}: ResolveContextWindowInput): number {
const manual = sanitizeContextWindow(provider?.contextWindow);
if (manual != null) return manual;
const discovered = modelId ? sanitizeContextWindow(provider?.modelContextWindows?.[modelId]) : null;
if (discovered != null) return discovered;
return defaultContextWindow;
}
export function sanitizeContextWindow(value: unknown): number | undefined {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num) || num <= 0) return undefined;
return Math.max(1, Math.round(num));
}
export function estimateModelMessagesTokens(
messages: ModelMessage[],
providerId?: string | null,
): number {
return estimateModelMessagesTokensWithKind({ messages, providerId }).tokens;
}
export { estimateUnknownTokens };
export function findSafeCompactionSplitIndex(
messages: ModelMessage[],
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
): number {
let splitAt = Math.max(0, messages.length - protectRecentMessages);
while (splitAt > 0 && startsWithToolResult(messages[splitAt])) {
splitAt -= 1;
}
while (splitAt > 0 && endsWithToolCall(messages[splitAt - 1])) {
splitAt -= 1;
}
return splitAt;
}
/**
* UI-message equivalent of findSafeCompactionSplitIndex.
* Persisted force-compact boundaries use ChatMessage indices; never cut between
* an assistant toolCalls message and its following tool result messages.
*/
export function findSafeChatMessageCompactionSplitIndex(
messages: Array<{ role: string; toolCalls?: readonly unknown[] | null }>,
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
): number {
let splitAt = Math.max(0, messages.length - protectRecentMessages);
while (splitAt > 0 && messages[splitAt]?.role === "tool") {
splitAt -= 1;
}
while (
splitAt > 0
&& messages[splitAt - 1]?.role === "assistant"
&& Array.isArray(messages[splitAt - 1]?.toolCalls)
&& (messages[splitAt - 1]?.toolCalls?.length ?? 0) > 0
) {
splitAt -= 1;
}
return splitAt;
}
export function buildCompactedMessages({
summary,
recentMessages,
}: {
summary: string;
recentMessages: ModelMessage[];
}): ModelMessage[] {
return [
{
role: "user",
content: `[Previous conversation summary]\n\n${summary.trim()}\n\n[Continue with the recent messages below.]`,
},
{
role: "assistant",
content: "I understand the previous conversation summary and will continue from the recent messages.",
},
...recentMessages,
];
}
export async function prepareContextCompaction({
messages,
contextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
reservedTokens = 0,
thresholdRatio,
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
providerId,
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
summarize,
}: PrepareContextCompactionInput): Promise<PrepareContextCompactionResult> {
const promptTokens = estimateModelMessagesTokens(messages, providerId)
+ Math.max(0, Math.ceil(reservedTokens));
if (!shouldCompactContext({
promptTokens,
contextWindow,
thresholdRatio,
maxOutputTokens,
})) {
return { messages, didCompact: false };
}
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
const oldMessages = messages.slice(0, splitAt);
const recentMessages = messages.slice(splitAt);
if (oldMessages.length === 0) {
return { messages, didCompact: false };
}
const summary = (await summarize(oldMessages)).trim();
if (!summary) {
return { messages, didCompact: false };
}
return {
messages: buildCompactedMessages({ summary, recentMessages }),
summary,
didCompact: true,
};
}
export function formatMessagesForCompaction(messages: ModelMessage[]): string {
return messages
.map((message, index) => {
return `<message index="${index + 1}" role="${escapeXml(String(message.role))}">\n${escapeXml(formatMessageContent(message.content))}\n</message>`;
})
.join("\n\n");
}
export function keepRecentContextMessages(
messages: ModelMessage[],
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
): ModelMessage[] {
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
return messages.slice(splitAt);
}
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 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 formatMessageContent(content: ModelMessage["content"]): string {
if (typeof content === "string") return redactSecretsForModel(content);
return redactSecretsForModel(JSON.stringify(sanitizeContentForCompaction(content), null, 2));
}
function sanitizeContentForCompaction(content: Exclude<ModelMessage["content"], string>): unknown {
if (!Array.isArray(content)) return sanitizeUnknownForCompaction(content);
return content.map((part) => sanitizeContentPartForCompaction(part));
}
function sanitizeContentPartForCompaction(part: unknown): unknown {
if (!isRecord(part)) return sanitizeUnknownForCompaction(part);
const sanitized = sanitizeRecordForCompaction(part);
// Provider options are replay metadata for the actual model request, not
// conversation content. In particular, Responses encrypted reasoning can
// be tens of KB per turn and must never become literal summarization text.
delete sanitized.providerOptions;
if (part.type === "image") {
return {
...sanitized,
image: describeRedactedPayload(part.image, {
label: "image",
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
}),
};
}
if (part.type === "file") {
return {
...sanitized,
data: describeRedactedPayload(part.data, {
label: "file",
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
filename: typeof part.filename === "string" ? part.filename : undefined,
}),
};
}
return sanitized;
}
function sanitizeRecordForCompaction(value: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
for (const [entryKey, entryValue] of Object.entries(value)) {
sanitized[entryKey] = sanitizeUnknownForCompaction(entryValue, entryKey);
}
return sanitized;
}
function sanitizeUnknownForCompaction(value: unknown, key?: string): unknown {
if (value == null) return value;
if (typeof value === "string") {
if (key === "base64Data" || key === "dataUrl" || key === "file_data") {
return describeRedactedPayload(value, { label: key });
}
return value;
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (value instanceof URL) return value.toString();
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return describeRedactedPayload(value, { label: key ?? "binary" });
}
if (Array.isArray(value)) return value.map((part) => sanitizeUnknownForCompaction(part));
if (isRecord(value)) return sanitizeRecordForCompaction(value);
return String(value);
}
function describeRedactedPayload(
value: unknown,
{
label,
filename,
mediaType,
}: {
label: string;
filename?: string;
mediaType?: string;
},
): string {
const details = [
filename ? `filename=${filename}` : undefined,
mediaType ? `mediaType=${mediaType}` : undefined,
describePayloadSize(value),
typeof value === "string" ? describeStringPreview(value) : undefined,
].filter(Boolean);
return `[redacted ${label} payload${details.length ? `: ${details.join(", ")}` : ""}]`;
}
function describePayloadSize(value: unknown): string {
if (typeof value === "string") return `${value.length} chars`;
if (value instanceof ArrayBuffer) return `${value.byteLength} bytes`;
if (ArrayBuffer.isView(value)) return `${value.byteLength} bytes`;
if (value instanceof URL) return "url";
return typeof value;
}
function describeStringPreview(value: string): string | undefined {
if (!value.startsWith("data:")) return undefined;
const commaIndex = value.indexOf(",");
const header = commaIndex >= 0 ? value.slice(0, commaIndex) : value.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS);
return `source=${header.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View File

@@ -0,0 +1,125 @@
import type { AISession } from './types';
/**
* Export a session as Markdown
*/
export function exportAsMarkdown(session: AISession): string {
const lines: string[] = [];
lines.push(`# ${session.title || 'Untitled Chat'}`);
lines.push('');
lines.push(`- **Agent:** ${session.agentId}`);
lines.push(`- **Scope:** ${session.scope.type}${session.scope.targetId ? ` (${session.scope.targetId})` : ''}`);
lines.push(`- **Created:** ${new Date(session.createdAt).toLocaleString()}`);
lines.push(`- **Updated:** ${new Date(session.updatedAt).toLocaleString()}`);
lines.push('');
lines.push('---');
lines.push('');
for (const msg of session.messages) {
if (msg.role === 'system') continue;
const time = new Date(msg.timestamp).toLocaleTimeString();
if (msg.role === 'user') {
lines.push(`## User [${time}]`);
lines.push('');
lines.push(msg.content);
lines.push('');
} else if (msg.role === 'assistant') {
lines.push(`## Assistant [${time}]${msg.model ? ` (${msg.model})` : ''}`);
lines.push('');
lines.push(msg.content);
if (msg.toolCalls?.length) {
lines.push('');
for (const tc of msg.toolCalls) {
lines.push(`### Tool Call: \`${tc.name}\``);
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(tc.arguments, null, 2));
lines.push('```');
lines.push('');
}
}
lines.push('');
} else if (msg.role === 'tool') {
if (msg.toolResults?.length) {
for (const tr of msg.toolResults) {
lines.push(`### Tool Result${tr.isError ? ' (Error)' : ''}`);
lines.push('');
lines.push('```');
lines.push(tr.content);
lines.push('```');
lines.push('');
}
}
}
}
return lines.join('\n');
}
/**
* Export a session as JSON
*/
export function exportAsJSON(session: AISession): string {
return JSON.stringify(session, null, 2);
}
/**
* Export a session as plain text
*/
export function exportAsPlainText(session: AISession): string {
const lines: string[] = [];
lines.push(`Chat: ${session.title || 'Untitled'}`);
lines.push(`Date: ${new Date(session.createdAt).toLocaleString()}`);
lines.push('='.repeat(60));
lines.push('');
for (const msg of session.messages) {
if (msg.role === 'system') continue;
const time = new Date(msg.timestamp).toLocaleTimeString();
if (msg.role === 'user') {
lines.push(`[${time}] You:`);
lines.push(msg.content);
lines.push('');
} else if (msg.role === 'assistant') {
lines.push(`[${time}] Assistant:`);
lines.push(msg.content);
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
lines.push(` > Tool: ${tc.name}(${JSON.stringify(tc.arguments)})`);
}
}
lines.push('');
} else if (msg.role === 'tool') {
if (msg.toolResults?.length) {
for (const tr of msg.toolResults) {
lines.push(` > Result${tr.isError ? ' [ERROR]' : ''}:`);
lines.push(` ${tr.content}`);
}
lines.push('');
}
}
}
return lines.join('\n');
}
/**
* Generate a suggested filename for export
*/
export function getExportFilename(session: AISession, format: 'md' | 'json' | 'txt'): string {
const title = (session.title || 'chat')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40);
const date = new Date(session.createdAt).toISOString().slice(0, 10);
return `netcatty-${title}-${date}.${format}`;
}

View File

@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
// -------------------------------------------------------------------
// sanitizeErrorMessage — regression guard for pre-existing behavior
// -------------------------------------------------------------------
test("sanitizeErrorMessage strips absolute user paths", () => {
const result = sanitizeErrorMessage("ENOENT at /Users/alice/project/file.ts");
assert.match(result, /<path>/);
assert.doesNotMatch(result, /alice/);
});
test("sanitizeErrorMessage redacts URL credentials", () => {
const result = sanitizeErrorMessage("Failed https://api.example.com/v1?api_key=SECRET123");
assert.match(result, /<url-redacted>/);
assert.doesNotMatch(result, /SECRET123/);
});
test("sanitizeErrorMessage truncates very long messages", () => {
const long = "a".repeat(1000);
const result = sanitizeErrorMessage(long);
assert.ok(result.length < 600, `expected truncation, got ${result.length} chars`);
assert.match(result, /\.\.\.$/);
});
// -------------------------------------------------------------------
// classifyError — 413 detection
// -------------------------------------------------------------------
test("classifyError surfaces a friendly 413 message when statusCode is 413", () => {
const err = Object.assign(new Error("Request failed with status 413"), {
statusCode: 413,
responseBody: "<html>nginx 413</html>",
});
const info = classifyError(err);
assert.equal(info.type, "network");
assert.match(info.message, /Request too large/i);
assert.match(info.message, /client_max_body_size/i);
assert.match(info.message, /Raw:/);
});
test("classifyError detects 'Request Entity Too Large' in a string error", () => {
const info = classifyError("413 Request Entity Too Large");
assert.equal(info.type, "network");
assert.match(info.message, /Request too large/i);
});
test("classifyError handles 413 via the message when no statusCode field is set", () => {
const info = classifyError(new Error("AI_APICallError: 413 payload rejected"));
assert.equal(info.type, "network");
assert.match(info.message, /Request too large/i);
});
test("isRequestTooLargeError detects structured and textual 413 errors", () => {
assert.equal(isRequestTooLargeError(Object.assign(new Error("blocked"), { statusCode: 413 })), true);
assert.equal(isRequestTooLargeError("413 Request Entity Too Large"), true);
assert.equal(isRequestTooLargeError(new Error("AI_APICallError: 413 payload rejected")), true);
assert.equal(isRequestTooLargeError(Object.assign(new Error("bad gateway"), { statusCode: 502 })), false);
});
test("isRequestTooLargeError detects 413 hidden in an HTML response body", () => {
const err = Object.assign(new Error("Failed to parse provider response"), {
responseBody: "<html><body><center><h1>413 Request Entity Too Large</h1></center></body></html>",
});
assert.equal(isRequestTooLargeError(err), true);
});
test("isRequestTooLargeError does not treat timing text as HTTP 413", () => {
assert.equal(isRequestTooLargeError("upstream timed out in 413 ms"), false);
assert.equal(isRequestTooLargeError("413 ms"), false);
});
// -------------------------------------------------------------------
// classifyError — 502 / 503 / 504 upstream gateway
// -------------------------------------------------------------------
test("classifyError marks 502/503/504 as network+retryable", () => {
for (const code of [502, 503, 504]) {
const info = classifyError(Object.assign(new Error(`status ${code}`), { statusCode: code }));
assert.equal(info.type, "network");
assert.equal(info.retryable, true, `code ${code} should be retryable`);
assert.match(info.message, new RegExp(String(code)));
}
});
test("classifyError does not treat timing text as a gateway status", () => {
const info = classifyError("retry after 502 ms");
const leadingInfo = classifyError("502 ms");
assert.equal(info.type, "unknown");
assert.equal(leadingInfo.type, "unknown");
});
// -------------------------------------------------------------------
// classifyError — HTML response body
// -------------------------------------------------------------------
test("classifyError detects HTML in responseBody even when status is unknown", () => {
const err = Object.assign(new Error("Invalid JSON"), {
responseBody: "<!DOCTYPE html>\n<html><body>nginx error</body></html>",
});
const info = classifyError(err);
assert.equal(info.type, "provider");
assert.match(info.message, /HTML error page/i);
assert.match(info.message, /proxy/i);
});
test("classifyError detects HTML directly embedded in the error message", () => {
const info = classifyError("Parse failed: <html><body>...</body></html>");
assert.equal(info.type, "provider");
assert.match(info.message, /HTML error page/i);
});
// -------------------------------------------------------------------
// classifyError — Zod / schema parse failures
// -------------------------------------------------------------------
test("classifyError surfaces a friendlier message for 'Expected \\'id\\' to be a string.'", () => {
// This is the exact error pattern reported in #765.
const info = classifyError("Expected 'id' to be a string.");
assert.equal(info.type, "provider");
assert.match(info.message, /could not be parsed/i);
assert.match(info.message, /request-size limit/i);
// Raw error must still be visible for debugging / user reports.
assert.match(info.message, /Expected 'id' to be a string/);
});
test("classifyError handles a variety of schema validation wordings", () => {
for (const raw of [
"Invalid JSON response: missing field",
"Type validation failed: expected number",
"Expected 'choices' to be an array.",
]) {
const info = classifyError(raw);
assert.equal(info.type, "provider", `wording: ${raw}`);
assert.match(info.message, /could not be parsed|HTML error page/i);
}
});
// -------------------------------------------------------------------
// classifyError — fallthrough
// -------------------------------------------------------------------
test("classifyError falls through to 'unknown' for unclassified errors", () => {
const info = classifyError(new Error("Some other provider failure"));
assert.equal(info.type, "unknown");
assert.match(info.message, /Some other provider failure/);
});
test("classifyError handles null, undefined, and non-Error shapes without throwing", () => {
assert.doesNotThrow(() => classifyError(null));
assert.doesNotThrow(() => classifyError(undefined));
assert.doesNotThrow(() => classifyError({ foo: "bar" }));
assert.doesNotThrow(() => classifyError(42));
});

View File

@@ -0,0 +1,212 @@
import type { ChatMessage } from './types';
type ErrorInfo = NonNullable<ChatMessage['errorInfo']>;
/**
* Extract the human-readable message from anything that might surface as an
* error (Error instance, string, SDK error object with `.message`, etc.).
*/
function extractMessage(error: unknown): string {
if (error instanceof Error) return error.message || '';
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
const m = (error as { message: unknown }).message;
if (typeof m === 'string') return m;
}
try {
return JSON.stringify(error) ?? '';
} catch {
return '';
}
}
/**
* Pull the HTTP status code out of an error when the SDK layer attached one.
* Vercel AI SDK's APICallError exposes `.statusCode`; some shims use
* `.status` or `.cause.statusCode`. Falls back to parsing the message text
* when no structured field is available.
*/
function extractStatusCode(error: unknown, message: string): number | undefined {
if (error && typeof error === 'object') {
const obj = error as Record<string, unknown>;
if (typeof obj.statusCode === 'number') return obj.statusCode;
if (typeof obj.status === 'number') return obj.status;
if (obj.cause && typeof obj.cause === 'object') {
const causeStatus = (obj.cause as Record<string, unknown>).statusCode;
if (typeof causeStatus === 'number') return causeStatus;
}
}
const statusPatterns = [
/\bHTTP\s*(4\d{2}|5\d{2})\b/i,
/\bstatus(?:Code)?\s*[:=]?\s*(4\d{2}|5\d{2})\b/i,
/\bcode\s*[:=]\s*(4\d{2}|5\d{2})\b/i,
/^\s*(4\d{2}|5\d{2})\b(?!\s*ms\b)/i,
];
for (const pattern of statusPatterns) {
const match = message.match(pattern);
if (match) return Number(match[1]);
}
return undefined;
}
/**
* Pull the response body out of an error object if the SDK attached it.
* Nginx / CDN proxy error pages ship as HTML, so we can detect them here.
*/
function extractResponseBody(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
const body = (error as Record<string, unknown>).responseBody;
if (typeof body === 'string') return body;
return undefined;
}
export function isRequestTooLargeError(error: unknown): boolean {
const message = extractMessage(error).trim();
const responseBody = extractResponseBody(error) ?? "";
const combined = `${message}\n${responseBody}`;
const statusCode = extractStatusCode(error, combined);
return (
statusCode === 413 ||
/\brequest entity too large\b/i.test(combined) ||
/\b413\b(?!\s*ms\b).*\b(payload|request|too large|entity)\b/i.test(combined)
);
}
function looksLikeHtml(text: string): boolean {
if (!text) return false;
const lower = text.toLowerCase();
const trimmedStart = lower.trimStart().slice(0, 200);
// Start-of-body: responseBody captured verbatim by the SDK lands here.
if (
trimmedStart.startsWith('<!doctype html') ||
trimmedStart.startsWith('<html') ||
trimmedStart.startsWith('<head') ||
trimmedStart.startsWith('<body')
) {
return true;
}
// Embedded: some SDKs wrap the HTML body inside an error message like
// "Parse failed: <html>...". Look for unmistakable HTML tags anywhere
// in the text. Kept narrow to avoid flagging errors that casually
// mention "html" as a word.
if (
lower.includes('<!doctype html') ||
lower.includes('<html>') ||
lower.includes('<html ') ||
// Common nginx default error-page opener.
/<center>\s*<h1>/.test(lower)
) {
return true;
}
return false;
}
function looksLikeZodParseError(message: string): boolean {
// Zod and Vercel AI SDK schema errors look like:
// Expected 'id' to be a string.
// Expected 'choices' to be an array.
// Invalid JSON response: ...
// Type validation failed: ...
return (
/\bExpected '[^']+' to be (a|an) /i.test(message) ||
/\binvalid json response\b/i.test(message) ||
/\btype validation failed\b/i.test(message)
);
}
/**
* Map an arbitrary error surface to display-safe error info shown in the
* chat UI. Known hostile scenarios get a concrete, actionable message; the
* raw SDK text is appended so users can still report it verbatim.
*
* Covers:
* - HTTP 413 (proxy request-size limit, e.g. nginx client_max_body_size)
* - HTTP 502/504 (upstream proxy failures)
* - HTML error page returned in place of JSON (any proxy)
* - Schema/parse failures ("Expected 'id' to be a string.") that typically
* mean the server swapped the response body for an error page
*/
export function classifyError(error: unknown): ErrorInfo {
const rawMessage = extractMessage(error).trim() || 'Unknown error';
const statusCode = extractStatusCode(error, rawMessage);
const responseBody = extractResponseBody(error);
const hasHtml =
looksLikeHtml(rawMessage) ||
(responseBody !== undefined && looksLikeHtml(responseBody));
const looksLikeParseError = looksLikeZodParseError(rawMessage);
const sanitizedRaw = sanitizeErrorMessage(rawMessage);
if (isRequestTooLargeError(error)) {
return {
type: 'network',
message:
`Request too large (HTTP 413). The AI gateway rejected the payload — this usually means ` +
`the request body exceeded the proxy's size limit (for example nginx \`client_max_body_size\`). ` +
`Try sending a shorter message, fewer/smaller attachments, or raising the proxy limit.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: false,
};
}
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
return {
type: 'network',
message:
`AI gateway error (HTTP ${statusCode}). The proxy in front of the provider returned an error — ` +
`the upstream AI service may be unreachable or timing out.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: true,
};
}
if (hasHtml) {
return {
type: 'provider',
message:
`The server returned an HTML error page instead of a JSON response. ` +
`This almost always means a proxy (nginx / CDN / gateway) between you and the AI provider ` +
`intercepted the request — commonly due to a size limit, auth failure, or the upstream service being down.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: false,
};
}
if (looksLikeParseError) {
return {
type: 'provider',
message:
`The AI response could not be parsed as a valid chat completion. ` +
`A proxy may have replaced or truncated the response body, or the provider returned a non-standard format. ` +
`If you just sent a large request, check for a request-size limit on any intermediate proxy.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: false,
};
}
return { type: 'unknown', message: sanitizedRaw, retryable: false };
}
const MAX_ERROR_MESSAGE_LENGTH = 500;
/**
* Sanitize an error message before displaying it to the user.
* Strips file paths, URLs with credentials, and truncates long messages.
*/
export function sanitizeErrorMessage(msg: string): string {
let sanitized = msg;
// Strip file system paths (Unix and Windows)
sanitized = sanitized.replace(/(?:\/Users\/|\/home\/|\/tmp\/|\/var\/|[A-Z]:\\)[^\s"'`,;)}\]>]*/gi, '<path>');
// Strip URLs containing API keys or tokens in query params
sanitized = sanitized.replace(/https?:\/\/[^\s"']*[?&](key|token|api_key|apikey|secret|access_token|auth)=[^\s"'&]*/gi, '<url-redacted>');
// Truncate overly long messages
if (sanitized.length > MAX_ERROR_MESSAGE_LENGTH) {
sanitized = sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) + '...';
}
return sanitized;
}

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

View File

@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from './managedAgents';
test('managed Claude matching ignores legacy adapter command-only configs', () => {
assert.equal(
matchesManagedAgentConfig(
{
id: 'custom-claude-adapter',
command: 'claude-agent-acp',
acpCommand: 'custom-acp',
},
'claude',
),
false,
);
});
test('managed Claude matching ignores legacy adapter configs', () => {
assert.equal(
matchesManagedAgentConfig(
{
id: 'custom-claude-adapter',
command: 'claude-agent-acp',
acpCommand: 'claude-agent-acp',
},
'claude',
),
false,
);
});
test('codex managed config no longer matches legacy adapter backend values', () => {
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'codex', sdkBackend: 'codex' }, 'codex'),
true,
);
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'other', acpCommand: 'codex-acp' }, 'codex'),
false,
);
});
test('cursor managed config matches by sdk backend and discovered id', () => {
assert.equal(
matchesManagedAgentConfig({ id: 'discovered_cursor', command: 'cursor', sdkBackend: 'cursor' }, 'cursor'),
true,
);
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'other', sdkBackend: 'cursor' }, 'cursor'),
true,
);
});
test('claude managed config matches by sdk backend value', () => {
assert.equal(
matchesManagedAgentConfig({ id: 'discovered_claude', command: 'claude', sdkBackend: 'claude' }, 'claude'),
true,
);
});
test('legacy backend field is still accepted for saved settings', () => {
assert.equal(
getExternalAgentSdkBackend({ acpCommand: 'codex' }),
'codex',
);
});
test('grok managed config matches by sdk backend and discovered id', () => {
assert.equal(
matchesManagedAgentConfig({ id: 'discovered_grok', command: 'grok', sdkBackend: 'grok' }, 'grok'),
true,
);
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'other', sdkBackend: 'grok' }, 'grok'),
true,
);
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'C:\\\\Tools\\\\grok.exe', sdkBackend: 'grok' }, 'grok'),
true,
);
});

View File

@@ -0,0 +1,90 @@
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
import { getCommandBasename, isPathLikeCommand } from './shared/pathLikeCommand';
export { isPathLikeCommand, getCommandBasename };
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot' | 'cursor' | 'codebuddy' | 'opencode' | 'grok';
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; sdkBackend: string }> = {
codex: { commandNames: ['codex'], sdkBackend: 'codex' },
claude: { commandNames: ['claude'], sdkBackend: 'claude' },
copilot: { commandNames: ['copilot'], sdkBackend: 'copilot' },
cursor: { commandNames: ['cursor'], sdkBackend: 'cursor' },
codebuddy: { commandNames: ['codebuddy'], sdkBackend: 'codebuddy' },
opencode: { commandNames: ['opencode'], sdkBackend: 'opencode' },
grok: { commandNames: ['grok'], sdkBackend: 'grok' },
};
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
const basename = getCommandBasename(command);
return basename === agentKey || basename.startsWith(`${agentKey}.`);
}
export function isSettingsManagedDiscoveredAgent(
agent: Pick<DiscoveredAgent, 'command'>,
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
return agent.command === 'codex'
|| agent.command === 'claude'
|| agent.command === 'copilot'
|| agent.command === 'cursor'
|| agent.command === 'codebuddy'
|| agent.command === 'opencode'
|| agent.command === 'grok';
}
export function matchesManagedAgentConfig(
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'sdkBackend' | 'acpCommand'>,
agentKey: ManagedAgentKey,
): boolean {
const meta = MANAGED_AGENT_META[agentKey];
const basename = getCommandBasename(agent.command);
if (agentKey === 'claude') {
return (
agent.id === 'discovered_claude' ||
basename === 'claude' ||
basename.startsWith('claude.')
);
}
return (
agent.id === `discovered_${agentKey}` ||
getExternalAgentSdkBackend(agent) === meta.sdkBackend ||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
);
}
export function getExternalAgentSdkBackend(
agent: Pick<ExternalAgentConfig, 'sdkBackend' | 'acpCommand'> | undefined,
): string | undefined {
return agent?.sdkBackend || agent?.acpCommand || undefined;
}
export function getManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managedId = `discovered_${agentKey}`;
const preferredAgent = agents.find(
(agent) =>
agent.id === managedId &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
if (preferredAgent) {
return preferredAgent.command;
}
const fallbackAgent = agents.find(
(agent) =>
matchesManagedAgentConfig(agent, agentKey) &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
return fallbackAgent?.command ?? null;
}
export function getManualAgentCommand(
config: Pick<ExternalAgentConfig, 'command' | 'commandSource'> | null | undefined,
): string | undefined {
const command = String(config?.command || '').trim();
return config?.commandSource === 'manual' && command ? command : undefined;
}

View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "./modelDiscoveryHeaders";
test("buildModelDiscoveryHeaders uses x-api-key+anthropic-version for the anthropic family", () => {
assert.deepEqual(buildModelDiscoveryHeaders("anthropic", "sk-test"), {
"x-api-key": "sk-test",
"anthropic-version": "2023-06-01",
});
});
test("buildModelDiscoveryHeaders uses Bearer auth for the openai-compatible family", () => {
assert.deepEqual(buildModelDiscoveryHeaders("openai", "sk-test"), {
Authorization: "Bearer sk-test",
});
});
test("buildModelDiscoveryHeaders uses x-goog-api-key for the google family", () => {
// Google Generative AI rejects Bearer auth — discovery has to match
// the createGoogle runtime client, which uses x-goog-api-key.
assert.deepEqual(buildModelDiscoveryHeaders("google", "AIza-test"), {
"x-goog-api-key": "AIza-test",
});
});
test("buildModelDiscoveryHeaders returns no headers when the api key is missing", () => {
assert.deepEqual(buildModelDiscoveryHeaders("anthropic", undefined), {});
assert.deepEqual(buildModelDiscoveryHeaders("openai", ""), {});
});
test("buildModelDiscoveryHeaders honors the style override on an anthropic providerId pointing at an OpenAI-compatible backend", () => {
// Regression: PR #1105 lets users pick `style` independently from
// `providerId`. Without this fix the discovery call still sent
// `x-api-key` because the old code switched on `providerId === "anthropic"`.
assert.deepEqual(buildModelDiscoveryHeaders("openai", "sk-test"), {
Authorization: "Bearer sk-test",
});
});
test("resolveModelsDiscoveryEndpoint follows the resolved style by default", () => {
assert.equal(resolveModelsDiscoveryEndpoint("openai"), "/models");
assert.equal(resolveModelsDiscoveryEndpoint("anthropic"), "/v1/models");
assert.equal(resolveModelsDiscoveryEndpoint("google"), undefined);
});
test("resolveModelsDiscoveryEndpoint overrides the preset path when style flips", () => {
// Anthropic providerId preset would otherwise pin /v1/models, but the user
// switched style to openai — pick /models instead so the path matches the
// protocol family the headers already speak.
assert.equal(resolveModelsDiscoveryEndpoint("openai", "/v1/models"), "/models");
assert.equal(resolveModelsDiscoveryEndpoint("anthropic", "/models"), "/v1/models");
});
test("resolveModelsDiscoveryEndpoint falls back to the preset path only when the style has no convention", () => {
// google has no STYLE_DEFAULT — preserve whatever the caller passed.
assert.equal(resolveModelsDiscoveryEndpoint("google", "/custom/list"), "/custom/list");
assert.equal(resolveModelsDiscoveryEndpoint("google", undefined), undefined);
});

View File

@@ -0,0 +1,58 @@
import type { ProviderStyle } from "./types";
/**
* Conventional `/models`-listing path for each wire-protocol family. These
* are the same paths the official Anthropic/OpenAI/Google clients use,
* so they line up with what compliant Anthropic-compat or OpenAI-compat
* third parties (DeepSeek, Moonshot, Qwen, Ollama, OpenRouter, ...)
* expose. Google is `undefined` because Generative AI's discovery isn't
* a standard REST listing.
*/
export const STYLE_DEFAULT_MODELS_ENDPOINT: Record<ProviderStyle, string | undefined> = {
openai: "/models",
anthropic: "/v1/models",
google: undefined,
};
/**
* Pick the `/models` discovery path for a provider config. The resolved
* `style` wins — keeping it aligned with {@link buildModelDiscoveryHeaders}
* — falling back to the providerId-derived `presetEndpoint` only when the
* style has no convention of its own.
*/
export function resolveModelsDiscoveryEndpoint(
style: ProviderStyle,
presetEndpoint?: string,
): string | undefined {
return STYLE_DEFAULT_MODELS_ENDPOINT[style] ?? presetEndpoint;
}
/**
* Pick auth headers for a provider's `/models` discovery endpoint.
*
* Each wire-protocol family uses its own auth dialect:
* - `anthropic`: `x-api-key` + `anthropic-version`
* - `google`: `x-goog-api-key` (Google Generative AI rejects Bearer)
* - `openai`: `Authorization: Bearer …` (also the OpenAI-compat default)
*
* Returning an empty object when the key is missing lets the caller still
* issue an unauthenticated probe (e.g. against local Ollama).
*/
export function buildModelDiscoveryHeaders(
style: ProviderStyle,
apiKey: string | undefined,
): Record<string, string> {
if (!apiKey) return {};
switch (style) {
case "anthropic":
return {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
};
case "google":
return { "x-goog-api-key": apiKey };
case "openai":
default:
return { Authorization: `Bearer ${apiKey}` };
}
}

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeOllamaSdkBaseURL } from "./ollamaCompatBaseUrl";
test("normalizeOllamaSdkBaseURL appends /v1 to the Cloud origin only", () => {
assert.equal(normalizeOllamaSdkBaseURL("https://ollama.com"), "https://ollama.com/v1");
assert.equal(normalizeOllamaSdkBaseURL("https://ollama.com/"), "https://ollama.com/v1");
assert.equal(normalizeOllamaSdkBaseURL("HTTP://OLLAMA.COM"), "HTTP://OLLAMA.COM/v1");
assert.equal(normalizeOllamaSdkBaseURL("https://ollama.com/v1"), "https://ollama.com/v1");
assert.equal(normalizeOllamaSdkBaseURL("https://ollama.com/v1/"), "https://ollama.com/v1");
assert.equal(normalizeOllamaSdkBaseURL("https://ollama.com/api"), "https://ollama.com/v1");
assert.equal(normalizeOllamaSdkBaseURL("https://ollama.com/api/"), "https://ollama.com/v1");
assert.equal(normalizeOllamaSdkBaseURL("http://localhost:11434/v1"), "http://localhost:11434/v1");
assert.equal(normalizeOllamaSdkBaseURL("http://192.168.1.10:11434/v1"), "http://192.168.1.10:11434/v1");
});

Some files were not shown because too many files have changed in this diff Show More