Files
paseo/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts
Mohamed Boudra 0ad69d3eb0 fix(app): paint conversations immediately after restart (#3907)
* fix(app): paint cached conversations before reconnect

Cold startup restored every saved host before publishing any session, so a valid cached chat was blocked behind unrelated directory validation and network-only readiness. Directory and timeline owners now resolve demand through typed storage and authoritative reconciliation while consumers remain source-agnostic.

* fix(app): stabilize demand-driven cache restore

Create host projections before network availability, route cached workspace and timeline preparation through their owners, and coalesce directory reconciliation per online connection. Update browser cache fixtures for per-agent timeline rows.

* fix(app): resume cached timeline through owner

* fix(app): surface timeline owner load errors
2026-08-26 22:55:48 +02:00

292 lines
11 KiB
TypeScript

import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "../support/helpers/composer";
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
function userMessage(page: Page, text: string): Locator {
return page.getByTestId("user-message").filter({ hasText: text });
}
async function expectUserMessageVisible(page: Page, text: string): Promise<void> {
await expect(userMessage(page, text)).toBeVisible();
}
async function rewriteCachedMessageAsLegacyRow(
page: Page,
input: { prompt: string; agentId: string; workspaceId: string },
): Promise<void> {
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
for (const host of cache.hosts ?? []) {
const hasAgent = host.agents.some(
(agent) =>
agent.snapshot &&
typeof agent.snapshot === "object" &&
Reflect.get(agent.snapshot, "id") === input.agentId,
);
const hasWorkspace = host.workspaces.some(
(workspace) => workspace.id === input.workspaceId,
);
if (!hasAgent || !hasWorkspace) continue;
for (const timeline of host.timelines) {
for (const item of timeline.items ?? []) {
if (
timeline.agentId === input.agentId &&
item.kind === "user_message" &&
item.text === input.prompt &&
item.messageId
) {
return true;
}
}
}
}
return false;
})
.toBe(true);
const cache = await readReplicaCache(page);
if (!cache) throw new Error("Replica cache was not persisted");
const cachedMessage = cache.hosts
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
.find((item) => item.kind === "user_message" && item.text === input.prompt);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
await writeReplicaCache(page, cache);
}
async function waitForCurrentSubmissionExcludedFromCache(
page: Page,
prompt: string,
): Promise<void> {
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
return !cache.hosts
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
.some(
(item) =>
item.kind === "user_message" &&
item.text === prompt &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
})
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
return cache.hosts
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
.some(
(item) =>
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
);
})
.toBe(true);
}
async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise<void> {
const prompt = "Keep this cached submission pending.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-current-cache-e2e-",
title: "Current cache submission e2e",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
gate.holdNextClientRequest("send_agent_message_request");
await submitMessage(page, prompt);
await gate.waitForHeldClientRequest();
await waitForCurrentSubmissionExcludedFromCache(page, prompt);
await gate.drop();
await page.reload();
await expect(userMessage(page, prompt)).toHaveCount(0);
} finally {
gate.restore();
await session.cleanup();
}
}
test.describe("Rewind sheet", () => {
test("does not restore a local-only submission from the display cache", async ({ page }) => {
await expectPendingSubmissionNotRestoredAfterReload(page);
});
test("does not invent rewind identity for an ID-less cached message", async ({ page }) => {
const prompt = "Restore this rewind identity from the legacy cache.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-cache-upgrade-e2e-",
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await gate.drop();
await rewriteCachedMessageAsLegacyRow(page, {
prompt,
agentId: session.agentId,
workspaceId: session.workspaceId,
});
await page.reload();
await waitForCachedMessageWithoutProviderId(page, prompt);
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
await restoredMessage.hover();
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
} finally {
gate.restore();
await session.cleanup();
}
});
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-e2e-",
title: "Rewind e2e",
initialPrompt: firstPrompt,
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expectUserMessageVisible(page, firstPrompt);
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expectUserMessageVisible(page, secondPrompt);
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");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expectUserMessageVisible(page, replacementPrompt);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
await composerLocator(page).evaluate((element) => element.blur());
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
} finally {
await session.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-failure-e2e-",
title: "Rewind failure e2e",
initialPrompt: firstPrompt,
featureValues: {
mockRewindError: rewindError,
},
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expectUserMessageVisible(page, firstPrompt);
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await session.cleanup();
}
});
});