From 1007719065f3563ec788e1e26adc3de44a6aa8f2 Mon Sep 17 00:00:00 2001 From: yexisu <1761512688@qq.com> Date: Sat, 3 Oct 2026 14:40:18 +0800 Subject: [PATCH] fix(transcript): collapse a missed-reconcile prompt on session reentry Switching away right after a send could leave the optimistic user row in the live cache under its temporary id; if the user_message_persisted reconcile was missed, reselecting the session merged it beside the durable echo of the same prompt and the question appeared twice (#1308). mergeLiveSessionMessages now grants each durable completed user row one collapse credit: an orphan live user row with equal text consumes one credit and is dropped. A genuinely repeated prompt stays visible because two sends persist two durable rows while each live orphan only consumes one credit. Validated: session-transcript tests 15/15, session-transcript-updates and empty-read suites 10/10; the remaining desktop tsc storage errors reproduce on the pristine main checkout and are unrelated baseline noise. --- apps/desktop/src/lib/session-transcript.ts | 43 ++++++++++++++- apps/desktop/test/session-transcript.test.mjs | 52 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/session-transcript.ts b/apps/desktop/src/lib/session-transcript.ts index da0abd6ba8..03423caaee 100644 --- a/apps/desktop/src/lib/session-transcript.ts +++ b/apps/desktop/src/lib/session-transcript.ts @@ -202,9 +202,38 @@ export function mergeLiveSessionMessages( } const used = new Set(); + // An optimistic prompt whose reconcile event was missed survives with its + // temporary id, so id-based merging replays it next to the durable echo of + // the same prompt (#1308: prompt reappears after switching away and back). + // Each durable completed user row grants one collapse credit: an orphan + // live user row with equal text consumes one and is dropped. A genuinely + // repeated prompt keeps every durable row because two sends persist two + // rows while each live orphan still only consumes one credit. + const durableUserTextCredits = new Map(); + for (const message of durable) { + if (message.role !== "user") continue; + durableUserTextCredits.set( + message.content, + (durableUserTextCredits.get(message.content) ?? 0) + 1, + ); + } const merged: UiMessage[] = []; const push = (message: UiMessage) => { if (used.has(message.id)) return; + if ( + message.role === "user" && + !isInFlightMessage(message) && + !durableIds.has(message.id) + ) { + const credits = durableUserTextCredits.get(message.content) ?? 0; + if (credits > 0) { + durableUserTextCredits.set(message.content, credits - 1); + // Record the drop so a later merge pass cannot replay the orphan + // after its collapse credit has been consumed. + used.add(message.id); + return; + } + } used.add(message.id); merged.push(message); }; @@ -246,7 +275,19 @@ export function mergeLiveSessionMessages( } for (const message of liveNormalized) { - if (!used.has(message.id)) push(message); + if (used.has(message.id)) continue; + if ( + message.role === "user" && + !isInFlightMessage(message) && + !durableIds.has(message.id) + ) { + const credits = durableUserTextCredits.get(message.content) ?? 0; + if (credits > 0) { + durableUserTextCredits.set(message.content, credits - 1); + continue; + } + } + push(message); } const unchanged = diff --git a/apps/desktop/test/session-transcript.test.mjs b/apps/desktop/test/session-transcript.test.mjs index 2637c3f4ce..d911c33d40 100644 --- a/apps/desktop/test/session-transcript.test.mjs +++ b/apps/desktop/test/session-transcript.test.mjs @@ -109,6 +109,58 @@ test("repeated transcript rows keep one position and the latest value", () => { ); }); +test("an orphan optimistic prompt collapses into its durable echo (D334)", () => { + // The prompt was sent, the renderer switched away, and the reconcile event + // was missed: the optimistic row survives under its temporary id while the + // durable page carries the same prompt under the host id. + const orphan = message( + "11111111-2222-4333-8444-555555555555", + { role: "user", content: "1", createdAt: "2026-08-31T00:00:00.000Z" }, + ); + const echoed = message("sdk-user-1", { + role: "user", + content: "1", + createdAt: "2026-08-31T00:00:01.000Z", + }); + const answer = message("sdk-answer-1", { content: "reply" }); + const durable = [echoed, answer]; + const live = [orphan, echoed, answer]; + + assert.deepEqual( + mergeLiveSessionMessages(durable, live).map(({ id, content }) => ({ id, content })), + [ + { id: "sdk-user-1", content: "1" }, + { id: "sdk-answer-1", content: "reply" }, + ], + ); +}); + +test("a genuinely repeated prompt stays visible after a switch", () => { + // Two real sends of the same text persist two durable rows; one live orphan + // may consume one, but the second repeat must survive the merge. + const orphan = message( + "11111111-2222-4333-8444-555555555555", + { role: "user", content: "1", createdAt: "2026-08-31T00:02:00.000Z" }, + ); + const first = message("sdk-user-1", { + role: "user", + content: "1", + createdAt: "2026-08-31T00:00:01.000Z", + }); + const second = message("sdk-user-2", { + role: "user", + content: "1", + createdAt: "2026-08-31T00:02:01.000Z", + }); + const durable = [first, second]; + const live = [orphan, first, second]; + + assert.deepEqual( + mergeLiveSessionMessages(durable, live).map(({ id }) => id), + ["sdk-user-1", "sdk-user-2"], + ); +}); + test("live event upserts preserve array identity for unchanged rows", () => { const original = [message("answer", { status: "streaming" })]; const updated = upsertLiveSessionMessage(original, {