import { type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; import { attachGatewayTrafficCapture } from "../../helpers/ws-traffic"; // --------------------------------------------------------------------------- // Overview // --------------------------------------------------------------------------- // // Covers the plan checkpointing feature (task plan revisions + rewind/revert). // // Coalesce window is set to 2000ms by the backend fixture // (KANDEV_PLAN_COALESCE_WINDOW_MS=2000). Tests that cross the boundary use // e2e:delay(2500) inside the mock-agent script; tests that stay within use // e2e:delay(200). // // Agent-authored revisions are seeded through the MCP tool // (create_task_plan_kandev). User-authored revisions are produced by typing // into the plan editor and waiting for the 1500ms autosave debounce. // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function expectedAgentCompletionText(description: string): string | null { return description.includes("Revisions seeded.") ? "Revisions seeded." : null; } async function waitForAgentOutput(session: SessionPage, expectedText: string) { await expect .poll( async () => { const matchingMessages = session.chat.getByText(expectedText, { exact: false }); const messageCount = await matchingMessages.count(); for (let index = 0; index < messageCount; index++) { if (await matchingMessages.nth(index).isVisible()) return true; } return session.planPanel.isVisible(); }, { timeout: 45_000, message: `agent output "${expectedText}" not visible` }, ) .toBe(true); } /** Create a task with agent, navigate to session, wait for agent output. */ async function seedTaskAndWaitForIdle( testPage: Page, apiClient: ApiClient, seedData: SeedData, title: string, description = "/e2e:simple-message", ) { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, title, seedData.agentProfileId, { description, workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); const sessionId = task.session_id; if (!sessionId) throw new Error("createTaskWithAgent did not return a session_id"); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); const expectedCompletionText = expectedAgentCompletionText(description); if (expectedCompletionText) { await waitForAgentOutput(session, expectedCompletionText); // The mock agent can render its final message before the SPA receives the // websocket state transition. Reload once after visible output so the page // rehydrates the finished session state instead of waiting on a stale // "Starting" footer. await testPage.reload(); await session.waitForLoad(); } else { await session.waitForChatIdle({ timeout: 45_000 }); } return { session, taskId: task.id, sessionId }; } /** Ensure the plan panel is visible. When an agent write auto-opens it, * the toggle is unnecessary (and would close it). After a page reload the * panel may not auto-open, in which case togglePlanMode adds it; we poll * a few times because hydration can race the click. */ async function openPlanPanel(session: SessionPage) { if (await session.planPanel.isVisible()) return; for (let attempt = 0; attempt < 3; attempt++) { try { await session.togglePlanMode(); } catch { // toggle button not yet ready — retry after a short wait } try { await session.planPanel.waitFor({ state: "visible", timeout: 5_000 }); return; } catch { // Not visible yet; loop and try toggling again. } } await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); } /** Poll for the number of revision rows in the popover. */ async function expectRevisionCount(session: SessionPage, count: number, timeout = 10_000) { await expect(session.revisionRows()).toHaveCount(count, { timeout }); } // MCP script building blocks — each emits one agent-authored plan revision. function mcpWrite(content: string): string { // JSON-string-escape content: handles backslashes, quotes, and control chars. const escaped = JSON.stringify(content).slice(1, -1); return `e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"${escaped}"})`; } /** Build a mock-agent script that chains plan writes with inter-write delays. */ function planWriteScript(writes: Array<{ content: string; delayMsAfter?: number }>): string { const lines: string[] = ['e2e:thinking("Seeding plan revisions...")']; for (const w of writes) { lines.push(mcpWrite(w.content)); if (w.delayMsAfter) lines.push(`e2e:delay(${w.delayMsAfter})`); } lines.push('e2e:message("Revisions seeded.")'); return lines.join("\n"); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test.describe("Plan checkpointing — rewind UI", () => { test.describe.configure({ retries: 1 }); test("empty plan: rewind button is disabled when no revisions exist", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(60_000); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint empty", ); await openPlanPanel(session); await expect(session.rewindButton()).toBeVisible({ timeout: 5_000 }); // No plan yet → no revisions → button disabled. await expect(session.rewindButton()).toBeDisabled(); }); test("single agent write: rewind exposes one revision with current badge", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const script = planWriteScript([{ content: "Initial plan" }]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint single", script, ); await openPlanPanel(session); await expect(session.planPanel.getByText("Initial plan", { exact: false })).toBeVisible({ timeout: 10_000, }); await session.openRewind(); await expectRevisionCount(session, 1); await expect(session.revisionRow(1).getByTestId("plan-revision-current-badge")).toBeVisible(); }); test("two agent writes within coalesce window: remain one revision", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const script = planWriteScript([ { content: "Draft v1", delayMsAfter: 200 }, { content: "Draft v2 (merged)" }, ]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint coalesce", script, ); await openPlanPanel(session); await expect(session.planPanel.getByText("Draft v2", { exact: false })).toBeVisible({ timeout: 10_000, }); await session.openRewind(); await expectRevisionCount(session, 1); }); test("two agent writes across coalesce window: produce two revisions", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const script = planWriteScript([ { content: "First pass", delayMsAfter: 2500 }, { content: "Second pass" }, ]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint cross-window", script, ); await openPlanPanel(session); await expect(session.planPanel.getByText("Second pass", { exact: false })).toBeVisible({ timeout: 10_000, }); await session.openRewind(); await expectRevisionCount(session, 2); // Newest first: v2 has current badge; v1 has revert button instead. await expect(session.revisionRow(2).getByTestId("plan-revision-current-badge")).toBeVisible(); await expect(session.revisionRow(1).getByTestId("plan-revision-current-badge")).toHaveCount(0); await expect(session.revisionRow(1).getByTestId("plan-revision-revert-button")).toBeVisible(); }); test("revert to earlier revision: HEAD updates and new revision appears", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); const script = planWriteScript([ { content: "Agent draft A", delayMsAfter: 2500 }, { content: "Agent draft B" }, ]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint revert", script, ); await openPlanPanel(session); await expect(session.planPanel.getByText("Agent draft B", { exact: false })).toBeVisible({ timeout: 10_000, }); await session.openRewind(); await expectRevisionCount(session, 2); // Revert to v1. await session.revertToRevision(1); // HEAD content must now be the old v1 content. await expect(session.planPanel.getByText("Agent draft A", { exact: false })).toBeVisible({ timeout: 10_000, }); // Reopen popover; expect 3 revisions with restore marker on v3. await session.openRewind(); await expectRevisionCount(session, 3); await expect(session.revisionRow(3).getByTestId("plan-revision-current-badge")).toBeVisible(); await expect(session.revisionRow(3).getByTestId("plan-revision-revert-marker")).toBeVisible(); }); test("revert confirmation: cancel leaves revisions untouched", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const script = planWriteScript([ { content: "First", delayMsAfter: 2500 }, { content: "Second" }, ]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint cancel", script, ); await openPlanPanel(session); await session.openRewind(); await expectRevisionCount(session, 2); // Click revert on v1 then cancel the row-local confirmation. await session.revertButton(session.revisionRow(1)).click(); await expect(session.revertConfirmPopover()).toBeVisible({ timeout: 5_000 }); await session.revertConfirmPopoverCancel().click(); await expect(session.revertConfirmPopover()).toBeHidden({ timeout: 5_000 }); // HEAD is unchanged and the two revisions are still there. await expect(session.planPanel.getByText("Second", { exact: false })).toBeVisible(); await session.openRewind(); await expectRevisionCount(session, 2); }); test("author switch breaks coalesce: user edit creates a second revision", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); // Attach before the first navigation so the capture sees the websocket // from the moment it opens. const traffic = attachGatewayTrafficCapture(testPage); const script = planWriteScript([{ content: "Agent wrote this" }]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint author switch", script, ); const planUpdatesBeforeEdit = () => traffic.frames.filter((frame) => frame.action === "task.plan.update").length; const planUpdatesAtStart = planUpdatesBeforeEdit(); await openPlanPanel(session); await expect(session.planPanel.getByText("Agent wrote this", { exact: false })).toBeVisible({ timeout: 10_000, }); // User types into the editor; autosave fires after 1500ms. const editor = session.planEditor(); await editor.click(); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await testPage.keyboard.press(`${modifier}+a`); await testPage.keyboard.type("User overwrote it"); // Autosave is debounced, but the debounce firing is observable: the save // travels as a `task.plan.update` frame on the gateway socket. Wait for // that frame rather than for a budget chosen to outlast the debounce. await expect .poll(planUpdatesBeforeEdit, { timeout: 15_000, message: "plan autosave never sent a task.plan.update frame", }) .toBeGreaterThan(planUpdatesAtStart); await session.openRewind(); await expectRevisionCount(session, 2); // v2 (newest) is the user write — UI displays "You" for any user-authored // revision regardless of stored author_name. v1 stays as the agent label, // resolved from the active session's profile snapshot — exact value varies // per E2E setup ("Claude", "mock-default", …) so we just ensure it's a // non-empty agent name (i.e., not the user sentinel). const v2Author = session.revisionRow(2).getByTestId("plan-revision-author"); const v1Author = session.revisionRow(1).getByTestId("plan-revision-author"); await expect(v1Author).not.toHaveText("You"); await expect(v1Author).not.toBeEmpty(); await expect(v2Author).toHaveText("You"); }); test("preview dialog: row click opens content, restore goes through confirm", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); const script = planWriteScript([ { content: "Older draft", delayMsAfter: 2500 }, { content: "Newer draft" }, ]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint preview", script, ); await openPlanPanel(session); await session.openRewind(); await expectRevisionCount(session, 2); // Open the older revision via row click. await session.openRevisionPreview(1); await expect(session.previewBody()).toContainText("Older draft", { timeout: 5_000 }); await expect(session.previewRestoreButton()).toBeVisible(); // Restore from preview routes through the confirm dialog. await session.previewRestoreButton().click(); await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 }); await session.revertConfirmOk().click(); await expect(session.revertConfirmDialog()).toBeHidden({ timeout: 5_000 }); // HEAD is now the older content again. await expect(session.planPanel.getByText("Older draft", { exact: false })).toBeVisible({ timeout: 10_000, }); await session.openRewind(); await expectRevisionCount(session, 3); }); test("compare diff: open from preview, view unified + split, restore older", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); const script = planWriteScript([ { content: "alpha\nbeta\ngamma", delayMsAfter: 2500 }, { content: "alpha\nBETA\ngamma\ndelta" }, ]); const { session } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint compare", script, ); await openPlanPanel(session); await session.openRewind(); await expectRevisionCount(session, 2); // Open the older revision in preview, then "Compare with current" — this // is the only entry point now; there is no per-row compare toggle. await session.openRevisionPreview(1); await session.previewCompareWithCurrentButton().click(); await expect(session.diffDialog()).toBeVisible({ timeout: 5_000 }); await expect(session.diffDialog()).toContainText("Compare v1 → v2"); // Summary + unified diff lines. await expect(session.diffSummary()).toContainText("added", { timeout: 5_000 }); await expect(session.diffSummary()).toContainText("removed"); await expect(session.diffLines("add").first()).toBeVisible(); await expect(session.diffLines("remove").first()).toBeVisible(); // Switch to split mode: side-by-side cells render with kinds. await session.diffModeToggle("split").click(); await expect(session.diffSplitCells("remove").first()).toBeVisible({ timeout: 5_000 }); await expect(session.diffSplitCells("add").first()).toBeVisible(); // Back to unified. await session.diffModeToggle("unified").click(); await expect(session.diffLines("add").first()).toBeVisible(); // Restore from diff targets the older revision (v1). await session.diffRestoreButton().click(); await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 }); await expect(session.revertConfirmDialog()).toContainText("version 1"); await session.revertConfirmCancel().click(); }); test("persistence across reload: revisions survive page refresh", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); const script = planWriteScript([{ content: "One", delayMsAfter: 2500 }, { content: "Two" }]); const { session, taskId } = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Checkpoint reload", script, ); await openPlanPanel(session); await session.openRewind(); await expectRevisionCount(session, 2); // Reload and reopen the panel. await testPage.goto(`/t/${taskId}`); const freshSession = new SessionPage(testPage); await freshSession.waitForLoad(); await openPlanPanel(freshSession); await freshSession.openRewind(); await expectRevisionCount(freshSession, 2); }); });