Files
kandev/apps/web/e2e/tests/task/sessionless-task-switch.spec.ts
T
José Almeida 45ee2ff48f feat: key terminals + dockview layout to TaskEnvironment (#755)
* feat(backend): key user shells by TaskEnvironmentID

Sessions in the same task share a TaskEnvironment. Re-key the user shell
runner so terminals are scoped per env, not per session — switching between
sessions of the same task now sees the same shell list.

- Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest
  / executor.LaunchAgentRequest, propagated through ToAgentExecution and the
  orchestrator launch path.
- Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries
  it, falls back to sessionID for legacy paths.
- Rename runner param sessionID -> scopeID in interactive_shells.go; map keys
  are now scopeID:terminalID.
- Shell handlers + WS terminal handler resolve sid -> scopeID via
  ResolveScopeKey before calling the runner.

* feat(web): key dockview layout + portals to TaskEnvironmentID

Switching between sessions of the same task no longer rebuilds the dockview
layout — terminals and panels persist because layouts are env-scoped, not
session-scoped. The bottom terminal also reuses a stable sessionId across
same-env switches so it doesn't reconnect to a different shell.

- Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState
  -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix).
- Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch
  -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId.
- dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout
  -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op.
  Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it
  structurally.
- panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv.
  ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED).
- useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only.
- All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve
  sid -> envId via environmentIdBySessionId before switching.
- bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId().
- cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped
  layout/maximize cleanup.

* test: add coverage for env-keyed terminals + layout

Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of
the same task keeps terminal scrollback intact and asserts both sessions
share one task_environment_id. Update the comment in
sessionless-task-switch.spec.ts to reference performEnvSwitch.

* fix: address env layout PR feedback

* test: fix flaky handshake auth check

* fix: address env scope review findings
2026-05-01 11:00:33 +01:00

190 lines
7.3 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 expect(session.idleInput()).toBeVisible({ 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();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
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();
});
});