UNPKG

@convex-dev/agent

Version:

A agent component for Convex.

608 lines (579 loc) 19 kB
import { describe, it, expect } from "vitest"; import { applyUIMessageChunksIncremental, blankUIMessage, emptyIncrementalStreamState, getParts, updateFromUIMessageChunks, } from "./deltas.js"; import type { StreamDelta } from "./validators.js"; import type { ToolUIPart, UIMessageChunk } from "ai"; describe("UIMessageChunks", () => { it("updates a UIMessage with a tool call and follow up", async () => { const uiMessage = blankUIMessage( { streamId: "s1", status: "streaming", order: 0, stepOrder: 1, format: "UIMessageChunk", agentName: "agent1", }, "thread1", ); expect(uiMessage.text).toBe(""); expect(uiMessage.parts).toEqual([]); const updatedMessage = await updateFromUIMessageChunks(uiMessage, [ { type: "start" }, { type: "start-step" }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", delta: "Okay" }, { type: "reasoning-delta", id: "reasoning-0", delta: ", the user is asking...", }, { type: "text-start", id: "txt-1" }, { type: "text-delta", id: "txt-1", delta: "Hey ho.", }, { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "txt-1" }, { type: "tool-input-start", toolCallId: "0ychh9k6f", toolName: "say" }, { type: "tool-input-delta", toolCallId: "0ychh9k6f", inputTextDelta: '{"question":"What is your favorite flavor of ice cream?"}', }, { type: "tool-input-available", toolCallId: "0ychh9k6f", toolName: "say", input: { question: "What is your favorite flavor of ice cream?" }, providerMetadata: { openai: { itemId: "123" } }, }, { type: "tool-output-available", toolCallId: "0ychh9k6f", output: "I'm sorry I can't help you. Stop asking me questions.", }, { type: "finish-step" }, { type: "start-step" }, { type: "tool-input-start", toolCallId: "1ychh9k6f", toolName: "say" }, { type: "tool-input-delta", toolCallId: "1ychh9k6f", inputTextDelta: '{"question":"What is your favorite flavor of ice cream?"}', }, { type: "tool-input-available", toolCallId: "1ychh9k6f", toolName: "say", input: { question: "What is your favorite flavor of ice cream?" }, }, { type: "tool-output-available", toolCallId: "1ychh9k6f", output: "I'm serious.", }, { type: "finish-step" }, { type: "start-step" }, { type: "text-start", id: "msg_0" }, { type: "text-delta", id: "msg_0", delta: "The best ice cream flavor is vanilla", }, { type: "text-delta", id: "msg_0", delta: ".", }, { type: "text-end", id: "msg_0" }, { type: "finish-step" }, { type: "finish" }, ]); expect(updatedMessage.text).toBe( "Hey ho. The best ice cream flavor is vanilla.", ); const expectedParts = [ { type: "step-start", }, { state: "done", text: "Okay, the user is asking...", type: "reasoning", }, { state: "done", text: "Hey ho.", type: "text", }, { callProviderMetadata: { openai: { itemId: "123", }, }, input: { question: "What is your favorite flavor of ice cream?", }, output: "I'm sorry I can't help you. Stop asking me questions.", state: "output-available", toolCallId: "0ychh9k6f", type: "tool-say", }, { type: "step-start", }, { input: { question: "What is your favorite flavor of ice cream?", }, output: "I'm serious.", state: "output-available", toolCallId: "1ychh9k6f", type: "tool-say", }, { type: "step-start", }, { state: "done", text: "The best ice cream flavor is vanilla.", type: "text", }, ]; expect(updatedMessage.parts).toEqual(expectedParts); expect(updatedMessage.parts).toHaveLength(8); }); }); describe("UIMessageChunks - continuation stream", () => { it("gracefully handles tool-result without tool-call in continuation stream after approval", async () => { // This simulates what happens after tool approval: // Stream A: tool-call, tool-approval-request -> finishes // User approves // Stream B: tool-result (referencing tool-call from Stream A) -> this test // // The AI SDK's readUIMessageStream expects tool-call before tool-result, // but they're in different streams. The onError handler should gracefully // ignore this error since stored messages provide the fallback. const uiMessage = blankUIMessage( { streamId: "continuation-stream", status: "streaming", order: 1, stepOrder: 0, format: "UIMessageChunk", agentName: "agent1", }, "thread1", ); // Send a tool-result without the corresponding tool-call in this stream // This would normally throw "No tool invocation found" error const updatedMessage = await updateFromUIMessageChunks(uiMessage, [ { type: "start" }, { type: "start-step" }, { type: "tool-output-available", toolCallId: "call_from_previous_stream", output: "Tool execution result", }, { type: "finish-step" }, { type: "finish" }, ]); // The message should NOT be marked as failed - the error should be suppressed expect(updatedMessage.status).not.toBe("failed"); // The stream still processes (even if tool-output isn't reflected without tool-input) expect(updatedMessage).toBeDefined(); }); }); describe("mergeDeltas", () => { it("incremental apply only consumes parts past the cursor (no re-processing)", () => { const N = 500; const streamId = "s-perf"; const toolCallId = "tool-0"; const streamMessage = { streamId, status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "agent1", }; // One StreamDelta with preamble, then N deltas each with one tool-input-delta const allDeltas: StreamDelta[] = [ { streamId, start: 0, end: 1, parts: [ { type: "start" }, { type: "start-step" }, { type: "tool-input-start", toolCallId, toolName: "myTool" }, ] as UIMessageChunk[], }, ...Array.from({ length: N }, (_, i) => ({ streamId, start: i + 1, end: i + 2, parts: [ { type: "tool-input-delta", toolCallId, inputTextDelta: "x", } as UIMessageChunk, ], })), ]; // Simulate the hook: process one delta at a time, tracking cursor + prior message let cursor = 0; let uiMessage = blankUIMessage(streamMessage, "thread-perf"); let streamState = emptyIncrementalStreamState(); let totalPartsProcessed = 0; for (let i = 0; i <= N; i++) { const available = allDeltas.slice(0, i + 1); const { parts: newParts, cursor: newCursor } = getParts<UIMessageChunk>( available, cursor, ); if (newParts.length > 0) { totalPartsProcessed += newParts.length; ({ message: uiMessage, streamState } = applyUIMessageChunksIncremental( structuredClone(uiMessage), newParts, streamState, )); cursor = newCursor; } } // Each delta part is handed to applyUIMessageChunksIncremental exactly // once across all batches (cursor slicing — no re-processing of prior // parts). N tool-input-deltas + 3 preamble parts. The end-to-end O(N) // claim is proven by the PR's 21,000 ms → 73 ms benchmark, not by this // unit test. expect(totalPartsProcessed).toBe(N + 3); // Correctness: the raw accumulator holds "x" repeated N times across batches expect(streamState.toolInputText[toolCallId]).toBe("x".repeat(N)); const toolPart = uiMessage.parts.find( (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === toolCallId, ); expect(toolPart).toBeDefined(); }); it("applyUIMessageChunksIncremental: text-delta accumulation across calls", () => { const streamMessage = { streamId: "s-text", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; let msg = blankUIMessage(streamMessage, "thread-text"); let state = emptyIncrementalStreamState(); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "start" }, { type: "start-step" }, { type: "text-start", id: "t0" }, { type: "text-delta", id: "t0", delta: "Hello " }, ] as UIMessageChunk[], state, )); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [{ type: "text-delta", id: "t0", delta: "world" }] as UIMessageChunk[], state, )); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "text-delta", id: "t0", delta: "!" }, { type: "text-end", id: "t0" }, ] as UIMessageChunk[], state, )); const textPart = msg.parts.find((p) => p.type === "text") as | { text: string; state: string } | undefined; expect(textPart?.text).toBe("Hello world!"); expect(textPart?.state).toBe("done"); expect(msg.text).toBe("Hello world!"); }); it("applyUIMessageChunksIncremental: tool-output-available preserves input and sets fields", async () => { const streamMessage = { streamId: "s-tool-out", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; let msg = blankUIMessage(streamMessage, "thread-tool-out"); let state = emptyIncrementalStreamState(); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "start" }, { type: "start-step" }, { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" }, { type: "tool-input-available", toolCallId: "c1", toolName: "myTool", input: { q: "hi" }, }, ] as UIMessageChunk[], state, )); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "tool-output-available", toolCallId: "c1", output: { result: "ok" }, preliminary: true, providerExecuted: true, }, ] as UIMessageChunk[], state, )); const toolPart = msg.parts.find( (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1", ); expect(toolPart?.state).toBe("output-available"); expect(toolPart?.input).toEqual({ q: "hi" }); expect((toolPart as { output?: unknown }).output).toEqual({ result: "ok" }); expect((toolPart as { preliminary?: boolean }).preliminary).toBe(true); expect((toolPart as { providerExecuted?: boolean }).providerExecuted).toBe( true, ); }); it("applyUIMessageChunksIncremental: tool-input-error sets rawInput and clears input for static tools", async () => { const streamMessage = { streamId: "s-tool-err", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; let msg = blankUIMessage(streamMessage, "thread-tool-err"); let state = emptyIncrementalStreamState(); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "start" }, { type: "start-step" }, { type: "tool-input-start", toolCallId: "c2", toolName: "myTool" }, ] as UIMessageChunk[], state, )); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "tool-input-error", toolCallId: "c2", toolName: "myTool", input: { bad: "args" }, errorText: "validation failed", }, ] as UIMessageChunk[], state, )); const toolPart = msg.parts.find( (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c2", ); expect(toolPart?.state).toBe("output-error"); expect((toolPart as { errorText?: string }).errorText).toBe( "validation failed", ); expect(toolPart?.input).toBeUndefined(); expect((toolPart as { rawInput?: unknown }).rawInput).toEqual({ bad: "args", }); }); it("accumulates tool input across a batch boundary", async () => { const streamMessage = { streamId: "s-tool-split", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; let msg = blankUIMessage(streamMessage, "thread-tool-split"); let state = emptyIncrementalStreamState(); // Batch A: preamble + the first half of the JSON input. ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "start" }, { type: "start-step" }, { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" }, { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '{"a":1', }, ] as UIMessageChunk[], state, )); const afterA = msg.parts.find( (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1", ); // Mid-stream: JSON is incomplete, input stays unset. expect(afterA?.input).toBeUndefined(); // Batch B: the remainder of the JSON input. ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: ',"b":2}', }, ] as UIMessageChunk[], state, )); const afterB = msg.parts.find( (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1", ); // Complete JSON is parsed once the accumulator is valid. expect(afterB?.input).toEqual({ a: 1, b: 2 }); expect(state.toolInputText["c1"]).toBe('{"a":1,"b":2}'); }); it("pushes file parts and merges message metadata in later batches", async () => { const streamMessage = { streamId: "s-file-meta", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; let msg = blankUIMessage(streamMessage, "thread-file-meta"); let state = emptyIncrementalStreamState(); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [{ type: "start" }, { type: "start-step" }] as UIMessageChunk[], state, )); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "file", mediaType: "image/png", url: "https://example.com/a.png", }, { type: "message-metadata", messageMetadata: { foo: "bar" } }, ] as UIMessageChunk[], state, )); const filePart = msg.parts.find((p) => p.type === "file") as | { mediaType: string; url: string } | undefined; expect(filePart?.mediaType).toBe("image/png"); expect(filePart?.url).toBe("https://example.com/a.png"); expect(msg.metadata).toEqual({ foo: "bar" }); }); it("tracks concurrent text parts by id across batches", async () => { const streamMessage = { streamId: "s-multi-text", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; let msg = blankUIMessage(streamMessage, "thread-multi-text"); let state = emptyIncrementalStreamState(); ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "start" }, { type: "start-step" }, { type: "text-start", id: "t0" }, { type: "text-start", id: "t1" }, { type: "text-delta", id: "t0", delta: "A" }, ] as UIMessageChunk[], state, )); // Deltas in a later batch must land on the part matching their id. ({ message: msg, streamState: state } = applyUIMessageChunksIncremental( msg, [ { type: "text-delta", id: "t1", delta: "B" }, { type: "text-delta", id: "t0", delta: "C" }, ] as UIMessageChunk[], state, )); const textParts = msg.parts.filter((p) => p.type === "text") as Array<{ text: string; }>; expect(textParts.map((p) => p.text)).toEqual(["AC", "B"]); }); it("incremental batches match the SDK processing the full stream", async () => { const streamMessage = { streamId: "s-equiv", status: "streaming" as const, order: 0, stepOrder: 0, format: "UIMessageChunk" as const, agentName: "a", }; const batches: UIMessageChunk[][] = [ [ { type: "start" }, { type: "start-step" }, { type: "text-start", id: "t0" }, { type: "text-delta", id: "t0", delta: "Hello " }, ] as UIMessageChunk[], [ { type: "text-delta", id: "t0", delta: "world" }, { type: "text-end", id: "t0" }, { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" }, { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '{"q":' }, ] as UIMessageChunk[], [ { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '"hi"}' }, { type: "tool-input-available", toolCallId: "c1", toolName: "myTool", input: { q: "hi" }, }, { type: "tool-output-available", toolCallId: "c1", output: { ok: true }, }, { type: "finish-step" }, { type: "finish" }, ] as UIMessageChunk[], ]; // SDK: process the entire stream at once. const sdkMsg = await updateFromUIMessageChunks( blankUIMessage(streamMessage, "thread-equiv"), batches.flat(), ); // Incremental: process batch by batch, threading state. let incMsg = blankUIMessage(streamMessage, "thread-equiv"); let state = emptyIncrementalStreamState(); for (const batch of batches) { ({ message: incMsg, streamState: state } = applyUIMessageChunksIncremental(incMsg, batch, state)); } expect(incMsg.parts).toEqual(sdkMsg.parts); expect(incMsg.text).toBe(sdkMsg.text); }); });