[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
@@ -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.');
|
||||
});
|
||||
495
infrastructure/ai/harness/turnDrivers/cattyMessageBuilder.ts
Normal file
495
infrastructure/ai/harness/turnDrivers/cattyMessageBuilder.ts
Normal 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 };
|
||||
585
infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts
Normal file
585
infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts
Normal 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 };
|
||||
549
infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts
Normal file
549
infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts
Normal 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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
495
infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts
Normal file
495
infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts
Normal 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();
|
||||
187
infrastructure/ai/harness/turnDrivers/types.ts
Normal file
187
infrastructure/ai/harness/turnDrivers/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user