From 6d29092fe67daf704fa1e01c3e665d04adeb7583 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 22:32:12 +0700 Subject: [PATCH] Fix older timeline loading after catch-up --- .../app/e2e/agent-timeline-pagination.spec.ts | 28 +++++ .../app/e2e/helpers/timeline-pagination.ts | 102 ++++++++++++++++++ .../src/server/agent/timeline-projection.ts | 32 +++++- .../daemon-e2e/timeline-window.e2e.test.ts | 39 +++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/agent-timeline-pagination.spec.ts create mode 100644 packages/app/e2e/helpers/timeline-pagination.ts diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts new file mode 100644 index 000000000..ebe7815aa --- /dev/null +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -0,0 +1,28 @@ +import { test } from "./fixtures"; +import { + expectTimelinePromptNotMounted, + expectTimelinePromptVisible, + openAgentTimeline, + scrollTimelineUntilOlderHistoryIsReachable, + seedLongMockAgentTimeline, +} from "./helpers/timeline-pagination"; + +test.describe("Agent timeline pagination", () => { + test("loads older history when the user scrolls to the top of a long agent timeline", async ({ + page, + }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await expectTimelinePromptNotMounted(page, agent.oldestPrompt); + + await scrollTimelineUntilOlderHistoryIsReachable(page); + + await expectTimelinePromptVisible(page, agent.oldestPrompt); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts new file mode 100644 index 000000000..c4f019b70 --- /dev/null +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -0,0 +1,102 @@ +import { expect, type Page } from "@playwright/test"; +import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +interface LongTimelineAgentOptions { + turns: number; +} + +interface LongTimelineAgent extends MockAgentWorkspace { + oldestPrompt: string; + newestPrompt: string; +} + +const PROMPT_PREFIX = "timeline-pagination-turn"; + +function promptForTurn(index: number): string { + return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`; +} + +export async function seedLongMockAgentTimeline( + options: LongTimelineAgentOptions, +): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-pagination-", + title: "Timeline pagination regression", + model: "ten-second-stream", + }); + + for (let index = 0; index < options.turns; index += 1) { + await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index)); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + + return { + ...agent, + oldestPrompt: promptForTurn(0), + newestPrompt: promptForTurn(options.turns - 1), + }; +} + +export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise { + await page.goto(buildAgentRoute(agent.cwd, agent.agentId)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); +} + +export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise { + await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise { + await expect(page.getByText(prompt, { exact: true })).toHaveCount(0); +} + +export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await scroll.hover(); + await page.mouse.wheel(0, -20_000); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + await scroll.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error("Agent chat scroll element is not an HTMLElement"); + } + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); +} + +export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const previousHeight = await scroll.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error("Agent chat scroll element is not an HTMLElement"); + } + return element.scrollHeight; + }); + + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + await scrollTimelineToOldestLoadedEdge(page); + await expect + .poll(async () => + scroll.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error("Agent chat scroll element is not an HTMLElement"); + } + return element.scrollHeight; + }), + ) + .toBeGreaterThan(previousHeight); + await scrollTimelineToOldestLoadedEdge(page); +} diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 279999e4e..bf9344677 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -386,7 +386,37 @@ export function selectProjectedTimelinePage(input: { const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); const bounds = input.bounds ?? getTimelineBounds(input.rows); const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); - if (projectedAll.length === 0 || !bounds) { + if (!bounds) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (projectedAll.length === 0) { + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq >= bounds.minSeq, + hasNewer: cursorSeq < bounds.maxSeq, + }; + } + if (input.direction === "before") { + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq > bounds.minSeq, + hasNewer: cursorSeq <= bounds.maxSeq, + }; + } return { entries: [], startSeq: null, diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index 409a12261..0c3e0da0d 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -362,6 +362,45 @@ describe("daemon E2E - timeline window", () => { } }); + test("projected empty after fetch preserves older history availability", async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Timeline Empty After HasOlder Test", + modeId: "full-access", + }); + for (let seq = 1; seq <= 160; seq += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text: `row ${seq}`, + }); + } + + const tail = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 100, + projection: "projected", + }); + expect(tail.hasOlder).toBe(true); + expect(tail.startCursor?.seq).toBe(61); + expect(tail.endCursor?.seq).toBe(160); + + const after = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { epoch: tail.epoch, seq: tail.endCursor?.seq ?? 160 }, + limit: 100, + projection: "projected", + }); + + expect(after.entries).toHaveLength(0); + expect(after.hasOlder).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("before fetch does not re-fetch full plain chat history", async () => { const cwd = tmpCwd(); try {