45ee2ff48f
* 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
190 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|