From 94b22420cf1382d8145fe9ba90afe12280be344a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 7 Aug 2026 07:35:43 +0200 Subject: [PATCH 1/3] refactor(chat): move provider-input types to their own leaf module Splits RawToolCallMessagePart, RawToolResultMessagePart, ChatProviderModelInputToolCallPart, ChatProviderModelInputToolResultPart, ChatProviderModelInputPart, and ChatProviderModelInputMessage out of conversation.ts into provider-input-types.ts. This removes the deliberate type-only back-edge tool-replay-reconciliation.ts had into conversation.ts, and sets up the next increment to move provider conversion out of conversation.ts without creating a cycle. Pure type move, zero behaviour change. --- src/chat/conversation.test.ts | 2 +- src/chat/conversation.ts | 27 +--------------- src/chat/provider-input-types.ts | 34 +++++++++++++++++++++ src/chat/tool-replay-reconciliation.test.ts | 5 ++- src/chat/tool-replay-reconciliation.ts | 5 +-- 5 files changed, 41 insertions(+), 32 deletions(-) create mode 100644 src/chat/provider-input-types.ts diff --git a/src/chat/conversation.test.ts b/src/chat/conversation.test.ts index c2bd0e6c8e..d855bc9c99 100644 --- a/src/chat/conversation.test.ts +++ b/src/chat/conversation.test.ts @@ -5,7 +5,7 @@ import type { ChatProviderModelInputMessage, ChatProviderModelInputPart, ChatProviderModelInputToolResultPart, -} from "#veryfront/chat/conversation"; +} from "./provider-input-types.ts"; import type { ChatToolCallPart, ChatToolPartState, diff --git a/src/chat/conversation.ts b/src/chat/conversation.ts index edd6c50af1..53ef036f0f 100644 --- a/src/chat/conversation.ts +++ b/src/chat/conversation.ts @@ -4,7 +4,6 @@ import type { ChatToolResultPart, ChatUiMessage, ChatUiMessagePart, - ChatUiMessageRole, ProviderModelMessage, } from "./types.ts"; import { getOptionalStringField, isRecord, toRecord } from "./part-field-access.ts"; @@ -24,6 +23,7 @@ import { isTransientToolState, } from "./tool-replay-reconciliation.ts"; import type { ProviderVisibleToolReplayMatches } from "./tool-replay-reconciliation.ts"; +import type { ChatProviderModelInputMessage } from "./provider-input-types.ts"; export { getStringField, isRecord, stringifyUnknown } from "./part-field-access.ts"; export type { JsonValue } from "./part-field-access.ts"; @@ -227,31 +227,6 @@ type ProviderToolResultContent = { value: string; }; }; -type RawToolCallMessagePart = Extract; -type RawToolResultMessagePart = Extract; - -/** Stored tool-call replay part accepted by provider conversion. */ -export type ChatProviderModelInputToolCallPart = RawToolCallMessagePart; - -/** Stored tool-result replay part accepted by provider conversion. */ -export type ChatProviderModelInputToolResultPart = RawToolResultMessagePart & { - tool_name?: string; -}; - -/** Message part accepted by provider conversion. */ -export type ChatProviderModelInputPart = - | ChatUiMessagePart - | ChatProviderModelInputToolCallPart - | ChatProviderModelInputToolResultPart; - -/** Message accepted by provider conversion. */ -export interface ChatProviderModelInputMessage { - id: string; - role: ChatUiMessageRole; - parts: ChatProviderModelInputPart[]; - metadata?: TMessageMetadata; -} - /** Shared UUID pattern value. */ export const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i; diff --git a/src/chat/provider-input-types.ts b/src/chat/provider-input-types.ts new file mode 100644 index 0000000000..0f082639eb --- /dev/null +++ b/src/chat/provider-input-types.ts @@ -0,0 +1,34 @@ +/** + * Provider input types. + * + * The message-part shapes that provider conversion accepts as input, shared by + * tool replay reconciliation and provider conversion. Kept out of both so + * neither has to import the other's leaf types. + */ +import type { ChatUiMessagePart, ChatUiMessageRole } from "./types.ts"; +import type { MessagePart } from "./conversation.ts"; + +type RawToolCallMessagePart = Extract; +type RawToolResultMessagePart = Extract; + +/** Stored tool-call replay part accepted by provider conversion. */ +export type ChatProviderModelInputToolCallPart = RawToolCallMessagePart; + +/** Stored tool-result replay part accepted by provider conversion. */ +export type ChatProviderModelInputToolResultPart = RawToolResultMessagePart & { + tool_name?: string; +}; + +/** Message part accepted by provider conversion. */ +export type ChatProviderModelInputPart = + | ChatUiMessagePart + | ChatProviderModelInputToolCallPart + | ChatProviderModelInputToolResultPart; + +/** Message accepted by provider conversion. */ +export interface ChatProviderModelInputMessage { + id: string; + role: ChatUiMessageRole; + parts: ChatProviderModelInputPart[]; + metadata?: TMessageMetadata; +} diff --git a/src/chat/tool-replay-reconciliation.test.ts b/src/chat/tool-replay-reconciliation.test.ts index 049ac7019f..dacd0392e3 100644 --- a/src/chat/tool-replay-reconciliation.test.ts +++ b/src/chat/tool-replay-reconciliation.test.ts @@ -5,7 +5,10 @@ import { findProviderVisibleToolReplayMatches, isTransientToolState, } from "./tool-replay-reconciliation.ts"; -import type { ChatProviderModelInputMessage, ChatProviderModelInputPart } from "./conversation.ts"; +import type { + ChatProviderModelInputMessage, + ChatProviderModelInputPart, +} from "./provider-input-types.ts"; function assistantMessage( parts: ChatProviderModelInputPart[], diff --git a/src/chat/tool-replay-reconciliation.ts b/src/chat/tool-replay-reconciliation.ts index d334b5c8b4..82fcd7e2ee 100644 --- a/src/chat/tool-replay-reconciliation.ts +++ b/src/chat/tool-replay-reconciliation.ts @@ -19,10 +19,7 @@ import { 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"; +import type { ChatProviderModelInputMessage } from "./provider-input-types.ts"; export function isTransientToolState(state: string | undefined): boolean { return state === "pending" || state === "input-available" || state === "input-streaming" || From 2d0da88a976bd694c6c03dba8966d707eee536db Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 7 Aug 2026 07:46:14 +0200 Subject: [PATCH 2/3] refactor(chat): give provider message conversion its own module Move convertUiMessagesToProviderModelMessages and its private helpers (buildToolNameMap, resolveRawToolResultPart, shouldSkipTransientToolCall, convertSystemMessage, convertUserMessage, convertAssistantMessage, convertToolMessage, ProviderToolResultContent) out of conversation.ts into src/chat/provider-message-conversion.ts, verbatim. conversation.ts keeps the persisted-conversation format and how UI messages become stored parts; provider-message-conversion.ts is now the single owner of turning replay history into the ordered message list a provider sees. Consumers (message-prep.ts, compat.ts, chat-request.test.ts, conversation.test.ts) repoint directly at the new module rather than via a re-export from conversation.ts. --- src/agent/hosted/chat-request.test.ts | 2 +- src/chat/compat.ts | 2 +- src/chat/conversation.test.ts | 2 +- src/chat/conversation.ts | 440 +---------------------- src/chat/message-prep.ts | 2 +- src/chat/provider-message-conversion.ts | 447 ++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 442 deletions(-) create mode 100644 src/chat/provider-message-conversion.ts diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index d5585b729a..063d125798 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { convertUiMessagesToProviderModelMessages } from "#veryfront/chat/conversation"; +import { convertUiMessagesToProviderModelMessages } from "../../chat/provider-message-conversion.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; diff --git a/src/chat/compat.ts b/src/chat/compat.ts index 79b59fc007..5a0409bafb 100644 --- a/src/chat/compat.ts +++ b/src/chat/compat.ts @@ -26,13 +26,13 @@ import { getAgUiWireEventSchema, } from "./ag-ui.ts"; import { - convertUiMessagesToProviderModelMessages, getApiConversationSchema, getApiMessageSchema, getConversationTypeSchema, getMessagePartSchema, getMessageStatusSchema, } from "./conversation.ts"; +import { convertUiMessagesToProviderModelMessages } from "./provider-message-conversion.ts"; import { prepareProviderModelMessagesFromUiMessages, sanitizeProviderModelMessages, diff --git a/src/chat/conversation.test.ts b/src/chat/conversation.test.ts index d855bc9c99..d77e07ed8c 100644 --- a/src/chat/conversation.test.ts +++ b/src/chat/conversation.test.ts @@ -21,7 +21,6 @@ import { messageStatusSchema, } from "#veryfront/chat/compat"; import { - convertUiMessagesToProviderModelMessages, extractTextFromMessage, extractUploadId, hasIncompleteToolParts, @@ -34,6 +33,7 @@ import { stringifyUnknown, toConversationPartsFromUiMessage, } from "#veryfront/chat/conversation"; +import { convertUiMessagesToProviderModelMessages } from "./provider-message-conversion.ts"; const GITHUB_PR_DIFF_INPUT = { owner: "veryfront", repo: "veryfront-code", pull_number: 3092 }; const GITHUB_LIST_PRS_INPUT = { owner: "veryfront", repo: "veryfront-code" }; diff --git a/src/chat/conversation.ts b/src/chat/conversation.ts index 53ef036f0f..c0995bab8b 100644 --- a/src/chat/conversation.ts +++ b/src/chat/conversation.ts @@ -1,29 +1,8 @@ import { defineSchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; -import type { - ChatToolResultPart, - ChatUiMessage, - ChatUiMessagePart, - ProviderModelMessage, -} from "./types.ts"; +import type { ChatUiMessage, ChatUiMessagePart, ProviderModelMessage } from "./types.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"; -import type { ChatProviderModelInputMessage } from "./provider-input-types.ts"; +import { isTextPart } from "./message-part-parsing.ts"; export { getStringField, isRecord, stringifyUnknown } from "./part-field-access.ts"; export type { JsonValue } from "./part-field-access.ts"; @@ -213,20 +192,6 @@ export interface ToolResultLike { /** Chat UI tool part with a call ID and state. */ type ToolUiPart = Extract; -type ProviderToolResultContent = { - type: "tool-result"; - toolCallId: string; - toolName: string; - output: - | { - type: "json"; - value: JsonValue; - } - | { - type: "error-text"; - value: string; - }; -}; /** Shared UUID pattern value. */ export const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i; @@ -590,404 +555,3 @@ export function extractTextFromMessage(message: ProviderModelMessage): string { return ""; } - -function buildToolNameMap(parts: ReadonlyArray): Map { - const toolNames = new Map(); - - for (const part of parts) { - const toolPart = getToolPart(part); - if (toolPart) { - toolNames.set(toolPart.toolCallId, toolPart.toolName); - continue; - } - - const rawToolCall = getRawToolCallPart(part); - if (!rawToolCall) { - continue; - } - - toolNames.set(rawToolCall.toolCallId, rawToolCall.toolName); - } - - return toolNames; -} - -function resolveRawToolResultPart( - rawResult: NonNullable>, - toolNamesById: ReadonlyMap, - knownToolNamesById: ReadonlyMap, - matchedToolName?: string, -): ProviderToolResultContent | null { - const toolName = matchedToolName ?? rawResult.toolName ?? - toolNamesById.get(rawResult.toolCallId) ?? - knownToolNamesById.get(rawResult.toolCallId); - if (!toolName) { - return null; - } - - return { - type: "tool-result", - toolCallId: rawResult.toolCallId, - toolName, - output: rawResult.output, - }; -} - -function shouldSkipTransientToolCall( - part: unknown, - state: string | undefined, - replayMatches: ProviderVisibleToolReplayMatches, -): boolean { - return isTransientToolState(state) && - (!isRecord(part) || !replayMatches.preservedTransientToolParts.has(part)); -} - -function convertSystemMessage(message: ChatProviderModelInputMessage): ProviderModelMessage[] { - const content = message.parts.flatMap((part) => (isTextPart(part) ? [part.text] : [])).join(""); - if (content.length === 0) { - return []; - } - - return [ - { - role: "system", - content, - }, - ]; -} - -function convertUserMessage(message: ChatProviderModelInputMessage): ProviderModelMessage[] { - const content: Array< - { type: "text"; text: string } | { - type: "file" | "image"; - mediaType: string; - data: string; - url: string; - filename?: string; - uploadId?: string; - uploadPath?: string; - } - > = []; - - for (const part of message.parts) { - if (isTextPart(part)) { - if (part.text.length > 0) { - content.push({ type: "text", text: part.text }); - } - continue; - } - - const filePart = getFilePart(part); - if (filePart) { - content.push(filePart); - } - } - - if (content.length === 0) { - return []; - } - - return [ - { - role: "user", - content, - }, - ]; -} - -function convertAssistantMessage( - message: ChatProviderModelInputMessage, - knownToolNamesById: ReadonlyMap, - replayMatches: ProviderVisibleToolReplayMatches, -): ProviderModelMessage[] { - const toolNamesById = buildToolNameMap(message.parts); - const assistantContent: Array< - | { type: "text"; text: string } - | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } - | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: Record } - > = []; - const deferredAssistantContent: typeof assistantContent = []; - const toolResults: ProviderToolResultContent[] = []; - const pendingToolCallIds = new Set(); - const messages: ProviderModelMessage[] = []; - - const flushAssistantMessage = (content: typeof assistantContent) => { - if (content.length === 0) { - return; - } - - messages.push({ - role: "assistant", - content: [...content], - }); - content.length = 0; - }; - - const flushToolMessage = () => { - if (toolResults.length === 0) { - return; - } - - messages.push({ - role: "tool", - content: [...toolResults], - }); - toolResults.length = 0; - }; - - const pushAssistantPart = ( - part: - | { type: "text"; text: string } - | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } - | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: Record }, - ) => { - if (part.type === "tool-call") { - if (deferredAssistantContent.length > 0) { - flushAssistantMessage(assistantContent); - flushToolMessage(); - flushAssistantMessage(deferredAssistantContent); - } - - assistantContent.push(part); - pendingToolCallIds.add(part.toolCallId); - return; - } - - if (pendingToolCallIds.size > 0) { - deferredAssistantContent.push(part); - return; - } - - if (toolResults.length > 0) { - flushAssistantMessage(assistantContent); - flushToolMessage(); - flushAssistantMessage(deferredAssistantContent); - } - - assistantContent.push(part); - }; - - const pushToolResult = (part: ProviderToolResultContent) => { - toolResults.push(part); - pendingToolCallIds.delete(part.toolCallId); - }; - - const pushToolCall = ( - part: unknown, - toolCall: { - toolCallId: string; - toolName: string; - input: Record; - state?: string; - }, - resultOutput: ReturnType, - ) => { - if (shouldSkipTransientToolCall(part, toolCall.state, replayMatches)) { - return; - } - - if (isRecord(part) && replayMatches.supersededToolCallParts.has(part)) { - return; - } - - if (isRecord(part) && replayMatches.toolCallPartsStartingNewBatch.has(part)) { - flushAssistantMessage(assistantContent); - flushToolMessage(); - flushAssistantMessage(deferredAssistantContent); - } - - pushAssistantPart({ - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: toolCall.input, - }); - - if (resultOutput) { - pushToolResult({ - type: "tool-result", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - output: resultOutput, - }); - } - }; - - for (const part of message.parts) { - if (isTextPart(part)) { - if (part.text.length > 0) { - pushAssistantPart({ type: "text", text: part.text }); - } - continue; - } - - if (isProviderVisibleReasoningPart(part)) { - pushAssistantPart({ - type: "reasoning", - text: part.text, - ...(typeof part.signature === "string" ? { signature: part.signature } : {}), - ...(typeof part.redactedData === "string" ? { redactedData: part.redactedData } : {}), - }); - continue; - } - - const filePart = getFilePart(part); - if (filePart) { - pushAssistantPart(filePart); - continue; - } - - const toolPart = getToolPart(part); - if (toolPart) { - pushToolCall(part, toolPart, buildToolResultOutput(toolPart)); - continue; - } - - const rawToolCall = getRawToolCallPart(part); - if (rawToolCall) { - const authoritativeResultFollows = isRecord(part) && - replayMatches.matchedToolCallParts.has(part); - pushToolCall( - part, - rawToolCall, - authoritativeResultFollows ? null : buildRawToolCallResultOutput(rawToolCall), - ); - continue; - } - - const rawToolResult = getRawToolResultPart(part); - if (rawToolResult) { - if ( - !isRecord(part) || !replayMatches.matchedToolResultParts.has(part) || - replayMatches.supersededToolResultParts.has(part) - ) { - continue; - } - - const toolResult = resolveRawToolResultPart( - rawToolResult, - toolNamesById, - knownToolNamesById, - replayMatches.matchedToolResultNames.get(part), - ); - if (toolResult) { - pushToolResult(toolResult); - } - } - } - - flushAssistantMessage(assistantContent); - flushToolMessage(); - flushAssistantMessage(deferredAssistantContent); - - return messages; -} - -function convertToolMessage( - message: ChatProviderModelInputMessage, - knownToolNamesById: ReadonlyMap, - replayMatches: ProviderVisibleToolReplayMatches, -): ProviderModelMessage[] { - const toolNamesById = buildToolNameMap(message.parts); - const toolResults: ChatToolResultPart[] = []; - - for (const part of message.parts) { - const toolPart = getToolPart(part); - if (toolPart) { - const output = buildToolResultOutput(toolPart); - if (output) { - if ( - !isRecord(part) || !replayMatches.matchedToolResultParts.has(part) || - replayMatches.supersededToolResultParts.has(part) - ) { - continue; - } - - toolResults.push({ - type: "tool-result", - toolCallId: toolPart.toolCallId, - toolName: toolPart.toolName, - output, - }); - } - continue; - } - - const rawResult = getRawToolResultPart(part); - if (!rawResult) { - continue; - } - if ( - !isRecord(part) || !replayMatches.matchedToolResultParts.has(part) || - replayMatches.supersededToolResultParts.has(part) - ) { - continue; - } - - const toolResult = resolveRawToolResultPart( - rawResult, - toolNamesById, - knownToolNamesById, - replayMatches.matchedToolResultNames.get(part), - ); - if (toolResult) { - toolResults.push(toolResult); - } - } - - if (toolResults.length === 0) { - return []; - } - - return [{ role: "tool", content: toolResults }]; -} - -/** Convert UI messages to provider model messages. */ -export function convertUiMessagesToProviderModelMessages( - messages: readonly ChatProviderModelInputMessage[], -): ProviderModelMessage[] { - const providerMessages: ProviderModelMessage[] = []; - const knownToolNamesById = new Map(); - const replayMatches = findProviderVisibleToolReplayMatches(messages); - - for (const message of messages) { - if (message.role === "assistant") { - for (const [toolCallId, toolName] of buildToolNameMap(message.parts)) { - knownToolNamesById.set(toolCallId, toolName); - } - } - - const converted = (() => { - switch (message.role) { - case "system": - return convertSystemMessage(message); - case "user": - return convertUserMessage(message); - case "assistant": - return convertAssistantMessage(message, knownToolNamesById, replayMatches); - case "tool": - return convertToolMessage(message, knownToolNamesById, replayMatches); - default: - return []; - } - })(); - - for (const rawProviderMessage of converted) { - const providerMessage = withProviderModelMessageSourceId(rawProviderMessage, message.id); - const previous = providerMessages.at(-1); - if (previous?.role === "tool" && providerMessage.role === "tool") { - providerMessages[providerMessages.length - 1] = withProviderModelMessageSourceId({ - role: "tool", - content: [...previous.content, ...providerMessage.content], - }, getProviderModelMessageSourceId(previous) ?? message.id); - continue; - } - - providerMessages.push(providerMessage); - } - } - - return providerMessages; -} diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index bcfa4b410b..eed0ce983e 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1,11 +1,11 @@ import { - convertUiMessagesToProviderModelMessages, copyProviderModelMessageSourceId, getStringField, isReasoningPart, isToolCallPart, isToolResultPart, } from "./conversation.ts"; +import { convertUiMessagesToProviderModelMessages } from "./provider-message-conversion.ts"; import { findProviderVisibleToolReplayMatches } from "./tool-replay-reconciliation.ts"; import { buildDataFileAnnotation, diff --git a/src/chat/provider-message-conversion.ts b/src/chat/provider-message-conversion.ts new file mode 100644 index 0000000000..ed496c8938 --- /dev/null +++ b/src/chat/provider-message-conversion.ts @@ -0,0 +1,447 @@ +/** + * Provider message conversion. + * + * The single owner of turning a chat's replay history into the ordered message + * list a provider sees. It asks Tool Replay Reconciliation which tool + * occurrences are authoritative, maps each role's parts into provider content, + * and settles into one ProviderModelMessage[]. + */ +import { isRecord } 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"; +import type { ChatProviderModelInputMessage } from "./provider-input-types.ts"; +import { + getProviderModelMessageSourceId, + withProviderModelMessageSourceId, +} from "./conversation.ts"; +import type { ChatToolResultPart, ProviderModelMessage } from "./types.ts"; + +type ProviderToolResultContent = { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: + | { + type: "json"; + value: JsonValue; + } + | { + type: "error-text"; + value: string; + }; +}; + +function buildToolNameMap(parts: ReadonlyArray): Map { + const toolNames = new Map(); + + for (const part of parts) { + const toolPart = getToolPart(part); + if (toolPart) { + toolNames.set(toolPart.toolCallId, toolPart.toolName); + continue; + } + + const rawToolCall = getRawToolCallPart(part); + if (!rawToolCall) { + continue; + } + + toolNames.set(rawToolCall.toolCallId, rawToolCall.toolName); + } + + return toolNames; +} + +function resolveRawToolResultPart( + rawResult: NonNullable>, + toolNamesById: ReadonlyMap, + knownToolNamesById: ReadonlyMap, + matchedToolName?: string, +): ProviderToolResultContent | null { + const toolName = matchedToolName ?? rawResult.toolName ?? + toolNamesById.get(rawResult.toolCallId) ?? + knownToolNamesById.get(rawResult.toolCallId); + if (!toolName) { + return null; + } + + return { + type: "tool-result", + toolCallId: rawResult.toolCallId, + toolName, + output: rawResult.output, + }; +} + +function shouldSkipTransientToolCall( + part: unknown, + state: string | undefined, + replayMatches: ProviderVisibleToolReplayMatches, +): boolean { + return isTransientToolState(state) && + (!isRecord(part) || !replayMatches.preservedTransientToolParts.has(part)); +} + +function convertSystemMessage(message: ChatProviderModelInputMessage): ProviderModelMessage[] { + const content = message.parts.flatMap((part) => (isTextPart(part) ? [part.text] : [])).join(""); + if (content.length === 0) { + return []; + } + + return [ + { + role: "system", + content, + }, + ]; +} + +function convertUserMessage(message: ChatProviderModelInputMessage): ProviderModelMessage[] { + const content: Array< + { type: "text"; text: string } | { + type: "file" | "image"; + mediaType: string; + data: string; + url: string; + filename?: string; + uploadId?: string; + uploadPath?: string; + } + > = []; + + for (const part of message.parts) { + if (isTextPart(part)) { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); + } + continue; + } + + const filePart = getFilePart(part); + if (filePart) { + content.push(filePart); + } + } + + if (content.length === 0) { + return []; + } + + return [ + { + role: "user", + content, + }, + ]; +} + +function convertAssistantMessage( + message: ChatProviderModelInputMessage, + knownToolNamesById: ReadonlyMap, + replayMatches: ProviderVisibleToolReplayMatches, +): ProviderModelMessage[] { + const toolNamesById = buildToolNameMap(message.parts); + const assistantContent: Array< + | { type: "text"; text: string } + | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } + | { type: "file" | "image"; mediaType: string; data: string; filename?: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: Record } + > = []; + const deferredAssistantContent: typeof assistantContent = []; + const toolResults: ProviderToolResultContent[] = []; + const pendingToolCallIds = new Set(); + const messages: ProviderModelMessage[] = []; + + const flushAssistantMessage = (content: typeof assistantContent) => { + if (content.length === 0) { + return; + } + + messages.push({ + role: "assistant", + content: [...content], + }); + content.length = 0; + }; + + const flushToolMessage = () => { + if (toolResults.length === 0) { + return; + } + + messages.push({ + role: "tool", + content: [...toolResults], + }); + toolResults.length = 0; + }; + + const pushAssistantPart = ( + part: + | { type: "text"; text: string } + | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } + | { type: "file" | "image"; mediaType: string; data: string; filename?: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: Record }, + ) => { + if (part.type === "tool-call") { + if (deferredAssistantContent.length > 0) { + flushAssistantMessage(assistantContent); + flushToolMessage(); + flushAssistantMessage(deferredAssistantContent); + } + + assistantContent.push(part); + pendingToolCallIds.add(part.toolCallId); + return; + } + + if (pendingToolCallIds.size > 0) { + deferredAssistantContent.push(part); + return; + } + + if (toolResults.length > 0) { + flushAssistantMessage(assistantContent); + flushToolMessage(); + flushAssistantMessage(deferredAssistantContent); + } + + assistantContent.push(part); + }; + + const pushToolResult = (part: ProviderToolResultContent) => { + toolResults.push(part); + pendingToolCallIds.delete(part.toolCallId); + }; + + const pushToolCall = ( + part: unknown, + toolCall: { + toolCallId: string; + toolName: string; + input: Record; + state?: string; + }, + resultOutput: ReturnType, + ) => { + if (shouldSkipTransientToolCall(part, toolCall.state, replayMatches)) { + return; + } + + if (isRecord(part) && replayMatches.supersededToolCallParts.has(part)) { + return; + } + + if (isRecord(part) && replayMatches.toolCallPartsStartingNewBatch.has(part)) { + flushAssistantMessage(assistantContent); + flushToolMessage(); + flushAssistantMessage(deferredAssistantContent); + } + + pushAssistantPart({ + type: "tool-call", + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + input: toolCall.input, + }); + + if (resultOutput) { + pushToolResult({ + type: "tool-result", + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: resultOutput, + }); + } + }; + + for (const part of message.parts) { + if (isTextPart(part)) { + if (part.text.length > 0) { + pushAssistantPart({ type: "text", text: part.text }); + } + continue; + } + + if (isProviderVisibleReasoningPart(part)) { + pushAssistantPart({ + type: "reasoning", + text: part.text, + ...(typeof part.signature === "string" ? { signature: part.signature } : {}), + ...(typeof part.redactedData === "string" ? { redactedData: part.redactedData } : {}), + }); + continue; + } + + const filePart = getFilePart(part); + if (filePart) { + pushAssistantPart(filePart); + continue; + } + + const toolPart = getToolPart(part); + if (toolPart) { + pushToolCall(part, toolPart, buildToolResultOutput(toolPart)); + continue; + } + + const rawToolCall = getRawToolCallPart(part); + if (rawToolCall) { + const authoritativeResultFollows = isRecord(part) && + replayMatches.matchedToolCallParts.has(part); + pushToolCall( + part, + rawToolCall, + authoritativeResultFollows ? null : buildRawToolCallResultOutput(rawToolCall), + ); + continue; + } + + const rawToolResult = getRawToolResultPart(part); + if (rawToolResult) { + if ( + !isRecord(part) || !replayMatches.matchedToolResultParts.has(part) || + replayMatches.supersededToolResultParts.has(part) + ) { + continue; + } + + const toolResult = resolveRawToolResultPart( + rawToolResult, + toolNamesById, + knownToolNamesById, + replayMatches.matchedToolResultNames.get(part), + ); + if (toolResult) { + pushToolResult(toolResult); + } + } + } + + flushAssistantMessage(assistantContent); + flushToolMessage(); + flushAssistantMessage(deferredAssistantContent); + + return messages; +} + +function convertToolMessage( + message: ChatProviderModelInputMessage, + knownToolNamesById: ReadonlyMap, + replayMatches: ProviderVisibleToolReplayMatches, +): ProviderModelMessage[] { + const toolNamesById = buildToolNameMap(message.parts); + const toolResults: ChatToolResultPart[] = []; + + for (const part of message.parts) { + const toolPart = getToolPart(part); + if (toolPart) { + const output = buildToolResultOutput(toolPart); + if (output) { + if ( + !isRecord(part) || !replayMatches.matchedToolResultParts.has(part) || + replayMatches.supersededToolResultParts.has(part) + ) { + continue; + } + + toolResults.push({ + type: "tool-result", + toolCallId: toolPart.toolCallId, + toolName: toolPart.toolName, + output, + }); + } + continue; + } + + const rawResult = getRawToolResultPart(part); + if (!rawResult) { + continue; + } + if ( + !isRecord(part) || !replayMatches.matchedToolResultParts.has(part) || + replayMatches.supersededToolResultParts.has(part) + ) { + continue; + } + + const toolResult = resolveRawToolResultPart( + rawResult, + toolNamesById, + knownToolNamesById, + replayMatches.matchedToolResultNames.get(part), + ); + if (toolResult) { + toolResults.push(toolResult); + } + } + + if (toolResults.length === 0) { + return []; + } + + return [{ role: "tool", content: toolResults }]; +} + +/** Convert UI messages to provider model messages. */ +export function convertUiMessagesToProviderModelMessages( + messages: readonly ChatProviderModelInputMessage[], +): ProviderModelMessage[] { + const providerMessages: ProviderModelMessage[] = []; + const knownToolNamesById = new Map(); + const replayMatches = findProviderVisibleToolReplayMatches(messages); + + for (const message of messages) { + if (message.role === "assistant") { + for (const [toolCallId, toolName] of buildToolNameMap(message.parts)) { + knownToolNamesById.set(toolCallId, toolName); + } + } + + const converted = (() => { + switch (message.role) { + case "system": + return convertSystemMessage(message); + case "user": + return convertUserMessage(message); + case "assistant": + return convertAssistantMessage(message, knownToolNamesById, replayMatches); + case "tool": + return convertToolMessage(message, knownToolNamesById, replayMatches); + default: + return []; + } + })(); + + for (const rawProviderMessage of converted) { + const providerMessage = withProviderModelMessageSourceId(rawProviderMessage, message.id); + const previous = providerMessages.at(-1); + if (previous?.role === "tool" && providerMessage.role === "tool") { + providerMessages[providerMessages.length - 1] = withProviderModelMessageSourceId({ + role: "tool", + content: [...previous.content, ...providerMessage.content], + }, getProviderModelMessageSourceId(previous) ?? message.id); + continue; + } + + providerMessages.push(providerMessage); + } + } + + return providerMessages; +} From 9dd5faf555f192e230326b619ae3d326c9f778be Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 7 Aug 2026 07:50:55 +0200 Subject: [PATCH 3/3] chore(chat): re-pin file-size ceilings after provider-conversion split conversation.ts shrank from 1018 to 557 lines and split off two new leaf modules; tighten ceilings to match actual sizes and add entries for provider-input-types.ts and provider-message-conversion.ts so regrowth fails CI immediately. Record Provider Message Conversion as a domain term in CONTEXT.md. --- CONTEXT.md | 9 +++++++++ scripts/lint/ban-chat-antipatterns.ts | 8 +++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index d79ab63b81..e6416439d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -90,3 +90,12 @@ live, durable, diagnostic, and usage Adapters (Phase 5, separately designed). Through Gate 4, hosted durable and AG-UI production projections still consume compatibility UI chunks, and production runs stay on stream protocol version 1. + +## Provider Message Conversion + +The single owner of turning a chat's replay history into the ordered message +list a provider sees: `src/chat/provider-message-conversion.ts`. It asks Tool +Replay Reconciliation which tool occurrences are authoritative, maps each role's +parts into provider content, and settles into one `ProviderModelMessage[]`. +Message preparation and the compatibility layer are callers; neither re-derives +the mapping. diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index f144391054..86a129c825 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -113,11 +113,13 @@ const FILE_SIZE_CEILINGS: Record = { // 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/conversation.ts": 557, "src/chat/message-prep.ts": 2016, - "src/chat/tool-replay-reconciliation.ts": 294, + "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, - "src/chat/part-field-access.ts": 66, + "src/chat/part-field-access.ts": 65, + "src/chat/provider-input-types.ts": 34, + "src/chat/provider-message-conversion.ts": 447, }; function checkFileSizes(): boolean {