import { test, expect } from "../../fixtures/test-base"; import { useRegularMode } from "../../helpers/regular-mode"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; import { expectTaskDescription } from "../../pages/task-description-editor"; import { seedIncompatibleAgentScenario, seedLockedWorkflow } from "./agent-compatibility-helpers"; // Exercises the regular task-create dialog (New Task in the sidebar), so run // with the office feature disabled. useRegularMode(); const START_AGENT_TEST_ID = "submit-start-agent"; const START_ENABLED_TIMEOUT = 30_000; const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; test.describe("Task creation", () => { test("hidden workflow task detail does not leak into standard task creation", async ({ testPage, apiClient, seedData, }) => { const hidden = await apiClient.e2eCreateHiddenWorkflow( seedData.workspaceId, "Hidden task-detail workflow", ); const hiddenStart = await apiClient.createWorkflowStep(hidden.id, "Improve", 0, { is_start_step: true, }); const sourceTask = await apiClient.createTask(seedData.workspaceId, "Hidden source task", { description: "Source task in the hidden workflow", workflow_id: hidden.id, workflow_step_id: hiddenStart.id, }); await testPage.goto(`/t/${sourceTask.id}`); await testPage.getByTestId("create-task-button").first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await dialog.getByTestId("task-title-input").fill("Visible workflow task"); await dialog.getByTestId("task-description-input").fill("Created from hidden task detail"); await expect(dialog.getByTestId(START_AGENT_TEST_ID)).toBeEnabled({ timeout: START_ENABLED_TIMEOUT, }); await dialog.getByTestId("submit-start-agent-chevron").click(); await testPage.getByTestId("submit-create-without-agent").click(); await expect .poll(() => getTaskIdFromPage(testPage), { timeout: 15_000 }) .not.toBe(sourceTask.id); const createdTaskId = await getTaskIdFromPage(testPage); const createdTask = await apiClient.getTask(createdTaskId); expect(createdTask.workflow_step_id).toBe(seedData.startStepId); }); test("selects the single visible workflow when hidden workflows are loaded", async ({ testPage, apiClient, seedData, }) => { await apiClient.e2eCreateHiddenWorkflow(seedData.workspaceId, "Hidden system workflow"); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: "", }); const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("workflow-selector-trigger")).toHaveCount(0); }); test("dialog pre-selects repository, branch, and agent profile from seed data", async ({ testPage, }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); // Fill title + description to trigger the "Start task" split button (showStartTask) await testPage.getByTestId("task-title-input").fill("Pre-select Test"); await testPage.getByTestId("task-description-input").fill("testing pre-selections"); // Wait for the submit button to be enabled — confirms all selections resolved const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); // Verify the pre-seeded selections are displayed in the dialog selectors. // The dialog uses chip-style repo/branch pills since the multi-repo refactor. await expect(testPage.getByTestId("repo-chip-trigger").first()).toContainText("E2E Repo"); await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main"); // Agent profile name varies — just verify it's not the empty placeholder await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText( "Select agent...", ); const repositorySelector = testPage.getByTestId("repo-chip-trigger").first(); await repositorySelector.click(); const repositoryListbox = testPage.getByRole("listbox"); const repositoryOptions = repositoryListbox.getByRole("option"); await expect(repositoryOptions.first()).toHaveAttribute("aria-selected", "true"); await expect(repositoryOptions.first()).toHaveClass(/bg-card/); await testPage.keyboard.press("Escape"); const agentSelector = testPage.getByTestId("agent-profile-selector"); await agentSelector.click(); const agentListbox = testPage.getByRole("listbox"); const agentOptions = agentListbox.getByRole("option"); await expect(agentOptions.first()).toHaveAttribute("aria-selected", "true"); await expect(agentOptions.first()).toHaveClass(/bg-card/); await testPage.keyboard.press("Escape"); const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await branchSelector.click(); const branchListbox = testPage.getByRole("listbox"); const branchOptions = branchListbox.getByRole("option"); await expect(branchOptions.first()).toHaveAttribute("aria-selected", "true"); await expect(branchOptions.first()).toHaveClass(/bg-card/); }); test("dialog remembers selections after creating a task", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); // First: create a task so its selections are persisted to user settings. await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await testPage.getByTestId("task-title-input").fill("First Task"); await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); await startBtn.click(); await expect(dialog).not.toBeVisible({ timeout: 10_000 }); // Second: open the dialog again and verify selections persist await kanban.createTaskButton.first().click(); await expect(dialog).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Second Task"); await testPage.getByTestId("task-description-input").fill("checking persistence"); const startBtn2 = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn2).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); // The same repo, branch, and agent profile should still be selected await expect(testPage.getByTestId("repo-chip-trigger").first()).toContainText("E2E Repo"); await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main"); await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText( "Select agent...", ); }); test("uses the remembered workflow instead of the board filter", async ({ testPage, apiClient, seedData, }) => { const devWorkflow = await apiClient.createWorkflow(seedData.workspaceId, "Dev", "simple"); const devSteps = await apiClient.listWorkflowSteps(devWorkflow.id); const devStartStep = devSteps.steps.find((step) => step.is_start_step) ?? devSteps.steps[0]; if (!devStartStep) throw new Error("Dev workflow has no start step"); try { await apiClient.createTask(seedData.workspaceId, "Remembered Dev task", { workflow_id: devWorkflow.id, workflow_step_id: devStartStep.id, }); const { settings } = await apiClient.getUserSettings(); expect( settings.task_create_last_used?.workflow_ids_by_workspace?.[seedData.workspaceId], ).toBe(devWorkflow.id); // Keep the board filtered to the seeded workflow while remembering a // different workflow for task creation. await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, }); const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev"); } finally { await apiClient.deleteWorkflow(devWorkflow.id).catch(() => {}); } }); test("shows the selected workflow launch prompt preview", async ({ testPage, apiClient, seedData, }) => { const workflow = await apiClient.createWorkflow( seedData.workspaceId, "Launch Preview Workflow", ); const configuredStart = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0, { is_start_step: true, }); const autoStart = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1); await apiClient.updateWorkflowStep(configuredStart.id, { events: {} }); await apiClient.updateWorkflowStep(autoStart.id, { prompt: "Launch instructions: {{task_prompt}} | {{task_prompt}} | {task_id} | @saved", events: { on_enter: [{ type: "auto_start_agent" }] }, }); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, task_create_last_used: { repository_id: seedData.repositoryId, branch: "main", agent_profile_id: seedData.agentProfileId, workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId }, }, }); try { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); const workflowSelector = dialog.getByTestId("workflow-selector-trigger"); await expect(workflowSelector).toContainText("E2E Workflow"); await workflowSelector.click(); await testPage .getByRole("button", { name: /^Launch Preview Workflow/ }) .last() .click(); await expect(workflowSelector).toContainText("Launch Preview Workflow"); await dialog.getByTestId("task-description-input").fill(""); const launchStep = dialog.getByTestId("task-create-launch-step"); await expect(launchStep).toHaveText("Backlog"); await expect(workflowSelector).not.toContainText("Start step:"); const selectorBox = await workflowSelector.boundingBox(); const launchStepBox = await launchStep.boundingBox(); const launchStepInfo = dialog.getByTestId("task-create-launch-step-info"); await expect(launchStepInfo).toHaveAttribute("aria-label", "Learn about the task start step"); const launchStepInfoBox = await launchStepInfo.boundingBox(); if (!selectorBox || !launchStepInfoBox || !launchStepBox) { throw new Error("launch step controls have no layout box"); } expect(launchStepInfoBox.x).toBeGreaterThanOrEqual(selectorBox.x + selectorBox.width - 1); expect(launchStepBox.x).toBeGreaterThanOrEqual( launchStepInfoBox.x + launchStepInfoBox.width - 1, ); await launchStepInfo.hover(); const launchStepTooltip = testPage.getByRole("tooltip", { name: "The task starts in this workflow step. With a task description, an auto-start step can take priority over the configured Start step.", }); await expect(launchStepTooltip).toBeVisible(); await testPage.mouse.move(0, 0); await expect(launchStepTooltip).toBeHidden(); await dialog.getByTestId("task-title-input").fill("Preview the launch prompt"); const description = "Review the launch preview"; await dialog.getByTestId("task-description-input").fill(description); await expect(launchStep).toHaveText("In Progress"); const toggle = dialog.getByTestId("task-create-launch-preview-toggle"); await expect(toggle).toHaveAttribute( "aria-label", "Preview launch prompt with workflow step prompt: In Progress", ); await expect(toggle).toHaveAttribute("aria-pressed", "false"); await toggle.hover(); await expect( testPage.getByRole("tooltip", { name: "Preview launch prompt with workflow step prompt: In Progress", }), ).toBeVisible(); await toggle.click(); await expect(dialog.getByTestId("task-create-launch-preview-content")).toContainText( `Launch instructions: ${description} | {{task_prompt}} | {task_id} | @saved`, ); await expect(dialog.getByTestId("task-description-input")).toHaveCount(0); await toggle.click(); await expectTaskDescription(dialog.getByTestId("task-description-input"), description); await expect(toggle).toHaveAttribute("aria-pressed", "false"); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(dialog).not.toBeVisible(); } finally { await apiClient.deleteWorkflow(workflow.id).catch(() => {}); } }); test("keeps remembered workflows isolated by workspace after reload", async ({ testPage, apiClient, seedData, }) => { let devWorkflowId = ""; let workspaceBId = ""; try { const devWorkflow = await apiClient.createWorkflow(seedData.workspaceId, "Dev", "simple"); devWorkflowId = devWorkflow.id; const devSteps = await apiClient.listWorkflowSteps(devWorkflow.id); const devStartStep = devSteps.steps.find((step) => step.is_start_step) ?? devSteps.steps[0]; if (!devStartStep) throw new Error("Dev workflow has no start step"); const workspaceB = await apiClient.createWorkspace("Workflow Memory B"); workspaceBId = workspaceB.id; const supportWorkflow = await apiClient.createWorkflow(workspaceB.id, "Support", "simple"); const otherWorkflow = await apiClient.createWorkflow(workspaceB.id, "Other", "simple"); const supportSteps = await apiClient.listWorkflowSteps(supportWorkflow.id); const supportStartStep = supportSteps.steps.find((step) => step.is_start_step) ?? supportSteps.steps[0]; if (!supportStartStep) throw new Error("Support workflow has no start step"); await apiClient.createTask(seedData.workspaceId, "Remembered Dev task", { workflow_id: devWorkflow.id, workflow_step_id: devStartStep.id, }); await apiClient.createTask(workspaceB.id, "Remembered Support task", { workflow_id: supportWorkflow.id, workflow_step_id: supportStartStep.id, }); const { settings } = await apiClient.getUserSettings(); expect(settings.task_create_last_used?.workflow_ids_by_workspace).toMatchObject({ [seedData.workspaceId]: devWorkflow.id, [workspaceB.id]: supportWorkflow.id, }); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, }); const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev"); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(dialog).not.toBeVisible(); await testPage.getByTestId("sidebar-workspace-trigger").click(); await testPage.getByTestId(`sidebar-workspace-item-${workspaceB.id}`).click(); await expect(testPage).toHaveURL( (url) => url.pathname === "/" && url.searchParams.get("workspaceId") === workspaceB.id, ); // Give workspace B a conflicting board filter so the remembered Support // workflow is the only source that can select it in the dialog. await apiClient.saveUserSettings({ workspace_id: workspaceB.id, workflow_filter_id: otherWorkflow.id, }); await testPage.reload(); await kanban.board.waitFor({ state: "visible" }); await kanban.createTaskButton.first().click(); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Support"); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(dialog).not.toBeVisible(); // Reload workspace A again to prove the two memories do not overwrite // each other when the active workspace changes. await testPage.getByTestId("sidebar-workspace-trigger").click(); await testPage.getByTestId(`sidebar-workspace-item-${seedData.workspaceId}`).click(); await expect(testPage).toHaveURL( (url) => url.pathname === "/" && url.searchParams.get("workspaceId") === seedData.workspaceId, ); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, }); await testPage.reload(); await kanban.board.waitFor({ state: "visible" }); await kanban.createTaskButton.first().click(); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev"); } finally { if (devWorkflowId) await apiClient.deleteWorkflow(devWorkflowId).catch(() => {}); if (workspaceBId) await apiClient.deleteWorkspace(workspaceBId, "Workflow Memory B").catch(() => {}); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, }); } }); test("opens create task dialog from kanban header", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); }); test("explains when a Docker executor has no compatible agent credentials", async ({ testPage, apiClient, seedData, }) => { const profile = await apiClient.getAgentProfile(seedData.agentProfileId); const { agents } = await apiClient.listAgents(); const agent = agents.find((item) => item.profiles?.some((p) => p.id === profile.id)); if (!agent) { throw new Error(`agent for profile ${profile.id} not found`); } const dockerExec = await apiClient.createExecutor("E2E Docker Empty Agent", "local_docker"); const dockerProfile = await apiClient.createExecutorProfile(dockerExec.id, { name: "Docker Missing Auth", config: { image_tag: "kandev-mock-agent:test", dockerfile: "FROM busybox\nWORKDIR /workspace\n", }, }); await testPage.route("**/api/v1/remote-credentials", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ auth_specs: [ { id: agent.name, display_name: profile.agentDisplayName, methods: [{ method_id: "test-token", type: "env", env_var: "TEST_TOKEN" }], }, ], }), }); }); try { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Docker Missing Auth Task"); await testPage.getByTestId("task-description-input").fill("testing docker auth empty state"); await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: /Docker Missing Auth/i }).click(); await expect(testPage.getByTestId("agent-profile-empty-state")).toContainText( "No compatible agent profiles", ); await expect(testPage.getByRole("link", { name: "Configure credentials" })).toHaveAttribute( "href", `/settings/executors/${dockerProfile.id}`, ); await expect(dialog).toContainText( "A Docker container will be created from the selected base branch and checked out on a task branch.", ); await expect(testPage.getByTestId(START_AGENT_TEST_ID)).toBeDisabled(); } finally { await apiClient.deleteExecutor(dockerExec.id).catch(() => {}); } }); test("ignores a stale browser profile and selects a valid backend profile", async ({ testPage, }) => { // A stale value from an older browser build must not participate in // selection. The dialog uses backend settings and then falls back through // the workspace default / first-profile chain. await testPage.addInitScript(() => { localStorage.setItem( "kandev.dialog.lastAgentProfileId", JSON.stringify("00000000-0000-0000-0000-000000000000"), ); }); const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Stale LS Task"); await testPage.getByTestId("task-description-input").fill("stale localStorage repro"); // The bug surfaces as the "No compatible agent profiles" empty state. await expect(testPage.getByTestId("agent-profile-empty-state")).toHaveCount(0); // Auto-select must resolve to a real profile, and the start button must // become enabled - these are the user-visible symptoms of the fix. await expect(testPage.getByTestId(START_AGENT_TEST_ID)).toBeEnabled({ timeout: START_ENABLED_TIMEOUT, }); await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText("Select agent"); }); test("can fill in task title and description", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); const titleInput = testPage.getByTestId("task-title-input"); await titleInput.fill("My E2E Test Task"); await expect(titleInput).toHaveValue("My E2E Test Task"); const descInput = testPage.getByTestId("task-description-input"); await descInput.fill("This is a test description"); await expectTaskDescription(descInput, "This is a test description"); }); test("start agent: creates task, starts session, navigates to session", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); // Fill in title and description — description enables the "Start task" button await testPage.getByTestId("task-title-input").fill("Start Agent Task"); await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); // The dialog auto-selects the E2E Repo (first available repository) and the main branch. // Wait for the button to become enabled (repo + branch + agent profile all resolved). // Under load the branch/profile resolution can take a moment. const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); // Click "Start task" — the agent starts, the dialog closes, and sidebar creation // focuses the newly created task immediately. await startBtn.click(); await expect(dialog).not.toBeVisible({ timeout: 10_000 }); await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); await session.waitForLoad(); // Default layout: sidebar, terminal, and file tree are all visible await expect(session.sidebar).toBeVisible(); await expect(session.terminal).toBeVisible(); await expect(session.files).toBeVisible(); await expect(session.chat).toBeVisible(); // Task title appears in the sidebar await expect(session.taskInSidebar("Start Agent Task")).toBeVisible(); // The mock agent's simple-message scenario emits this response text — // waiting for it confirms the agent ran and completed its turn. await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ timeout: 30_000, }); // The first user message (task description) is visible in the chat await expect(session.chat.getByText("/e2e:simple-message")).toBeVisible(); // Session transitions to idle — input placeholder changes from "Queue instructions..." await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); // Sidebar shows the task under "Turn Finished" section — this uses kanban tasks which update // via task.updated WS event. Check this first to give more time for the stepper data // (session.state_changed) to propagate through the store. await expect(session.sidebarSection("Turn Finished")).toBeVisible({ timeout: 15_000 }); // After the agent completes, the workflow step transitions from "In Progress" to "Review". // The step update arrives via WS (task.updated and session.state_changed events) which may // take a moment to propagate through the store. await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { timeout: 15_000, }); }); test("plan mode with MCP tool: creates plan via create_task_plan", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); // Multi-line script: thinking → MCP create plan → text message const script = [ 'e2e:thinking("Analyzing task and creating plan...")', "e2e:delay(100)", 'e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"## Plan\\n\\n1. Analyze requirements\\n2. Implement solution\\n3. Write tests","title":"Implementation Plan"})', "e2e:delay(100)", 'e2e:message("I\'ve created an implementation plan for this task.")', ].join("\n"); await testPage.getByTestId("task-title-input").fill("Plan MCP Task"); await testPage.getByTestId("task-description-input").fill(script); const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); await testPage.getByTestId("submit-start-agent-chevron").click(); await expect(testPage.getByTestId("submit-plan-mode")).toBeVisible({ timeout: 5_000 }); await testPage.getByTestId("submit-plan-mode").click(); await expect(dialog).not.toBeVisible({ timeout: 10_000 }); await expect(testPage).toHaveURL(/\/t\/.*layout=plan/, { timeout: 15_000 }); const session = new SessionPage(testPage); await session.waitForLoad(); // Plan panel visible (plan layout) await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); // The first user message (script content) is visible in the chat with plan mode badge await expect(session.chat.getByText("e2e:thinking", { exact: false })).toBeVisible({ timeout: 15_000, }); await expect(session.planModeBadge()).toBeVisible(); // Agent completion text visible in chat (use .last() because the user message // paragraph also contains this text inside the e2e:message(...) script line) await expect( session.chat.getByText("I've created an implementation plan for this task.").last(), ).toBeVisible({ timeout: 30_000 }); // Plan content appears in plan panel (created via real MCP call to create_task_plan) await expect(session.planPanel.getByText("Analyze requirements", { exact: false })).toBeVisible( { timeout: 15_000, }, ); await expect(session.planPanel.getByText("Implement solution", { exact: false })).toBeVisible(); await expect(session.planPanel.getByText("Write tests", { exact: false })).toBeVisible(); // Session transitions to idle — plan mode input placeholder visible await expect(session.planModeInput()).toBeVisible({ timeout: 15_000 }); // After the agent completes, the workflow step transitions to "Review" await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { timeout: 10_000, }); // Sidebar shows the task under "Turn Finished" section await expect(session.sidebarSection("Turn Finished")).toBeVisible(); }); test("start task in plan mode: opens session with plan panel visible", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Plan Mode Task"); await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); // Wait for the main submit button to be enabled (repo + agent profile resolved), // then open the dropdown chevron to reveal the "Start task in plan mode" option. const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); // The split-button group wraps "Start task" + a chevron-only dropdown trigger. // Click the chevron to open the dropdown. await testPage.getByTestId("submit-start-agent-chevron").click(); const planModeBtn = testPage.getByTestId("submit-plan-mode"); await expect(planModeBtn).toBeVisible({ timeout: 5_000 }); await planModeBtn.click(); await expect(dialog).not.toBeVisible({ timeout: 10_000 }); // activatePlanMode navigates to /t/?layout=plan which applies the plan preset await expect(testPage).toHaveURL(/\/t\/.*layout=plan/, { timeout: 15_000 }); const session = new SessionPage(testPage); await session.waitForLoad(); // Plan panel is visible — the layout preset shows it instead of the default file tree await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); // Chat is still accessible in plan mode await expect(session.chat).toBeVisible(); // Wait for the agent to complete so messages are loaded await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ timeout: 30_000, }); // The first user message (task description) is visible with plan mode badge await expect(session.chat.getByText("/e2e:simple-message")).toBeVisible(); await expect(session.planModeBadge()).toBeVisible(); // Session transitions to idle — plan mode input placeholder visible await expect(session.planModeInput()).toBeVisible({ timeout: 15_000 }); // After the agent completes, the workflow step transitions to "Review" await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { timeout: 10_000, }); // Sidebar shows the task under "Turn Finished" section await expect(session.sidebarSection("Turn Finished")).toBeVisible(); }); }); /** * Regression tests for the create-task flow after TaskEnvironment model changes. * Ensures that task creation, agent launch, and session navigation still work * correctly with the new per-task environment layer. */ test.describe("Create task regression", () => { test("create and start agent: session completes with task environment created", async ({ testPage, apiClient, }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Regression Task"); await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); await startBtn.click(); await expect(dialog).not.toBeVisible({ timeout: 10_000 }); await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); await session.waitForLoad(); // Wait for mock agent to complete its turn await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ timeout: 30_000, }); await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); // Verify task has exactly one session const taskId = await getTaskIdFromPage(testPage); const { sessions } = await apiClient.listTaskSessions(taskId); expect(sessions.length).toBe(1); expect(DONE_STATES).toContain(sessions[0].state); // Verify task environment was created const env = await apiClient.getTaskEnvironment(taskId); expect(env).not.toBeNull(); expect(env!.task_id).toBe(taskId); expect(env!.status).toBe("ready"); }); test("create task via API: session and environment link correctly", async ({ apiClient, seedData, }) => { // Create task with agent via REST API (bypasses UI) const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "API Regression Task", seedData.agentProfileId, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); // Wait for agent to finish (poll session state) 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); // Verify task environment was created and linked const env = await apiClient.getTaskEnvironment(task.id); expect(env).not.toBeNull(); expect(env!.task_id).toBe(task.id); // Verify session has task_environment_id set const { sessions } = await apiClient.listTaskSessions(task.id); expect(sessions[0].task_environment_id).toBe(env!.id); }); }); /** Extract task ID from the current page URL (/t/). */ async function getTaskIdFromPage(page: import("@playwright/test").Page): Promise { const url = page.url(); const match = url.match(/\/t\/([^/?]+)/); if (!match) throw new Error(`Cannot extract task ID from URL: ${url}`); return match[1]; } test.describe("Task creation agent compatibility", () => { // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.1 // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.2 // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.6 test("replaces the selected agent with a compatible one when the executor changes", async ({ testPage, apiClient, backend, seedData, }) => { await backend.restart({ KANDEV_MOCK_PROVIDERS: "codex-acp" }); const scenario = await seedIncompatibleAgentScenario( apiClient, testPage, seedData.agentProfileId, { executor: "E2E Docker Replace Agent", dockerProfile: "Docker Codex Only", compatibleProfile: "Codex Compatible", }, ); try { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await dialog.getByTestId("task-title-input").fill("Replace incompatible agent"); await dialog.getByTestId("task-description-input").fill("executor switch after agent pick"); // Make sure the seeded agent is the selection while the default executor // still accepts it. The dialog usually restores it on its own; clicking // an already-selected option would toggle it off, so only pick it when // something else is selected. const agentSelector = dialog.getByTestId("agent-profile-selector"); if (!(await agentSelector.textContent())?.includes(scenario.seedProfileName)) { await agentSelector.click(); await testPage .getByRole("listbox") .getByRole("option") .filter({ hasText: scenario.seedProfileName }) .filter({ hasNotText: scenario.secondAgentDisplayName }) .first() .click(); } await expect(agentSelector).toContainText(scenario.seedProfileName); await expect(agentSelector).not.toContainText(scenario.secondAgentDisplayName); await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: /Docker Codex Only/i }).click(); // The mocked alias also ships a default profile, so the replacement can // be either Codex profile; what matters is that it is no longer the seed. await expect(agentSelector).toContainText(scenario.secondAgentDisplayName); await expect(agentSelector).not.toContainText(scenario.seedProfileName); await expect(dialog.getByTestId("agent-profile-empty-state")).toHaveCount(0); await expect(dialog.getByTestId("agent-profile-incompatible-note")).toHaveCount(0); await expect(dialog.getByTestId(START_AGENT_TEST_ID)).toBeEnabled({ timeout: START_ENABLED_TIMEOUT, }); } finally { await scenario.cleanup(); await backend.restart(); } }); // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.5 // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.6 // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.7 test("names the workflow-locked agent when the executor lacks its credentials", async ({ testPage, apiClient, backend, seedData, }) => { await backend.restart({ KANDEV_MOCK_PROVIDERS: "codex-acp" }); const scenario = await seedIncompatibleAgentScenario( apiClient, testPage, seedData.agentProfileId, { executor: "E2E Docker Locked Agent", dockerProfile: "Docker Locked Auth", compatibleProfile: "Codex Unlocked", }, ); const workflow = await seedLockedWorkflow( apiClient, seedData.workspaceId, seedData.workflowId, "Locked Agent", seedData.agentProfileId, ); try { const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Locked Agent"); await dialog.getByTestId("task-title-input").fill("Locked agent task"); await dialog.getByTestId("task-description-input").fill("workflow-locked incompatible agent"); await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: /Docker Locked Auth/i }).click(); const note = dialog.getByTestId("agent-profile-incompatible-note"); await expect(note).toContainText("Locked Agent"); await expect(note).toContainText(scenario.seedProfileName); await expect(note).toContainText("Docker Locked Auth"); await expect(dialog.getByRole("link", { name: "Configure credentials" })).toHaveAttribute( "href", `/settings/executors/${scenario.dockerProfileId}`, ); await expect(dialog.getByTestId("agent-profile-empty-state")).toHaveCount(0); await expect(dialog.getByTestId(START_AGENT_TEST_ID)).toBeDisabled(); } finally { await workflow.cleanup(); await scenario.cleanup(); await backend.restart(); } }); });