Skip to content

Commit cf9f181

Browse files
fix: finalize artist handoff after chat completion
1 parent d01ff28 commit cf9f181

6 files changed

Lines changed: 231 additions & 39 deletions

File tree

lib/chat/__tests__/handleChatCompletion.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversat
99
import { generateChatTitle } from "@/lib/chat/generateChatTitle";
1010
import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs";
1111
import { sendErrorNotification } from "@/lib/telegram/sendErrorNotification";
12+
import { copyRoom } from "@/lib/rooms/copyRoom";
13+
import { copyChatMessages } from "@/lib/chats/copyChatMessages";
1214
import { handleChatCompletion } from "../handleChatCompletion";
1315
import type { ChatRequestBody } from "../validateChatRequest";
1416

@@ -45,6 +47,14 @@ vi.mock("@/lib/telegram/sendErrorNotification", () => ({
4547
sendErrorNotification: vi.fn(),
4648
}));
4749

50+
vi.mock("@/lib/rooms/copyRoom", () => ({
51+
copyRoom: vi.fn(),
52+
}));
53+
54+
vi.mock("@/lib/chats/copyChatMessages", () => ({
55+
copyChatMessages: vi.fn(),
56+
}));
57+
4858
const mockSelectAccountEmails = vi.mocked(selectAccountEmails);
4959
const mockSelectRoom = vi.mocked(selectRoom);
5060
const mockUpsertRoom = vi.mocked(upsertRoom);
@@ -53,6 +63,8 @@ const mockSendNewConversationNotification = vi.mocked(sendNewConversationNotific
5363
const mockGenerateChatTitle = vi.mocked(generateChatTitle);
5464
const mockHandleSendEmailToolOutputs = vi.mocked(handleSendEmailToolOutputs);
5565
const mockSendErrorNotification = vi.mocked(sendErrorNotification);
66+
const mockCopyRoom = vi.mocked(copyRoom);
67+
const mockCopyChatMessages = vi.mocked(copyChatMessages);
5668

5769
// Helper to create mock UIMessage
5870
/**
@@ -92,6 +104,12 @@ describe("handleChatCompletion", () => {
92104
mockSelectRoom.mockResolvedValue({ id: "room-456" });
93105
mockUpsertMemory.mockResolvedValue(null);
94106
mockHandleSendEmailToolOutputs.mockResolvedValue();
107+
mockCopyRoom.mockResolvedValue("new-room-123");
108+
mockCopyChatMessages.mockResolvedValue({
109+
success: true,
110+
copiedCount: 2,
111+
clearedExisting: true,
112+
});
95113
});
96114

97115
afterEach(() => {
@@ -267,7 +285,7 @@ describe("handleChatCompletion", () => {
267285
const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi!")];
268286

269287
// Should not throw
270-
await expect(handleChatCompletion(body, responseMessages)).resolves.toBeUndefined();
288+
await expect(handleChatCompletion(body, responseMessages)).resolves.toEqual({});
271289
});
272290
});
273291

@@ -317,5 +335,42 @@ describe("handleChatCompletion", () => {
317335
}),
318336
);
319337
});
338+
339+
it("returns redirect path after creating and copying the final artist room", async () => {
340+
const body = createMockBody();
341+
const responseMessages: UIMessage[] = [
342+
{
343+
id: "resp-1",
344+
role: "assistant",
345+
parts: [
346+
{
347+
type: "tool-create_new_artist",
348+
toolCallId: "tool-1",
349+
state: "output-available",
350+
input: {},
351+
output: {
352+
artist: {
353+
account_id: "artist-123",
354+
name: "Test Artist",
355+
},
356+
artistAccountId: "artist-123",
357+
message: "ok",
358+
},
359+
} as any,
360+
],
361+
createdAt: new Date(),
362+
},
363+
];
364+
365+
const result = await handleChatCompletion(body, responseMessages);
366+
367+
expect(mockCopyRoom).toHaveBeenCalledWith("room-456", "artist-123");
368+
expect(mockCopyChatMessages).toHaveBeenCalledWith({
369+
sourceChatId: "room-456",
370+
targetChatId: "new-room-123",
371+
clearExisting: true,
372+
});
373+
expect(result).toEqual({ redirectPath: "/chat/new-room-123" });
374+
});
320375
});
321376
});

lib/chat/__tests__/handleChatStream.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
55
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
66
import { setupChatRequest } from "@/lib/chat/setupChatRequest";
77
import { setupConversation } from "@/lib/chat/setupConversation";
8+
import { handleChatCompletion } from "@/lib/chat/handleChatCompletion";
89
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
910
import { handleChatStream } from "../handleChatStream";
1011

@@ -71,6 +72,7 @@ const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId);
7172
const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId);
7273
const mockSetupConversation = vi.mocked(setupConversation);
7374
const mockSetupChatRequest = vi.mocked(setupChatRequest);
75+
const mockHandleChatCompletion = vi.mocked(handleChatCompletion);
7476
const mockCreateUIMessageStream = vi.mocked(createUIMessageStream);
7577
const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse);
7678

@@ -99,6 +101,7 @@ describe("handleChatStream", () => {
99101
roomId: roomId || "mock-room-id",
100102
memoryId: "mock-memory-id",
101103
}));
104+
mockHandleChatCompletion.mockResolvedValue({});
102105
});
103106

104107
afterEach(() => {
@@ -247,6 +250,68 @@ describe("handleChatStream", () => {
247250
}),
248251
);
249252
});
253+
254+
it("uses sendFinish false and emits redirect data after completion", async () => {
255+
mockGetApiKeyAccountId.mockResolvedValue("account-123");
256+
mockHandleChatCompletion.mockResolvedValue({ redirectPath: "/chat/new-room-123" });
257+
258+
const toUIMessageStream = vi.fn().mockReturnValue(new ReadableStream());
259+
const mockAgent = {
260+
stream: vi.fn().mockResolvedValue({
261+
toUIMessageStream,
262+
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
263+
}),
264+
tools: {},
265+
};
266+
267+
mockSetupChatRequest.mockResolvedValue({
268+
agent: mockAgent,
269+
messages: [],
270+
} as any);
271+
272+
const mockStream = new ReadableStream();
273+
mockCreateUIMessageStream.mockReturnValue(mockStream);
274+
mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream));
275+
276+
const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" });
277+
278+
await handleChatStream(request as any);
279+
280+
const execute = mockCreateUIMessageStream.mock.calls[0][0].execute;
281+
const writer = {
282+
merge: vi.fn(),
283+
write: vi.fn(),
284+
onError: undefined,
285+
};
286+
287+
await execute({ writer } as any);
288+
289+
expect(toUIMessageStream).toHaveBeenCalledWith(
290+
expect.objectContaining({
291+
sendFinish: false,
292+
onFinish: expect.any(Function),
293+
}),
294+
);
295+
296+
const onFinish = toUIMessageStream.mock.calls[0][0].onFinish;
297+
await onFinish({
298+
isAborted: false,
299+
finishReason: "stop",
300+
messages: [{ id: "resp-1", role: "assistant", parts: [] }],
301+
responseMessage: { id: "resp-1", role: "assistant", parts: [] },
302+
isContinuation: false,
303+
});
304+
305+
expect(writer.write).toHaveBeenCalledWith({
306+
type: "data-redirect",
307+
data: { path: "/chat/new-room-123" },
308+
transient: true,
309+
});
310+
expect(writer.write).toHaveBeenCalledWith({
311+
type: "finish",
312+
finishReason: "stop",
313+
});
314+
});
250315
});
251316

252317
describe("error handling", () => {

lib/chat/handleChatCompletion.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { UIMessage } from "ai";
1+
import {
2+
getToolOrDynamicToolName,
3+
isToolOrDynamicToolUIPart,
4+
type UIMessage,
5+
} from "ai";
26
import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
37
import selectRoom from "@/lib/supabase/rooms/selectRoom";
48
import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom";
@@ -11,6 +15,38 @@ import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutp
1115
import { sendErrorNotification } from "@/lib/telegram/sendErrorNotification";
1216
import { serializeError } from "@/lib/errors/serializeError";
1317
import type { ChatRequestBody } from "./validateChatRequest";
18+
import { copyRoom } from "@/lib/rooms/copyRoom";
19+
import { copyChatMessages } from "@/lib/chats/copyChatMessages";
20+
import type { CreateNewArtistResult } from "@/lib/mcp/tools/artists/registerCreateNewArtistTool";
21+
22+
export interface ChatCompletionResult {
23+
redirectPath?: string;
24+
}
25+
26+
function getCreateArtistResult(responseMessages: UIMessage[]): CreateNewArtistResult | null {
27+
for (const message of responseMessages) {
28+
for (const part of message.parts) {
29+
if (!isToolOrDynamicToolUIPart(part)) continue;
30+
if (getToolOrDynamicToolName(part) !== "create_new_artist") continue;
31+
if (part.state !== "output-available") continue;
32+
33+
if (part.type === "dynamic-tool") {
34+
const text = part.output?.content?.[0]?.text;
35+
if (!text) continue;
36+
37+
try {
38+
return JSON.parse(text) as CreateNewArtistResult;
39+
} catch {
40+
continue;
41+
}
42+
}
43+
44+
return part.output as CreateNewArtistResult;
45+
}
46+
}
47+
48+
return null;
49+
}
1450

1551
/**
1652
* Handles post-chat-completion tasks:
@@ -28,7 +64,7 @@ import type { ChatRequestBody } from "./validateChatRequest";
2864
export async function handleChatCompletion(
2965
body: ChatRequestBody,
3066
responseMessages: UIMessage[],
31-
): Promise<void> {
67+
): Promise<ChatCompletionResult> {
3268
try {
3369
const { messages, roomId = "", accountId, artistId } = body;
3470

@@ -81,14 +117,39 @@ export async function handleChatCompletion(
81117
content: filterMessageContentForMemories(responseMessages[responseMessages.length - 1]),
82118
});
83119

120+
let redirectPath: string | undefined;
121+
const createArtistResult = getCreateArtistResult(responseMessages);
122+
if (createArtistResult?.artistAccountId) {
123+
const newRoomId = await copyRoom(roomId, createArtistResult.artistAccountId);
124+
125+
if (newRoomId) {
126+
const copyResult = await copyChatMessages({
127+
sourceChatId: roomId,
128+
targetChatId: newRoomId,
129+
clearExisting: true,
130+
});
131+
132+
if (copyResult.success) {
133+
redirectPath = `/chat/${newRoomId}`;
134+
} else {
135+
console.error("Failed to copy final artist conversation:", copyResult.error);
136+
}
137+
} else {
138+
console.error("Failed to create final artist conversation room");
139+
}
140+
}
141+
84142
// Process any email tool outputs
85143
await handleSendEmailToolOutputs(responseMessages);
144+
145+
return { redirectPath };
86146
} catch (error) {
87147
sendErrorNotification({
88148
...body,
89149
path: "/api/chat",
90150
error: serializeError(error),
91151
});
92152
console.error("Failed to save chat", error);
153+
return {};
93154
}
94155
}

lib/chat/handleChatStream.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,42 @@ export async function handleChatStream(request: NextRequest): Promise<Response>
3838
execute: async options => {
3939
const { writer } = options;
4040
streamResult = await agent.stream(chatConfig);
41-
writer.merge(streamResult.toUIMessageStream());
42-
},
43-
onFinish: async event => {
44-
if (event.isAborted) {
45-
return;
46-
}
47-
const assistantMessages = event.messages.filter(message => message.role === "assistant");
48-
const responseMessages =
49-
assistantMessages.length > 0 ? assistantMessages : [event.responseMessage];
50-
await handleChatCompletion(body, responseMessages);
51-
if (streamResult) {
52-
await handleChatCredits({
53-
usage: await streamResult.usage,
54-
model: body.model ?? DEFAULT_MODEL,
55-
accountId: body.accountId,
56-
});
57-
}
41+
writer.merge(
42+
streamResult.toUIMessageStream({
43+
sendFinish: false,
44+
onFinish: async event => {
45+
if (event.isAborted) {
46+
return;
47+
}
48+
49+
const assistantMessages = event.messages.filter(
50+
message => message.role === "assistant",
51+
);
52+
const responseMessages =
53+
assistantMessages.length > 0 ? assistantMessages : [event.responseMessage];
54+
const { redirectPath } = await handleChatCompletion(body, responseMessages);
55+
56+
if (redirectPath) {
57+
writer.write({
58+
type: "data-redirect",
59+
data: { path: redirectPath },
60+
transient: true,
61+
});
62+
}
63+
64+
writer.write({
65+
type: "finish",
66+
finishReason: event.finishReason,
67+
});
68+
69+
await handleChatCredits({
70+
usage: await streamResult!.usage,
71+
model: body.model ?? DEFAULT_MODEL,
72+
accountId: body.accountId,
73+
});
74+
},
75+
}),
76+
);
5877
},
5978
onError: e => {
6079
console.error("/api/chat onError:", e);

0 commit comments

Comments
 (0)