diff --git a/src/features/agents/components/FastAgentPanel/adapters/convexToUIParts.test.ts b/src/features/agents/components/FastAgentPanel/adapters/convexToUIParts.test.ts new file mode 100644 index 00000000..33168309 --- /dev/null +++ b/src/features/agents/components/FastAgentPanel/adapters/convexToUIParts.test.ts @@ -0,0 +1,802 @@ +import type { UIMessage } from "@convex-dev/agent/react"; +import { describe, expect, it } from "vitest"; + +import { convexToUIParts } from "./convexToUIParts"; + +function message( + parts: Array>, + overrides: Partial & { text?: string } = {}, +): UIMessage { + return { + id: "message-1", + key: "thread-1-0-0", + order: 0, + stepOrder: 0, + status: "success", + agentName: "coordinator", + text: "materialized text", + _creationTime: 1, + role: "assistant", + parts, + ...overrides, + } as unknown as UIMessage; +} + +describe("convexToUIParts", () => { + it("preserves the Convex materialized text, role, status, and streaming flag", () => { + const result = convexToUIParts( + message( + [ + { type: "text", text: "part one" }, + { type: "text", text: "part two" }, + ], + { role: "user", status: "streaming", text: "live materialized text" }, + ), + ); + + expect(result.from).toBe("user"); + expect(result.text).toBe("live materialized text"); + expect(result.status).toBe("streaming"); + expect(result.isStreaming).toBe(true); + }); + + it("falls back to ordered text parts for an unaugmented AI SDK message", () => { + const input = message( + [ + { type: "text", text: "hello " }, + { type: "text", text: "world" }, + ], + { text: undefined }, + ); + + expect(convexToUIParts(input).text).toBe("hello world"); + }); + + it("joins reasoning parts in message order without mixing them into text", () => { + const result = convexToUIParts( + message([ + { type: "reasoning", text: "inspect" }, + { type: "text", text: "answer" }, + { type: "reasoning", text: "verify" }, + ]), + ); + + expect(result.reasoning).toBe("inspect\nverify"); + expect(result.text).toBe("materialized text"); + }); + + it("passes valid AI SDK v5 unified tool parts through by identity", () => { + const tool = { + type: "tool-webSearch", + toolCallId: "call-1", + state: "output-available", + input: { query: "NodeBench" }, + output: { results: ["one"] }, + callProviderMetadata: { openai: { cached: true } }, + }; + + const result = convexToUIParts(message([tool])); + + expect(result.toolParts).toHaveLength(1); + expect(result.toolParts[0]).toBe(tool); + }); + + it.each([ + [ + { + type: "tool-webSearch", + toolCallId: "call-1", + args: { query: "q" }, + status: "running", + }, + "input-streaming", + { input: { query: "q" } }, + ], + [ + { + type: "tool-webSearch", + toolCallId: "call-1", + args: { query: "q" }, + result: { ok: true }, + }, + "output-available", + { input: { query: "q" }, output: { ok: true } }, + ], + [ + { + type: "tool-webSearch", + toolCallId: "call-1", + args: { query: "q" }, + error: "network", + }, + "output-error", + { input: { query: "q" }, errorText: "network" }, + ], + [ + { + type: "tool-webSearch", + toolCallId: "call-1", + state: "input-available", + input: { query: "q" }, + errorText: "provider failed", + }, + "output-error", + { input: { query: "q" }, errorText: "provider failed" }, + ], + ])( + "normalizes incomplete unified tool parts (%s)", + (tool, state, expected) => { + const normalized = convexToUIParts(message([tool])).toolParts[0]; + + expect(normalized).toMatchObject({ + type: "tool-webSearch", + toolCallId: "call-1", + state, + ...expected, + }); + expect(normalized).not.toBe(tool); + }, + ); + + it("merges legacy split tool-call and tool-result parts by call id", () => { + const input = { query: "funding" }; + const output = { results: ["Acme"] }; + const call = { + type: "tool-call", + toolName: "fusionSearch", + toolCallId: "call-7", + args: input, + }; + const resultPart = { + type: "tool-result", + toolName: "fusionSearch", + toolCallId: "call-7", + output, + }; + + const [tool] = convexToUIParts(message([call, resultPart])).toolParts; + + expect(tool).toMatchObject({ + type: "tool-fusionSearch", + toolCallId: "call-7", + state: "output-available", + }); + expect(tool.input).toBe(input); + expect(tool.state === "output-available" && tool.output).toBe(output); + expect(tool).not.toHaveProperty("args"); + expect(tool).not.toHaveProperty("result"); + expect(call).not.toHaveProperty("state"); + expect(resultPart.type).toBe("tool-result"); + }); + + it("merges typed, id-less legacy call/result pairs using the tool name", () => { + const output = { value: "done" }; + const tools = convexToUIParts( + message([ + { type: "tool-call-secCompanySearch", args: { ticker: "NB" } }, + { type: "tool-result-secCompanySearch", result: output }, + ]), + ).toolParts; + + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ + type: "tool-secCompanySearch", + state: "output-available", + toolCallId: "legacy:message-1:secCompanySearch:1", + input: { ticker: "NB" }, + }); + expect(tools[0].state === "output-available" && tools[0].output).toBe( + output, + ); + }); + + it("pairs repeated id-less legacy calls and results in invocation order", () => { + const tools = convexToUIParts( + message([ + { type: "tool-call-fetchUrl", args: { page: 1 } }, + { type: "tool-call-fetchUrl", args: { page: 2 } }, + { type: "tool-result-fetchUrl", result: "first" }, + { type: "tool-result-fetchUrl", result: "second" }, + ]), + ).toolParts; + + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ input: { page: 1 }, output: "first" }); + expect(tools[1]).toMatchObject({ input: { page: 2 }, output: "second" }); + }); + + it("does not enqueue repeated unified snapshots as separate open calls", () => { + const input = { page: 1 }; + const streamingSnapshot = { + type: "tool-fetchUrl", + toolCallId: "fetch-1", + state: "input-streaming", + input, + }; + const availableSnapshot = { + type: "tool-fetchUrl", + toolCallId: "fetch-1", + state: "input-available", + input, + }; + const firstResult = { + type: "tool-result", + toolName: "fetchUrl", + result: "first", + }; + const secondResult = { + type: "tool-result", + toolName: "fetchUrl", + result: "second", + }; + + const result = convexToUIParts( + message([ + streamingSnapshot, + availableSnapshot, + firstResult, + secondResult, + ]), + ); + + expect(result.toolParts).toHaveLength(2); + expect(result.toolParts[0]).toMatchObject({ + toolCallId: "fetch-1", + input, + output: "first", + }); + expect(result.toolParts[1]).toMatchObject({ + toolCallId: "legacy:message-1:fetchUrl:4", + output: "second", + }); + expect( + result.renderParts.map(({ kind, originalIndex }) => ({ + kind, + originalIndex, + })), + ).toEqual([ + { kind: "tool", originalIndex: 0 }, + { kind: "tool", originalIndex: 3 }, + ]); + expect(result.renderParts[0]).toMatchObject({ + rawParts: [streamingSnapshot, availableSnapshot, firstResult], + }); + expect(result.renderParts[1]).toMatchObject({ + rawParts: [secondResult], + }); + }); + + it("normalizes legacy tool errors and keeps the original input object", () => { + const input = { url: "https://example.com" }; + const [tool] = convexToUIParts( + message([ + { + type: "tool-call", + toolName: "fetchUrl", + toolCallId: "fetch-1", + args: input, + }, + { + type: "tool-error", + toolName: "fetchUrl", + toolCallId: "fetch-1", + error: "timeout", + }, + ]), + ).toolParts; + + expect(tool).toMatchObject({ + type: "tool-fetchUrl", + state: "output-error", + errorText: "timeout", + }); + expect(tool.input).toBe(input); + expect(tool).not.toHaveProperty("error"); + expect(tool).not.toHaveProperty("output"); + }); + + it("does not reuse a completed explicit call for a later id-less result", () => { + const tools = convexToUIParts( + message([ + { + type: "tool-call", + toolName: "fetchUrl", + toolCallId: "fetch-1", + args: { page: 1 }, + }, + { + type: "tool-result", + toolName: "fetchUrl", + toolCallId: "fetch-1", + result: "first", + }, + { type: "tool-result", toolName: "fetchUrl", result: "second" }, + ]), + ).toolParts; + + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ toolCallId: "fetch-1", output: "first" }); + expect(tools[1]).toMatchObject({ + toolCallId: "legacy:message-1:fetchUrl:3", + output: "second", + }); + }); + + it("keeps standalone legacy results instead of dropping completed work", () => { + const output = { rows: 3 }; + const [tool] = convexToUIParts( + message([{ type: "tool-result", toolName: "queryRows", output }]), + ).toolParts; + + expect(tool).toMatchObject({ + type: "tool-queryRows", + state: "output-available", + toolCallId: "legacy:message-1:queryRows:1", + }); + expect(tool.state === "output-available" && tool.output).toBe(output); + }); + + it("supports dynamic tool parts without flattening their tool name", () => { + const dynamic = { + type: "dynamic-tool", + toolName: "customerTool", + toolCallId: "dynamic-1", + state: "input-available", + input: { id: 1 }, + }; + + const [tool] = convexToUIParts(message([dynamic])).toolParts; + + expect(tool).toBe(dynamic); + expect(tool).toMatchObject({ + type: "dynamic-tool", + toolName: "customerTool", + }); + }); + + it("exposes one ordered render owner for each generic message part", () => { + const text = { type: "text", text: "before" }; + const tool = { + type: "tool-webSearch", + toolCallId: "search-1", + state: "input-available", + input: { query: "NodeBench" }, + }; + const reasoning = { type: "reasoning", text: "checking" }; + const domain = { + type: "data-verification-receipt", + data: { passed: true }, + }; + const source = { + type: "source-url", + sourceId: "source-1", + url: "https://example.com", + }; + const file = { + type: "file", + mediaType: "image/png", + url: "https://example.com/chart.png", + }; + + const result = convexToUIParts( + message([text, tool, reasoning, domain, source, file]), + ); + + expect( + result.renderParts.map(({ kind, originalIndex }) => ({ + kind, + originalIndex, + })), + ).toEqual([ + { kind: "text", originalIndex: 0 }, + { kind: "tool", originalIndex: 1 }, + { kind: "reasoning", originalIndex: 2 }, + { kind: "domain", originalIndex: 3 }, + { kind: "source", originalIndex: 4 }, + { kind: "file", originalIndex: 5 }, + ]); + expect(result.renderParts[0]?.part).toBe(text); + expect(result.renderParts[1]?.part).toBe(tool); + expect(result.renderParts[2]?.part).toBe(reasoning); + expect(result.renderParts[3]?.part).toBe(domain); + expect(result.renderParts[4]?.part).toBe(source); + expect(result.renderParts[5]?.part).toBe(file); + }); + + it("gives a unified domain tool exactly one render owner", () => { + const domainTool = { + type: "tool-fusionSearch", + toolCallId: "fusion-1", + state: "output-available", + input: { query: "NodeBench" }, + output: + "\n", + }; + + const result = convexToUIParts(message([domainTool])); + + expect(result.renderParts).toHaveLength(1); + expect(result.renderParts[0]).toMatchObject({ + kind: "domain-tool", + originalIndex: 0, + categories: ["media", "fusedSearch"], + }); + expect(result.renderParts[0]?.part).toBe(domainTool); + expect( + result.renderParts[0]?.kind === "domain-tool" && + result.renderParts[0].domainPart, + ).toBe(domainTool); + expect(result.toolParts).toEqual([domainTool]); + expect(result.domainParts.all).toEqual([domainTool]); + }); + + it("merges an id-less legacy domain tool into one ordered render owner", () => { + const call = { + type: "tool-call-fusionSearch", + args: { query: "NodeBench" }, + }; + const text = { type: "text", text: "interleaved" }; + const resultPart = { + type: "tool-result-fusionSearch", + result: "", + }; + + const result = convexToUIParts(message([call, text, resultPart])); + + expect( + result.renderParts.map(({ kind, originalIndex }) => ({ + kind, + originalIndex, + })), + ).toEqual([ + { kind: "domain-tool", originalIndex: 0 }, + { kind: "text", originalIndex: 1 }, + ]); + const owner = result.renderParts[0]; + expect(owner).toMatchObject({ + kind: "domain-tool", + categories: ["fusedSearch"], + part: { + type: "tool-fusionSearch", + toolCallId: "legacy:message-1:fusionSearch:1", + state: "output-available", + input: { query: "NodeBench" }, + output: "", + }, + }); + expect(owner?.kind === "domain-tool" && owner.domainPart).toBe(resultPart); + expect(owner?.kind === "domain-tool" && owner.rawParts).toEqual([ + call, + resultPart, + ]); + }); + + it("rebuilds Convex output errors that carry an AI SDK-forbidden output", () => { + const input = { url: "https://example.com" }; + const malformed = { + type: "tool-fetchUrl", + toolCallId: "fetch-1", + state: "output-error", + input, + output: { partial: true }, + errorText: "timeout", + callProviderMetadata: { openai: { requestId: "req-1" } }, + }; + + const [tool] = convexToUIParts(message([malformed])).toolParts; + + expect(tool).not.toBe(malformed); + expect(tool).toMatchObject({ + type: "tool-fetchUrl", + toolCallId: "fetch-1", + state: "output-error", + input, + errorText: "timeout", + callProviderMetadata: { openai: { requestId: "req-1" } }, + }); + expect(tool).not.toHaveProperty("output"); + }); + + it("keeps explicit terminal states while repairing contradictory nullable fields", () => { + const errorInput = { url: "https://example.com" }; + const partialOutput = { partial: true }; + const explicitError = { + type: "tool-fetchUrl", + toolCallId: "fetch-with-partial-output", + state: "output-error", + input: errorInput, + output: partialOutput, + }; + const successInput = { id: 1 }; + const successOutput = { ok: true }; + const nullableError = { + type: "tool-saveRecord", + toolCallId: "save-with-null-error", + state: "output-available", + input: successInput, + output: successOutput, + error: null, + }; + + const [normalizedError, normalizedOutput] = convexToUIParts( + message([explicitError, nullableError]), + ).toolParts; + + expect(normalizedError).not.toBe(explicitError); + expect(normalizedError).toMatchObject({ + type: "tool-fetchUrl", + toolCallId: "fetch-with-partial-output", + state: "output-error", + input: errorInput, + errorText: "Tool execution failed", + }); + expect(normalizedError).not.toHaveProperty("output"); + + expect(normalizedOutput).not.toBe(nullableError); + expect(normalizedOutput).toMatchObject({ + type: "tool-saveRecord", + toolCallId: "save-with-null-error", + state: "output-available", + input: successInput, + output: successOutput, + }); + expect( + normalizedOutput.state === "output-available" && normalizedOutput.output, + ).toBe(successOutput); + expect(normalizedOutput).not.toHaveProperty("error"); + expect(normalizedOutput).not.toHaveProperty("errorText"); + }); + + it("passes a state-valid unified output error through by identity", () => { + const valid = { + type: "tool-fetchUrl", + toolCallId: "fetch-valid", + state: "output-error", + input: { url: "https://example.com" }, + errorText: "timeout", + }; + + const [tool] = convexToUIParts(message([valid])).toolParts; + + expect(tool).toBe(valid); + }); + + it("rebuilds terminal unified tools whose required state field is missing", () => { + const outputAvailable = { + type: "tool-voidAction", + toolCallId: "void-1", + state: "output-available", + input: { id: 1 }, + }; + const outputError = { + type: "tool-fetchUrl", + toolCallId: "fetch-2", + state: "output-error", + input: { url: "https://example.com" }, + }; + + const [normalizedOutput, normalizedError] = convexToUIParts( + message([outputAvailable, outputError]), + ).toolParts; + + expect(normalizedOutput).not.toBe(outputAvailable); + expect(normalizedOutput).toMatchObject({ + state: "output-available", + input: { id: 1 }, + }); + expect(normalizedOutput).toHaveProperty("output", undefined); + + expect(normalizedError).not.toBe(outputError); + expect(normalizedError).toMatchObject({ + state: "output-error", + input: { url: "https://example.com" }, + errorText: "Tool execution failed", + }); + expect(normalizedError).not.toHaveProperty("output"); + }); + + it("preserves source and file objects by identity", () => { + const urlSource = { + type: "source-url", + sourceId: "source-1", + url: "https://example.com", + title: "Example", + }; + const documentSource = { + type: "source-document", + sourceId: "source-2", + mediaType: "application/pdf", + title: "Filing", + filename: "filing.pdf", + }; + const file = { + type: "file", + mediaType: "image/png", + url: "https://example.com/chart.png", + }; + + const result = convexToUIParts(message([urlSource, documentSource, file])); + + expect(result.sources).toEqual([urlSource, documentSource]); + expect(result.sources[0]).toBe(urlSource); + expect(result.sources[1]).toBe(documentSource); + expect(result.fileParts).toEqual([file]); + expect(result.fileParts[0]).toBe(file); + }); + + it("routes every known domain category by reference and preserves order", () => { + const selection = { + type: "data-company_selection", + data: { companies: [] }, + }; + const arbitrage = { + type: "data-arbitrage-report", + data: { contradictions: [] }, + }; + const media = { type: "data-mediaGallery", data: { videos: [] } }; + const goal = { type: "data-goal-card", data: { goal: "Ship" } }; + const fused = { type: "data-fused-search", data: { results: [] } }; + const document = { type: "data-document-action", data: { id: "doc-1" } }; + const edit = { type: "data-edit-progress", data: { status: "running" } }; + const unknown = { + type: "data-verification-receipt", + data: { passed: true }, + }; + + const { domainParts } = convexToUIParts( + message([ + selection, + arbitrage, + media, + goal, + fused, + document, + edit, + unknown, + ]), + ); + + expect(domainParts.all).toEqual([ + selection, + arbitrage, + media, + goal, + fused, + document, + edit, + unknown, + ]); + expect(domainParts.selection[0]).toBe(selection); + expect(domainParts.arbitrage[0]).toBe(arbitrage); + expect(domainParts.media[0]).toBe(media); + expect(domainParts.goalCard[0]).toBe(goal); + expect(domainParts.fusedSearch[0]).toBe(fused); + expect(domainParts.documentAction[0]).toBe(document); + expect(domainParts.editProgress[0]).toBe(edit); + expect(domainParts.other[0]).toBe(unknown); + }); + + it("routes legacy non-data domain parts without cloning them", () => { + const selection = { type: "company_selection", options: [{ id: "acme" }] }; + + const { domainParts } = convexToUIParts(message([selection])); + + expect(domainParts.selection).toHaveLength(1); + expect(domainParts.selection[0]).toBe(selection); + }); + + it("routes structured legacy tool results to custom renderers by identity", () => { + const selection = { + type: "tool-result", + toolName: "searchCompanies", + toolCallId: "selection-1", + output: { + kind: "company_selection", + version: 1, + summary: "Choose a company", + data: { companies: [] }, + }, + }; + const document = { + type: "tool-result-createDocument", + toolCallId: "document-1", + result: '', + }; + + const { domainParts } = convexToUIParts(message([selection, document])); + + expect(domainParts.all).toEqual([selection, document]); + expect(domainParts.selection[0]).toBe(selection); + expect(domainParts.documentAction[0]).toBe(document); + }); + + it("routes live unified tool parts to every applicable domain renderer", () => { + const fusedWithMedia = { + type: "tool-fusionSearch", + toolCallId: "fusion-1", + state: "output-available", + input: { query: "NodeBench" }, + output: + "\n", + }; + const delegation = { + type: "tool-delegateToResearchAgent", + toolCallId: "delegate-1", + state: "input-available", + input: { goal: "Research" }, + }; + const arbitrage = { + type: "tool-contradictionDetection", + toolCallId: "arbitrage-1", + state: "output-available", + input: {}, + output: { contradictions: [] }, + }; + + const { domainParts } = convexToUIParts( + message([fusedWithMedia, delegation, arbitrage]), + ); + + expect(domainParts.fusedSearch[0]).toBe(fusedWithMedia); + expect(domainParts.media[0]).toBe(fusedWithMedia); + expect(domainParts.goalCard[0]).toBe(delegation); + expect(domainParts.arbitrage[0]).toBe(arbitrage); + expect(domainParts.all).toEqual([fusedWithMedia, delegation, arbitrage]); + }); + + it("does not emit persistent-text-streaming bodies or message-only stream fields", () => { + const streamBody = { + type: "data-persistentTextStreaming", + data: { body: "must remain in useStream" }, + }; + const input = message([ + streamBody, + { type: "text", text: "safe" }, + ]) as UIMessage & { + streamId: string; + streamBody: string; + }; + input.streamId = "stream-1"; + input.streamBody = "private stream body"; + + const result = convexToUIParts(input); + + expect(result.domainParts.all).toEqual([]); + expect(result.renderParts).toMatchObject([ + { kind: "text", originalIndex: 1 }, + ]); + expect(result).not.toHaveProperty("streamId"); + expect(result).not.toHaveProperty("streamBody"); + expect(JSON.stringify(result)).not.toContain("private stream body"); + expect(JSON.stringify(result)).not.toContain("must remain in useStream"); + }); + + it.each(["pending", "success", "failed"] as const)( + "does not label %s messages as streaming", + (status) => { + const result = convexToUIParts(message([], { status })); + + expect(result.status).toBe(status); + expect(result.isStreaming).toBe(false); + }, + ); + + it("preserves unknown custom parts while ignoring malformed and step boundary parts", () => { + const futureDomainPart = { + type: "verification-receipt", + value: { passed: true }, + }; + const result = convexToUIParts( + message([ + { type: "step-start" }, + futureDomainPart, + { value: "missing type" }, + ]), + ); + + expect(result.toolParts).toEqual([]); + expect(result.sources).toEqual([]); + expect(result.domainParts.all).toEqual([futureDomainPart]); + expect(result.domainParts.other[0]).toBe(futureDomainPart); + }); +}); diff --git a/src/features/agents/components/FastAgentPanel/adapters/convexToUIParts.ts b/src/features/agents/components/FastAgentPanel/adapters/convexToUIParts.ts new file mode 100644 index 00000000..dad529eb --- /dev/null +++ b/src/features/agents/components/FastAgentPanel/adapters/convexToUIParts.ts @@ -0,0 +1,808 @@ +import type { UIMessage } from "@convex-dev/agent/react"; +import type { + DynamicToolUIPart, + FileUIPart, + ReasoningUIPart, + SourceDocumentUIPart, + SourceUrlUIPart, + TextUIPart, + ToolUIPart, +} from "ai"; + +type ConvexMessagePart = UIMessage["parts"][number]; + +/** + * Parts produced by older NodeBench writers predate the AI SDK v5 UIMessage + * union. Keep the compatibility shape deliberately loose at this boundary; + * callers still receive strongly typed, normalized standard parts. + */ +export type LegacyMessagePart = Record & { type: string }; +export type PassthroughMessagePart = ConvexMessagePart | LegacyMessagePart; +export type NormalizedToolPart = ToolUIPart | DynamicToolUIPart; +export type SourcePart = SourceUrlUIPart | SourceDocumentUIPart; + +export interface DomainParts { + /** Every routed domain/data part, in message order. */ + all: PassthroughMessagePart[]; + selection: PassthroughMessagePart[]; + arbitrage: PassthroughMessagePart[]; + media: PassthroughMessagePart[]; + goalCard: PassthroughMessagePart[]; + fusedSearch: PassthroughMessagePart[]; + documentAction: PassthroughMessagePart[]; + editProgress: PassthroughMessagePart[]; + /** Future or product-specific data parts not covered by a named renderer. */ + other: PassthroughMessagePart[]; +} + +export type DomainCategory = Exclude; + +interface IndexedRenderPart { + /** Position in the original Convex `message.parts` array. */ + originalIndex: number; +} + +export type ConvexUIRenderPart = + | (IndexedRenderPart & { + kind: "text"; + part: TextUIPart; + }) + | (IndexedRenderPart & { + kind: "reasoning"; + part: ReasoningUIPart; + }) + | (IndexedRenderPart & { + kind: "source"; + part: SourcePart; + }) + | (IndexedRenderPart & { + kind: "file"; + part: FileUIPart; + }) + | (IndexedRenderPart & { + kind: "tool"; + part: NormalizedToolPart; + /** Original tool snapshots represented by this one logical owner. */ + rawParts: PassthroughMessagePart[]; + }) + | (IndexedRenderPart & { + kind: "domain-tool"; + part: NormalizedToolPart; + /** Latest raw domain payload, retained by identity for custom renderers. */ + domainPart: PassthroughMessagePart; + /** Every domain facet this one logical tool owns, in routing order. */ + categories: DomainCategory[]; + /** Original call/result snapshots represented by this one logical owner. */ + rawParts: PassthroughMessagePart[]; + }) + | (IndexedRenderPart & { + kind: "domain"; + part: PassthroughMessagePart; + categories: DomainCategory[]; + }); + +export interface ConvexUIParts { + from: UIMessage["role"]; + text: string; + reasoning: string; + /** + * Canonical, ordered, exactly-once render ownership plan. Aggregate arrays + * below are convenient projections only and must not be rendered in parallel. + */ + renderParts: ConvexUIRenderPart[]; + toolParts: NormalizedToolPart[]; + sources: SourcePart[]; + fileParts: FileUIPart[]; + domainParts: DomainParts; + status: UIMessage["status"]; + isStreaming: boolean; +} + +type PartRecord = Record & { type: string }; +type ToolState = NormalizedToolPart["state"]; + +const TOOL_STATES = new Set([ + "input-streaming", + "input-available", + "output-available", + "output-error", +]); + +const PERSISTENT_STREAM_PART_TYPES = new Set([ + "persistent-stream", + "persistent-stream-body", + "persistent-text-streaming", + "stream-body", + "data-persistent-stream", + "data-persistent-stream-body", + "data-persistent-text-streaming", + "data-stream-body", +]); + +function isPersistentStreamPartType(type: string): boolean { + return ( + PERSISTENT_STREAM_PART_TYPES.has(type) || + PERSISTENT_STREAM_PART_TYPES.has(normalizeDomainType(type)) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asPartRecord(part: unknown): PartRecord | null { + if (!isRecord(part) || typeof part.type !== "string") return null; + return part as PartRecord; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function firstDefined(...values: unknown[]): unknown { + return values.find((value) => value !== undefined); +} + +function hasErrorEvidence(value: unknown): boolean { + if (value === undefined || value === null) return false; + return typeof value !== "string" || value.trim().length > 0; +} + +function firstErrorEvidence(...values: unknown[]): unknown { + return values.find(hasErrorEvidence); +} + +function hasOwn(part: PartRecord, key: string): boolean { + return Object.prototype.hasOwnProperty.call(part, key); +} + +function getToolName(part: PartRecord): string | undefined { + const explicit = nonEmptyString(part.toolName) ?? nonEmptyString(part.name); + if (explicit) return explicit; + + if (part.type === "dynamic-tool") return undefined; + + const legacyTyped = part.type.match(/^tool-(?:call|result|error)-(.+)$/); + if (legacyTyped?.[1]) return legacyTyped[1]; + + const unified = part.type.match(/^tool-(.+)$/); + return unified?.[1]; +} + +function isLegacyCallType(type: string): boolean { + return type === "tool-call" || type.startsWith("tool-call-"); +} + +function isLegacyResultType(type: string): boolean { + return type === "tool-result" || type.startsWith("tool-result-"); +} + +function isLegacyErrorType(type: string): boolean { + return type === "tool-error" || type.startsWith("tool-error-"); +} + +function isToolPart(part: PartRecord): boolean { + return part.type === "dynamic-tool" || part.type.startsWith("tool-"); +} + +function validToolState(value: unknown): value is ToolState { + return typeof value === "string" && TOOL_STATES.has(value as ToolState); +} + +function hasValidUnifiedStateShape( + part: PartRecord, + state: ToolState, +): boolean { + if (!hasOwn(part, "input")) return false; + if (hasOwn(part, "args") || hasOwn(part, "result") || hasOwn(part, "error")) { + return false; + } + + switch (state) { + case "input-streaming": + case "input-available": + return !hasOwn(part, "output") && !hasOwn(part, "errorText"); + case "output-available": + return hasOwn(part, "output") && !hasOwn(part, "errorText"); + case "output-error": + return ( + !hasOwn(part, "output") && nonEmptyString(part.errorText) !== undefined + ); + } +} + +function inferUnifiedState(part: PartRecord): ToolState { + const explicitState = validToolState(part.state) ? part.state : undefined; + if ( + explicitState === "output-available" || + explicitState === "output-error" + ) { + return explicitState; + } + + const status = nonEmptyString(part.status)?.toLowerCase(); + if ( + hasErrorEvidence(part.errorText) || + hasErrorEvidence(part.error) || + status === "error" || + status === "failed" + ) { + return "output-error"; + } + if ( + firstDefined(part.output, part.result) !== undefined || + status === "success" || + status === "complete" || + status === "completed" + ) { + return "output-available"; + } + if (status === "running" || status === "streaming" || status === "pending") { + return "input-streaming"; + } + if (explicitState) return explicitState; + return "input-available"; +} + +function toolMetadata( + part: Record | undefined, +): Record { + if (!part) return {}; + const { + type: _type, + state: _state, + input: _input, + args: _args, + output: _output, + result: _result, + error: _error, + errorText: _errorText, + status: _status, + toolName: _toolName, + name: _name, + toolCallId: _toolCallId, + ...metadata + } = part; + return metadata; +} + +function syntheticToolCallId( + messageId: string, + toolName: string, + ordinal: number, +): string { + return `legacy:${messageId}:${toolName}:${ordinal}`; +} + +function normalizeUnifiedToolPart( + part: PartRecord, + messageId: string, + ordinal: number, +): NormalizedToolPart | null { + const dynamic = part.type === "dynamic-tool"; + const toolName = dynamic ? nonEmptyString(part.toolName) : getToolName(part); + if (!toolName) return null; + + const state = inferUnifiedState(part); + const toolCallId = + nonEmptyString(part.toolCallId) ?? + syntheticToolCallId(messageId, toolName, ordinal); + const input = firstDefined(part.input, part.args); + + // The Convex Agent v0.2 stream already emits a valid AI SDK v5 tool part. + // Returning it unchanged preserves provider metadata and object identity. + if ( + validToolState(part.state) && + part.state === state && + nonEmptyString(part.toolCallId) && + hasValidUnifiedStateShape(part, state) + ) { + return part as NormalizedToolPart; + } + + const common = { + ...toolMetadata(part), + type: dynamic ? ("dynamic-tool" as const) : (`tool-${toolName}` as const), + ...(dynamic ? { toolName } : {}), + toolCallId, + state, + input, + }; + + if (state === "output-available") { + return { + ...common, + output: firstDefined(part.output, part.result), + } as NormalizedToolPart; + } + + if (state === "output-error") { + return { + ...common, + errorText: String( + firstErrorEvidence(part.errorText, part.error) ?? + "Tool execution failed", + ), + } as NormalizedToolPart; + } + + return common as NormalizedToolPart; +} + +function normalizeLegacyCall( + part: PartRecord, + messageId: string, + toolName: string, + ordinal: number, +): NormalizedToolPart { + const status = nonEmptyString(part.status)?.toLowerCase(); + const state: ToolState = + status === "running" || status === "streaming" || status === "pending" + ? "input-streaming" + : "input-available"; + + return { + ...toolMetadata(part), + type: `tool-${toolName}`, + toolCallId: + nonEmptyString(part.toolCallId) ?? + syntheticToolCallId(messageId, toolName, ordinal), + state, + input: firstDefined(part.input, part.args), + } as NormalizedToolPart; +} + +function mergeLegacyTerminalPart( + base: NormalizedToolPart | undefined, + terminal: PartRecord, + messageId: string, + toolName: string, + ordinal: number, + isError: boolean, +): NormalizedToolPart { + const input = firstDefined(terminal.input, terminal.args, base?.input); + const toolCallId = + nonEmptyString(terminal.toolCallId) ?? + base?.toolCallId ?? + syntheticToolCallId(messageId, toolName, ordinal); + const dynamic = + base?.type === "dynamic-tool" || terminal.type === "dynamic-tool"; + const common = { + ...toolMetadata(base as unknown as Record | undefined), + ...toolMetadata(terminal), + type: dynamic ? ("dynamic-tool" as const) : (`tool-${toolName}` as const), + ...(dynamic ? { toolName } : {}), + toolCallId, + input, + }; + + if (isError) { + return { + ...common, + state: "output-error", + errorText: String( + firstDefined( + terminal.errorText, + terminal.error, + terminal.result, + terminal.output, + ) ?? "Tool execution failed", + ), + } as NormalizedToolPart; + } + + return { + ...common, + state: "output-available", + output: firstDefined(terminal.output, terminal.result, base?.output), + } as NormalizedToolPart; +} + +interface IndexedPart { + part: PartRecord; + originalIndex: number; +} + +interface NormalizedToolEntry { + part: NormalizedToolPart; + originalIndex: number; + rawParts: PassthroughMessagePart[]; + categories: DomainCategory[]; + domainPart?: PassthroughMessagePart; +} + +function createToolEntry( + toolPart: NormalizedToolPart, + indexedPart: IndexedPart, +): NormalizedToolEntry { + const categories = domainCategories(indexedPart.part); + const rawPart = indexedPart.part as PassthroughMessagePart; + return { + part: toolPart, + originalIndex: indexedPart.originalIndex, + rawParts: [rawPart], + categories, + ...(categories.length > 0 ? { domainPart: rawPart } : {}), + }; +} + +function updateToolEntry( + entry: NormalizedToolEntry, + toolPart: NormalizedToolPart, + indexedPart: IndexedPart, +): void { + const rawPart = indexedPart.part as PassthroughMessagePart; + const nextCategories = domainCategories(indexedPart.part); + entry.part = toolPart; + entry.rawParts.push(rawPart); + if (nextCategories.length > 0) { + entry.categories = [...new Set([...entry.categories, ...nextCategories])]; + entry.domainPart = rawPart; + } +} + +function normalizeToolParts( + parts: IndexedPart[], + messageId: string, +): NormalizedToolEntry[] { + const normalized: NormalizedToolEntry[] = []; + const indexByCallId = new Map(); + const openCallIdsByName = new Map(); + const ordinalByName = new Map(); + + const nextOrdinal = (toolName: string) => { + const ordinal = (ordinalByName.get(toolName) ?? 0) + 1; + ordinalByName.set(toolName, ordinal); + return ordinal; + }; + + const rememberOpenCall = (toolName: string, toolCallId: string) => { + const ids = openCallIdsByName.get(toolName) ?? []; + if (!ids.includes(toolCallId)) ids.push(toolCallId); + openCallIdsByName.set(toolName, ids); + }; + + const takeOpenCall = (toolName: string): string | undefined => { + const ids = openCallIdsByName.get(toolName); + const id = ids?.shift(); + if (ids?.length === 0) openCallIdsByName.delete(toolName); + return id; + }; + + const forgetOpenCall = (toolName: string, toolCallId: string) => { + const ids = openCallIdsByName.get(toolName); + if (!ids) return; + const remaining = ids.filter((id) => id !== toolCallId); + if (remaining.length > 0) openCallIdsByName.set(toolName, remaining); + else openCallIdsByName.delete(toolName); + }; + + for (const indexedPart of parts) { + const { part } = indexedPart; + if (!isToolPart(part)) continue; + const toolName = getToolName(part); + if (!toolName) continue; + + if (isLegacyCallType(part.type)) { + const toolPart = normalizeLegacyCall( + part, + messageId, + toolName, + nextOrdinal(toolName), + ); + const existingIndex = indexByCallId.get(toolPart.toolCallId); + if (existingIndex === undefined) { + indexByCallId.set(toolPart.toolCallId, normalized.length); + normalized.push(createToolEntry(toolPart, indexedPart)); + } else { + updateToolEntry(normalized[existingIndex], toolPart, indexedPart); + } + rememberOpenCall(toolName, toolPart.toolCallId); + continue; + } + + if (isLegacyResultType(part.type) || isLegacyErrorType(part.type)) { + const explicitCallId = nonEmptyString(part.toolCallId); + const matchedCallId = explicitCallId ?? takeOpenCall(toolName); + if (explicitCallId) forgetOpenCall(toolName, explicitCallId); + const existingIndex = matchedCallId + ? indexByCallId.get(matchedCallId) + : undefined; + const existing = + existingIndex === undefined + ? undefined + : normalized[existingIndex].part; + const merged = mergeLegacyTerminalPart( + existing, + part, + messageId, + toolName, + nextOrdinal(toolName), + isLegacyErrorType(part.type), + ); + + if (existingIndex === undefined) { + indexByCallId.set(merged.toolCallId, normalized.length); + normalized.push(createToolEntry(merged, indexedPart)); + } else { + updateToolEntry(normalized[existingIndex], merged, indexedPart); + } + continue; + } + + const toolPart = normalizeUnifiedToolPart( + part, + messageId, + nextOrdinal(toolName), + ); + if (!toolPart) continue; + const existingIndex = indexByCallId.get(toolPart.toolCallId); + if (existingIndex === undefined) { + indexByCallId.set(toolPart.toolCallId, normalized.length); + normalized.push(createToolEntry(toolPart, indexedPart)); + } else { + updateToolEntry(normalized[existingIndex], toolPart, indexedPart); + } + + if ( + toolPart.state === "input-streaming" || + toolPart.state === "input-available" + ) { + rememberOpenCall(toolName, toolPart.toolCallId); + } else { + forgetOpenCall(toolName, toolPart.toolCallId); + } + } + + return normalized; +} + +function normalizeDomainType(type: string): string { + return type + .replace(/^data-/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[_\s]+/g, "-") + .toLowerCase(); +} + +function structuredOutputKind(output: unknown): string | undefined { + if (isRecord(output)) return nonEmptyString(output.kind)?.toLowerCase(); + if (typeof output !== "string") return undefined; + const trimmed = output.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined; + try { + const parsed: unknown = JSON.parse(trimmed); + return isRecord(parsed) + ? nonEmptyString(parsed.kind)?.toLowerCase() + : undefined; + } catch { + return undefined; + } +} + +function domainCategories(part: PartRecord): DomainCategory[] { + const categories = new Set(); + const type = normalizeDomainType(part.type); + const toolName = getToolName(part)?.toLowerCase() ?? ""; + const output = firstDefined(part.output, part.result); + const kind = structuredOutputKind(output) ?? ""; + const outputText = typeof output === "string" ? output : ""; + + if ( + type.includes("selection") || + kind.endsWith("_selection") || + /\b(?:COMPANY|PEOPLE|EVENT|NEWS)_SELECTION_DATA\b/.test(outputText) + ) { + categories.add("selection"); + } + if ( + type.includes("arbitrage") || + toolName.includes("arbitrage") || + toolName.includes("contradiction") || + toolName.includes("sourcequality") || + toolName.includes("delta") || + outputText.includes("{{arbitrage:") + ) { + categories.add("arbitrage"); + } + if ( + type.includes("media") || + type.includes("gallery") || + kind === "youtube_search_results" || + kind === "sec_filing_results" || + /\b(?:YOUTUBE|SEC|SOURCE)_GALLERY_DATA\b/.test(outputText) + ) { + categories.add("media"); + } + if ( + type.includes("goal-card") || + type === "goal" || + toolName.startsWith("delegateto") + ) { + categories.add("goalCard"); + } + if ( + type.includes("fused-search") || + type.includes("fusion-search") || + toolName === "fusionsearch" || + toolName === "quicksearch" || + (toolName.includes("fusion") && toolName.includes("search")) || + outputText.includes("FUSION_SEARCH_DATA") + ) { + categories.add("fusedSearch"); + } + if ( + type.includes("document-action") || + kind === "document_action" || + outputText.includes("DOCUMENT_ACTION_DATA") + ) { + categories.add("documentAction"); + } + if (type.includes("edit-progress") || kind === "edit_progress") { + categories.add("editProgress"); + } + + return [...categories]; +} + +function emptyDomainParts(): DomainParts { + return { + all: [], + selection: [], + arbitrage: [], + media: [], + goalCard: [], + fusedSearch: [], + documentAction: [], + editProgress: [], + other: [], + }; +} + +function routeDomainPart( + domainParts: DomainParts, + part: PassthroughMessagePart, + categories: DomainCategory[], +): void { + domainParts.all.push(part); + if (categories.length === 0) { + domainParts.other.push(part); + return; + } + for (const category of categories) { + domainParts[category].push(part); + } +} + +/** + * Convert a live Convex Agent UIMessage into the standard presentation parts + * consumed by AI Elements while keeping NodeBench domain renderers outside the + * primitive layer. + * + * This function is intentionally pure. In particular it never reads or returns + * a persistent-text-streaming body: that body remains owned by useStream in + * StreamingMessage. Domain/data parts are routed by reference, not cloned. + */ +export function convexToUIParts(message: UIMessage): ConvexUIParts { + const rawParts = Array.isArray(message.parts) ? message.parts : []; + const parts: IndexedPart[] = rawParts.flatMap((rawPart, originalIndex) => { + const part = asPartRecord(rawPart); + return part ? [{ part, originalIndex }] : []; + }); + const sources: SourcePart[] = []; + const fileParts: FileUIPart[] = []; + const domainParts = emptyDomainParts(); + const renderParts: ConvexUIRenderPart[] = []; + const reasoningParts: string[] = []; + const textParts: string[] = []; + const toolEntries = normalizeToolParts(parts, message.id); + + for (const { part, originalIndex } of parts) { + if (isPersistentStreamPartType(part.type)) continue; + + if (part.type === "text" && typeof part.text === "string") { + textParts.push(part.text); + renderParts.push({ + kind: "text", + originalIndex, + part: part as TextUIPart, + }); + continue; + } + if (part.type === "reasoning" && typeof part.text === "string") { + reasoningParts.push(part.text); + renderParts.push({ + kind: "reasoning", + originalIndex, + part: part as ReasoningUIPart, + }); + continue; + } + if (part.type === "source-url" || part.type === "source-document") { + sources.push(part as SourcePart); + renderParts.push({ + kind: "source", + originalIndex, + part: part as SourcePart, + }); + continue; + } + if (part.type === "file") { + fileParts.push(part as FileUIPart); + renderParts.push({ + kind: "file", + originalIndex, + part: part as FileUIPart, + }); + continue; + } + const categories = domainCategories(part); + if (part.type.startsWith("data-") || categories.length > 0) { + routeDomainPart(domainParts, part as PassthroughMessagePart, categories); + if (!isToolPart(part)) { + renderParts.push({ + kind: "domain", + originalIndex, + part: part as PassthroughMessagePart, + categories, + }); + } + continue; + } + // The AI SDK union grows over time, and NodeBench also carries product + // parts outside data-* (for example verification receipts). Preserve any + // non-standard part through the custom-render escape hatch by reference. + if (part.type !== "step-start" && !isToolPart(part)) { + routeDomainPart(domainParts, part as PassthroughMessagePart, []); + renderParts.push({ + kind: "domain", + originalIndex, + part: part as PassthroughMessagePart, + categories: [], + }); + } + } + + for (const entry of toolEntries) { + if (entry.domainPart) { + renderParts.push({ + kind: "domain-tool", + originalIndex: entry.originalIndex, + part: entry.part, + domainPart: entry.domainPart, + categories: entry.categories, + rawParts: entry.rawParts, + }); + } else { + renderParts.push({ + kind: "tool", + originalIndex: entry.originalIndex, + part: entry.part, + rawParts: entry.rawParts, + }); + } + } + + renderParts.sort((left, right) => left.originalIndex - right.originalIndex); + + const materializedText = (message as UIMessage & { text?: unknown }).text; + const status = message.status; + + return { + from: message.role, + text: + typeof materializedText === "string" + ? materializedText + : textParts.join(""), + reasoning: reasoningParts.join("\n"), + renderParts, + toolParts: toolEntries.map((entry) => entry.part), + sources, + fileParts, + domainParts, + status, + isStreaming: status === "streaming", + }; +}