fix: dedupe same-id continuation copies in persistTurn (coalescing follow-up)

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).
This commit is contained in:
amosblomqvist
2026-09-09 05:43:28 +03:00
parent e33b83ca2d
commit 91ae2e137d
2 changed files with 42 additions and 1 deletions
+15 -1
View File
@@ -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<string>();
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 {
+27
View File
@@ -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);