diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index dda0dc3a99..31d381ed27 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -19,6 +19,8 @@ import { scrollTimelineToOldestLoadedEdge, scrollTimelineToNewestLoadedEdge, seedLongMockAgentTimeline, + scrollThroughOlderHistoryPages, + seedRealisticOlderTimeline, sendLiveTurnBeforeHydration, expectTimelineViewportAnchoredAfterPrepend, userScrollsTimelineToHistoryStart, @@ -48,6 +50,21 @@ test.describe("Agent timeline pagination", () => { } }); + test("does not repeat an assistant block that spans older history pages", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedRealisticOlderTimeline(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + + await scrollThroughOlderHistoryPages(page, 3, history); + history.expectNoRepeatedAssistantEntries(); + } finally { + await agent.cleanup(); + } + }); + test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => { test.setTimeout(120_000); const agent = await seedLongMockAgentTimeline({ turns: 80 }); diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/helpers/agent-timeline-gate.ts index 104f0a1595..3c7f7865a1 100644 --- a/packages/app/e2e/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/helpers/agent-timeline-gate.ts @@ -17,6 +17,7 @@ export interface AgentTimelineResponseGate { export interface OlderTimelinePagesGate { getRequestCount(): number; + getRepeatedAssistantEntryCount(): number; releasePage(pageNumber: number): void; waitForRequestCount(count: number): Promise; } @@ -178,6 +179,8 @@ export async function holdAgentOlderTimelinePages( ): Promise { let requestCount = 0; let responseCount = 0; + let repeatedAssistantEntryCount = 0; + const assistantEntryKeys = new Set(); const releasedPages = new Set(); const delayedForwards = new Map void>>(); const requestWaiters = new Map void>>(); @@ -213,6 +216,26 @@ export async function holdAgentOlderTimelinePages( payload.direction === "before" ) { responseCount += 1; + const entries = Array.isArray(payload.entries) ? payload.entries : []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const projectedEntry = entry as { + seqStart?: unknown; + seqEnd?: unknown; + item?: { type?: unknown }; + }; + if ( + typeof projectedEntry.seqStart !== "number" || + typeof projectedEntry.seqEnd !== "number" || + typeof projectedEntry.item?.type !== "string" + ) { + continue; + } + if (projectedEntry.item.type !== "assistant_message") continue; + const key = `${projectedEntry.seqStart}:${projectedEntry.seqEnd}:${projectedEntry.item.type}`; + if (assistantEntryKeys.has(key)) repeatedAssistantEntryCount += 1; + else assistantEntryKeys.add(key); + } const pageNumber = responseCount; if (releasedPages.has(pageNumber)) { ws.send(message); @@ -229,6 +252,7 @@ export async function holdAgentOlderTimelinePages( return { getRequestCount: () => requestCount, + getRepeatedAssistantEntryCount: () => repeatedAssistantEntryCount, releasePage(pageNumber) { releasedPages.add(pageNumber); for (const forward of delayedForwards.get(pageNumber) ?? []) forward(); diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index 7c8d15ee7f..03f0f51d0b 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -23,6 +23,10 @@ interface LongTimelineAgent extends MockAgentWorkspace { newestPrompt: string; } +interface RealisticOlderTimelineAgent extends MockAgentWorkspace { + newestPrompt: string; +} + const PROMPT_PREFIX = "timeline-pagination-turn"; const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration"; const HISTORY_START_THRESHOLD_PX = 96; @@ -45,6 +49,7 @@ interface OlderHistoryLoadingOperation { interface OlderHistoryPages { expectRequestedPages(count: number): Promise; expectSettledWithRequestedPages(count: number): Promise; + expectNoRepeatedAssistantEntries(): void; releasePage(pageNumber: number): void; } @@ -75,6 +80,50 @@ export async function seedLongMockAgentTimeline( }; } +export async function seedRealisticOlderTimeline(): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-realistic-older-history-", + title: "Realistic older timeline pagination regression", + model: "ten-second-stream", + }); + + const olderTurnCount = 40; + for (let index = 0; index < olderTurnCount; index += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + + await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); + await agent.client.waitForFinish(agent.agentId, 20_000); + + const newerTurnCount = 20; + for (let index = 0; index < newerTurnCount; index += 1) { + await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index)); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + + return { + ...agent, + newestPrompt: promptForTurn(newerTurnCount - 1), + }; +} + +export async function scrollThroughOlderHistoryPages( + page: Page, + pageCount: number, + history: OlderHistoryPages, +): Promise { + for (let pageNumber = 0; pageNumber < pageCount; pageNumber += 1) { + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(pageNumber + 1); + history.releasePage(pageNumber + 1); + await history.expectSettledWithRequestedPages(pageNumber + 1); + } +} + export async function rememberTimelinePromptPosition( page: Page, prompt: string, @@ -104,7 +153,7 @@ export async function expectTimelinePromptPositionPreserved( .toBeLessThanOrEqual(2); } -export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise { +export async function openAgentTimeline(page: Page, agent: MockAgentWorkspace): Promise { await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), @@ -204,7 +253,7 @@ export async function holdNextOlderTimelinePage( export async function holdOlderHistoryPages( page: Page, - agent: LongTimelineAgent, + agent: MockAgentWorkspace, ): Promise { const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId); return { @@ -216,6 +265,9 @@ export async function holdOlderHistoryPages( await waitForTimelineGeometryToSettle(page); expect(gate.getRequestCount()).toBe(count); }, + expectNoRepeatedAssistantEntries() { + expect(gate.getRepeatedAssistantEntryCount()).toBe(0); + }, releasePage(pageNumber) { gate.releasePage(pageNumber); }, diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index 8bfbcef831..35c3156276 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -627,7 +627,63 @@ describe("selectProjectedTimelinePage", () => { }); }); - test("before page includes a wide tool whose earlier source range is before the cursor", () => { + test("before limit counts projected entries without repeating a merged assistant", () => { + const rows: AgentTimelineRow[] = [ + ...Array.from({ length: 80 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + item: { type: "assistant_message" as const, text: "x" }, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + seq: index + 81, + timestamp: new Date(2000 + index).toISOString(), + item: { type: "user_message" as const, text: `message ${index + 1}` }, + })), + ]; + + const newest = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 85, + limit: 2, + }); + const middle = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: newest.startSeq ?? 0, + limit: 2, + }); + const oldest = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: middle.startSeq ?? 0, + limit: 2, + }); + const exhausted = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: oldest.startSeq ?? 0, + limit: 2, + }); + + const pageSummaries = []; + for (const page of [newest, middle, oldest]) { + pageSummaries.push({ + entryTypes: page.entries.map((entry) => entry.item.type), + startSeq: page.startSeq, + endSeq: page.endSeq, + hasOlder: page.hasOlder, + }); + } + expect(pageSummaries).toEqual([ + { entryTypes: ["user_message", "user_message"], startSeq: 83, endSeq: 84, hasOlder: true }, + { entryTypes: ["user_message", "user_message"], startSeq: 81, endSeq: 82, hasOlder: true }, + { entryTypes: ["assistant_message"], startSeq: 1, endSeq: 80, hasOlder: false }, + ]); + expect(exhausted.entries).toEqual([]); + }); + + test("before pages place a full wide tool on the page containing its projected anchor", () => { const rows: AgentTimelineRow[] = [ toolRow(1, "running"), ...Array.from({ length: 498 }, (_, index) => ({ @@ -638,16 +694,32 @@ describe("selectProjectedTimelinePage", () => { toolRow(500, "completed"), ]; - const page = selectProjectedTimelinePage({ + const newestPage = selectProjectedTimelinePage({ rows, direction: "before", cursorSeq: 500, limit: 100, }); + const anchoredPage = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 101, + limit: 100, + }); - expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true); - expect(page.endSeq).toBeLessThan(500); - expect(page.hasOlder).toBe(true); + expect(newestPage.entries).toHaveLength(100); + expect(newestPage.entries.some((entry) => entry.item.type === "tool_call")).toBe(false); + expect(newestPage.startSeq).toBe(400); + expect(newestPage.endSeq).toBe(499); + expect(newestPage.hasOlder).toBe(true); + + expect(anchoredPage.entries).toHaveLength(100); + expect(anchoredPage.entries[0]?.item.type).toBe("tool_call"); + expect(anchoredPage.entries[0]?.seqStart).toBe(1); + expect(anchoredPage.entries[0]?.seqEnd).toBe(500); + expect(anchoredPage.startSeq).toBe(1); + expect(anchoredPage.endSeq).toBe(100); + expect(anchoredPage.hasOlder).toBe(false); }); test("tail page includes a wide tool when its completion is the newest seq", () => { diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index e6bd1d1614..73c77f80fe 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -366,16 +366,6 @@ function getTimelineBounds( return { minSeq: first.seq, maxSeq: last.seq }; } -function selectEntriesOverlappingSeqRange(input: { - entries: readonly TimelineProjectionEntry[]; - startSeq: number; - endSeq: number; -}): TimelineProjectionEntry[] { - return input.entries.filter( - (entry) => entry.seqStart <= input.endSeq && entry.seqEnd >= input.startSeq, - ); -} - function firstSourceSeqInRange( entry: TimelineProjectionEntry, startSeq: number, @@ -439,6 +429,27 @@ function selectProjectedEntriesAfter(input: { }; } +function selectProjectedEntriesBefore(input: { + entries: readonly TimelineProjectionEntry[]; + endSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { + // Older history follows projected display order. Lifecycle updates can move an + // entry's seqEnd without moving its display anchor, so seqStart keeps the full + // projected item on exactly one backward page. + const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); + const selected = + input.limit === 0 || input.limit >= eligible.length + ? eligible + : eligible.slice(eligible.length - input.limit); + + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + hasOlder: selected.length < eligible.length, + }; +} + export function selectProjectedTimelinePage(input: { rows: readonly AgentTimelineRow[]; bounds?: { minSeq: number; maxSeq: number }; @@ -504,11 +515,9 @@ export function selectProjectedTimelinePage(input: { }; } - let startSeq: number; - let endSeq: number; if (input.direction === "after") { const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - startSeq = Math.max(bounds.minSeq, cursorSeq + 1); + const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); const selected = selectProjectedEntriesAfter({ entries: projectedAll, rows: input.rows, @@ -523,28 +532,26 @@ export function selectProjectedTimelinePage(input: { hasOlder: startSeq > bounds.minSeq, hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, }; - } else { - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); - startSeq = limit === 0 ? bounds.minSeq : Math.max(bounds.minSeq, cursorSeq - limit); } - if (startSeq > endSeq) { + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); + if (endSeq < bounds.minSeq) { return { entries: [], startSeq: null, endSeq: null, - hasOlder: startSeq > bounds.minSeq, + hasOlder: false, hasNewer: endSeq < bounds.maxSeq, }; } - const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq }); + const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); return { - entries, - startSeq, + entries: selected.entries, + startSeq: selected.startSeq, endSeq, - hasOlder: startSeq > bounds.minSeq, + hasOlder: selected.hasOlder, hasNewer: endSeq < bounds.maxSeq, }; }