Skip to content
Merged
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
15 changes: 15 additions & 0 deletions backend/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,21 @@ chatRouter.post("/", requireAuth, async (req, res) => {
});
}

// Send one canonical post-persistence snapshot. The incremental
// stream deliberately holds back a short tail while checking for the
// <CITATIONS> marker. Some proxies/browsers can render the earlier
// chunks but miss that final small delta. Replacing the provisional
// client events with this saved snapshot guarantees that the live UI
// and a refreshed chat show the same complete answer.
write(
`data: ${JSON.stringify({
type: "assistant_message_final",
events: persistedEvents,
citations,
})}\n\n`,
);
write("data: [DONE]\n\n");

if (!chatTitle && lastUser?.content) {
await db
.from("chats")
Expand Down
12 changes: 12 additions & 0 deletions backend/src/routes/projectChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,18 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
});
}

// Send the canonical saved response after persistence so the browser
// can replace its provisional stream, including any short tail held
// back while the server checked for the <CITATIONS> marker.
write(
`data: ${JSON.stringify({
type: "assistant_message_final",
events: persistedEvents,
citations,
})}\n\n`,
);
write("data: [DONE]\n\n");

if (!chatTitle && lastUser?.content) {
await db
.from("chats")
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/app/hooks/useAssistantChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,36 @@ export function useAssistantChat({
continue;
}

if (data.type === "assistant_message_final") {
const finalEvents = Array.isArray(data.events)
? (data.events as AssistantEvent[])
: [];
const finalCitations = Array.isArray(data.citations)
? (data.citations as Citation[])
: [];

eventsRef.current = finalEvents;
updateLatestAssistantMessage((assistantMessage) => ({
...assistantMessage,
content: finalEvents
.filter(
(
event,
): event is Extract<
AssistantEvent,
{ type: "content" }
> => event.type === "content",
)
.map((event) => event.text)
.join(""),
events: finalEvents,
citations: finalCitations,
citationStatus: finalCitations.length ? "final" : undefined,
}));
setIsLoadingCitations(false);
continue;
}

if (data.type === "error") {
const message = readableStreamError(data.message);
clearStreamingPlaceholders();
Expand Down
18 changes: 18 additions & 0 deletions tests/baseline/ross-chat-persistence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,21 @@ test("the live assistant view reconciles with persisted messages", () => {
assert.match(hook, /setMessages\(persistedMessages\)/);
assert.match(hook, /finalizeStreamingContent\(\);[\s\S]*?getChat\(finalChatId\)/);
});

test("chat streams finish with a canonical post-persistence snapshot", () => {
const routes = [
read("backend/src/routes/chat.ts"),
read("backend/src/routes/projectChat.ts"),
];
const hook = read("frontend/src/app/hooks/useAssistantChat.ts");

for (const route of routes) {
assert.match(
route,
/persistedEvents[\s\S]*?type: "assistant_message_final"[\s\S]*?events: persistedEvents/,
);
}
assert.match(hook, /data\.type === "assistant_message_final"/);
assert.match(hook, /eventsRef\.current = finalEvents/);
assert.match(hook, /events: finalEvents/);
});
Loading