UNPKG

@convex-dev/agent

Version:

A agent component for Convex.

421 lines (402 loc) 11.4 kB
import { describe, it, expect } from "vitest"; import { toUIMessages } from "./toUIMessages.js"; import type { MessageDoc } from "../client/index.js"; import { assert } from "convex-helpers"; // Helper to create a base message doc function baseMessageDoc(overrides: Partial<MessageDoc> = {}): MessageDoc { return { _id: "msg1", _creationTime: Date.now(), order: 1, stepOrder: 0, status: "success", threadId: "thread1", tool: false, ...overrides, }; } describe("toUIMessages", () => { it("handles user message", () => { const messages = [ baseMessageDoc({ message: { role: "user", content: "Hello!", }, text: "Hello!", }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("user"); expect(uiMessages[0].text).toBe("Hello!"); expect(uiMessages[0].parts[0]).toEqual({ type: "text", text: "Hello!" }); }); it("handles assistant message", () => { const messages = [ baseMessageDoc({ message: { role: "assistant", content: "Hi, how can I help?", }, text: "Hi, how can I help?", }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("assistant"); expect(uiMessages[0].text).toBe("Hi, how can I help?"); expect(uiMessages[0].parts[0]).toEqual({ type: "text", text: "Hi, how can I help?", state: "done", }); }); it("handles multiple messages", () => { const messages = [ baseMessageDoc({ message: { role: "user", content: "Hello!", }, text: "Hello!", }), baseMessageDoc({ message: { role: "assistant", content: [ { type: "reasoning", text: "I'm thinking...", }, { type: "redacted-reasoning", data: "asdfasdfasdf", }, { type: "text", text: "I'm thinking...", }, { type: "file", mimeType: "text/plain", data: "https://example.com/file.txt", }, { type: "tool-call", toolName: "myTool", toolCallId: "call1", args: "an arg", }, ], }, tool: true, reasoning: "I'm thinking...", text: "I'm thinking...", }), baseMessageDoc({ message: { role: "tool", content: [ { type: "tool-result", toolCallId: "call1", toolName: "myTool", result: "42", }, ], }, tool: true, }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(2); expect(uiMessages[0].role).toBe("user"); expect(uiMessages[0].parts.filter((p) => p.type === "text")).toHaveLength( 1, ); expect(uiMessages[1].role).toBe("assistant"); expect( uiMessages[1].parts.filter((p) => p.type === "tool-myTool"), ).toHaveLength(1); expect( uiMessages[1].parts.filter((p) => p.type === "tool-myTool")[0], ).toMatchObject({ type: "tool-myTool", toolCallId: "call1", state: "output-available", output: "42", }); }); it("handles multiple text and reasoning parts", () => { const messages = [ baseMessageDoc({ message: { role: "assistant", content: [ { type: "reasoning", text: "I'm thinking...", }, { type: "text", text: "Here's one idea.", }, { type: "reasoning", text: "I'm thinking...", }, { type: "text", text: "Here's another idea.", }, ], }, reasoning: "I'm thinking...I'm thinking...", text: "Here's one idea. Here's another idea.", }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("assistant"); expect(uiMessages[0].text).toBe("Here's one idea. Here's another idea."); expect(uiMessages[0].parts.filter((p) => p.type === "reasoning")).toEqual([ { providerMetadata: undefined, state: undefined, text: "I'm thinking...", type: "reasoning", }, { providerMetadata: undefined, state: undefined, text: "I'm thinking...", type: "reasoning", }, ]); expect(uiMessages[0].parts[0].type).toBe("reasoning"); assert(uiMessages[0].parts[0].type === "reasoning"); expect(uiMessages[0].parts[0].text).toBe("I'm thinking..."); expect(uiMessages[0].parts[1].type).toBe("text"); assert(uiMessages[0].parts[1].type === "text"); expect(uiMessages[0].parts[1].text).toBe("Here's one idea."); expect(uiMessages[0].parts[2].type).toBe("reasoning"); assert(uiMessages[0].parts[2].type === "reasoning"); expect(uiMessages[0].parts[2].text).toBe("I'm thinking..."); expect(uiMessages[0].parts.filter((p) => p.type === "text")).toHaveLength( 2, ); expect(uiMessages[0].parts.filter((p) => p.type === "text")[0].text).toBe( "Here's one idea.", ); expect(uiMessages[0].parts.filter((p) => p.type === "text")[1].text).toBe( "Here's another idea.", ); }); it("handles system message", () => { const messages = [ baseMessageDoc({ message: { role: "system", content: "System message here", }, text: "System message here", }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("system"); expect(uiMessages[0].text).toBe("System message here"); expect(uiMessages[0].parts[0]).toEqual({ type: "text", text: "System message here", state: "done", providerMetadata: undefined, }); }); it("handles wrapped JSON tool output", () => { const messages = [ baseMessageDoc({ message: { role: "assistant", content: [ { type: "tool-call", toolName: "myTool", toolCallId: "call1", args: { query: "test" }, }, ], }, tool: true, }), baseMessageDoc({ message: { role: "tool", content: [ { type: "tool-result", toolName: "myTool", toolCallId: "call1", result: { type: "json", value: { data: "wrapped result", success: true }, }, }, ], }, tool: true, }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); const toolPart = uiMessages[0].parts.find((p) => p.type === "tool-myTool"); expect(toolPart).toMatchObject({ type: "tool-myTool", toolCallId: "call1", state: "output-available", input: { query: "test" }, output: { data: "wrapped result", success: true }, // Should be unwrapped }); }); it("handles tool call", () => { const messages = [ baseMessageDoc({ message: { role: "assistant", content: [ { type: "tool-call", toolName: "myTool", toolCallId: "call1", args: "hi", }, ], }, text: "", }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("assistant"); expect( uiMessages[0].parts.filter((p) => p.type === "tool-myTool")[0], ).toMatchObject({ type: "tool-myTool", toolCallId: "call1", input: "hi", state: "input-available", }); }); it("handles tool result", () => { const messages = [ baseMessageDoc({ tool: true, message: { role: "assistant", content: [ { type: "tool-call", toolName: "myTool", toolCallId: "call1", args: "", }, ], }, text: "", }), baseMessageDoc({ message: { role: "tool", content: [ { type: "tool-result", toolCallId: "call1", toolName: "myTool", result: "42", }, ], }, text: "", }), ]; const uiMessages = toUIMessages(messages); expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("assistant"); // Should have a tool-invocation part expect(uiMessages[0].parts.some((p) => p.type === "tool-myTool")).toBe( true, ); }); it("does not duplicate text content", () => { const messages = [ baseMessageDoc({ message: { role: "assistant", content: "Hello!", }, text: "Hello!", }), ]; const uiMessages = toUIMessages(messages); // There should only be one text part const textParts = uiMessages[0].parts.filter((p) => p.type === "text"); expect(textParts).toHaveLength(1); expect(textParts[0].text).toBe("Hello!"); }); // Add more tests for array content, tool calls, etc. as needed it("should update tool call state from input-available to output-available", () => { const messages = [ baseMessageDoc({ message: { role: "assistant", content: [ { type: "tool-call", toolName: "calculator", toolCallId: "call1", args: { operation: "add", a: 1, b: 2 }, }, ], }, tool: true, }), baseMessageDoc({ message: { role: "tool", content: [ { type: "tool-result", toolCallId: "call1", toolName: "calculator", result: { sum: 3 }, }, ], }, tool: true, }), ]; const uiMessages = toUIMessages(messages); // Should have one assistant message expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("assistant"); // Should have a single tool-calculator part (not separate tool-call and tool-result parts) const toolParts = uiMessages[0].parts.filter( (p) => p.type === "tool-calculator", ); expect(toolParts).toHaveLength(1); const toolPart = toolParts[0]; expect(toolPart).toMatchObject({ type: "tool-calculator", toolCallId: "call1", state: "output-available", input: { operation: "add", a: 1, b: 2 }, output: { sum: 3 }, }); // Should NOT have a tool-call part (which is what currently happens) const toolCallParts = uiMessages[0].parts.filter( (p) => p.type === "tool-call", ); expect(toolCallParts).toHaveLength(0); }); });