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);