import { describe, it, expect } from "vitest"; import { applyChunkToParts, type MessageParts, type StreamChunkData } from "../message-builder"; function makeParts(): MessageParts { return []; } describe("applyChunkToParts", () => { describe("text chunks", () => { it("text-start creates a streaming text part", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "text-start", id: "t1" }); expect(handled).toBe(true); expect(parts.length).toBe(1); expect(parts[0]).toEqual({ type: "text", text: "", state: "streaming" }); }); it("text-delta appends to existing text part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "text-start", id: "t1" }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "Hello " }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "world!" }); expect(parts.length).toBe(1); expect((parts[0] as { text: string }).text).toBe("Hello world!"); }); it("text-delta creates new text part with streaming state if no text-start received", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "fallback" }); expect(parts.length).toBe(1); expect((parts[0] as { text: string }).text).toBe("fallback"); expect((parts[0] as { state: string }).state).toBe("streaming"); }); it("text-end marks fallback text part as done", () => { const parts = makeParts(); // No text-start — simulates stream resumption applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "resumed" }); applyChunkToParts(parts, { type: "text-end", id: "t1" }); expect((parts[0] as { state: string }).state).toBe("done"); expect((parts[0] as { text: string }).text).toBe("resumed"); }); it("text-end marks text part as done", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "text-start", id: "t1" }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "content" }); applyChunkToParts(parts, { type: "text-end", id: "t1" }); expect((parts[0] as { state: string }).state).toBe("done"); expect((parts[0] as { text: string }).text).toBe("content"); }); it("handles a full text lifecycle", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "text-start", id: "t1" }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "The " }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "answer is 42." }); applyChunkToParts(parts, { type: "text-end", id: "t1" }); expect(parts.length).toBe(1); expect(parts[0]).toEqual({ type: "text", text: "The answer is 42.", state: "done" }); }); }); describe("reasoning chunks", () => { it("reasoning-start creates a streaming reasoning part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "reasoning-start", id: "r1" }); expect(parts.length).toBe(1); expect(parts[0]).toEqual({ type: "reasoning", text: "", state: "streaming" }); }); it("reasoning-delta appends to reasoning part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "reasoning-start", id: "r1" }); applyChunkToParts(parts, { type: "reasoning-delta", id: "r1", delta: "thinking..." }); expect((parts[0] as { text: string }).text).toBe("thinking..."); }); it("reasoning-end marks reasoning as done", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "reasoning-start", id: "r1" }); applyChunkToParts(parts, { type: "reasoning-delta", id: "r1", delta: "done thinking" }); applyChunkToParts(parts, { type: "reasoning-end", id: "r1" }); expect((parts[0] as { state: string }).state).toBe("done"); }); it("reasoning-delta creates fallback part when no reasoning-start received", () => { const parts = makeParts(); // No reasoning-start — simulates stream resumption where start was missed applyChunkToParts(parts, { type: "reasoning-delta", id: "r1", delta: "resumed thinking" }); expect(parts.length).toBe(1); expect(parts[0]).toEqual({ type: "reasoning", text: "resumed thinking", state: "streaming" }); // Subsequent deltas append normally applyChunkToParts(parts, { type: "reasoning-delta", id: "r1", delta: " more" }); expect((parts[0] as { text: string }).text).toBe("resumed thinking more"); }); }); describe("file chunks", () => { it("creates a file part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "file", mediaType: "image/png", url: "https://example.com/image.png" }); expect(parts.length).toBe(1); expect(parts[0]).toEqual({ type: "file", mediaType: "image/png", url: "https://example.com/image.png" }); }); }); describe("source chunks", () => { it("creates a source-url part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "source-url", sourceId: "s1", url: "https://example.com", title: "Example" }); expect(parts[0]).toEqual({ type: "source-url", sourceId: "s1", url: "https://example.com", title: "Example", providerMetadata: undefined }); }); it("creates a source-document part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "source-document", sourceId: "d1", mediaType: "application/pdf", title: "Doc", filename: "doc.pdf" }); expect(parts[0]).toEqual({ type: "source-document", sourceId: "d1", mediaType: "application/pdf", title: "Doc", filename: "doc.pdf", providerMetadata: undefined }); }); }); describe("tool chunks", () => { it("tool-input-available creates a tool part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); expect(parts.length).toBe(1); const part = parts[0] as Record; expect(part.type).toBe("tool-getWeather"); expect(part.toolCallId).toBe("call_1"); expect(part.state).toBe("input-available"); expect(part.input).toEqual({ city: "London" }); }); it("tool-output-available updates existing tool part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: "Sunny, 22°C" }); expect(parts.length).toBe(1); const part = parts[0] as Record; expect(part.state).toBe("output-available"); expect(part.output).toBe("Sunny, 22°C"); }); it("tool-output-available is a no-op if tool part not found", () => { const parts = makeParts(); // No tool-input-available first applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "nonexistent", output: "result" }); expect(parts.length).toBe(0); // Nothing added }); }); describe("step chunks", () => { it("start-step adds a step-start part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "start-step" }); expect(parts.length).toBe(1); expect(parts[0].type).toBe("step-start"); }); it("step-start (client alias) also adds a step-start part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "step-start" }); expect(parts.length).toBe(1); expect(parts[0].type).toBe("step-start"); }); }); describe("data-* chunks", () => { it("appends a new data part", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "data-sources", data: { query: "hello", results: [] } }); expect(handled).toBe(true); expect(parts.length).toBe(1); expect(parts[0]).toEqual({ type: "data-sources", data: { query: "hello", results: [] } }); }); it("preserves the id field when present", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "data-sources", id: "src-1", data: { query: "hello", status: "searching" } }); expect(parts[0]).toEqual({ type: "data-sources", id: "src-1", data: { query: "hello", status: "searching" } }); }); it("reconciles by type+id (updates data in-place)", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "data-sources", id: "src-1", data: { status: "searching", results: [] } }); applyChunkToParts(parts, { type: "data-sources", id: "src-1", data: { status: "found", results: ["doc1", "doc2"] } }); // Should update in-place, not append expect(parts.length).toBe(1); expect((parts[0] as Record).data).toEqual({ status: "found", results: ["doc1", "doc2"] }); }); it("does not reconcile when ids differ", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "data-sources", id: "src-1", data: { query: "first" } }); applyChunkToParts(parts, { type: "data-sources", id: "src-2", data: { query: "second" } }); expect(parts.length).toBe(2); }); it("does not reconcile when types differ", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "data-sources", id: "x", data: { from: "sources" } }); applyChunkToParts(parts, { type: "data-usage", id: "x", data: { from: "usage" } }); expect(parts.length).toBe(2); }); it("does not reconcile parts without ids (appends each time)", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "data-status", data: { step: 1 } }); applyChunkToParts(parts, { type: "data-status", data: { step: 2 } }); expect(parts.length).toBe(2); }); it("transient parts return true but are not added to parts", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "data-thinking", transient: true, data: { model: "gpt-4o" } }); expect(handled).toBe(true); expect(parts.length).toBe(0); }); it("transient parts with id are still skipped (not reconciled)", () => { const parts = makeParts(); // First: non-transient part with id applyChunkToParts(parts, { type: "data-progress", id: "p-1", data: { step: 1 } }); expect(parts.length).toBe(1); // Second: transient part with SAME type+id — should not reconcile const handled = applyChunkToParts(parts, { type: "data-progress", id: "p-1", transient: true, data: { step: 2 } }); expect(handled).toBe(true); // Original part unchanged expect(parts.length).toBe(1); expect((parts[0] as Record).data).toEqual({ step: 1 }); }); it("handles undefined data (part persisted without data field)", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "data-empty" } as StreamChunkData); expect(handled).toBe(true); expect(parts.length).toBe(1); expect(parts[0].type).toBe("data-empty"); // data is undefined — JSON.stringify would drop it expect((parts[0] as Record).data).toBeUndefined(); }); it("non-data-* prefixed types still return false", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "unknown-type" } as StreamChunkData); expect(handled).toBe(false); expect(parts.length).toBe(0); }); it("coexists with other part types", () => { const parts = makeParts(); // Text applyChunkToParts(parts, { type: "text-start", id: "t1" }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "Hello" }); applyChunkToParts(parts, { type: "text-end", id: "t1" }); // Data part applyChunkToParts(parts, { type: "data-usage", data: { tokens: 42 } }); expect(parts.length).toBe(2); expect(parts[0].type).toBe("text"); expect(parts[1].type).toBe("data-usage"); }); }); describe("unrecognized chunks", () => { it("returns false for unknown chunk types", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "unknown-type" } as StreamChunkData); expect(handled).toBe(false); expect(parts.length).toBe(0); }); }); describe("mixed content", () => { it("builds a complex message with text + reasoning + tool + file", () => { const parts = makeParts(); // Reasoning first applyChunkToParts(parts, { type: "reasoning-start", id: "r1" }); applyChunkToParts(parts, { type: "reasoning-delta", id: "r1", delta: "Let me think..." }); applyChunkToParts(parts, { type: "reasoning-end", id: "r1" }); // Step boundary applyChunkToParts(parts, { type: "start-step" }); // Text applyChunkToParts(parts, { type: "text-start", id: "t1" }); applyChunkToParts(parts, { type: "text-delta", id: "t1", delta: "Here's the weather" }); applyChunkToParts(parts, { type: "text-end", id: "t1" }); // Tool call applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: { temp: 22, condition: "Sunny" } }); // File applyChunkToParts(parts, { type: "file", mediaType: "image/png", url: "https://example.com/chart.png" }); // reasoning + step-start + text + tool (updated in place) + file = 5 expect(parts.length).toBe(5); expect(parts[0].type).toBe("reasoning"); expect(parts[1].type).toBe("step-start"); expect(parts[2].type).toBe("text"); expect(parts[3].type).toBe("tool-getWeather"); expect((parts[3] as Record).state).toBe( "output-available" ); expect(parts[4].type).toBe("file"); }); }); describe("tool streaming lifecycle", () => { it("tool-input-start creates a tool part in input-streaming state", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "getWeather" }); expect(handled).toBe(true); expect(parts.length).toBe(1); const part = parts[0] as Record; expect(part.type).toBe("tool-getWeather"); expect(part.toolCallId).toBe("call_1"); expect(part.state).toBe("input-streaming"); expect(part.input).toBeUndefined(); }); it("tool-input-delta updates the tool part with partial input", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "getWeather" }); applyChunkToParts(parts, { type: "tool-input-delta", toolCallId: "call_1", input: { city: "Lon" } }); expect(parts.length).toBe(1); expect((parts[0] as Record).input).toEqual({ city: "Lon" }); expect((parts[0] as Record).state).toBe( "input-streaming" ); }); it("tool-input-available finalizes an existing streaming tool part", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "getWeather" }); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); expect(parts.length).toBe(1); const part = parts[0] as Record; expect(part.state).toBe("input-available"); expect(part.input).toEqual({ city: "London" }); }); it("tool-input-available creates a new part if no tool-input-start", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); expect(parts.length).toBe(1); expect((parts[0] as Record).state).toBe( "input-available" ); }); it("full tool streaming lifecycle: start -> delta -> available -> output", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "getWeather" }); expect((parts[0] as Record).state).toBe( "input-streaming" ); applyChunkToParts(parts, { type: "tool-input-delta", toolCallId: "call_1", input: { city: "Lon" } }); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); expect((parts[0] as Record).state).toBe( "input-available" ); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: "Sunny, 22C" }); expect((parts[0] as Record).state).toBe( "output-available" ); expect((parts[0] as Record).output).toBe("Sunny, 22C"); expect(parts.length).toBe(1); }); }); describe("tool error handling", () => { it("tool-input-error marks tool part as output-error", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "getWeather" }); applyChunkToParts(parts, { type: "tool-input-error", toolCallId: "call_1", toolName: "getWeather", input: '{"city": "Lond', errorText: "Unexpected end of JSON input" }); const part = parts[0] as Record; expect(part.state).toBe("output-error"); expect(part.errorText).toBe("Unexpected end of JSON input"); }); it("tool-input-error creates a new part if no tool-input-start", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-error", toolCallId: "call_1", toolName: "getWeather", errorText: "Schema validation failed" }); expect(parts.length).toBe(1); expect((parts[0] as Record).state).toBe("output-error"); }); it("tool-output-error marks tool part as output-error", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); applyChunkToParts(parts, { type: "tool-output-error", toolCallId: "call_1", errorText: "API rate limit exceeded" }); const part = parts[0] as Record; expect(part.state).toBe("output-error"); expect(part.errorText).toBe("API rate limit exceeded"); }); it("tool-output-error is a no-op if tool part not found", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-output-error", toolCallId: "nonexistent", errorText: "error" }); expect(parts.length).toBe(0); }); }); describe("preliminary tool output", () => { it("tool-output-available with preliminary=true marks output as preliminary", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "streamingTool", input: {} }); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: { partial: true, data: "chunk1" }, preliminary: true }); const part = parts[0] as Record; expect(part.state).toBe("output-available"); expect(part.preliminary).toBe(true); expect(part.output).toEqual({ partial: true, data: "chunk1" }); }); it("subsequent tool-output-available with preliminary=false finalizes", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "streamingTool", input: {} }); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: "partial", preliminary: true }); expect((parts[0] as Record).preliminary).toBe(true); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: "complete result", preliminary: false }); const part = parts[0] as Record; expect(part.preliminary).toBe(false); expect(part.output).toBe("complete result"); }); it("tool-output-available without preliminary does not set the flag", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); applyChunkToParts(parts, { type: "tool-output-available", toolCallId: "call_1", output: "Sunny" }); const part = parts[0] as Record; expect(part.preliminary).toBeUndefined(); }); }); describe("tool provider metadata (callProviderMetadata, providerExecuted, title)", () => { it("tool-input-available preserves callProviderMetadata from providerMetadata", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "askQuestion", input: { question: "What is your name?" }, providerMetadata: { google: { thoughtSignature: "sig_abc123" } } }); const part = parts[0] as Record; expect(part.callProviderMetadata).toEqual({ google: { thoughtSignature: "sig_abc123" } }); }); it("tool-input-available update path preserves callProviderMetadata", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "askQuestion" }); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "askQuestion", input: { question: "Name?" }, providerMetadata: { google: { thoughtSignature: "sig_xyz" } } }); const part = parts[0] as Record; expect(part.callProviderMetadata).toEqual({ google: { thoughtSignature: "sig_xyz" } }); }); it("tool-input-start preserves callProviderMetadata", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "askQuestion", providerMetadata: { google: { thoughtSignature: "sig_start" } } }); const part = parts[0] as Record; expect(part.callProviderMetadata).toEqual({ google: { thoughtSignature: "sig_start" } }); }); it("tool-input-error preserves callProviderMetadata (create path)", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-error", toolCallId: "call_1", toolName: "askQuestion", errorText: "Parse error", providerMetadata: { google: { thoughtSignature: "sig_err" } } }); const part = parts[0] as Record; expect(part.callProviderMetadata).toEqual({ google: { thoughtSignature: "sig_err" } }); }); it("tool-input-error preserves callProviderMetadata (update path)", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "askQuestion" }); applyChunkToParts(parts, { type: "tool-input-error", toolCallId: "call_1", toolName: "askQuestion", errorText: "Parse error", providerMetadata: { google: { thoughtSignature: "sig_err2" } } }); const part = parts[0] as Record; expect(part.callProviderMetadata).toEqual({ google: { thoughtSignature: "sig_err2" } }); }); it("does not set callProviderMetadata when providerMetadata is absent", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" } }); const part = parts[0] as Record; expect(part.callProviderMetadata).toBeUndefined(); }); it("tool-input-available preserves providerExecuted", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "codeExec", input: { code: "1+1" }, providerExecuted: true } as StreamChunkData); const part = parts[0] as Record; expect(part.providerExecuted).toBe(true); }); it("tool-input-available update path preserves providerExecuted", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "codeExec" }); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "codeExec", input: { code: "1+1" }, providerExecuted: true } as StreamChunkData); const part = parts[0] as Record; expect(part.providerExecuted).toBe(true); }); it("tool-input-available preserves title", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" }, title: "Get Weather" }); const part = parts[0] as Record; expect(part.title).toBe("Get Weather"); }); it("tool-input-available update path preserves title", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "getWeather" }); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "getWeather", input: { city: "London" }, title: "Get Weather" }); const part = parts[0] as Record; expect(part.title).toBe("Get Weather"); }); it("tool-input-start preserves providerExecuted and title", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-start", toolCallId: "call_1", toolName: "codeExec", providerExecuted: true, title: "Code Execution" } as StreamChunkData); const part = parts[0] as Record; expect(part.providerExecuted).toBe(true); expect(part.title).toBe("Code Execution"); }); it("preserves all three fields together on tool-input-available", () => { const parts = makeParts(); applyChunkToParts(parts, { type: "tool-input-available", toolCallId: "call_1", toolName: "askQuestion", input: { question: "Name?" }, providerMetadata: { google: { thoughtSignature: "sig_full" } }, providerExecuted: false, title: "Ask Question" } as StreamChunkData); const part = parts[0] as Record; expect(part.callProviderMetadata).toEqual({ google: { thoughtSignature: "sig_full" } }); expect(part.providerExecuted).toBe(false); expect(part.title).toBe("Ask Question"); }); }); describe("metadata and message-level chunks", () => { it("returns false for 'start' chunk (caller handles metadata)", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "start", messageId: "msg-1", messageMetadata: { model: "gpt-4o" } } as StreamChunkData); expect(handled).toBe(false); // Should not add any parts expect(parts.length).toBe(0); }); it("returns false for 'finish' chunk (caller handles metadata)", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "finish", messageMetadata: { totalTokens: 100 } } as StreamChunkData); expect(handled).toBe(false); expect(parts.length).toBe(0); }); it("returns false for 'message-metadata' chunk (caller handles metadata)", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "message-metadata", messageMetadata: { createdAt: 1234567890 } } as StreamChunkData); expect(handled).toBe(false); expect(parts.length).toBe(0); }); it("returns false for 'finish-step' chunk", () => { const parts = makeParts(); const handled = applyChunkToParts(parts, { type: "finish-step" } as StreamChunkData); expect(handled).toBe(false); expect(parts.length).toBe(0); }); }); });