From 8e5b6876b48d183860971de7a9621125689d8f32 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 4 Aug 2026 14:46:30 +0200 Subject: [PATCH] fix(app): preserve assistant order across outline jumps Live Markdown block promotion discarded timeline positions, so merging a disjoint history window could sort the final block ahead of earlier blocks. Preserve the source position on every promoted block so timeline merges keep the response intact. --- .../timeline/session-stream-reducers.test.ts | 43 +++++++++++++++++++ packages/app/src/types/stream.test.ts | 24 +++++++++++ packages/app/src/types/stream.ts | 4 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 78fea2cbf..c89855105 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -2715,6 +2715,49 @@ describe("processTimelineResponse", () => { expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" })); }); + it("keeps live assistant blocks ordered when merging a disjoint prompt-jump window", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent( + makeAssistantTimelineEvent("First paragraph.\n\nSecond", "assistant-1"), + 80, + ), + makeStreamReducerEvent(makeAssistantTimelineEvent(" paragraph.", "assistant-1"), 81), + makeStreamReducerEvent(makeTimelineEvent("newest prompt", "user_message"), 82), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + }); + expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([ + "First paragraph.", + "Second paragraph.", + ]); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: live.cursor ?? undefined, + payload: { + ...baseTimelineInput.payload, + direction: "before", + mergeWindow: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + hasOlder: false, + hasNewer: true, + entries: [makeTimelineEntry(1, "oldest prompt", "user_message")], + }, + }); + + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ + "First paragraph.", + "Second paragraph.", + ]); + }); + it("fills the gap between a retained prompt window and the contiguous tail", () => { const currentTail: StreamItem[] = [ { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index e3de58f42..c12d45253 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -616,6 +616,30 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length); }); + it("keeps the timeline position on every promoted assistant block", () => { + const timelineCursor = { epoch: "epoch-1", seq: 42 }; + const result = applyStreamEvent({ + tail: [], + head: [], + event: assistantTimeline("First paragraph.\n\nSecond paragraph.", undefined, "message-1"), + timestamp: new Date("2025-01-01T10:02:00Z"), + timelineCursor, + }); + + const messages = [...result.tail, ...result.head].filter( + (item): item is Extract => + item.kind === "assistant_message", + ); + expect(messages.map((message) => message.text)).toEqual([ + "First paragraph.", + "Second paragraph.", + ]); + expect(messages.map((message) => message.timelineCursor)).toEqual([ + timelineCursor, + timelineCursor, + ]); + }); + it("preserves old assistant merge behavior when message ids are absent", () => { const state = hydrateStreamState([ { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 50fcfbd02..2a2b41f5a 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1573,16 +1573,14 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str const completedBlocks = blocks.slice(0, -1); const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`; const promotedItems = completedBlocks.map((block, offset) => ({ - kind: "assistant_message", + ...activeItem, id: createAssistantBlockId({ groupId: blockGroupId, blockIndex: firstBlockIndex + offset, }), - ...(activeItem.messageId ? { messageId: activeItem.messageId } : {}), blockGroupId, blockIndex: firstBlockIndex + offset, text: block, - timestamp: activeItem.timestamp, })); const nextTail = flushHeadToTail(params.tail, promotedItems);