From a2df01a7d7832f7c5254ae101f172fd86a7b9596 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 23:07:09 +0000 Subject: [PATCH 1/2] test(client-runtime): reproduce #29700 older answer jumping behind a later full history page These tests reproduce #29700: merging a full history page that only covers the later slice of an already-merged conversation page moves the newer answer ahead of the question and the older answer. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadHistoryMerge.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 59e1ea3652e52..8998b92843de4 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -174,6 +174,44 @@ describe("threadHistoryMerge", () => { ]); }); + it("keeps an older answer before a full page that only covers the later part of a conversation page", () => { + const [question, olderAnswer, reasoningA, reasoningB, reasoningC, newerAnswer] = [ + row(0), + row(1), + row(2), + row(3), + row(4), + row(5), + ]; + const window = [row(6), row(7)]; + const projection = { + ...v2Projection, + turnItems: window.map((entry) => entry.item), + visibleTurnItems: window.map((entry, position) => ({ ...entry, position })), + }; + const conversation = mergeOlderHistoryIntoProjection(projection, [ + question, + olderAnswer, + newerAnswer, + ]); + const full = mergeOlderHistoryIntoProjection(conversation, [ + reasoningA, + reasoningB, + reasoningC, + newerAnswer, + ]); + expect(full.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([ + "item-0", + "item-1", + "item-2", + "item-3", + "item-4", + "item-5", + "item-6", + "item-7", + ]); + }); + it("does not resurrect a local item hidden while an older page was in flight", () => { const stalePageRow = row(1); const currentItem = { From 6c687c96fc0ce267e4d38ef458316867f8a8ac04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 23:11:22 +0000 Subject: [PATCH 2/2] fix(client-runtime): older answers stay in order when a full history page overlaps Loading an old search result merges a conversation page, then a byte-bounded full page from the same cursor that can cover only the later part of that range. The merge put the whole page ahead of every current row, so a newer answer jumped ahead of the question and the older answer. New page rows are now placed just before the next row that both lists share, after any current rows older than that row. A page with no overlap still goes first, as before. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadHistoryMerge.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 6fd678b66b129..a8d5a410c5f07 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -49,6 +49,39 @@ function renumberVisible( return rows.map((row, position) => (row.position === position ? row : { ...row, position })); } +// A page is a contiguous history range, so current rows before its first +// visible row are older than the page start and new page rows sit just +// before the next row both lists share. +function interleavePage( + current: ReadonlyArray, + page: ReadonlyArray, + pageKeys: ReadonlySet, +): OrchestrationV2ProjectedTurnItem[] { + const currentIndex = new Map(current.map((row, index) => [projectedItemKey(row), index])); + const merged: OrchestrationV2ProjectedTurnItem[] = []; + let pending: OrchestrationV2ProjectedTurnItem[] = []; + let next = 0; + const emitCurrentThrough = (end: number) => { + for (; next < end; next += 1) { + const row = current[next]!; + if (!pageKeys.has(projectedItemKey(row))) merged.push(row); + } + }; + for (const row of page) { + const index = currentIndex.get(projectedItemKey(row)); + if (index === undefined) { + pending.push(row); + continue; + } + emitCurrentThrough(index + 1); + merged.push(...pending, row); + pending = []; + } + merged.push(...pending); + emitCurrentThrough(current.length); + return merged; +} + export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, @@ -112,10 +145,7 @@ export function mergeOlderHistoryIntoProjection( turnItemById.set(String(row.sourceItemId), row.item); } if (page.length === 0 && work === undefined) return projection; - let visible = [ - ...page, - ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), - ]; + let visible = interleavePage(projection.visibleTurnItems, page, pageKeys); if (work && anchor && deferred) { let end = start + 1; while (end < projection.visibleTurnItems.length) {