From b454517d3c4a7e755e5347c920c6249f8ec4ac85 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 18:01:56 -0500 Subject: [PATCH 01/19] feat: migrate ChatConfig interface from Recoup-Chat to recoup-api Add ChatConfig interface that extends RoutingDecision with: - system prompt, messages, and tools - experimental_generateMessageId and experimental_download functions - prepareStep function and providerOptions Add 10 unit tests for the chat types. Co-Authored-By: Claude Opus 4.5 --- lib/chat/__tests__/types.test.ts | 142 +++++++++++++++++++++++++++++++ lib/chat/types.ts | 20 ++++- 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 lib/chat/__tests__/types.test.ts diff --git a/lib/chat/__tests__/types.test.ts b/lib/chat/__tests__/types.test.ts new file mode 100644 index 00000000..0cb89a5e --- /dev/null +++ b/lib/chat/__tests__/types.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { stepCountIs, ToolLoopAgent } from "ai"; +import type { ChatConfig, RoutingDecision } from "../types"; + +describe("Chat Types", () => { + describe("RoutingDecision", () => { + it("should have required model property", () => { + const decision: RoutingDecision = { + model: "gpt-4", + instructions: "Test instructions", + agent: new ToolLoopAgent({ model: "gpt-4" }), + }; + expect(decision.model).toBe("gpt-4"); + }); + + it("should have required instructions property", () => { + const decision: RoutingDecision = { + model: "gpt-4", + instructions: "Test instructions", + agent: new ToolLoopAgent({ model: "gpt-4" }), + }; + expect(decision.instructions).toBe("Test instructions"); + }); + + it("should have optional stopWhen property", () => { + const decision: RoutingDecision = { + model: "gpt-4", + instructions: "Test instructions", + agent: new ToolLoopAgent({ model: "gpt-4" }), + stopWhen: stepCountIs(5), + }; + expect(decision.stopWhen).toBeDefined(); + }); + }); + + describe("ChatConfig", () => { + it("should extend RoutingDecision", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "You are a helpful assistant", + messages: [], + experimental_generateMessageId: () => "test-id", + tools: {}, + }; + expect(config.model).toBe("gpt-4"); + expect(config.system).toBe("You are a helpful assistant"); + }); + + it("should have required system property", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "System prompt", + messages: [], + experimental_generateMessageId: () => "test-id", + tools: {}, + }; + expect(config.system).toBe("System prompt"); + }); + + it("should have required messages array", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "System prompt", + messages: [{ role: "user", content: "Hello" }], + experimental_generateMessageId: () => "test-id", + tools: {}, + }; + expect(config.messages).toHaveLength(1); + }); + + it("should have required experimental_generateMessageId function", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "System prompt", + messages: [], + experimental_generateMessageId: () => "generated-id-123", + tools: {}, + }; + expect(config.experimental_generateMessageId()).toBe("generated-id-123"); + }); + + it("should have optional experimental_download function", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "System prompt", + messages: [], + experimental_generateMessageId: () => "test-id", + tools: {}, + experimental_download: async () => [], + }; + expect(config.experimental_download).toBeDefined(); + }); + + it("should have optional prepareStep function", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "System prompt", + messages: [], + experimental_generateMessageId: () => "test-id", + tools: {}, + prepareStep: (options) => options, + }; + expect(config.prepareStep).toBeDefined(); + }); + + it("should have optional providerOptions property", () => { + const agent = new ToolLoopAgent({ model: "gpt-4" }); + const config: ChatConfig = { + model: "gpt-4", + instructions: "Test instructions", + agent, + system: "System prompt", + messages: [], + experimental_generateMessageId: () => "test-id", + tools: {}, + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } }, + }, + }; + expect(config.providerOptions).toBeDefined(); + expect(config.providerOptions?.anthropic).toBeDefined(); + }); + }); +}); diff --git a/lib/chat/types.ts b/lib/chat/types.ts index 4e20e74b..bf277cc0 100644 --- a/lib/chat/types.ts +++ b/lib/chat/types.ts @@ -1,5 +1,11 @@ import { VercelToolCollection } from "@composio/vercel"; -import { type ToolSet, type StopCondition, type ToolLoopAgent } from "ai"; +import { + type ModelMessage, + type ToolSet, + type StopCondition, + type PrepareStepFunction, + type ToolLoopAgent, +} from "ai"; export interface RoutingDecision { model: string; @@ -7,3 +13,15 @@ export interface RoutingDecision { agent: ToolLoopAgent; stopWhen?: StopCondition> | StopCondition>[] | undefined; } + +export interface ChatConfig extends RoutingDecision { + system: string; + messages: ModelMessage[]; + experimental_generateMessageId: () => string; + experimental_download?: ( + files: Array<{ url: URL; isUrlSupportedByModel: boolean }> + ) => Promise>; + tools: ToolSet; + prepareStep?: PrepareStepFunction; + providerOptions?: Record; +} From 8385ca5fc946bc2cc31289ce851bbce3f4e6f069 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 18:06:42 -0500 Subject: [PATCH 02/19] feat: migrate validateChatRequest function from Recoup-Chat Add validateChatRequest function to recoup-api that: - Validates request body using Zod schema (prompt/messages mutual exclusivity) - Authenticates via API key using getApiKeyAccountId - Supports accountId override for org API keys via validateOverrideAccountId - Normalizes prompt to messages array using getMessages Includes 18 unit tests covering schema validation, authentication, accountId override, message normalization, and optional fields. Part of MYC-3520 API chat endpoint migration. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateChatRequest.test.ts | 313 ++++++++++++++++++ lib/chat/validateChatRequest.ts | 80 +++++ 2 files changed, 393 insertions(+) create mode 100644 lib/chat/__tests__/validateChatRequest.test.ts diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts new file mode 100644 index 00000000..1d68edae --- /dev/null +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateChatRequest, chatRequestSchema } from "../validateChatRequest"; + +// Mock dependencies +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), +})); + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); + +// Helper to create mock NextRequest +function createMockRequest(body: unknown, headers: Record = {}): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + has: (key: string) => key.toLowerCase() in headers, + }, + } as unknown as Request; +} + +describe("validateChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("schema validation", () => { + it("rejects when neither messages nor prompt is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { roomId: "room-123" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("rejects when both messages and prompt are provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + messages: [{ role: "user", content: "Hello" }], + prompt: "Hello", + }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("accepts valid messages array", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { messages: [{ role: "user", content: "Hello" }] }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + + it("accepts valid prompt string", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello, world!" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + }); + + describe("authentication", () => { + it("rejects request without x-api-key header", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "x-api-key header required" }, + { status: 401 }, + ), + ); + + const request = createMockRequest({ prompt: "Hello" }, {}); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + }); + + it("rejects request with invalid API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Invalid API key" }, + { status: 401 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "invalid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("uses accountId from valid API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-abc-123"); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-abc-123"); + }); + }); + + describe("accountId override", () => { + it("allows org API key to override accountId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockValidateOverrideAccountId.mockResolvedValue({ + accountId: "target-account-456", + }); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "org-api-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("target-account-456"); + expect(mockValidateOverrideAccountId).toHaveBeenCalledWith({ + apiKey: "org-api-key", + targetAccountId: "target-account-456", + }); + }); + + it("rejects unauthorized accountId override", async () => { + mockGetApiKeyAccountId.mockResolvedValue("personal-account-123"); + mockValidateOverrideAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Access denied to specified accountId" }, + { status: 403 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "personal-api-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified accountId"); + }); + }); + + describe("message normalization", () => { + it("converts prompt to messages array", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello, world!" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toHaveLength(1); + expect((result as any).messages[0].role).toBe("user"); + expect((result as any).messages[0].parts[0].text).toBe("Hello, world!"); + }); + + it("preserves original messages when provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const originalMessages = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello!" }, + ]; + const request = createMockRequest( + { messages: originalMessages }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toEqual(originalMessages); + }); + }); + + describe("optional fields", () => { + it("passes through roomId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", roomId: "room-xyz" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("room-xyz"); + }); + + it("passes through artistId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", artistId: "artist-abc" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).artistId).toBe("artist-abc"); + }); + + it("passes through model selection", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", model: "gpt-4" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).model).toBe("gpt-4"); + }); + + it("passes through excludeTools array", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", excludeTools: ["tool1", "tool2"] }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).excludeTools).toEqual(["tool1", "tool2"]); + }); + }); + + describe("chatRequestSchema", () => { + it("exports the schema for external validation", () => { + expect(chatRequestSchema).toBeDefined(); + const result = chatRequestSchema.safeParse({ prompt: "test" }); + expect(result.success).toBe(true); + }); + + it("schema validates messages array type", () => { + const result = chatRequestSchema.safeParse({ + messages: [{ role: "user", content: "test" }], + }); + expect(result.success).toBe(true); + }); + + it("schema enforces mutual exclusivity", () => { + const result = chatRequestSchema.safeParse({ + messages: [{ role: "user", content: "test" }], + prompt: "test", + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index fbfb6aaf..4a1e0324 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -1,4 +1,10 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getMessages } from "@/lib/messages/getMessages"; export const chatRequestSchema = z .object({ @@ -35,3 +41,77 @@ type BaseChatRequestBody = z.infer; export type ChatRequestBody = BaseChatRequestBody & { accountId: string; }; + +/** + * Validates chat request body and auth headers. + * + * Returns: + * - Response (400/401/403/500) when invalid (body or headers) + * - Parsed & augmented body when valid (including header-derived accountId) + * + * @param request - The NextRequest object + * @returns A NextResponse with an error or validated ChatRequestBody + */ +export async function validateChatRequest( + request: NextRequest, +): Promise { + const json = await request.json(); + const validationResult = chatRequestSchema.safeParse(json); + + if (!validationResult.success) { + return NextResponse.json( + { + status: "error", + message: "Invalid input", + errors: validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })), + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const validatedBody: BaseChatRequestBody = validationResult.data; + + // Validate API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + let accountId = accountIdOrError; + + // Handle accountId override for org API keys + if (validatedBody.accountId) { + const overrideResult = await validateOverrideAccountId({ + apiKey: request.headers.get("x-api-key"), + targetAccountId: validatedBody.accountId, + }); + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + accountId = overrideResult.accountId; + } + + // Normalize chat content: + // - If messages are provided, keep them as-is + // - If only prompt is provided, convert it into a single user UIMessage + const hasMessages = + Array.isArray(validatedBody.messages) && validatedBody.messages.length > 0; + const hasPrompt = + typeof validatedBody.prompt === "string" && + validatedBody.prompt.trim().length > 0; + + if (!hasMessages && hasPrompt) { + validatedBody.messages = getMessages(validatedBody.prompt); + } + + return { + ...validatedBody, + accountId, + } as ChatRequestBody; +} From ffe29f5ed5f0ac6954250905edaa8628fd6d4330 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 18:12:29 -0500 Subject: [PATCH 03/19] feat: migrate setupChatRequest function from Recoup-Chat Add setupChatRequest that prepares chat configuration: - Calls getGeneralAgent for routing decision - Converts messages with MAX_MESSAGES limit (55) - Configures provider options for Anthropic/Google/OpenAI - Includes experimental_generateMessageId Co-Authored-By: Claude Opus 4.5 --- lib/chat/__tests__/setupChatRequest.test.ts | 256 ++++++++++++++++++++ lib/chat/const.ts | 2 + lib/chat/setupChatRequest.ts | 63 +++++ 3 files changed, 321 insertions(+) create mode 100644 lib/chat/__tests__/setupChatRequest.test.ts create mode 100644 lib/chat/setupChatRequest.ts diff --git a/lib/chat/__tests__/setupChatRequest.test.ts b/lib/chat/__tests__/setupChatRequest.test.ts new file mode 100644 index 00000000..991b0c96 --- /dev/null +++ b/lib/chat/__tests__/setupChatRequest.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatRequestBody } from "../validateChatRequest"; + +// Mock dependencies +vi.mock("@/lib/agents/generalAgent/getGeneralAgent", () => ({ + default: vi.fn(), +})); + +vi.mock("ai", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + convertToModelMessages: vi.fn((messages) => messages), + stepCountIs: actual.stepCountIs, + ToolLoopAgent: actual.ToolLoopAgent, + }; +}); + +import { setupChatRequest } from "../setupChatRequest"; +import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; +import { convertToModelMessages } from "ai"; + +const mockGetGeneralAgent = vi.mocked(getGeneralAgent); +const mockConvertToModelMessages = vi.mocked(convertToModelMessages); + +describe("setupChatRequest", () => { + const mockRoutingDecision = { + model: "claude-sonnet-4-20250514", + instructions: "You are a helpful assistant", + agent: { + model: "claude-sonnet-4-20250514", + tools: {}, + instructions: "You are a helpful assistant", + stopWhen: undefined, + }, + stopWhen: undefined, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetGeneralAgent.mockResolvedValue(mockRoutingDecision as any); + mockConvertToModelMessages.mockImplementation((messages) => messages as any); + }); + + describe("basic functionality", () => { + it("returns a ChatConfig object with all required properties", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result).toHaveProperty("system"); + expect(result).toHaveProperty("messages"); + expect(result).toHaveProperty("experimental_generateMessageId"); + expect(result).toHaveProperty("tools"); + expect(result).toHaveProperty("providerOptions"); + }); + + it("calls getGeneralAgent with the body", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await setupChatRequest(body); + + expect(mockGetGeneralAgent).toHaveBeenCalledWith(body); + }); + + it("uses instructions from routing decision as system prompt", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.system).toBe(mockRoutingDecision.instructions); + }); + }); + + describe("message conversion", () => { + it("converts messages using convertToModelMessages", async () => { + const messages = [ + { id: "1", role: "user", content: "Hello" }, + { id: "2", role: "assistant", content: "Hi there!" }, + ]; + const body: ChatRequestBody = { + accountId: "account-123", + messages, + }; + + await setupChatRequest(body); + + expect(mockConvertToModelMessages).toHaveBeenCalledWith(messages, expect.any(Object)); + }); + + it("passes tools to convertToModelMessages", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await setupChatRequest(body); + + expect(mockConvertToModelMessages).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + tools: expect.any(Object), + ignoreIncompleteToolCalls: true, + }), + ); + }); + + it("limits messages to MAX_MESSAGES", async () => { + const manyMessages = Array.from({ length: 100 }, (_, i) => ({ + id: `${i}`, + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i}`, + })); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: manyMessages, + }; + + const result = await setupChatRequest(body); + + // Should be limited to MAX_MESSAGES (55) + expect(result.messages.length).toBeLessThanOrEqual(55); + }); + }); + + describe("experimental_generateMessageId", () => { + it("provides a function that generates unique UUIDs", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + const id1 = result.experimental_generateMessageId(); + const id2 = result.experimental_generateMessageId(); + + expect(typeof id1).toBe("string"); + expect(typeof id2).toBe("string"); + expect(id1).not.toBe(id2); + // UUID format validation + expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + }); + + describe("provider options", () => { + it("includes anthropic thinking configuration", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.providerOptions).toHaveProperty("anthropic"); + expect(result.providerOptions?.anthropic).toHaveProperty("thinking"); + }); + + it("includes google thinkingConfig", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.providerOptions).toHaveProperty("google"); + expect(result.providerOptions?.google).toHaveProperty("thinkingConfig"); + }); + + it("includes openai reasoning configuration", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.providerOptions).toHaveProperty("openai"); + expect(result.providerOptions?.openai).toHaveProperty("reasoningEffort"); + }); + }); + + describe("routing decision properties", () => { + it("spreads routing decision properties into result", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.model).toBe(mockRoutingDecision.model); + expect(result.instructions).toBe(mockRoutingDecision.instructions); + expect(result.agent).toBeDefined(); + }); + + it("includes tools from routing decision agent", async () => { + const mockTools = { tool1: {}, tool2: {} }; + mockGetGeneralAgent.mockResolvedValue({ + ...mockRoutingDecision, + agent: { + ...mockRoutingDecision.agent, + tools: mockTools, + }, + } as any); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.tools).toEqual(mockTools); + }); + }); + + describe("prepareStep function", () => { + it("includes a prepareStep function", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + expect(result.prepareStep).toBeInstanceOf(Function); + }); + + it("prepareStep returns options when no tool chain matches", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + const mockOptions = { steps: [], stepNumber: 0, model: "test-model", messages: [] }; + const prepareResult = result.prepareStep!(mockOptions as any); + + // Should return the original options when no tool chain matches + expect(prepareResult).toEqual(mockOptions); + }); + }); +}); diff --git a/lib/chat/const.ts b/lib/chat/const.ts index 804b35fb..8a8ed0c5 100644 --- a/lib/chat/const.ts +++ b/lib/chat/const.ts @@ -1,3 +1,5 @@ +export const MAX_MESSAGES = 55; + export const SYSTEM_PROMPT = `You are Recoup, a friendly, sharp, and strategic AI assistant specialized in the music industry. Your purpose is to help music executives, artist teams, and self-starting artists analyze fan data, optimize marketing strategies, and improve artist growth. βΈ» diff --git a/lib/chat/setupChatRequest.ts b/lib/chat/setupChatRequest.ts new file mode 100644 index 00000000..2b3c9d56 --- /dev/null +++ b/lib/chat/setupChatRequest.ts @@ -0,0 +1,63 @@ +import { convertToModelMessages } from "ai"; +import { AnthropicProviderOptions } from "@ai-sdk/anthropic"; +import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; +import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; +import generateUUID from "@/lib/uuid/generateUUID"; +import { MAX_MESSAGES } from "./const"; +import { type ChatConfig } from "./types"; +import { ChatRequestBody } from "./validateChatRequest"; +import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; + +/** + * Sets up and prepares the chat request configuration. + * + * This function: + * 1. Gets the routing decision from getGeneralAgent + * 2. Converts messages to model format + * 3. Sets up provider options for different AI models + * 4. Configures the prepareStep function for tool chain handling + * + * @param body - The validated chat request body + * @returns ChatConfig ready for use with AI SDK + */ +export async function setupChatRequest(body: ChatRequestBody): Promise { + const decision = await getGeneralAgent(body); + + const system = decision.instructions; + const tools = decision.agent.tools; + + const convertedMessages = convertToModelMessages(body.messages, { + tools, + ignoreIncompleteToolCalls: true, + }).slice(-MAX_MESSAGES); + + const config: ChatConfig = { + ...decision, + system, + messages: convertedMessages, + experimental_generateMessageId: generateUUID, + tools, + prepareStep: (options) => { + // TODO: Implement getPrepareStepResult from toolChains migration + // For now, return options unchanged (no tool chain routing) + return options; + }, + providerOptions: { + anthropic: { + thinking: { type: "enabled", budgetTokens: 12000 }, + } satisfies AnthropicProviderOptions, + google: { + thinkingConfig: { + thinkingBudget: 8192, + includeThoughts: true, + }, + } satisfies GoogleGenerativeAIProviderOptions, + openai: { + reasoningEffort: "medium", + reasoningSummary: "detailed", + } satisfies OpenAIResponsesProviderOptions, + }, + }; + + return config; +} From a2d36cb8a5b239497482f5916a6440dc52a6aadb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 18:17:50 -0500 Subject: [PATCH 04/19] feat: create /api/chat streaming endpoint Implements POST /api/chat with createUIMessageStream for real-time streaming responses using the existing chat infrastructure. - Add handleChatStream function for streaming chat processing - Add route handler with CORS support - Add 7 unit tests covering validation, streaming, and error handling Note: handleChatCredits and handleChatCompletion hooks are no-ops until those functions are migrated. Co-Authored-By: Claude Opus 4.5 --- app/api/chat/route.ts | 40 +++ lib/chat/__tests__/handleChatStream.test.ts | 290 ++++++++++++++++++++ lib/chat/handleChatStream.ts | 63 +++++ 3 files changed, 393 insertions(+) create mode 100644 app/api/chat/route.ts create mode 100644 lib/chat/__tests__/handleChatStream.test.ts create mode 100644 lib/chat/handleChatStream.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 00000000..644d7a5d --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,40 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatStream } from "@/lib/chat/handleChatStream"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/chat + * + * Streaming chat endpoint that processes messages and returns a streaming response. + * + * Authentication: x-api-key header required. + * The account ID is inferred from the API key. + * + * Request body: + * - messages: Array of chat messages (mutually exclusive with prompt) + * - prompt: String prompt (mutually exclusive with messages) + * - roomId: Optional UUID of the chat room + * - artistId: Optional UUID of the artist account + * - model: Optional model ID override + * - excludeTools: Optional array of tool names to exclude + * - accountId: Optional accountId override (requires org API key) + * + * @param request - The request object + * @returns A streaming response or error + */ +export async function POST(request: NextRequest): Promise { + return handleChatStream(request); +} diff --git a/lib/chat/__tests__/handleChatStream.test.ts b/lib/chat/__tests__/handleChatStream.test.ts new file mode 100644 index 00000000..83fc1c73 --- /dev/null +++ b/lib/chat/__tests__/handleChatStream.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextResponse } from "next/server"; + +// Mock all dependencies before importing the module under test +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), +})); + +vi.mock("@/lib/chat/setupChatRequest", () => ({ + setupChatRequest: vi.fn(), +})); + +vi.mock("ai", () => ({ + createUIMessageStream: vi.fn(), + createUIMessageStreamResponse: vi.fn(), +})); + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { setupChatRequest } from "@/lib/chat/setupChatRequest"; +import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { handleChatStream } from "../handleChatStream"; + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); +const mockSetupChatRequest = vi.mocked(setupChatRequest); +const mockCreateUIMessageStream = vi.mocked(createUIMessageStream); +const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse); + +// Helper to create mock NextRequest +function createMockRequest( + body: unknown, + headers: Record = {}, +): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + has: (key: string) => key.toLowerCase() in headers, + }, + } as unknown as Request; +} + +describe("handleChatStream", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("validation", () => { + it("returns 400 error when neither messages nor prompt is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { roomId: "room-123" }, + { "x-api-key": "test-key" }, + ); + + const result = await handleChatStream(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(400); + const json = await result.json(); + expect(json.status).toBe("error"); + }); + + it("returns 401 error when x-api-key header is missing", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "x-api-key header required" }, + { status: 401 }, + ), + ); + + const request = createMockRequest({ prompt: "Hello" }, {}); + + const result = await handleChatStream(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(401); + }); + }); + + describe("streaming", () => { + it("creates a streaming response for valid requests", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const mockAgent = { + stream: vi.fn().mockResolvedValue({ + toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + }), + tools: {}, + }; + + mockSetupChatRequest.mockResolvedValue({ + agent: mockAgent, + model: "gpt-4", + instructions: "You are a helpful assistant", + system: "You are a helpful assistant", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + const mockStream = new ReadableStream(); + mockCreateUIMessageStream.mockReturnValue(mockStream); + + const mockResponse = new Response(mockStream); + mockCreateUIMessageStreamResponse.mockReturnValue(mockResponse); + + const request = createMockRequest( + { prompt: "Hello, world!" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatStream(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalled(); + expect(mockCreateUIMessageStream).toHaveBeenCalled(); + expect(mockCreateUIMessageStreamResponse).toHaveBeenCalledWith({ + stream: mockStream, + }); + expect(result).toBe(mockResponse); + }); + + it("uses messages array when provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const mockAgent = { + stream: vi.fn().mockResolvedValue({ + toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + }), + tools: {}, + }; + + mockSetupChatRequest.mockResolvedValue({ + agent: mockAgent, + model: "gpt-4", + instructions: "You are a helpful assistant", + system: "You are a helpful assistant", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + const mockStream = new ReadableStream(); + mockCreateUIMessageStream.mockReturnValue(mockStream); + mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); + + const messages = [{ role: "user", content: "Hello" }]; + const request = createMockRequest( + { messages }, + { "x-api-key": "valid-key" }, + ); + + await handleChatStream(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalledWith( + expect.objectContaining({ + messages, + accountId: "account-123", + }), + ); + }); + + it("passes through optional parameters", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const mockAgent = { + stream: vi.fn().mockResolvedValue({ + toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + }), + tools: {}, + }; + + mockSetupChatRequest.mockResolvedValue({ + agent: mockAgent, + model: "claude-3-opus", + instructions: "You are a helpful assistant", + system: "You are a helpful assistant", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + const mockStream = new ReadableStream(); + mockCreateUIMessageStream.mockReturnValue(mockStream); + mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); + + const request = createMockRequest( + { + prompt: "Hello", + roomId: "room-xyz", + artistId: "artist-abc", + model: "claude-3-opus", + excludeTools: ["tool1"], + }, + { "x-api-key": "valid-key" }, + ); + + await handleChatStream(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "room-xyz", + artistId: "artist-abc", + model: "claude-3-opus", + excludeTools: ["tool1"], + }), + ); + }); + }); + + describe("error handling", () => { + it("returns 500 error when setupChatRequest fails", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatStream(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(500); + const json = await result.json(); + expect(json.status).toBe("error"); + }); + }); + + describe("accountId override", () => { + it("allows org API key to override accountId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockValidateOverrideAccountId.mockResolvedValue({ + accountId: "target-account-456", + }); + + const mockAgent = { + stream: vi.fn().mockResolvedValue({ + toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + }), + tools: {}, + }; + + mockSetupChatRequest.mockResolvedValue({ + agent: mockAgent, + model: "gpt-4", + instructions: "test", + system: "test", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + } as any); + + const mockStream = new ReadableStream(); + mockCreateUIMessageStream.mockReturnValue(mockStream); + mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "org-api-key" }, + ); + + await handleChatStream(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "target-account-456", + }), + ); + }); + }); +}); diff --git a/lib/chat/handleChatStream.ts b/lib/chat/handleChatStream.ts new file mode 100644 index 00000000..396a66ec --- /dev/null +++ b/lib/chat/handleChatStream.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { validateChatRequest } from "./validateChatRequest"; +import { setupChatRequest } from "./setupChatRequest"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import generateUUID from "@/lib/uuid/generateUUID"; + +/** + * Handles a streaming chat request. + * + * This function: + * 1. Validates the request (auth, body schema) + * 2. Sets up the chat configuration (agent, model, tools) + * 3. Creates a streaming response using the AI SDK + * + * @param request - The incoming NextRequest + * @returns A streaming response or error NextResponse + */ +export async function handleChatStream(request: NextRequest): Promise { + const validatedBodyOrError = await validateChatRequest(request); + if (validatedBodyOrError instanceof NextResponse) { + return validatedBodyOrError; + } + const body = validatedBodyOrError; + + try { + const chatConfig = await setupChatRequest(body); + const { agent } = chatConfig; + + const stream = createUIMessageStream({ + originalMessages: body.messages, + generateId: generateUUID, + execute: async (options) => { + const { writer } = options; + const result = await agent.stream(chatConfig); + writer.merge(result.toUIMessageStream()); + // Note: Credit handling and chat completion handling will be added + // as part of the handleChatCredits and handleChatCompletion migrations + }, + onError: (e) => { + console.error("/api/chat onError:", e); + return JSON.stringify({ + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }); + }, + }); + + return createUIMessageStreamResponse({ stream }); + } catch (e) { + console.error("/api/chat Global error:", e); + return NextResponse.json( + { + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} From 6f71c16ce687ff2665bea88be71656db75579396 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 18:21:52 -0500 Subject: [PATCH 05/19] feat: create /api/chat/generate non-streaming endpoint Add POST /api/chat/generate endpoint for synchronous text generation: - handleChatGenerate function using AI SDK's generateText - Returns JSON with text, reasoningText, sources, finishReason, usage - Validates via validateChatRequest, configures via setupChatRequest - Includes 9 unit tests covering validation, generation, and error handling Co-Authored-By: Claude Opus 4.5 --- app/api/chat/generate/route.ts | 48 +++ lib/chat/__tests__/handleChatGenerate.test.ts | 332 ++++++++++++++++++ lib/chat/handleChatGenerate.ts | 65 ++++ 3 files changed, 445 insertions(+) create mode 100644 app/api/chat/generate/route.ts create mode 100644 lib/chat/__tests__/handleChatGenerate.test.ts create mode 100644 lib/chat/handleChatGenerate.ts diff --git a/app/api/chat/generate/route.ts b/app/api/chat/generate/route.ts new file mode 100644 index 00000000..c1acb2f1 --- /dev/null +++ b/app/api/chat/generate/route.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatGenerate } from "@/lib/chat/handleChatGenerate"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/chat/generate + * + * Non-streaming chat endpoint that processes messages and returns a JSON response. + * + * Authentication: x-api-key header required. + * The account ID is inferred from the API key. + * + * Request body: + * - messages: Array of chat messages (mutually exclusive with prompt) + * - prompt: String prompt (mutually exclusive with messages) + * - roomId: Optional UUID of the chat room + * - artistId: Optional UUID of the artist account + * - model: Optional model ID override + * - excludeTools: Optional array of tool names to exclude + * - accountId: Optional accountId override (requires org API key) + * + * Response body: + * - text: The generated text response + * - reasoningText: Optional reasoning text (for models that support it) + * - sources: Array of sources used in generation + * - finishReason: The reason generation finished + * - usage: Token usage information + * - response: Additional response metadata + * + * @param request - The request object + * @returns A JSON response with the generated text or error + */ +export async function POST(request: NextRequest): Promise { + return handleChatGenerate(request); +} diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts new file mode 100644 index 00000000..b0c20004 --- /dev/null +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextResponse } from "next/server"; + +// Mock all dependencies before importing the module under test +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), +})); + +vi.mock("@/lib/chat/setupChatRequest", () => ({ + setupChatRequest: vi.fn(), +})); + +vi.mock("ai", () => ({ + generateText: 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 { handleChatGenerate } from "../handleChatGenerate"; + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); +const mockSetupChatRequest = vi.mocked(setupChatRequest); +const mockGenerateText = vi.mocked(generateText); + +// Helper to create mock NextRequest +function createMockRequest( + body: unknown, + headers: Record = {}, +): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + has: (key: string) => key.toLowerCase() in headers, + }, + } as unknown as Request; +} + +describe("handleChatGenerate", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("validation", () => { + it("returns 400 error when neither messages nor prompt is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { roomId: "room-123" }, + { "x-api-key": "test-key" }, + ); + + const result = await handleChatGenerate(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(400); + const json = await result.json(); + expect(json.status).toBe("error"); + }); + + it("returns 401 error when x-api-key header is missing", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "x-api-key header required" }, + { status: 401 }, + ), + ); + + const request = createMockRequest({ prompt: "Hello" }, {}); + + const result = await handleChatGenerate(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(401); + }); + }); + + describe("text generation", () => { + it("returns generated text for valid requests", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const mockChatConfig = { + model: "gpt-4", + instructions: "You are a helpful assistant", + system: "You are a helpful assistant", + messages: [], + experimental_generateMessageId: vi.fn(), + tools: {}, + providerOptions: {}, + }; + + mockSetupChatRequest.mockResolvedValue(mockChatConfig as any); + + mockGenerateText.mockResolvedValue({ + text: "Hello! How can I help you?", + reasoningText: undefined, + sources: [], + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + response: { + messages: [], + headers: {}, + body: null, + }, + } as any); + + const request = createMockRequest( + { prompt: "Hello, world!" }, + { "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("Hello! How can I help you?"); + expect(json.finishReason).toBe("stop"); + expect(json.usage).toEqual({ promptTokens: 10, completionTokens: 20 }); + }); + + it("uses messages array when 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 messages = [{ role: "user", content: "Hello" }]; + const request = createMockRequest( + { messages }, + { "x-api-key": "valid-key" }, + ); + + await handleChatGenerate(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalledWith( + expect.objectContaining({ + messages, + accountId: "account-123", + }), + ); + }); + + it("passes through optional parameters", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + mockSetupChatRequest.mockResolvedValue({ + model: "claude-3-opus", + 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", + roomId: "room-xyz", + artistId: "artist-abc", + model: "claude-3-opus", + excludeTools: ["tool1"], + }, + { "x-api-key": "valid-key" }, + ); + + await handleChatGenerate(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "room-xyz", + artistId: "artist-abc", + model: "claude-3-opus", + excludeTools: ["tool1"], + }), + ); + }); + + it("includes reasoningText when present", 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", + reasoningText: "Let me think about this...", + sources: [{ url: "https://example.com" }], + finishReason: "stop", + usage: { promptTokens: 10, completionTokens: 20 }, + response: { messages: [], headers: {}, body: null }, + } as any); + + 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.reasoningText).toBe("Let me think about this..."); + expect(json.sources).toEqual([{ url: "https://example.com" }]); + }); + }); + + describe("error handling", () => { + it("returns 500 error when setupChatRequest fails", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatGenerate(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(500); + const json = await result.json(); + expect(json.status).toBe("error"); + }); + + it("returns 500 error when generateText 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.mockRejectedValue(new Error("Generation failed")); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + const result = await handleChatGenerate(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect(result.status).toBe(500); + const json = await result.json(); + expect(json.status).toBe("error"); + }); + }); + + describe("accountId override", () => { + it("allows org API key to override accountId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockValidateOverrideAccountId.mockResolvedValue({ + accountId: "target-account-456", + }); + + 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", accountId: "target-account-456" }, + { "x-api-key": "org-api-key" }, + ); + + await handleChatGenerate(request as any); + + expect(mockSetupChatRequest).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "target-account-456", + }), + ); + }); + }); +}); diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts new file mode 100644 index 00000000..d708bcff --- /dev/null +++ b/lib/chat/handleChatGenerate.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { validateChatRequest } from "./validateChatRequest"; +import { setupChatRequest } from "./setupChatRequest"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Handles a non-streaming chat generate request. + * + * This function: + * 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. + * + * @param request - The incoming NextRequest + * @returns A JSON response or error NextResponse + */ +export async function handleChatGenerate(request: NextRequest): Promise { + const validatedBodyOrError = await validateChatRequest(request); + if (validatedBodyOrError instanceof NextResponse) { + return validatedBodyOrError; + } + const body = validatedBodyOrError; + + try { + const chatConfig = await setupChatRequest(body); + + const result = await generateText(chatConfig); + + // Note: Credit handling and chat completion handling will be added + // as part of the handleChatCredits and handleChatCompletion migrations + + return NextResponse.json( + { + text: result.text, + reasoningText: result.reasoningText, + sources: result.sources, + finishReason: result.finishReason, + usage: result.usage, + response: { + messages: result.response.messages, + headers: result.response.headers, + body: result.response.body, + }, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (e) { + console.error("/api/chat/generate Global error:", e); + return NextResponse.json( + { + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} From 678445e3b4d9342874a5e7d592059cef19e4624e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:06:40 -0500 Subject: [PATCH 06/19] feat: add title generation to /api/chats endpoint - Add generateChatTitle function using LIGHTWEIGHT_MODEL (gpt-4o-mini) - Generate formal titles under 20 characters from firstMessage - Highlight segment names if present in the message - Strip accidental quote wrapping from generated titles - Add firstMessage field to createChatBody schema - Integrate title generation into createChatHandler - Gracefully fall back to null topic on generation failure - Add 21 new unit tests (15 for generateChatTitle, 6 for validation/handler) Co-Authored-By: Claude Opus 4.5 --- lib/chats/__tests__/createChatHandler.test.ts | 102 +++++++++ lib/chats/__tests__/generateChatTitle.test.ts | 196 ++++++++++++++++++ .../__tests__/validateCreateChatBody.test.ts | 33 +++ lib/chats/createChatHandler.ts | 15 +- lib/chats/generateChatTitle.ts | 21 ++ lib/chats/validateCreateChatBody.ts | 1 + 6 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 lib/chats/__tests__/generateChatTitle.test.ts create mode 100644 lib/chats/generateChatTitle.ts diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index 3d8b53f9..e05b0bdc 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -6,6 +6,7 @@ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { generateChatTitle } from "../generateChatTitle"; // Mock dependencies vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ @@ -32,6 +33,10 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(), })); +vi.mock("../generateChatTitle", () => ({ + generateChatTitle: vi.fn(), +})); + /** * * @param apiKey @@ -168,4 +173,101 @@ describe("createChatHandler", () => { expect(insertRoom).not.toHaveBeenCalled(); }); }); + + describe("with firstMessage (title generation)", () => { + it("generates a title from firstMessage when provided", async () => { + const apiKeyAccountId = "api-key-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + const firstMessage = "What marketing strategies should I use?"; + const generatedTitle = "Marketing Plan"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + firstMessage, + }); + vi.mocked(generateChatTitle).mockResolvedValue(generatedTitle); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: generatedTitle, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(generateChatTitle).toHaveBeenCalledWith(firstMessage); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: generatedTitle, + }); + }); + + it("uses null topic when firstMessage is not provided", async () => { + const apiKeyAccountId = "api-key-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + }); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + + expect(response.status).toBe(200); + expect(generateChatTitle).not.toHaveBeenCalled(); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + }); + + it("handles title generation failure gracefully (uses null)", async () => { + const apiKeyAccountId = "api-key-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + const firstMessage = "What marketing strategies should I use?"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + firstMessage, + }); + vi.mocked(generateChatTitle).mockRejectedValue(new Error("AI generation failed")); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(generateChatTitle).toHaveBeenCalledWith(firstMessage); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + }); + }); }); diff --git a/lib/chats/__tests__/generateChatTitle.test.ts b/lib/chats/__tests__/generateChatTitle.test.ts new file mode 100644 index 00000000..32be1dd6 --- /dev/null +++ b/lib/chats/__tests__/generateChatTitle.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/ai/generateText", () => ({ + default: vi.fn(), +})); + +import { generateChatTitle } from "../generateChatTitle"; +import generateText from "@/lib/ai/generateText"; + +const mockGenerateText = vi.mocked(generateText); + +describe("generateChatTitle", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("title generation", () => { + it("generates a title from the input text", async () => { + mockGenerateText.mockResolvedValue({ + text: "Marketing Plan", + } as any); + + const result = await generateChatTitle("What marketing strategies should I use?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("calls generateText with correct prompt structure", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test Title", + } as any); + + await generateChatTitle("Test input"); + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Test input"), + model: expect.any(String), + }), + ); + }); + + it("uses LIGHTWEIGHT_MODEL for efficiency", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test Title", + } as any); + + await generateChatTitle("Test input"); + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: expect.stringContaining("gpt-4o-mini"), + }), + ); + }); + }); + + describe("quote handling", () => { + it("removes leading double quotes from generated title", async () => { + mockGenerateText.mockResolvedValue({ + text: '"Marketing Plan', + } as any); + + const result = await generateChatTitle("What marketing strategies?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("removes trailing double quotes from generated title", async () => { + mockGenerateText.mockResolvedValue({ + text: 'Marketing Plan"', + } as any); + + const result = await generateChatTitle("What marketing strategies?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("removes both leading and trailing double quotes", async () => { + mockGenerateText.mockResolvedValue({ + text: '"Marketing Plan"', + } as any); + + const result = await generateChatTitle("What marketing strategies?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("removes leading single quotes", async () => { + mockGenerateText.mockResolvedValue({ + text: "'Marketing Plan", + } as any); + + const result = await generateChatTitle("What marketing strategies?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("removes trailing single quotes", async () => { + mockGenerateText.mockResolvedValue({ + text: "Marketing Plan'", + } as any); + + const result = await generateChatTitle("What marketing strategies?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("removes both leading and trailing single quotes", async () => { + mockGenerateText.mockResolvedValue({ + text: "'Marketing Plan'", + } as any); + + const result = await generateChatTitle("What marketing strategies?"); + + expect(result).toBe("Marketing Plan"); + }); + + it("does not remove quotes in the middle of the title", async () => { + mockGenerateText.mockResolvedValue({ + text: "User's Plan", + } as any); + + const result = await generateChatTitle("Help me with the user's plan"); + + expect(result).toBe("User's Plan"); + }); + }); + + describe("prompt instructions", () => { + it("instructs model to generate title under 20 characters", async () => { + mockGenerateText.mockResolvedValue({ + text: "Short Title", + } as any); + + await generateChatTitle("A very long question about many things"); + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringMatching(/20 characters/i), + }), + ); + }); + + it("instructs model to highlight segment names if present", async () => { + mockGenerateText.mockResolvedValue({ + text: "Active Fans", + } as any); + + await generateChatTitle("Show me the Active Fans segment"); + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringMatching(/segment/i), + }), + ); + }); + + it("instructs model not to wrap title in quotes", async () => { + mockGenerateText.mockResolvedValue({ + text: "Clean Title", + } as any); + + await generateChatTitle("Some question"); + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringMatching(/Do not wrap.*quotes/i), + }), + ); + }); + }); + + describe("edge cases", () => { + it("handles empty input gracefully", async () => { + mockGenerateText.mockResolvedValue({ + text: "New Chat", + } as any); + + const result = await generateChatTitle(""); + + expect(result).toBe("New Chat"); + }); + + it("handles very long input", async () => { + mockGenerateText.mockResolvedValue({ + text: "Long Discussion", + } as any); + + const longInput = "a".repeat(1000); + const result = await generateChatTitle(longInput); + + expect(result).toBe("Long Discussion"); + }); + }); +}); diff --git a/lib/chats/__tests__/validateCreateChatBody.test.ts b/lib/chats/__tests__/validateCreateChatBody.test.ts index 851171d8..e233e441 100644 --- a/lib/chats/__tests__/validateCreateChatBody.test.ts +++ b/lib/chats/__tests__/validateCreateChatBody.test.ts @@ -101,4 +101,37 @@ describe("validateCreateChatBody", () => { } }); }); + + describe("firstMessage validation", () => { + it("accepts valid string for firstMessage", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + firstMessage: "What marketing strategies should I use?", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).firstMessage).toBe( + "What marketing strategies should I use?", + ); + }); + + it("accepts missing firstMessage (optional)", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).firstMessage).toBeUndefined(); + }); + + it("accepts empty string for firstMessage", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + firstMessage: "", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).firstMessage).toBe(""); + }); + }); }); diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index d80cf736..584b5f8f 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -6,6 +6,7 @@ import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { generateChatTitle } from "@/lib/chats/generateChatTitle"; /** * Handler for creating a new chat room. @@ -33,7 +34,7 @@ export async function createChatHandler(request: NextRequest): Promise { + const response = await generateText({ + prompt: `Provide a brief title (more formal, no more than 20 characters!!!) that reflects the key elements of the given context. Do not wrap the title in quotes. + If the question is related to a segment or contains a segment name, highlight the segment name. + Context: ${question}`, + model: LIGHTWEIGHT_MODEL, + }); + + // In case model accidentally generates quotes again, remove them here + return response.text.replace(/^["']|["']$/g, ""); +} diff --git a/lib/chats/validateCreateChatBody.ts b/lib/chats/validateCreateChatBody.ts index 9e4e3f3a..ac69a609 100644 --- a/lib/chats/validateCreateChatBody.ts +++ b/lib/chats/validateCreateChatBody.ts @@ -6,6 +6,7 @@ export const createChatBodySchema = z.object({ artistId: z.string().uuid("artistId must be a valid UUID").optional(), chatId: z.string().uuid("chatId must be a valid UUID").optional(), accountId: z.string().uuid("accountId must be a valid UUID").optional(), + firstMessage: z.string().optional(), }); export type CreateChatBody = z.infer; From 9cc8a03458a0f25a3a69b90c944eebb158c9a875 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:15:11 -0500 Subject: [PATCH 07/19] fix: change validateChatRequest return type to NextResponse Use NextResponse instead of Response in the return type so TypeScript properly narrows the union type when checking instanceof NextResponse. Co-Authored-By: Claude Opus 4.5 --- lib/chat/validateChatRequest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 4a1e0324..76f3bc02 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -46,7 +46,7 @@ export type ChatRequestBody = BaseChatRequestBody & { * Validates chat request body and auth headers. * * Returns: - * - Response (400/401/403/500) when invalid (body or headers) + * - NextResponse (400/401/403/500) when invalid (body or headers) * - Parsed & augmented body when valid (including header-derived accountId) * * @param request - The NextRequest object @@ -54,7 +54,7 @@ export type ChatRequestBody = BaseChatRequestBody & { */ export async function validateChatRequest( request: NextRequest, -): Promise { +): Promise { const json = await request.json(); const validationResult = chatRequestSchema.safeParse(json); From 0ebc38ed988af68529ab4f46bf00ccd4a9bf4d44 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:18:37 -0500 Subject: [PATCH 08/19] fix: properly type providerOptions in ChatConfig Use specific provider option types (AnthropicProviderOptions, GoogleGenerativeAIProviderOptions, OpenAIResponsesProviderOptions) instead of Record for compatibility with generateText. Co-Authored-By: Claude Opus 4.5 --- lib/chat/types.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/chat/types.ts b/lib/chat/types.ts index bf277cc0..2fe93bfd 100644 --- a/lib/chat/types.ts +++ b/lib/chat/types.ts @@ -1,3 +1,6 @@ +import { AnthropicProviderOptions } from "@ai-sdk/anthropic"; +import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; +import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; import { VercelToolCollection } from "@composio/vercel"; import { type ModelMessage, @@ -23,5 +26,9 @@ export interface ChatConfig extends RoutingDecision { ) => Promise>; tools: ToolSet; prepareStep?: PrepareStepFunction; - providerOptions?: Record; + providerOptions?: { + anthropic?: AnthropicProviderOptions; + google?: GoogleGenerativeAIProviderOptions; + openai?: OpenAIResponsesProviderOptions; + }; } From a2091b5e9b4b9f2bae18717a129a398e09222ba4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:21:47 -0500 Subject: [PATCH 09/19] chore: add AI SDK provider packages Add @ai-sdk/anthropic, @ai-sdk/google, and @ai-sdk/openai for provider-specific type definitions used in ChatConfig. Co-Authored-By: Claude Opus 4.5 --- package.json | 3 +++ pnpm-lock.yaml | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/package.json b/package.json index ca86291d..c45d1cd2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "test:watch": "vitest" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.13", + "@ai-sdk/google": "^3.0.8", "@ai-sdk/mcp": "^0.0.12", + "@ai-sdk/openai": "^3.0.10", "@coinbase/cdp-sdk": "^1.38.6", "@coinbase/x402": "^0.7.3", "@composio/core": "^0.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfd67bed..ba609d90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,18 @@ importers: .: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.13 + version: 3.0.13(zod@4.1.13) + '@ai-sdk/google': + specifier: ^3.0.8 + version: 3.0.8(zod@4.1.13) '@ai-sdk/mcp': specifier: ^0.0.12 version: 0.0.12(zod@4.1.13) + '@ai-sdk/openai': + specifier: ^3.0.10 + version: 3.0.10(zod@4.1.13) '@coinbase/cdp-sdk': specifier: ^1.38.6 version: 1.38.6(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -147,18 +156,36 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@ai-sdk/anthropic@3.0.13': + resolution: {integrity: sha512-62UqSpZWuR8pU2ZLc1IgPYiNdH01blAcaNEjrQtx4wCN7L2fUTXm/iG6Tq9qRCiRED+8eQ43olggbf0fbguqkA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@2.0.0-beta.66': resolution: {integrity: sha512-9H4Y4pFcTlDqLOjhJNfHVJrmQiGGqzQLIDNKSGhab90KYgeZc7NouQF752jUIlEZCY1S4QynuUKISTUsKR6Qjg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/google@3.0.8': + resolution: {integrity: sha512-HiDetkn01f8ibcu6atygkPXsy6YgNA2uNz2bwgn6xHQQB1FsCCjDo8ylPA2EvaUbNypmD7oPj0zObDgwfE25Ug==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@0.0.12': resolution: {integrity: sha512-hyf31U2CmgGexqOLgLfno525pjbqidJLu9pU+XcEwW/PkMcfTFuRq1iD3wbqtAmURRW0qJITiKV+in1B4I23gA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@3.0.10': + resolution: {integrity: sha512-G6HJORN0rKuCFrqIUiYchjl2b4UjzKvv3VcNuW7xwQIdI8EcdB9Pr8ZaR9nEImK9E639nM8gCfvFEUM1xwGaCA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@3.0.19': resolution: {integrity: sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==} engines: {node: '>=18'} @@ -181,6 +208,12 @@ packages: effect: optional: true + '@ai-sdk/provider-utils@4.0.6': + resolution: {integrity: sha512-o/SP1GQOrpXAzHjMosPHI0Pu+YkwxIMndSjSLrEXtcVixdrjqrGaA9I7xJcWf+XpRFJ9byPHrKYnprwS+36gMg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@2.0.0': resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} @@ -189,6 +222,10 @@ packages: resolution: {integrity: sha512-+JqXbqHHtucRsMFGidygRyftpjX1GD2r4cG3Sh2URZ6g8IaN8k4loXNh2gX92dd4YjlYYn3eTHp3R8dDJfX25Q==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.3': + resolution: {integrity: sha512-qGPYdoAuECaUXPrrz0BPX1SacZQuJ6zky0aakxpW89QW1hrY0eF4gcFm/3L9Pk8C5Fwe+RvBf2z7ZjDhaPjnlg==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -5662,6 +5699,12 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@ai-sdk/anthropic@3.0.13(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 3.0.3 + '@ai-sdk/provider-utils': 4.0.6(zod@4.1.13) + zod: 4.1.13 + '@ai-sdk/gateway@2.0.0-beta.66(zod@4.1.13)': dependencies: '@ai-sdk/provider': 3.0.0-beta.20 @@ -5673,6 +5716,12 @@ snapshots: - arktype - effect + '@ai-sdk/google@3.0.8(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 3.0.3 + '@ai-sdk/provider-utils': 4.0.6(zod@4.1.13) + zod: 4.1.13 + '@ai-sdk/mcp@0.0.12(zod@4.1.13)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -5680,6 +5729,12 @@ snapshots: pkce-challenge: 5.0.1 zod: 4.1.13 + '@ai-sdk/openai@3.0.10(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 3.0.3 + '@ai-sdk/provider-utils': 4.0.6(zod@4.1.13) + zod: 4.1.13 + '@ai-sdk/provider-utils@3.0.19(zod@4.1.13)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -5694,6 +5749,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.13 + '@ai-sdk/provider-utils@4.0.6(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 3.0.3 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.1.13 + '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 @@ -5702,6 +5764,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.3': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@apify/consts@2.48.0': {} From 38b795c5c3f060075bea0af900dcdfd74ab43bf2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:22:17 -0500 Subject: [PATCH 10/19] feat: migrate handleChatCredits from Recoup-Chat to recoup-api Add credit calculation and deduction logic for chat usage: - getAvailableModels: fetches models from Vercel AI Gateway - getModel: finds model by ID from available models - isEmbedModel: filters embed models by output price - getCreditUsage: calculates USD cost from token usage - handleChatCredits: orchestrates credit deduction after chat Includes 22 unit tests (8 for handleChatCredits, 7 for getCreditUsage, 4 for getModel, 3 for getAvailableModels). Co-Authored-By: Claude Opus 4.5 --- lib/ai/__tests__/getAvailableModels.test.ts | 59 +++++++ lib/ai/__tests__/getModel.test.ts | 66 ++++++++ lib/ai/getAvailableModels.ts | 18 +++ lib/ai/getModel.ts | 19 +++ lib/ai/isEmbedModel.ts | 14 ++ lib/credits/__tests__/getCreditUsage.test.ts | 142 ++++++++++++++++ .../__tests__/handleChatCredits.test.ts | 152 ++++++++++++++++++ lib/credits/getCreditUsage.ts | 47 ++++++ lib/credits/handleChatCredits.ts | 43 +++++ 9 files changed, 560 insertions(+) create mode 100644 lib/ai/__tests__/getAvailableModels.test.ts create mode 100644 lib/ai/__tests__/getModel.test.ts create mode 100644 lib/ai/getAvailableModels.ts create mode 100644 lib/ai/getModel.ts create mode 100644 lib/ai/isEmbedModel.ts create mode 100644 lib/credits/__tests__/getCreditUsage.test.ts create mode 100644 lib/credits/__tests__/handleChatCredits.test.ts create mode 100644 lib/credits/getCreditUsage.ts create mode 100644 lib/credits/handleChatCredits.ts diff --git a/lib/ai/__tests__/getAvailableModels.test.ts b/lib/ai/__tests__/getAvailableModels.test.ts new file mode 100644 index 00000000..05a9014b --- /dev/null +++ b/lib/ai/__tests__/getAvailableModels.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@ai-sdk/gateway", () => ({ + gateway: { + getAvailableModels: vi.fn(), + }, +})); + +import { gateway } from "@ai-sdk/gateway"; +import { getAvailableModels } from "../getAvailableModels"; + +const mockGatewayGetAvailableModels = vi.mocked(gateway.getAvailableModels); + +describe("getAvailableModels", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("fetching models", () => { + it("returns models from gateway excluding embed models", async () => { + mockGatewayGetAvailableModels.mockResolvedValue({ + models: [ + { id: "gpt-4", pricing: { input: "0.00003", output: "0.00006" } }, + // Embed models have output price = 0 + { id: "text-embedding-ada-002", pricing: { input: "0.0001", output: "0" } }, + { id: "claude-3-opus", pricing: { input: "0.00001", output: "0.00003" } }, + ], + } as any); + + const models = await getAvailableModels(); + + // Should filter out embed models (output price = 0) + expect(models).toHaveLength(2); + expect(models.map((m) => m.id)).toEqual(["gpt-4", "claude-3-opus"]); + }); + + it("returns empty array when gateway returns no models", async () => { + mockGatewayGetAvailableModels.mockResolvedValue({ models: [] } as any); + + const models = await getAvailableModels(); + + expect(models).toEqual([]); + }); + }); + + describe("error handling", () => { + it("returns empty array when gateway throws", async () => { + mockGatewayGetAvailableModels.mockRejectedValue(new Error("API error")); + + const models = await getAvailableModels(); + + expect(models).toEqual([]); + }); + }); +}); diff --git a/lib/ai/__tests__/getModel.test.ts b/lib/ai/__tests__/getModel.test.ts new file mode 100644 index 00000000..52ec40c2 --- /dev/null +++ b/lib/ai/__tests__/getModel.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/ai/getAvailableModels", () => ({ + getAvailableModels: vi.fn(), +})); + +import { getAvailableModels } from "@/lib/ai/getAvailableModels"; +import { getModel } from "../getModel"; + +const mockGetAvailableModels = vi.mocked(getAvailableModels); + +describe("getModel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("finding models", () => { + it("returns the model when found by ID", async () => { + const models = [ + { id: "gpt-4", pricing: { input: "0.00003", output: "0.00006" } }, + { id: "claude-3-opus", pricing: { input: "0.00001", output: "0.00003" } }, + ]; + mockGetAvailableModels.mockResolvedValue(models as any); + + const model = await getModel("gpt-4"); + + expect(model).toEqual({ + id: "gpt-4", + pricing: { input: "0.00003", output: "0.00006" }, + }); + }); + + it("returns undefined when model is not found", async () => { + const models = [ + { id: "gpt-4", pricing: { input: "0.00003", output: "0.00006" } }, + ]; + mockGetAvailableModels.mockResolvedValue(models as any); + + const model = await getModel("unknown-model"); + + expect(model).toBeUndefined(); + }); + + it("returns undefined when getAvailableModels returns empty array", async () => { + mockGetAvailableModels.mockResolvedValue([]); + + const model = await getModel("gpt-4"); + + expect(model).toBeUndefined(); + }); + }); + + describe("error handling", () => { + it("returns undefined when getAvailableModels throws", async () => { + mockGetAvailableModels.mockRejectedValue(new Error("API error")); + + const model = await getModel("gpt-4"); + + expect(model).toBeUndefined(); + }); + }); +}); diff --git a/lib/ai/getAvailableModels.ts b/lib/ai/getAvailableModels.ts new file mode 100644 index 00000000..8ecfb8f3 --- /dev/null +++ b/lib/ai/getAvailableModels.ts @@ -0,0 +1,18 @@ +import { gateway, GatewayLanguageModelEntry } from "@ai-sdk/gateway"; +import isEmbedModel from "./isEmbedModel"; + +/** + * Returns the list of available LLMs from the Vercel AI Gateway. + * Filters out embed models that are not suitable for chat. + */ +export const getAvailableModels = async (): Promise< + GatewayLanguageModelEntry[] +> => { + try { + const apiResponse = await gateway.getAvailableModels(); + const gatewayModels = apiResponse.models.filter((m) => !isEmbedModel(m)); + return gatewayModels; + } catch { + return []; + } +}; diff --git a/lib/ai/getModel.ts b/lib/ai/getModel.ts new file mode 100644 index 00000000..b802acaf --- /dev/null +++ b/lib/ai/getModel.ts @@ -0,0 +1,19 @@ +import { getAvailableModels } from "./getAvailableModels"; +import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; + +/** + * Returns a specific model by its ID from the list of available models. + * @param modelId - The ID of the model to find + * @returns The matching model or undefined if not found + */ +export const getModel = async ( + modelId: string, +): Promise => { + try { + const availableModels = await getAvailableModels(); + return availableModels.find((model) => model.id === modelId); + } catch (error) { + console.error(`Failed to get model with ID ${modelId}:`, error); + return undefined; + } +}; diff --git a/lib/ai/isEmbedModel.ts b/lib/ai/isEmbedModel.ts new file mode 100644 index 00000000..7c5fbbfb --- /dev/null +++ b/lib/ai/isEmbedModel.ts @@ -0,0 +1,14 @@ +import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; + +/** + * Determines if a model is an embedding model (not suitable for chat). + * Embed models typically have 0 output pricing since they only produce embeddings. + */ +export const isEmbedModel = (m: GatewayLanguageModelEntry): boolean => { + const pricing = m.pricing; + if (!pricing) return false; + const output = parseFloat(pricing.output); + return output === 0; +}; + +export default isEmbedModel; diff --git a/lib/credits/__tests__/getCreditUsage.test.ts b/lib/credits/__tests__/getCreditUsage.test.ts new file mode 100644 index 00000000..fa55b85d --- /dev/null +++ b/lib/credits/__tests__/getCreditUsage.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/ai/getModel", () => ({ + getModel: vi.fn(), +})); + +import { getModel } from "@/lib/ai/getModel"; +import { getCreditUsage } from "../getCreditUsage"; + +const mockGetModel = vi.mocked(getModel); + +describe("getCreditUsage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("cost calculation", () => { + it("calculates total cost from input and output tokens", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { + input: "0.00003", // $0.03 per 1K tokens + output: "0.00006", // $0.06 per 1K tokens + }, + } as any); + + const usage = { + promptTokens: 1000, + completionTokens: 500, + }; + + const cost = await getCreditUsage(usage, "gpt-4"); + + // Expected: 1000 * 0.00003 + 500 * 0.00006 = 0.03 + 0.03 = 0.06 + expect(cost).toBeCloseTo(0.06); + }); + + it("returns 0 when model is not found", async () => { + mockGetModel.mockResolvedValue(undefined); + + const usage = { + promptTokens: 1000, + completionTokens: 500, + }; + + const cost = await getCreditUsage(usage, "unknown-model"); + + expect(cost).toBe(0); + }); + + it("returns 0 when promptTokens is undefined", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { + input: "0.00003", + output: "0.00006", + }, + } as any); + + const usage = { + promptTokens: undefined as unknown as number, + completionTokens: 500, + }; + + const cost = await getCreditUsage(usage, "gpt-4"); + + expect(cost).toBe(0); + }); + + it("returns 0 when completionTokens is undefined", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { + input: "0.00003", + output: "0.00006", + }, + } as any); + + const usage = { + promptTokens: 1000, + completionTokens: undefined as unknown as number, + }; + + const cost = await getCreditUsage(usage, "gpt-4"); + + expect(cost).toBe(0); + }); + + it("handles model without pricing gracefully", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + // No pricing property + } as any); + + const usage = { + promptTokens: 1000, + completionTokens: 500, + }; + + const cost = await getCreditUsage(usage, "gpt-4"); + + // Should return NaN or 0 - implementation should handle this + expect(cost).toBe(0); + }); + + it("handles zero tokens", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { + input: "0.00003", + output: "0.00006", + }, + } as any); + + const usage = { + promptTokens: 0, + completionTokens: 0, + }; + + const cost = await getCreditUsage(usage, "gpt-4"); + + expect(cost).toBe(0); + }); + + it("handles getModel errors gracefully", async () => { + mockGetModel.mockRejectedValue(new Error("API error")); + + const usage = { + promptTokens: 1000, + completionTokens: 500, + }; + + const cost = await getCreditUsage(usage, "gpt-4"); + + expect(cost).toBe(0); + }); + }); +}); diff --git a/lib/credits/__tests__/handleChatCredits.test.ts b/lib/credits/__tests__/handleChatCredits.test.ts new file mode 100644 index 00000000..27f6a9a9 --- /dev/null +++ b/lib/credits/__tests__/handleChatCredits.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/credits/getCreditUsage", () => ({ + getCreditUsage: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +import { getCreditUsage } from "@/lib/credits/getCreditUsage"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { handleChatCredits } from "../handleChatCredits"; + +const mockGetCreditUsage = vi.mocked(getCreditUsage); +const mockDeductCredits = vi.mocked(deductCredits); + +describe("handleChatCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("credit deduction", () => { + it("deducts credits when accountId is provided and usage cost > 0", async () => { + mockGetCreditUsage.mockResolvedValue(0.05); // $0.05 = 5 credits + mockDeductCredits.mockResolvedValue({ success: true, newBalance: 95 }); + + await handleChatCredits({ + usage: { promptTokens: 1000, completionTokens: 500 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockGetCreditUsage).toHaveBeenCalledWith( + { promptTokens: 1000, completionTokens: 500 }, + "gpt-4", + ); + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 5, // 0.05 * 100 = 5 + }); + }); + + it("rounds credits to at least 1 when cost is very small", async () => { + mockGetCreditUsage.mockResolvedValue(0.001); // $0.001 = 0.1 credits, rounds to 1 + mockDeductCredits.mockResolvedValue({ success: true, newBalance: 99 }); + + await handleChatCredits({ + usage: { promptTokens: 10, completionTokens: 5 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 1, // Math.max(1, Math.round(0.001 * 100)) = 1 + }); + }); + + it("rounds credits correctly for larger amounts", async () => { + mockGetCreditUsage.mockResolvedValue(1.234); // $1.234 = 123.4 credits, rounds to 123 + mockDeductCredits.mockResolvedValue({ success: true, newBalance: 877 }); + + await handleChatCredits({ + usage: { promptTokens: 10000, completionTokens: 5000 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 123, + }); + }); + }); + + describe("skip conditions", () => { + it("skips credit deduction when accountId is not provided", async () => { + await handleChatCredits({ + usage: { promptTokens: 1000, completionTokens: 500 }, + model: "gpt-4", + accountId: undefined, + }); + + expect(mockGetCreditUsage).not.toHaveBeenCalled(); + expect(mockDeductCredits).not.toHaveBeenCalled(); + }); + + it("skips credit deduction when usage cost is 0", async () => { + mockGetCreditUsage.mockResolvedValue(0); + + await handleChatCredits({ + usage: { promptTokens: 0, completionTokens: 0 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockGetCreditUsage).toHaveBeenCalled(); + expect(mockDeductCredits).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("does not throw when getCreditUsage fails", async () => { + mockGetCreditUsage.mockRejectedValue(new Error("Pricing error")); + + await expect( + handleChatCredits({ + usage: { promptTokens: 1000, completionTokens: 500 }, + model: "gpt-4", + accountId: "account-123", + }), + ).resolves.not.toThrow(); + + expect(mockDeductCredits).not.toHaveBeenCalled(); + }); + + it("does not throw when deductCredits fails", async () => { + mockGetCreditUsage.mockResolvedValue(0.05); + mockDeductCredits.mockRejectedValue(new Error("Database error")); + + await expect( + handleChatCredits({ + usage: { promptTokens: 1000, completionTokens: 500 }, + model: "gpt-4", + accountId: "account-123", + }), + ).resolves.not.toThrow(); + }); + + it("logs error when credit handling fails", async () => { + const consoleSpy = vi.spyOn(console, "error"); + mockGetCreditUsage.mockRejectedValue(new Error("API error")); + + await handleChatCredits({ + usage: { promptTokens: 1000, completionTokens: 500 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to handle chat credits:", + expect.any(Error), + ); + }); + }); +}); diff --git a/lib/credits/getCreditUsage.ts b/lib/credits/getCreditUsage.ts new file mode 100644 index 00000000..846314c8 --- /dev/null +++ b/lib/credits/getCreditUsage.ts @@ -0,0 +1,47 @@ +import { getModel } from "@/lib/ai/getModel"; +import { LanguageModelUsage } from "ai"; + +/** + * Calculates the total spend in USD for a given language model usage. + * @param usage - The language model usage data + * @param modelId - The ID of the model used + * @returns The total spend in USD or 0 if calculation fails + */ +export const getCreditUsage = async ( + usage: LanguageModelUsage, + modelId: string, +): Promise => { + try { + const model = await getModel(modelId); + if (!model) { + console.error(`Model not found for ID: ${modelId}`); + return 0; + } + + // LanguageModelUsage uses inputTokens/outputTokens (SDK v3) + // or promptTokens/completionTokens (SDK v2 compatibility) + const inputTokens = + (usage as any).inputTokens ?? (usage as any).promptTokens; + const outputTokens = + (usage as any).outputTokens ?? (usage as any).completionTokens; + + if (!inputTokens || !outputTokens) { + console.error("No tokens found in usage"); + return 0; + } + + // Check if model has pricing + if (!model.pricing?.input || !model.pricing?.output) { + return 0; + } + + const inputCost = inputTokens * Number(model.pricing.input); + const outputCost = outputTokens * Number(model.pricing.output); + const totalCost = inputCost + outputCost; + + return totalCost; + } catch (error) { + console.error("Failed to calculate credit usage:", error); + return 0; + } +}; diff --git a/lib/credits/handleChatCredits.ts b/lib/credits/handleChatCredits.ts new file mode 100644 index 00000000..c0462eab --- /dev/null +++ b/lib/credits/handleChatCredits.ts @@ -0,0 +1,43 @@ +import { getCreditUsage } from "./getCreditUsage"; +import { deductCredits } from "./deductCredits"; +import { LanguageModelUsage } from "ai"; + +interface HandleChatCreditsParams { + usage: LanguageModelUsage; + model: string; + accountId?: string; +} + +/** + * Handles credit deduction after chat completion. + * Calculates usage cost and deducts appropriate credits from the user's account. + * @param usage - The language model usage data + * @param model - The model ID used for the chat + * @param accountId - The account ID to deduct credits from (optional) + */ +export const handleChatCredits = async ({ + usage, + model, + accountId, +}: HandleChatCreditsParams): Promise => { + if (!accountId) { + console.error("No account ID provided, skipping credit deduction"); + return; + } + + try { + const usageCost = await getCreditUsage(usage, model); + + if (usageCost > 0) { + const creditsToDeduct = Math.max(1, Math.round(usageCost * 100)); + + await deductCredits({ + accountId, + creditsToDeduct, + }); + } + } catch (error) { + console.error("Failed to handle chat credits:", error); + // Don't throw error to avoid breaking the chat flow + } +}; From f81e9dc42e37e10d0d71ba0fc77a89c6cfc4af15 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:27:14 -0500 Subject: [PATCH 11/19] chore: add @ai-sdk/gateway package Add @ai-sdk/gateway for getAvailableModels functionality. Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + pnpm-lock.yaml | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/package.json b/package.json index c45d1cd2..acbc005b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "^3.0.13", + "@ai-sdk/gateway": "^3.0.14", "@ai-sdk/google": "^3.0.8", "@ai-sdk/mcp": "^0.0.12", "@ai-sdk/openai": "^3.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba609d90..91256a2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@ai-sdk/anthropic': specifier: ^3.0.13 version: 3.0.13(zod@4.1.13) + '@ai-sdk/gateway': + specifier: ^3.0.14 + version: 3.0.14(zod@4.1.13) '@ai-sdk/google': specifier: ^3.0.8 version: 3.0.8(zod@4.1.13) @@ -168,6 +171,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.14': + resolution: {integrity: sha512-udVpkDaQ00jMcBvtGGvmkEBU31XidsHB4E8HIF9l7/H7lyjOS/EtXzN2adoupDg5j1/VjjSI3Ny5P1zHUvLyMA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/google@3.0.8': resolution: {integrity: sha512-HiDetkn01f8ibcu6atygkPXsy6YgNA2uNz2bwgn6xHQQB1FsCCjDo8ylPA2EvaUbNypmD7oPj0zObDgwfE25Ug==} engines: {node: '>=18'} @@ -2135,6 +2144,10 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -5716,6 +5729,13 @@ snapshots: - arktype - effect + '@ai-sdk/gateway@3.0.14(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 3.0.3 + '@ai-sdk/provider-utils': 4.0.6(zod@4.1.13) + '@vercel/oidc': 3.1.0 + zod: 4.1.13 + '@ai-sdk/google@3.0.8(zod@4.1.13)': dependencies: '@ai-sdk/provider': 3.0.3 @@ -8336,6 +8356,8 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vercel/oidc@3.1.0': {} + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 From a23d48b58ac9ff049f75585b834fde50c093a3a1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:29:43 -0500 Subject: [PATCH 12/19] feat: migrate handleChatCompletion from Recoup-Chat to recoup-api Add post-completion handler for chat messages: - handleChatCompletion: orchestrates post-chat tasks - selectRoom: fetches room by ID from Supabase - upsertMemory: stores messages to memories table - extractSendEmailResults: parses email tool outputs - handleSendEmailToolOutputs: links emails to memories - sendErrorNotification: sends errors to Telegram - serializeError: converts errors to JSON format Includes 14 unit tests covering message storage, new conversation handling, email tool outputs, account email lookup, and error handling. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/handleChatCompletion.test.ts | 311 ++++++++++++++++++ lib/chat/handleChatCompletion.ts | 95 ++++++ lib/chat/validateMessages.ts | 21 ++ lib/emails/extractSendEmailResults.ts | 47 +++ lib/emails/handleSendEmailToolOutputs.ts | 24 ++ lib/errors/serializeError.ts | 29 ++ lib/supabase/memories/upsertMemory.ts | 31 ++ lib/supabase/rooms/selectRoom.ts | 22 ++ lib/telegram/sendErrorNotification.ts | 55 ++++ 9 files changed, 635 insertions(+) create mode 100644 lib/chat/__tests__/handleChatCompletion.test.ts create mode 100644 lib/chat/handleChatCompletion.ts create mode 100644 lib/chat/validateMessages.ts create mode 100644 lib/emails/extractSendEmailResults.ts create mode 100644 lib/emails/handleSendEmailToolOutputs.ts create mode 100644 lib/errors/serializeError.ts create mode 100644 lib/supabase/memories/upsertMemory.ts create mode 100644 lib/supabase/rooms/selectRoom.ts create mode 100644 lib/telegram/sendErrorNotification.ts diff --git a/lib/chat/__tests__/handleChatCompletion.test.ts b/lib/chat/__tests__/handleChatCompletion.test.ts new file mode 100644 index 00000000..ad9a9675 --- /dev/null +++ b/lib/chat/__tests__/handleChatCompletion.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { UIMessage } from "ai"; + +// Mock all dependencies before importing the module under test +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ + insertRoom: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/upsertMemory", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/telegram/sendNewConversationNotification", () => ({ + sendNewConversationNotification: vi.fn(), +})); + +vi.mock("@/lib/chat/generateChatTitle", () => ({ + generateChatTitle: vi.fn(), +})); + +vi.mock("@/lib/emails/handleSendEmailToolOutputs", () => ({ + handleSendEmailToolOutputs: vi.fn(), +})); + +vi.mock("@/lib/telegram/sendErrorNotification", () => ({ + sendErrorNotification: vi.fn(), +})); + +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import upsertMemory from "@/lib/supabase/memories/upsertMemory"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import { generateChatTitle } from "@/lib/chat/generateChatTitle"; +import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; +import { sendErrorNotification } from "@/lib/telegram/sendErrorNotification"; +import { handleChatCompletion } from "../handleChatCompletion"; +import type { ChatRequestBody } from "../validateChatRequest"; + +const mockSelectAccountEmails = vi.mocked(selectAccountEmails); +const mockSelectRoom = vi.mocked(selectRoom); +const mockInsertRoom = vi.mocked(insertRoom); +const mockUpsertMemory = vi.mocked(upsertMemory); +const mockSendNewConversationNotification = vi.mocked(sendNewConversationNotification); +const mockGenerateChatTitle = vi.mocked(generateChatTitle); +const mockHandleSendEmailToolOutputs = vi.mocked(handleSendEmailToolOutputs); +const mockSendErrorNotification = vi.mocked(sendErrorNotification); + +// Helper to create mock UIMessage +function createMockUIMessage(id: string, role: "user" | "assistant", text: string): UIMessage { + return { + id, + role, + parts: [{ type: "text" as const, text }], + createdAt: new Date(), + }; +} + +// Helper to create mock ChatRequestBody +function createMockBody(overrides: Partial = {}): ChatRequestBody { + return { + accountId: "account-123", + messages: [createMockUIMessage("msg-1", "user", "Hello")], + roomId: "room-456", + ...overrides, + }; +} + +describe("handleChatCompletion", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementations + mockSelectAccountEmails.mockResolvedValue([]); + mockSelectRoom.mockResolvedValue({ id: "room-456" }); + mockUpsertMemory.mockResolvedValue(null); + mockHandleSendEmailToolOutputs.mockResolvedValue(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("message storage", () => { + it("stores user message to memories", async () => { + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi there!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockUpsertMemory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "msg-1", + room_id: "room-456", + }), + ); + }); + + it("stores assistant message to memories", async () => { + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi there!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockUpsertMemory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "resp-1", + room_id: "room-456", + }), + ); + }); + + it("stores messages sequentially (user then assistant)", async () => { + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi there!")]; + + const callOrder: string[] = []; + mockUpsertMemory.mockImplementation(async (params) => { + callOrder.push(params.id); + return null; + }); + + await handleChatCompletion(body, responseMessages); + + expect(callOrder).toEqual(["msg-1", "resp-1"]); + }); + }); + + describe("new conversation handling", () => { + it("creates room when room does not exist", async () => { + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("Test Topic"); + + const body = createMockBody({ roomId: "new-room-123" }); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockInsertRoom).toHaveBeenCalledWith( + expect.objectContaining({ + id: "new-room-123", + account_id: "account-123", + topic: "Test Topic", + }), + ); + }); + + it("sends notification for new conversation", async () => { + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("Test Topic"); + mockSelectAccountEmails.mockResolvedValue([{ email: "test@example.com" } as any]); + + const body = createMockBody({ roomId: "new-room-123" }); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockSendNewConversationNotification).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-123", + email: "test@example.com", + conversationId: "new-room-123", + topic: "Test Topic", + }), + ); + }); + + it("does not create room when room already exists", async () => { + mockSelectRoom.mockResolvedValue({ id: "existing-room" }); + + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockInsertRoom).not.toHaveBeenCalled(); + expect(mockSendNewConversationNotification).not.toHaveBeenCalled(); + }); + }); + + describe("email handling", () => { + it("processes email tool outputs from response messages", async () => { + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Email sent!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockHandleSendEmailToolOutputs).toHaveBeenCalledWith(responseMessages); + }); + }); + + describe("account email lookup", () => { + it("retrieves email for account", async () => { + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockSelectAccountEmails).toHaveBeenCalledWith({ + accountIds: "account-123", + }); + }); + + it("uses first email from account emails", async () => { + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("Topic"); + mockSelectAccountEmails.mockResolvedValue([ + { email: "first@example.com" } as any, + { email: "second@example.com" } as any, + ]); + + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockSendNewConversationNotification).toHaveBeenCalledWith( + expect.objectContaining({ + email: "first@example.com", + }), + ); + }); + }); + + describe("error handling", () => { + it("sends error notification when an error occurs", async () => { + const testError = new Error("Storage failed"); + mockUpsertMemory.mockRejectedValue(testError); + + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockSendErrorNotification).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/chat", + error: expect.objectContaining({ + name: "Error", + message: "Storage failed", + }), + }), + ); + }); + + it("does not throw when error occurs (graceful handling)", async () => { + mockUpsertMemory.mockRejectedValue(new Error("DB error")); + + const body = createMockBody(); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + // Should not throw + await expect(handleChatCompletion(body, responseMessages)).resolves.toBeUndefined(); + }); + }); + + describe("edge cases", () => { + it("handles empty roomId", async () => { + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("Topic"); + + const body = createMockBody({ roomId: "" }); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + // Should still create room with empty string ID (or undefined) + expect(mockInsertRoom).toHaveBeenCalled(); + }); + + it("handles artistId when creating room", async () => { + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("Topic"); + + const body = createMockBody({ roomId: "new-room", artistId: "artist-789" }); + const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")]; + + await handleChatCompletion(body, responseMessages); + + expect(mockInsertRoom).toHaveBeenCalledWith( + expect.objectContaining({ + artist_id: "artist-789", + }), + ); + }); + + it("handles multiple response messages (uses last one)", async () => { + const body = createMockBody(); + const responseMessages = [ + createMockUIMessage("resp-1", "assistant", "First"), + createMockUIMessage("resp-2", "assistant", "Second"), + ]; + + await handleChatCompletion(body, responseMessages); + + // Should store the last response message + expect(mockUpsertMemory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "resp-2", + }), + ); + }); + }); +}); diff --git a/lib/chat/handleChatCompletion.ts b/lib/chat/handleChatCompletion.ts new file mode 100644 index 00000000..d3d6b741 --- /dev/null +++ b/lib/chat/handleChatCompletion.ts @@ -0,0 +1,95 @@ +import type { UIMessage } from "ai"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import upsertMemory from "@/lib/supabase/memories/upsertMemory"; +import { validateMessages } from "@/lib/messages/validateMessages"; +import { generateChatTitle } from "@/lib/chat/generateChatTitle"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; +import { sendErrorNotification } from "@/lib/telegram/sendErrorNotification"; +import { serializeError } from "@/lib/errors/serializeError"; +import type { ChatRequestBody } from "./validateChatRequest"; + +/** + * Handles post-chat-completion tasks: + * - Creates room if this is a new conversation + * - Sends notification for new conversations + * - Stores user and assistant messages to memories + * - Processes email tool outputs + * + * Errors are caught and sent to Telegram but do not propagate + * to avoid breaking the chat response. + * + * @param body - The validated chat request body + * @param responseMessages - The assistant response messages + */ +export async function handleChatCompletion( + body: ChatRequestBody, + responseMessages: UIMessage[], +): Promise { + try { + const { messages, roomId = "", accountId, artistId } = body; + + // Get account email + let email = ""; + const emails = await selectAccountEmails({ accountIds: accountId }); + if (emails.length > 0 && emails[0].email) { + email = emails[0].email; + } + + // Validate and get last user message + const { lastMessage } = validateMessages(messages); + + // Check if room exists + const room = await selectRoom(roomId); + + // Create room and send notification if this is a new conversation + if (!room) { + const latestMessageText = + lastMessage.parts.find((part) => part.type === "text")?.text || ""; + const conversationName = await generateChatTitle(latestMessageText); + + await Promise.all([ + insertRoom({ + id: roomId, + account_id: accountId, + topic: conversationName, + artist_id: artistId || null, + }), + sendNewConversationNotification({ + accountId, + email, + conversationId: roomId, + topic: conversationName, + firstMessage: latestMessageText, + }), + ]); + } + + // Store messages sequentially to maintain correct order + // First store the user message, then the assistant message + await upsertMemory({ + id: lastMessage.id, + room_id: roomId, + content: filterMessageContentForMemories(lastMessage), + }); + + await upsertMemory({ + id: responseMessages[responseMessages.length - 1].id, + room_id: roomId, + content: filterMessageContentForMemories(responseMessages[responseMessages.length - 1]), + }); + + // Process any email tool outputs + await handleSendEmailToolOutputs(responseMessages); + } catch (error) { + sendErrorNotification({ + ...body, + path: "/api/chat", + error: serializeError(error), + }); + console.error("Failed to save chat", error); + } +} diff --git a/lib/chat/validateMessages.ts b/lib/chat/validateMessages.ts new file mode 100644 index 00000000..7ae4c8e0 --- /dev/null +++ b/lib/chat/validateMessages.ts @@ -0,0 +1,21 @@ +import type { UIMessage } from "ai"; + +/** + * Validates messages and returns the last message. + * + * @param messages - Array of UI messages to validate + * @returns Object containing lastMessage and validMessages + * @throws Error if no messages provided + */ +export function validateMessages(messages: UIMessage[]) { + if (!messages.length) { + throw new Error("No messages provided"); + } + + return { + lastMessage: messages[messages.length - 1], + validMessages: messages.filter( + (m) => m.parts.find((part) => part.type === "text")?.text?.length, + ), + }; +} diff --git a/lib/emails/extractSendEmailResults.ts b/lib/emails/extractSendEmailResults.ts new file mode 100644 index 00000000..ec4ac2aa --- /dev/null +++ b/lib/emails/extractSendEmailResults.ts @@ -0,0 +1,47 @@ +import { getToolOrDynamicToolName, isToolOrDynamicToolUIPart, UIMessage } from "ai"; + +interface SendEmailResult { + emailId: string; + messageId: string; +} + +/** + * Extracts send_email tool results from response messages. + * Returns an array of email IDs paired with the message ID containing the tool call. + * + * @param responseMessages - The assistant messages from the chat response + * @returns Array of send email results with emailId and messageId + */ +export function extractSendEmailResults(responseMessages: UIMessage[]): SendEmailResult[] { + const results: SendEmailResult[] = []; + + for (const message of responseMessages) { + for (const part of message.parts) { + const isDynamicTool = isToolOrDynamicToolUIPart(part); + if (!isDynamicTool) continue; + const isSendEmailTool = getToolOrDynamicToolName(part) === "send_email"; + if (!isSendEmailTool) continue; + const hasEmailOutput = part.state === "output-available"; + if (!hasEmailOutput) continue; + + const output = part.output as { + content?: Array<{ type?: string; text?: string }>; + isError?: boolean; + }; + + if (!output.content?.[0]?.text) continue; + + const parsed = JSON.parse(output.content[0].text) as { + success?: boolean; + data?: { id?: string }; + }; + if (!parsed.data?.id) continue; + results.push({ + emailId: parsed.data.id, + messageId: message.id, + }); + } + } + + return results; +} diff --git a/lib/emails/handleSendEmailToolOutputs.ts b/lib/emails/handleSendEmailToolOutputs.ts new file mode 100644 index 00000000..b2224b97 --- /dev/null +++ b/lib/emails/handleSendEmailToolOutputs.ts @@ -0,0 +1,24 @@ +import type { UIMessage } from "ai"; +import { extractSendEmailResults } from "./extractSendEmailResults"; +import insertMemoryEmail from "@/lib/supabase/memory_emails/insertMemoryEmail"; + +/** + * Processes send_email tool outputs from response messages. + * Extracts email IDs and links them to the corresponding memory messages. + * + * @param responseMessages - The assistant messages from the chat response + */ +export async function handleSendEmailToolOutputs(responseMessages: UIMessage[]): Promise { + const emailResults = extractSendEmailResults(responseMessages); + if (emailResults.length === 0) return; + + await Promise.all( + emailResults.map(({ emailId, messageId }) => + insertMemoryEmail({ + email_id: emailId, + memory: messageId, + message_id: messageId, + }), + ), + ); +} diff --git a/lib/errors/serializeError.ts b/lib/errors/serializeError.ts new file mode 100644 index 00000000..2f495eec --- /dev/null +++ b/lib/errors/serializeError.ts @@ -0,0 +1,29 @@ +/** + * Interface for serialized error objects + */ +export interface SerializedError { + name: string; + message: string; + stack?: string; +} + +/** + * Extracts serializable properties from error objects. + * Ensures errors can be properly JSON serialized. + * + * @param error - The error to serialize + * @returns A serializable error object + */ +export function serializeError(error: unknown): SerializedError { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { + name: "UnknownError", + message: String(error), + }; +} diff --git a/lib/supabase/memories/upsertMemory.ts b/lib/supabase/memories/upsertMemory.ts new file mode 100644 index 00000000..07e07097 --- /dev/null +++ b/lib/supabase/memories/upsertMemory.ts @@ -0,0 +1,31 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type Memory = Tables<"memories">; + +type UpsertMemoryParams = Pick; + +/** + * Upserts a memory into the memories table. + * If a memory with the same ID exists, it will be updated. + * + * @param params - The parameters for the memory + * @param params.id - The ID of the memory + * @param params.room_id - The ID of the room + * @param params.content - The content of the memory + * @returns The upserted memory, or null if the upsert fails + */ +export default async function upsertMemory(params: UpsertMemoryParams): Promise { + const { data, error } = await supabase + .from("memories") + .upsert(params, { onConflict: "id" }) + .select() + .single(); + + if (error) { + console.error("Error upserting memory:", error); + throw error; + } + + return data; +} diff --git a/lib/supabase/rooms/selectRoom.ts b/lib/supabase/rooms/selectRoom.ts new file mode 100644 index 00000000..a50bf02d --- /dev/null +++ b/lib/supabase/rooms/selectRoom.ts @@ -0,0 +1,22 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type Room = Tables<"rooms">; + +/** + * Selects a room by its ID. + * + * @param roomId - The ID of the room to select + * @returns The room data or null if not found + */ +export default async function selectRoom(roomId: string): Promise { + if (!roomId) return null; + + const { data, error } = await supabase.from("rooms").select("*").eq("id", roomId).single(); + + if (error) { + return null; + } + + return data; +} diff --git a/lib/telegram/sendErrorNotification.ts b/lib/telegram/sendErrorNotification.ts new file mode 100644 index 00000000..ad9fe2e5 --- /dev/null +++ b/lib/telegram/sendErrorNotification.ts @@ -0,0 +1,55 @@ +import type { UIMessage } from "ai"; +import { sendMessage } from "./sendMessage"; +import type { SerializedError } from "@/lib/errors/serializeError"; + +export interface ErrorContext { + email?: string; + roomId?: string; + messages?: UIMessage[]; + path: string; + error: SerializedError; +} + +/** + * Formats an error context into a Telegram message. + * + * @param context - The error context to format + * @returns Formatted error message string + */ +function formatErrorMessage(context: ErrorContext): string { + const { path, error, roomId, email } = context; + const lines = [ + `*Error in ${path}*`, + "", + `*Error:* ${error.name}`, + `*Message:* ${error.message}`, + ]; + + if (roomId) { + lines.push(`*Room ID:* ${roomId}`); + } + + if (email) { + lines.push(`*Email:* ${email}`); + } + + lines.push(""); + lines.push(`*Time:* ${new Date().toISOString()}`); + + return lines.join("\n"); +} + +/** + * Sends error notification to Telegram. + * Non-blocking to avoid impacting API operations. + * + * @param params - The error context parameters + */ +export async function sendErrorNotification(params: ErrorContext): Promise { + try { + const message = formatErrorMessage(params); + await sendMessage(message, { parse_mode: "Markdown" }); + } catch (err) { + console.error("Error in sendErrorNotification:", err); + } +} From dd3a15058d68aa6ce94916ea8ae1235618f7127f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:38:30 -0500 Subject: [PATCH 13/19] test: add unit tests for getGeneralAgent and supporting functions Add comprehensive test coverage for the chat agent system: - getGeneralAgent: 23 tests covering agent creation, account lookups, artist context - getSystemPrompt: 29 tests for context values, user/artist sections, knowledge base - extractImageUrlsFromMessages: 19 tests for media type filtering and edge cases - buildSystemPromptWithImages: 12 tests for output formatting - getKnowledgeBaseText: 21 tests for file type filtering and content fetching Total: 104 new unit tests bringing test count to 317 Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getGeneralAgent.test.ts | 446 ++++++++++++++++++ .../buildSystemPromptWithImages.test.ts | 106 +++++ .../__tests__/getKnowledgeBaseText.test.ts | 271 +++++++++++ .../extractImageUrlsFromMessages.test.ts | 249 ++++++++++ lib/prompts/__tests__/getSystemPrompt.test.ts | 257 ++++++++++ 5 files changed, 1329 insertions(+) create mode 100644 lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts create mode 100644 lib/chat/__tests__/buildSystemPromptWithImages.test.ts create mode 100644 lib/files/__tests__/getKnowledgeBaseText.test.ts create mode 100644 lib/messages/__tests__/extractImageUrlsFromMessages.test.ts create mode 100644 lib/prompts/__tests__/getSystemPrompt.test.ts diff --git a/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts b/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts new file mode 100644 index 00000000..832e56fc --- /dev/null +++ b/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; +import { ToolLoopAgent, stepCountIs } from "ai"; + +// Mock all external dependencies +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_info/selectAccountInfo", () => ({ + selectAccountInfo: vi.fn(), +})); + +vi.mock("@/lib/supabase/accounts/getAccountWithDetails", () => ({ + getAccountWithDetails: vi.fn(), +})); + +vi.mock("@/lib/files/getKnowledgeBaseText", () => ({ + getKnowledgeBaseText: vi.fn(), +})); + +vi.mock("@/lib/chat/setupToolsForRequest", () => ({ + setupToolsForRequest: vi.fn(), +})); + +vi.mock("@/lib/prompts/getSystemPrompt", () => ({ + getSystemPrompt: vi.fn(), +})); + +vi.mock("@/lib/messages/extractImageUrlsFromMessages", () => ({ + extractImageUrlsFromMessages: vi.fn(), +})); + +vi.mock("@/lib/chat/buildSystemPromptWithImages", () => ({ + buildSystemPromptWithImages: vi.fn(), +})); + +// Import after mocks +import getGeneralAgent from "../getGeneralAgent"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; +import { setupToolsForRequest } from "@/lib/chat/setupToolsForRequest"; +import { getSystemPrompt } from "@/lib/prompts/getSystemPrompt"; +import { extractImageUrlsFromMessages } from "@/lib/messages/extractImageUrlsFromMessages"; +import { buildSystemPromptWithImages } from "@/lib/chat/buildSystemPromptWithImages"; + +const mockSelectAccountEmails = vi.mocked(selectAccountEmails); +const mockSelectAccountInfo = vi.mocked(selectAccountInfo); +const mockGetAccountWithDetails = vi.mocked(getAccountWithDetails); +const mockGetKnowledgeBaseText = vi.mocked(getKnowledgeBaseText); +const mockSetupToolsForRequest = vi.mocked(setupToolsForRequest); +const mockGetSystemPrompt = vi.mocked(getSystemPrompt); +const mockExtractImageUrls = vi.mocked(extractImageUrlsFromMessages); +const mockBuildSystemPromptWithImages = vi.mocked(buildSystemPromptWithImages); + +describe("getGeneralAgent", () => { + const mockTools = { tool1: {}, tool2: {} }; + const baseSystemPrompt = "You are Recoup..."; + const finalInstructions = "You are Recoup... with images"; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up default mock returns + mockSelectAccountEmails.mockResolvedValue([ + { email: "user@example.com", account_id: "account-123" } as any, + ]); + mockSelectAccountInfo.mockResolvedValue(null); + mockGetAccountWithDetails.mockResolvedValue({ + id: "account-123", + name: "Test User", + email: "user@example.com", + } as any); + mockGetKnowledgeBaseText.mockResolvedValue(undefined); + mockSetupToolsForRequest.mockResolvedValue(mockTools); + mockGetSystemPrompt.mockReturnValue(baseSystemPrompt); + mockExtractImageUrls.mockReturnValue([]); + mockBuildSystemPromptWithImages.mockReturnValue(finalInstructions); + }); + + describe("basic functionality", () => { + it("returns a RoutingDecision object with required properties", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result).toHaveProperty("agent"); + expect(result).toHaveProperty("model"); + expect(result).toHaveProperty("instructions"); + expect(result).toHaveProperty("stopWhen"); + }); + + it("returns a ToolLoopAgent instance", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result.agent).toBeInstanceOf(ToolLoopAgent); + }); + + it("uses DEFAULT_MODEL when no model is specified", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result.model).toBe("openai/gpt-5-mini"); + }); + + it("uses custom model when specified in body", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + model: "anthropic/claude-3-opus", + }; + + const result = await getGeneralAgent(body); + + expect(result.model).toBe("anthropic/claude-3-opus"); + }); + + it("sets stopWhen to stepCountIs(111)", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result.stopWhen).toBeDefined(); + // stepCountIs returns a function, verify it's the expected type + expect(typeof result.stopWhen).toBe("function"); + }); + }); + + describe("account email lookup", () => { + it("fetches account emails using accountId", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockSelectAccountEmails).toHaveBeenCalledWith({ + accountIds: "account-123", + }); + }); + + it("uses first email from account emails list", async () => { + mockSelectAccountEmails.mockResolvedValue([ + { email: "first@example.com", account_id: "account-123" } as any, + { email: "second@example.com", account_id: "account-123" } as any, + ]); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockGetSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ email: "first@example.com" }), + ); + }); + + it("handles empty account emails gracefully", async () => { + mockSelectAccountEmails.mockResolvedValue([]); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockGetSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ email: undefined }), + ); + }); + }); + + describe("artist context", () => { + it("fetches artist info when artistId is provided", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + artistId: "artist-456", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockSelectAccountInfo).toHaveBeenCalledWith("artist-456"); + }); + + it("does not fetch artist info when artistId is not provided", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockSelectAccountInfo).not.toHaveBeenCalled(); + }); + + it("extracts artist instruction from account info", async () => { + mockSelectAccountInfo.mockResolvedValue({ + instruction: "Always be formal with this artist", + knowledges: [], + } as any); + + const body: ChatRequestBody = { + accountId: "account-123", + artistId: "artist-456", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockGetSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + artistInstruction: "Always be formal with this artist", + }), + ); + }); + + it("fetches knowledge base text when artist has knowledges", async () => { + const mockKnowledges = [ + { name: "faq.md", url: "https://example.com/faq.md", type: "text/markdown" }, + ]; + mockSelectAccountInfo.mockResolvedValue({ + instruction: null, + knowledges: mockKnowledges, + } as any); + mockGetKnowledgeBaseText.mockResolvedValue("FAQ content here"); + + const body: ChatRequestBody = { + accountId: "account-123", + artistId: "artist-456", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockGetKnowledgeBaseText).toHaveBeenCalledWith(mockKnowledges); + expect(mockGetSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ knowledgeBaseText: "FAQ content here" }), + ); + }); + }); + + describe("system prompt generation", () => { + it("calls getSystemPrompt with all required parameters", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + artistId: "artist-456", + roomId: "room-789", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockGetSystemPrompt).toHaveBeenCalledWith({ + roomId: "room-789", + artistId: "artist-456", + accountId: "account-123", + email: "user@example.com", + artistInstruction: undefined, + knowledgeBaseText: undefined, + accountWithDetails: expect.any(Object), + }); + }); + + it("passes accountWithDetails to getSystemPrompt", async () => { + const mockAccountDetails = { + id: "account-123", + name: "Test User", + email: "user@example.com", + job_title: "Music Manager", + }; + mockGetAccountWithDetails.mockResolvedValue(mockAccountDetails as any); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGeneralAgent(body); + + expect(mockGetSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ accountWithDetails: mockAccountDetails }), + ); + }); + }); + + describe("image URL handling", () => { + it("extracts image URLs from messages", async () => { + const messages = [ + { + id: "1", + role: "user", + content: "Edit this image", + parts: [{ type: "file", mediaType: "image/png", url: "https://example.com/image.png" }], + }, + ]; + const body: ChatRequestBody = { + accountId: "account-123", + messages, + }; + + await getGeneralAgent(body); + + expect(mockExtractImageUrls).toHaveBeenCalledWith(messages); + }); + + it("builds system prompt with image URLs", async () => { + mockExtractImageUrls.mockReturnValue([ + "https://example.com/image1.png", + "https://example.com/image2.png", + ]); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Edit these images" }], + }; + + await getGeneralAgent(body); + + expect(mockBuildSystemPromptWithImages).toHaveBeenCalledWith(baseSystemPrompt, [ + "https://example.com/image1.png", + "https://example.com/image2.png", + ]); + }); + + it("uses final instructions from buildSystemPromptWithImages", async () => { + mockBuildSystemPromptWithImages.mockReturnValue("Final system prompt with images"); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result.instructions).toBe("Final system prompt with images"); + }); + }); + + describe("tools setup", () => { + it("calls setupToolsForRequest with body", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + excludeTools: ["dangerous_tool"], + }; + + await getGeneralAgent(body); + + expect(mockSetupToolsForRequest).toHaveBeenCalledWith(body); + }); + + it("includes tools in returned agent", async () => { + const customTools = { myTool: {}, anotherTool: {} }; + mockSetupToolsForRequest.mockResolvedValue(customTools); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result.agent.tools).toEqual(customTools); + }); + }); + + describe("agent configuration", () => { + it("creates ToolLoopAgent with model matching result model", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + model: "anthropic/claude-sonnet-4", + }; + + const result = await getGeneralAgent(body); + + // The agent is created with the model, which is also returned in result.model + expect(result.model).toBe("anthropic/claude-sonnet-4"); + expect(result.agent).toBeInstanceOf(ToolLoopAgent); + }); + + it("creates ToolLoopAgent with instructions matching result instructions", async () => { + mockBuildSystemPromptWithImages.mockReturnValue("Complete system instructions"); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + // Instructions returned in result match what was used to create the agent + expect(result.instructions).toBe("Complete system instructions"); + expect(result.agent).toBeInstanceOf(ToolLoopAgent); + }); + + it("creates ToolLoopAgent with tools accessible via agent.tools", async () => { + const expectedTools = { toolA: {}, toolB: {} }; + mockSetupToolsForRequest.mockResolvedValue(expectedTools); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + expect(result.agent.tools).toEqual(expectedTools); + }); + + it("returns stopWhen in the routing decision", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGeneralAgent(body); + + // stopWhen is returned in the routing decision (stepCountIs returns a function) + expect(result.stopWhen).toBeDefined(); + expect(typeof result.stopWhen).toBe("function"); + }); + }); +}); diff --git a/lib/chat/__tests__/buildSystemPromptWithImages.test.ts b/lib/chat/__tests__/buildSystemPromptWithImages.test.ts new file mode 100644 index 00000000..37717cee --- /dev/null +++ b/lib/chat/__tests__/buildSystemPromptWithImages.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { buildSystemPromptWithImages } from "../buildSystemPromptWithImages"; + +describe("buildSystemPromptWithImages", () => { + describe("basic functionality", () => { + it("returns base prompt unchanged when no image URLs", () => { + const basePrompt = "You are a helpful assistant."; + const result = buildSystemPromptWithImages(basePrompt, []); + expect(result).toBe(basePrompt); + }); + + it("appends image URLs section when images provided", () => { + const basePrompt = "You are a helpful assistant."; + const imageUrls = ["https://example.com/image.png"]; + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + expect(result).toContain(basePrompt); + expect(result).toContain("**ATTACHED IMAGE URLS"); + expect(result).toContain("https://example.com/image.png"); + }); + + it("formats multiple images with index numbers", () => { + const basePrompt = "Base prompt"; + const imageUrls = [ + "https://example.com/1.png", + "https://example.com/2.jpg", + "https://example.com/3.gif", + ]; + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + expect(result).toContain("- Image 0: https://example.com/1.png"); + expect(result).toContain("- Image 1: https://example.com/2.jpg"); + expect(result).toContain("- Image 2: https://example.com/3.gif"); + }); + }); + + describe("output format", () => { + it("separates base prompt from images section with newlines", () => { + const basePrompt = "Base prompt here"; + const imageUrls = ["https://example.com/test.png"]; + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + expect(result).toMatch(/Base prompt here\n\n\*\*ATTACHED IMAGE URLS/); + }); + + it("includes edit_image context in section header", () => { + const basePrompt = "Base"; + const imageUrls = ["https://example.com/test.png"]; + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + expect(result).toContain("(for edit_image imageUrl parameter)"); + }); + + it("lists each image on its own line", () => { + const basePrompt = "Base"; + const imageUrls = ["https://example.com/a.png", "https://example.com/b.png"]; + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + const lines = result.split("\n"); + const imageLines = lines.filter((line) => line.startsWith("- Image")); + expect(imageLines).toHaveLength(2); + }); + }); + + describe("edge cases", () => { + it("handles empty base prompt", () => { + const result = buildSystemPromptWithImages("", ["https://example.com/test.png"]); + + expect(result).toContain("**ATTACHED IMAGE URLS"); + expect(result).toContain("https://example.com/test.png"); + }); + + it("handles single image", () => { + const basePrompt = "Single image test"; + const result = buildSystemPromptWithImages(basePrompt, ["https://example.com/only.png"]); + + expect(result).toContain("- Image 0: https://example.com/only.png"); + expect(result).not.toContain("Image 1"); + }); + + it("handles many images", () => { + const basePrompt = "Many images"; + const imageUrls = Array.from({ length: 10 }, (_, i) => `https://example.com/${i}.png`); + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + expect(result).toContain("- Image 0:"); + expect(result).toContain("- Image 9:"); + }); + + it("preserves special characters in URLs", () => { + const basePrompt = "Test"; + const imageUrls = ["https://example.com/image%20with%20spaces.png?token=abc&id=123"]; + const result = buildSystemPromptWithImages(basePrompt, imageUrls); + + expect(result).toContain("https://example.com/image%20with%20spaces.png?token=abc&id=123"); + }); + + it("preserves multiline base prompts", () => { + const basePrompt = "Line 1\nLine 2\nLine 3"; + const result = buildSystemPromptWithImages(basePrompt, ["https://example.com/test.png"]); + + expect(result).toContain("Line 1\nLine 2\nLine 3"); + expect(result).toContain("**ATTACHED IMAGE URLS"); + }); + }); +}); diff --git a/lib/files/__tests__/getKnowledgeBaseText.test.ts b/lib/files/__tests__/getKnowledgeBaseText.test.ts new file mode 100644 index 00000000..5dbb9583 --- /dev/null +++ b/lib/files/__tests__/getKnowledgeBaseText.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getKnowledgeBaseText } from "../getKnowledgeBaseText"; + +describe("getKnowledgeBaseText", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe("input validation", () => { + it("returns undefined for null input", async () => { + const result = await getKnowledgeBaseText(null); + expect(result).toBeUndefined(); + }); + + it("returns undefined for undefined input", async () => { + const result = await getKnowledgeBaseText(undefined); + expect(result).toBeUndefined(); + }); + + it("returns undefined for empty array", async () => { + const result = await getKnowledgeBaseText([]); + expect(result).toBeUndefined(); + }); + + it("returns undefined for non-array input", async () => { + const result = await getKnowledgeBaseText("not an array"); + expect(result).toBeUndefined(); + }); + + it("returns undefined for object input", async () => { + const result = await getKnowledgeBaseText({ key: "value" }); + expect(result).toBeUndefined(); + }); + }); + + describe("file type filtering", () => { + it("includes text/plain files", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("Plain text content"), + } as Response); + + const knowledges = [{ name: "notes.txt", url: "https://example.com/notes.txt", type: "text/plain" }]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("Plain text content"); + }); + + it("includes text/markdown files", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("# Markdown heading"), + } as Response); + + const knowledges = [ + { name: "readme.md", url: "https://example.com/readme.md", type: "text/markdown" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("# Markdown heading"); + }); + + it("includes application/json files", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve('{"key": "value"}'), + } as Response); + + const knowledges = [ + { name: "config.json", url: "https://example.com/config.json", type: "application/json" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain('{"key": "value"}'); + }); + + it("includes text/csv files", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("name,email\nJohn,john@example.com"), + } as Response); + + const knowledges = [ + { name: "data.csv", url: "https://example.com/data.csv", type: "text/csv" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("name,email"); + }); + + it("excludes image files", async () => { + const knowledges = [ + { name: "photo.jpg", url: "https://example.com/photo.jpg", type: "image/jpeg" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("excludes PDF files", async () => { + const knowledges = [ + { name: "doc.pdf", url: "https://example.com/doc.pdf", type: "application/pdf" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("excludes audio files", async () => { + const knowledges = [ + { name: "song.mp3", url: "https://example.com/song.mp3", type: "audio/mpeg" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + }); + }); + + describe("content fetching", () => { + it("fetches content from URL", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("Fetched content"), + } as Response); + + const knowledges = [ + { name: "file.txt", url: "https://example.com/file.txt", type: "text/plain" }, + ]; + await getKnowledgeBaseText(knowledges); + + expect(global.fetch).toHaveBeenCalledWith("https://example.com/file.txt"); + }); + + it("handles failed fetch gracefully", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve(""), + } as Response); + + const knowledges = [ + { name: "missing.txt", url: "https://example.com/missing.txt", type: "text/plain" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + }); + + it("handles fetch errors gracefully", async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + + const knowledges = [ + { name: "file.txt", url: "https://example.com/file.txt", type: "text/plain" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + }); + }); + + describe("content formatting", () => { + it("prefixes content with file name header", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("File content"), + } as Response); + + const knowledges = [ + { name: "myfile.txt", url: "https://example.com/myfile.txt", type: "text/plain" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("--- myfile.txt ---"); + expect(result).toContain("File content"); + }); + + it("uses Unknown for missing file name", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("Content"), + } as Response); + + const knowledges = [{ url: "https://example.com/file.txt", type: "text/plain" }]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("--- Unknown ---"); + }); + + it("combines multiple files with double newlines", async () => { + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("Content A"), + } as Response) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("Content B"), + } as Response); + + const knowledges = [ + { name: "a.txt", url: "https://example.com/a.txt", type: "text/plain" }, + { name: "b.txt", url: "https://example.com/b.txt", type: "text/plain" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("--- a.txt ---"); + expect(result).toContain("--- b.txt ---"); + expect(result).toContain("Content A"); + expect(result).toContain("Content B"); + }); + }); + + describe("edge cases", () => { + it("filters out files without URL", async () => { + const knowledges = [{ name: "no-url.txt", type: "text/plain" }]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("filters out files without type", async () => { + const knowledges = [{ name: "no-type.txt", url: "https://example.com/no-type.txt" }]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("handles mix of valid and invalid files", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("Valid content"), + } as Response); + + const knowledges = [ + { name: "valid.txt", url: "https://example.com/valid.txt", type: "text/plain" }, + { name: "image.jpg", url: "https://example.com/image.jpg", type: "image/jpeg" }, + { name: "no-url.txt", type: "text/plain" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toContain("Valid content"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when all fetches fail", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + text: () => Promise.resolve(""), + } as Response); + + const knowledges = [ + { name: "a.txt", url: "https://example.com/a.txt", type: "text/plain" }, + { name: "b.txt", url: "https://example.com/b.txt", type: "text/plain" }, + ]; + const result = await getKnowledgeBaseText(knowledges); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts b/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts new file mode 100644 index 00000000..ccc125ea --- /dev/null +++ b/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from "vitest"; +import { extractImageUrlsFromMessages } from "../extractImageUrlsFromMessages"; +import { UIMessage } from "ai"; + +describe("extractImageUrlsFromMessages", () => { + describe("basic functionality", () => { + it("returns empty array for empty messages", () => { + const result = extractImageUrlsFromMessages([]); + expect(result).toEqual([]); + }); + + it("returns empty array for messages without parts", () => { + const messages: UIMessage[] = [ + { id: "1", role: "user", content: "Hello" } as UIMessage, + ]; + const result = extractImageUrlsFromMessages(messages); + expect(result).toEqual([]); + }); + + it("extracts image URLs from file parts", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Check this image", + parts: [ + { type: "file", mediaType: "image/png", url: "https://example.com/image.png" }, + ], + } as UIMessage, + ]; + const result = extractImageUrlsFromMessages(messages); + expect(result).toEqual(["https://example.com/image.png"]); + }); + + it("extracts multiple image URLs from multiple messages", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Image 1", + parts: [ + { type: "file", mediaType: "image/png", url: "https://example.com/1.png" }, + ], + } as UIMessage, + { + id: "2", + role: "user", + content: "Image 2", + parts: [ + { type: "file", mediaType: "image/jpeg", url: "https://example.com/2.jpg" }, + ], + } as UIMessage, + ]; + const result = extractImageUrlsFromMessages(messages); + expect(result).toEqual(["https://example.com/1.png", "https://example.com/2.jpg"]); + }); + + it("extracts multiple image URLs from same message", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Multiple images", + parts: [ + { type: "file", mediaType: "image/png", url: "https://example.com/a.png" }, + { type: "file", mediaType: "image/gif", url: "https://example.com/b.gif" }, + ], + } as UIMessage, + ]; + const result = extractImageUrlsFromMessages(messages); + expect(result).toEqual(["https://example.com/a.png", "https://example.com/b.gif"]); + }); + }); + + describe("media type filtering", () => { + it("includes image/png files", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/png", url: "https://example.com/test.png" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toHaveLength(1); + }); + + it("includes image/jpeg files", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/jpeg", url: "https://example.com/test.jpg" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toHaveLength(1); + }); + + it("includes image/gif files", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/gif", url: "https://example.com/test.gif" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toHaveLength(1); + }); + + it("includes image/webp files", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/webp", url: "https://example.com/test.webp" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toHaveLength(1); + }); + + it("excludes non-image files (application/pdf)", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [ + { type: "file", mediaType: "application/pdf", url: "https://example.com/doc.pdf" }, + ], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + + it("excludes audio files", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "audio/mp3", url: "https://example.com/song.mp3" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + + it("excludes video files", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "video/mp4", url: "https://example.com/video.mp4" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + }); + + describe("edge cases", () => { + it("ignores parts without mediaType", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", url: "https://example.com/test.png" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + + it("ignores parts without url", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/png" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + + it("ignores parts with empty url", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/png", url: "" }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + + it("ignores parts with whitespace-only url", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [{ type: "file", mediaType: "image/png", url: " " }], + } as UIMessage, + ]; + expect(extractImageUrlsFromMessages(messages)).toEqual([]); + }); + + it("ignores non-file type parts", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [ + { type: "text", text: "hello" }, + { type: "file", mediaType: "image/png", url: "https://example.com/valid.png" }, + ], + } as UIMessage, + ]; + const result = extractImageUrlsFromMessages(messages); + expect(result).toEqual(["https://example.com/valid.png"]); + }); + + it("handles mixed valid and invalid parts", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "", + parts: [ + { type: "file", mediaType: "image/png", url: "https://example.com/valid.png" }, + { type: "file", mediaType: "application/pdf", url: "https://example.com/doc.pdf" }, + { type: "file", mediaType: "image/jpeg", url: "" }, + { type: "file", mediaType: "image/gif", url: "https://example.com/valid.gif" }, + ], + } as UIMessage, + ]; + const result = extractImageUrlsFromMessages(messages); + expect(result).toEqual([ + "https://example.com/valid.png", + "https://example.com/valid.gif", + ]); + }); + }); +}); diff --git a/lib/prompts/__tests__/getSystemPrompt.test.ts b/lib/prompts/__tests__/getSystemPrompt.test.ts new file mode 100644 index 00000000..942608af --- /dev/null +++ b/lib/prompts/__tests__/getSystemPrompt.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from "vitest"; +import { getSystemPrompt } from "../getSystemPrompt"; + +describe("getSystemPrompt", () => { + describe("basic functionality", () => { + it("returns a string prompt", () => { + const result = getSystemPrompt({ accountId: "account-123" }); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("includes base system prompt content", () => { + const result = getSystemPrompt({ accountId: "account-123" }); + expect(result).toContain("Recoup"); + expect(result).toContain("music industry"); + }); + }); + + describe("context values section", () => { + it("includes accountId in context", () => { + const result = getSystemPrompt({ accountId: "acc-12345" }); + expect(result).toContain("account_id: acc-12345"); + }); + + it("shows Unknown for missing accountId", () => { + const result = getSystemPrompt({ accountId: "" }); + expect(result).toContain("account_id: Unknown"); + }); + + it("includes artistId as artist_account_id", () => { + const result = getSystemPrompt({ accountId: "acc-1", artistId: "artist-789" }); + expect(result).toContain("artist_account_id: artist-789"); + }); + + it("includes email in context", () => { + const result = getSystemPrompt({ accountId: "acc-1", email: "user@example.com" }); + expect(result).toContain("active_account_email: user@example.com"); + }); + + it("shows Unknown for missing email", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).toContain("active_account_email: Unknown"); + }); + + it("includes roomId as conversation id", () => { + const result = getSystemPrompt({ accountId: "acc-1", roomId: "room-456" }); + expect(result).toContain("active_conversation_id: room-456"); + }); + + it("shows No ID for missing roomId", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).toContain("active_conversation_id: No ID"); + }); + + it("includes conversationName", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + conversationName: "Marketing Chat", + }); + expect(result).toContain("active_conversation_name: Marketing Chat"); + }); + + it("defaults conversationName to New conversation", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).toContain("active_conversation_name: New conversation"); + }); + }); + + describe("image editing instructions", () => { + it("includes image editing instructions", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).toContain("**IMAGE EDITING INSTRUCTIONS:**"); + }); + + it("includes guidance about which image to edit", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).toContain("WHICH IMAGE TO EDIT"); + }); + + it("includes guidance about how to call the tool", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).toContain("HOW TO CALL THE TOOL"); + }); + }); + + describe("user context section", () => { + it("includes user context when accountWithDetails provided", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { + id: "acc-1", + name: "John Doe", + email: "john@example.com", + } as any, + }); + expect(result).toContain("-----CURRENT USER CONTEXT-----"); + expect(result).toContain("Name: John Doe"); + expect(result).toContain("Email: john@example.com"); + }); + + it("shows Not provided for missing user name", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { + id: "acc-1", + } as any, + }); + expect(result).toContain("Name: Not provided"); + }); + + it("includes professional context when available", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { + id: "acc-1", + job_title: "Music Manager", + role_type: "Manager", + company_name: "Warner Music", + organization: "Warner Records", + } as any, + }); + expect(result).toContain("Professional Context:"); + expect(result).toContain("Job Title: Music Manager"); + expect(result).toContain("Role Type: Manager"); + expect(result).toContain("Company: Warner Music"); + expect(result).toContain("Organization: Warner Records"); + }); + + it("omits professional context section if no professional fields", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { + id: "acc-1", + name: "John", + } as any, + }); + expect(result).not.toContain("Professional Context:"); + }); + + it("includes user custom instructions when available", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { + id: "acc-1", + instruction: "Always respond in formal English", + } as any, + }); + expect(result).toContain("User's Custom Instructions & Preferences:"); + expect(result).toContain("Always respond in formal English"); + }); + + it("includes end marker for user context", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { id: "acc-1" } as any, + }); + expect(result).toContain("-----END USER CONTEXT-----"); + }); + + it("uses email from accountWithDetails if available", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + email: "passed@example.com", + accountWithDetails: { + id: "acc-1", + email: "details@example.com", + } as any, + }); + expect(result).toContain("Email: details@example.com"); + }); + + it("falls back to email param if accountWithDetails email is missing", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + email: "fallback@example.com", + accountWithDetails: { + id: "acc-1", + } as any, + }); + expect(result).toContain("Email: fallback@example.com"); + }); + }); + + describe("artist context section", () => { + it("includes artist context when artistInstruction provided", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + artistInstruction: "Always mention tour dates", + }); + expect(result).toContain("-----SELECTED ARTIST/WORKSPACE CONTEXT-----"); + expect(result).toContain("Always mention tour dates"); + }); + + it("includes end marker for artist context", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + artistInstruction: "Some instruction", + }); + expect(result).toContain("-----END ARTIST/WORKSPACE CONTEXT-----"); + }); + + it("omits artist context section when no artistInstruction", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).not.toContain("-----SELECTED ARTIST/WORKSPACE CONTEXT-----"); + }); + }); + + describe("knowledge base section", () => { + it("includes knowledge base when knowledgeBaseText provided", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + knowledgeBaseText: "FAQ: What is Recoup?\nAnswer: AI platform for music", + }); + expect(result).toContain("-----ARTIST/WORKSPACE KNOWLEDGE BASE-----"); + expect(result).toContain("FAQ: What is Recoup?"); + }); + + it("includes end marker for knowledge base", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + knowledgeBaseText: "Some knowledge", + }); + expect(result).toContain("-----END ARTIST/WORKSPACE KNOWLEDGE BASE-----"); + }); + + it("omits knowledge base section when no knowledgeBaseText", () => { + const result = getSystemPrompt({ accountId: "acc-1" }); + expect(result).not.toContain("-----ARTIST/WORKSPACE KNOWLEDGE BASE-----"); + }); + }); + + describe("section ordering", () => { + it("places user context before artist context", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + accountWithDetails: { id: "acc-1", name: "User" } as any, + artistInstruction: "Artist instructions", + }); + + const userIndex = result.indexOf("CURRENT USER CONTEXT"); + const artistIndex = result.indexOf("SELECTED ARTIST/WORKSPACE"); + expect(userIndex).toBeLessThan(artistIndex); + }); + + it("places artist context before knowledge base", () => { + const result = getSystemPrompt({ + accountId: "acc-1", + artistInstruction: "Artist instructions", + knowledgeBaseText: "Knowledge content", + }); + + const artistIndex = result.indexOf("SELECTED ARTIST/WORKSPACE"); + const kbIndex = result.indexOf("KNOWLEDGE BASE"); + expect(artistIndex).toBeLessThan(kbIndex); + }); + }); +}); From d86309ca9471fee95e0f8c18390721ec9f042b93 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:45:31 -0500 Subject: [PATCH 14/19] feat: complete setupToolsForRequest migration with Google Sheets integration Add Google Sheets tools integration via Composio to setupToolsForRequest. Now aggregates tools from MCP client and Google Sheets: - If user is authenticated: returns full Composio Google Sheets toolkit - If not authenticated: returns google_sheets_login tool for OAuth flow New files: - getGoogleSheetsTools: checks auth status and returns appropriate tools - getLatestUserMessageText: extracts latest user message for callback URL - googleSheetsLoginTool: initiates OAuth authentication via Composio Added 32 unit tests for setupToolsForRequest (11), getGoogleSheetsTools (10), and getLatestUserMessageText (11). All 349 tests pass. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getGoogleSheetsTools.test.ts | 229 ++++++++++++++++++ .../googleSheetsAgent/getGoogleSheetsTools.ts | 44 ++++ lib/agents/googleSheetsAgent/index.ts | 3 + .../__tests__/setupToolsForRequest.test.ts | 216 +++++++++++++++++ lib/chat/setupToolsForRequest.ts | 22 +- lib/composio/tools/googleSheetsLoginTool.ts | 24 ++ .../getLatestUserMessageText.test.ts | 208 ++++++++++++++++ lib/messages/getLatestUserMessageText.ts | 13 + 8 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts create mode 100644 lib/agents/googleSheetsAgent/getGoogleSheetsTools.ts create mode 100644 lib/agents/googleSheetsAgent/index.ts create mode 100644 lib/chat/__tests__/setupToolsForRequest.test.ts create mode 100644 lib/composio/tools/googleSheetsLoginTool.ts create mode 100644 lib/messages/__tests__/getLatestUserMessageText.test.ts create mode 100644 lib/messages/getLatestUserMessageText.ts diff --git a/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts b/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts new file mode 100644 index 00000000..7cc9bddb --- /dev/null +++ b/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; + +// Mock external dependencies +vi.mock("@/lib/composio/client", () => ({ + getComposioClient: vi.fn(), +})); + +vi.mock("@/lib/composio/googleSheets/getConnectedAccount", () => ({ + default: vi.fn(), + GOOGLE_SHEETS_TOOLKIT_SLUG: "GOOGLESHEETS", +})); + +vi.mock("@/lib/messages/getLatestUserMessageText", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/composio/tools/googleSheetsLoginTool", () => ({ + default: { description: "Login to Google Sheets", parameters: {} }, +})); + +// Import after mocks +import getGoogleSheetsTools from "../getGoogleSheetsTools"; +import { getComposioClient } from "@/lib/composio/client"; +import getConnectedAccount from "@/lib/composio/googleSheets/getConnectedAccount"; +import getLatestUserMessageText from "@/lib/messages/getLatestUserMessageText"; + +const mockGetComposioClient = vi.mocked(getComposioClient); +const mockGetConnectedAccount = vi.mocked(getConnectedAccount); +const mockGetLatestUserMessageText = vi.mocked(getLatestUserMessageText); + +describe("getGoogleSheetsTools", () => { + const mockGoogleSheetsTools = { + googlesheets_create: { description: "Create sheet" }, + googlesheets_read: { description: "Read sheet" }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Default: not authenticated + mockGetConnectedAccount.mockResolvedValue({ + items: [], + } as any); + + mockGetLatestUserMessageText.mockReturnValue("Test message"); + + mockGetComposioClient.mockReturnValue({ + tools: { + get: vi.fn().mockResolvedValue(mockGoogleSheetsTools), + }, + } as any); + }); + + describe("authentication check", () => { + it("calls getConnectedAccount with accountId", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGoogleSheetsTools(body); + + expect(mockGetConnectedAccount).toHaveBeenCalledWith( + "account-123", + expect.any(Object), + ); + }); + + it("passes callback URL with encoded latest user message", async () => { + mockGetLatestUserMessageText.mockReturnValue("Create a spreadsheet for me"); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Create a spreadsheet for me" }], + }; + + await getGoogleSheetsTools(body); + + expect(mockGetConnectedAccount).toHaveBeenCalledWith( + "account-123", + expect.objectContaining({ + callbackUrl: expect.stringContaining("Create%20a%20spreadsheet%20for%20me"), + }), + ); + }); + + it("callback URL uses chat.recoupable.com as base", async () => { + mockGetLatestUserMessageText.mockReturnValue("Test"); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Test" }], + }; + + await getGoogleSheetsTools(body); + + expect(mockGetConnectedAccount).toHaveBeenCalledWith( + "account-123", + expect.objectContaining({ + callbackUrl: expect.stringMatching(/^https:\/\/chat\.recoupable\.com/), + }), + ); + }); + }); + + describe("when user is authenticated", () => { + beforeEach(() => { + mockGetConnectedAccount.mockResolvedValue({ + items: [{ data: { status: "ACTIVE" } }], + } as any); + }); + + it("returns Google Sheets tools from Composio", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGoogleSheetsTools(body); + + expect(result).toHaveProperty("googlesheets_create"); + expect(result).toHaveProperty("googlesheets_read"); + }); + + it("calls composio.tools.get with correct parameters", async () => { + const mockToolsGet = vi.fn().mockResolvedValue(mockGoogleSheetsTools); + mockGetComposioClient.mockReturnValue({ + tools: { get: mockToolsGet }, + } as any); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGoogleSheetsTools(body); + + expect(mockToolsGet).toHaveBeenCalledWith("account-123", { + toolkits: ["GOOGLESHEETS"], + }); + }); + }); + + describe("when user is not authenticated", () => { + beforeEach(() => { + mockGetConnectedAccount.mockResolvedValue({ + items: [], + } as any); + }); + + it("returns google_sheets_login tool", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGoogleSheetsTools(body); + + expect(result).toHaveProperty("google_sheets_login"); + }); + + it("does not call composio.tools.get", async () => { + const mockToolsGet = vi.fn().mockResolvedValue(mockGoogleSheetsTools); + mockGetComposioClient.mockReturnValue({ + tools: { get: mockToolsGet }, + } as any); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await getGoogleSheetsTools(body); + + expect(mockToolsGet).not.toHaveBeenCalled(); + }); + }); + + describe("when connection status is not ACTIVE", () => { + it("returns login tool when status is PENDING", async () => { + mockGetConnectedAccount.mockResolvedValue({ + items: [{ data: { status: "PENDING" } }], + } as any); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGoogleSheetsTools(body); + + expect(result).toHaveProperty("google_sheets_login"); + }); + + it("returns login tool when status is EXPIRED", async () => { + mockGetConnectedAccount.mockResolvedValue({ + items: [{ data: { status: "EXPIRED" } }], + } as any); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await getGoogleSheetsTools(body); + + expect(result).toHaveProperty("google_sheets_login"); + }); + }); + + describe("message text extraction", () => { + it("extracts text from messages using getLatestUserMessageText", async () => { + const messages = [ + { id: "1", role: "user", content: "First message" }, + { id: "2", role: "assistant", content: "Response" }, + { id: "3", role: "user", content: "Second message" }, + ]; + const body: ChatRequestBody = { + accountId: "account-123", + messages, + }; + + await getGoogleSheetsTools(body); + + expect(mockGetLatestUserMessageText).toHaveBeenCalledWith(messages); + }); + }); +}); diff --git a/lib/agents/googleSheetsAgent/getGoogleSheetsTools.ts b/lib/agents/googleSheetsAgent/getGoogleSheetsTools.ts new file mode 100644 index 00000000..1fb12012 --- /dev/null +++ b/lib/agents/googleSheetsAgent/getGoogleSheetsTools.ts @@ -0,0 +1,44 @@ +import { ToolSet } from "ai"; +import { CreateConnectedAccountOptions } from "@composio/core"; +import { getComposioClient } from "@/lib/composio/client"; +import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; +import getLatestUserMessageText from "@/lib/messages/getLatestUserMessageText"; +import getConnectedAccount, { + GOOGLE_SHEETS_TOOLKIT_SLUG, +} from "@/lib/composio/googleSheets/getConnectedAccount"; +import googleSheetsLoginTool from "@/lib/composio/tools/googleSheetsLoginTool"; + +/** Frontend callback URL for Google Sheets authentication */ +const CHAT_CALLBACK_URL = process.env.CHAT_CALLBACK_URL || "https://chat.recoupable.com"; + +/** + * Gets Google Sheets tools for a chat request. + * If the user is authenticated with Google Sheets, returns the full toolkit. + * Otherwise, returns a login tool to initiate authentication. + * + * @param body - The chat request body + * @returns ToolSet containing Google Sheets tools + */ +export default async function getGoogleSheetsTools(body: ChatRequestBody): Promise { + const { accountId, messages } = body; + + const latestUserMessageText = getLatestUserMessageText(messages); + + const options: CreateConnectedAccountOptions = { + callbackUrl: `${CHAT_CALLBACK_URL}?q=${encodeURIComponent(latestUserMessageText)}`, + }; + + const composio = getComposioClient(); + const userAccounts = await getConnectedAccount(accountId, options); + const isAuthenticated = userAccounts.items[0]?.data?.status === "ACTIVE"; + + const tools = isAuthenticated + ? await composio.tools.get(accountId, { + toolkits: [GOOGLE_SHEETS_TOOLKIT_SLUG], + }) + : { + google_sheets_login: googleSheetsLoginTool, + }; + + return tools; +} diff --git a/lib/agents/googleSheetsAgent/index.ts b/lib/agents/googleSheetsAgent/index.ts new file mode 100644 index 00000000..79bd8f91 --- /dev/null +++ b/lib/agents/googleSheetsAgent/index.ts @@ -0,0 +1,3 @@ +import getGoogleSheetsTools from "./getGoogleSheetsTools"; + +export { getGoogleSheetsTools }; diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts new file mode 100644 index 00000000..5734a194 --- /dev/null +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatRequestBody } from "../validateChatRequest"; + +// Mock external dependencies +vi.mock("@ai-sdk/mcp", () => ({ + experimental_createMCPClient: vi.fn(), +})); + +vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: vi.fn().mockImplementation(() => ({})), +})); + +vi.mock("@/lib/agents/googleSheetsAgent", () => ({ + getGoogleSheetsTools: vi.fn(), +})); + +// Import after mocks +import { setupToolsForRequest } from "../setupToolsForRequest"; +import { experimental_createMCPClient } from "@ai-sdk/mcp"; +import { getGoogleSheetsTools } from "@/lib/agents/googleSheetsAgent"; + +const mockCreateMCPClient = vi.mocked(experimental_createMCPClient); +const mockGetGoogleSheetsTools = vi.mocked(getGoogleSheetsTools); + +describe("setupToolsForRequest", () => { + const mockMcpTools = { + tool1: { description: "Tool 1", parameters: {} }, + tool2: { description: "Tool 2", parameters: {} }, + }; + + const mockGoogleSheetsTools = { + googlesheets_create: { description: "Create sheet", parameters: {} }, + googlesheets_read: { description: "Read sheet", parameters: {} }, + }; + + const mockGoogleSheetsLoginTool = { + google_sheets_login: { description: "Login to Google Sheets", parameters: {} }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock for MCP client + mockCreateMCPClient.mockResolvedValue({ + tools: vi.fn().mockResolvedValue(mockMcpTools), + } as any); + + // Default mock for Google Sheets tools - returns login tool (not authenticated) + mockGetGoogleSheetsTools.mockResolvedValue(mockGoogleSheetsLoginTool); + }); + + describe("MCP tools integration", () => { + it("creates MCP client with correct URL", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await setupToolsForRequest(body); + + expect(mockCreateMCPClient).toHaveBeenCalled(); + }); + + it("fetches tools from MCP client", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupToolsForRequest(body); + + expect(result).toHaveProperty("tool1"); + expect(result).toHaveProperty("tool2"); + }); + }); + + describe("Google Sheets tools integration", () => { + it("calls getGoogleSheetsTools with request body", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], + }; + + await setupToolsForRequest(body); + + expect(mockGetGoogleSheetsTools).toHaveBeenCalledWith(body); + }); + + it("includes Google Sheets tools when user is authenticated", async () => { + mockGetGoogleSheetsTools.mockResolvedValue(mockGoogleSheetsTools); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], + }; + + const result = await setupToolsForRequest(body); + + expect(result).toHaveProperty("googlesheets_create"); + expect(result).toHaveProperty("googlesheets_read"); + }); + + it("includes googleSheetsLoginTool when user is not authenticated", async () => { + mockGetGoogleSheetsTools.mockResolvedValue(mockGoogleSheetsLoginTool); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], + }; + + const result = await setupToolsForRequest(body); + + expect(result).toHaveProperty("google_sheets_login"); + }); + }); + + describe("tool aggregation", () => { + it("merges MCP tools and Google Sheets tools", async () => { + mockGetGoogleSheetsTools.mockResolvedValue(mockGoogleSheetsTools); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupToolsForRequest(body); + + // Should have both MCP and Google Sheets tools + expect(result).toHaveProperty("tool1"); + expect(result).toHaveProperty("tool2"); + expect(result).toHaveProperty("googlesheets_create"); + expect(result).toHaveProperty("googlesheets_read"); + }); + + it("Google Sheets tools take precedence over MCP tools with same name", async () => { + mockCreateMCPClient.mockResolvedValue({ + tools: vi.fn().mockResolvedValue({ + googlesheets_create: { description: "MCP version", parameters: {} }, + }), + } as any); + + mockGetGoogleSheetsTools.mockResolvedValue({ + googlesheets_create: { description: "Composio version", parameters: {} }, + }); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupToolsForRequest(body); + + // Google Sheets (Composio) version should win + expect(result.googlesheets_create).toEqual( + expect.objectContaining({ description: "Composio version" }), + ); + }); + }); + + describe("tool filtering", () => { + it("excludes tools specified in excludeTools array", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + excludeTools: ["tool1"], + }; + + const result = await setupToolsForRequest(body); + + expect(result).not.toHaveProperty("tool1"); + expect(result).toHaveProperty("tool2"); + }); + + it("excludes multiple tools", async () => { + mockGetGoogleSheetsTools.mockResolvedValue(mockGoogleSheetsTools); + + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + excludeTools: ["tool1", "googlesheets_create"], + }; + + const result = await setupToolsForRequest(body); + + expect(result).not.toHaveProperty("tool1"); + expect(result).not.toHaveProperty("googlesheets_create"); + expect(result).toHaveProperty("tool2"); + expect(result).toHaveProperty("googlesheets_read"); + }); + + it("returns all tools when excludeTools is undefined", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupToolsForRequest(body); + + expect(result).toHaveProperty("tool1"); + expect(result).toHaveProperty("tool2"); + }); + + it("returns all tools when excludeTools is empty array", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + excludeTools: [], + }; + + const result = await setupToolsForRequest(body); + + expect(result).toHaveProperty("tool1"); + expect(result).toHaveProperty("tool2"); + }); + }); +}); diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index 8ebbff45..f46e3371 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -3,11 +3,16 @@ import { filterExcludedTools } from "./filterExcludedTools"; import { ChatRequestBody } from "./validateChatRequest"; import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { getGoogleSheetsTools } from "@/lib/agents/googleSheetsAgent"; + +/** Base URL for the MCP server */ +const MCP_BASE_URL = process.env.MCP_BASE_URL || "https://recoup-api.vercel.app"; /** * Sets up and filters tools for a chat request. - * This is a simplified version that returns an empty tool set. - * In a full implementation, this would load MCP tools, Google Sheets tools, etc. + * Aggregates tools from: + * - MCP client (remote tools via MCP protocol) + * - Google Sheets (via Composio integration) * * @param body - The chat request body * @returns Filtered tool set ready for use @@ -15,13 +20,20 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ export async function setupToolsForRequest(body: ChatRequestBody): Promise { const { excludeTools } = body; + // Fetch MCP tools const mcpClient = await createMCPClient({ - transport: new StreamableHTTPClientTransport(new URL("/mcp", "https://recoup-api.vercel.app")), + transport: new StreamableHTTPClientTransport(new URL("/mcp", MCP_BASE_URL)), }); - const mcpClientTools = (await mcpClient.tools()) as ToolSet; - const allTools: ToolSet = { ...mcpClientTools }; + // Fetch Google Sheets tools (authenticated tools or login tool) + const googleSheetsTools = await getGoogleSheetsTools(body); + + // Merge all tools - Google Sheets tools take precedence over MCP tools + const allTools: ToolSet = { + ...mcpClientTools, + ...googleSheetsTools, + }; const tools = filterExcludedTools(allTools, excludeTools); return tools; diff --git a/lib/composio/tools/googleSheetsLoginTool.ts b/lib/composio/tools/googleSheetsLoginTool.ts new file mode 100644 index 00000000..c3bfad0f --- /dev/null +++ b/lib/composio/tools/googleSheetsLoginTool.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { tool } from "ai"; +import getConnectedAccount from "@/lib/composio/googleSheets/getConnectedAccount"; + +const schema = z.object({ + account_id: z + .string() + .min(1, "account_id is required and should be pulled from the system prompt."), +}); + +const googleSheetsLoginTool = tool({ + description: "Initiate the authentication flow for the Google Sheets account.", + parameters: schema, + execute: async ({ account_id }) => { + await getConnectedAccount(account_id); + return { + success: true, + message: + "Google Sheets login initiated successfully. Please click the button above to login with Google Sheets.", + }; + }, +}); + +export default googleSheetsLoginTool; diff --git a/lib/messages/__tests__/getLatestUserMessageText.test.ts b/lib/messages/__tests__/getLatestUserMessageText.test.ts new file mode 100644 index 00000000..855cd33e --- /dev/null +++ b/lib/messages/__tests__/getLatestUserMessageText.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import { UIMessage } from "ai"; +import getLatestUserMessageText from "../getLatestUserMessageText"; + +describe("getLatestUserMessageText", () => { + describe("basic functionality", () => { + it("returns text from a single user message", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Hello world", + parts: [{ type: "text", text: "Hello world" }], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe("Hello world"); + }); + + it("returns text from the latest user message when multiple exist", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "First message", + parts: [{ type: "text", text: "First message" }], + }, + { + id: "2", + role: "assistant", + content: "Response", + parts: [{ type: "text", text: "Response" }], + }, + { + id: "3", + role: "user", + content: "Second message", + parts: [{ type: "text", text: "Second message" }], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe("Second message"); + }); + }); + + describe("edge cases", () => { + it("returns empty string for empty messages array", () => { + const messages: UIMessage[] = []; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe(""); + }); + + it("returns empty string when no user messages exist", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "assistant", + content: "Response", + parts: [{ type: "text", text: "Response" }], + }, + { + id: "2", + role: "system", + content: "System message", + parts: [{ type: "text", text: "System message" }], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe(""); + }); + + it("returns empty string when user message has no parts", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Hello", + } as UIMessage, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe(""); + }); + + it("returns empty string when user message has empty parts array", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Hello", + parts: [], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe(""); + }); + + it("returns empty string when user message has no text part", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Hello", + parts: [ + { type: "file", mediaType: "image/png", url: "https://example.com/image.png" }, + ], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe(""); + }); + }); + + describe("mixed content", () => { + it("extracts text from message with both text and file parts", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "Check this image", + parts: [ + { type: "file", mediaType: "image/png", url: "https://example.com/image.png" }, + { type: "text", text: "Check this image" }, + ], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe("Check this image"); + }); + + it("returns first text part when multiple text parts exist", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "First text Second text", + parts: [ + { type: "text", text: "First text" }, + { type: "text", text: "Second text" }, + ], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe("First text"); + }); + }); + + describe("role filtering", () => { + it("ignores assistant messages", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "User message", + parts: [{ type: "text", text: "User message" }], + }, + { + id: "2", + role: "assistant", + content: "Latest but assistant", + parts: [{ type: "text", text: "Latest but assistant" }], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe("User message"); + }); + + it("ignores system messages", () => { + const messages: UIMessage[] = [ + { + id: "1", + role: "user", + content: "User message", + parts: [{ type: "text", text: "User message" }], + }, + { + id: "2", + role: "system", + content: "System message", + parts: [{ type: "text", text: "System message" }], + }, + ]; + + const result = getLatestUserMessageText(messages); + + expect(result).toBe("User message"); + }); + }); +}); diff --git a/lib/messages/getLatestUserMessageText.ts b/lib/messages/getLatestUserMessageText.ts new file mode 100644 index 00000000..2d83f506 --- /dev/null +++ b/lib/messages/getLatestUserMessageText.ts @@ -0,0 +1,13 @@ +import { UIMessage } from "ai"; + +/** + * Extracts the text content from the most recent user message + * + * @param messages - Array of UI messages + * @returns The text content of the latest user message, or empty string if none found + */ +export default function getLatestUserMessageText(messages: UIMessage[]): string { + const userMessages = messages.filter((msg) => msg.role === "user"); + const latestUserMessage = userMessages[userMessages.length - 1]; + return latestUserMessage?.parts?.find((part) => part.type === "text")?.text || ""; +} From 78c0da404b5e48c8389e5f2cf582993927dfd89e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:50:15 -0500 Subject: [PATCH 15/19] fix: use inputSchema instead of parameters in googleSheetsLoginTool AI SDK v6 uses inputSchema property instead of parameters for tool schema definition. Co-Authored-By: Claude Opus 4.5 --- lib/composio/tools/googleSheetsLoginTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/composio/tools/googleSheetsLoginTool.ts b/lib/composio/tools/googleSheetsLoginTool.ts index c3bfad0f..c99dd7a5 100644 --- a/lib/composio/tools/googleSheetsLoginTool.ts +++ b/lib/composio/tools/googleSheetsLoginTool.ts @@ -10,7 +10,7 @@ const schema = z.object({ const googleSheetsLoginTool = tool({ description: "Initiate the authentication flow for the Google Sheets account.", - parameters: schema, + inputSchema: schema, execute: async ({ account_id }) => { await getConnectedAccount(account_id); return { From b7d17cd4d77245c804a7a39fd5ac9ef9d8998489 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 20:55:11 -0500 Subject: [PATCH 16/19] feat: migrate toolChains framework from Recoup-Chat to recoup-api Migrates the complete toolChains orchestration framework for multi-step tool sequences. This enables automated workflows like artist onboarding and release report generation. Components migrated: - getPrepareStepResult: Orchestration engine that determines next tool - getExecutedToolTimeline: Tracks tool execution history from steps - TOOL_CHAINS: Registry with create_new_artist (17 steps) and create_release_report (5 steps) chains - TOOL_MODEL_MAP: Maps tools to specific models (e.g., gemini-2.5-pro) - Reference message generators for knowledge base and release reports Integration: - setupChatRequest now uses getPrepareStepResult in prepareStep callback - When a chain trigger tool is executed, subsequent tools are automatically routed through the defined sequence Test coverage: 38 new unit tests for toolChains framework plus 2 integration tests for setupChatRequest. Co-Authored-By: Claude Opus 4.5 --- lib/chat/__tests__/setupChatRequest.test.ts | 57 ++++ lib/chat/setupChatRequest.ts | 7 +- .../__tests__/getExecutedToolTimeline.test.ts | 170 +++++++++++ .../__tests__/getPrepareStepResult.test.ts | 276 ++++++++++++++++++ .../toolChains/__tests__/toolChains.test.ts | 139 +++++++++ .../toolChains/createNewArtistToolChain.ts | 35 +++ .../createReleaseReportToolChain.ts | 40 +++ .../getReleaseReportReferenceMessage.ts | 21 ++ .../referenceReleaseReport.ts | 142 +++++++++ .../toolChains/getExecutedToolTimeline.ts | 25 ++ .../getKnowledgeBaseReportReferenceMessage.ts | 12 + lib/chat/toolChains/getPrepareStepResult.ts | 78 +++++ lib/chat/toolChains/index.ts | 5 + .../knowledgeBaseReferenceReport.ts | 71 +++++ lib/chat/toolChains/toolChains.ts | 36 +++ 15 files changed, 1112 insertions(+), 2 deletions(-) create mode 100644 lib/chat/toolChains/__tests__/getExecutedToolTimeline.test.ts create mode 100644 lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts create mode 100644 lib/chat/toolChains/__tests__/toolChains.test.ts create mode 100644 lib/chat/toolChains/createNewArtistToolChain.ts create mode 100644 lib/chat/toolChains/create_release_report/createReleaseReportToolChain.ts create mode 100644 lib/chat/toolChains/create_release_report/getReleaseReportReferenceMessage.ts create mode 100644 lib/chat/toolChains/create_release_report/referenceReleaseReport.ts create mode 100644 lib/chat/toolChains/getExecutedToolTimeline.ts create mode 100644 lib/chat/toolChains/getKnowledgeBaseReportReferenceMessage.ts create mode 100644 lib/chat/toolChains/getPrepareStepResult.ts create mode 100644 lib/chat/toolChains/index.ts create mode 100644 lib/chat/toolChains/knowledgeBaseReferenceReport.ts create mode 100644 lib/chat/toolChains/toolChains.ts diff --git a/lib/chat/__tests__/setupChatRequest.test.ts b/lib/chat/__tests__/setupChatRequest.test.ts index 991b0c96..b715d768 100644 --- a/lib/chat/__tests__/setupChatRequest.test.ts +++ b/lib/chat/__tests__/setupChatRequest.test.ts @@ -252,5 +252,62 @@ describe("setupChatRequest", () => { // Should return the original options when no tool chain matches expect(prepareResult).toEqual(mockOptions); }); + + it("prepareStep returns next tool when tool chain is triggered", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + // Simulate create_new_artist tool being executed + const mockOptions = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "create_new_artist", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 1, + model: "test-model", + messages: [], + }; + const prepareResult = result.prepareStep!(mockOptions as any); + + // Should return next tool in the create_new_artist chain (get_spotify_search) + expect(prepareResult).toHaveProperty("toolChoice"); + expect((prepareResult as any).toolChoice.toolName).toBe("get_spotify_search"); + }); + + it("prepareStep merges tool chain result with original options", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body); + + // Simulate create_new_artist tool being executed + const mockOptions = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "create_new_artist", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 1, + model: "original-model", + messages: [{ role: "user", content: "original" }], + }; + const prepareResult = result.prepareStep!(mockOptions as any); + + // Should merge original options with tool chain result + expect(prepareResult).toHaveProperty("stepNumber", 1); + expect(prepareResult).toHaveProperty("model", "original-model"); + expect(prepareResult).toHaveProperty("toolChoice"); + }); }); }); diff --git a/lib/chat/setupChatRequest.ts b/lib/chat/setupChatRequest.ts index 2b3c9d56..31cef000 100644 --- a/lib/chat/setupChatRequest.ts +++ b/lib/chat/setupChatRequest.ts @@ -7,6 +7,7 @@ import { MAX_MESSAGES } from "./const"; import { type ChatConfig } from "./types"; import { ChatRequestBody } from "./validateChatRequest"; import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; +import getPrepareStepResult from "./toolChains/getPrepareStepResult"; /** * Sets up and prepares the chat request configuration. @@ -38,8 +39,10 @@ export async function setupChatRequest(body: ChatRequestBody): Promise { - // TODO: Implement getPrepareStepResult from toolChains migration - // For now, return options unchanged (no tool chain routing) + const next = getPrepareStepResult(options); + if (next) { + return { ...options, ...next }; + } return options; }, providerOptions: { diff --git a/lib/chat/toolChains/__tests__/getExecutedToolTimeline.test.ts b/lib/chat/toolChains/__tests__/getExecutedToolTimeline.test.ts new file mode 100644 index 00000000..4e7085c9 --- /dev/null +++ b/lib/chat/toolChains/__tests__/getExecutedToolTimeline.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import getExecutedToolTimeline from "../getExecutedToolTimeline"; + +describe("getExecutedToolTimeline", () => { + describe("basic functionality", () => { + it("returns an empty array when steps is empty", () => { + const result = getExecutedToolTimeline([]); + expect(result).toEqual([]); + }); + + it("extracts tool names from a single step with one tool result", () => { + const steps = [ + { + toolResults: [ + { + toolCallId: "call-1", + toolName: "get_spotify_search", + output: { type: "json", value: {} }, + }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual(["get_spotify_search"]); + }); + + it("extracts tool names from multiple tool results in a single step", () => { + const steps = [ + { + toolResults: [ + { + toolCallId: "call-1", + toolName: "tool_a", + output: { type: "json", value: {} }, + }, + { + toolCallId: "call-2", + toolName: "tool_b", + output: { type: "json", value: {} }, + }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual(["tool_a", "tool_b"]); + }); + + it("extracts tool names from multiple steps", () => { + const steps = [ + { + toolResults: [ + { + toolCallId: "call-1", + toolName: "create_new_artist", + output: { type: "json", value: {} }, + }, + ], + }, + { + toolResults: [ + { + toolCallId: "call-2", + toolName: "get_spotify_search", + output: { type: "json", value: {} }, + }, + ], + }, + { + toolResults: [ + { + toolCallId: "call-3", + toolName: "update_account_info", + output: { type: "json", value: {} }, + }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual(["create_new_artist", "get_spotify_search", "update_account_info"]); + }); + }); + + describe("edge cases", () => { + it("handles steps with empty toolResults array", () => { + const steps = [ + { toolResults: [] }, + { + toolResults: [ + { + toolCallId: "call-1", + toolName: "tool_a", + output: { type: "json", value: {} }, + }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual(["tool_a"]); + }); + + it("handles steps with undefined toolResults", () => { + const steps = [ + {}, + { + toolResults: [ + { + toolCallId: "call-1", + toolName: "tool_a", + output: { type: "json", value: {} }, + }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual(["tool_a"]); + }); + + it("preserves order of execution across multiple steps", () => { + const steps = [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "step1_tool1", output: { type: "json", value: {} } }, + { toolCallId: "call-2", toolName: "step1_tool2", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-3", toolName: "step2_tool1", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-4", toolName: "step3_tool1", output: { type: "json", value: {} } }, + { toolCallId: "call-5", toolName: "step3_tool2", output: { type: "json", value: {} } }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual([ + "step1_tool1", + "step1_tool2", + "step2_tool1", + "step3_tool1", + "step3_tool2", + ]); + }); + + it("handles tool results with missing toolCallId", () => { + const steps = [ + { + toolResults: [ + { + toolName: "tool_without_id", + output: { type: "json", value: {} }, + }, + ], + }, + ]; + + const result = getExecutedToolTimeline(steps as any); + expect(result).toEqual(["tool_without_id"]); + }); + }); +}); diff --git a/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts b/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts new file mode 100644 index 00000000..3f1ace6b --- /dev/null +++ b/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the toolChains module +vi.mock("../toolChains", () => ({ + TOOL_CHAINS: { + test_trigger: [ + { toolName: "step_one" }, + { toolName: "step_two" }, + { toolName: "step_three" }, + ], + custom_chain: [ + { + toolName: "custom_step_one", + system: "Custom system prompt for step one", + }, + { + toolName: "custom_step_two", + messages: [{ role: "user", content: "Reference message" }], + }, + ], + }, + TOOL_MODEL_MAP: { + step_two: "gemini-2.5-pro", + custom_step_one: "gpt-4-turbo", + }, +})); + +import getPrepareStepResult from "../getPrepareStepResult"; + +describe("getPrepareStepResult", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when no tool chain is triggered", () => { + it("returns undefined when steps is empty", () => { + const options = { + steps: [], + stepNumber: 0, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result).toBeUndefined(); + }); + + it("returns undefined when no trigger tool has been executed", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "unrelated_tool", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 1, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result).toBeUndefined(); + }); + }); + + describe("tool chain progression", () => { + it("returns the first tool in sequence when trigger tool is executed", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 1, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result).toEqual({ + toolChoice: { type: "tool", toolName: "step_one" }, + }); + }); + + it("returns the second tool after first tool in sequence is executed", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-2", toolName: "step_one", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 2, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result).toEqual({ + toolChoice: { type: "tool", toolName: "step_two" }, + model: "gemini-2.5-pro", // From TOOL_MODEL_MAP + }); + }); + + it("returns undefined when all tools in sequence have been executed", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-2", toolName: "step_one", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-3", toolName: "step_two", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-4", toolName: "step_three", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 4, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result).toBeUndefined(); + }); + + it("handles out-of-order tool executions by matching timeline against sequence", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-2", toolName: "some_other_tool", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-3", toolName: "step_one", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 3, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + // Should return step_two since step_one has been executed + expect(result).toEqual({ + toolChoice: { type: "tool", toolName: "step_two" }, + model: "gemini-2.5-pro", + }); + }); + }); + + describe("custom chain with system prompts and messages", () => { + it("includes system prompt when defined in tool chain item", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "custom_chain", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 1, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result).toEqual({ + toolChoice: { type: "tool", toolName: "custom_step_one" }, + system: "Custom system prompt for step one", + model: "gpt-4-turbo", + }); + }); + + it("includes messages when defined in tool chain item", () => { + const existingMessages = [{ role: "user", content: "Hello" }]; + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "custom_chain", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-2", toolName: "custom_step_one", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 2, + model: "test-model", + messages: existingMessages, + }; + + const result = getPrepareStepResult(options as any); + expect(result).toEqual({ + toolChoice: { type: "tool", toolName: "custom_step_two" }, + messages: [ + { role: "user", content: "Hello" }, + { role: "user", content: "Reference message" }, + ], + }); + }); + }); + + describe("model override from TOOL_MODEL_MAP", () => { + it("includes model override when tool is in TOOL_MODEL_MAP", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + ], + }, + { + toolResults: [ + { toolCallId: "call-2", toolName: "step_one", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 2, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result?.model).toBe("gemini-2.5-pro"); + }); + + it("does not include model when tool is not in TOOL_MODEL_MAP", () => { + const options = { + steps: [ + { + toolResults: [ + { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + ], + }, + ], + stepNumber: 1, + model: "test-model", + messages: [], + }; + + const result = getPrepareStepResult(options as any); + expect(result?.model).toBeUndefined(); + }); + }); +}); diff --git a/lib/chat/toolChains/__tests__/toolChains.test.ts b/lib/chat/toolChains/__tests__/toolChains.test.ts new file mode 100644 index 00000000..67f58223 --- /dev/null +++ b/lib/chat/toolChains/__tests__/toolChains.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { + TOOL_CHAINS, + TOOL_MODEL_MAP, + ToolChainItem, + PrepareStepResult, +} from "../toolChains"; + +describe("toolChains", () => { + describe("ToolChainItem type", () => { + it("allows basic tool chain item with just toolName", () => { + const item: ToolChainItem = { + toolName: "test_tool", + }; + expect(item.toolName).toBe("test_tool"); + }); + + it("allows tool chain item with system prompt", () => { + const item: ToolChainItem = { + toolName: "test_tool", + system: "Custom system prompt", + }; + expect(item.system).toBe("Custom system prompt"); + }); + + it("allows tool chain item with messages", () => { + const item: ToolChainItem = { + toolName: "test_tool", + messages: [{ role: "user", content: "Test message" }], + }; + expect(item.messages).toHaveLength(1); + }); + }); + + describe("PrepareStepResult type", () => { + it("allows result with just toolChoice", () => { + const result: PrepareStepResult = { + toolChoice: { type: "tool", toolName: "test_tool" }, + }; + expect(result.toolChoice?.toolName).toBe("test_tool"); + }); + + it("allows result with model override", () => { + const result: PrepareStepResult = { + toolChoice: { type: "tool", toolName: "test_tool" }, + model: "gemini-2.5-pro" as any, + }; + expect(result.model).toBe("gemini-2.5-pro"); + }); + + it("allows result with all properties", () => { + const result: PrepareStepResult = { + toolChoice: { type: "tool", toolName: "test_tool" }, + model: "gemini-2.5-pro" as any, + system: "Custom prompt", + messages: [{ role: "user", content: "Test" }], + }; + expect(result.toolChoice).toBeDefined(); + expect(result.model).toBeDefined(); + expect(result.system).toBeDefined(); + expect(result.messages).toBeDefined(); + }); + }); + + describe("TOOL_CHAINS", () => { + it("exports TOOL_CHAINS as an object", () => { + expect(typeof TOOL_CHAINS).toBe("object"); + }); + + it("contains create_new_artist chain", () => { + expect(TOOL_CHAINS).toHaveProperty("create_new_artist"); + expect(Array.isArray(TOOL_CHAINS.create_new_artist)).toBe(true); + }); + + it("contains create_release_report chain", () => { + expect(TOOL_CHAINS).toHaveProperty("create_release_report"); + expect(Array.isArray(TOOL_CHAINS.create_release_report)).toBe(true); + }); + + describe("create_new_artist chain", () => { + it("starts with get_spotify_search", () => { + const chain = TOOL_CHAINS.create_new_artist; + expect(chain[0].toolName).toBe("get_spotify_search"); + }); + + it("includes update_account_info with custom system prompt", () => { + const chain = TOOL_CHAINS.create_new_artist; + const updateAccountStep = chain.find( + (item) => item.toolName === "update_account_info" && item.system + ); + expect(updateAccountStep).toBeDefined(); + expect(updateAccountStep?.system).toContain("get_spotify_search"); + }); + + it("has expected number of steps", () => { + const chain = TOOL_CHAINS.create_new_artist; + // Original chain has 17 steps after trigger + expect(chain.length).toBeGreaterThan(10); + }); + + it("ends with youtube_login", () => { + const chain = TOOL_CHAINS.create_new_artist; + expect(chain[chain.length - 1].toolName).toBe("youtube_login"); + }); + }); + + describe("create_release_report chain", () => { + it("starts with web_deep_research", () => { + const chain = TOOL_CHAINS.create_release_report; + expect(chain[0].toolName).toBe("web_deep_research"); + }); + + it("includes generate_txt_file with custom system prompt", () => { + const chain = TOOL_CHAINS.create_release_report; + const generateStep = chain.find( + (item) => item.toolName === "generate_txt_file" + ); + expect(generateStep).toBeDefined(); + expect(generateStep?.system).toBeDefined(); + }); + + it("ends with send_email", () => { + const chain = TOOL_CHAINS.create_release_report; + expect(chain[chain.length - 1].toolName).toBe("send_email"); + }); + }); + }); + + describe("TOOL_MODEL_MAP", () => { + it("exports TOOL_MODEL_MAP as an object", () => { + expect(typeof TOOL_MODEL_MAP).toBe("object"); + }); + + it("contains update_account_info mapped to gemini model", () => { + expect(TOOL_MODEL_MAP).toHaveProperty("update_account_info"); + expect(TOOL_MODEL_MAP.update_account_info).toBe("gemini-2.5-pro"); + }); + }); +}); diff --git a/lib/chat/toolChains/createNewArtistToolChain.ts b/lib/chat/toolChains/createNewArtistToolChain.ts new file mode 100644 index 00000000..3247a84b --- /dev/null +++ b/lib/chat/toolChains/createNewArtistToolChain.ts @@ -0,0 +1,35 @@ +import { ToolChainItem } from "./toolChains"; +import getKnowledgeBaseReportReferenceMessage from "./getKnowledgeBaseReportReferenceMessage"; + +export const createNewArtistToolChain: ToolChainItem[] = [ + { toolName: "get_spotify_search" }, + { + toolName: "update_account_info", + system: + "From the get_spotify_search results, select the artist whose name best matches the user-provided artist name (prefer exact, case-insensitive match; otherwise choose the closest by name and popularity). Update the account using the update_account_info tool with the artist's basic information: name, image, label, etc.", + }, + { toolName: "update_artist_socials" }, + { toolName: "artist_deep_research" }, + { toolName: "spotify_deep_research" }, + { toolName: "get_artist_socials" }, + { toolName: "get_spotify_artist_top_tracks" }, + { toolName: "get_spotify_artist_albums" }, + { toolName: "get_spotify_album" }, + { toolName: "search_web" }, + { toolName: "update_artist_socials" }, + { + toolName: "search_web", + messages: [getKnowledgeBaseReportReferenceMessage()], + }, + { + toolName: "create_knowledge_base", + messages: [getKnowledgeBaseReportReferenceMessage()], + }, + { + toolName: "generate_txt_file", + messages: [getKnowledgeBaseReportReferenceMessage()], + }, + { toolName: "update_account_info" }, + { toolName: "create_segments" }, + { toolName: "youtube_login" }, +]; diff --git a/lib/chat/toolChains/create_release_report/createReleaseReportToolChain.ts b/lib/chat/toolChains/create_release_report/createReleaseReportToolChain.ts new file mode 100644 index 00000000..afac8c0e --- /dev/null +++ b/lib/chat/toolChains/create_release_report/createReleaseReportToolChain.ts @@ -0,0 +1,40 @@ +import { ToolChainItem } from "../toolChains"; +import getReleaseReportReferenceMessage from "./getReleaseReportReferenceMessage"; + +export const createReleaseReportToolChain: ToolChainItem[] = [ + { + toolName: "web_deep_research", + messages: [getReleaseReportReferenceMessage()], + }, + { + toolName: "create_knowledge_base", + messages: [getReleaseReportReferenceMessage()], + }, + { + toolName: "generate_txt_file", + system: `Create a Release Report TXT file matching the reference release report. + Do not make up any data. Only use the data that is present in the web_deep_research tool results. + The following sections must be included in the report (only if the data is present in the web_deep_research tool results) passed to the contents parameter in the generate_txt_file tool: + - {artwork title} Summary + - Streaming headlines + - Global streaming headlines + - TikTok Story So Far + - {artwork title} Charts + - - Spotify + - - Apple Music + - - iTunes + - - Shazam + - - Deezer + - Citations (links to the sources generated in the web_deep_research tool)`, + messages: [getReleaseReportReferenceMessage()], + }, + { + toolName: "update_account_info", + system: + "Attach the newly created release report to the artist's account info as a knowledge base. IMPORTANT: Use the active_artist_account_id from the system prompt as the artistId parameter.", + }, + { + toolName: "send_email", + messages: [getReleaseReportReferenceMessage()], + }, +]; diff --git a/lib/chat/toolChains/create_release_report/getReleaseReportReferenceMessage.ts b/lib/chat/toolChains/create_release_report/getReleaseReportReferenceMessage.ts new file mode 100644 index 00000000..40507d7b --- /dev/null +++ b/lib/chat/toolChains/create_release_report/getReleaseReportReferenceMessage.ts @@ -0,0 +1,21 @@ +import { ModelMessage } from "ai"; +import { referenceReleaseReport } from "./referenceReleaseReport"; + +/** + * Creates a reference message with the release report example + */ +const getReleaseReportReferenceMessage = (): ModelMessage => { + return { + role: "user", + content: [ + { + type: "text" as const, + text: `Here is an example release report for reference. Use this as a template for creating your own release reports / email text: + + ${referenceReleaseReport}`, + }, + ], + }; +}; + +export default getReleaseReportReferenceMessage; diff --git a/lib/chat/toolChains/create_release_report/referenceReleaseReport.ts b/lib/chat/toolChains/create_release_report/referenceReleaseReport.ts new file mode 100644 index 00000000..3c4b90c1 --- /dev/null +++ b/lib/chat/toolChains/create_release_report/referenceReleaseReport.ts @@ -0,0 +1,142 @@ +export const referenceReleaseReport = ` +Hi all, + +Midweek update on all things PinkPantheress and 'Illegal' below. + +'Illegal' breaks into 6 new markets via local Spotify Top-200, charting in 22-markets overall, and a sharp surge in the UK, US and Global charts, all within the Top-100. + +In the UK 'Illegal' is midweeking at #22, forecasting #19 for the end of this chart week. 'Fancy That' is slowly climbing the charts, midweeking #163 (+12%), week previous charting at #180. + +Streaming headlines: + +'Illegal' is up 53% this-week-to-date, across all platforms +Spotify is up 58% Monday's streams were up 15% DoD +Apple Music is up 54%, all streams still growing daily across all sources +Amazon Music is up 25%, radio and playlist stream driving daily increase + +Spotify UK Chart: #20 (+21) +Spotify Global Chart: #86 (+55) +Apple Music UK Chart: #39 (Flat) + +Global streaming headlines: + +Illegal +Total Global Streams: 50.6M (+8%) +Week Prior Global Streams: 9.7M (+61%) +This Week Global Streams: 7.9M (+54%) + +Fancy That +Total Global Streams: 172M (+3%) +Last Week Global Streams: 17.5M (+31%) +This Week Global Streams: 11.2M (+16%) + +Update below. + +TikTok Story So Far: +June 2nd: + +Sound 1, here - 471,000 Creations + 53,000 +Sound 2, here - 100,000 Creations +18,000 +Sound 3, here - 15,900 Creations +2,800 +Sound 4, here - 6,700 Creations +3,700 + +'Illegal' Charts: +July 2nd: + +Spotify: +#20 United Kingdom +21 +#42 Estonia +1 +#40 New Zealand +15 +#43 Australia +15 +#37 Latvia +30 +#53 Lithuania +21 +#60 United States +41 +#77 Iceland +33 +#72 Ireland +45 +#71 United Arab Emirates +52 +#85 Global +56 +#107 Austria +22 +#110 Norway +31 +#130 Germany +23 +#126 Netherlands +38 +#122 Switzerland +45 +#114 Canada +59 +#118 Singapore +62 +#133 Bulgaria +#144 Belgium +#151 Poland +#167 Saudi Arabia +#198 Romania + +Apple Music: +#6 Iceland +#18 Bahrain +#39 United Kingdom +#68 Lebanon +#70 Kuwait +#82 Sweden +#83 Netherlands +#85 Lithuania +#120 Finland +#136 New Zealand +#137 Poland +#139 Ireland +#151 Norway +#190 Switzerland +#197 Austria + +iTunes: +#19 Egypt +#20 Qatar +#168 United Kingdom + +Shazam: +#32 Belarus +#35 Norway +#38 Germany +#46 Switzerland +#49 Czech Republic +#51 Australia +#51 Singapore +#52 Austria +#55 United Kingdom +#61 Israel +#64 Ireland +#64 Netherlands +#66 Sweden +#72 Belgium +#75 Worldwide +#77 Poland +#79 Canada +#79 Finland +#80 France +#89 United States +#95 New Zealand +#108 United Arab Emirates +#114 Hungary +#141 Greece +#148 Denmark +#163 Malaysia +#166 Bulgaria +#175 Saudi Arabia +#177 Philippines +#179 Ukraine + +Deezer: +#57 Indonesia +#57 Lebanon +#73 Singapore + +Citations: +- Nia Archives remix (YouTube) β€” remix strategy and additional content: https://www.youtube.com/watch?v=h-VvUPp6BgE +- Press profile / campaign case study β€” context on creative & audience playbook: https://musically.com/2025/07/09/behind-the-campaign-pinkpantheress/ +- Industry metrics snapshot (artist-level): https://www.musicmetricsvault.com/artists/pinkpantheress/78rUTD7y6Cy67W1RVzYs7t +- Fashion/brand tie-in (example): https://www.showstudio.com/news/pinkpantheress-joins-eccos-modern-family + +Thanks! + +-- +Zak Boumlaki +Head of Marketing | Warner Records +27 Wrights Lane, Kensington, London W8 5SW +(0) +44 7984 662 662`; diff --git a/lib/chat/toolChains/getExecutedToolTimeline.ts b/lib/chat/toolChains/getExecutedToolTimeline.ts new file mode 100644 index 00000000..1e522d70 --- /dev/null +++ b/lib/chat/toolChains/getExecutedToolTimeline.ts @@ -0,0 +1,25 @@ +import { StepResult, ToolSet } from "ai"; + +type ToolCallContent = { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: { type: "json"; value: unknown }; +}; + +const getExecutedToolTimeline = (steps: StepResult[]): string[] => { + const toolCallsContent = steps.flatMap( + (step): ToolCallContent[] => + step.toolResults?.map((result) => ({ + type: "tool-result" as const, + toolCallId: result.toolCallId || "", + toolName: result.toolName, + output: { type: "json" as const, value: result.output }, + })) || [] + ); + + // Build timeline of executed tools from toolCallsContent + return toolCallsContent.map((call) => call.toolName); +}; + +export default getExecutedToolTimeline; diff --git a/lib/chat/toolChains/getKnowledgeBaseReportReferenceMessage.ts b/lib/chat/toolChains/getKnowledgeBaseReportReferenceMessage.ts new file mode 100644 index 00000000..a2c32c1e --- /dev/null +++ b/lib/chat/toolChains/getKnowledgeBaseReportReferenceMessage.ts @@ -0,0 +1,12 @@ +import { ModelMessage } from "ai"; +import { knowledgeBaseReferenceReport } from "./knowledgeBaseReferenceReport"; + +const getKnowledgeBaseReportReferenceMessage = (): ModelMessage => { + return { + role: "user", + content: `Here is an example knowledge base report for reference. Use this as a template for creating your own knowledge base reports: + ${knowledgeBaseReferenceReport}`, + }; +}; + +export default getKnowledgeBaseReportReferenceMessage; diff --git a/lib/chat/toolChains/getPrepareStepResult.ts b/lib/chat/toolChains/getPrepareStepResult.ts new file mode 100644 index 00000000..5a908afa --- /dev/null +++ b/lib/chat/toolChains/getPrepareStepResult.ts @@ -0,0 +1,78 @@ +import { LanguageModel, ModelMessage, StepResult, ToolSet } from "ai"; +import { PrepareStepResult, TOOL_CHAINS, TOOL_MODEL_MAP } from "./toolChains"; +import getExecutedToolTimeline from "./getExecutedToolTimeline"; + +type PrepareStepOptions = { + steps: Array>>; + stepNumber: number; + model: LanguageModel; + messages: Array; +}; + +/** + * Returns the next tool to run based on timeline progression through tool chains. + * Uses toolCallsContent to track exact execution order and position in sequence. + */ +const getPrepareStepResult = ( + options: PrepareStepOptions +): PrepareStepResult | undefined => { + const { steps } = options; + // Extract tool calls timeline (history) from steps + const executedTimeline = getExecutedToolTimeline(steps); + + for (const [trigger, sequenceAfter] of Object.entries(TOOL_CHAINS)) { + // Check if this chain has been triggered + const triggerIndex = executedTimeline.indexOf(trigger); + if (triggerIndex === -1) continue; // Chain not started + + const fullSequence = [{ toolName: trigger }, ...sequenceAfter]; + + // Find our current position in the sequence by matching timeline + let sequencePosition = 0; + let timelinePosition = triggerIndex; + + // Walk through the timeline starting from trigger + while ( + timelinePosition < executedTimeline.length && + sequencePosition < fullSequence.length + ) { + const currentTool = executedTimeline[timelinePosition]; + const expectedTool = fullSequence[sequencePosition].toolName; + + if (currentTool === expectedTool) { + sequencePosition++; + } + timelinePosition++; + } + + // Return next tool in sequence if available + if (sequencePosition < fullSequence.length) { + const nextToolItem = fullSequence[sequencePosition]; + const result: PrepareStepResult = { + toolChoice: { type: "tool", toolName: nextToolItem.toolName }, + }; + + // Add system prompt if available + if (nextToolItem.system) { + result.system = nextToolItem.system; + } + + // Add messages if available + if (nextToolItem.messages) { + result.messages = options.messages.concat(nextToolItem.messages); + } + + // Add model if specified for this tool + const model = TOOL_MODEL_MAP[nextToolItem.toolName]; + if (model) { + result.model = model; + } + + return result; + } + } + + return undefined; +}; + +export default getPrepareStepResult; diff --git a/lib/chat/toolChains/index.ts b/lib/chat/toolChains/index.ts new file mode 100644 index 00000000..7d52b25e --- /dev/null +++ b/lib/chat/toolChains/index.ts @@ -0,0 +1,5 @@ +export { TOOL_CHAINS, TOOL_MODEL_MAP, type ToolChainItem, type PrepareStepResult } from "./toolChains"; +export { default as getPrepareStepResult } from "./getPrepareStepResult"; +export { default as getExecutedToolTimeline } from "./getExecutedToolTimeline"; +export { createNewArtistToolChain } from "./createNewArtistToolChain"; +export { createReleaseReportToolChain } from "./create_release_report/createReleaseReportToolChain"; diff --git a/lib/chat/toolChains/knowledgeBaseReferenceReport.ts b/lib/chat/toolChains/knowledgeBaseReferenceReport.ts new file mode 100644 index 00000000..1bfd5618 --- /dev/null +++ b/lib/chat/toolChains/knowledgeBaseReferenceReport.ts @@ -0,0 +1,71 @@ +export const knowledgeBaseReferenceReport = ` +# BRADEN BALES - Comprehensive Artist Profile + +## Artist Overview +Braden Bales is a 22-year-old Canadian singer-songwriter who has rapidly emerged in the music scene with his emotionally honest songwriting and distinctive blend of pop, indie, and rock influences. His music is characterized by self-reflection and unfiltered storytelling, exploring themes such as anxiety, mental health, and personal growth. + +## Career Highlights +- **Professional Start**: Began releasing music professionally in 2021 +- **Breakthrough**: Achieved viral success in 2023 with "CHRONICALLY CAUTIOUS" after a TikTok duet by Elyse Myers +- **Record Deal**: Signed with Geffen Records following his initial success +- **Social Media Presence**: Built substantial followings on Instagram (147,000 followers) and TikTok (316,700 followers) + +## Musical Style & Influences +- **Genre**: Guitar-driven pop with indie and rock elements, occasionally incorporating rap-sung and pop-punk styles +- **Lyrical Themes**: Emotional transparency, mental health, anxiety, relationships, and self-discovery +- **Vocal Style**: Comparable to artists like Don Toliver, Lil Yachty, and NF +- **Production**: Combines catchy hooks with introspective lyricism +- **Authenticity**: Rejects "cookie cutter" approaches to pop music, prioritizing personal confession and connection + +## Discography Highlights + +### Top Singles by Popularity (Spotify) +1. "CHRONICALLY CAUTIOUS" (2023) - 55 popularity score - Over 46 million streams +2. "WHEN YOU THINK ABOUT IT" (2025) - 47 popularity score +3. "ME MYSELF AND YOU" (2023) - 46 popularity score +4. "YOU LIED" (2025) - 44 popularity score +5. "CINNAMON TWISTS" (2024) - 44 popularity score + +### Notable Projects +- **NOMAD EP** (2023): 5-track EP released after signing with Geffen Records +- **5 STAGES OF GRIEF** (2025): Recent 5-track release +- **YOU LIED** (2025): 4-track release +- **SUBTITLE** (2025): 3-track release +- **CATALYST EP** (2023): 4-track release + +## Online Presence +- **Spotify**: 89,690 followers with a popularity score of 48/100 +- **Instagram**: @bradenbales - 147,000 followers +- **TikTok**: @bradenbales - 316,700 followers +- **Discord**: Maintains an active server for fan engagement + +## Audience & Fanbase +- Strong following among Gen Z and young Millennials +- Resonates with listeners focused on mental health awareness and emotional transparency +- Built community through authentic social media engagement +- Digital-first audience discovery primarily through streaming platforms and TikTok + +## Recent Activities +- Continued regular music releases through 2023-2025 +- Active engagement with fans through social media platforms +- Present in both Nashville and Los Angeles music scenes + +## Industry Connections +- Recognized by established artists including Dermot Kennedy and Chelsea Cutler +- Signed to Geffen Records, a major label under Universal Music Group + +## Unique Selling Points +- Emotional honesty and vulnerability in songwriting +- Authentic connection with fans through relatable content +- Multi-genre appeal spanning pop, indie, and rock demographics +- DIY ethos (sells the vocal effects processing he uses in the studio) + +## Career Trajectory +Braden Bales has demonstrated rapid growth from an independent artist to a signed musician with a substantial following. His breakthrough came through viral social media success that translated into streaming numbers, showcasing the modern path to music industry success. His continued output of releases and growing fanbase position him as an emerging artist with potential for mainstream crossover. + +## Additional Notes +- Maintains authenticity as a core value in his artistic expression +- Focuses on mental health themes that resonate with his generation +- Leverages both traditional and digital marketing channels effectively +- Combines commercial appeal with artistic integrity +`; diff --git a/lib/chat/toolChains/toolChains.ts b/lib/chat/toolChains/toolChains.ts new file mode 100644 index 00000000..1275199d --- /dev/null +++ b/lib/chat/toolChains/toolChains.ts @@ -0,0 +1,36 @@ +import { LanguageModel, ModelMessage } from "ai"; +import { createReleaseReportToolChain } from "./create_release_report/createReleaseReportToolChain"; +import { createNewArtistToolChain } from "./createNewArtistToolChain"; + +export type ToolChainItem = { + toolName: string; + system?: string; + messages?: ModelMessage[]; +}; + +export type PrepareStepResult = { + toolChoice?: { type: "tool"; toolName: string }; + model?: LanguageModel; + system?: string; + messages?: ModelMessage[]; +}; + +// Map specific tools to their required models +export const TOOL_MODEL_MAP: Record = { + update_account_info: "gemini-2.5-pro", + // Add other tools that need specific models here + // e.g., create_segments: "gpt-4-turbo", +}; + +// Map trigger tool -> sequence AFTER trigger +export const TOOL_CHAINS: Record = { + create_new_artist: createNewArtistToolChain, + create_release_report: createReleaseReportToolChain, + // You can add other chains here, e.g.: + // create_campaign: [ + // { toolName: "fetch_posts" }, + // { toolName: "analyze_funnel" }, + // { toolName: "generate_email_copy" }, + // { toolName: "schedule_campaign" } + // ], +}; From 20a32ff26823809ddd3f1cb0bf9641e8ca7c66db Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 21:03:53 -0500 Subject: [PATCH 17/19] feat: add Authorization header support to chat endpoints Chat endpoints now support both x-api-key and Authorization Bearer token authentication. Uses getAuthenticatedAccountId for JWT validation via Privy. - Updated validateChatRequest to support dual auth mechanisms - Enforces exactly one auth method (x-api-key XOR Authorization) - Added 3 new tests for Authorization header support - Updated handleChatStream and handleChatGenerate tests with mock This enables Recoup-Chat to proxy requests to recoup-api by forwarding the Authorization header directly. Co-Authored-By: Claude Opus 4.5 --- lib/chat/__tests__/handleChatGenerate.test.ts | 15 ++--- lib/chat/__tests__/handleChatStream.test.ts | 15 ++--- .../__tests__/validateChatRequest.test.ts | 64 ++++++++++++++++--- lib/chat/validateChatRequest.ts | 60 +++++++++++++---- 4 files changed, 116 insertions(+), 38 deletions(-) diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts index b0c20004..1ba8dbea 100644 --- a/lib/chat/__tests__/handleChatGenerate.test.ts +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -6,6 +6,10 @@ vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ validateOverrideAccountId: vi.fn(), })); @@ -69,20 +73,15 @@ describe("handleChatGenerate", () => { expect(json.status).toBe("error"); }); - it("returns 401 error when x-api-key header is missing", async () => { - mockGetApiKeyAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "x-api-key header required" }, - { status: 401 }, - ), - ); - + it("returns 401 error when no auth header is provided", async () => { const request = createMockRequest({ prompt: "Hello" }, {}); const result = await handleChatGenerate(request as any); expect(result).toBeInstanceOf(NextResponse); expect(result.status).toBe(401); + const json = await result.json(); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); }); }); diff --git a/lib/chat/__tests__/handleChatStream.test.ts b/lib/chat/__tests__/handleChatStream.test.ts index 83fc1c73..652befaa 100644 --- a/lib/chat/__tests__/handleChatStream.test.ts +++ b/lib/chat/__tests__/handleChatStream.test.ts @@ -6,6 +6,10 @@ vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ validateOverrideAccountId: vi.fn(), })); @@ -71,20 +75,15 @@ describe("handleChatStream", () => { expect(json.status).toBe("error"); }); - it("returns 401 error when x-api-key header is missing", async () => { - mockGetApiKeyAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "x-api-key header required" }, - { status: 401 }, - ), - ); - + it("returns 401 error when no auth header is provided", async () => { const request = createMockRequest({ prompt: "Hello" }, {}); const result = await handleChatStream(request as any); expect(result).toBeInstanceOf(NextResponse); expect(result.status).toBe(401); + const json = await result.json(); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); }); }); diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index 1d68edae..50efe204 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -7,14 +7,20 @@ vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ validateOverrideAccountId: vi.fn(), })); import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); // Helper to create mock NextRequest @@ -99,14 +105,7 @@ describe("validateChatRequest", () => { }); describe("authentication", () => { - it("rejects request without x-api-key header", async () => { - mockGetApiKeyAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "x-api-key header required" }, - { status: 401 }, - ), - ); - + it("rejects request without any auth header", async () => { const request = createMockRequest({ prompt: "Hello" }, {}); const result = await validateChatRequest(request as any); @@ -114,6 +113,21 @@ describe("validateChatRequest", () => { expect(result).toBeInstanceOf(NextResponse); const json = await (result as NextResponse).json(); expect(json.status).toBe("error"); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("rejects request with both x-api-key and Authorization headers", async () => { + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "test-key", authorization: "Bearer test-token" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); }); it("rejects request with invalid API key", async () => { @@ -147,6 +161,40 @@ describe("validateChatRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect((result as any).accountId).toBe("account-abc-123"); }); + + it("accepts valid Authorization Bearer token", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("account-from-jwt-456"); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-from-jwt-456"); + }); + + it("rejects request with invalid Authorization token", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Failed to verify authentication token" }, + { status: 401 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer invalid-token" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + }); }); describe("accountId override", () => { diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 76f3bc02..164356a5 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { getMessages } from "@/lib/messages/getMessages"; @@ -77,24 +78,55 @@ export async function validateChatRequest( const validatedBody: BaseChatRequestBody = validationResult.data; - // Validate API key authentication - const accountIdOrError = await getApiKeyAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; + // Check which auth mechanism is provided + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce that exactly one auth mechanism is provided + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { + status: "error", + message: "Exactly one of x-api-key or Authorization must be provided", + }, + { + status: 401, + headers: getCorsHeaders(), + }, + ); } - let accountId = accountIdOrError; + // Authenticate and get accountId + let accountId: string; + + if (hasApiKey) { + // Validate API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; - // Handle accountId override for org API keys - if (validatedBody.accountId) { - const overrideResult = await validateOverrideAccountId({ - apiKey: request.headers.get("x-api-key"), - targetAccountId: validatedBody.accountId, - }); - if (overrideResult instanceof NextResponse) { - return overrideResult; + // Handle accountId override for org API keys + if (validatedBody.accountId) { + const overrideResult = await validateOverrideAccountId({ + apiKey, + targetAccountId: validatedBody.accountId, + }); + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + accountId = overrideResult.accountId; + } + } else { + // Validate bearer token authentication + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; } - accountId = overrideResult.accountId; + accountId = accountIdOrError; } // Normalize chat content: From 08c1d6fb4553809f338c9533159f77e3934ca3ef Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 21:21:42 -0500 Subject: [PATCH 18/19] feat: migrate getMcpTools (web_deep_research, artist_deep_research) from Recoup-Chat Add critical MCP tools needed for toolChains: - web_deep_research: Perplexity sonar-deep-research for comprehensive research with citations - artist_deep_research: Fetches artist socials for deep research - chatWithPerplexity: New Perplexity API wrapper for non-streaming chat completions These tools are required by create_new_artist and create_release_report toolChains. Migrated with 21 unit tests total (8 + 7 + 6). Co-Authored-By: Claude Opus 4.5 --- .../registerArtistDeepResearchTool.test.ts | 132 +++++++++++++++ .../registerWebDeepResearchTool.test.ts | 138 ++++++++++++++++ lib/mcp/tools/index.ts | 4 + .../tools/registerArtistDeepResearchTool.ts | 73 +++++++++ lib/mcp/tools/registerWebDeepResearchTool.ts | 66 ++++++++ .../__tests__/chatWithPerplexity.test.ts | 151 ++++++++++++++++++ lib/perplexity/chatWithPerplexity.ts | 61 +++++++ 7 files changed, 625 insertions(+) create mode 100644 lib/mcp/tools/__tests__/registerArtistDeepResearchTool.test.ts create mode 100644 lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts create mode 100644 lib/mcp/tools/registerArtistDeepResearchTool.ts create mode 100644 lib/mcp/tools/registerWebDeepResearchTool.ts create mode 100644 lib/perplexity/__tests__/chatWithPerplexity.test.ts create mode 100644 lib/perplexity/chatWithPerplexity.ts diff --git a/lib/mcp/tools/__tests__/registerArtistDeepResearchTool.test.ts b/lib/mcp/tools/__tests__/registerArtistDeepResearchTool.test.ts new file mode 100644 index 00000000..7235a811 --- /dev/null +++ b/lib/mcp/tools/__tests__/registerArtistDeepResearchTool.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerArtistDeepResearchTool } from "../registerArtistDeepResearchTool"; + +const mockGetArtistSocials = vi.fn(); + +vi.mock("@/lib/artist/getArtistSocials", () => ({ + getArtistSocials: (...args: unknown[]) => mockGetArtistSocials(...args), +})); + +describe("registerArtistDeepResearchTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown) => Promise; + let registeredConfig: { description: string; inputSchema: unknown }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredConfig = config; + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerArtistDeepResearchTool(mockServer); + }); + + it("registers the artist_deep_research tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "artist_deep_research", + expect.objectContaining({ + description: expect.stringContaining("comprehensive research"), + }), + expect.any(Function), + ); + }); + + it("has correct input schema with artist_account_id", () => { + expect(registeredConfig.inputSchema).toBeDefined(); + }); + + it("returns artist socials data for research", async () => { + mockGetArtistSocials.mockResolvedValue({ + status: "success", + socials: [ + { + id: "social-1", + social_id: "instagram", + username: "artist_handle", + profile_url: "https://instagram.com/artist_handle", + avatar: "https://example.com/avatar.jpg", + bio: "Artist bio here", + follower_count: 10000, + following_count: 500, + }, + ], + pagination: { + total_count: 1, + page: 1, + limit: 20, + total_pages: 1, + }, + }); + + const result = await registeredHandler({ + artist_account_id: "artist-123", + }); + + expect(mockGetArtistSocials).toHaveBeenCalledWith({ + artist_account_id: "artist-123", + page: 1, + limit: 100, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("artist_handle"), + }, + ], + }); + }); + + it("includes research requirements in description", () => { + expect(registeredConfig.description).toContain("Spotify"); + expect(registeredConfig.description).toContain("Socials"); + }); + + it("returns success flag in response", async () => { + mockGetArtistSocials.mockResolvedValue({ + status: "success", + socials: [], + pagination: { total_count: 0, page: 1, limit: 20, total_pages: 0 }, + }); + + const result = (await registeredHandler({ + artist_account_id: "artist-123", + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.artist_account_id).toBe("artist-123"); + }); + + it("handles getArtistSocials errors gracefully", async () => { + mockGetArtistSocials.mockRejectedValue(new Error("Database connection failed")); + + const result = (await registeredHandler({ + artist_account_id: "artist-123", + })) as { content: Array<{ type: string; text: string }> }; + + expect(result.content[0].text).toContain("Database connection failed"); + }); + + it("handles empty socials array", async () => { + mockGetArtistSocials.mockResolvedValue({ + status: "success", + socials: [], + pagination: { total_count: 0, page: 1, limit: 20, total_pages: 0 }, + }); + + const result = (await registeredHandler({ + artist_account_id: "artist-123", + })) as { content: Array<{ type: string; text: string }> }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.artistSocials.socials).toEqual([]); + expect(parsed.success).toBe(true); + }); +}); diff --git a/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts b/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts new file mode 100644 index 00000000..64ee2767 --- /dev/null +++ b/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerWebDeepResearchTool } from "../registerWebDeepResearchTool"; + +const mockChatWithPerplexity = vi.fn(); + +vi.mock("@/lib/perplexity/chatWithPerplexity", () => ({ + chatWithPerplexity: (...args: unknown[]) => mockChatWithPerplexity(...args), +})); + +describe("registerWebDeepResearchTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown) => Promise; + let registeredConfig: { description: string; inputSchema: unknown }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredConfig = config; + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerWebDeepResearchTool(mockServer); + }); + + it("registers the web_deep_research tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "web_deep_research", + expect.objectContaining({ + description: expect.stringContaining("Deep web research"), + }), + expect.any(Function), + ); + }); + + it("has correct input schema with messages array", () => { + expect(registeredConfig.inputSchema).toBeDefined(); + }); + + it("returns research results with content and citations", async () => { + mockChatWithPerplexity.mockResolvedValue({ + content: "Research findings about the topic...", + citations: ["https://example.com/source1", "https://example.com/source2"], + searchResults: [ + { title: "Source 1", url: "https://example.com/source1" }, + { title: "Source 2", url: "https://example.com/source2" }, + ], + }); + + const result = await registeredHandler({ + messages: [{ role: "user", content: "Research this topic" }], + }); + + expect(mockChatWithPerplexity).toHaveBeenCalledWith( + [{ role: "user", content: "Research this topic" }], + "sonar-deep-research", + ); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Research findings about the topic"), + }, + ], + }); + }); + + it("appends citations to the output", async () => { + mockChatWithPerplexity.mockResolvedValue({ + content: "Research findings", + citations: ["https://example.com/source1", "https://example.com/source2"], + searchResults: [], + }); + + const result = (await registeredHandler({ + messages: [{ role: "user", content: "Research this topic" }], + })) as { content: Array<{ type: string; text: string }> }; + + expect(result.content[0].text).toContain("Citations:"); + expect(result.content[0].text).toContain("[1] https://example.com/source1"); + expect(result.content[0].text).toContain("[2] https://example.com/source2"); + }); + + it("handles empty messages array", async () => { + const result = (await registeredHandler({ + messages: [], + })) as { content: Array<{ type: string; text: string }> }; + + // Error result contains JSON with message field + expect(result.content[0].text).toContain("messages array is required"); + }); + + it("returns error when chatWithPerplexity fails", async () => { + mockChatWithPerplexity.mockRejectedValue(new Error("API rate limit exceeded")); + + const result = (await registeredHandler({ + messages: [{ role: "user", content: "Research this topic" }], + })) as { content: Array<{ type: string; text: string }> }; + + expect(result.content[0].text).toContain("API rate limit exceeded"); + }); + + it("uses sonar-deep-research model by default", async () => { + mockChatWithPerplexity.mockResolvedValue({ + content: "Research findings", + citations: [], + searchResults: [], + }); + + await registeredHandler({ + messages: [{ role: "user", content: "Research this topic" }], + }); + + expect(mockChatWithPerplexity).toHaveBeenCalledWith( + expect.any(Array), + "sonar-deep-research", + ); + }); + + it("handles no citations gracefully", async () => { + mockChatWithPerplexity.mockResolvedValue({ + content: "Research findings without citations", + citations: [], + searchResults: [], + }); + + const result = (await registeredHandler({ + messages: [{ role: "user", content: "Research this topic" }], + })) as { content: Array<{ type: string; text: string }> }; + + expect(result.content[0].text).toContain("Research findings without citations"); + expect(result.content[0].text).not.toContain("Citations:"); + }); +}); diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 39261efa..78db215b 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -9,6 +9,8 @@ import { registerContactTeamTool } from "./registerContactTeamTool"; import { registerUpdateAccountInfoTool } from "./registerUpdateAccountInfoTool"; import { registerAllArtistSocialsTools } from "./artistSocials"; import { registerSearchWebTool } from "./registerSearchWebTool"; +import { registerWebDeepResearchTool } from "./registerWebDeepResearchTool"; +import { registerArtistDeepResearchTool } from "./registerArtistDeepResearchTool"; import { registerAllFileTools } from "./files"; import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; @@ -33,6 +35,8 @@ export const registerAllTools = (server: McpServer): void => { registerContactTeamTool(server); registerGetLocalTimeTool(server); registerSearchWebTool(server); + registerWebDeepResearchTool(server); + registerArtistDeepResearchTool(server); registerSendEmailTool(server); registerUpdateAccountInfoTool(server); registerCreateSegmentsTool(server); diff --git a/lib/mcp/tools/registerArtistDeepResearchTool.ts b/lib/mcp/tools/registerArtistDeepResearchTool.ts new file mode 100644 index 00000000..b871306d --- /dev/null +++ b/lib/mcp/tools/registerArtistDeepResearchTool.ts @@ -0,0 +1,73 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getArtistSocials } from "@/lib/artist/getArtistSocials"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const SPOTIFY_DEEP_RESEARCH_REQUIREMENTS = ` + - popularity info (MANDATORY): + * Track popularity scores (0-100) for all tracks + * Average popularity across all tracks + * Most popular tracks ranked by popularity + * Popularity trends over time (if available) + - Spotify follower metrics (MANDATORY): + * Current total follower count for the artist on Spotify + - engagement info + - tracklist + - collaborators + - album art + - album name + `; + +const artistDeepResearchSchema = z.object({ + artist_account_id: z.string().describe("Artist account ID to research"), +}); + +type ArtistDeepResearchArgs = z.infer; + +/** + * Registers the "artist_deep_research" tool on the MCP server. + * Conducts comprehensive research on an artist across multiple platforms. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerArtistDeepResearchTool(server: McpServer): void { + server.registerTool( + "artist_deep_research", + { + description: + "Conducts comprehensive research on an artist across multiple platforms and generates a detailed report. " + + `Spotify research requirements: ${SPOTIFY_DEEP_RESEARCH_REQUIREMENTS} ` + + "Other research requirements: " + + "- Socials: Follower counts, engagement rates, top content, branding, posting consistency " + + "- Website: Branding, layout, contact info, mailing list " + + "- YouTube: Consistency, video quality, viewership, contact info " + + "- Marketing: Campaign ideas, revenue streams, collaboration opportunities, brand partnerships", + inputSchema: artistDeepResearchSchema, + }, + async (args: ArtistDeepResearchArgs) => { + try { + const { artist_account_id } = args; + + // Fetch all artist socials (high limit to get comprehensive data) + const artistSocials = await getArtistSocials({ + artist_account_id, + page: 1, + limit: 100, + }); + + return getToolResultSuccess({ + artistSocials, + artist_account_id, + success: true, + }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? `Artist deep research failed: ${error.message}` + : "Artist deep research failed", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/registerWebDeepResearchTool.ts b/lib/mcp/tools/registerWebDeepResearchTool.ts new file mode 100644 index 00000000..df9f5d8e --- /dev/null +++ b/lib/mcp/tools/registerWebDeepResearchTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { chatWithPerplexity } from "@/lib/perplexity/chatWithPerplexity"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const webDeepResearchSchema = z.object({ + messages: z + .array( + z.object({ + role: z.string().describe("The role of the message sender (user, assistant, system)"), + content: z.string().describe("The content of the message"), + }), + ) + .min(1, "At least one message is required") + .describe("Array of messages for the research query"), +}); + +type WebDeepResearchArgs = z.infer; + +/** + * Registers the "web_deep_research" tool on the MCP server. + * Performs deep web research using Perplexity's sonar-deep-research model. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerWebDeepResearchTool(server: McpServer): void { + server.registerTool( + "web_deep_research", + { + description: + "Deep web research tool for comprehensive, multi-source analysis. " + + "Use this when you need thorough research on complex topics that require synthesizing information from many sources. " + + "This tool performs more extensive research than the standard web search. " + + "Accepts an array of messages and returns comprehensive research results with citations.", + inputSchema: webDeepResearchSchema, + }, + async (args: WebDeepResearchArgs) => { + try { + const { messages } = args; + + if (!messages || messages.length === 0) { + return getToolResultError("messages array is required and cannot be empty"); + } + + const result = await chatWithPerplexity(messages, "sonar-deep-research"); + + let finalContent = result.content; + + // Append citations if available + if (result.citations.length > 0) { + finalContent += "\n\nCitations:\n"; + result.citations.forEach((citation, index) => { + finalContent += `[${index + 1}] ${citation}\n`; + }); + } + + return getToolResultSuccess(finalContent); + } catch (error) { + return getToolResultError( + error instanceof Error ? `Deep research failed: ${error.message}` : "Deep research failed", + ); + } + }, + ); +} diff --git a/lib/perplexity/__tests__/chatWithPerplexity.test.ts b/lib/perplexity/__tests__/chatWithPerplexity.test.ts new file mode 100644 index 00000000..beb3ea3b --- /dev/null +++ b/lib/perplexity/__tests__/chatWithPerplexity.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { chatWithPerplexity, PerplexityMessage } from "../chatWithPerplexity"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +vi.mock("../config", () => ({ + getPerplexityApiKey: () => "test-api-key", + getPerplexityHeaders: (apiKey: string) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }), + PERPLEXITY_BASE_URL: "https://api.perplexity.ai", +})); + +describe("chatWithPerplexity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends correct request to Perplexity API", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Response content" } }], + citations: [], + search_results: [], + }), + }); + + const messages: PerplexityMessage[] = [{ role: "user", content: "Test query" }]; + + await chatWithPerplexity(messages, "sonar-pro"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.perplexity.ai/chat/completions", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-api-key", + }, + body: JSON.stringify({ + model: "sonar-pro", + messages, + stream: false, + }), + }), + ); + }); + + it("returns content, citations, and search results", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Research findings" } }], + citations: ["https://example.com/1", "https://example.com/2"], + search_results: [ + { title: "Source 1", url: "https://example.com/1" }, + { title: "Source 2", url: "https://example.com/2" }, + ], + }), + }); + + const result = await chatWithPerplexity([{ role: "user", content: "Test" }]); + + expect(result).toEqual({ + content: "Research findings", + citations: ["https://example.com/1", "https://example.com/2"], + searchResults: [ + { title: "Source 1", url: "https://example.com/1" }, + { title: "Source 2", url: "https://example.com/2" }, + ], + }); + }); + + it("uses default model when not specified", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Response" } }], + citations: [], + search_results: [], + }), + }); + + await chatWithPerplexity([{ role: "user", content: "Test" }]); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"model":"sonar-pro"'), + }), + ); + }); + + it("handles API errors gracefully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: "Too Many Requests", + text: () => Promise.resolve("Rate limit exceeded"), + }); + + await expect(chatWithPerplexity([{ role: "user", content: "Test" }])).rejects.toThrow( + "Perplexity API error: 429 Too Many Requests", + ); + }); + + it("handles missing fields gracefully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [], + }), + }); + + const result = await chatWithPerplexity([{ role: "user", content: "Test" }]); + + expect(result).toEqual({ + content: "", + citations: [], + searchResults: [], + }); + }); + + it("supports custom models like sonar-deep-research", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Deep research results" } }], + citations: [], + search_results: [], + }), + }); + + await chatWithPerplexity([{ role: "user", content: "Test" }], "sonar-deep-research"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"model":"sonar-deep-research"'), + }), + ); + }); +}); diff --git a/lib/perplexity/chatWithPerplexity.ts b/lib/perplexity/chatWithPerplexity.ts new file mode 100644 index 00000000..476d1c37 --- /dev/null +++ b/lib/perplexity/chatWithPerplexity.ts @@ -0,0 +1,61 @@ +import { getPerplexityApiKey, getPerplexityHeaders, PERPLEXITY_BASE_URL } from "./config"; + +export interface PerplexityMessage { + role: string; + content: string; +} + +export interface ChatResult { + content: string; + citations: string[]; + searchResults: Array<{ + title: string; + url: string; + snippet?: string; + }>; +} + +/** + * Sends a chat completion request to Perplexity API. + * Uses non-streaming for simpler MCP tool integration. + * + * @param messages - Array of messages with role and content + * @param model - The Perplexity model to use (default: sonar-pro) + * @returns Chat result with content, citations, and search results + */ +export async function chatWithPerplexity( + messages: PerplexityMessage[], + model: string = "sonar-pro", +): Promise { + const apiKey = getPerplexityApiKey(); + const url = `${PERPLEXITY_BASE_URL}/chat/completions`; + + const body = { + model, + messages, + stream: false, + }; + + const response = await fetch(url, { + method: "POST", + headers: getPerplexityHeaders(apiKey), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Perplexity API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + const data = await response.json(); + + const content = data.choices?.[0]?.message?.content || ""; + const citations = data.citations || []; + const searchResults = data.search_results || []; + + return { + content, + citations, + searchResults, + }; +} From 94286aae2f4849249a57c366c93da8af14641ffd Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 21:33:15 -0500 Subject: [PATCH 19/19] test: add integration tests for chat migration Add comprehensive integration tests for the chat migration: - validateChatRequest integration (7 tests): auth validation, prompt/messages validation, parameter passing - setupChatRequest integration (6 tests): account email lookup, artist context fetching, account details, model override - handleChatCompletion integration (6 tests): room creation, memory storage, email tool outputs, error handling - handleChatCredits integration (5 tests): credit calculation, deduction, zero cost handling, error recovery - End-to-end validation flow (3 tests): complete pipeline validation Total: 27 new integration tests, 438 tests passing Co-Authored-By: Claude Opus 4.5 --- .../integration/chatEndToEnd.test.ts | 694 ++++++++++++++++++ 1 file changed, 694 insertions(+) create mode 100644 lib/chat/__tests__/integration/chatEndToEnd.test.ts diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts new file mode 100644 index 00000000..0019cb46 --- /dev/null +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -0,0 +1,694 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextResponse } from "next/server"; + +/** + * Integration tests for chat endpoints. + * + * These tests verify the end-to-end chat flow including: + * 1. Request validation through the validation + auth flow + * 2. Setup chat request with agent, tools, and system prompt + * 3. Post-completion handling (handleChatCompletion, handleChatCredits) + * 4. Tool chains preparation + * + * External dependencies (database, AI providers) are mocked to test + * the integration of internal components. + */ + +// Mock auth dependencies +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), +})); + +// Mock Supabase dependencies +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_info/selectAccountInfo", () => ({ + selectAccountInfo: vi.fn(), +})); + +vi.mock("@/lib/supabase/accounts/getAccountWithDetails", () => ({ + getAccountWithDetails: vi.fn(), +})); + +vi.mock("@/lib/files/getKnowledgeBaseText", () => ({ + getKnowledgeBaseText: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ + insertRoom: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/upsertMemory", () => ({ + default: vi.fn(), +})); + +// Mock notification dependencies +vi.mock("@/lib/telegram/sendNewConversationNotification", () => ({ + sendNewConversationNotification: vi.fn(), +})); + +vi.mock("@/lib/telegram/sendErrorNotification", () => ({ + sendErrorNotification: vi.fn(), +})); + +// Mock email dependencies +vi.mock("@/lib/emails/handleSendEmailToolOutputs", () => ({ + handleSendEmailToolOutputs: vi.fn(), +})); + +// Mock credit dependencies +vi.mock("@/lib/credits/getCreditUsage", () => ({ + getCreditUsage: vi.fn().mockResolvedValue(0.1), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +// Mock tools setup +vi.mock("@/lib/chat/setupToolsForRequest", () => ({ + setupToolsForRequest: vi.fn().mockResolvedValue({}), +})); + +// Mock internal AI text generation +vi.mock("@/lib/ai/generateText", () => ({ + default: vi.fn().mockResolvedValue({ text: "Generated Title" }), +})); + +// Mock chat title generation +vi.mock("@/lib/chat/generateChatTitle", () => ({ + generateChatTitle: vi.fn().mockResolvedValue("Test Chat"), +})); + +// Mock AI SDK +vi.mock("ai", () => ({ + convertToModelMessages: vi.fn((messages: unknown[]) => messages), + stepCountIs: vi.fn().mockReturnValue(() => true), + ToolLoopAgent: vi.fn().mockImplementation(() => ({ + stream: vi.fn(), + tools: {}, + })), +})); + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import upsertMemory from "@/lib/supabase/memories/upsertMemory"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; +import { getCreditUsage } from "@/lib/credits/getCreditUsage"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { generateChatTitle } from "../../generateChatTitle"; +import { handleChatCompletion } from "../../handleChatCompletion"; +import { handleChatCredits } from "@/lib/credits/handleChatCredits"; +import { validateChatRequest } from "../../validateChatRequest"; +import { setupChatRequest } from "../../setupChatRequest"; + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockSelectAccountEmails = vi.mocked(selectAccountEmails); +const mockSelectAccountInfo = vi.mocked(selectAccountInfo); +const mockGetAccountWithDetails = vi.mocked(getAccountWithDetails); +const mockGetKnowledgeBaseText = vi.mocked(getKnowledgeBaseText); +const mockSelectRoom = vi.mocked(selectRoom); +const mockInsertRoom = vi.mocked(insertRoom); +const mockUpsertMemory = vi.mocked(upsertMemory); +const mockSendNewConversationNotification = vi.mocked(sendNewConversationNotification); +const mockHandleSendEmailToolOutputs = vi.mocked(handleSendEmailToolOutputs); +const mockGetCreditUsage = vi.mocked(getCreditUsage); +const mockDeductCredits = vi.mocked(deductCredits); +const mockGenerateChatTitle = vi.mocked(generateChatTitle); + +// Helper to create mock NextRequest +function createMockRequest( + body: unknown, + headers: Record = {}, +): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + has: (key: string) => key.toLowerCase() in headers, + }, + } as unknown as Request; +} + +describe("Chat Integration Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Set up default mocks for Supabase operations + mockSelectAccountEmails.mockResolvedValue([{ email: "test@example.com" }] as any); + mockSelectAccountInfo.mockResolvedValue(null); + mockGetAccountWithDetails.mockResolvedValue(null); + mockGetKnowledgeBaseText.mockResolvedValue(""); + mockSelectRoom.mockResolvedValue(null); + mockInsertRoom.mockResolvedValue(undefined); + mockUpsertMemory.mockResolvedValue(undefined); + mockSendNewConversationNotification.mockResolvedValue(undefined); + mockHandleSendEmailToolOutputs.mockResolvedValue(undefined); + mockGetCreditUsage.mockResolvedValue(0.1); + mockDeductCredits.mockResolvedValue(undefined); + mockGenerateChatTitle.mockResolvedValue("Test Chat"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("validateChatRequest integration", () => { + it("validates and returns body for valid request with prompt", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "valid-key" }, + ); + + const result = await validateChatRequest(request as any); + + // Should not be a NextResponse error + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + expect((result as any).prompt).toBe("Hello"); + }); + + it("validates and returns body for valid request with messages", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }, + { "x-api-key": "valid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toHaveLength(1); + }); + + it("returns 401 when no auth header is provided", async () => { + const request = createMockRequest({ prompt: "Hello" }, {}); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 401 when API key lookup fails", async () => { + // getApiKeyAccountId returns a NextResponse when authentication fails + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Unauthorized" }, + { status: 401 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "invalid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 400 when neither messages nor prompt is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { roomId: "room-123" }, + { "x-api-key": "valid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when both prompt and messages are provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + prompt: "Hello", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }, + { "x-api-key": "valid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("passes through optional parameters", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + prompt: "Hello", + roomId: "room-123", + artistId: "artist-456", + model: "gpt-4", + excludeTools: ["tool1", "tool2"], + }, + { "x-api-key": "valid-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("room-123"); + expect((result as any).artistId).toBe("artist-456"); + expect((result as any).model).toBe("gpt-4"); + expect((result as any).excludeTools).toEqual(["tool1", "tool2"]); + }); + }); + + describe("setupChatRequest integration", () => { + it("correctly retrieves account email for system prompt", async () => { + mockSelectAccountEmails.mockResolvedValue([{ email: "user@test.com" }] as any); + + const body = { + accountId: "account-123", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }; + + await setupChatRequest(body as any); + + expect(mockSelectAccountEmails).toHaveBeenCalledWith({ + accountIds: "account-123", + }); + }); + + it("fetches artist context when artistId is provided", async () => { + mockSelectAccountInfo.mockResolvedValue({ + instruction: "Be helpful for this artist", + knowledges: [{ id: "kb-1" }], + } as any); + mockGetKnowledgeBaseText.mockResolvedValue("Artist knowledge base content"); + + const body = { + accountId: "account-123", + artistId: "artist-456", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }; + + await setupChatRequest(body as any); + + expect(mockSelectAccountInfo).toHaveBeenCalledWith("artist-456"); + expect(mockGetKnowledgeBaseText).toHaveBeenCalled(); + }); + + it("does not fetch artist context when artistId is not provided", async () => { + const body = { + accountId: "account-123", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }; + + await setupChatRequest(body as any); + + expect(mockSelectAccountInfo).not.toHaveBeenCalled(); + expect(mockGetKnowledgeBaseText).not.toHaveBeenCalled(); + }); + + it("returns ChatConfig with agent and tools", async () => { + const body = { + accountId: "account-123", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }; + + const result = await setupChatRequest(body as any); + + expect(result).toHaveProperty("agent"); + expect(result).toHaveProperty("model"); + expect(result).toHaveProperty("instructions"); + expect(result).toHaveProperty("tools"); + expect(result).toHaveProperty("system"); + }); + + it("uses provided model override", async () => { + const body = { + accountId: "account-123", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + model: "claude-3-opus", + }; + + const result = await setupChatRequest(body as any); + + expect(result.model).toBe("claude-3-opus"); + }); + + it("fetches account details for system prompt context", async () => { + mockGetAccountWithDetails.mockResolvedValue({ + name: "Test User", + professional_context: "Music producer", + } as any); + + const body = { + accountId: "account-123", + messages: [{ id: "msg-1", role: "user", content: "Hello" }], + }; + + await setupChatRequest(body as any); + + expect(mockGetAccountWithDetails).toHaveBeenCalledWith("account-123"); + }); + }); + + describe("handleChatCompletion integration", () => { + it("creates room for new conversations", async () => { + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("New Chat Title"); + + const body = { + messages: [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + ], + roomId: "new-room-123", + accountId: "account-123", + }; + + const responseMessages = [ + { id: "response-1", role: "assistant", parts: [{ type: "text", text: "Hi there!" }] }, + ]; + + await handleChatCompletion(body as any, responseMessages as any); + + expect(mockInsertRoom).toHaveBeenCalledWith( + expect.objectContaining({ + id: "new-room-123", + account_id: "account-123", + topic: "New Chat Title", + }), + ); + expect(mockSendNewConversationNotification).toHaveBeenCalled(); + }); + + it("skips room creation for existing rooms", async () => { + mockSelectRoom.mockResolvedValue({ id: "existing-room" } as any); + + const body = { + messages: [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + ], + roomId: "existing-room", + accountId: "account-123", + }; + + const responseMessages = [ + { id: "response-1", role: "assistant", parts: [{ type: "text", text: "Hi!" }] }, + ]; + + await handleChatCompletion(body as any, responseMessages as any); + + expect(mockInsertRoom).not.toHaveBeenCalled(); + expect(mockSendNewConversationNotification).not.toHaveBeenCalled(); + }); + + it("stores both user and assistant messages to memories", async () => { + mockSelectRoom.mockResolvedValue({ id: "room-123" } as any); + + const body = { + messages: [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + ], + roomId: "room-123", + accountId: "account-123", + }; + + const responseMessages = [ + { id: "response-1", role: "assistant", parts: [{ type: "text", text: "Hi!" }] }, + ]; + + await handleChatCompletion(body as any, responseMessages as any); + + expect(mockUpsertMemory).toHaveBeenCalledTimes(2); + expect(mockUpsertMemory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "msg-1", + room_id: "room-123", + }), + ); + expect(mockUpsertMemory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "response-1", + room_id: "room-123", + }), + ); + }); + + it("processes email tool outputs", async () => { + mockSelectRoom.mockResolvedValue({ id: "room-123" } as any); + + const body = { + messages: [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Send an email" }] }, + ], + roomId: "room-123", + accountId: "account-123", + }; + + const responseMessages = [ + { + id: "response-1", + role: "assistant", + parts: [ + { + type: "tool-invocation", + toolName: "send_email", + result: { success: true }, + }, + ], + }, + ]; + + await handleChatCompletion(body as any, responseMessages as any); + + expect(mockHandleSendEmailToolOutputs).toHaveBeenCalledWith(responseMessages); + }); + + it("catches errors without breaking chat response", async () => { + mockSelectRoom.mockRejectedValue(new Error("Database error")); + + const body = { + messages: [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + ], + roomId: "room-123", + accountId: "account-123", + }; + + const responseMessages = [ + { id: "response-1", role: "assistant", parts: [{ type: "text", text: "Hi!" }] }, + ]; + + // Should not throw + await expect( + handleChatCompletion(body as any, responseMessages as any), + ).resolves.toBeUndefined(); + }); + + it("handles empty roomId by defaulting to empty string", async () => { + const body = { + messages: [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + ], + accountId: "account-123", + // roomId not provided + }; + + const responseMessages = [ + { id: "response-1", role: "assistant", parts: [{ type: "text", text: "Hi!" }] }, + ]; + + await handleChatCompletion(body as any, responseMessages as any); + + expect(mockSelectRoom).toHaveBeenCalledWith(""); + }); + }); + + describe("handleChatCredits integration", () => { + it("calculates and deducts credits based on usage", async () => { + mockGetCreditUsage.mockResolvedValue(0.5); + + await handleChatCredits({ + usage: { promptTokens: 1000, completionTokens: 500 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockGetCreditUsage).toHaveBeenCalledWith( + { promptTokens: 1000, completionTokens: 500 }, + "gpt-4", + ); + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 50, // 0.5 * 100 + }); + }); + + it("skips deduction when no accountId is provided", async () => { + await handleChatCredits({ + usage: { promptTokens: 100, completionTokens: 50 }, + model: "gpt-4", + accountId: undefined, + }); + + expect(mockGetCreditUsage).not.toHaveBeenCalled(); + expect(mockDeductCredits).not.toHaveBeenCalled(); + }); + + it("handles zero cost gracefully", async () => { + mockGetCreditUsage.mockResolvedValue(0); + + await handleChatCredits({ + usage: { promptTokens: 10, completionTokens: 5 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockGetCreditUsage).toHaveBeenCalled(); + expect(mockDeductCredits).not.toHaveBeenCalled(); + }); + + it("catches credit deduction errors without breaking chat flow", async () => { + mockGetCreditUsage.mockRejectedValue(new Error("Gateway error")); + + // Should not throw + await expect( + handleChatCredits({ + usage: { promptTokens: 100, completionTokens: 50 }, + model: "gpt-4", + accountId: "account-123", + }), + ).resolves.toBeUndefined(); + }); + + it("rounds credits to minimum of 1 when cost is very small", async () => { + mockGetCreditUsage.mockResolvedValue(0.001); + + await handleChatCredits({ + usage: { promptTokens: 5, completionTokens: 5 }, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 1, // Math.max(1, Math.round(0.001 * 100)) + }); + }); + }); + + describe("end-to-end validation flow", () => { + it("validates prompt-based requests through full pipeline", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "What is 2+2?" }, + { "x-api-key": "valid-key" }, + ); + + const validationResult = await validateChatRequest(request as any); + expect(validationResult).not.toBeInstanceOf(NextResponse); + + const chatConfig = await setupChatRequest(validationResult as any); + expect(chatConfig.agent).toBeDefined(); + expect(chatConfig.model).toBeDefined(); + }); + + it("validates messages-based requests through full pipeline", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + messages: [ + { id: "msg-1", role: "user", content: "Hello" }, + { id: "msg-2", role: "assistant", content: "Hi there!" }, + { id: "msg-3", role: "user", content: "How are you?" }, + ], + }, + { "x-api-key": "valid-key" }, + ); + + const validationResult = await validateChatRequest(request as any); + expect(validationResult).not.toBeInstanceOf(NextResponse); + + const chatConfig = await setupChatRequest(validationResult as any); + expect(chatConfig.agent).toBeDefined(); + expect(chatConfig.messages.length).toBeLessThanOrEqual(100); // MAX_MESSAGES + }); + + it("handles complete chat flow with post-completion", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSelectRoom.mockResolvedValue(null); + mockGenerateChatTitle.mockResolvedValue("Math Question"); + + // 1. Validate request + const request = createMockRequest( + { + prompt: "What is 2+2?", + roomId: "new-room-123", + }, + { "x-api-key": "valid-key" }, + ); + + const body = await validateChatRequest(request as any); + expect(body).not.toBeInstanceOf(NextResponse); + + // 2. Setup chat request + const chatConfig = await setupChatRequest(body as any); + expect(chatConfig.agent).toBeDefined(); + + // 3. Handle post-completion (simulating what would happen after agent response) + const responseMessages = [ + { + id: "response-1", + role: "assistant", + parts: [{ type: "text", text: "2 + 2 = 4" }], + }, + ]; + + await handleChatCompletion(body as any, responseMessages as any); + + expect(mockInsertRoom).toHaveBeenCalled(); + expect(mockUpsertMemory).toHaveBeenCalledTimes(2); + + // 4. Handle credits + await handleChatCredits({ + usage: { promptTokens: 100, completionTokens: 50 }, + model: chatConfig.model, + accountId: (body as any).accountId, + }); + + expect(mockGetCreditUsage).toHaveBeenCalled(); + expect(mockDeductCredits).toHaveBeenCalled(); + }); + }); +});