Fix older timeline loading after catch-up
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<LongTimelineAgent> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
|
||||||
|
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
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<void>((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);
|
||||||
|
}
|
||||||
@@ -386,7 +386,37 @@ export function selectProjectedTimelinePage(input: {
|
|||||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
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 {
|
return {
|
||||||
entries: [],
|
entries: [],
|
||||||
startSeq: null,
|
startSeq: null,
|
||||||
|
|||||||
@@ -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 () => {
|
test("before fetch does not re-fetch full plain chat history", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user