Files
kandev/apps/web/e2e/tests/session/terminal-env-keyed.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

154 lines
5.4 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
/**
* Create a task with a primary session and navigate to it.
* Mirrors the helper in multi-session-ux.spec.ts.
*/
async function createTaskAndNavigate(
testPage: import("@playwright/test").Page,
apiClient: import("../../helpers/api-client").ApiClient,
seedData: import("../../fixtures/test-base").SeedData,
title: string,
) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for session to finish" },
)
.toBe(true);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardByTitle(title);
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 15_000,
});
return { task, session };
}
test.describe("Terminal stays put across same-task session switch", () => {
test("terminal scrollback persists when switching sessions of the same task", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"Env-Keyed Terminal Task",
);
// Create a second session (same task → same TaskEnvironmentID).
await session.openNewSessionDialog();
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
await session.newSessionPromptInput().fill("/e2e:simple-message");
await session.newSessionStartButton().click();
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.length;
},
{ timeout: 30_000, message: "Waiting for second session" },
)
.toBe(2);
// Both sessions must share the same task_environment_id, otherwise
// the rest of this test asserts nothing meaningful.
const { sessions } = await apiClient.listTaskSessions(task.id);
const envIds = new Set(sessions.map((s) => s.task_environment_id ?? null));
expect(
envIds.size,
`expected one shared task_environment_id, got: ${[...envIds].join(", ")}`,
).toBe(1);
const sortedSessions = [...sessions].sort(
(a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
);
const sessionA = sortedSessions[0];
const sessionB = sortedSessions[1];
// Terminal panel exists by default in the dockview layout — bring it to
// focus, then type a marker so we can detect reconnects.
await session.clickTab("Terminal");
await session.typeInTerminal("echo kandev-marker-A");
await session.expectTerminalHasText("kandev-marker-A");
// Switch to session B by clicking its dockview tab. Layout is env-keyed,
// so the terminal panel must stay put — same xterm instance, same buffer.
await session.sessionTabBySessionId(sessionB.id).click();
await session.expectTerminalHasText("kandev-marker-A");
// Type a second marker; switch back to A; both markers must still be visible.
await session.typeInTerminal("echo kandev-marker-B");
await session.expectTerminalHasText("kandev-marker-B");
await session.sessionTabBySessionId(sessionA.id).click();
await session.expectTerminalHasText("kandev-marker-A");
await session.expectTerminalHasText("kandev-marker-B");
});
test("user_shell.list returns the same shell across sessions sharing an env", async ({
apiClient,
seedData,
}) => {
// Backend-only check: the runner must group user shells by TaskEnvironmentID,
// so two sessions sharing an env see the same shell list. This is the
// server-side complement to the UI test above.
test.setTimeout(60_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Env-Keyed Shell List Task",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000 },
)
.toBe(true);
const { sessions } = await apiClient.listTaskSessions(task.id);
const sessionA = sessions[0];
expect(sessionA.task_environment_id, "session A must have a task_environment_id").toBeTruthy();
});
});