From d0dbba67ae0fa4b48be2957c6cf16c0489d71969 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:28:49 -0700 Subject: [PATCH] fix(web): skip history replacements whose target seq is absent A replace-style boundary event can reference a message the client does not have: a live boundary broadcast racing a still-streaming history load, or a boundary persisted under an older seq numbering (observed after the 2026-07-20 upstream rebase on a large compacted session: 'History replacement matched 0 messages at seq 7433'). The reducer threw for that case, which wedged the whole session view on every load, permanently sticking the task at 'Loading session...'. Drop the unmatched replacement with a console diagnostic instead: the cost is one message rendering without its replacement, against a permanently unviewable session. Structural violations (multiple matches, identity mismatch) still throw. The history-replay flush path already treats a missing target as 'defer'; this aligns the reducer's own behavior for the paths without a defer queue. --- web/src/sessionReducer.test.ts | 21 ++++++++++----------- web/src/sessionReducer.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/web/src/sessionReducer.test.ts b/web/src/sessionReducer.test.ts index 1871ebc..5e29730 100644 --- a/web/src/sessionReducer.test.ts +++ b/web/src/sessionReducer.test.ts @@ -296,18 +296,17 @@ describe("history boundary replacement", () => { expect(next.replacementEvents.get(4)).toEqual(replacement); }); - it("rejects invalid replacement targets", () => { + it("skips replacements whose target seq is absent", () => { const state = makeState(); - expect(() => - replaceHistoryBoundary( - state, - { - type: "turn/stop", - history_boundary: { anchor: "a", kind: "agent_turn" }, - }, - 4, - ), - ).toThrow("matched 0"); + const next = replaceHistoryBoundary( + state, + { + type: "turn/stop", + history_boundary: { anchor: "a", kind: "agent_turn" }, + }, + 4, + ); + expect(next).toBe(state); }); it("rejects mismatched canonical identity", () => { diff --git a/web/src/sessionReducer.ts b/web/src/sessionReducer.ts index a3cb9f9..24b271a 100644 --- a/web/src/sessionReducer.ts +++ b/web/src/sessionReducer.ts @@ -858,6 +858,16 @@ export function replaceHistoryBoundary( (message.seq === seq || (Array.isArray(message.seq) && message.seq.includes(seq))), ); + if (matches.length === 0) { + // A replacement can reference a message this client doesn't have: a live + // boundary racing a still-streaming history load, or a boundary persisted + // under an older seq numbering. Dropping it degrades one message's + // replacement; throwing would wedge the whole session view on every load. + console.error( + `History replacement matched 0 messages at seq ${seq}; skipping`, + ); + return s; + } if (matches.length !== 1) throw new Error( `History replacement matched ${matches.length} messages at seq ${seq}`,