[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

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

View File

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