Files
kandev/apps/web/e2e/tests/session/session-recovery.spec.ts
T
2026-09-13 12:08:05 +01:00

384 lines
14 KiB
TypeScript

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 { waitForSessionState } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import {
cleanupDelayedResumeFixture,
createFailOnResumeProfile,
seedDelayedResumeFixture,
waitForSessionReady,
waitForQueuedCount,
} from "../../helpers/session-resume-prompt-queue";
type ContextWindowStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
tasks: { activeSessionId: string | null };
setContextWindow: (
sessionId: string,
contextWindow: {
size: number;
used: number;
remaining: number;
efficiency: number;
compactionCount: number;
source: "acp" | "api";
},
) => void;
};
};
};
/**
* Seed a task + session via the API and navigate directly to the session page.
* Waits for the mock agent to complete its turn (idle input visible).
*/
async function seedTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
opts: { description?: string; agentProfileId?: string } = {},
): Promise<SessionPage> {
const description = opts.description ?? "/e2e:simple-message";
const agentProfileId = opts.agentProfileId ?? seedData.agentProfileId;
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, agentProfileId, {
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
/** Seed a high-usage reading that represents the stale value from before reset. */
async function seedStaleContextWindow(testPage: Page): Promise<void> {
await testPage.evaluate(() => {
const store = (window as ContextWindowStoreWindow).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge is unavailable");
const sessionId = store.getState().tasks.activeSessionId;
if (!sessionId) throw new Error("No active session is available");
store.getState().setContextWindow(sessionId, {
size: 200_000,
used: 190_000,
remaining: 10_000,
efficiency: 95,
compactionCount: 0,
source: "acp",
});
});
}
// Worst-case wait for the manual-recovery banner after `/crash`. The mock
// agent's crash is a real subprocess exit whose ACP-level error carries the
// same "peer disconnected before response" text the routingerr classifier
// now treats as a retryable transport-lost signature (see
// docs/decisions/2026-08-08-provider-neutral-agent-error-recovery.md) — every
// genuine production occurrence of that text also means the local subprocess
// died, so the classifier deliberately does not special-case a crash out of
// the retry ladder. `/crash` always re-crashes on relaunch, so recovery
// exhausts the full Kanban backoff (5+10+20+40+60s = 135s) before the manual
// recovery banner replaces the retry-countdown card. Give it headroom above
// that worst case rather than the default 30s.
const CRASH_RECOVERY_TIMEOUT = 170_000;
test.describe("Session recovery", () => {
test.describe.configure({ retries: 1 });
test("cancelling delayed resume fences the old work before a retry", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(150_000);
const fixture = await seedDelayedResumeFixture(
testPage,
apiClient,
seedData,
backend,
"Session cancel and retry recovery",
);
try {
// Cancel the actual STARTING session while the provider load is held by
// the delayed mock agent. This is the browser path that used to leave a
// resume continuation alive after cancellation.
await expect(fixture.session.cancelAgentButton()).toBeVisible({ timeout: 15_000 });
await fixture.session.cancelAgentButton().click();
await waitForSessionState(apiClient, {
taskId: fixture.task.id,
sessionId: fixture.identity.sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "Waiting for delayed resume cancellation",
timeout: 30_000,
});
// Retry the same saved conversation through the normal composer. The
// old delayed callback must not publish a second response or consume
// this new attempt.
await waitForSessionReady(
testPage,
apiClient,
fixture.task.id,
fixture.identity.sessionId,
90_000,
);
await expect(fixture.session.activeChat().getByTestId("chat-input-editor")).toHaveAttribute(
"contenteditable",
"true",
{ timeout: 30_000 },
);
await fixture.session.sendMessage("/e2e:simple-message");
await fixture.session.expectChatResponseVisible("simple mock response", 1, {
timeout: 60_000,
});
await expect(fixture.session.activeChat().getByText("simple mock response")).toHaveCount(2);
} finally {
await cleanupDelayedResumeFixture(apiClient, fixture);
}
});
test("session startup keeps the composer editable and queues a submitted prompt", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
const fixture = await seedDelayedResumeFixture(
testPage,
apiClient,
seedData,
backend,
"Session startup composer readiness test",
);
try {
const editor = fixture.session.activeChat().getByTestId("chat-input-editor");
const submit = fixture.session.submitButton();
// @covers AC-UI-SESSION-START-COMPOSER-READINESS-001.1
await expect(editor).toHaveAttribute("contenteditable", "true");
await editor.fill('e2e:message("startup queue marker")');
// @covers AC-TASKS-RESUME-PROMPT-QUEUE-001.1
await expect(submit).toBeEnabled();
await submit.click();
// @covers AC-TASKS-RESUME-PROMPT-QUEUE-001.2
await expect(editor).toHaveText("");
await waitForQueuedCount(apiClient, fixture.identity, 1);
await expect(fixture.session.activeChat().getByTestId("queue-chip")).toBeVisible();
// @covers AC-TASKS-RESUME-PROMPT-QUEUE-001.8
await testPage.reload();
await fixture.session.waitForLoad();
await waitForQueuedCount(apiClient, fixture.identity, 1);
await waitForSessionReady(testPage, apiClient, fixture.task.id, fixture.identity.sessionId);
// @covers AC-TASKS-RESUME-PROMPT-QUEUE-001.3
const responses = fixture.session
.activeChat()
.locator("[data-agent-message-body][data-message-id]")
.filter({ hasText: "startup queue marker" });
await expect(responses).toHaveCount(1, { timeout: 60_000 });
} finally {
await cleanupDelayedResumeFixture(apiClient, fixture);
}
});
test("reset context hides stale usage until a fresh report arrives", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Reset Context Test");
await seedStaleContextWindow(testPage);
const contextRing = testPage.getByRole("button", { name: "Context window: 95% used" });
const contextIndicators = testPage.getByRole("button", { name: /^Context window:/ });
await expect(contextRing).toBeVisible();
// Click reset context button — confirmation dialog should appear
await session.resetContextButton().click();
await expect(session.resetContextConfirm()).toBeVisible();
// Confirm the reset
await session.resetContextConfirm().click();
// Divider should appear in chat
await expect(session.contextResetDivider()).toBeVisible({ timeout: 30_000 });
// Agent should restart and become idle again
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
await expect(session.resetContextButton()).toBeVisible();
await expect(contextIndicators).toHaveCount(0, { timeout: 30_000 });
// The ring should return only after the agent reports fresh usage.
await session.sendMessage("/background 1ms");
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
await expect(testPage.getByRole("button", { name: "Context window: 2% used" })).toBeVisible({
timeout: 30_000,
});
});
test("agent crash — start fresh session recovers", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(220_000);
const session = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Crash Recovery Fresh Test",
);
// Send /crash to make the agent exit with code 1
await session.sendMessage("/crash");
// Recovery buttons should appear
await expect(session.recoveryFreshButton()).toBeVisible({
timeout: CRASH_RECOVERY_TIMEOUT,
});
await expect(session.recoveryResumeButton()).toBeVisible();
// Click "Start fresh session"
await session.recoveryFreshButton().click();
// Native session resume can move directly from recovery into an editable
// replacement session, so assert stable readiness instead of a transient
// placeholder that may be skipped.
await expect(testPage.getByTestId("chat-input-editor")).toHaveAttribute(
"contenteditable",
"true",
{
timeout: 30_000,
},
);
// Verify agent works after recovery
await session.sendMessage("/e2e:simple-message");
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 });
});
test("agent crash — resume session recovers", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(220_000);
const session = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Crash Recovery Resume Test",
);
// Send /crash to make the agent exit with code 1
await session.sendMessage("/crash");
// Recovery buttons should appear
await expect(session.recoveryResumeButton()).toBeVisible({
timeout: CRASH_RECOVERY_TIMEOUT,
});
// Click "Resume session"
await session.recoveryResumeButton().click();
const editor = session.activeChat().getByTestId("chat-input-editor");
// The recovery endpoint waits until the resumed agent is prompt-ready
// before it resolves. The startup-specific composer gate is exercised by
// the slow-preparation test above; this flow verifies the recovered
// composer is usable afterwards.
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
await expect(session.submitButton()).toBeEnabled({ timeout: 30_000 });
// The resume settles the session back to WAITING_FOR_INPUT (agent idle).
// The recovery card must not reappear now that the resume is resolved —
// before the fix it came back until the next message flipped the session to
// RUNNING, without the user ever typing anything.
await expect(session.recoveryResumeButton()).toHaveCount(0);
await expect(session.recoveryFreshButton()).toHaveCount(0);
// Reload: the card is persisted, so a fix that only remembered the click in
// component state would resurrect it here (this is also what the user hits
// when a task is reopened and the session auto-resumes on open). The
// transcript's own agent-boot record is what keeps it hidden.
await testPage.reload();
await session.waitForLoad();
// Gate on the transcript's own boot record rather than the composer becoming
// editable: the boot row is the signal the card is derived from, so waiting
// for it removes the race where a fast hydration outruns the WS history.
// (session.waitForChatIdle is unusable here — it clicks a visible Resume
// button, which would hide the very regression this asserts.)
await expect(testPage.getByText(/Resumed agent|Started agent/i).first()).toBeVisible({
timeout: 30_000,
});
await expect(session.recoveryResumeButton()).toHaveCount(0);
await expect(session.recoveryFreshButton()).toHaveCount(0);
// Verify the resumed agent works after recovery.
await session.sendMessage("/e2e:simple-message");
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 });
});
test("agent crash — resume fails again, no stuck button remains", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(220_000);
// Unique suffix so a swallowed cleanup from a prior run doesn't collide on name.
const profile = await createFailOnResumeProfile(apiClient, `ACP Fail On Resume ${Date.now()}`);
try {
const session = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Crash Recovery Resume Fails Test",
{ agentProfileId: profile.id },
);
// Crash the agent so the recovery message renders with action buttons.
await session.sendMessage("/crash");
await expect(session.recoveryResumeButton()).toBeVisible({
timeout: CRASH_RECOVERY_TIMEOUT,
});
// Click "Resume session". The backend may resolve this through several
// paths depending on a race (handleAgentStartFailed vs the AgentFailed
// event from MarkCompleted) — either FAILED session state, a new
// recovery ActionMessage, or a warning status. We don't pin the
// downstream behaviour; the fix is purely frontend, so the assertion
// below focuses on what the fix actually guarantees.
await session.recoveryResumeButton().click();
// The user-visible bug was the original button getting permanently
// re-labelled to "Resume session requested" (disabled forever, only a
// refresh got out of it). With the fix, the button unmounts as soon as
// the ws_request completes — so the relabel never renders. Before the
// fix this assertion fails as soon as the click's WS round-trip
// resolves; with the fix the text never appears in the DOM.
await expect(testPage.getByText(/Resume session requested/i)).toHaveCount(0, {
timeout: 15_000,
});
} finally {
await apiClient.deleteAgentProfile(profile.id, true).catch(() => undefined);
}
});
});