@convex-dev/agent
Version:
A agent component for Convex.
493 lines (438 loc) • 14.2 kB
text/typescript
import { describe, expect, it } from "vitest";
import type { MessageDoc } from "./client/index.js";
import type { UIMessage } from "./UIMessages.js";
import { fromUIMessages, toUIMessages } from "./UIMessages.js";
// Helper to create a base message doc
function baseMessageDoc<T = unknown>(
overrides: Partial<MessageDoc & { streaming?: boolean; metadata?: T }> = {},
): MessageDoc & { streaming?: boolean; metadata?: T } {
return {
_id: "msg1",
_creationTime: Date.now(),
order: 0,
stepOrder: 0,
status: "success",
threadId: "thread1",
tool: false,
...overrides,
};
}
describe("fromUIMessages round-trip tests", () => {
it("preserves essential data for simple user message", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "user",
content: "Hello world!",
},
text: "Hello world!",
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("user");
expect(uiMessages[0].text).toBe("Hello world!");
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].text).toBe("Hello world!");
expect(backToMessageDocs[0].threadId).toBe("thread1");
// Content gets normalized to array format
expect(Array.isArray(backToMessageDocs[0].message?.content)).toBe(true);
if (Array.isArray(backToMessageDocs[0].message?.content)) {
expect(backToMessageDocs[0].message.content[0]).toMatchObject({
type: "text",
text: "Hello world!",
});
}
});
it("preserves essential data for assistant message", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "assistant",
content: "Hi there! How can I help?",
},
text: "Hi there! How can I help?",
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("assistant");
expect(uiMessages[0].text).toBe("Hi there! How can I help?");
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].text).toBe("Hi there! How can I help?");
});
it("preserves system messages correctly", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "system",
content: "You are a helpful assistant.",
},
text: "You are a helpful assistant.",
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("system");
expect(uiMessages[0].text).toBe("You are a helpful assistant.");
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].text).toBe("You are a helpful assistant.");
expect(backToMessageDocs[0].message?.role).toBe("system");
// System content stays as string
expect(backToMessageDocs[0].message?.content).toBe(
"You are a helpful assistant.",
);
});
it("preserves reasoning in assistant messages", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this...",
},
{
type: "text",
text: "Here's my response.",
},
],
},
text: "Here's my response.",
reasoning: "Let me think about this...",
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].text).toBe("Here's my response.");
// Check that reasoning parts are preserved in UI message
const reasoningParts = uiMessages[0].parts.filter(
(part) => part.type === "reasoning",
);
expect(reasoningParts).toHaveLength(1);
expect(reasoningParts[0]).toMatchObject({
type: "reasoning",
text: "Let me think about this...",
});
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].text).toBe("Here's my response.");
expect(backToMessageDocs[0].reasoning).toBe("Let me think about this...");
});
it("handles tool calls and groups them correctly", () => {
// Tool calls get grouped into single UI message but expanded back to multiple message docs
const originalMessages = [
baseMessageDoc({
_id: "msg1",
order: 1,
stepOrder: 1,
message: {
role: "assistant",
content: [
{
type: "tool-call",
toolName: "calculator",
toolCallId: "call1",
args: { operation: "add", a: 2, b: 3 },
},
],
},
tool: true,
}),
baseMessageDoc({
_id: "msg2",
order: 1,
stepOrder: 2,
message: {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "calculator",
output: {
type: "json",
value: { result: 5 },
},
},
],
},
tool: true,
}),
];
const toTest = [originalMessages, [...originalMessages].reverse()];
for (const messages of toTest) {
const uiMessages = toUIMessages(messages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
// Should be grouped into single UI message
expect(uiMessages).toHaveLength(1);
const uiMessage = uiMessages[0];
expect(uiMessage.role).toBe("assistant");
expect(uiMessage.id).toBe("msg1");
// Check tool parts exist
const toolParts = uiMessage.parts.filter(
(part) => part.type === "tool-calculator",
);
expect(toolParts).toHaveLength(1);
expect(toolParts[0]).toMatchObject({
type: "tool-calculator",
toolCallId: "call1",
state: "output-available",
input: { operation: "add", a: 2, b: 3 },
output: { result: 5 },
});
// Should expand back to multiple message docs
expect(backToMessageDocs.length).toBeGreaterThanOrEqual(1);
// Check that tool information is preserved
const toolMessages = backToMessageDocs.filter((msg) => msg.tool);
expect(toolMessages.length).toBeGreaterThan(0);
expect(toolMessages[0].stepOrder).toBe(1);
expect(toolMessages[1].stepOrder).toBe(2);
}
});
it("preserves file attachments in user messages", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "user",
content: [
{
type: "text",
text: "What's in this image?",
},
{
type: "file",
mimeType: "image/png",
data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
},
text: "What's in this image?",
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("user");
expect(uiMessages[0].text).toBe("What's in this image?");
// Check file parts exist in UI message
const fileParts = uiMessages[0].parts.filter(
(part) => part.type === "file",
);
expect(fileParts).toHaveLength(1);
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].text).toBe("What's in this image?");
// Check file content is preserved
const content = backToMessageDocs[0].message?.content;
expect(Array.isArray(content)).toBe(true);
if (Array.isArray(content)) {
const fileContent = content.find((c) => c.type === "file");
expect(fileContent).toBeDefined();
expect(fileContent).toMatchObject({
type: "file",
mimeType: "image/png",
});
}
});
it("preserves sources correctly", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "assistant",
content: [
{
type: "text",
text: "I found some relevant sources.",
},
],
},
text: "I found some relevant sources.",
sources: [
{
type: "source",
sourceType: "url",
id: "source1",
url: "https://example.com",
title: "Example Source",
},
{
type: "source",
sourceType: "document",
id: "source2",
mediaType: "application/pdf",
title: "Document Source",
},
],
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
// Check source parts exist in UI message
const sourceParts = uiMessages[0].parts.filter(
(part) => part.type === "source-url" || part.type === "source-document",
);
expect(sourceParts).toHaveLength(2);
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].sources).toHaveLength(2);
expect(backToMessageDocs[0].sources![0]).toMatchObject({
type: "source",
sourceType: "url",
id: "source1",
url: "https://example.com",
title: "Example Source",
});
});
it("preserves metadata when provided", () => {
const testMetadata = {
customField: "customValue",
timestamp: Date.now(),
};
const originalMessages = [
baseMessageDoc({
message: {
role: "user",
content: "Test message",
},
text: "Test message",
metadata: testMetadata,
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].metadata).toEqual(testMetadata);
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].metadata).toEqual(testMetadata);
});
it("handles streaming status correctly", () => {
const originalMessages = [
baseMessageDoc({
message: {
role: "assistant",
content: "Streaming response...",
},
text: "Streaming response...",
streaming: true,
status: "pending",
}),
];
const uiMessages = toUIMessages(originalMessages);
const backToMessageDocs = fromUIMessages(uiMessages, {
threadId: "thread1",
});
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].status).toBe("streaming");
expect(backToMessageDocs).toHaveLength(1);
expect(backToMessageDocs[0].streaming).toBe(true);
expect(backToMessageDocs[0].status).toBe("pending");
});
});
describe("fromUIMessages functionality tests", () => {
it("handles empty messages array", () => {
const uiMessages: UIMessage[] = [];
const result = fromUIMessages(uiMessages, { threadId: "thread1" });
expect(result).toHaveLength(0);
});
it("correctly assigns thread ID", () => {
const uiMessage: UIMessage = {
id: "test-id",
_creationTime: Date.now(),
order: 0,
stepOrder: 0,
status: "success",
key: "test-key",
text: "Hello",
role: "user",
parts: [{ type: "text", text: "Hello" }],
};
const result = fromUIMessages([uiMessage], {
threadId: "custom-thread-id",
});
expect(result).toHaveLength(1);
expect(result[0].threadId).toBe("custom-thread-id");
});
it("correctly determines tool status", () => {
const toolUIMessage: UIMessage = {
id: "tool-id",
_creationTime: Date.now(),
order: 0,
stepOrder: 0,
status: "success",
key: "tool-key",
text: "",
role: "assistant",
parts: [
{
type: "tool-calculator",
toolCallId: "call1",
input: { a: 1, b: 2 },
state: "output-available",
output: { result: 3 },
},
],
};
const result = fromUIMessages([toolUIMessage], { threadId: "thread1" });
expect(result.length).toBeGreaterThan(0);
// Should have tool messages
const toolMessages = result.filter((msg) => msg.tool);
expect(toolMessages.length).toBeGreaterThan(0);
});
it("handles tool calls without responses", () => {
const toolUIMessage: UIMessage = {
id: "tool-id",
_creationTime: Date.now(),
order: 0,
stepOrder: 0,
status: "success",
key: "tool-key",
text: "",
role: "assistant",
parts: [
{ type: "text", text: "Tool call" },
{
type: "tool-calculator",
toolCallId: "call1",
input: { a: 1, b: 2 },
state: "input-available",
},
],
};
const result = fromUIMessages([toolUIMessage], { threadId: "thread1" });
expect(result.length).toBeGreaterThan(0);
// Should have tool messages
const toolMessages = result.filter((msg) => msg.tool);
expect(toolMessages.length).toBe(1);
expect(toolMessages[0].message?.role).toBe("assistant");
expect(toolMessages[0].message?.content[0]).toMatchObject({
type: "text",
text: "Tool call",
});
expect(toolMessages[0].message?.content[1]).toMatchObject({
args: { a: 1, b: 2 },
toolCallId: "call1",
toolName: "calculator",
type: "tool-call",
});
});
});