@convex-dev/agent
Version:
A agent component for Convex.
732 lines (699 loc) • 20.5 kB
text/typescript
import { assert } from "convex-helpers";
import { describe, expect, it } from "vitest";
import { toUIMessages } from "./UIMessages.js";
import type { MessageDoc } from "./validators.js";
// Helper to create a base message doc
function baseMessageDoc(overrides: Partial<MessageDoc> = {}): MessageDoc {
return {
_id: "msg1",
_creationTime: Date.now(),
order: 0,
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",
output: {
type: "text",
value: "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([
{
providerOptions: undefined,
state: "done",
text: "I'm thinking...",
type: "reasoning",
},
{
providerOptions: undefined,
state: "done",
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("combines text from between messages", () => {
const messages = [
baseMessageDoc({
message: {
role: "assistant",
content: [
{
type: "reasoning",
text: "I'm thinking...",
},
{
type: "text",
text: "I'm going to ask a question.",
},
{
type: "tool-call",
args: "What's the meaning of life?",
toolCallId: "call1",
toolName: "myTool",
},
],
},
reasoning: "I'm thinking...",
text: "Here's one idea.",
tool: true,
order: 1,
stepOrder: 1,
}),
baseMessageDoc({
message: {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "myTool",
output: {
type: "text",
value: "42",
},
},
],
},
text: "",
tool: true,
order: 1,
stepOrder: 2,
}),
baseMessageDoc({
message: {
role: "assistant",
content: [
{
type: "reasoning",
text: "Thinking again...",
},
{
type: "text",
text: "Ok now I know.",
},
],
},
text: "One last thing.",
order: 1,
stepOrder: 3,
}),
];
const uiMessages = toUIMessages(messages);
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("assistant");
expect(uiMessages[0].text).toBe(
"I'm going to ask a question. Ok now I know.",
);
});
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",
output: {
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!");
});
it("sets text field correctly when message has many parts with text part as final part", () => {
const messages = [
baseMessageDoc({
message: {
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this...",
},
{
type: "tool-call",
toolName: "calculator",
toolCallId: "call1",
args: { operation: "add", a: 2, b: 3 },
},
{
type: "file",
mimeType: "application/json",
data: "some-file-data",
},
{
type: "text",
text: "Here's my final answer.",
},
],
},
text: "Here's my final answer.",
reasoning: "Let me think about this...",
}),
];
const uiMessages = toUIMessages(messages);
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("assistant");
expect(uiMessages[0].text).toBe("Here's my final answer.");
// Verify that the text part exists in parts
const textParts = uiMessages[0].parts.filter((p) => p.type === "text");
expect(textParts).toHaveLength(1);
expect(textParts[0].text).toBe("Here's my final answer.");
});
// 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);
});
it("sets text field correctly when message has many parts with text part as final part", () => {
const messages = [
baseMessageDoc({
text: "what time is it in paris?",
message: {
role: "user",
content: "what time is it in paris?",
},
}),
baseMessageDoc({
tool: true,
finishReason: "tool-calls",
text: "",
stepOrder: 1,
message: {
role: "assistant",
content: [
{
type: "reasoning",
text: "**Finding the Time**\n\nI've pinpointed the core task: obtaining the current time in Paris. It involves using the `dateTime` tool. I've identified \"Europe/Paris\" as the necessary timezone identifier to provide to the tool. My next step is to test the tool.\n\n\n",
},
{
args: {
timezone: "Europe/Paris",
},
type: "tool-call",
toolName: "dateTime",
toolCallId: "tool_0_dateTime",
},
],
},
reasoning:
"**Finding the Time**\n\nI've pinpointed the core task: obtaining the current time in Paris. It involves using the `dateTime` tool. I've identified \"Europe/Paris\" as the necessary timezone identifier to provide to the tool. My next step is to test the tool.\n\n\n",
reasoningDetails: [
{
text: "**Finding the Time**\n\nI've pinpointed the core task: obtaining the current time in Paris. It involves using the `dateTime` tool. I've identified \"Europe/Paris\" as the necessary timezone identifier to provide to the tool. My next step is to test the tool.\n\n\n",
type: "reasoning",
},
],
warnings: [],
}),
baseMessageDoc({
tool: true,
stepOrder: 2,
message: {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tool_0_dateTime",
toolName: "dateTime",
result: {
type: "json",
value: {
day: "20",
hours: 16,
minutes: 3,
month: "August",
year: "2025",
},
},
},
],
},
sources: [],
}),
baseMessageDoc({
finishReason: "stop",
text: "The time in Paris, France is 4:03 PM on August 20, 2025.",
stepOrder: 3,
message: {
role: "assistant",
content: [
{
type: "text",
text: "The time in Paris, France is 4:03 PM on August 20, 2025.",
},
],
},
reasoningDetails: [],
sources: [],
warnings: [],
}),
];
const uiMessages = toUIMessages(messages);
expect(uiMessages).toHaveLength(2);
expect(uiMessages[0].role).toBe("user");
expect(uiMessages[0].text).toBe("what time is it in paris?");
expect(uiMessages[1].role).toBe("assistant");
expect(uiMessages[1].text).toBe(
"The time in Paris, France is 4:03 PM on August 20, 2025.",
);
});
it("handles messages in reverse order (assistant response, then tool response, then tool call)", () => {
const messages = [
// Final assistant response comes first (stepOrder 3) - no tool flag since it's the final message
baseMessageDoc({
_id: "msg3",
order: 1,
stepOrder: 3,
finishReason: "stop",
text: "The result is 42.",
tool: false, // This is the final message without tool calls
message: {
role: "assistant",
content: [
{
type: "text",
text: "The result is 42.",
},
],
},
}),
// Tool result comes second (stepOrder 2)
baseMessageDoc({
_id: "msg2",
order: 1,
stepOrder: 2,
tool: true,
message: {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "calculator",
output: {
type: "text",
value: "42",
},
},
],
},
}),
// Tool call comes last (stepOrder 1)
baseMessageDoc({
_id: "msg1",
order: 1,
stepOrder: 1,
tool: true,
text: "",
message: {
role: "assistant",
content: [
{
type: "tool-call",
toolName: "calculator",
toolCallId: "call1",
args: { operation: "add", a: 40, b: 2 },
},
],
},
}),
];
const uiMessages = toUIMessages(messages);
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("assistant");
// Should concatenate text from all messages (only the final response has text)
expect(uiMessages[0].text).toBe("The result is 42.");
// Should use first message's fields (msg1 with stepOrder 1)
expect(uiMessages[0].id).toBe("msg1");
expect(uiMessages[0].order).toBe(1);
expect(uiMessages[0].stepOrder).toBe(1);
// Should have both tool call and result parts
const toolParts = uiMessages[0].parts.filter(
(p) => p.type === "tool-calculator",
);
expect(toolParts).toHaveLength(1);
expect(toolParts[0]).toMatchObject({
type: "tool-calculator",
toolCallId: "call1",
state: "output-available",
input: { operation: "add", a: 40, b: 2 },
output: "42",
});
// Should also have text part
const textParts = uiMessages[0].parts.filter((p) => p.type === "text");
expect(textParts).toHaveLength(1);
expect(textParts[0].text).toBe("The result is 42.");
});
});