diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 3f2be832a2b23..95f2b01e7347e 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -175,6 +175,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 = { diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 62eba89d587f2..ee9174e8fbe81 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, @@ -149,10 +182,7 @@ export function mergeOlderHistoryIntoProjection( ]; if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); } else { - visible = [ - ...page, - ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), - ]; + visible = interleavePage(projection.visibleTurnItems, page, pageKeys); } if ( visible.length === projection.visibleTurnItems.length &&