Files
kandev/apps/web/e2e/tests/task/sessionless-task-switch.spec.ts

194 lines
7.6 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
/**
* Bug: when a task is created via MCP `create_task_kandev` with
* `start_agent=false`, it has no session/environment. Selecting that task in
* the sidebar previously left the dockview layout corrupted (wrong widths on
* the central group) and did not update the breadcrumb to the new task. The
* corruption then persisted across subsequent task switches because the bad
* layout was saved back to per-session storage.
*
* This test reproduces the failing flow end-to-end and asserts:
* 1. Selecting the sessionless subtask updates the URL + breadcrumb.
* 2. The dockview layout remains healthy (no zero-width groups).
* 3. Switching back to the parent task keeps the layout healthy
* (regression on persisted-corruption).
*/
const SUBTASK_TITLE = "Sessionless MCP Subtask";
test.describe("Sessionless task switching", () => {
test("selecting an MCP-created sessionless subtask keeps layout healthy", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
// Parent task agent script: create a subtask via MCP with start_agent=false.
const script = [
'e2e:thinking("Creating sessionless subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${SUBTASK_TITLE}","description":"Sessionless subtask for layout-recovery test","start_agent":false})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
const parent = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Sessionless Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Wait for parent agent to settle so the layout has stabilised.
await session.waitForChatIdle({ timeout: 30_000 });
// Poll the API until the MCP-created subtask exists. We need its ID for URL
// assertions. The subtask is created by the agent asynchronously, so the
// idle indicator alone isn't a guarantee.
type TaskEntry = { id: string; title: string; primary_session_id?: string | null };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const all = await apiClient.listTasks(seedData.workspaceId);
subtask = all.tasks.find((t: TaskEntry) => t.title === SUBTASK_TITLE);
return subtask?.id ?? null;
},
{ timeout: 30_000, message: "Waiting for MCP-created subtask to appear" },
)
.toBeTruthy();
const subtaskId = subtask!.id;
// Sanity: the bug requires a sessionless task. start_agent=false implies no
// primary session was created on the backend.
expect(subtask!.primary_session_id ?? null).toBeNull();
// Baseline: layout is healthy on the parent.
await session.expectLayoutHealthy();
// Wait for the subtask to appear in the sidebar, then click it.
const subtaskRow = session.taskInSidebar(SUBTASK_TITLE);
await expect(subtaskRow).toBeVisible({ timeout: 10_000 });
await session.clickTaskInSidebar(SUBTASK_TITLE);
// URL should switch to the subtask within a reasonable time.
await expect(testPage).toHaveURL(new RegExp(`/t/${subtaskId}(?:\\?|$)`), {
timeout: 10_000,
});
// Breadcrumb (task title in the topbar) must reflect the new task.
const breadcrumb = testPage.locator('[aria-current="page"]');
await expect(breadcrumb).toHaveText(SUBTASK_TITLE, { timeout: 10_000 });
// Layout must remain healthy on the sessionless task.
await session.expectLayoutHealthy();
// Switch back to the parent task. The bug surfaces here: persisted bad
// layout state corrupts the central-group widths on this second switch.
await session.clickTaskInSidebar("Sessionless Parent Task");
await expect(testPage).toHaveURL(new RegExp(`/t/${parent.id}(?:\\?|$)`), {
timeout: 10_000,
});
await expect(breadcrumb).toHaveText("Sessionless Parent Task", { timeout: 10_000 });
await session.expectLayoutHealthy();
});
test("self-recovers when sessionStorage holds a corrupted layout for a known session", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
// Two tasks with agents — A navigated first, then B, then back to A. The
// back-to-A switch goes through performEnvSwitch's slow path which
// historically applied the saved (potentially corrupt) blob verbatim.
const taskA = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Layout Recovery Task A",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const taskB = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Layout Recovery Task B",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${taskA.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Don't wait for the agent to go idle: the seed workflow auto-starts a
// second session as soon as the first turn completes, so the chat
// composer cycles Preparing → idle (briefly) → Preparing within the
// polling window. The test only needs the dockview layout to be ready.
await session.waitForDockviewReady();
await session.expectLayoutHealthy();
// Resolve A's task environment id so we can target the env-keyed layout.
let sessionAEnvId: string | null = null;
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskA.id);
sessionAEnvId = sessions[0]?.task_environment_id ?? null;
return sessionAEnvId;
},
{ timeout: 15_000, message: "Waiting for task A environment id" },
)
.toBeTruthy();
// Switch to B, then back — this primes both layouts in sessionStorage.
await session.clickTaskInSidebar("Layout Recovery Task B");
await expect(testPage).toHaveURL(new RegExp(`/t/${taskB.id}(?:\\?|$)`), { timeout: 10_000 });
await session.expectLayoutHealthy();
// Inject a corrupt layout for A while we're focused on B.
await testPage.evaluate((envId) => {
const corrupt = {
grid: {
root: {
type: "leaf",
size: 800,
data: { id: "g1", views: ["chat"], activeView: "chat" },
},
height: 600,
width: 0, // <-- corruption
orientation: "HORIZONTAL",
},
panels: { chat: { id: "chat", contentComponent: "chat" } },
activeGroup: "g1",
};
window.sessionStorage.setItem(`kandev.dockview.env-layout.${envId}`, JSON.stringify(corrupt));
}, sessionAEnvId!);
// Switch back to A — performEnvSwitch should drop the corrupt blob
// and rebuild the default instead of applying the zero-width layout.
await session.clickTaskInSidebar("Layout Recovery Task A");
await expect(testPage).toHaveURL(new RegExp(`/t/${taskA.id}(?:\\?|$)`), { timeout: 10_000 });
await session.expectLayoutHealthy();
});
});