refactor: type both chat message parsers (#23176)

Both message parsers accepted untyped input and relied on scattered
asRecord/asString calls to extract fields at runtime. With the
discriminated ChatMessagePart union, both accept typed input directly
and narrow via switch (part.type).

parseMessageContent narrows from (content: unknown) to
(content: readonly ChatMessagePart[] | undefined), removing legacy
input shape handling the Go backend normalizes away.
applyMessagePartToStreamState narrows from Record<string, unknown>
to ChatMessagePart.

The SSE type guards had a & Record<string, unknown> intersection
that widened everything untyped downstream. Since the data comes
from our own API, the intersection was removed and all handlers in
ChatContext now use generated types directly.

Fixes tool_call_id and tool_name variant tags in codersdk/chats.go:
marked optional to match reality (Go guards against empty values,
omitempty omits them at the wire level).

Refs #23168, #23175
This commit is contained in:
Mathias Fredriksson
2026-03-18 15:50:57 +02:00
committed by GitHub
parent d69dcf18de
commit 8b4d35798a
8 changed files with 207 additions and 323 deletions
+2 -2
View File
@@ -129,8 +129,8 @@ type ChatMessagePart struct {
Type ChatMessagePartType `json:"type"`
Text string `json:"text,omitempty" variants:"text,reasoning"`
Signature string `json:"signature,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty" variants:"tool-call,tool-result"`
ToolName string `json:"tool_name,omitempty" variants:"tool-call,tool-result"`
ToolCallID string `json:"tool_call_id,omitempty" variants:"tool-call?,tool-result?"`
ToolName string `json:"tool_name,omitempty" variants:"tool-call?,tool-result?"`
Args json.RawMessage `json:"args,omitempty" variants:"tool-call?"`
ArgsDelta string `json:"args_delta,omitempty" variants:"tool-call?"`
Result json.RawMessage `json:"result,omitempty" variants:"tool-result?"`
-11
View File
@@ -223,7 +223,6 @@ func TestChatMessagePartVariantTags(t *testing.T) {
// Parse all variants tags from the struct and validate them.
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
coveredTypes := make(map[codersdk.ChatMessagePartType]bool)
hasRequired := make(map[codersdk.ChatMessagePartType]bool)
for i := range typ.NumField() {
f := typ.Field(i)
@@ -245,7 +244,6 @@ func TestChatMessagePartVariantTags(t *testing.T) {
"the discriminant field must not have a variants tag; %s", editHint)
for _, entry := range strings.Split(varTag, ",") {
isOptional := strings.HasSuffix(entry, "?")
typeLit := codersdk.ChatMessagePartType(strings.TrimSuffix(entry, "?"))
assert.True(t, knownTypes[typeLit],
@@ -253,9 +251,6 @@ func TestChatMessagePartVariantTags(t *testing.T) {
f.Name, typeLit, editHint)
coveredTypes[typeLit] = true
if !isOptional {
hasRequired[typeLit] = true
}
}
}
@@ -264,12 +259,6 @@ func TestChatMessagePartVariantTags(t *testing.T) {
assert.True(t, coveredTypes[pt],
"ChatMessagePartType %q is not referenced by any variants tag; %s", pt, editHint)
}
// Every variant must have at least one required field.
for pt := range coveredTypes {
assert.True(t, hasRequired[pt],
"variant %q has no required fields (all have ? suffix); %s", pt, editHint)
}
}
func TestModelCostConfig_LegacyNumericJSON(t *testing.T) {
+4 -4
View File
@@ -1805,8 +1805,8 @@ export interface ChatTextPart {
// From codersdk/chats.go
export interface ChatToolCallPart {
readonly type: "tool-call";
readonly tool_call_id: string;
readonly tool_name: string;
readonly tool_call_id?: string;
readonly tool_name?: string;
readonly args?: Record<string, string>;
readonly args_delta?: string;
/**
@@ -1819,8 +1819,8 @@ export interface ChatToolCallPart {
// From codersdk/chats.go
export interface ChatToolResultPart {
readonly type: "tool-result";
readonly tool_call_id: string;
readonly tool_name: string;
readonly tool_call_id?: string;
readonly tool_name?: string;
readonly result?: Record<string, string>;
readonly is_error?: boolean;
/**
@@ -1,7 +1,7 @@
import { watchChat } from "api/api";
import { chatMessagesKey, updateInfiniteChatsCache } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils";
import {
startTransition,
useCallback,
@@ -16,21 +16,7 @@ import type { ChatDetailError } from "../usageLimitMessage";
import { applyMessagePartToStreamState } from "./streamState";
import type { StreamState } from "./types";
const VALID_CHAT_STATUSES: ReadonlySet<string> = new Set<TypesGen.ChatStatus>([
"pending",
"running",
"completed",
"error",
"paused",
"waiting",
]);
const isValidChatStatus = (value: unknown): value is TypesGen.ChatStatus =>
typeof value === "string" && VALID_CHAT_STATUSES.has(value);
const isChatStreamEvent = (
data: unknown,
): data is TypesGen.ChatStreamEvent & Record<string, unknown> =>
const isChatStreamEvent = (data: unknown): data is TypesGen.ChatStreamEvent =>
typeof data === "object" &&
data !== null &&
"type" in data &&
@@ -38,12 +24,10 @@ const isChatStreamEvent = (
const isChatStreamEventArray = (
data: unknown,
): data is (TypesGen.ChatStreamEvent & Record<string, unknown>)[] =>
): data is TypesGen.ChatStreamEvent[] =>
Array.isArray(data) && data.every(isChatStreamEvent);
const toChatStreamEvents = (
data: unknown,
): (TypesGen.ChatStreamEvent & Record<string, unknown>)[] => {
const toChatStreamEvents = (data: unknown): TypesGen.ChatStreamEvent[] => {
if (isChatStreamEvent(data)) {
return [data];
}
@@ -158,8 +142,8 @@ type ChatStore = {
isDuplicate: boolean;
changed: boolean;
};
applyMessagePart: (part: Record<string, unknown>) => void;
applyMessageParts: (parts: readonly Record<string, unknown>[]) => void;
applyMessagePart: (part: TypesGen.ChatMessagePart) => void;
applyMessageParts: (parts: readonly TypesGen.ChatMessagePart[]) => void;
setQueuedMessages: (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => void;
@@ -281,7 +265,7 @@ export const createChatStore = (): ChatStore => {
return { isDuplicate, changed: actuallyChanged };
};
const applyMessageParts = (parts: readonly Record<string, unknown>[]) => {
const applyMessageParts = (parts: readonly TypesGen.ChatMessagePart[]) => {
if (parts.length === 0) {
return;
}
@@ -657,7 +641,7 @@ export const useChatStore = (
return currentStatus !== "pending" && currentStatus !== "waiting";
};
const pendingMessageParts: Record<string, unknown>[] = [];
const pendingMessageParts: TypesGen.ChatMessagePart[] = [];
const flushMessageParts = () => {
if (pendingMessageParts.length === 0) {
return;
@@ -681,14 +665,13 @@ export const useChatStore = (
for (const streamEvent of streamEvents) {
if (streamEvent.type === "message_part") {
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
if (!shouldApplyMessagePart()) {
continue;
}
const part = asRecord(streamEvent.message_part?.part);
const part = streamEvent.message_part?.part;
if (part) {
cancelScheduledStreamReset();
pendingMessageParts.push(part);
@@ -703,8 +686,7 @@ export const useChatStore = (
if (!message) {
continue;
}
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
const { changed } = store.upsertDurableMessage(message);
@@ -730,26 +712,21 @@ export const useChatStore = (
continue;
}
case "queue_update":
{
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
continue;
}
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
wsQueueUpdateReceivedRef.current = true;
store.setQueuedMessages(streamEvent.queued_messages);
updateChatQueuedMessages(streamEvent.queued_messages);
continue;
case "status": {
const status = asRecord(streamEvent.status);
const nextStatus = asString(status?.status);
if (!isValidChatStatus(nextStatus)) {
const nextStatus = streamEvent.status?.status;
if (!nextStatus) {
continue;
}
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
store.setSubagentStatusOverride(eventChatID, nextStatus);
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
store.setSubagentStatusOverride(streamEvent.chat_id, nextStatus);
continue;
}
@@ -771,13 +748,11 @@ export const useChatStore = (
continue;
}
case "error": {
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
const error = asRecord(streamEvent.error);
const reason =
asString(error?.message).trim() || "Chat processing failed.";
streamEvent.error?.message.trim() || "Chat processing failed.";
store.setChatStatus("error");
store.setStreamError(reason);
store.clearRetryState();
@@ -792,8 +767,7 @@ export const useChatStore = (
continue;
}
case "retry": {
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
const retry = streamEvent.retry;
@@ -49,14 +49,6 @@ describe("parseToolResultIsError", () => {
});
describe("parseMessageContent", () => {
it("returns empty result for null content", () => {
const result = parseMessageContent(null);
expect(result.markdown).toBe("");
expect(result.blocks).toEqual([]);
expect(result.toolCalls).toEqual([]);
expect(result.toolResults).toEqual([]);
});
it("returns empty result for undefined content", () => {
const result = parseMessageContent(undefined);
expect(result.markdown).toBe("");
@@ -71,12 +63,6 @@ describe("parseMessageContent", () => {
expect(result.toolResults).toEqual([]);
});
it("handles a plain string content", () => {
const result = parseMessageContent("Hello world");
expect(result.markdown).toBe("Hello world");
expect(result.blocks).toEqual([]);
});
it("parses a single text block", () => {
const result = parseMessageContent([{ type: "text", text: "Hello" }]);
expect(result.markdown).toBe("Hello");
@@ -173,7 +159,7 @@ describe("parseMessageContent", () => {
type: "tool-result",
tool_name: "bash",
tool_call_id: "call-1",
result: "ok",
result: { output: "ok" },
},
{ type: "text", text: "Done!" },
]);
@@ -195,32 +181,6 @@ describe("parseMessageContent", () => {
expect(result.toolCalls[0].id).toBe("tool-call-0");
});
it("handles unknown block types gracefully (no crash)", () => {
const result = parseMessageContent([
{ type: "unknown_block_type", text: "some text" },
]);
// Unknown types fall through to the default branch which treats
// the text field as a response.
expect(result.markdown).toBe("some text");
expect(result.blocks).toEqual([{ type: "response", text: "some text" }]);
});
it("handles non-object array entries gracefully", () => {
const result = parseMessageContent(["raw string", 42, null]);
expect(result.markdown).toBe("raw string");
expect(result.blocks).toEqual([{ type: "response", text: "raw string" }]);
});
it("handles an object with a type field (treated as single-element array)", () => {
const result = parseMessageContent({ type: "text", text: "single" });
expect(result.markdown).toBe("single");
});
it("handles an object with text/content fields", () => {
const result = parseMessageContent({ text: "fallback text" });
expect(result.markdown).toBe("fallback text");
});
it("extracts fileId from a file block with file_id", () => {
const result = parseMessageContent([
{
@@ -255,6 +215,16 @@ describe("parseMessageContent", () => {
});
});
it("skips file parts without data or file_id", () => {
const result = parseMessageContent([
{
type: "file",
media_type: "image/png",
},
]);
expect(result.blocks).toHaveLength(0);
});
it("parses a file-reference block into blocks", () => {
const result = parseMessageContent([
{
@@ -275,19 +245,6 @@ describe("parseMessageContent", () => {
});
});
it("defaults lines to 0 when no line fields are provided", () => {
const result = parseMessageContent([
{
type: "file-reference",
file_name: "bare.ts",
content: "bare content",
},
]);
const ref = result.blocks[0] as { start_line: number; end_line: number };
expect(ref.start_line).toBe(0);
expect(ref.end_line).toBe(0);
});
it("does not affect markdown when file-reference blocks are present", () => {
const result = parseMessageContent([
{ type: "text", text: "Hello" },
@@ -115,166 +115,101 @@ export const mergeTools = (
return merged;
};
export const parseMessageContent = (content: unknown): ParsedMessageContent => {
if (typeof content === "string") {
return {
...emptyParsedMessageContent(),
markdown: content,
};
}
if (Array.isArray(content)) {
const parsed = emptyParsedMessageContent();
for (const [index, block] of content.entries()) {
if (typeof block === "string") {
parsed.markdown = appendText(parsed.markdown, block);
parsed.blocks = appendTextBlock(parsed.blocks, "response", block);
continue;
}
const typedBlock = asRecord(block);
if (!typedBlock) {
continue;
}
switch (asString(typedBlock.type)) {
case "text": {
const text = asString(typedBlock.text);
parsed.markdown = appendText(parsed.markdown, text);
parsed.blocks = appendTextBlock(parsed.blocks, "response", text);
break;
}
case "reasoning": {
const text = asString(typedBlock.text);
parsed.reasoning = appendText(parsed.reasoning, text);
parsed.blocks = appendTextBlock(parsed.blocks, "thinking", text);
break;
}
case "tool-call": {
// Provider-executed tool calls (e.g. web_search) are
// handled by the provider itself — hide them from the
// tool card UI and let the sources component render
// their results.
if (typedBlock.provider_executed) {
break;
}
const name = asString(typedBlock.tool_name);
const id = asString(typedBlock.tool_call_id) || `tool-call-${index}`;
parsed.toolCalls.push({
id,
name: name || "Tool",
args: typedBlock.args,
});
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
case "file-reference": {
const fileName = asString(typedBlock.file_name);
const startLine = Number(typedBlock.start_line) || 0;
const endLine = Number(typedBlock.end_line) || startLine;
const content = asString(typedBlock.content);
parsed.blocks.push({
type: "file-reference",
file_name: fileName,
start_line: startLine,
end_line: endLine,
content,
});
break;
}
case "tool-result": {
// Skip synthetic results for provider-executed tools.
if (typedBlock.provider_executed) {
break;
}
const name = asString(typedBlock.tool_name);
const id =
asString(typedBlock.tool_call_id) || `tool-result-${index}`;
const result = typedBlock.result;
parsed.toolResults.push({
id,
name: name || "Tool",
result,
isError: parseToolResultIsError(name, typedBlock, result),
});
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
case "file": {
const mediaType = asString(typedBlock.media_type);
const data = asString(typedBlock.data) || undefined;
const fileId = asString(typedBlock.file_id) || undefined;
if (mediaType && (data || fileId)) {
parsed.blocks = [
...parsed.blocks,
{ type: "file", media_type: mediaType, data, file_id: fileId },
];
}
break;
}
case "source": {
const url = asString(typedBlock.url);
const title = asString(typedBlock.title);
if (url) {
const source = { url, title: title || url };
// Still populate the flat list for backward compat.
if (!parsed.sources.some((s) => s.url === url)) {
parsed.sources.push(source);
}
// Group consecutive sources into a single
// inline block at this position.
const lastBlock = parsed.blocks[parsed.blocks.length - 1];
if (
lastBlock &&
lastBlock.type === "sources" &&
!lastBlock.sources.some((s) => s.url === url)
) {
lastBlock.sources.push(source);
} else if (!lastBlock || lastBlock.type !== "sources") {
parsed.blocks.push({
type: "sources",
sources: [source],
});
}
}
break;
}
default: {
const text = asString(typedBlock.text);
parsed.markdown = appendText(parsed.markdown, text);
parsed.blocks = appendTextBlock(parsed.blocks, "response", text);
break;
}
}
}
return parsed;
}
if (content === null || content === undefined) {
export const parseMessageContent = (
content: readonly TypesGen.ChatMessagePart[] | undefined,
): ParsedMessageContent => {
if (!content || content.length === 0) {
return emptyParsedMessageContent();
}
const typedContent = asRecord(content);
if (!typedContent) {
const markdown = String(content);
return {
...emptyParsedMessageContent(),
markdown,
blocks: appendTextBlock([], "response", markdown),
};
const parsed = emptyParsedMessageContent();
for (const [index, part] of content.entries()) {
switch (part.type) {
case "text": {
parsed.markdown = appendText(parsed.markdown, part.text);
parsed.blocks = appendTextBlock(parsed.blocks, "response", part.text);
break;
}
case "reasoning": {
parsed.reasoning = appendText(parsed.reasoning, part.text);
parsed.blocks = appendTextBlock(parsed.blocks, "thinking", part.text);
break;
}
case "tool-call": {
// Provider-executed tool calls (e.g. web_search) are
// handled by the provider itself — hide them from the
// tool card UI and let the sources component render
// their results.
if (part.provider_executed) {
break;
}
const id = part.tool_call_id || `tool-call-${index}`;
parsed.toolCalls.push({
id,
name: part.tool_name || "Tool",
args: part.args,
});
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
case "file-reference": {
parsed.blocks.push(part);
break;
}
case "tool-result": {
// Skip synthetic results for provider-executed tools.
if (part.provider_executed) {
break;
}
const id = part.tool_call_id || `tool-result-${index}`;
const name = part.tool_name || "Tool";
parsed.toolResults.push({
id,
name,
result: part.result,
isError: parseToolResultIsError(name, part, part.result),
});
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
case "file": {
if (part.data || part.file_id) {
parsed.blocks = [...parsed.blocks, part];
}
break;
}
case "source": {
if (part.url) {
const source = { url: part.url, title: part.title || part.url };
// Still populate the flat list for backward compat.
if (!parsed.sources.some((s) => s.url === part.url)) {
parsed.sources.push(source);
}
// Group consecutive sources into a single
// inline block at this position.
const lastBlock = parsed.blocks[parsed.blocks.length - 1];
if (
lastBlock &&
lastBlock.type === "sources" &&
!lastBlock.sources.some((s) => s.url === part.url)
) {
lastBlock.sources.push(source);
} else if (!lastBlock || lastBlock.type !== "sources") {
parsed.blocks.push({
type: "sources",
sources: [source],
});
}
}
break;
}
default: {
const _exhaustive: never = part;
break;
}
}
}
if (typedContent.type) {
return parseMessageContent([typedContent]);
}
const markdown =
asString(typedContent.text) || asString(typedContent.content);
return {
...emptyParsedMessageContent(),
markdown,
blocks: appendTextBlock([], "response", markdown),
};
return parsed;
};
export const parseMessagesWithMergedTools = (
@@ -139,13 +139,13 @@ describe("applyMessagePartToStreamState", () => {
const callIds = Object.keys(state!.toolCalls);
expect(callIds).toHaveLength(2);
// First result arrives without an explicit tool_call_id.
// First result arrives without a tool_call_id.
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "bash",
result: { output: "file.txt" },
});
// Second result arrives without an explicit tool_call_id.
// Second result arrives without a tool_call_id.
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "bash",
@@ -179,21 +179,6 @@ describe("applyMessagePartToStreamState", () => {
});
});
it("returns prev for unknown part type", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
type: "banana",
});
expect(result).toBe(prev);
});
it("returns null for unknown part type when prev is null", () => {
const result = applyMessagePartToStreamState(null, {
type: "banana",
});
expect(result).toBeNull();
});
it("accumulates multiple tool calls in sequence", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
@@ -268,6 +253,57 @@ describe("applyMessagePartToStreamState", () => {
expect(prev.toolResults).toEqual({});
});
it("adds a file block from a file part with data", () => {
const result = applyMessagePartToStreamState(null, {
type: "file",
media_type: "image/png",
data: "iVBORw0KGgo=",
});
expect(result).not.toBeNull();
expect(result!.blocks).toHaveLength(1);
expect(result!.blocks[0]).toMatchObject({
type: "file",
media_type: "image/png",
data: "iVBORw0KGgo=",
});
});
it("adds a file block from a file part with file_id", () => {
const result = applyMessagePartToStreamState(null, {
type: "file",
media_type: "image/png",
file_id: "abc-123",
});
expect(result).not.toBeNull();
expect(result!.blocks).toHaveLength(1);
expect(result!.blocks[0]).toMatchObject({
type: "file",
media_type: "image/png",
file_id: "abc-123",
});
});
it("returns prev for file part without data or file_id", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
type: "file",
media_type: "image/png",
});
expect(result).toBe(prev);
});
it("returns prev for file-reference part (not a streaming type)", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
type: "file-reference",
file_name: "main.go",
start_line: 1,
end_line: 10,
content: "package main",
});
expect(result).toBe(prev);
});
it("adds a sources block from a source part", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
@@ -1,4 +1,4 @@
import { asString } from "components/ai-elements/runtimeTypeUtils";
import type * as TypesGen from "api/typesGenerated";
import { appendTextBlock } from "./blockUtils";
import { ensureToolBlock, parseToolResultIsError } from "./messageParsing";
import { mergeStreamPayload } from "./streamingJson";
@@ -15,30 +15,27 @@ export const createEmptyStreamState = (): StreamState => ({
export const applyMessagePartToStreamState = (
prev: StreamState | null,
part: Record<string, unknown>,
part: TypesGen.ChatMessagePart,
): StreamState | null => {
const partType = asString(part.type);
const nextState: StreamState = prev ?? createEmptyStreamState();
switch (partType) {
switch (part.type) {
case "text": {
const text = asString(part.text);
if (!text) {
if (!part.text) {
return prev;
}
return {
...nextState,
blocks: appendTextBlock(nextState.blocks, "response", text),
blocks: appendTextBlock(nextState.blocks, "response", part.text),
};
}
case "reasoning": {
const text = asString(part.text);
if (!text) {
if (!part.text) {
return prev;
}
return {
...nextState,
blocks: appendTextBlock(nextState.blocks, "thinking", text),
blocks: appendTextBlock(nextState.blocks, "thinking", part.text),
};
}
case "tool-call": {
@@ -48,12 +45,11 @@ export const applyMessagePartToStreamState = (
if (part.provider_executed) {
return prev;
}
const toolName = asString(part.tool_name);
const existingByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
(call) => call.name === part.tool_name,
);
const toolCallID =
asString(part.tool_call_id) ||
part.tool_call_id ||
(existingByName && !existingByName.args ? existingByName.id : null) ||
`tool-call-${Object.keys(nextState.toolCalls).length + 1}-${++nextFallbackID}`;
const existing = nextState.toolCalls[toolCallID];
@@ -71,7 +67,7 @@ export const applyMessagePartToStreamState = (
...nextState.toolCalls,
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
name: part.tool_name || existing?.name || "Tool",
args: nextArgs.value,
argsRaw: nextArgs.rawText,
},
@@ -83,15 +79,14 @@ export const applyMessagePartToStreamState = (
if (part.provider_executed) {
return prev;
}
const toolName = asString(part.tool_name);
const existingByName = Object.values(nextState.toolResults).find(
(result) => result.name === toolName,
(result) => result.name === part.tool_name,
);
const existingCallByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
(call) => call.name === part.tool_name,
);
const toolCallID =
asString(part.tool_call_id) ||
part.tool_call_id ||
(existingByName && !existingByName.result ? existingByName.id : null) ||
(existingCallByName && !nextState.toolResults[existingCallByName.id]
? existingCallByName.id
@@ -104,7 +99,7 @@ export const applyMessagePartToStreamState = (
part.result,
undefined, // no delta: tool results arrive complete, not streamed incrementally
);
const nextToolName = toolName || existing?.name || "Tool";
const nextToolName = part.tool_name || existing?.name || "Tool";
const nextIsError =
existing?.isError ||
parseToolResultIsError(nextToolName, part, nextResult.value);
@@ -125,29 +120,21 @@ export const applyMessagePartToStreamState = (
};
}
case "file": {
const mediaType = asString(part.media_type);
const data = asString(part.data) || undefined;
const fileId = asString(part.file_id) || undefined;
if (!mediaType || (!data && !fileId)) {
if (!part.data && !part.file_id) {
return prev;
}
return {
...nextState,
blocks: [
...nextState.blocks,
{ type: "file", media_type: mediaType, data, file_id: fileId },
],
blocks: [...nextState.blocks, part],
};
}
case "source": {
const url = asString(part.url);
const title = asString(part.title);
if (!url) {
if (!part.url) {
return prev;
}
const source = { url, title: title || url };
const source = { url: part.url, title: part.title || part.url };
// Still populate the flat list for backward compat.
if (nextState.sources.some((s) => s.url === url)) {
if (nextState.sources.some((s) => s.url === part.url)) {
return prev;
}
const newSources = [...nextState.sources, source];
@@ -174,8 +161,14 @@ export const applyMessagePartToStreamState = (
blocks: newBlocks,
};
}
default:
// file-reference parts only appear in persisted messages
// from user input, never via SSE streaming.
case "file-reference":
return prev;
default: {
const _exhaustive: never = part;
return prev;
}
}
};