diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts index 04b0a05..8ee8827 100644 --- a/lib/chat/__tests__/handleChatGenerate.test.ts +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -30,16 +30,46 @@ vi.mock("ai", () => ({ generateText: vi.fn(), })); +vi.mock("@/lib/chat/saveChatCompletion", () => ({ + saveChatCompletion: vi.fn(), +})); + +vi.mock("@/lib/uuid/generateUUID", () => { + const mockFn = vi.fn(() => "auto-generated-room-id"); + return { + generateUUID: mockFn, + default: mockFn, + }; +}); + +vi.mock("@/lib/chat/createNewRoom", () => ({ + createNewRoom: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/insertMemories", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ + default: vi.fn((msg: unknown) => msg), +})); + 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 { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; 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); +const mockGenerateUUID = vi.mocked(generateUUID); +const mockCreateNewRoom = vi.mocked(createNewRoom); // Helper to create mock NextRequest function createMockRequest( @@ -336,4 +366,218 @@ 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("saves message with auto-generated roomId when roomId is not provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("auto-generated-room-id"); + mockCreateNewRoom.mockResolvedValue(undefined); + + 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.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + await handleChatGenerate(request as any); + + // Since roomId is auto-created, saveChatCompletion should be called + expect(mockSaveChatCompletion).toHaveBeenCalledWith({ + text: "Response", + roomId: "auto-generated-room-id", + }); + }); + + it("includes roomId in HTTP response when provided by client", 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.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "Hello", roomId: "client-provided-room-id" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatGenerate(request as any); + + expect(result.status).toBe(200); + const json = await result.json(); + expect(json.roomId).toBe("client-provided-room-id"); + }); + + it("includes auto-generated roomId in HTTP response when not provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("auto-generated-room-456"); + mockCreateNewRoom.mockResolvedValue(undefined); + + 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.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatGenerate(request as any); + + expect(result.status).toBe(200); + const json = await result.json(); + expect(json.roomId).toBe("auto-generated-room-456"); + }); + + 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__/handleChatStream.test.ts b/lib/chat/__tests__/handleChatStream.test.ts index b78918e..5943fee 100644 --- a/lib/chat/__tests__/handleChatStream.test.ts +++ b/lib/chat/__tests__/handleChatStream.test.ts @@ -22,10 +22,29 @@ vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ validateOrganizationAccess: vi.fn(), })); +vi.mock("@/lib/chat/setupConversation", () => ({ + setupConversation: vi.fn().mockResolvedValue({ roomId: "mock-room-id", memoryId: "mock-memory-id" }), +})); + +vi.mock("@/lib/chat/validateMessages", () => ({ + validateMessages: vi.fn((messages) => ({ + lastMessage: messages[messages.length - 1] || { id: "mock-id", role: "user", parts: [] }, + validMessages: messages, + })), +})); + +vi.mock("@/lib/messages/convertToUiMessages", () => ({ + default: vi.fn((messages) => messages), +})); + vi.mock("@/lib/chat/setupChatRequest", () => ({ setupChatRequest: vi.fn(), })); +vi.mock("@/lib/chat/handleChatCompletion", () => ({ + handleChatCompletion: vi.fn(), +})); + vi.mock("ai", () => ({ createUIMessageStream: vi.fn(), createUIMessageStreamResponse: vi.fn(), @@ -34,11 +53,13 @@ vi.mock("ai", () => ({ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { setupChatRequest } from "@/lib/chat/setupChatRequest"; +import { setupConversation } from "@/lib/chat/setupConversation"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; import { handleChatStream } from "../handleChatStream"; const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); +const mockSetupConversation = vi.mocked(setupConversation); const mockSetupChatRequest = vi.mocked(setupChatRequest); const mockCreateUIMessageStream = vi.mocked(createUIMessageStream); const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse); @@ -60,6 +81,12 @@ function createMockRequest( describe("handleChatStream", () => { beforeEach(() => { vi.clearAllMocks(); + // Re-setup mock return value after clearAllMocks + // Return the provided roomId if given, otherwise return mock-room-id + mockSetupConversation.mockImplementation(async ({ roomId }) => ({ + roomId: roomId || "mock-room-id", + memoryId: "mock-memory-id", + })); }); afterEach(() => { diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts index 27206c0..185e0e9 100644 --- a/lib/chat/__tests__/integration/chatEndToEnd.test.ts +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -102,6 +102,31 @@ vi.mock("@/lib/chat/generateChatTitle", () => ({ generateChatTitle: vi.fn().mockResolvedValue("Test Chat"), })); +// Mock room creation dependencies (for auto-create roomId) +vi.mock("@/lib/uuid/generateUUID", () => { + const mockFn = vi.fn(() => "mock-uuid-default"); + return { + generateUUID: mockFn, + default: mockFn, + }; +}); + +vi.mock("@/lib/chat/createNewRoom", () => ({ + createNewRoom: vi.fn(), +})); + +vi.mock("@/lib/chat/saveChatCompletion", () => ({ + saveChatCompletion: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/insertMemories", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ + default: vi.fn((msg: unknown) => msg), +})); + // Mock AI SDK vi.mock("ai", () => ({ convertToModelMessages: vi.fn((messages: unknown[]) => messages), 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/__tests__/setupConversation.test.ts b/lib/chat/__tests__/setupConversation.test.ts new file mode 100644 index 0000000..e9e57d9 --- /dev/null +++ b/lib/chat/__tests__/setupConversation.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies +vi.mock("@/lib/uuid/generateUUID", () => { + const mockFn = vi.fn(() => "mock-uuid"); + return { + generateUUID: mockFn, + default: mockFn, + }; +}); + +vi.mock("@/lib/chat/createNewRoom", () => ({ + createNewRoom: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/insertMemories", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ + default: vi.fn((msg: unknown) => msg), +})); + +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import { setupConversation } from "../setupConversation"; + +const mockGenerateUUID = vi.mocked(generateUUID); +const mockCreateNewRoom = vi.mocked(createNewRoom); +const mockInsertMemories = vi.mocked(insertMemories); +const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories); + +/** + * Helper to create a UIMessage for testing. + * + * @param text - The message text + * @param role - The message role (user or assistant) + * @returns A UIMessage-like object + */ +function createUIMessage(text: string, role: "user" | "assistant" = "user") { + return { + id: "msg-id", + role, + parts: [{ type: "text" as const, text }], + }; +} + +describe("setupConversation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateUUID.mockReturnValue("generated-uuid"); + mockCreateNewRoom.mockResolvedValue(undefined); + mockInsertMemories.mockResolvedValue(null); + }); + + describe("room creation", () => { + it("creates a new room when roomId is not provided", async () => { + const promptMessage = createUIMessage("Hello"); + + await setupConversation({ + accountId: "account-123", + promptMessage, + }); + + expect(mockCreateNewRoom).toHaveBeenCalledWith({ + accountId: "account-123", + roomId: "generated-uuid", + artistId: undefined, + lastMessage: promptMessage, + }); + }); + + it("does NOT create a room when roomId is provided", async () => { + const promptMessage = createUIMessage("Hello"); + + await setupConversation({ + accountId: "account-123", + roomId: "existing-room-id", + promptMessage, + }); + + expect(mockCreateNewRoom).not.toHaveBeenCalled(); + }); + + it("passes artistId to createNewRoom when provided", async () => { + const promptMessage = createUIMessage("Hello"); + + await setupConversation({ + accountId: "account-123", + promptMessage, + artistId: "artist-xyz", + }); + + expect(mockCreateNewRoom).toHaveBeenCalledWith( + expect.objectContaining({ + artistId: "artist-xyz", + }), + ); + }); + }); + + describe("message persistence", () => { + it("persists user message to memories", async () => { + const promptMessage = createUIMessage("Test message"); + + await setupConversation({ + accountId: "account-123", + promptMessage, + }); + + expect(mockInsertMemories).toHaveBeenCalledWith({ + id: "generated-uuid", + room_id: "generated-uuid", + content: promptMessage, + }); + expect(mockFilterMessageContentForMemories).toHaveBeenCalledWith(promptMessage); + }); + + it("uses provided memoryId instead of generating one", async () => { + const promptMessage = createUIMessage("Test message"); + + await setupConversation({ + accountId: "account-123", + roomId: "existing-room", + promptMessage, + memoryId: "custom-memory-id", + }); + + expect(mockInsertMemories).toHaveBeenCalledWith({ + id: "custom-memory-id", + room_id: "existing-room", + content: promptMessage, + }); + }); + + it("persists message for both new and existing rooms", async () => { + const promptMessage = createUIMessage("Hello"); + + // New room + await setupConversation({ + accountId: "account-123", + promptMessage, + }); + expect(mockInsertMemories).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + // Existing room + await setupConversation({ + accountId: "account-123", + roomId: "existing-room", + promptMessage, + }); + expect(mockInsertMemories).toHaveBeenCalledTimes(1); + }); + }); + + describe("return values", () => { + it("returns generated roomId when not provided", async () => { + mockGenerateUUID.mockReturnValue("new-room-uuid"); + const promptMessage = createUIMessage("Hello"); + + const result = await setupConversation({ + accountId: "account-123", + promptMessage, + }); + + expect(result.roomId).toBe("new-room-uuid"); + }); + + it("returns provided roomId when given", async () => { + const promptMessage = createUIMessage("Hello"); + + const result = await setupConversation({ + accountId: "account-123", + roomId: "provided-room-id", + promptMessage, + }); + + expect(result.roomId).toBe("provided-room-id"); + }); + + it("returns generated memoryId when not provided", async () => { + mockGenerateUUID.mockReturnValue("new-memory-uuid"); + const promptMessage = createUIMessage("Hello"); + + const result = await setupConversation({ + accountId: "account-123", + roomId: "existing-room", + promptMessage, + }); + + expect(result.memoryId).toBe("new-memory-uuid"); + }); + + it("returns provided memoryId when given", async () => { + const promptMessage = createUIMessage("Hello"); + + const result = await setupConversation({ + accountId: "account-123", + roomId: "existing-room", + promptMessage, + memoryId: "custom-memory-id", + }); + + expect(result.memoryId).toBe("custom-memory-id"); + }); + }); + + describe("error handling", () => { + it("propagates insertMemories errors (for duplicate handling by caller)", async () => { + const duplicateError = { code: "23505", message: "unique constraint violation" }; + mockInsertMemories.mockRejectedValue(duplicateError); + const promptMessage = createUIMessage("Hello"); + + await expect( + setupConversation({ + accountId: "account-123", + promptMessage, + }), + ).rejects.toEqual(duplicateError); + }); + }); +}); diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index b15ebf4..29f704e 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -23,17 +23,45 @@ vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ validateOrganizationAccess: vi.fn(), })); +vi.mock("@/lib/uuid/generateUUID", () => { + const mockFn = vi.fn(() => "mock-uuid-default"); + return { + generateUUID: mockFn, + default: mockFn, + }; +}); + +vi.mock("@/lib/chat/createNewRoom", () => ({ + createNewRoom: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/insertMemories", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ + default: vi.fn((msg: unknown) => msg), +})); + import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); const mockGetApiKeyDetails = vi.mocked(getApiKeyDetails); const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); +const mockGenerateUUID = vi.mocked(generateUUID); +const mockCreateNewRoom = vi.mocked(createNewRoom); +const mockInsertMemories = vi.mocked(insertMemories); +const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories); // Helper to create mock NextRequest function createMockRequest(body: unknown, headers: Record = {}): Request { @@ -546,4 +574,165 @@ describe("validateChatRequest", () => { expect(mockValidateOrganizationAccess).not.toHaveBeenCalled(); }); }); + + describe("auto room creation", () => { + it("generates a new roomId when roomId is not provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("generated-uuid-456"); + mockCreateNewRoom.mockResolvedValue(undefined); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "test-key" }); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("generated-uuid-456"); + expect(mockGenerateUUID).toHaveBeenCalled(); + }); + + it("creates a new room when roomId is not provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("generated-uuid-789"); + mockCreateNewRoom.mockResolvedValue(undefined); + + const request = createMockRequest({ prompt: "Create a new room" }, { "x-api-key": "test-key" }); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(mockCreateNewRoom).toHaveBeenCalledWith({ + accountId: "account-123", + roomId: "generated-uuid-789", + artistId: undefined, + lastMessage: expect.objectContaining({ + role: "user", + parts: expect.arrayContaining([expect.objectContaining({ text: "Create a new room" })]), + }), + }); + }); + + it("creates a new room with artistId when provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("generated-uuid-abc"); + mockCreateNewRoom.mockResolvedValue(undefined); + + const request = createMockRequest( + { prompt: "Hello", artistId: "artist-xyz" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(mockCreateNewRoom).toHaveBeenCalledWith({ + accountId: "account-123", + roomId: "generated-uuid-abc", + artistId: "artist-xyz", + lastMessage: expect.any(Object), + }); + }); + + it("does NOT generate a new roomId when roomId is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", roomId: "existing-room-123" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("existing-room-123"); + // Note: generateUUID may be called by getMessages for message IDs, but not for roomId + expect(mockCreateNewRoom).not.toHaveBeenCalled(); + }); + + it("does NOT create a room when roomId is already provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", roomId: "existing-room-456" }, + { "x-api-key": "test-key" }, + ); + + await validateChatRequest(request as any); + + expect(mockCreateNewRoom).not.toHaveBeenCalled(); + }); + + it("works with bearer token auth for auto room creation", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("jwt-account-123"); + mockGenerateUUID.mockReturnValue("jwt-generated-uuid"); + mockCreateNewRoom.mockResolvedValue(undefined); + + const request = createMockRequest( + { prompt: "Hello from JWT" }, + { authorization: "Bearer valid-jwt" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("jwt-generated-uuid"); + expect(mockCreateNewRoom).toHaveBeenCalledWith({ + accountId: "jwt-account-123", + roomId: "jwt-generated-uuid", + artistId: undefined, + lastMessage: expect.any(Object), + }); + }); + + it("persists user message to memories when roomId is auto-created", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("new-room-uuid"); + mockCreateNewRoom.mockResolvedValue(undefined); + mockInsertMemories.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "This is my first message" }, + { "x-api-key": "test-key" }, + ); + + await validateChatRequest(request as any); + + expect(mockInsertMemories).toHaveBeenCalledWith({ + id: "new-room-uuid", + room_id: "new-room-uuid", + content: expect.objectContaining({ + role: "user", + parts: expect.arrayContaining([ + expect.objectContaining({ text: "This is my first message" }), + ]), + }), + }); + }); + + it("persists user message to memories for existing rooms (match /api/emails/inbound behavior)", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGenerateUUID.mockReturnValue("memory-uuid"); + mockInsertMemories.mockResolvedValue(null); + + const request = createMockRequest( + { prompt: "Hello to existing room", roomId: "existing-room-id" }, + { "x-api-key": "test-key" }, + ); + + await validateChatRequest(request as any); + + // User message should be persisted for ALL requests (matching email flow) + expect(mockInsertMemories).toHaveBeenCalledWith({ + id: "memory-uuid", + room_id: "existing-room-id", + content: expect.objectContaining({ + role: "user", + parts: expect.arrayContaining([ + expect.objectContaining({ text: "Hello to existing room" }), + ]), + }), + }); + // Room should NOT be created for existing rooms + expect(mockCreateNewRoom).not.toHaveBeenCalled(); + }); + }); }); diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts index d708bcf..ab3d678 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,12 +30,22 @@ 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/chat/setupConversation.ts b/lib/chat/setupConversation.ts new file mode 100644 index 0000000..4814562 --- /dev/null +++ b/lib/chat/setupConversation.ts @@ -0,0 +1,65 @@ +import { UIMessage } from "ai"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; + +interface SetupConversationParams { + accountId: string; + roomId?: string; + promptMessage: UIMessage; + artistId?: string; + memoryId?: string; +} + +interface SetupConversationResult { + roomId: string; + memoryId: string; +} + +/** + * Sets up a conversation by creating a room (if needed) and persisting the user message. + * + * This utility encapsulates the common logic shared between: + * - validateChatRequest.ts (chat API flow) + * - validateNewEmailMemory.ts (email inbound flow) + * + * @param root0 - The setup conversation parameters + * @param root0.accountId - The account ID for the conversation + * @param root0.roomId - Optional existing room ID. If not provided, a new room is created. + * @param root0.promptMessage - The user's message in UIMessage format + * @param root0.artistId - Optional artist ID for the room + * @param root0.memoryId - Optional memory ID. If not provided, a UUID is generated. + * (Email flow uses emailId for deduplication) + * @returns The final roomId and memoryId used + * @throws Propagates insertMemories errors (caller can handle duplicates via error code 23505) + */ +export async function setupConversation({ + accountId, + roomId, + promptMessage, + artistId, + memoryId, +}: SetupConversationParams): Promise { + const finalRoomId = roomId || generateUUID(); + const finalMemoryId = memoryId || generateUUID(); + + // Create room if roomId was not provided + if (!roomId) { + await createNewRoom({ + accountId, + roomId: finalRoomId, + artistId, + lastMessage: promptMessage, + }); + } + + // Persist user message to memories + await insertMemories({ + id: finalMemoryId, + room_id: finalRoomId, + content: filterMessageContentForMemories(promptMessage), + }); + + return { roomId: finalRoomId, memoryId: finalMemoryId }; +} diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 5805dba..c1a7ec6 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -6,8 +6,11 @@ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { getMessages } from "@/lib/messages/getMessages"; +import convertToUiMessages from "@/lib/messages/convertToUiMessages"; import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { setupConversation } from "@/lib/chat/setupConversation"; +import { validateMessages } from "@/lib/chat/validateMessages"; export const chatRequestSchema = z .object({ @@ -165,8 +168,8 @@ export async function validateChatRequest( } // Normalize chat content: - // - If messages are provided, keep them as-is // - If only prompt is provided, convert it into a single user UIMessage + // - Convert all messages to UIMessage format (handles mixed formats) const hasMessages = Array.isArray(validatedBody.messages) && validatedBody.messages.length > 0; const hasPrompt = typeof validatedBody.prompt === "string" && validatedBody.prompt.trim().length > 0; @@ -175,9 +178,24 @@ export async function validateChatRequest( validatedBody.messages = getMessages(validatedBody.prompt); } + // Convert messages to UIMessage format and get the last (newest) message + const uiMessages = convertToUiMessages(validatedBody.messages); + const { lastMessage } = validateMessages(uiMessages); + + // Setup conversation: auto-create room if needed and persist user message + // Use the message's original ID to prevent duplicates with handleChatCompletion's upsert + const { roomId: finalRoomId } = await setupConversation({ + accountId, + roomId: validatedBody.roomId, + promptMessage: lastMessage, + artistId: validatedBody.artistId, + memoryId: lastMessage.id, + }); + return { ...validatedBody, accountId, orgId, + roomId: finalRoomId, } as ChatRequestBody; } 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; diff --git a/lib/emails/inbound/validateNewEmailMemory.ts b/lib/emails/inbound/validateNewEmailMemory.ts index ed16681..c07437f 100644 --- a/lib/emails/inbound/validateNewEmailMemory.ts +++ b/lib/emails/inbound/validateNewEmailMemory.ts @@ -5,10 +5,7 @@ import { getMessages } from "@/lib/messages/getMessages"; import { getEmailContent } from "@/lib/emails/inbound/getEmailContent"; import { getEmailRoomId } from "@/lib/emails/inbound/getEmailRoomId"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; -import insertMemories from "@/lib/supabase/memories/insertMemories"; -import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; -import { createNewRoom } from "@/lib/chat/createNewRoom"; -import { generateUUID } from "@/lib/uuid/generateUUID"; +import { setupConversation } from "@/lib/chat/setupConversation"; import insertMemoryEmail from "@/lib/supabase/memory_emails/insertMemoryEmail"; import { trimRepliedContext } from "@/lib/emails/inbound/trimRepliedContext"; @@ -34,25 +31,19 @@ export async function validateNewEmailMemory( const emailText = trimRepliedContext(emailContent.html || ""); const roomId = await getEmailRoomId(emailContent); - const finalRoomId = roomId || generateUUID(); const promptMessage = getMessages(emailText)[0]; - if (!roomId) { - await createNewRoom({ - accountId, - roomId: finalRoomId, - artistId: undefined, - lastMessage: promptMessage, - }); - } - // Insert the prompt message with emailId as the id to prevent duplicate processing - // If this email was already processed, the insert will fail with a unique constraint violation + // Setup conversation: auto-create room if needed and persist user message + // Uses emailId as memoryId for deduplication - duplicate inserts will fail with unique constraint + let finalRoomId: string; try { - await insertMemories({ - id: emailId, - room_id: finalRoomId, - content: filterMessageContentForMemories(promptMessage), + const result = await setupConversation({ + accountId, + roomId, + promptMessage, + memoryId: emailId, }); + finalRoomId = result.roomId; } catch (error: unknown) { // If duplicate (unique constraint violation), return early to prevent duplicate response if (error && typeof error === "object" && "code" in error && error.code === "23505") { diff --git a/lib/messages/__tests__/convertToUiMessages.test.ts b/lib/messages/__tests__/convertToUiMessages.test.ts new file mode 100644 index 0000000..7528006 --- /dev/null +++ b/lib/messages/__tests__/convertToUiMessages.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock generateUUID before importing the module +vi.mock("@/lib/uuid/generateUUID", () => ({ + default: vi.fn(() => "generated-uuid"), + generateUUID: vi.fn(() => "generated-uuid"), +})); + +import convertToUiMessages from "../convertToUiMessages"; + +describe("convertToUiMessages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("passthrough for UIMessage format", () => { + it("returns messages unchanged when already in UIMessage format", () => { + const messages = [ + { + id: "msg-1", + role: "user" as const, + parts: [{ type: "text" as const, text: "Hello" }], + }, + { + id: "msg-2", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "Hi there!" }], + }, + ]; + + const result = convertToUiMessages(messages); + + expect(result).toEqual(messages); + expect(result[0].id).toBe("msg-1"); + expect(result[1].id).toBe("msg-2"); + }); + + it("preserves existing message IDs", () => { + const messages = [ + { + id: "custom-id-123", + role: "user" as const, + parts: [{ type: "text" as const, text: "Test" }], + }, + ]; + + const result = convertToUiMessages(messages); + + expect(result[0].id).toBe("custom-id-123"); + }); + }); + + describe("conversion from simple format", () => { + it("converts { role, content } format to UIMessage format", () => { + const messages = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ]; + + const result = convertToUiMessages(messages); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: "generated-uuid", + role: "user", + parts: [{ type: "text", text: "Hello" }], + }); + expect(result[1]).toEqual({ + id: "generated-uuid", + role: "assistant", + parts: [{ type: "text", text: "Hi there!" }], + }); + }); + + it("generates UUIDs for ModelMessage format (ModelMessage has no id field)", () => { + const messages = [{ role: "user", content: "Hello" }]; + + const result = convertToUiMessages(messages); + + expect(result[0].id).toBe("generated-uuid"); + }); + }); + + describe("mixed formats", () => { + it("handles mixed UIMessage and simple formats", () => { + const messages = [ + { + id: "ui-msg-1", + role: "user" as const, + parts: [{ type: "text" as const, text: "First message" }], + }, + { role: "assistant", content: "Second message" }, + { + id: "ui-msg-3", + role: "user" as const, + parts: [{ type: "text" as const, text: "Third message" }], + }, + ]; + + const result = convertToUiMessages(messages); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe("ui-msg-1"); + expect(result[0].parts[0].text).toBe("First message"); + expect(result[1].id).toBe("generated-uuid"); + expect(result[1].parts[0].text).toBe("Second message"); + expect(result[2].id).toBe("ui-msg-3"); + expect(result[2].parts[0].text).toBe("Third message"); + }); + }); + + describe("edge cases", () => { + it("returns empty array for empty input", () => { + const result = convertToUiMessages([]); + + expect(result).toEqual([]); + }); + + it("handles system role messages", () => { + const messages = [{ role: "system", content: "You are a helpful assistant" }]; + + const result = convertToUiMessages(messages); + + expect(result[0].role).toBe("system"); + expect(result[0].parts[0].text).toBe("You are a helpful assistant"); + }); + + it("handles messages with empty content", () => { + const messages = [{ role: "user", content: "" }]; + + const result = convertToUiMessages(messages); + + expect(result[0].parts[0].text).toBe(""); + }); + + it("handles ModelMessage with content parts array", () => { + const messages = [ + { + role: "user", + content: [ + { type: "text" as const, text: "Hello " }, + { type: "text" as const, text: "world!" }, + ], + }, + ]; + + const result = convertToUiMessages(messages as any); + + expect(result[0].parts[0].text).toBe("Hello world!"); + }); + }); +}); diff --git a/lib/messages/__tests__/getTextContent.test.ts b/lib/messages/__tests__/getTextContent.test.ts new file mode 100644 index 0000000..9820e6a --- /dev/null +++ b/lib/messages/__tests__/getTextContent.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import getTextContent from "../getTextContent"; + +describe("getTextContent", () => { + describe("string content", () => { + it("returns string content as-is", () => { + expect(getTextContent("Hello world")).toBe("Hello world"); + }); + + it("handles empty string", () => { + expect(getTextContent("")).toBe(""); + }); + }); + + describe("content parts array", () => { + it("extracts text from single text part", () => { + const content = [{ type: "text" as const, text: "Hello" }]; + expect(getTextContent(content)).toBe("Hello"); + }); + + it("joins multiple text parts", () => { + const content = [ + { type: "text" as const, text: "Hello " }, + { type: "text" as const, text: "world!" }, + ]; + expect(getTextContent(content)).toBe("Hello world!"); + }); + + it("filters out non-text parts", () => { + const content = [ + { type: "text" as const, text: "Hello" }, + { type: "image" as const, image: "data:image/png;base64,..." }, + { type: "text" as const, text: " world" }, + ] as any; + expect(getTextContent(content)).toBe("Hello world"); + }); + + it("returns empty string for empty array", () => { + expect(getTextContent([])).toBe(""); + }); + + it("returns empty string when no text parts exist", () => { + const content = [ + { type: "image" as const, image: "data:image/png;base64,..." }, + ] as any; + expect(getTextContent(content)).toBe(""); + }); + }); +}); diff --git a/lib/messages/__tests__/isUiMessage.test.ts b/lib/messages/__tests__/isUiMessage.test.ts new file mode 100644 index 0000000..222e60d --- /dev/null +++ b/lib/messages/__tests__/isUiMessage.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import isUiMessage from "../isUiMessage"; + +describe("isUiMessage", () => { + describe("returns true for UIMessage format", () => { + it("identifies message with parts array as UIMessage", () => { + const message = { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + }; + + expect(isUiMessage(message)).toBe(true); + }); + + it("identifies message with empty parts array as UIMessage", () => { + const message = { + id: "msg-1", + role: "user", + parts: [], + }; + + expect(isUiMessage(message)).toBe(true); + }); + + it("identifies assistant message as UIMessage", () => { + const message = { + id: "msg-2", + role: "assistant", + parts: [{ type: "text", text: "Hi there!" }], + }; + + expect(isUiMessage(message)).toBe(true); + }); + }); + + describe("returns false for simple format", () => { + it("identifies message with content string as non-UIMessage", () => { + const message = { + role: "user", + content: "Hello", + }; + + expect(isUiMessage(message)).toBe(false); + }); + + it("identifies message with id and content as non-UIMessage", () => { + const message = { + id: "msg-1", + role: "user", + content: "Hello", + }; + + expect(isUiMessage(message)).toBe(false); + }); + }); + + describe("handles edge cases", () => { + it("returns false for null", () => { + expect(isUiMessage(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isUiMessage(undefined)).toBe(false); + }); + + it("returns false for primitive values", () => { + expect(isUiMessage("string")).toBe(false); + expect(isUiMessage(123)).toBe(false); + expect(isUiMessage(true)).toBe(false); + }); + + it("returns false for empty object", () => { + expect(isUiMessage({})).toBe(false); + }); + + it("returns false when parts is not an array", () => { + const message = { + id: "msg-1", + role: "user", + parts: "not an array", + }; + + expect(isUiMessage(message)).toBe(false); + }); + }); +}); diff --git a/lib/messages/convertToUiMessages.ts b/lib/messages/convertToUiMessages.ts new file mode 100644 index 0000000..7318f23 --- /dev/null +++ b/lib/messages/convertToUiMessages.ts @@ -0,0 +1,40 @@ +import { UIMessage, ModelMessage } from "ai"; +import generateUUID from "@/lib/uuid/generateUUID"; +import isUiMessage from "@/lib/messages/isUiMessage"; +import getTextContent from "@/lib/messages/getTextContent"; + +/** + * Input message that can be either UIMessage or ModelMessage format. + */ +type InputMessage = UIMessage | ModelMessage; + +/** + * Converts messages to UIMessage format. + * + * Similar to AI SDK's convertToModelMessages, this utility normalizes + * messages from various formats into the standard UIMessage format. + * + * Handles: + * - UIMessage format (with parts array) - passed through unchanged + * - ModelMessage format ({ role, content }) - converted to UIMessage + * - Mixed arrays of both formats + * + * @param messages - Array of messages in any supported format + * @returns Array of messages in UIMessage format + */ +export default function convertToUiMessages(messages: InputMessage[]): UIMessage[] { + return messages.map((message) => { + if (isUiMessage(message)) { + return message; + } + + // Convert ModelMessage { role, content } format to UIMessage + const modelMessage = message as ModelMessage; + + return { + id: generateUUID(), + role: modelMessage.role as "user" | "assistant" | "system", + parts: [{ type: "text" as const, text: getTextContent(modelMessage.content) }], + }; + }); +} diff --git a/lib/messages/getTextContent.ts b/lib/messages/getTextContent.ts new file mode 100644 index 0000000..8f54bd1 --- /dev/null +++ b/lib/messages/getTextContent.ts @@ -0,0 +1,18 @@ +import { ModelMessage } from "ai"; + +/** + * Extracts text from ModelMessage content (handles string or content parts array). + * + * @param content - The content field from ModelMessage + * @returns The extracted text string + */ +export default function getTextContent(content: ModelMessage["content"]): string { + if (typeof content === "string") { + return content; + } + // Content is an array of parts - extract and join text parts + return content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join(""); +} diff --git a/lib/messages/isUiMessage.ts b/lib/messages/isUiMessage.ts new file mode 100644 index 0000000..ca3925e --- /dev/null +++ b/lib/messages/isUiMessage.ts @@ -0,0 +1,19 @@ +import { UIMessage } from "ai"; + +/** + * Type guard to check if a message is in UIMessage format. + * + * UIMessage format has a `parts` array containing message content, + * while simple format uses a `content` string directly. + * + * @param message - The message to check + * @returns True if the message has a parts array (UIMessage format) + */ +export default function isUiMessage(message: unknown): message is UIMessage { + return ( + typeof message === "object" && + message !== null && + "parts" in message && + Array.isArray((message as UIMessage).parts) + ); +}