Keep older chat history and image previews stable (#2490)

* fix(app): keep timeline history and previews stable

Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.

* fix(app): keep file image previews current

* fix(app): stabilize history pagination edges

* fix(app): recover transient timeline loads

* fix(app): retain assistant image previews

* Stabilize history pagination lifecycle

* Stabilize history settlement and image previews

* Retry file images after reconnect

* Keep active previews and pagination requests alive

* Replace image hook tests with typed ports

* Integrate timeline hydration with submission authority

* Harden pre-hydration timeline reconciliation

* Make rewind E2E use real scroll intent

* Preserve live timeline state through hydration

* Keep mounted image attachments retained

* Protect preview persistence and lifecycle hydration

* Preserve idless live assistant continuations

* Preserve timeline rows across delayed hydration

* test(app): cover real assistant image files

* Preserve assistant tool ordering during hydration
This commit is contained in:
Mohamed Boudra
2026-07-28 22:29:03 +02:00
committed by GitHub
parent bbf3d0f9cc
commit 504b687f89
55 changed files with 4550 additions and 722 deletions
@@ -0,0 +1,85 @@
import { test as base } from "./fixtures";
import {
appendSettledTimelineTurns,
createNearTenMegabyteAssistantPng,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageNotMounted,
expectAssistantImageRendered,
openAssistantImageTimeline,
openExistingImageAgentTabs,
remountAndRecoverAssistantImageFromHistory,
sendFollowUpAndExpectVisibleResponse,
switchAwayAndBackWithoutImageInstability,
userPagesUntilAssistantImageRenders,
} from "./helpers/assistant-images";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
try {
await provide(workspace);
} finally {
await workspace.cleanup();
}
},
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Real file image",
fileName: "assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Image timeline");
const otherAgent = await createSettledMockAgent(workspace, "Other timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openExistingImageAgentTabs(page, { imageAgent, otherAgent });
await expectAssistantImageRendered(page, image);
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Paginated real file image",
fileName: "paginated-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 40);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageNotMounted(page, image);
await userPagesUntilAssistantImageRenders(page, image);
await remountAndRecoverAssistantImageFromHistory(page, image);
});
test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(180_000);
const image = await createNearTenMegabyteAssistantPng(workspace, {
alt: "Large real file image",
fileName: "large-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Large image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await sendFollowUpAndExpectVisibleResponse(page, {
prompt: "confirm responsiveness: emit 1 coalesced agent stream updates",
response: "stress-update-0",
});
});
@@ -1,27 +1,169 @@
import { test } from "./fixtures";
import { expect, test } from "./fixtures";
import {
expectLoadedTimelineDoesNotScroll,
expectSameOlderHistoryLoadingOperation,
expectTimelineAtHistoryStart,
expectTimelinePromptNotMounted,
expectTimelinePromptPositionPreserved,
expectTimelinePromptVisible,
holdNextOlderTimelinePage,
holdBootstrapTimelinePage,
holdDaemonHydration,
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToNewestLoadedEdge,
seedLongMockAgentTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userScrollsTimelineToHistoryStart,
} 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 ({
test("loads one page each time the user returns to history start", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);
} 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 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await agent.client.sendAgentMessage(
agent.agentId,
"timeline live during held older page: emit 20 coalesced agent stream updates",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
} finally {
await agent.cleanup();
}
});
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "running",
);
history.releasePage(1);
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
"running",
);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
});
test("keeps complete loaded history reachable after reload", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
const hydration = await holdDaemonHydration(page);
await reloadAgentTimelineFromPersistedReplica(page, agent);
hydration.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
@@ -29,18 +171,27 @@ test.describe("Agent timeline pagination", () => {
}
});
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test("preserves a live row received before replica hydration", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
const hydration = await holdBootstrapTimelinePage(page, agent);
await reloadAgentTimelineFromPersistedReplica(page, agent);
await hydration.waitForDelayedResponse();
const livePrompt = await sendLiveTurnBeforeHydration(agent);
await expectTimelinePromptVisible(page, livePrompt);
hydration.release();
await hydration.waitForDelayedCatchUp();
await expectTimelinePromptVisible(page, livePrompt);
hydration.releaseCatchUp();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, livePrompt);
} finally {
await agent.cleanup();
}
+178 -1
View File
@@ -15,6 +15,21 @@ export interface AgentTimelineResponseGate {
waitForDelayedResponse(): Promise<void>;
}
export interface OlderTimelinePagesGate {
getRequestCount(): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
release(): void;
}
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
releaseCatchUp(): void;
waitForDelayedCatchUp(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -45,6 +60,32 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
: null;
}
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
let released = false;
const delayedForwards: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (released) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
});
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
};
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
@@ -127,6 +168,142 @@ export async function delayCreatedAgentInitialTailResponse(
export async function delayAgentOlderTimelineResponse(
page: Page,
agentId: string,
): Promise<AgentTimelineResponseGate> {
return delayAgentTimelineResponse(page, agentId, "before");
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
): Promise<OlderTimelinePagesGate> {
let requestCount = 0;
let responseCount = 0;
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId &&
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
) {
responseCount += 1;
const pageNumber = responseCount;
if (releasedPages.has(pageNumber)) {
ws.send(message);
return;
}
const forwards = delayedForwards.get(pageNumber) ?? [];
forwards.push(() => ws.send(message));
delayedForwards.set(pageNumber, forwards);
return;
}
ws.send(message);
});
});
return {
getRequestCount: () => requestCount,
releasePage(pageNumber) {
releasedPages.add(pageNumber);
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
export async function delayAgentBootstrapTailResponse(
page: Page,
agentId: string,
): Promise<BootstrapTimelineGate> {
let tailReleased = false;
let catchUpReleased = false;
const delayedTailForwards: Array<() => void> = [];
const delayedCatchUpForwards: Array<() => void> = [];
let resolveDelayedTail: (() => void) | null = null;
let resolveDelayedCatchUp: (() => void) | null = null;
const delayedTail = new Promise<void>((resolve) => {
resolveDelayedTail = resolve;
});
const delayedCatchUp = new Promise<void>((resolve) => {
resolveDelayedCatchUp = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
const isTimelineResponse =
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
if (isTimelineResponse && payload.direction === "tail") {
resolveDelayedTail?.();
if (tailReleased) ws.send(message);
else delayedTailForwards.push(() => ws.send(message));
return;
}
if (isTimelineResponse && payload.direction === "after") {
resolveDelayedCatchUp?.();
if (catchUpReleased) ws.send(message);
else delayedCatchUpForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
tailReleased = true;
for (const forward of delayedTailForwards.splice(0)) forward();
},
releaseCatchUp() {
catchUpReleased = true;
for (const forward of delayedCatchUpForwards.splice(0)) forward();
},
waitForDelayedResponse: () => delayedTail,
waitForDelayedCatchUp: () => delayedCatchUp,
};
}
async function delayAgentTimelineResponse(
page: Page,
agentId: string,
direction: "before" | "tail",
): Promise<AgentTimelineResponseGate> {
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -148,7 +325,7 @@ export async function delayAgentOlderTimelineResponse(
!delayedResponseSeen &&
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
payload.direction === direction
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
@@ -0,0 +1,343 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { deflateSync } from "node:zlib";
import { expect, type Page } from "@playwright/test";
import type { ArchiveTabAgent } from "./archive-tab";
import { openWorkspaceWithAgents } from "./archive-tab";
import { submitMessage } from "./composer";
import type { SeedDaemonClient, SeededWorkspace } from "./seed-client";
import { openAgentRoute } from "./mock-agent";
import { rememberTimelineViewport, userScrollsTimelineToHistoryStart } from "./timeline-pagination";
const IMAGE_PREVIEW_ERROR = "Unable to load image preview.";
const SMALL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
const TEN_MEBIBYTES = 10 * 1024 * 1024;
export interface AssistantImageFixture {
alt: string;
height: number;
relativePath: string;
size: number;
width: number;
}
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
}
export async function createNearTenMegabyteAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
): Promise<AssistantImageFixture> {
const width = 2_048;
const height = 1_280;
const bytes = encodeDeterministicPng(width, height);
if (bytes.byteLength < TEN_MEBIBYTES * 0.95 || bytes.byteLength > TEN_MEBIBYTES * 1.05) {
throw new Error(`Expected a PNG near 10 MiB, encoded ${bytes.byteLength} bytes`);
}
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return {
alt: input.alt,
height,
relativePath: input.fileName,
size: bytes.byteLength,
width,
};
}
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
): Promise<ArchiveTabAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
});
await workspace.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return {
id: agent.id,
title,
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
};
}
export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
throw new Error(
`Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`,
);
}
}
export async function appendSettledTimelineTurns(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
count: number,
): Promise<void> {
for (let index = 0; index < count; index += 1) {
await client.sendAgentMessage(
agent.id,
`image-history-turn-${index}: emit 1 coalesced agent stream updates`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
throw new Error(
`Assistant image history turn did not settle: ${result.final?.lastError ?? result.status}`,
);
}
}
}
export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
rendered.evaluate((element) => {
const imageElement =
element instanceof HTMLImageElement ? element : element.querySelector("img");
return imageElement?.complete
? { height: imageElement.naturalHeight, width: imageElement.naturalWidth }
: null;
}),
)
.toEqual({ height: image.height, width: image.width });
}
export async function switchAwayAndBackWithoutImageInstability(
page: Page,
input: {
image: AssistantImageFixture;
imageAgent: ArchiveTabAgent;
otherAgent: ArchiveTabAgent;
},
): Promise<void> {
await beginVisibleImageStabilityObservation(page, input.image.alt, input.imageAgent.id);
await selectSettledAgentTab(page, input.otherAgent);
await selectSettledAgentTab(page, input.imageAgent);
await expectAssistantImageRendered(page, input.image);
await expectNoVisibleImageInstability(page);
}
export async function openExistingImageAgentTabs(
page: Page,
input: { imageAgent: ArchiveTabAgent; otherAgent: ArchiveTabAgent },
): Promise<void> {
await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]);
}
export async function openAssistantImageTimeline(
page: Page,
agent: ArchiveTabAgent,
): Promise<void> {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
}
export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
return;
}
const previous = await rememberTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await rememberTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previous.scrollHeight);
}
await expectAssistantImageRendered(page, image);
}
export async function remountAndRecoverAssistantImageFromHistory(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await page.reload();
await userPagesUntilAssistantImageRenders(page, image);
}
export async function sendFollowUpAndExpectVisibleResponse(
page: Page,
input: { prompt: string; response: string },
): Promise<void> {
await submitMessage(page, input.prompt);
await expect(page.getByText(input.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(input.response, { exact: true })).toBeVisible({ timeout: 30_000 });
}
async function selectSettledAgentTab(page: Page, agent: ArchiveTabAgent): Promise<void> {
const tab = page.getByRole("button", { name: agent.title, exact: true });
await tab.click();
await expect(page).toHaveTitle(agent.title);
await expect(tab).toHaveAttribute("aria-selected", "true");
await expect(page.locator('[data-testid="agent-chat-scroll"]:visible').first()).toBeVisible({
timeout: 30_000,
});
}
async function beginVisibleImageStabilityObservation(
page: Page,
alt: string,
imageAgentId: string,
): Promise<void> {
await page.evaluate(
({ accessibleName, errorText, tabTestId }) => {
const record = {
errorSeen: false,
missingSeen: false,
inspect: () => undefined,
observer: null as MutationObserver | null,
};
const isVisible = (element: Element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
record.inspect = () => {
record.errorSeen ||= Array.from(document.querySelectorAll("*")).some(
(element) =>
element.children.length === 0 &&
element.textContent?.trim() === errorText &&
isVisible(element),
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
record.observer = new MutationObserver(record.inspect);
record.observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
characterData: true,
});
record.inspect();
(
window as unknown as {
__paseoAssistantImageObservation?: typeof record;
}
).__paseoAssistantImageObservation = record;
},
{
accessibleName: alt,
errorText: IMAGE_PREVIEW_ERROR,
tabTestId: `workspace-tab-agent_${imageAgentId}`,
},
);
}
async function expectNoVisibleImageInstability(page: Page): Promise<void> {
const observation = await page.evaluate(() => {
const owner = window as unknown as {
__paseoAssistantImageObservation?: {
errorSeen: boolean;
missingSeen: boolean;
inspect(): void;
observer: MutationObserver | null;
};
};
const record = owner.__paseoAssistantImageObservation;
if (!record) throw new Error("Assistant image stability observation was not started");
record.inspect();
record.observer?.disconnect();
delete owner.__paseoAssistantImageObservation;
return { errorSeen: record.errorSeen, missingSeen: record.missingSeen };
});
expect(observation).toEqual({ errorSeen: false, missingSeen: false });
}
function encodeDeterministicPng(width: number, height: number): Buffer {
const stride = width * 4 + 1;
const scanlines = Buffer.allocUnsafe(stride * height);
let state = 0x9e3779b9;
for (let row = 0; row < height; row += 1) {
const rowOffset = row * stride;
scanlines[rowOffset] = 0;
for (let offset = 1; offset < stride; offset += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
scanlines[rowOffset + offset] = state & 0xff;
}
}
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(scanlines, { level: 0 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function pngChunk(type: string, data: Buffer): Buffer {
const typeBytes = Buffer.from(type, "ascii");
const chunk = Buffer.allocUnsafe(12 + data.byteLength);
chunk.writeUInt32BE(data.byteLength, 0);
typeBytes.copy(chunk, 4);
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.byteLength);
return chunk;
}
function crc32(bytes: Buffer): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
+276 -22
View File
@@ -1,20 +1,51 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import {
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
holdDaemonHydration,
holdAgentOlderTimelinePages,
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
interface LongTimelineAgentOptions {
turns: number;
}
interface LongTimelineAgent extends MockAgentWorkspace {
initialTailOldestPrompt: string;
oldestPrompt: string;
newestPrompt: string;
}
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
interface TimelineViewportSnapshot {
scrollHeight: number;
scrollTop: number;
}
interface TimelinePromptPositionSnapshot {
prompt: string;
top: number;
}
interface OlderHistoryLoadingOperation {
animationStartTime: number | null;
marker: string;
}
interface OlderHistoryPages {
expectRequestedPages(count: number): Promise<void>;
expectSettledWithRequestedPages(count: number): Promise<void>;
releasePage(pageNumber: number): void;
}
function promptForTurn(index: number): string {
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
@@ -36,11 +67,41 @@ export async function seedLongMockAgentTimeline(
return {
...agent,
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
};
}
export async function rememberTimelinePromptPosition(
page: Page,
prompt: string,
): Promise<TimelinePromptPositionSnapshot> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(prompt, { exact: true });
await expect(item).toBeVisible();
const box = await item.boundingBox();
if (!box) {
throw new Error(`Expected a rendered timeline item for ${prompt}`);
}
return { prompt, top: box.y };
}
export async function expectTimelinePromptPositionPreserved(
page: Page,
before: TimelinePromptPositionSnapshot,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(before.prompt, { exact: true });
await expect(item).toBeVisible();
await expect
.poll(async () => {
const box = await item.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(2);
}
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
await page.waitForURL(
@@ -50,15 +111,17 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P
}
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.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);
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1280, height: 8_000 });
await page.setViewportSize({ width: 1280, height: 20_000 });
}
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
@@ -75,6 +138,33 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<voi
.toBe(true);
}
export async function reloadAgentTimelineFromPersistedReplica(
page: Page,
agent: LongTimelineAgent,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((agentId) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -90,6 +180,106 @@ export async function holdNextOlderTimelinePage(
};
}
export async function holdOlderHistoryPages(
page: Page,
agent: LongTimelineAgent,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count);
},
releasePage(pageNumber) {
gate.releasePage(pageNumber);
},
};
}
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
return readTimelineViewport(page);
}
export async function expectTimelineViewportAnchoredAfterPrepend(
page: Page,
before: TimelineViewportSnapshot,
): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(before.scrollHeight);
await waitForTimelineGeometryToSettle(page);
const after = await readTimelineViewport(page);
const contentGrowth = after.scrollHeight - before.scrollHeight;
const scrollAdjustment = after.scrollTop - before.scrollTop;
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
export async function rememberOlderHistoryLoadingOperation(
page: Page,
): Promise<OlderHistoryLoadingOperation> {
const slot = page.getByTestId("load-older-history-spinner");
await expect(slot).toBeVisible();
const marker = `older-history-loading-${Date.now()}`;
await slot.evaluate((element, operationMarker) => {
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
const animated = candidates.find(
(candidate) => getComputedStyle(candidate).animationName !== "none",
);
if (!(animated instanceof HTMLElement)) {
throw new Error("Expected the older-history loader to contain an animated element");
}
animated.dataset.olderHistoryLoadingOperation = operationMarker;
}, marker);
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
await expect
.poll(async () => {
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
return typeof startTime === "number" ? startTime : null;
})
.not.toBeNull();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
return { animationStartTime, marker };
}
export async function expectSameOlderHistoryLoadingOperation(
page: Page,
operation: OlderHistoryLoadingOperation,
): Promise<void> {
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
await expect(animated).toBeVisible();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
expect(animationStartTime).toBe(operation.animationStartTime);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function holdBootstrapTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<BootstrapTimelineGate> {
return delayAgentBootstrapTailResponse(page, agent.agentId);
}
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT);
await agent.client.waitForFinish(agent.agentId, 15_000);
return LIVE_BEFORE_HYDRATION_PROMPT;
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
@@ -109,31 +299,95 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
});
}
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const previousHeight = await scroll.evaluate((element) => {
await scroll.hover();
for (let step = 0; step < 60; step += 1) {
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
break;
}
await page.mouse.wheel(0, -1_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}),
);
}
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
export async function scrollTimelineUntilOlderHistoryIsReachable(
page: Page,
oldestPrompt: string,
): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const prompt = scroll.getByText(oldestPrompt, { exact: true });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await prompt.count()) > 0) {
await expect(prompt).toBeVisible();
return;
}
const previousHeight = await readTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previousHeight.scrollHeight);
await waitForTimelineGeometryToSettle(page);
}
await expect(prompt).toBeVisible();
}
async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
});
}
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate(
(element) =>
new Promise<void>((resolve, reject) => {
if (!(element instanceof HTMLElement)) {
reject(new Error("Agent chat scroll element is not an HTMLElement"));
return;
}
const startedAt = performance.now();
let stableFrames = 0;
let previous = `${element.scrollTop}:${element.scrollHeight}`;
const sample = () => {
const current = `${element.scrollTop}:${element.scrollHeight}`;
stableFrames = current === previous ? stableFrames + 1 : 0;
previous = current;
if (stableFrames >= 4) {
resolve();
return;
}
if (performance.now() - startedAt > 5_000) {
reject(new Error("Timeline geometry did not settle"));
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}),
);
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);
}
@@ -2,6 +2,7 @@ import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { scrollChatAwayFromBottom } from "./helpers/agent-bottom-anchor";
import {
composerLocator,
expectComposerDraft,
@@ -191,6 +192,10 @@ test.describe("Rewind sheet", () => {
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");