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:
@@ -55,9 +55,23 @@ export function buildTeacherContext(graphStore: GraphStore, chatId: string): str
|
|||||||
* NEVER touched (the client's next POST still carries them verbatim).
|
* NEVER touched (the client's next POST still carries them verbatim).
|
||||||
*/
|
*/
|
||||||
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
|
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)}` },
|
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 {
|
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
|
||||||
|
|||||||
@@ -764,6 +764,33 @@ describe("persistTurn id hygiene", () => {
|
|||||||
assert.notEqual(filled[0]!.id, filled[1]!.id);
|
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)", () => {
|
it("persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)", () => {
|
||||||
const chat = chatStore.createChat("Test: persist ids");
|
const chat = chatStore.createChat("Test: persist ids");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user