Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions lib/chat/__tests__/handleChatGenerate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,22 @@ vi.mock("ai", () => ({
generateText: vi.fn(),
}));

vi.mock("@/lib/chat/saveChatCompletion", () => ({
saveChatCompletion: vi.fn(),
}));

import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
import { setupChatRequest } from "@/lib/chat/setupChatRequest";
import { generateText } from "ai";
import { saveChatCompletion } from "@/lib/chat/saveChatCompletion";
import { handleChatGenerate } from "../handleChatGenerate";

const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId);
const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId);
const mockSetupChatRequest = vi.mocked(setupChatRequest);
const mockGenerateText = vi.mocked(generateText);
const mockSaveChatCompletion = vi.mocked(saveChatCompletion);

// Helper to create mock NextRequest
function createMockRequest(
Expand Down Expand Up @@ -336,4 +342,140 @@ describe("handleChatGenerate", () => {
);
});
});

describe("message persistence", () => {
it("saves assistant message to database when roomId is provided", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Hello! How can I help you?",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello", roomId: "room-abc-123" },
{ "x-api-key": "valid-key" },
);

await handleChatGenerate(request as any);

expect(mockSaveChatCompletion).toHaveBeenCalledWith({
text: "Hello! How can I help you?",
roomId: "room-abc-123",
});
});

it("does not save message when roomId is not provided", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Response",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

const request = createMockRequest(
{ prompt: "Hello" },
{ "x-api-key": "valid-key" },
);

await handleChatGenerate(request as any);

expect(mockSaveChatCompletion).not.toHaveBeenCalled();
});

it("passes correct text to saveChatCompletion", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "This is the assistant response text",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello", roomId: "room-xyz" },
{ "x-api-key": "valid-key" },
);

await handleChatGenerate(request as any);

expect(mockSaveChatCompletion).toHaveBeenCalledWith({
text: "This is the assistant response text",
roomId: "room-xyz",
});
});

it("still returns success response even if saveChatCompletion fails", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Response",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockRejectedValue(new Error("Database error"));

const request = createMockRequest(
{ prompt: "Hello", roomId: "room-abc" },
{ "x-api-key": "valid-key" },
);

const result = await handleChatGenerate(request as any);

expect(result.status).toBe(200);
const json = await result.json();
expect(json.text).toBe("Response");
});
});
});
180 changes: 180 additions & 0 deletions lib/chat/__tests__/saveChatCompletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// Mock dependencies before importing the module under test
vi.mock("@/lib/messages/getMessages", () => ({
getMessages: vi.fn(),
}));

vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({
default: vi.fn(),
}));

vi.mock("@/lib/supabase/memories/insertMemories", () => ({
default: vi.fn(),
}));

import { getMessages } from "@/lib/messages/getMessages";
import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories";
import insertMemories from "@/lib/supabase/memories/insertMemories";
import { saveChatCompletion } from "../saveChatCompletion";

const mockGetMessages = vi.mocked(getMessages);
const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories);
const mockInsertMemories = vi.mocked(insertMemories);

describe("saveChatCompletion", () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

it("calls getMessages with text and role", async () => {
const mockMessage = {
id: "msg-123",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Hello, world!" }],
};
mockGetMessages.mockReturnValue([mockMessage]);
mockFilterMessageContentForMemories.mockReturnValue({
role: "assistant",
parts: mockMessage.parts,
content: "Hello, world!",
});
mockInsertMemories.mockResolvedValue(null);

await saveChatCompletion({
text: "Hello, world!",
role: "assistant",
roomId: "room-abc",
});

expect(mockGetMessages).toHaveBeenCalledWith("Hello, world!", "assistant");
});

it("calls filterMessageContentForMemories with the message", async () => {
const mockMessage = {
id: "msg-456",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Test response" }],
};
mockGetMessages.mockReturnValue([mockMessage]);
mockFilterMessageContentForMemories.mockReturnValue({
role: "assistant",
parts: mockMessage.parts,
content: "Test response",
});
mockInsertMemories.mockResolvedValue(null);

await saveChatCompletion({
text: "Test response",
role: "assistant",
roomId: "room-xyz",
});

expect(mockFilterMessageContentForMemories).toHaveBeenCalledWith(mockMessage);
});

it("calls insertMemories with id, room_id, and filtered content", async () => {
const mockMessage = {
id: "msg-789",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "AI response" }],
};
const mockFilteredContent = {
role: "assistant",
parts: mockMessage.parts,
content: "AI response",
};
mockGetMessages.mockReturnValue([mockMessage]);
mockFilterMessageContentForMemories.mockReturnValue(mockFilteredContent);
mockInsertMemories.mockResolvedValue(null);

await saveChatCompletion({
text: "AI response",
role: "assistant",
roomId: "room-123",
});

expect(mockInsertMemories).toHaveBeenCalledWith({
id: "msg-789",
room_id: "room-123",
content: mockFilteredContent,
});
});

