diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts index 04b0a05..3445527 100644 --- a/lib/chat/__tests__/handleChatGenerate.test.ts +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -30,16 +30,22 @@ vi.mock("ai", () => ({ generateText: vi.fn(), })); +vi.mock("@/lib/chat/saveChatCompletion", () => ({ + saveChatCompletion: vi.fn(), +})); + import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { setupChatRequest } from "@/lib/chat/setupChatRequest"; import { generateText } from "ai"; +import { saveChatCompletion } from "@/lib/chat/saveChatCompletion"; import { handleChatGenerate } from "../handleChatGenerate"; const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); const mockSetupChatRequest = vi.mocked(setupChatRequest); const mockGenerateText = vi.mocked(generateText); +const mockSaveChatCompletion = vi.mocked(saveChatCompletion); // Helper to create mock NextRequest function createMockRequest( @@ -336,4 +342,140 @@ describe("handleChatGenerate", () => { ); }); }); + + describe("message persistence", () => { + it("saves assistant message to database when roomId is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + mockSetupChatRequest.mockResolvedValue({ + model: "gpt-4", + instructions: "test", + system: "test", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + mockGenerateText.mockResolvedValue({ + text: "Hello! How can I help you?", + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + response: { messages: [], headers: {}, body: null }, + } as any); + + mockSaveChatCompletion.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "Hello", roomId: "room-abc-123" }, + { "x-api-key": "valid-key" }, + ); + + await handleChatGenerate(request as any); + + expect(mockSaveChatCompletion).toHaveBeenCalledWith({ + text: "Hello! How can I help you?", + roomId: "room-abc-123", + }); + }); + + it("does not save message when roomId is not provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + mockSetupChatRequest.mockResolvedValue({ + model: "gpt-4", + instructions: "test", + system: "test", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + mockGenerateText.mockResolvedValue({ + text: "Response", + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + response: { messages: [], headers: {}, body: null }, + } as any); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + await handleChatGenerate(request as any); + + expect(mockSaveChatCompletion).not.toHaveBeenCalled(); + }); + + it("passes correct text to saveChatCompletion", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + mockSetupChatRequest.mockResolvedValue({ + model: "gpt-4", + instructions: "test", + system: "test", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + mockGenerateText.mockResolvedValue({ + text: "This is the assistant response text", + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + response: { messages: [], headers: {}, body: null }, + } as any); + + mockSaveChatCompletion.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "Hello", roomId: "room-xyz" }, + { "x-api-key": "valid-key" }, + ); + + await handleChatGenerate(request as any); + + expect(mockSaveChatCompletion).toHaveBeenCalledWith({ + text: "This is the assistant response text", + roomId: "room-xyz", + }); + }); + + it("still returns success response even if saveChatCompletion fails", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + mockSetupChatRequest.mockResolvedValue({ + model: "gpt-4", + instructions: "test", + system: "test", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + mockGenerateText.mockResolvedValue({ + text: "Response", + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + response: { messages: [], headers: {}, body: null }, + } as any); + + mockSaveChatCompletion.mockRejectedValue(new Error("Database error")); + + const request = createMockRequest( + { prompt: "Hello", roomId: "room-abc" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatGenerate(request as any); + + expect(result.status).toBe(200); + const json = await result.json(); + expect(json.text).toBe("Response"); + }); + }); }); diff --git a/lib/chat/__tests__/saveChatCompletion.test.ts b/lib/chat/__tests__/saveChatCompletion.test.ts new file mode 100644 index 0000000..f25d717 --- /dev/null +++ b/lib/chat/__tests__/saveChatCompletion.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock dependencies before importing the module under test +vi.mock("@/lib/messages/getMessages", () => ({ + getMessages: vi.fn(), +})); + +vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/insertMemories", () => ({ + default: vi.fn(), +})); + +import { getMessages } from "@/lib/messages/getMessages"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import { saveChatCompletion } from "../saveChatCompletion"; + +const mockGetMessages = vi.mocked(getMessages); +const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories); +const mockInsertMemories = vi.mocked(insertMemories); + +describe("saveChatCompletion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("calls getMessages with text and role", async () => { + const mockMessage = { + id: "msg-123", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "Hello, world!" }], + }; + mockGetMessages.mockReturnValue([mockMessage]); + mockFilterMessageContentForMemories.mockReturnValue({ + role: "assistant", + parts: mockMessage.parts, + content: "Hello, world!", + }); + mockInsertMemories.mockResolvedValue(null); + + await saveChatCompletion({ + text: "Hello, world!", + role: "assistant", + roomId: "room-abc", + }); + + expect(mockGetMessages).toHaveBeenCalledWith("Hello, world!", "assistant"); + }); + + it("calls filterMessageContentForMemories with the message", async () => { + const mockMessage = { + id: "msg-456", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "Test response" }], + }; + mockGetMessages.mockReturnValue([mockMessage]); + mockFilterMessageContentForMemories.mockReturnValue({ + role: "assistant", + parts: mockMessage.parts, + content: "Test response", + }); + mockInsertMemories.mockResolvedValue(null); + + await saveChatCompletion({ + text: "Test response", + role: "assistant", + roomId: "room-xyz", + }); + + expect(mockFilterMessageContentForMemories).toHaveBeenCalledWith(mockMessage); + }); + + it("calls insertMemories with id, room_id, and filtered content", async () => { + const mockMessage = { + id: "msg-789", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "AI response" }], + }; + const mockFilteredContent = { + role: "assistant", + parts: mockMessage.parts, + content: "AI response", + }; + mockGetMessages.mockReturnValue([mockMessage]); + mockFilterMessageContentForMemories.mockReturnValue(mockFilteredContent); + mockInsertMemories.mockResolvedValue(null); + + await saveChatCompletion({ + text: "AI response", + role: "assistant", + roomId: "room-123", + }); + + expect(mockInsertMemories).toHaveBeenCalledWith({ + id: "msg-789", + room_id: "room-123", + content: mockFilteredContent, + }); + }); + + it("uses 'assistant' as default role when not specified", async () => { + const mockMessage = { + id: "msg-default", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "Default role test" }], + }; + mockGetMessages.mockReturnValue([mockMessage]); + mockFilterMessageContentForMemories.mockReturnValue({ + role: "assistant", + parts: mockMessage.parts, + content: "Default role test", + }); + mockInsertMemories.mockResolvedValue(null); + + await saveChatCompletion({ + text: "Default role test", + roomId: "room-default", + }); + + expect(mockGetMessages).toHaveBeenCalledWith("Default role test", "assistant"); + }); + + it("returns the inserted memory", async () => { + const mockMessage = { + id: "msg-return", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "Return test" }], + }; + const mockFilteredContent = { + role: "assistant", + parts: mockMessage.parts, + content: "Return test", + }; + const mockInsertedMemory = { + id: "msg-return", + room_id: "room-return", + content: mockFilteredContent, + created_at: "2026-01-19T00:00:00Z", + }; + mockGetMessages.mockReturnValue([mockMessage]); + mockFilterMessageContentForMemories.mockReturnValue(mockFilteredContent); + mockInsertMemories.mockResolvedValue(mockInsertedMemory as any); + + const result = await saveChatCompletion({ + text: "Return test", + roomId: "room-return", + }); + + expect(result).toEqual(mockInsertedMemory); + }); + + it("propagates errors from insertMemories", async () => { + const mockMessage = { + id: "msg-error", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "Error test" }], + }; + mockGetMessages.mockReturnValue([mockMessage]); + mockFilterMessageContentForMemories.mockReturnValue({ + role: "assistant", + parts: mockMessage.parts, + content: "Error test", + }); + mockInsertMemories.mockRejectedValue(new Error("Database error")); + + await expect( + saveChatCompletion({ + text: "Error test", + roomId: "room-error", + }), + ).rejects.toThrow("Database error"); + }); +}); diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts index d708bcf..11f9f69 100644 --- a/lib/chat/handleChatGenerate.ts +++ b/lib/chat/handleChatGenerate.ts @@ -3,6 +3,7 @@ import { generateText } from "ai"; import { validateChatRequest } from "./validateChatRequest"; import { setupChatRequest } from "./setupChatRequest"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { saveChatCompletion } from "./saveChatCompletion"; /** * Handles a non-streaming chat generate request. @@ -11,7 +12,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; * 1. Validates the request (auth, body schema) * 2. Sets up the chat configuration (agent, model, tools) * 3. Generates text using the AI SDK's generateText - * 4. Returns a JSON response with text, reasoning, sources, etc. + * 4. Persists the assistant message to the database (if roomId is provided) + * 5. Returns a JSON response with text, reasoning, sources, etc. * * @param request - The incoming NextRequest * @returns A JSON response or error NextResponse @@ -28,8 +30,18 @@ export async function handleChatGenerate(request: NextRequest): Promise; + +interface SaveChatCompletionParams { + text: string; + roomId: string; + role?: "user" | "assistant"; +} + +/** + * Saves a chat completion message to the database. + * + * This utility encapsulates the three-step process of: + * 1. Converting text to a UIMessage using getMessages + * 2. Filtering message content for storage using filterMessageContentForMemories + * 3. Inserting the memory using insertMemories + * + * @param params - The parameters for saving the chat completion + * @param params.text - The text content of the message + * @param params.roomId - The ID of the room to save the message to + * @param params.role - The role of the message sender (defaults to "assistant") + * @returns The inserted memory, or null if the insert fails + */ +export async function saveChatCompletion({ + text, + roomId, + role = "assistant", +}: SaveChatCompletionParams): Promise { + const message = getMessages(text, role)[0]; + const content = filterMessageContentForMemories(message); + + return insertMemories({ + id: message.id, + room_id: roomId, + content, + }); +} diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts index d021964..ea75fb3 100644 --- a/lib/emails/inbound/respondToInboundEmail.ts +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -1,13 +1,11 @@ import { NextResponse } from "next/server"; import type { ResendEmailReceivedEvent } from "@/lib/emails/validateInboundEmailEvent"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; -import { getMessages } from "@/lib/messages/getMessages"; import { getFromWithName } from "@/lib/emails/inbound/getFromWithName"; -import insertMemories from "@/lib/supabase/memories/insertMemories"; -import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; import { validateNewEmailMemory } from "@/lib/emails/inbound/validateNewEmailMemory"; import { generateEmailResponse } from "@/lib/emails/inbound/generateEmailResponse"; import { validateCcReplyExpected } from "@/lib/emails/inbound/validateCcReplyExpected"; +import { saveChatCompletion } from "@/lib/chat/saveChatCompletion"; /** * Responds to an inbound email by sending a hard-coded reply in the same thread. @@ -60,12 +58,7 @@ export async function respondToInboundEmail( const result = await sendEmailWithResend(payload); // Save the assistant response message - const assistantMessage = getMessages(text, "assistant")[0]; - await insertMemories({ - id: assistantMessage.id, - room_id: roomId, - content: filterMessageContentForMemories(assistantMessage), - }); + await saveChatCompletion({ text, roomId }); if (result instanceof NextResponse) { return result;