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