From 90c6de058039fd6ea57f68e4b3417300835f9805 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:01:33 +0200 Subject: [PATCH] fix(app): keep streaming replies complete after reloading (#7256) A catch-up projection could replace the live reply while leaving its cached prefix in the committed rows. Completion then discarded the complete reply as a duplicate. Reconcile the covered cached prefix together with the live reply. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/src/timeline/replica.test.ts | 84 +++++++++++++++++++ .../src/timeline/session-stream-reducers.ts | 16 +++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index fa7521e487..47bd36596a 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void { afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); describe("viewed timeline persistence", () => { + it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const partial = "Paragraph 1. Paragraph 2."; + const complete = `${partial} Paragraph 3. END`; + const owner = createOwner({ + readTimeline: async () => ({ + agentId: AGENT_ID, + items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 }, + hasOlder: false, + }), + commitTimeline: () => undefined, + }); + try { + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll( + () => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID) + .status, + ) + .toBe("painted"); + owner.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + owner.applyTimelineResponse({ + requestId: "resume", + agentId: AGENT_ID, + agent: null, + direction: "after", + projection: "projected", + reset: false, + epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 6, nextSeq: 7 }, + startCursor: { epoch: "epoch-1", seq: 5 }, + endCursor: { epoch: "epoch-1", seq: 6 }, + entries: [ + { + provider: "codex", + item: { type: "assistant_message", text: complete, messageId: "reply-1" }, + timestamp: "2026-08-26T10:00:02.000Z", + seqStart: 2, + seqEnd: 6, + sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }], + collapsed: ["assistant_merge"], + }, + ], + error: null, + hasNewer: false, + hasOlder: false, + staleCursor: false, + gap: false, + }); + const replyTexts = () => { + const session = useSessionStore.getState().sessions[SERVER_ID]; + return [ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ] + .filter((entry) => entry.kind === "assistant_message") + .map((entry) => entry.text); + }; + expect.soft(replyTexts()).toEqual([complete]); + owner.enqueueStreamEvent(AGENT_ID, { + event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload, + seq: 7, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:03.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + expect.soft(replyTexts()).toEqual([complete]); + } finally { + owner.dispose(); + } + }); + it("shares an in-flight cache preparation with the viewed owner", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index dce9daa4da..34a461d893 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: { }; const headMatch = findMatch(params.head); - const tailMatch = headMatch ? null : findMatch(params.tail); + const tailMatch = findMatch(params.tail); const match = headMatch ?? tailMatch; if (!match) { return { tail: params.tail, head: params.head, reconciled: false }; @@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: { }; if (headMatch) { + const tailCursor = tailMatch?.current.timelineCursor; + const replacesCachedPrefix = + tailMatch !== null && + tailCursor?.epoch === params.epoch && + unit.sourceSeqRanges.some( + (range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq, + ); return { - tail: params.tail, + // Live chunks can arrive before catch-up while their cached prefix is still painted. + // The projected reply owns both copies; keeping the prefix would duplicate it and + // cause completion to discard the updated head as an already-committed item. + tail: replacesCachedPrefix + ? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)] + : params.tail, head: replaceMatch(params.head, headMatch.index), reconciled: true, };