it("uses 'assistant' as default role when not specified", async () => {
const mockMessage = {
id: "msg-default",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Default role test" }],
};
mockGetMessages.mockReturnValue([mockMessage]);
mockFilterMessageContentForMemories.mockReturnValue({
role: "assistant",
parts: mockMessage.parts,
content: "Default role test",
});
mockInsertMemories.mockResolvedValue(null);

await saveChatCompletion({
text: "Default role test",
roomId: "room-default",
});

expect(mockGetMessages).toHaveBeenCalledWith("Default role test", "assistant");
});

it("returns the inserted memory", async () => {
const mockMessage = {
id: "msg-return",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Return test" }],
};
const mockFilteredContent = {
role: "assistant",
parts: mockMessage.parts,
content: "Return test",
};
const mockInsertedMemory = {
id: "msg-return",
room_id: "room-return",
content: mockFilteredContent,
created_at: "2026-01-19T00:00:00Z",
};
mockGetMessages.mockReturnValue([mockMessage]);
mockFilterMessageContentForMemories.mockReturnValue(mockFilteredContent);
mockInsertMemories.mockResolvedValue(mockInsertedMemory as any);

const result = await saveChatCompletion({
text: "Return test",
roomId: "room-return",
});

expect(result).toEqual(mockInsertedMemory);
});

it("propagates errors from insertMemories", async () => {
const mockMessage = {
id: "msg-error",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Error test" }],
};
mockGetMessages.mockReturnValue([mockMessage]);
mockFilterMessageContentForMemories.mockReturnValue({
role: "assistant",
parts: mockMessage.parts,
content: "Error test",
});
mockInsertMemories.mockRejectedValue(new Error("Database error"));

await expect(
saveChatCompletion({
text: "Error test",
roomId: "room-error",
}),
).rejects.toThrow("Database error");
});
});
18 changes: 15 additions & 3 deletions lib/chat/handleChatGenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { generateText } from "ai";
import { validateChatRequest } from "./validateChatRequest";
import { setupChatRequest } from "./setupChatRequest";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { saveChatCompletion } from "./saveChatCompletion";

/**
* Handles a non-streaming chat generate request.
Expand All @@ -11,7 +12,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
* 1. Validates the request (auth, body schema)
* 2. Sets up the chat configuration (agent, model, tools)
* 3. Generates text using the AI SDK's generateText
* 4. Returns a JSON response with text, reasoning, sources, etc.
* 4. Persists the assistant message to the database (if roomId is provided)
* 5. Returns a JSON response with text, reasoning, sources, etc.
*
* @param request - The incoming NextRequest
* @returns A JSON response or error NextResponse
Expand All @@ -28,8 +30,18 @@ export async function handleChatGenerate(request: NextRequest): Promise<Response

const result = await generateText(chatConfig);

// Note: Credit handling and chat completion handling will be added
// as part of the handleChatCredits and handleChatCompletion migrations
// Save assistant message to database if roomId is provided
if (body.roomId) {
try {
await saveChatCompletion({
text: result.text,
roomId: body.roomId,
});
} catch (error) {
// Log error but don't fail the request - message persistence is non-critical
console.error("Failed to persist assistant message:", error);
}
}

return NextResponse.json(
{
Expand Down
Loading
Loading