Rewind chat or files from any user message (#1154)

* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
This commit is contained in:
Mohamed Boudra
2026-05-25 17:11:34 +08:00
committed by GitHub
parent e966f70322
commit 132e572d6e
99 changed files with 7106 additions and 3425 deletions
+12 -3
View File
@@ -537,13 +537,22 @@ async function awaitRelayReady(
}
}
async function startRelay(): Promise<number> {
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const relayPort = await getAvailablePortExcluding(excludedPorts);
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -729,7 +738,7 @@ export default async function globalSetup() {
const dictation = await resolveDictationConfig();
try {
const relayPort = await startRelay();
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
+342
View File
@@ -0,0 +1,342 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expectComposerEditable, submitMessage } from "./composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
export type RewindFlowMode = "conversation" | "files" | "both";
export interface AgentHandle {
page: Page;
client: TerminalPerfDaemonClient;
agentId: string;
cwd: string;
provider: RewindFlowProvider;
}
export interface TranscriptMessage {
role: "user" | "assistant";
text: string | RegExp;
}
interface ProviderLaunchConfig {
provider: RewindFlowProvider;
model?: string;
thinkingOptionId?: string;
modeId?: string;
}
const SEND_TIMEOUT_MS = 240_000;
const REWIND_TIMEOUT_MS = 120_000;
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
switch (provider) {
case "claude":
return { provider, model: "haiku", modeId: "bypassPermissions" };
case "codex":
return {
provider,
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
};
case "opencode":
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
case "pi":
return {
provider,
model: "openrouter/google/gemini-2.5-flash-lite",
thinkingOptionId: "medium",
};
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await assertComposerIdle({ page });
}
function visibleChatMessages(page: Page) {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
}
async function transcript(
page: Page,
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
elements.map((element) => ({
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
| "user"
| "assistant",
text: (element.textContent ?? "")
.replace(/\s+/g, " ")
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
.trim(),
})),
);
return coalesceAssistantTurnSegments(rawMessages);
}
function coalesceAssistantTurnSegments(
messages: Array<{ role: "user" | "assistant"; text: string }>,
): Array<{ role: "user" | "assistant"; text: string }> {
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const message of messages) {
const previous = transcriptMessages.at(-1);
if (message.role === "assistant" && previous?.role === "assistant") {
const joinedText =
previous.text && message.text
? `${previous.text}\n${message.text}`
: previous.text || message.text;
transcriptMessages[transcriptMessages.length - 1] = {
role: "assistant",
text: joinedText,
};
continue;
}
transcriptMessages.push(message);
}
return transcriptMessages.filter(
(message) => message.role !== "assistant" || message.text.length > 0,
);
}
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
if (typeof expected === "string") {
return actual === expected;
}
return expected.test(actual);
}
function formatExpectedMessage(message: TranscriptMessage): string {
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
return `${message.role}:${text}`;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export async function launchAgent(input: {
page: Page;
provider: RewindFlowProvider;
cwd: string;
mode: "full-access";
}): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: input.cwd,
stdio: "ignore",
});
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectTerminalClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
return handle;
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.close().catch(() => undefined);
}
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
const before = await transcript(handle.page);
await submitMessage(handle.page, text);
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
if (finish.status !== "idle") {
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
throw new Error(
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
);
}
if (finish.final?.lastError) {
throw new Error(finish.final.lastError);
}
await expect
.poll(async () => transcript(handle.page), { timeout: 30_000 })
.toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", text }),
expect.objectContaining({ role: "assistant" }),
]),
);
await expect
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
timeout: 30_000,
})
.toBeGreaterThanOrEqual(before.length + 2);
await assertComposerIdle(handle);
}
export async function assertChatTranscript(
handle: Pick<AgentHandle, "page">,
expected: TranscriptMessage[],
): Promise<void> {
await expect
.poll(
async () => {
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
return JSON.stringify(actual);
}
const matches = actual.every(
(message, index) =>
message.role === expected[index]?.role &&
expectedTextMatches(message.text, expected[index]!.text),
);
return matches ? "match" : JSON.stringify(actual);
},
{ timeout: 30_000 },
)
.toBe("match");
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
throw new Error(
`Expected ${expected.length} chat messages (${expected
.map(formatExpectedMessage)
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
);
}
}
export async function rewindMessage(
handle: AgentHandle,
userMessageIndex: number,
mode: RewindFlowMode,
): Promise<void> {
const beforeEpoch = await fetchTimelineEpoch(handle);
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
await expect(userMessage).toBeVisible({ timeout: 30_000 });
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
const userText = userMessages[userMessageIndex]?.text;
if (!userText) {
throw new Error(`No user message found at index ${userMessageIndex}`);
}
await userMessage
.getByText(new RegExp(escapeRegExp(userText)))
.first()
.hover();
const trigger = userMessage.getByTestId("rewind-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
await expect(modeItem).toBeVisible({ timeout: 10_000 });
await modeItem.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
if (mode !== "files") {
await waitForNextTimelineEpoch(handle, beforeEpoch);
}
await assertComposerIdle(handle);
}
export async function assertFileExists(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
}
export async function assertFileMissing(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
}
export async function assertFileContains(filePath: string, text: string): Promise<void> {
await assertFileExists(filePath);
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
}
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
}
export async function cleanupRewindFlow(input: {
handle?: AgentHandle;
cwd: string;
}): Promise<void> {
if (input.handle) {
await closeAgent(input.handle);
}
rmSync(input.cwd, { recursive: true, force: true });
}
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
const client = handle.client as TerminalPerfDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
limit: 0,
});
return timeline.epoch;
}
async function waitForNextTimelineEpoch(
handle: AgentHandle,
beforeEpoch: string | undefined,
): Promise<void> {
await expect
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
.not.toBe(beforeEpoch);
}
+16
View File
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -35,6 +36,10 @@ export interface TerminalPerfDaemonClient {
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -68,6 +73,7 @@ interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
@@ -91,12 +97,22 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory,
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, workspaceId);
@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "claude",
rewindMode: "both",
fileReverted: true,
});
@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "codex",
rewindMode: "conversation",
fileReverted: false,
});
@@ -0,0 +1,8 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "opencode",
initialRewindMode: "both",
rewindMode: "both",
fileReverted: true,
});
@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "pi",
rewindMode: "conversation",
fileReverted: false,
});
+88
View File
@@ -0,0 +1,88 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "./fixtures";
import {
assertChatTranscript,
assertComposerIdle,
assertFileContains,
assertFileExists,
assertFileMissing,
cleanupRewindFlow,
launchAgent,
rewindMessage,
sendMessage,
type AgentHandle,
type RewindFlowMode,
type RewindFlowProvider,
} from "./helpers/rewind-flow";
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
interface RewindFlowCase {
provider: RewindFlowProvider;
initialRewindMode?: RewindFlowMode;
rewindMode: RewindFlowMode;
fileReverted: boolean;
}
export function defineRewindFlowSpec(input: RewindFlowCase): void {
test.describe(`rewind flow - ${input.provider}`, () => {
test.setTimeout(600_000);
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: input.provider,
cwd,
mode: "full-access",
});
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
await assertChatTranscript(handle, []);
await assertComposerIdle(handle);
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await sendMessage(handle, FILE_PROMPT);
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
{ role: "user", text: /Use the Write tool/ },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 1, input.rewindMode);
if (input.fileReverted) {
await assertFileMissing(path.join(cwd, "qa.txt"));
} else {
await assertFileExists(path.join(cwd, "qa.txt"));
}
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await assertComposerIdle(handle);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
}
@@ -0,0 +1,169 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const repo = await createTempGitRepo("rewind-e2e-");
const client = await connectTerminalClient();
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.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).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(page.getByText(secondPrompt, { exact: true })).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 expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
await expect(page.getByText(secondPrompt, { exact: true })).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 page.getByText(replacementPrompt, { exact: true }).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 page.getByText(replacementPrompt, { exact: true }).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 page.getByText(replacementPrompt, { exact: true }).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 client.close();
await repo.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const repo = await createTempGitRepo("rewind-failure-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind failure e2e",
modeId: "load-test",
model: "ten-second-stream",
featureValues: {
mockRewindError: rewindError,
},
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).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 page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -0,0 +1,92 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerEditable,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
}
async function expectIdleComposer(page: Page): Promise<void> {
await expectComposerEditable(page);
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 15_000,
});
}
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
await expectIdleComposer(page);
await page.waitForTimeout(1_000);
await expectIdleComposer(page);
}
test.describe("User message UI contract", () => {
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
const repo = await createTempGitRepo("user-message-contract-e2e-");
const client = await connectTerminalClient();
const prompts = [
"emit 1 coalesced agent stream updates for user message contract turn one.",
"emit 1 coalesced agent stream updates for user message contract turn two.",
"emit 1 coalesced agent stream updates for user message contract turn three.",
];
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "User message contract e2e",
modeId: "load-test",
model: "ten-second-stream",
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
for (let index = 0; index < prompts.length; index += 1) {
const prompt = prompts[index]!;
await submitMessage(page, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
timeout: 15_000,
});
await expectUserMessageCount(page, index + 1);
await expectNoLoadingRegressionAfterIdle(page);
}
await fillComposerDraft(page, "append");
await composerLocator(page).evaluate((element) => element.blur());
await expectUserMessageCount(page, 3);
await expectIdleComposer(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});