From 91ae2e137ddb2d40876d31d9297a22a5e84cb23c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 05:43:28 +0300 Subject: [PATCH] fix: dedupe same-id continuation copies in persistTurn (coalescing follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a steer coalesced into a quiz-answer continuation, the steer sits AFTER the assistant message being continued, so the SDK's onFinish list carries the input copy AND the extended response copy as two entries with the SAME id — persistTurn saved both, the jobs poller's syncFromServer imported the dup, and React keyed a single message twice (the user's live test hit it). withMessageIds now dedupes by id keeping the LAST occurrence — the SDK's final merge state (a superset of the earlier copy) — which also yields the right semantic shape: [user, steer, assistant(extended)]. Out-of-lane surgical touch (flagged for master review): turn-service.ts. Squashed the live session's already-corrupted record by hand (same dedupe). --- src/server/turn-service.ts | 16 +++++++++++++++- tests/jobs.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts index 39d8728..e999669 100644 --- a/src/server/turn-service.ts +++ b/src/server/turn-service.ts @@ -55,9 +55,23 @@ export function buildTeacherContext(graphStore: GraphStore, chatId: string): str * NEVER touched (the client's next POST still carries them verbatim). */ export function withMessageIds(messages: UIMessage[]): UIMessage[] { - return messages.map((m) => + const filled = messages.map((m) => m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` }, ); + // Same id twice = the SDK's continuation merge couldn't happen in place + // (continuation coalescing appends a user-role steer AFTER the assistant + // message being continued, so onFinish carries the input copy AND the + // extended response copy under one id). Keep the LAST occurrence — it is + // the SDK's final merge state (a superset of the earlier copy). + const seen = new Set(); + const deduped: UIMessage[] = []; + for (let i = filled.length - 1; i >= 0; i--) { + const m = filled[i]!; + if (seen.has(m.id)) continue; + seen.add(m.id); + deduped.unshift(m); + } + return deduped; } export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void { diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index ba0c107..b2382a1 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -764,6 +764,33 @@ describe("persistTurn id hygiene", () => { assert.notEqual(filled[0]!.id, filled[1]!.id); }); + it("persistTurn dedupes same-id continuation copies, keeping the LAST (the SDK's merged superset)", () => { + // The coalescing shape: a steer (user-role) rides a quiz-answer + // continuation AFTER the assistant message being continued, so the + // SDK's onFinish list carries the input copy AND the extended + // response copy under one id — the record must keep only the merge. + const chat = chatStore.createChat("Test: dedupe continuation copies"); + trackChat(chat.id); + const short = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }] }; + const long = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }, { type: "text", text: "step 2 (continued)" }] }; + persistTurn(chatStore, chat.id, [ + { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }, + short, + { id: "steer_x", role: "user", parts: [{ type: "text", text: "research complete" }] }, + long, + ], false); + const record = chatStore.getChat(chat.id)!; + assert.equal(record.messages.length, 3, "the duplicate entry is dropped"); + assert.deepEqual( + record.messages.map((m) => m.id), + ["u1", "steer_x", "msg_dup_1"], + "order preserved, steer before the merged assistant copy", + ); + const merged = record.messages[2]!; + assert.equal(merged.parts.length, 2, "the KEPT copy is the extended one"); + assert.equal((merged.parts[1] as { text: string }).text, "step 2 (continued)"); + }); + it("persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)", () => { const chat = chatStore.createChat("Test: persist ids"); trackChat(chat.id);