UNPKG

@convex-dev/agent

Version:

A agent component for Convex.

584 lines (504 loc) 18.1 kB
import { beforeEach, describe, expect, test, vi } from "vitest"; import { saveInputMessages } from "./saveInputMessages.js"; import type { MessageDoc } from "../validators.js"; import type { ActionCtx } from "./types.js"; import { defineSchema, type Auth, type StorageActionWriter, } from "convex/server"; import { initConvexTest } from "./setup.test.js"; import { components } from "./setup.test.js"; const schema = defineSchema({}); // Hoisted mock functions const { mockSaveMessages, mockEmbedMessages } = vi.hoisted(() => ({ mockSaveMessages: vi.fn(), mockEmbedMessages: vi.fn(), })); vi.mock("./messages.js", () => ({ saveMessages: mockSaveMessages, })); vi.mock("./search.js", async () => { const actual = await vi.importActual("./search.js"); return { ...actual, embedMessages: mockEmbedMessages, }; }); // Helper to create mock MessageDoc const createMockMessageDoc = ( id: string, role: "user" | "assistant" | "tool" | "system", content: any, ): MessageDoc => ({ _id: id, _creationTime: Date.now(), userId: "test-user", threadId: "test-thread", order: 1, stepOrder: 1, status: role === "assistant" ? "pending" : "success", tool: false, message: { role, content }, }); describe("saveInputMessages", () => { const defaultArgs = { threadId: "test-thread", userId: "test-user", promptMessageId: undefined, agentName: "test-agent", storageOptions: { saveMessages: "promptAndOutput" as const }, usageHandler: undefined, textEmbeddingModel: undefined, callSettings: undefined, }; const mockComponent = components.agent; let t = initConvexTest(schema); let ctx: ActionCtx; beforeEach(() => { vi.clearAllMocks(); t = initConvexTest(schema); ctx = { runQuery: t.query, runAction: t.action, runMutation: t.mutation, auth: {} as Auth, storage: {} as StorageActionWriter, } as ActionCtx; mockSaveMessages.mockResolvedValue({ messages: [ createMockMessageDoc("saved-1", "user", "Test prompt"), createMockMessageDoc("pending-1", "assistant", []), ], }); mockEmbedMessages.mockResolvedValue({ vectors: [[0.1, 0.2, 0.3], null], dimension: 3, model: "test-model", }); }); describe("saveMessages: 'all' scenarios", () => { test("should save all messages and prompt when storageOptions.saveMessages is 'all'", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const prompt = "Test prompt"; const messages = [ { role: "user" as const, content: "Previous message 1" }, { role: "assistant" as const, content: "Response 1" }, ]; const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages, storageOptions: { saveMessages: "all" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ threadId: "test-thread", userId: "test-user", messages: [ ...messages, { role: "user", content: "Test prompt" }, { role: "assistant", content: [] }, ], metadata: expect.arrayContaining([ {}, {}, {}, { status: "pending" }, ]), failPendingSteps: false, }), ); expect(result.promptMessageId).toBe("saved-1"); expect(result.pendingMessage?._id).toBe("pending-1"); expect(result.savedMessages).toHaveLength(1); expect(result.savedMessages[0]._id).toBe("saved-1"); }); }); test("should save all with promptMessageId provided (no new messages saved)", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { // Mock saveMessages to return only pending message mockSaveMessages.mockResolvedValueOnce({ messages: [createMockMessageDoc("pending-1", "assistant", [])], }); const prompt = "Test prompt"; const messages = [ { role: "user" as const, content: "Previous message" }, ]; const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages, promptMessageId: "existing-prompt-id", storageOptions: { saveMessages: "all" }, }); // Should not save any input messages when promptMessageId is provided expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [{ role: "assistant", content: [] }], metadata: [{ status: "pending" }], failPendingSteps: true, }), ); expect(result.promptMessageId).toBe("existing-prompt-id"); expect(result.savedMessages).toHaveLength(0); }); }); test("should save all with only prompt messages provided", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const prompt = [ { role: "user" as const, content: "Multi-part prompt 1" }, { role: "user" as const, content: "Multi-part prompt 2" }, ]; const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages: undefined, storageOptions: { saveMessages: "all" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [...prompt, { role: "assistant", content: [] }], }), ); expect(result.savedMessages).toHaveLength(1); }); }); test("should save all with both prompt and messages provided", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const prompt = "Single prompt"; const messages = [ { role: "user" as const, content: "Context message" }, ]; await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages, storageOptions: { saveMessages: "all" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [ ...messages, { role: "user", content: "Single prompt" }, { role: "assistant", content: [] }, ], }), ); }); }); }); describe("saveMessages: 'promptAndOutput' scenarios", () => { test("should save only prompt when storageOptions.saveMessages is 'promptAndOutput'", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const prompt = "Test prompt"; const messages = [ { role: "user" as const, content: "Previous message" }, ]; const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages, storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [ { role: "user", content: "Test prompt" }, { role: "assistant", content: [] }, ], metadata: [{}, { status: "pending" }], }), ); expect(result.promptMessageId).toBe("saved-1"); expect(result.savedMessages).toHaveLength(1); }); }); test("should save prompt array when provided with promptAndOutput", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const prompt = [ { role: "user" as const, content: "Part 1" }, { role: "user" as const, content: "Part 2" }, ]; const messages = [ { role: "user" as const, content: "Context message" }, ]; await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages, storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [...prompt, { role: "assistant", content: [] }], }), ); }); }); test("should save last message when no prompt provided with promptAndOutput", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const messages = [ { role: "user" as const, content: "First message" }, { role: "user" as const, content: "Last message" }, ]; await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: undefined, messages, storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [ { role: "user", content: "Last message" }, { role: "assistant", content: [] }, ], }), ); }); }); test("should handle promptMessageId with promptAndOutput (no new messages saved)", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { // Mock saveMessages to return only pending message mockSaveMessages.mockResolvedValueOnce({ messages: [createMockMessageDoc("pending-1", "assistant", [])], }); const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: [{ role: "user" as const, content: "Context" }], promptMessageId: "existing-prompt-id", storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [{ role: "assistant", content: [] }], failPendingSteps: true, }), ); expect(result.promptMessageId).toBe("existing-prompt-id"); expect(result.savedMessages).toHaveLength(0); }); }); }); describe("embedding generation scenarios", () => { test("should generate embeddings when textEmbeddingModel is provided (action context)", async () => { // Create action context with runAction method const actionCtx = { runQuery: vi.fn(), runMutation: vi.fn(), runAction: vi.fn(), auth: {} as Auth, storage: {} as StorageActionWriter, } as ActionCtx; await saveInputMessages(actionCtx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: undefined, textEmbeddingModel: "test-embedding-model", storageOptions: { saveMessages: "promptAndOutput" }, }); // Verify embedMessages was called expect(mockEmbedMessages).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ userId: "test-user", threadId: "test-thread", textEmbeddingModel: "test-embedding-model", }), [{ role: "user", content: "Test prompt" }], ); // Verify saveMessages was called with embeddings expect(mockSaveMessages).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.objectContaining({ embeddings: expect.objectContaining({ vectors: expect.arrayContaining([[0.1, 0.2, 0.3], null]), dimension: 3, model: "test-model", }), }), ); }); test("should not generate embeddings in mutation context even with textEmbeddingModel", async () => { await expect( t.run(async (ctx) => { return saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: undefined, textEmbeddingModel: "test-embedding-model", storageOptions: { saveMessages: "promptAndOutput" }, }); }), ).rejects.toThrow( "You must be in an action context to generate embeddings", ); }); test("should not generate embeddings when no textEmbeddingModel provided", async () => { await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: undefined, textEmbeddingModel: undefined, storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockEmbedMessages).not.toHaveBeenCalled(); }); test("should not generate embeddings when no messages to save", async () => { await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: undefined, messages: undefined, promptMessageId: "existing-id", textEmbeddingModel: "test-model", storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockEmbedMessages).not.toHaveBeenCalled(); }); }); describe("edge cases and validation", () => { test("should handle empty prompt and messages gracefully", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { // Mock saveMessages to return only pending message mockSaveMessages.mockResolvedValueOnce({ messages: [createMockMessageDoc("pending-1", "assistant", [])], }); const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: undefined, messages: undefined, storageOptions: { saveMessages: "promptAndOutput" }, }); expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [{ role: "assistant", content: [] }], metadata: [{ status: "pending" }], }), ); expect(result.promptMessageId).toBeUndefined(); expect(result.savedMessages).toHaveLength(0); }); }); test("should default to 'promptAndOutput' when storageOptions.saveMessages is not specified", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const prompt = "Test prompt"; const messages = [ { role: "user" as const, content: "Context message" }, ]; await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt, messages, storageOptions: undefined, }); // Should behave like promptAndOutput - only save prompt expect(mockSaveMessages).toHaveBeenCalledWith( ctx, mockComponent, expect.objectContaining({ messages: [ { role: "user", content: "Test prompt" }, { role: "assistant", content: [] }, ], }), ); }); }); test("should always include pending message in saved messages", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: undefined, storageOptions: { saveMessages: "all" }, }); expect(result.pendingMessage.status).toBe("pending"); expect(result.pendingMessage.message?.role).toBe("assistant"); expect(result.pendingMessage.message?.content).toEqual([]); // Pending message should NOT be included in savedMessages expect(result.savedMessages).not.toContainEqual( expect.objectContaining({ status: "pending" }), ); }); }); test("should return correct promptMessageId when messages are saved", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { // Mock saveMessages to return multiple messages mockSaveMessages.mockResolvedValueOnce({ messages: [ createMockMessageDoc("msg-1", "user", "First"), createMockMessageDoc( "msg-2", "user", "Second - this should be the prompt", ), createMockMessageDoc("pending-1", "assistant", []), ], }); const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: [{ role: "user" as const, content: "Context" }], storageOptions: { saveMessages: "all" }, }); // promptMessageId should be the second-to-last message (before pending) expect(result.promptMessageId).toBe("msg-2"); expect(result.savedMessages).toHaveLength(2); expect(result.savedMessages.map((m) => m._id)).toEqual([ "msg-1", "msg-2", ]); }); }); test("should use provided promptMessageId when no new messages are saved", async () => { const t = initConvexTest(schema); await t.run(async (ctx) => { mockSaveMessages.mockResolvedValueOnce({ messages: [createMockMessageDoc("pending-1", "assistant", [])], }); const result = await saveInputMessages(ctx, mockComponent, { ...defaultArgs, prompt: "Test prompt", messages: undefined, promptMessageId: "existing-prompt-123", storageOptions: { saveMessages: "promptAndOutput" }, }); expect(result.promptMessageId).toBe("existing-prompt-123"); expect(result.savedMessages).toHaveLength(0); }); }); }); });