diff --git a/CONTEXT.md b/CONTEXT.md index bd067f9133..4598fcf13b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,6 +53,23 @@ never the decision. There is exactly one persisted link format (inferred or local-link) and never on a dry run. The project client (control plane over HTTP, CLI API client, fake in tests) is its one seam. +## Tool Replay Reconciliation + +The single owner of deciding which tool-call and tool-result occurrences in +UI-message replay history are authoritative for provider conversion: +`src/chat/tool-replay-reconciliation.ts`. Matching is by part **object +identity**, so one pass over history marks parts as matched, superseded, +batch-starting, or transient-but-preserved without mutating them. Provider +conversion and message preparation both consult this module rather than +re-deriving which occurrence wins. + +## Message Part Interpretation + +The single owner of interpreting one message part — tool, text, reasoning, or +file — into a normalized shape: `src/chat/message-part-parsing.ts`. Provider +conversion and Tool Replay Reconciliation both read parts through it, so a +change to how a part is recognized lands in one file. + ## Stream Delivery The separate agent-loop fan-out boundary that will route lifecycle frames to diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index 0791f74e46..f144391054 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -109,6 +109,15 @@ const FILE_SIZE_CEILINGS: Record = { "src/react/components/chat/chat-actions.tsx": 203, "src/react/components/chat/chat/controlled-chat.tsx": 242, "src/react/components/chat/chat/app-mode-chat.tsx": 177, + // Chat core: message construction and provider-conversion, split along its + // real seams (part-field-access, message-part-parsing, tool-replay + // reconciliation). Not React components, so this map does not subject them + // to the antipattern ratchets above — it only pins their size. + "src/chat/conversation.ts": 1018, + "src/chat/message-prep.ts": 2016, + "src/chat/tool-replay-reconciliation.ts": 294, + "src/chat/message-part-parsing.ts": 264, + "src/chat/part-field-access.ts": 66, }; function checkFileSizes(): boolean { diff --git a/src/chat/conversation.ts b/src/chat/conversation.ts index 5756ef33a9..edd6c50af1 100644 --- a/src/chat/conversation.ts +++ b/src/chat/conversation.ts @@ -7,7 +7,27 @@ import type { ChatUiMessageRole, ProviderModelMessage, } from "./types.ts"; -import { stringifyChatJson, toChatJsonValue } from "./json-value.ts"; +import { getOptionalStringField, isRecord, toRecord } from "./part-field-access.ts"; +import type { JsonValue } from "./part-field-access.ts"; +import { + buildRawToolCallResultOutput, + buildToolResultOutput, + getFilePart, + getRawToolCallPart, + getRawToolResultPart, + getToolPart, + isProviderVisibleReasoningPart, + isTextPart, +} from "./message-part-parsing.ts"; +import { + findProviderVisibleToolReplayMatches, + isTransientToolState, +} from "./tool-replay-reconciliation.ts"; +import type { ProviderVisibleToolReplayMatches } from "./tool-replay-reconciliation.ts"; + +export { getStringField, isRecord, stringifyUnknown } from "./part-field-access.ts"; +export type { JsonValue } from "./part-field-access.ts"; +export { isReasoningPart, isTextPart } from "./message-part-parsing.ts"; const PROVIDER_MODEL_MESSAGE_SOURCE_ID = Symbol.for("veryfront.providerModelMessageSourceId"); const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; @@ -191,23 +211,8 @@ export interface ToolResultLike { providerOptions?: unknown; } -/** Text-like provider message part. */ -export interface TextPartLike { - type: "text"; - text: string; -} - -/** Reasoning-like provider message part. */ -export interface ReasoningPartLike { - type: "reasoning"; - text?: string; - signature?: string; - redactedData?: string; -} - /** Chat UI tool part with a call ID and state. */ type ToolUiPart = Extract; -type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; type ProviderToolResultContent = { type: "tool-result"; toolCallId: string; @@ -292,52 +297,6 @@ export function mapToolState(sdkState: string): "streaming" | "pending" | "compl } } -/** Record shape for is. */ -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Return string field. */ -export function getStringField(value: unknown, field: string, fallback: string): string { - if (!isRecord(value) || typeof value[field] !== "string") { - return fallback; - } - - return value[field]; -} - -function getOptionalStringField(value: unknown, key: string): string | undefined { - if (!isRecord(value)) { - return undefined; - } - - const field = value[key]; - return typeof field === "string" ? field : undefined; -} - -function getNonEmptyStringField(value: unknown, key: string): string | undefined { - const field = getOptionalStringField(value, key); - return field && field.length > 0 ? field : undefined; -} - -function toRecord(value: unknown): Record { - return isRecord(value) ? Object.fromEntries(Object.entries(value)) : {}; -} - -/** Stringify unknown helper. */ -export function stringifyUnknown(value: unknown): string { - if (typeof value === "string") return value; - if ( - typeof value === "bigint" || - typeof value === "undefined" || - typeof value === "function" || - typeof value === "symbol" - ) { - return String(value); - } - return stringifyChatJson(value); -} - /** Check whether a chat part is a custom data part. */ export function isDataUiPart( part: ChatUiMessagePart, @@ -634,26 +593,6 @@ export function isToolResultPart(value: unknown): value is ToolResultLike { ); } -/** Check whether a value is a text part. */ -export function isTextPart(value: unknown): value is TextPartLike { - return isRecord(value) && value.type === "text" && typeof value.text === "string"; -} - -/** Check whether a value is a reasoning part. */ -export function isReasoningPart(value: unknown): value is ReasoningPartLike { - return isRecord(value) && value.type === "reasoning" && - (typeof value.text === "string" || - typeof value.signature === "string" || - typeof value.redactedData === "string"); -} - -function isProviderVisibleReasoningPart(value: unknown): value is ReasoningPartLike { - return isReasoningPart(value) && - (getNonEmptyStringField(value, "text") !== undefined || - getNonEmptyStringField(value, "signature") !== undefined || - getNonEmptyStringField(value, "redactedData") !== undefined); -} - /** Message shape for extract text from. */ export function extractTextFromMessage(message: ProviderModelMessage): string { if (!message || !message.content) return ""; @@ -677,164 +616,6 @@ export function extractTextFromMessage(message: ProviderModelMessage): string { return ""; } -function toJsonValue(value: unknown): JsonValue { - return toChatJsonValue(value); -} - -function getFilePart(part: unknown): { - type: "file" | "image"; - mediaType: string; - data: string; - url: string; - filename?: string; - uploadId?: string; - uploadPath?: string; -} | null { - if (!isRecord(part) || (part.type !== "file" && part.type !== "image")) { - return null; - } - - const mediaType = getNonEmptyStringField(part, "mediaType") ?? - getNonEmptyStringField(part, "media_type"); - const data = getNonEmptyStringField(part, "url"); - if (!mediaType || !data) { - return null; - } - - const filename = getNonEmptyStringField(part, "filename"); - const uploadId = getNonEmptyStringField(part, "uploadId") ?? - getNonEmptyStringField(part, "upload_id"); - const uploadPath = getNonEmptyStringField(part, "uploadPath") ?? - getNonEmptyStringField(part, "upload_path"); - - return { - type: part.type === "image" ? "image" : "file", - mediaType, - data, - url: data, - ...(filename ? { filename } : {}), - ...(uploadId ? { uploadId } : {}), - ...(uploadPath ? { uploadPath } : {}), - }; -} - -function getToolPart(part: unknown): { - toolCallId: string; - toolName: string; - input: Record; - state: string; - output?: unknown; - errorText?: string; -} | null { - if (!isRecord(part) || typeof part.type !== "string") { - return null; - } - - const type = part.type; - const toolCallId = getNonEmptyStringField(part, "toolCallId"); - const state = getNonEmptyStringField(part, "state"); - const explicitToolName = getNonEmptyStringField(part, "toolName") ?? - getNonEmptyStringField(part, "name"); - const derivedToolName = - type === "dynamic-tool" || type === "tool_call" || !type.startsWith("tool-") - ? undefined - : type.replace(/^tool-/, ""); - const toolName = explicitToolName ?? derivedToolName; - if (!toolCallId || !state || !toolName) { - return null; - } - - const errorText = getOptionalStringField(part, "errorText"); - const output = Object.hasOwn(part, "output") ? part.output : undefined; - - return { - toolCallId, - toolName, - input: toRecord(part.input), - state, - ...(output !== undefined ? { output } : {}), - ...(errorText !== undefined ? { errorText } : {}), - }; -} - -function getRawToolCallPart(part: unknown): { - toolCallId: string; - toolName: string; - input: Record; - state?: string; - output?: unknown; - errorText?: string; -} | null { - if (!isRecord(part) || part.type !== "tool_call") { - return null; - } - - const toolCallId = getNonEmptyStringField(part, "toolCallId") ?? - getNonEmptyStringField(part, "tool_call_id") ?? - getNonEmptyStringField(part, "id"); - const toolName = getNonEmptyStringField(part, "toolName") ?? - getNonEmptyStringField(part, "tool_name") ?? - getNonEmptyStringField(part, "name"); - - if (!toolCallId || !toolName) { - return null; - } - - return { - toolCallId, - toolName, - input: toRecord(part.input), - ...(typeof part.state === "string" ? { state: part.state } : {}), - ...(Object.hasOwn(part, "output") ? { output: part.output } : {}), - ...(typeof part.errorText === "string" ? { errorText: part.errorText } : {}), - }; -} - -function getRawToolResultPart(part: unknown): { - toolCallId: string; - toolName?: string; - output: - | { - type: "json"; - value: JsonValue; - } - | { - type: "error-text"; - value: string; - }; -} | null { - if (!isRecord(part) || part.type !== "tool_result") { - return null; - } - - const toolCallId = getNonEmptyStringField(part, "toolCallId") ?? - getNonEmptyStringField(part, "tool_call_id") ?? - getNonEmptyStringField(part, "id"); - if (!toolCallId) { - return null; - } - - const toolName = getNonEmptyStringField(part, "toolName") ?? - getNonEmptyStringField(part, "tool_name") ?? - getNonEmptyStringField(part, "name"); - const isError = part.is_error === true || part.isError === true; - const output = isError - ? { - type: "error-text" as const, - value: stringifyUnknown(part.output ?? "Tool error"), - } - : { - type: "json" as const, - value: toJsonValue(part.output), - }; - - return { - toolCallId, - ...(toolName ? { toolName } : {}), - output, - }; -} - function buildToolNameMap(parts: ReadonlyArray): Map { const toolNames = new Map(); @@ -877,80 +658,6 @@ function resolveRawToolResultPart( }; } -function buildToolResultOutput(toolPart: { state: string; output?: unknown; errorText?: string }): - | { - type: "json"; - value: JsonValue; - } - | { - type: "error-text"; - value: string; - } - | null { - if (toolPart.state === "output-available") { - return { - type: "json", - value: toJsonValue(toolPart.output), - }; - } - - if ( - toolPart.state === "output-error" || toolPart.state === "output-denied" || - toolPart.state === "error" - ) { - return { - type: "error-text", - value: toolPart.errorText ?? stringifyUnknown(toolPart.output ?? "Tool error"), - }; - } - - return null; -} - -function isTransientToolState(state: string | undefined): boolean { - return state === "pending" || state === "input-available" || state === "input-streaming" || - state === "streaming" || state === "approval-requested" || state === "approval-responded"; -} - -type ReplayToolCallPart = { - part: object; - toolCallId: string; - toolName: string; - transient: boolean; - selfContainedResult: boolean; -}; - -type PendingReplayToolCall = Omit & { - originMessageIndex: number; -}; - -function buildRawToolCallResultOutput( - rawToolCall: NonNullable>, -): ReturnType { - if (!rawToolCall.state) { - return null; - } - - return buildToolResultOutput({ - state: rawToolCall.state, - ...(rawToolCall.output !== undefined ? { output: rawToolCall.output } : {}), - ...(rawToolCall.errorText !== undefined ? { errorText: rawToolCall.errorText } : {}), - }); -} - -function hasSelfContainedRawToolCallResult( - rawToolCall: NonNullable>, -): boolean { - if ( - rawToolCall.state === "error" && rawToolCall.output === undefined && - rawToolCall.errorText === undefined - ) { - return false; - } - - return buildRawToolCallResultOutput(rawToolCall) !== null; -} - function shouldSkipTransientToolCall( part: unknown, state: string | undefined, @@ -960,258 +667,6 @@ function shouldSkipTransientToolCall( (!isRecord(part) || !replayMatches.preservedTransientToolParts.has(part)); } -function getReplayToolCallPart(part: unknown, role: ChatUiMessageRole): ReplayToolCallPart | null { - if (role !== "assistant") { - return null; - } - - if (!isRecord(part)) { - return null; - } - - const toolPart = getToolPart(part); - if (toolPart) { - return { - part, - toolCallId: toolPart.toolCallId, - toolName: toolPart.toolName, - transient: isTransientToolState(toolPart.state), - selfContainedResult: buildToolResultOutput(toolPart) !== null, - }; - } - - const rawToolCall = getRawToolCallPart(part); - if (!rawToolCall) { - return null; - } - - return { - part, - toolCallId: rawToolCall.toolCallId, - toolName: rawToolCall.toolName, - transient: isTransientToolState(rawToolCall.state), - selfContainedResult: hasSelfContainedRawToolCallResult(rawToolCall), - }; -} - -function getReplayToolResultPart(part: unknown, role: ChatUiMessageRole): { - part: object; - toolCallId: string; - toolName?: string; -} | null { - if (role !== "assistant" && role !== "tool") { - return null; - } - - if (!isRecord(part)) { - return null; - } - - const rawToolResult = getRawToolResultPart(part); - if (rawToolResult) { - return { - part, - toolCallId: rawToolResult.toolCallId, - ...(rawToolResult.toolName ? { toolName: rawToolResult.toolName } : {}), - }; - } - - const toolPart = getToolPart(part); - if (role === "tool" && toolPart && buildToolResultOutput(toolPart)) { - return { - part, - toolCallId: toolPart.toolCallId, - toolName: toolPart.toolName, - }; - } - - return null; -} - -function isProviderVisibleNonToolPart(role: ChatUiMessageRole, part: unknown): boolean { - if (role === "system") { - return isTextPart(part) && part.text.length > 0; - } - - if (role === "user") { - return isTextPart(part) && part.text.length > 0 || getFilePart(part) !== null; - } - - if (role === "assistant") { - return isTextPart(part) && part.text.length > 0 || isProviderVisibleReasoningPart(part) || - getFilePart(part) !== null; - } - - return false; -} - -function isCompatibleToolResultName( - call: { toolName: string }, - result: { toolName?: string }, -): boolean { - return !result.toolName || result.toolName === call.toolName; -} - -function removePendingCallsThroughMatchedResult( - pendingCalls: PendingReplayToolCall[], - matchedIndex: number, - toolCallId: string, -): void { - const priorUnmatchedCalls = pendingCalls.slice(0, matchedIndex).filter((pendingCall) => - pendingCall.toolCallId !== toolCallId - ); - pendingCalls.splice(0, matchedIndex + 1, ...priorUnmatchedCalls); -} - -function removePendingCallsWithId( - pendingCalls: Array<{ toolCallId: string }>, - toolCallId: string, -): void { - for (let index = pendingCalls.length - 1; index >= 0; index--) { - if (pendingCalls[index]?.toolCallId === toolCallId) { - pendingCalls.splice(index, 1); - } - } -} - -function removePendingCallsFromEarlierMessages( - pendingCalls: Array<{ originMessageIndex: number }>, - messageIndex: number, -): void { - for (let index = pendingCalls.length - 1; index >= 0; index--) { - if ((pendingCalls[index]?.originMessageIndex ?? messageIndex) < messageIndex) { - pendingCalls.splice(index, 1); - } - } -} - -function hasPendingCallsFromEarlierMessages( - pendingCalls: Array<{ originMessageIndex: number }>, - messageIndex: number, -): boolean { - return pendingCalls.some((pendingCall) => pendingCall.originMessageIndex < messageIndex); -} - -/** Tool replay parts that are valid to expose to provider conversion. */ -export type ProviderVisibleToolReplayMatches = { - preservedTransientToolParts: WeakSet; - matchedToolCallParts: WeakSet; - matchedToolResultParts: WeakSet; - matchedToolResultNames: WeakMap; - toolCallPartsStartingNewBatch: WeakSet; - supersededToolCallParts: WeakSet; - supersededToolResultParts: WeakSet; -}; - -/** Find adjacent replay call/result occurrences using part object identity. */ -export function findProviderVisibleToolReplayMatches( - messages: readonly ChatProviderModelInputMessage[], -): ProviderVisibleToolReplayMatches { - const preservedTransientToolParts = new WeakSet(); - const matchedToolCallParts = new WeakSet(); - const matchedToolResultParts = new WeakSet(); - const matchedToolResultNames = new WeakMap(); - const toolCallPartsStartingNewBatch = new WeakSet(); - const supersededToolCallParts = new WeakSet(); - const supersededToolResultParts = new WeakSet(); - const matchedResultPartByCallPart = new WeakMap(); - const toolCallsById = new Map(); - const pendingCalls: PendingReplayToolCall[] = []; - - for (const [messageIndex, message] of messages.entries()) { - let pendingCountBeforeSameMessageVisibleContent: number | null = null; - - for (const part of message.parts) { - const call = getReplayToolCallPart(part, message.role); - if (call) { - const callsWithId = toolCallsById.get(call.toolCallId) ?? []; - callsWithId.push(call); - toolCallsById.set(call.toolCallId, callsWithId); - - if (hasPendingCallsFromEarlierMessages(pendingCalls, messageIndex)) { - toolCallPartsStartingNewBatch.add(call.part); - } - removePendingCallsFromEarlierMessages(pendingCalls, messageIndex); - if (pendingCountBeforeSameMessageVisibleContent !== null) { - pendingCalls.splice(0, pendingCountBeforeSameMessageVisibleContent); - pendingCountBeforeSameMessageVisibleContent = null; - } - removePendingCallsWithId(pendingCalls, call.toolCallId); - if (call.selfContainedResult) { - for (const priorCall of callsWithId) { - if (priorCall.part === call.part) { - continue; - } - - supersededToolCallParts.add(priorCall.part); - const priorResultPart = matchedResultPartByCallPart.get(priorCall.part); - if (priorResultPart) { - supersededToolResultParts.add(priorResultPart); - } - } - continue; - } - - pendingCalls.push({ ...call, originMessageIndex: messageIndex }); - continue; - } - - const result = getReplayToolResultPart(part, message.role); - if (result) { - const matchedIndex = pendingCalls.findLastIndex((pendingCall) => - pendingCall.toolCallId === result.toolCallId && - isCompatibleToolResultName(pendingCall, result) - ); - if (matchedIndex >= 0) { - const matchedCall = pendingCalls[matchedIndex]; - if (!matchedCall) { - continue; - } - if (matchedCall.transient) { - preservedTransientToolParts.add(matchedCall.part); - } - for (const priorCall of toolCallsById.get(matchedCall.toolCallId) ?? []) { - if (priorCall.part === matchedCall.part) { - continue; - } - - supersededToolCallParts.add(priorCall.part); - const priorResultPart = matchedResultPartByCallPart.get(priorCall.part); - if (priorResultPart) { - supersededToolResultParts.add(priorResultPart); - } - } - matchedToolCallParts.add(matchedCall.part); - matchedToolResultParts.add(result.part); - matchedToolResultNames.set(result.part, matchedCall.toolName); - matchedResultPartByCallPart.set(matchedCall.part, result.part); - removePendingCallsThroughMatchedResult(pendingCalls, matchedIndex, result.toolCallId); - } - continue; - } - - if (isProviderVisibleNonToolPart(message.role, part)) { - removePendingCallsFromEarlierMessages(pendingCalls, messageIndex); - pendingCountBeforeSameMessageVisibleContent ??= pendingCalls.length; - } - } - - if (pendingCountBeforeSameMessageVisibleContent !== null) { - pendingCalls.splice(0, pendingCountBeforeSameMessageVisibleContent); - } - } - - return { - preservedTransientToolParts, - matchedToolCallParts, - matchedToolResultParts, - matchedToolResultNames, - toolCallPartsStartingNewBatch, - supersededToolCallParts, - supersededToolResultParts, - }; -} - function convertSystemMessage(message: ChatProviderModelInputMessage): ProviderModelMessage[] { const content = message.parts.flatMap((part) => (isTextPart(part) ? [part.text] : [])).join(""); if (content.length === 0) { diff --git a/src/chat/message-part-parsing.test.ts b/src/chat/message-part-parsing.test.ts new file mode 100644 index 0000000000..d24f408e2f --- /dev/null +++ b/src/chat/message-part-parsing.test.ts @@ -0,0 +1,106 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + buildToolResultOutput, + getFilePart, + getRawToolCallPart, + getRawToolResultPart, + getToolPart, + hasSelfContainedRawToolCallResult, +} from "./message-part-parsing.ts"; + +describe("message-part-parsing", () => { + it("derives the tool name from a tool- prefixed type", () => { + const parsed = getToolPart({ + type: "tool-search", + toolCallId: "c1", + state: "output-available", + input: { q: "x" }, + }); + assertEquals(parsed?.toolName, "search"); + assertEquals(parsed?.toolCallId, "c1"); + }); + + it("rejects a part missing toolCallId, state, or name", () => { + assertEquals(getToolPart({ type: "tool-search", state: "output-available" }), null); + assertEquals(getToolPart({ type: "dynamic-tool", toolCallId: "c1", state: "s" }), null); + assertEquals(getToolPart(null), null); + }); + + it("maps an errored raw tool result to error-text", () => { + const parsed = getRawToolResultPart({ + type: "tool_result", + toolCallId: "c1", + is_error: true, + output: "boom", + }); + assertEquals(parsed?.output.type, "error-text"); + assertEquals(parsed?.output.value, "boom"); + }); + + it("returns null output for states that carry no result yet", () => { + assertEquals(buildToolResultOutput({ state: "input-available" }), null); + assertEquals( + buildToolResultOutput({ state: "output-error", errorText: "bad" }), + { type: "error-text", value: "bad" }, + ); + }); + + it("treats an errored tool call with no payload as not self-contained", () => { + const bare = getRawToolCallPart({ + type: "tool_call", + id: "c1", + name: "search", + state: "error", + }); + assertEquals(bare === null, false); + // buildRawToolCallResultOutput alone would return an error-text result for + // this state. The guard is what keeps it from superseding the paired result. + assertEquals(buildToolResultOutput({ state: "error" }), { + type: "error-text", + value: "Tool error", + }); + assertEquals(hasSelfContainedRawToolCallResult(bare!), false); + }); + + it("treats an errored tool call carrying errorText or output as self-contained", () => { + const withErrorText = getRawToolCallPart({ + type: "tool_call", + id: "c1", + name: "search", + state: "error", + errorText: "boom", + }); + assertEquals(hasSelfContainedRawToolCallResult(withErrorText!), true); + + const withOutput = getRawToolCallPart({ + type: "tool_call", + id: "c2", + name: "search", + state: "output-available", + output: { ok: true }, + }); + assertEquals(hasSelfContainedRawToolCallResult(withOutput!), true); + }); + + it("treats a stateless tool call as not self-contained", () => { + const stateless = getRawToolCallPart({ type: "tool_call", id: "c3", name: "search" }); + assertEquals(hasSelfContainedRawToolCallResult(stateless!), false); + }); + + it("reads a file part only when it carries a url", () => { + assertEquals( + getFilePart({ type: "file", mediaType: "text/plain", url: "https://e.test/a.txt" }), + { + type: "file", + mediaType: "text/plain", + data: "https://e.test/a.txt", + url: "https://e.test/a.txt", + }, + ); + assertEquals(getFilePart({ type: "file", mediaType: "text/plain" }), null); + assertEquals(getFilePart({ type: "file", url: "https://e.test/a.txt" }), null); + assertEquals(getFilePart({ type: "text", text: "hi" }), null); + }); +}); diff --git a/src/chat/message-part-parsing.ts b/src/chat/message-part-parsing.ts new file mode 100644 index 0000000000..86b6e6bd58 --- /dev/null +++ b/src/chat/message-part-parsing.ts @@ -0,0 +1,264 @@ +/** + * Interpreting one message part. + * + * The single owner of turning one raw or UI message part — tool-call, + * tool-result, text, reasoning, or file/image — into a normalized shape, so + * provider conversion and replay reconciliation read the same interpretation + * of a part rather than each deriving their own. + */ +import { + getNonEmptyStringField, + getOptionalStringField, + isRecord, + stringifyUnknown, + toJsonValue, + toRecord, +} from "./part-field-access.ts"; +import type { JsonValue } from "./part-field-access.ts"; + +export function getToolPart(part: unknown): { + toolCallId: string; + toolName: string; + input: Record; + state: string; + output?: unknown; + errorText?: string; +} | null { + if (!isRecord(part) || typeof part.type !== "string") { + return null; + } + + const type = part.type; + const toolCallId = getNonEmptyStringField(part, "toolCallId"); + const state = getNonEmptyStringField(part, "state"); + const explicitToolName = getNonEmptyStringField(part, "toolName") ?? + getNonEmptyStringField(part, "name"); + const derivedToolName = + type === "dynamic-tool" || type === "tool_call" || !type.startsWith("tool-") + ? undefined + : type.replace(/^tool-/, ""); + const toolName = explicitToolName ?? derivedToolName; + if (!toolCallId || !state || !toolName) { + return null; + } + + const errorText = getOptionalStringField(part, "errorText"); + const output = Object.hasOwn(part, "output") ? part.output : undefined; + + return { + toolCallId, + toolName, + input: toRecord(part.input), + state, + ...(output !== undefined ? { output } : {}), + ...(errorText !== undefined ? { errorText } : {}), + }; +} + +export function getRawToolCallPart(part: unknown): { + toolCallId: string; + toolName: string; + input: Record; + state?: string; + output?: unknown; + errorText?: string; +} | null { + if (!isRecord(part) || part.type !== "tool_call") { + return null; + } + + const toolCallId = getNonEmptyStringField(part, "toolCallId") ?? + getNonEmptyStringField(part, "tool_call_id") ?? + getNonEmptyStringField(part, "id"); + const toolName = getNonEmptyStringField(part, "toolName") ?? + getNonEmptyStringField(part, "tool_name") ?? + getNonEmptyStringField(part, "name"); + + if (!toolCallId || !toolName) { + return null; + } + + return { + toolCallId, + toolName, + input: toRecord(part.input), + ...(typeof part.state === "string" ? { state: part.state } : {}), + ...(Object.hasOwn(part, "output") ? { output: part.output } : {}), + ...(typeof part.errorText === "string" ? { errorText: part.errorText } : {}), + }; +} + +export function getRawToolResultPart(part: unknown): { + toolCallId: string; + toolName?: string; + output: + | { + type: "json"; + value: JsonValue; + } + | { + type: "error-text"; + value: string; + }; +} | null { + if (!isRecord(part) || part.type !== "tool_result") { + return null; + } + + const toolCallId = getNonEmptyStringField(part, "toolCallId") ?? + getNonEmptyStringField(part, "tool_call_id") ?? + getNonEmptyStringField(part, "id"); + if (!toolCallId) { + return null; + } + + const toolName = getNonEmptyStringField(part, "toolName") ?? + getNonEmptyStringField(part, "tool_name") ?? + getNonEmptyStringField(part, "name"); + const isError = part.is_error === true || part.isError === true; + const output = isError + ? { + type: "error-text" as const, + value: stringifyUnknown(part.output ?? "Tool error"), + } + : { + type: "json" as const, + value: toJsonValue(part.output), + }; + + return { + toolCallId, + ...(toolName ? { toolName } : {}), + output, + }; +} + +export function buildToolResultOutput( + toolPart: { state: string; output?: unknown; errorText?: string }, +): + | { + type: "json"; + value: JsonValue; + } + | { + type: "error-text"; + value: string; + } + | null { + if (toolPart.state === "output-available") { + return { + type: "json", + value: toJsonValue(toolPart.output), + }; + } + + if ( + toolPart.state === "output-error" || toolPart.state === "output-denied" || + toolPart.state === "error" + ) { + return { + type: "error-text", + value: toolPart.errorText ?? stringifyUnknown(toolPart.output ?? "Tool error"), + }; + } + + return null; +} + +export function buildRawToolCallResultOutput( + rawToolCall: NonNullable>, +): ReturnType { + if (!rawToolCall.state) { + return null; + } + + return buildToolResultOutput({ + state: rawToolCall.state, + ...(rawToolCall.output !== undefined ? { output: rawToolCall.output } : {}), + ...(rawToolCall.errorText !== undefined ? { errorText: rawToolCall.errorText } : {}), + }); +} + +export function hasSelfContainedRawToolCallResult( + rawToolCall: NonNullable>, +): boolean { + if ( + rawToolCall.state === "error" && rawToolCall.output === undefined && + rawToolCall.errorText === undefined + ) { + return false; + } + + return buildRawToolCallResultOutput(rawToolCall) !== null; +} + +/** Text-like provider message part. */ +export interface TextPartLike { + type: "text"; + text: string; +} + +/** Reasoning-like provider message part. */ +export interface ReasoningPartLike { + type: "reasoning"; + text?: string; + signature?: string; + redactedData?: string; +} + +/** Check whether a value is a text part. */ +export function isTextPart(value: unknown): value is TextPartLike { + return isRecord(value) && value.type === "text" && typeof value.text === "string"; +} + +/** Check whether a value is a reasoning part. */ +export function isReasoningPart(value: unknown): value is ReasoningPartLike { + return isRecord(value) && value.type === "reasoning" && + (typeof value.text === "string" || + typeof value.signature === "string" || + typeof value.redactedData === "string"); +} + +export function isProviderVisibleReasoningPart(value: unknown): value is ReasoningPartLike { + return isReasoningPart(value) && + (getNonEmptyStringField(value, "text") !== undefined || + getNonEmptyStringField(value, "signature") !== undefined || + getNonEmptyStringField(value, "redactedData") !== undefined); +} + +export function getFilePart(part: unknown): { + type: "file" | "image"; + mediaType: string; + data: string; + url: string; + filename?: string; + uploadId?: string; + uploadPath?: string; +} | null { + if (!isRecord(part) || (part.type !== "file" && part.type !== "image")) { + return null; + } + + const mediaType = getNonEmptyStringField(part, "mediaType") ?? + getNonEmptyStringField(part, "media_type"); + const url = getNonEmptyStringField(part, "url"); + if (!mediaType || !url) { + return null; + } + + const filename = getNonEmptyStringField(part, "filename"); + const uploadId = getNonEmptyStringField(part, "uploadId") ?? + getNonEmptyStringField(part, "upload_id"); + const uploadPath = getNonEmptyStringField(part, "uploadPath") ?? + getNonEmptyStringField(part, "upload_path"); + + return { + type: part.type === "image" ? "image" : "file", + mediaType, + data: url, + url, + ...(filename ? { filename } : {}), + ...(uploadId ? { uploadId } : {}), + ...(uploadPath ? { uploadPath } : {}), + }; +} diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 568b238ebc..bcfa4b410b 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1,12 +1,12 @@ import { convertUiMessagesToProviderModelMessages, copyProviderModelMessageSourceId, - findProviderVisibleToolReplayMatches, getStringField, isReasoningPart, isToolCallPart, isToolResultPart, } from "./conversation.ts"; +import { findProviderVisibleToolReplayMatches } from "./tool-replay-reconciliation.ts"; import { buildDataFileAnnotation, type ChatAssistantContentPart, diff --git a/src/chat/part-field-access.test.ts b/src/chat/part-field-access.test.ts new file mode 100644 index 0000000000..0edfa43174 --- /dev/null +++ b/src/chat/part-field-access.test.ts @@ -0,0 +1,56 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + getNonEmptyStringField, + getOptionalStringField, + getStringField, + isRecord, + stringifyUnknown, + toJsonValue, + toRecord, +} from "./part-field-access.ts"; + +describe("part-field-access", () => { + it("treats arrays and null as non-records", () => { + assertEquals(isRecord({}), true); + assertEquals(isRecord([]), false); + assertEquals(isRecord(null), false); + assertEquals(isRecord("x"), false); + }); + + it("falls back when the field is absent or not a string", () => { + assertEquals(getStringField({ a: "v" }, "a", "fb"), "v"); + assertEquals(getStringField({ a: 1 }, "a", "fb"), "fb"); + assertEquals(getStringField(null, "a", "fb"), "fb"); + }); + + it("distinguishes optional from non-empty string fields", () => { + assertEquals(getOptionalStringField({ a: "" }, "a"), ""); + assertEquals(getNonEmptyStringField({ a: "" }, "a"), undefined); + assertEquals(getNonEmptyStringField({ a: "v" }, "a"), "v"); + }); + + it("converts non-records to an empty record", () => { + assertEquals(toRecord({ a: 1 }), { a: 1 }); + assertEquals(toRecord(null), {}); + assertEquals(toRecord("x"), {}); + }); + + it("returns strings unchanged and stringifies other primitives", () => { + assertEquals(stringifyUnknown("already"), "already"); + assertEquals(stringifyUnknown(undefined), "undefined"); + assertEquals(stringifyUnknown(10n), "10"); + }); + + it("stringifies records and arrays through the JSON fallback", () => { + assertEquals(stringifyUnknown({ a: 1 }), '{"a":1}'); + assertEquals(stringifyUnknown([1, "b"]), '[1,"b"]'); + assertEquals(stringifyUnknown(null), "null"); + }); + + it("converts values into JSON-safe values", () => { + assertEquals(toJsonValue({ a: [1, "b"] }), { a: [1, "b"] }); + assertEquals(toJsonValue(null), null); + }); +}); diff --git a/src/chat/part-field-access.ts b/src/chat/part-field-access.ts new file mode 100644 index 0000000000..7e111ae179 --- /dev/null +++ b/src/chat/part-field-access.ts @@ -0,0 +1,65 @@ +/** + * Reading fields off untrusted `unknown` values. + * + * The single owner of turning an unvalidated value into a typed field, so chat + * parsing never hand-rolls its own record/string checks. Dependency-free apart + * from JSON stringification: no chat, tool, or schema knowledge belongs here. + */ +import { type ChatJsonValue, stringifyChatJson, toChatJsonValue } from "./json-value.ts"; + +/** JSON-compatible value. Re-exported from `json-value.ts` so both agree by construction. */ +export type JsonValue = ChatJsonValue; + +/** Check whether a value is a non-array object. */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Return string field. */ +export function getStringField(value: unknown, field: string, fallback: string): string { + if (!isRecord(value) || typeof value[field] !== "string") { + return fallback; + } + + return value[field]; +} + +/** Return a string field when present, else undefined. */ +export function getOptionalStringField(value: unknown, key: string): string | undefined { + if (!isRecord(value)) { + return undefined; + } + + const field = value[key]; + return typeof field === "string" ? field : undefined; +} + +/** Return a non-empty string field when present, else undefined. */ +export function getNonEmptyStringField(value: unknown, key: string): string | undefined { + const field = getOptionalStringField(value, key); + return field && field.length > 0 ? field : undefined; +} + +/** Shallow-copy a value into a plain record, or an empty record. */ +export function toRecord(value: unknown): Record { + return isRecord(value) ? Object.fromEntries(Object.entries(value)) : {}; +} + +/** Stringify unknown helper. */ +export function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if ( + typeof value === "bigint" || + typeof value === "undefined" || + typeof value === "function" || + typeof value === "symbol" + ) { + return String(value); + } + return stringifyChatJson(value); +} + +/** Convert a value into a JSON-safe value. */ +export function toJsonValue(value: unknown): JsonValue { + return toChatJsonValue(value); +} diff --git a/src/chat/tool-replay-reconciliation.test.ts b/src/chat/tool-replay-reconciliation.test.ts new file mode 100644 index 0000000000..049ac7019f --- /dev/null +++ b/src/chat/tool-replay-reconciliation.test.ts @@ -0,0 +1,365 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + findProviderVisibleToolReplayMatches, + isTransientToolState, +} from "./tool-replay-reconciliation.ts"; +import type { ChatProviderModelInputMessage, ChatProviderModelInputPart } from "./conversation.ts"; + +function assistantMessage( + parts: ChatProviderModelInputPart[], + id = "assistant-1", +): ChatProviderModelInputMessage { + return { id, role: "assistant", parts }; +} + +function toolMessage( + parts: ChatProviderModelInputPart[], + id = "assistant-1:tool", +): ChatProviderModelInputMessage { + return { id, role: "tool", parts }; +} + +function userMessage(text: string, id = "user-1"): ChatProviderModelInputMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function rawToolCall( + toolCallId: string, + toolName: string, + input: Record, + state = "completed", +): ChatProviderModelInputPart { + return { + type: "tool_call", + id: toolCallId, + name: toolName, + input, + state, + } as ChatProviderModelInputPart; +} + +function rawToolResult( + toolCallId: string, + output: unknown, + toolName?: string, +): ChatProviderModelInputPart { + return (toolName + ? { type: "tool_result", tool_call_id: toolCallId, tool_name: toolName, output } + : { type: "tool_result", tool_call_id: toolCallId, output }) as ChatProviderModelInputPart; +} + +function dynamicToolCall( + toolCallId: string, + toolName: string, + input: Record, + state: string, + output?: unknown, +): ChatProviderModelInputPart { + return (output === undefined ? { type: "dynamic-tool", toolName, toolCallId, input, state } : { + type: "dynamic-tool", + toolName, + toolCallId, + input, + state, + output, + }) as ChatProviderModelInputPart; +} + +describe("tool-replay-reconciliation", () => { + it("classifies in-flight states as transient", () => { + assertEquals(isTransientToolState("input-streaming"), true); + assertEquals(isTransientToolState("approval-requested"), true); + assertEquals(isTransientToolState("output-available"), false); + assertEquals(isTransientToolState(undefined), false); + }); + + it("returns an empty match set for empty history", () => { + const matches = findProviderVisibleToolReplayMatches([]); + assertEquals(typeof matches.preservedTransientToolParts.has, "function"); + assertEquals(typeof matches.matchedToolResultNames.get, "function"); + assertEquals(matches.matchedToolCallParts.has({}), false); + assertEquals(matches.supersededToolResultParts.has({}), false); + }); + + it("matches a simple call/result pair and records the result's tool name", () => { + const call = rawToolCall("tc-1", "bash", { command: "ls" }); + const result = rawToolResult("tc-1", "ok", "bash"); + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([call]), + toolMessage([result]), + ]); + + assertEquals(matches.matchedToolCallParts.has(call), true); + assertEquals(matches.matchedToolResultParts.has(result), true); + assertEquals(matches.matchedToolResultNames.get(result), "bash"); + // A non-transient, once-only occurrence is neither preserved-as-transient, + // superseded, nor a batch start. + assertEquals(matches.preservedTransientToolParts.has(call), false); + assertEquals(matches.supersededToolCallParts.has(call), false); + assertEquals(matches.supersededToolResultParts.has(result), false); + assertEquals(matches.toolCallPartsStartingNewBatch.has(call), false); + }); + + it("discriminates by part object identity, not structural equality", () => { + // THE CRITICAL PROPERTY: matching keys every collection off the exact part + // object seen during the walk. A structurally identical clone that was + // never part of the actual history must read as absent, even though it + // would satisfy any value-based equality check. + const call = rawToolCall("tc-1", "bash", { command: "ls" }); + const result = rawToolResult("tc-1", "ok", "bash"); + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([call]), + toolMessage([result]), + ]); + + const equalButDistinctCall = { ...call }; + const equalButDistinctResult = { ...result }; + + assertEquals(matches.matchedToolCallParts.has(call), true); + assertEquals(matches.matchedToolCallParts.has(equalButDistinctCall), false); + assertEquals(matches.matchedToolResultParts.has(result), true); + assertEquals(matches.matchedToolResultParts.has(equalButDistinctResult), false); + assertEquals(matches.matchedToolResultNames.get(result), "bash"); + assertEquals(matches.matchedToolResultNames.get(equalButDistinctResult), undefined); + }); + + it("supersedes an earlier call/result occurrence when a later same-id call wins the match", () => { + // Core of the algorithm: two split call/result pairs share a toolCallId + // across four messages. The later occurrence is authoritative; the + // earlier call AND its already-matched result are both marked superseded + // (while remaining "matched", since they were matched before losing). + const call1 = rawToolCall("dup", "github__get_pr_diff", { pull_number: 1 }); + const result1 = rawToolResult("dup", { files: ["old.ts"] }, "github__get_pr_diff"); + const call2 = rawToolCall("dup", "github__get_pr_diff", { pull_number: 2 }); + const result2 = rawToolResult("dup", { files: ["new.ts"] }, "github__get_pr_diff"); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([call1], "assistant-1"), + toolMessage([result1], "assistant-1:tool"), + assistantMessage([call2], "assistant-2"), + toolMessage([result2], "assistant-2:tool"), + ]); + + assertEquals(matches.matchedToolCallParts.has(call1), true); + assertEquals(matches.supersededToolCallParts.has(call1), true); + assertEquals(matches.matchedToolResultParts.has(result1), true); + assertEquals(matches.supersededToolResultParts.has(result1), true); + + assertEquals(matches.matchedToolCallParts.has(call2), true); + assertEquals(matches.supersededToolCallParts.has(call2), false); + assertEquals(matches.matchedToolResultParts.has(result2), true); + assertEquals(matches.supersededToolResultParts.has(result2), false); + }); + + it("supersedes an earlier self-contained call occurrence when a later same-id self-contained call lands", () => { + // The *other* supersede path: two self-contained occurrences (their own + // output, no separate result part) share a toolCallId in one message. + // Neither ever enters matchedToolCallParts (that set is only populated by + // the pendingCalls/result-matching path), but the earlier one is still + // marked superseded so it won't render. + const call1 = dynamicToolCall("dup", "github__list_prs", { page: 1 }, "output-available", { + data: [{ number: 3092 }], + }); + const call2 = dynamicToolCall("dup", "github__list_prs", { page: 2 }, "output-available", { + data: [{ number: 3093 }], + }); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([call1, call2]), + ]); + + assertEquals(matches.supersededToolCallParts.has(call1), true); + assertEquals(matches.supersededToolCallParts.has(call2), false); + assertEquals(matches.matchedToolCallParts.has(call1), false); + assertEquals(matches.matchedToolCallParts.has(call2), false); + }); + + it("preserves a transient call only when a later result actually resolves it", () => { + const resolvedCall = dynamicToolCall( + "resolved", + "github__get_pr_diff", + { pull_number: 1 }, + "streaming", + ); + const unresolvedCall = dynamicToolCall( + "unresolved", + "github__get_issue", + { number: 12 }, + "pending", + ); + const result = rawToolResult("resolved", { files: ["a.ts"] }, "github__get_pr_diff"); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([resolvedCall, unresolvedCall]), + toolMessage([result]), + ]); + + assertEquals(matches.preservedTransientToolParts.has(resolvedCall), true); + assertEquals(matches.matchedToolCallParts.has(resolvedCall), true); + + // Never resolved: stays pending forever, never matched, never preserved. + assertEquals(matches.preservedTransientToolParts.has(unresolvedCall), false); + assertEquals(matches.matchedToolCallParts.has(unresolvedCall), false); + }); + + it("does not match a result whose tool name conflicts with the pending call's name", () => { + const call = dynamicToolCall( + "tool-1", + "github__get_pr_diff", + { pull_number: 1 }, + "streaming", + ); + const mismatchedResult = rawToolResult("tool-1", { data: [] }, "github__list_prs"); + const controlCall = rawToolCall("tool-2", "github__list_prs", { state: "open" }); + const controlResult = rawToolResult("tool-2", { data: [] }, "github__list_prs"); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([call, controlCall]), + toolMessage([mismatchedResult, controlResult]), + ]); + + assertEquals(matches.matchedToolCallParts.has(call), false); + assertEquals(matches.matchedToolResultParts.has(mismatchedResult), false); + assertEquals(matches.preservedTransientToolParts.has(call), false); + // Positive control: a compatible pair in the same history really is + // matched, proving the fixture was processed rather than short-circuited. + assertEquals(matches.matchedToolCallParts.has(controlCall), true); + }); + + it("leaves a pending call with no result unmatched and unpreserved", () => { + const call = rawToolCall("never-resolved", "github__get_issue", { number: 42 }); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([call]), + ]); + + assertEquals(matches.matchedToolCallParts.has(call), false); + assertEquals(matches.supersededToolCallParts.has(call), false); + assertEquals(matches.preservedTransientToolParts.has(call), false); + assertEquals(matches.toolCallPartsStartingNewBatch.has(call), false); + }); + + it("marks a same-message call as starting a new batch when an earlier message's call is still pending", () => { + // Mirrors conversion.test.ts's "does not join prior-message unresolved + // calls into a new same-message call batch": first-call and second-call + // start pending in message 1. Message 2 resolves first-call, then + // introduces third-call while second-call (from the earlier message) is + // still pending — third-call starts a new batch. second-call is orphaned + // (its pending entry is dropped as stale before its own result arrives), + // so it never matches. + const firstCall = rawToolCall("first-call", "github__get_pr_diff", { pull_number: 1 }); + const secondCall = rawToolCall("second-call", "github__list_prs", { state: "open" }); + const firstResult = rawToolResult("first-call", { files: ["one.ts"] }, "github__get_pr_diff"); + const thirdCall = rawToolCall("third-call", "github__get_issue", { number: 42 }); + const secondResult = rawToolResult( + "second-call", + { data: [{ number: 3092 }] }, + "github__list_prs", + ); + const thirdResult = rawToolResult("third-call", { issue: 42 }, "github__get_issue"); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([firstCall, secondCall], "assistant-1"), + assistantMessage( + [firstResult, thirdCall, secondResult, thirdResult], + "assistant-2", + ), + ]); + + assertEquals(matches.toolCallPartsStartingNewBatch.has(thirdCall), true); + assertEquals(matches.toolCallPartsStartingNewBatch.has(firstCall), false); + assertEquals(matches.toolCallPartsStartingNewBatch.has(secondCall), false); + + assertEquals(matches.matchedToolCallParts.has(firstCall), true); + assertEquals(matches.matchedToolCallParts.has(thirdCall), true); + // second-call's own result arrives after third-call already evicted it + // from the pending list as a stale earlier-message entry. + assertEquals(matches.matchedToolCallParts.has(secondCall), false); + assertEquals(matches.matchedToolResultParts.has(secondResult), false); + }); + + it( + "supersedes an earlier same-id call via toolCallsById even once it's been evicted from the pending queue", + () => { + // This pins toolCallsById-based supersession only: staleCall and + // freshCall share a toolCallId, so removePendingCallsWithId's own + // id-based eviction removes staleCall from pendingCalls when freshCall + // is processed, regardless of whether any earlier-message/user-turn + // boundary flush ran. And freshCall is self-contained, so no result + // ever needs to match against pendingCalls. Neither of those redundant + // paths exercises the user-message boundary itself — see the next case + // for a fixture that actually discriminates that behavior. + const staleCall = dynamicToolCall( + "duplicate-call", + "github__get_pr_diff", + { pull_number: 1 }, + "streaming", + ); + const freshCall = dynamicToolCall( + "duplicate-call", + "github__get_pr_diff", + { pull_number: 2 }, + "output-available", + { files: ["new.ts"] }, + ); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([staleCall], "assistant-1"), + userMessage("continue with a different PR", "user-2"), + assistantMessage([freshCall], "assistant-2"), + ]); + + // The stale transient call from before the user turn is never resolved + // (its slot was dropped, not matched), so it is not preserved. + assertEquals(matches.preservedTransientToolParts.has(staleCall), false); + assertEquals(matches.matchedToolCallParts.has(staleCall), false); + // supersession tracks it via toolCallsById independent of pendingCalls + // eviction: the stale call is superseded even though it was already + // dropped from the pending queue by the same-id removal above. + assertEquals(matches.supersededToolCallParts.has(staleCall), true); + assertEquals(matches.matchedToolCallParts.has(freshCall), false); + }, + ); + + it( + "ends a pending call's window at a user message, so a later result can't resolve it", + () => { + // Unlike the case above, staleCall and lateResult share a toolCallId + // that appears nowhere else, so removePendingCallsWithId's same-id + // eviction can't be doing the work, and lateResult actually needs + // pendingCalls to still hold an entry to match against. If user text + // stopped counting as provider-visible content, staleCall would + // survive in pendingCalls and lateResult would match it, flipping all + // three assertions below to true. (A mutant that deletes only the + // immediate origin-filtered flush call inside the visible-content + // branch is *not* caught here: for a callless user message, the + // end-of-message-loop `pendingCalls.splice(0, + // pendingCountBeforeSameMessageVisibleContent)` fallback evicts the + // exact same entries regardless, since everything in pendingCalls + // before such a message is by construction from an earlier message.) + const staleCall = dynamicToolCall( + "only-call", + "github__get_pr_diff", + { pull_number: 1 }, + "streaming", + ); + const lateResult = rawToolResult( + "only-call", + { files: ["late.ts"] }, + "github__get_pr_diff", + ); + + const matches = findProviderVisibleToolReplayMatches([ + assistantMessage([staleCall], "assistant-1"), + userMessage("different question", "user-2"), + assistantMessage([lateResult], "assistant-3"), + ]); + + assertEquals(matches.matchedToolCallParts.has(staleCall), false); + assertEquals(matches.preservedTransientToolParts.has(staleCall), false); + assertEquals(matches.matchedToolResultParts.has(lateResult), false); + }, + ); +}); diff --git a/src/chat/tool-replay-reconciliation.ts b/src/chat/tool-replay-reconciliation.ts new file mode 100644 index 0000000000..d334b5c8b4 --- /dev/null +++ b/src/chat/tool-replay-reconciliation.ts @@ -0,0 +1,294 @@ +/** + * Tool replay reconciliation. + * + * The single owner of deciding which tool-call and tool-result occurrences in + * UI-message replay history are authoritative for provider conversion. Matching + * is by part *object identity*, so a single pass over history can mark parts as + * matched, superseded, batch-starting, or transient-but-preserved without + * mutating them. + */ +import { isRecord } from "./part-field-access.ts"; +import { + buildToolResultOutput, + getFilePart, + getRawToolCallPart, + getRawToolResultPart, + getToolPart, + hasSelfContainedRawToolCallResult, + isProviderVisibleReasoningPart, + isTextPart, +} from "./message-part-parsing.ts"; +import type { ChatUiMessageRole } from "./types.ts"; +// Must stay type-only: a value import would create a cycle with +// conversation.ts. deno check won't catch this (types erase), but +// lint:module-boundaries will. +import type { ChatProviderModelInputMessage } from "./conversation.ts"; + +export function isTransientToolState(state: string | undefined): boolean { + return state === "pending" || state === "input-available" || state === "input-streaming" || + state === "streaming" || state === "approval-requested" || state === "approval-responded"; +} + +type ReplayToolCallPart = { + part: object; + toolCallId: string; + toolName: string; + transient: boolean; + selfContainedResult: boolean; +}; + +type PendingReplayToolCall = Omit & { + originMessageIndex: number; +}; + +function getReplayToolCallPart(part: unknown, role: ChatUiMessageRole): ReplayToolCallPart | null { + if (role !== "assistant") { + return null; + } + + if (!isRecord(part)) { + return null; + } + + const toolPart = getToolPart(part); + if (toolPart) { + return { + part, + toolCallId: toolPart.toolCallId, + toolName: toolPart.toolName, + transient: isTransientToolState(toolPart.state), + selfContainedResult: buildToolResultOutput(toolPart) !== null, + }; + } + + const rawToolCall = getRawToolCallPart(part); + if (!rawToolCall) { + return null; + } + + return { + part, + toolCallId: rawToolCall.toolCallId, + toolName: rawToolCall.toolName, + transient: isTransientToolState(rawToolCall.state), + selfContainedResult: hasSelfContainedRawToolCallResult(rawToolCall), + }; +} + +function getReplayToolResultPart(part: unknown, role: ChatUiMessageRole): { + part: object; + toolCallId: string; + toolName?: string; +} | null { + if (role !== "assistant" && role !== "tool") { + return null; + } + + if (!isRecord(part)) { + return null; + } + + const rawToolResult = getRawToolResultPart(part); + if (rawToolResult) { + return { + part, + toolCallId: rawToolResult.toolCallId, + ...(rawToolResult.toolName ? { toolName: rawToolResult.toolName } : {}), + }; + } + + const toolPart = getToolPart(part); + if (role === "tool" && toolPart && buildToolResultOutput(toolPart)) { + return { + part, + toolCallId: toolPart.toolCallId, + toolName: toolPart.toolName, + }; + } + + return null; +} + +function isProviderVisibleNonToolPart(role: ChatUiMessageRole, part: unknown): boolean { + if (role === "system") { + return isTextPart(part) && part.text.length > 0; + } + + if (role === "user") { + return isTextPart(part) && part.text.length > 0 || getFilePart(part) !== null; + } + + if (role === "assistant") { + return isTextPart(part) && part.text.length > 0 || isProviderVisibleReasoningPart(part) || + getFilePart(part) !== null; + } + + return false; +} + +function isCompatibleToolResultName( + call: { toolName: string }, + result: { toolName?: string }, +): boolean { + return !result.toolName || result.toolName === call.toolName; +} + +function removePendingCallsThroughMatchedResult( + pendingCalls: PendingReplayToolCall[], + matchedIndex: number, + toolCallId: string, +): void { + const priorUnmatchedCalls = pendingCalls.slice(0, matchedIndex).filter((pendingCall) => + pendingCall.toolCallId !== toolCallId + ); + pendingCalls.splice(0, matchedIndex + 1, ...priorUnmatchedCalls); +} + +function removePendingCallsWithId( + pendingCalls: Array<{ toolCallId: string }>, + toolCallId: string, +): void { + for (let index = pendingCalls.length - 1; index >= 0; index--) { + if (pendingCalls[index]?.toolCallId === toolCallId) { + pendingCalls.splice(index, 1); + } + } +} + +function removePendingCallsFromEarlierMessages( + pendingCalls: Array<{ originMessageIndex: number }>, + messageIndex: number, +): void { + for (let index = pendingCalls.length - 1; index >= 0; index--) { + if ((pendingCalls[index]?.originMessageIndex ?? messageIndex) < messageIndex) { + pendingCalls.splice(index, 1); + } + } +} + +function hasPendingCallsFromEarlierMessages( + pendingCalls: Array<{ originMessageIndex: number }>, + messageIndex: number, +): boolean { + return pendingCalls.some((pendingCall) => pendingCall.originMessageIndex < messageIndex); +} + +/** Tool replay parts that are valid to expose to provider conversion. */ +export type ProviderVisibleToolReplayMatches = { + preservedTransientToolParts: WeakSet; + matchedToolCallParts: WeakSet; + matchedToolResultParts: WeakSet; + matchedToolResultNames: WeakMap; + toolCallPartsStartingNewBatch: WeakSet; + supersededToolCallParts: WeakSet; + supersededToolResultParts: WeakSet; +}; + +/** Find adjacent replay call/result occurrences using part object identity. */ +export function findProviderVisibleToolReplayMatches( + messages: readonly ChatProviderModelInputMessage[], +): ProviderVisibleToolReplayMatches { + const preservedTransientToolParts = new WeakSet(); + const matchedToolCallParts = new WeakSet(); + const matchedToolResultParts = new WeakSet(); + const matchedToolResultNames = new WeakMap(); + const toolCallPartsStartingNewBatch = new WeakSet(); + const supersededToolCallParts = new WeakSet(); + const supersededToolResultParts = new WeakSet(); + const matchedResultPartByCallPart = new WeakMap(); + const toolCallsById = new Map(); + const pendingCalls: PendingReplayToolCall[] = []; + + for (const [messageIndex, message] of messages.entries()) { + let pendingCountBeforeSameMessageVisibleContent: number | null = null; + + for (const part of message.parts) { + const call = getReplayToolCallPart(part, message.role); + if (call) { + const callsWithId = toolCallsById.get(call.toolCallId) ?? []; + callsWithId.push(call); + toolCallsById.set(call.toolCallId, callsWithId); + + if (hasPendingCallsFromEarlierMessages(pendingCalls, messageIndex)) { + toolCallPartsStartingNewBatch.add(call.part); + } + removePendingCallsFromEarlierMessages(pendingCalls, messageIndex); + if (pendingCountBeforeSameMessageVisibleContent !== null) { + pendingCalls.splice(0, pendingCountBeforeSameMessageVisibleContent); + pendingCountBeforeSameMessageVisibleContent = null; + } + removePendingCallsWithId(pendingCalls, call.toolCallId); + if (call.selfContainedResult) { + for (const priorCall of callsWithId) { + if (priorCall.part === call.part) { + continue; + } + + supersededToolCallParts.add(priorCall.part); + const priorResultPart = matchedResultPartByCallPart.get(priorCall.part); + if (priorResultPart) { + supersededToolResultParts.add(priorResultPart); + } + } + continue; + } + + pendingCalls.push({ ...call, originMessageIndex: messageIndex }); + continue; + } + + const result = getReplayToolResultPart(part, message.role); + if (result) { + const matchedIndex = pendingCalls.findLastIndex((pendingCall) => + pendingCall.toolCallId === result.toolCallId && + isCompatibleToolResultName(pendingCall, result) + ); + if (matchedIndex >= 0) { + const matchedCall = pendingCalls[matchedIndex]; + if (!matchedCall) { + continue; + } + if (matchedCall.transient) { + preservedTransientToolParts.add(matchedCall.part); + } + for (const priorCall of toolCallsById.get(matchedCall.toolCallId) ?? []) { + if (priorCall.part === matchedCall.part) { + continue; + } + + supersededToolCallParts.add(priorCall.part); + const priorResultPart = matchedResultPartByCallPart.get(priorCall.part); + if (priorResultPart) { + supersededToolResultParts.add(priorResultPart); + } + } + matchedToolCallParts.add(matchedCall.part); + matchedToolResultParts.add(result.part); + matchedToolResultNames.set(result.part, matchedCall.toolName); + matchedResultPartByCallPart.set(matchedCall.part, result.part); + removePendingCallsThroughMatchedResult(pendingCalls, matchedIndex, result.toolCallId); + } + continue; + } + + if (isProviderVisibleNonToolPart(message.role, part)) { + removePendingCallsFromEarlierMessages(pendingCalls, messageIndex); + pendingCountBeforeSameMessageVisibleContent ??= pendingCalls.length; + } + } + + if (pendingCountBeforeSameMessageVisibleContent !== null) { + pendingCalls.splice(0, pendingCountBeforeSameMessageVisibleContent); + } + } + + return { + preservedTransientToolParts, + matchedToolCallParts, + matchedToolResultParts, + matchedToolResultNames, + toolCallPartsStartingNewBatch, + supersededToolCallParts, + supersededToolResultParts, + }; +}