From 8f9dd96355ed77ba3749fe1f89ed766471bb02ec Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 01:33:16 +0200 Subject: [PATCH] fix(timeline): restore prompt identity on resume --- .../timeline/session-stream-reducers.test.ts | 68 ++++++++++++++++++- .../src/timeline/session-stream-reducers.ts | 42 ++++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 3fe8742e79..7cb734ccaf 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -258,9 +258,73 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("applies a missed same-sequence provider identity revision from an unchanged resume tail", () => { + const canonical = createUserMessage({ + id: "canonical-prompt", + clientMessageId: "client-message", + text: "local prompt", + timestamp: new Date(1000), + timelineCursor: { epoch: "epoch-1", seq: 5 }, + }); + const unrelatedTail = makeAssistantItem("unrelated tail", "unrelated-tail"); + const currentTail = [canonical, unrelatedTail]; + const currentHead = [makeAssistantItem("live head", "live-head")]; + const currentCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 1, endSeq: 8 }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentHead, + currentCursor, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 8, nextSeq: 9 }, + startCursor: { seq: 5 }, + endCursor: { seq: 8 }, + entries: [ + { + ...makeTimelineEntry(5, "provider prompt", "user_message"), + item: { + type: "user_message", + text: "provider prompt", + messageId: "provider-message", + clientMessageId: "client-message", + }, + }, + ], + }, + }); + + expect(result.commit).toBe("apply"); + expect(result.tail).not.toBe(currentTail); + expect(result.tail[0]).toEqual( + expect.objectContaining({ + id: "canonical-prompt", + clientMessageId: "client-message", + messageId: "provider-message", + text: "local prompt", + timestamp: canonical.timestamp, + timelineCursor: { epoch: "epoch-1", seq: 5 }, + }), + ); + expect(result.tail[1]).toBe(unrelatedTail); + expect(result.head).toBe(currentHead); + expect(result.cursor).toBe(currentCursor); + expect(result.cursorChanged).toBe(false); + expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]); + }); + it("discards an unchanged resume tail without replacing timeline state", () => { - const submitted = makeSubmittedUserMessage("local prompt", "client-message"); - const currentTail = [submitted, makeAssistantItem("existing tail", "existing-tail")]; + const canonical = createUserMessage({ + id: "canonical-prompt", + clientMessageId: "client-message", + messageId: "provider-message", + text: "local prompt", + timestamp: new Date(1000), + timelineCursor: { epoch: "epoch-1", seq: 1 }, + }); + const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail")]; const currentHead = [makeAssistantItem("existing head", "existing-head")]; const result = processTimelineResponse({ diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 87d43ea325..5a694584b5 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -782,6 +782,20 @@ function deriveCanonicalAcknowledgements(params: { return [...acknowledged]; } +function shouldDiscardUnchangedTimelineResult(params: { + discardPolicy: boolean; + currentTail: StreamItem[]; + currentHead: StreamItem[]; + nextTail: StreamItem[]; + nextHead: StreamItem[]; +}): boolean { + return ( + params.discardPolicy && + params.nextTail === params.currentTail && + params.nextHead === params.currentHead + ); +} + function applyTimelineIncrementalPath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; @@ -964,9 +978,20 @@ export function processTimelineResponse( const discard = resumeTailPolicy.kind === "discard"; let timelineResult: TimelinePathResult; if (discard) { + const revision = processAgentStreamEvents({ + events: timelineUnits.map((unit) => ({ + event: unit.event, + seq: unit.seqEnd, + epoch: payload.epoch, + timestamp: unit.timestamp, + })), + currentTail, + currentHead, + currentCursor, + }); timelineResult = { - tail: currentTail, - head: currentHead, + tail: revision.tail, + head: revision.head, cursor: currentCursor, cursorChanged: false, older: "unchanged", @@ -1036,9 +1061,18 @@ export function processTimelineResponse( timelineResponseComplete; const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; + const commit = shouldDiscardUnchangedTimelineResult({ + discardPolicy: discard, + currentTail, + currentHead, + nextTail, + nextHead, + }) + ? "discard" + : "apply"; return { - commit: discard ? "discard" : "apply", + commit, tail: nextTail, head: nextHead, cursor: nextCursor, @@ -1135,7 +1169,7 @@ function applyCanonicalUserMessageRevision(input: { messageId: event.item.messageId, text: event.item.text, timestamp: input.timestamp, - timelineCursor: { epoch, seq }, + timelineCursor: existing.timelineCursor, }), insert: "none", presentation: "existing",