Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion apps/desktop/src/lib/session-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,38 @@ export function mergeLiveSessionMessages(
}

const used = new Set<string>();
// 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<string, number>();
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);
};
Expand Down Expand Up @@ -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 =
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/test/session-transcript.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Loading