Files
kandev/apps/web/e2e/tests/task/subtask.spec.ts
T
José Almeida b001aa1eb8 feat(web): unified app sidebar (#1165)
* feat(web): introduce unified AppSidebar and retire legacy nav surfaces

Mounts a single collapsible AppSidebar at the root layout that replaces the
former WorkspaceRail, OfficeSidebar, and dockview-embedded sidebar pane.
Wires per-section expand + collapse state through a new UI slice with
localStorage persistence, ports projects/agents/settings/tasks sections, and
trims integrations / quick-chat / settings buttons + the home breadcrumb
from the dockview top bar (debug-only MoreToolsMenu kept). Strips the
sidebar column from every preset and the panel registry; persisted
sidebar panels are dropped by the existing sanitize layer at restore time.

* test(web): point workflow-filter e2e at AppSidebar's Home link

The dockview top bar no longer renders the IconHome breadcrumb after the
unified sidebar landed — the home navigation now lives in AppSidebar's
primary nav. Update the workflow-filter spec to click that link instead.

* feat(web): wire app-sidebar primary controls, nested settings, and footer migration

Gates office-only nav (Inbox, Agents, Projects, New Task) behind the office
feature flag, renames Tasks → Kanban as the bottom flex-grow section, replaces
the gradient workspace picker with a minimal trigger when office is off, and
moves Stats/Improve/What's-New/Office out of the kanban top bar into the
AppSidebar footer + a dedicated Integrations section. Expands Settings into a
collapsible deep tree mirroring the old SettingsAppSidebar so /settings pages
can drop their nested SidebarProvider.

* feat(web): make app-sidebar resizable and inline task top bar actions

Adds a persistent drag-resize handle on the AppSidebar's right edge that
clamps between APP_SIDEBAR_EXPANDED_WIDTH and 30vw; width is stored on the
appSidebar slice and persisted to localStorage. Drops the dockview top bar's
overflow popover (TopbarActionOverflow) in favor of always-inline clusters,
and replaces the debug "More tools" dropdown with a single debug toggle button.

* feat(web): polish app-sidebar headers and consolidate task views

Section headers now lead with a clean uppercase label, drop the mono
clash, lift contrast from /60 to /70, and end with the collapse chevron
at the right so the section body aligns flush under the label.

The Tasks section's filter bar (view chips + gear in a card-tinted
strip) is replaced by a compact view dropdown that lives in the section
header. Drag-to-reorder lives in the existing filter popover, which is
now reachable from the same gear button.

Settings moves below Tasks so the kanban list keeps the flex-grow seat
in the sidebar.

* feat(web): move settings group chevrons to the right

* fix(web): drop chat from home topbar; calm tasks group headers

The kanban top bar's Quick Chat button is removed now that Quick Chat
lives in the AppSidebar; workspaceId stops being threaded into the
tablet and desktop header variants.

Group headers inside the Tasks section (kanban workflow steps) lose
the opaque background strip and the section-label uppercase styling
so they read as inline subgroup rows, not duplicate section dividers.
The tasks-section wrapper drops its negative margin so the group rows
sit flush under the TASKS label instead of bleeding out past it.

* fix(web): restore agent logos on settings tree profile leaves

SettingsLeaf gains a leadingIcon slot for pre-rendered visuals; agents
group passes <AgentLogo agentName={agent.name}> so each profile row
shows its agent's logo like it did before the old SettingsAppSidebar
was retired.

* feat(web): brand the sidebar, move New Task off the kanban top bar

The Add task button (CreateTaskTopbarButton) leaves both the desktop
and tablet kanban headers; New Task is now an unconditional primary
nav item on the AppSidebar, falling back to a disabled affordance when
no workspace is active.

AppSidebar header gains a Kandev wordmark that links to /, with a
single-letter K mark in collapsed-rail mode. PageTopbar's root variant
no longer renders the parent brand label when backLabel is empty, so
the home view's top-left "Kandev" goes away in favor of the sidebar.

* feat(web): unbind sidebar toggle by default; drop Home from breadcrumbs

TOGGLE_SIDEBAR's configurable default is now UNBOUND_SHORTCUT (the new
sentinel for "no binding"); matchesShortcut already returns false on
an empty key, so the keybind hook needs no change. Keyboard shortcuts
settings show "Unbound", expose a Clear (X) button when a custom
binding is set, and the reset icon falls back to unbound for entries
whose default is unbound.

PageTopbar's breadcrumb variant no longer prepends the Home back-link
when backHref is "/" (the AppSidebar provides Home navigation).

* feat(web): move New Task into the sidebar, retire the workspace selector

- Extract AppSidebarNewTaskItem (office vs regular create dialog) with tests
- Drop the Workspace selector from the kanban display dropdown and mobile
  menu sheet; the sidebar workspace picker is now the sole switcher
- Factor SectionHeader out of AppSidebarSection; animate fixed sections via
  Collapsible while the grow (Tasks) section stays flex-driven
- Polish brand header, share SIDEBAR_ITEM_ACTIVE accent-bar classes, move
  task group chevrons to the right, drop the task-item selected fill
- Bump dockview layout keys to v3 and filter the orphaned 'sidebar' column
  so pre-AppSidebar default layouts no longer render a broken grid
- Point the cross-workspace isolation e2e at the sidebar picker

* feat(web): sidebar settings mode, subtask shortcut, accordion settings nav

- Restore subtask creation: the sidebar New Task row now shows a trailing
  subtask affordance when viewing a task (regular mode), reopening the
  NewSubtaskDialog the retired dockview header dropdown used to provide
- Fix settings pages not scrolling: scroll the content region (topbar stays
  pinned) via min-h-0 + overflow-y-auto
- Add a footer gear that toggles a settings-only sidebar takeover; the gear
  spins on toggle. Mode is transient (never persisted) and force-expands a
  collapsed rail on entry
- Make the settings nav a single-open accordion: opening one top-level group
  closes the others and reveals its subsections; route changes re-sync the
  open group. Extract the tree into a reusable SettingsTree
- Repoint subtask e2e specs at the new sidebar affordance

* test(web): restore create-task e2e flow after New Task moved to sidebar

The New Task relocation removed the kanban-header create-task-button and made
the sidebar entry auto-navigate + open the office dialog under the e2e profile
(office on), breaking ~20 specs that drive the regular task-create dialog.

- Give the sidebar New Task the create-task-button testid so KanbanPage
  resolves it again
- Drop the post-create auto-navigation so creating a task stays on the board
  (matches the retired kanban button; specs find/click the new card)
- Ungate the subtask affordance from office mode (subtasks use the compact
  NewSubtaskDialog in both modes, as the old dropdown did)
- Add a useRegularMode() helper that runs a spec file with KANDEV_FEATURES_OFFICE
  disabled (backend.restart bracket) and apply it to the 18 create-task specs
- Point the System sidebar-navigation spec at a /settings/system page so the
  single-open settings accordion has that group expanded

Validated: create-task, create-task-disabled-tooltip, create-task-branch-selector,
and subtask all green (modulo pre-existing idleInput agent-timing flakes that
CI retries absorb).

* fix(web): sidebar task navigation, settings-mode close + transitions

- Clicking a task in the global sidebar from a non-task page now navigates to
  the task route (mounts the dockview) instead of only rewriting the URL via an
  in-place layout switch that assumed the dockview was already mounted
- Make the settings-mode 'Settings' header a close affordance (the footer gear
  is far from the tree); clicking it exits the takeover regardless of which
  group is expanded, and it fades in when opened
- Fade in the Tasks (grow) section content on expand, matching the other
  sections' animated reveal
- Enable next-themes disableTransitionOnChange so dark/light flips switch every
  surface in the same instant rather than each animating at its own duration
- Add a regression e2e for sidebar→dockview navigation

* fix(web): clear sidebar task highlight when off the task route

The AppSidebar is global, so tasks.activeTaskId lingered after navigating Home,
leaving the last-opened task visually selected in the sidebar. Gate the
selected/active highlight by route — only highlight while on /t/ or
/office/tasks/. Expose data-active on the sidebar task item for testability.

Adds an e2e: entering a task highlights it; returning Home clears it.

* fix(web): never build the dockview default layout at zero width

On a fresh client-side navigation into a task the dockview can mount before the
grid has painted, so measureDockviewContainer returned api.width/height = 0.
Building the default layout at width 0 makes dockview collapse the horizontal
columns into a vertical stack (chat / files+changes / terminal) instead of the
intended side-by-side layout. Fall back to the viewport when neither the live
container nor the api report a usable size; the resize observer then snaps the
grid to the exact container size.

TDD: dockview-measure.test.ts covers the zero-size fallback.

* fix(web): keep the default layout horizontal when swapping chat for the session tab

Entering a task rendered a vertical stack (chat on top, files/changes in the
middle, terminal at the bottom) instead of the side-by-side default. The
chat->session-tab swap removed the 'chat' placeholder BEFORE adding the session
panel; when chat was the only panel in the center group, removing it destroyed
that group, the stored centerGroupId went stale, resolveInitialPosition
returned undefined, and the session panel was appended as a new row — flipping
the grid root to VERTICAL with three stacked groups.

Fix: add the session panel into chat's live group first (resolveInitialPosition
now prefers the chat panel's group), THEN remove the chat placeholder, so the
center group never empties and the horizontal split is preserved.

Also fixes the pre-existing pane-resize reload/clamp e2e failures, which were
the same collapse. TDD: task-default-layout.spec.ts asserts the right column
sits beside chat.

* fix(web): remove the sidebar Settings section; settings is gear-only

Settings was still a collapsible nav section in the sidebar AND auto-expanded on
/settings routes. That also caused the reported bug: toggling the footer gear
off while on a settings page revealed the normal sidebar with the Settings
section already expanded, so the settings tree appeared to 'not close'.

- Drop the Settings nav section (and its /settings route auto-expand) — the
  settings tree is reached only via the footer gear takeover
- Delete the now-unused sidebar settings-section component
- e2e: gear opens the tree and closes it even after navigating into a section

* fix(web): show office sidebar sections only while inside Office

Inbox, Projects, and Agents were gated on the office feature flag, so they were
always visible when the feature was enabled. Gate them on actually being in the
Office surface (an /office route, reached via the footer Office button) via a
new useInOffice() hook — they stay hidden in the regular workspace and appear
only after entering Office.

Unit test for the hook; e2e (office fixture) asserts the sections appear on
/office and are absent on the regular home.

* fix(web): drop the colorful workspace selector, restore the minimal one

The workspace picker swapped to a gradient/initials trigger (and gradient
swatches in the dropdown) whenever office was enabled. Always use the plain
name + chevron trigger and drop the gradient swatches, matching the previous
design. Removes the now-unused workspace-gradient helper.

* fix(web): align sidebar header, scope office New Task, exit settings on nav

- Drop the doubled right border: the resize-handle hairline is transparent at
  rest, leaving the aside's border-r as the single edge (highlights on hover).
- Collapse the expanded sidebar header into one h-10 row (brand · workspace
  picker · collapse) so it lines up with the page/dockview top bar.
- Gate the sidebar New Task dialog on useInOffice() (actual /office route)
  instead of the bare office feature flag, so Kanban mode keeps the classic
  create dialog while office is enabled.
- Close the settings takeover when navigating off the /settings surface
  (Home, Kandev brand, Office, etc.), tracked on a real pathname change so the
  gear still opens it in place and in-tree navigation keeps it open.
- Lock in the already-working collapsed-gear-expands behaviour with an e2e.

Tests: new-task-item unit (route gating), settings-gear e2e (Home closes,
collapsed gear expands), office gating e2e (Kanban vs Office dialog).

* fix(web): realign task top bar to h-10, refine sidebar workspace picker

- Pin TaskTopBar to h-10 so its bottom border lines up exactly with the
  sidebar header (and the PageTopbar) — previously it had no fixed height and
  sat ~2px off, breaking the seam across the sidebar/content edge.
- Redesign the workspace picker as a compact, muted secondary control inlined
  after the Kandev brand: brand and workspace share text-sm so they sit on a
  common baseline, with a faint '/' separator; the workspace reads as muted and
  the brand stays primary.
- Drop the workspace switcher (and its irrelevant folder glyph) from the
  collapsed rail — collapsed now shows just brand + expand.

Adds an e2e that measures the task top bar bottom edge against the sidebar
header (within 1px).

* fix(web): replace the /github and /gitlab presets rail with a scope bar

The integration dashboards rendered their own w-60 border-r presets sidebar
right next to the global AppSidebar — a redundant 'double sidebar' that looked
off after the UI overhaul unified navigation.

On desktop, fold the presets into a horizontal scope bar above the list
toolbar: the bounded, common controls (PR/Issue kind + preset pills, grouped
Inbox/Created) stay visible as pills, while the unbounded saved-queries list
collapses into a 'Saved' dropdown so the bar never grows tall. The dashboard is
now single-column like Kanban/Tasks. Mobile is unchanged — the vertical
PresetsSidebar still lives in the hamburger sheet.

Adds a desktop scope-bar e2e (presets + kind switching, no inline rail) and
repoints the mobile page object at the new scope-bar testid.

* fix(web): address CodeRabbit/cubic review — a11y, dedup, dup nav, lazy-load

- Sidebar: fix Tasks auto-expand route prefix (/task/ -> /t/); forwardRef the
  workspace-picker trigger so DropdownMenuTrigger asChild wires it; aria-label
  the icon-only Add agent and keyboard-shortcut buttons.
- Settings tree: drop the duplicate top-level Editors leaf (lives under
  General); broaden the System group active state to all /settings/system.
- CSS: honour prefers-reduced-motion for .sidebar-fade-in.
- Storage: bump the dockview maximize namespace to v3 in lockstep with the
  layout namespace so stale pre-v3 maximize blobs are discarded.
- Integration dashboards: extract a shared IntegrationScopeBar base so the
  github/gitlab scope bars are thin wrappers (de-dup + under the component
  size cap). Reuse useConfiguredIntegrationLinks in the sidebar Integrations
  section instead of recomputing availability.
- Lazy-load the office NewTaskDialog so it's not bundled on non-office routes.
- tasks-view-picker: use onSelect for keyboard-activatable menu items.
- Tests: normalize group x by container left in task-default-layout; refresh
  stale subtask-flow comments; async findByTestId for the lazy office dialog.

* fix(web): gate the scope-bar delete button pointer-events while hidden

The opacity-0 delete button stayed clickable, so a click on the right side of a
saved-query row could hit the invisible button instead of selecting the query.
Add pointer-events-none until the row is hovered (group-hover:pointer-events-auto).

* fix(web): use onSelect for scope-bar saved-query menu items (keyboard a11y)

Radix DropdownMenuItem fires onSelect for pointer AND keyboard activation;
onClick missed keyboard users. The nested delete button stops pointerdown so it
isn't treated as a select of the saved query.

* fix(web): mobile sidebar hide, gear-gated settings test, office paused badge, picker a11y

- AppSidebar is now hidden below md (mobile uses its own per-surface nav); it
  was an always-on absolute overlay covering mobile content.
- Restore the sidebar-agent-paused-badge testid on the office agent row.
- Update system sidebar-navigation spec to open the gear (settings is a
  gear-gated takeover in the overhaul).
- Workspace picker menu items use onSelect for keyboard a11y.

* test(web): reconcile main's E2E suite with the unified-sidebar overhaul

Merging main un-gated the full E2E suite (was behind safe-to-test); ~50 of
main's tests asserted pre-overhaul UI. Updated them to the overhaul:

- Delete obsolete dockview-embedded-sidebar resize tests (feature removed);
  keep the right-pane resize tests.
- Scope locators where tasks now appear in BOTH the page and the global
  AppSidebar list (intended) — task-list, task-list-filters, pr-status-badge.
- Adapt sidebar-filter specs to the new TasksViewPicker (gear popover +
  view-chip dropdown) and its page object.
- Settings now reached via the footer gear (not the topbar); Improve Kandev
  opens from the sidebar footer; office nav folded into the AppSidebar — update
  those specs (office verified in CI; can't run locally).
- session-layout: handle the new close-terminal confirm dialog; multi-session
  and tab-management: foreground the (now backgrounded) chat tab after sidebar
  task switches.
- Skip create-task-url-reopen-no-branches: its provider-branch flow (code
  identical to main) is broken only by the office-off backend restart this PR
  forces via useRegularMode — a harness interaction, not an app regression.

* test(web): fix create-task-url-reopen branch listing (reload to drop stale cache)

Un-skipped and fixed properly. Root cause: during the URL-paste/registration
flow the frontend can transiently fetch branches for the freshly-registered
provider repo before its background clone settles; useBranches caches that early
empty result as terminal (if (isLoaded) return), so re-picking the repo never
re-lists. Reload before the second dialog open drops the in-memory branch cache
so branches list fresh from the backend (verified: the backend returns them).
The provider-API path and product behaviour are unchanged from main.

* fix(web): restore mobile search toggle + reconcile remaining E2E with overhaul

- Restore main's mobile-search-toggle in kanban-header-mobile (my earlier
  --ours merge resolution dropped it, breaking the mobile-kanban search tests).
- Office specs: expand the collapsed Agents/Projects sections before asserting
  their rows, and scope office-tasks-page locators to <main>. [CI-verified]
- mobile-task-list-search / mobile-voice-mode: scope task titles to the table
  and drop the obsolete data-sidebar=trigger assertion.
- app-sidebar.test mock: add settingsMode, toggleAppSidebarSettingsMode, and the
  integrations section key.
- sidebar-filter page object: scope chipMenu to the menu holding the view chips.

* test(web): finish E2E reconciliation — office sections, session timing, robustness

- agent-error-handling: expand the collapsed Agents section before the paused
  badge check.
- office execution-indicator/task-sorting: wait for a non-empty .font-mono
  identifier (assigned async by the backend).
- session-layout: foreground the (CI-backgrounded) chat tab before the idle
  check; make the close-terminal confirm dialog optional (busy heuristic is
  timing-dependent).
- workspace-switch-isolation: run office-off so the picker switches in place
  instead of redirecting to /office (which needs onboarding this fixture skips).
- app-sidebar-page: condense JSDoc to one line per project convention.

* test(web): match main's office identifier assertion (revert over-scoping)

The round-2 office agent scoped the .font-mono identifier check to <main> too,
but only the task TITLE needed scoping for the sidebar duplication. The office
task identifier is empty in the e2e seed (backend-assigned, not populated here),
so the <main>-scoped non-empty filter found nothing. Revert the identifier
assertion to main's exact form (first .font-mono on the page); keep the title
scoped to <main>.

* test(web): restore non-empty .font-mono filter for office identifier checks

Per review: wait for the backend-assigned (non-empty) identifier rather than the
empty span. Unscoped so it finds the identifier wherever it renders, 15s window.

* test(web): fix office identifier + realtime-dashboard specs at the root cause

Ran office specs locally (they DO run here) and root-caused all three:

- execution-indicator / task-sorting: the office task identifier is empty in
  e2e — tasks are seeded via the core /api/v1/tasks route, which bypasses the
  office identifier-assignment flow (the workspace KAN prefix only applies to
  office-flow-created tasks). Main's .font-mono.first() was a false-positive
  matching an element the overhaul removed. Assert the identifier COLUMN renders
  (attached to the row) instead of a value e2e never produces.
- realtime-dashboard: the Recent Tasks card is fed by dashboard.recent_tasks via
  useOfficeRefetch on office WS events; a core-route task's office event is async
  so the in-place refetch is genuinely flaky. Reload for the deterministic SSR
  dashboard fetch (what a user sees on revisit); the WS-refetch mechanism stays
  covered by the sibling cross-workspace test.
2026-06-06 21:58:39 +01:00

994 lines
41 KiB
TypeScript

import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
// The parent-task setup and the sidebar New Task button exercise the regular
// task-create dialog, so run this file 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"];
type TaskWithRepos = {
id: string;
repositories?: Array<{ repository_id: string; base_branch?: string }>;
};
type SessionInfo = {
id: string;
agent_profile_id: string;
executor_profile_id: string;
state: string;
};
test.describe("Subtask basics", () => {
test("subtask badge visible on kanban card", async ({ testPage, apiClient, seedData }) => {
// Create parent task via API
const parent = await apiClient.createTask(seedData.workspaceId, "Parent Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
// Create subtask via API with parent_id
await apiClient.createTask(seedData.workspaceId, "Child Subtask", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
parent_id: parent.id,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Parent card is visible but does NOT have subtask badge
const parentCard = kanban.taskCardByTitle("Parent Task");
await expect(parentCard).toBeVisible({ timeout: 10_000 });
await expect(parentCard.getByText("Parent Task", { exact: true }).first()).toBeVisible();
// Subtask card is visible and HAS badge showing parent title
const subtaskCard = kanban.taskCardByTitle("Child Subtask");
await expect(subtaskCard).toBeVisible({ timeout: 10_000 });
// Badge now shows parent task title instead of generic "Subtask"
await expect(subtaskCard.getByText("Parent Task")).toBeVisible();
});
test("create subtask from sidebar header button", async ({ testPage, apiClient, seedData }) => {
// Create a task with an agent so we have a session to navigate to
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Subtask Parent",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
repository_ids: [seedData.repositoryId],
},
);
// Navigate to the session page
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Wait for agent to complete
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Open the New Subtask dialog from the sidebar New Task row's trailing
// subtask affordance (shown while viewing a task).
const subtaskButton = testPage.getByTestId("sidebar-new-subtask");
await expect(subtaskButton).toBeVisible({ timeout: 5_000 });
await subtaskButton.click();
// The compact NewSubtaskDialog should open with pre-filled title containing numeric suffix
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
await expect(titleInput).toHaveValue(/Subtask Parent \/ Subtask \d+/);
// Fill prompt and submit
const promptInput = testPage.getByTestId("subtask-prompt-input");
await expect(promptInput).toBeVisible();
await promptInput.fill("/e2e:simple-message");
const submitBtn = testPage.getByRole("button", { name: "Create Subtask" });
await submitBtn.click();
await expect(titleInput).not.toBeVisible({ timeout: 10_000 });
// After creation, we navigate to the new subtask's session
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// Verify the subtask card appears on the kanban board with "Subtask" badge
const kanban = new KanbanPage(testPage);
await kanban.goto();
const subtaskCard = kanban.taskCardByTitle(/Subtask Parent \/ Subtask \d+/);
await expect(subtaskCard).toBeVisible({ timeout: 10_000 });
});
});
test.describe("MCP subtask creation", () => {
test("agent creates subtask via MCP create_task with parent_id", async ({ testPage }) => {
const subtaskTitle = "MCP-subtask-e2e-verify";
const script = [
'e2e:thinking("Planning subtasks...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify MCP create_task with parent_id"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
// 1. Create parent task via UI dialog
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("MCP Subtask Parent");
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 startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// 2. Click the parent task card to navigate to its session
const parentCard = kanban.taskCardByTitle("MCP Subtask Parent");
await expect(parentCard).toBeVisible({ timeout: 10_000 });
await parentCard.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// 3. Wait for the agent to complete — the MCP create_task call happens during execution
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 4. Go back to kanban — subtask card should be visible with parent badge
await kanban.goto();
const subtaskCard = kanban.taskCardByTitle(subtaskTitle);
await expect(subtaskCard).toBeVisible({ timeout: 10_000 });
await expect(subtaskCard.getByText("MCP Subtask Parent")).toBeVisible();
});
test("MCP-created subtask inherits parent task repositories", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const subtaskTitle = "Repo-Inherit Subtask E2E";
const script = [
'e2e:thinking("Creating subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify repository inheritance"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
// 1. Create parent task via API with an explicit repository. Without the
// fix, the MCP handler would not copy repositories to the subtask and
// it would run as a repository-less "quick chat" session.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Repo Inherit Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
expect(parentTask.id).toBeTruthy();
// 2. Navigate to parent task so the agent session is visible and the
// backend keeps the execution alive for the duration of the test.
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// 3. Poll the API until the subtask appears. The agent starts
// asynchronously when created via the HTTP API, so we cannot rely on
// the idle-input indicator alone — it may appear before the agent has
// finished executing the MCP script.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 60_000, message: "Subtask should be created by the agent's MCP call" },
)
.toBeDefined();
// 4. Verify the subtask inherited the parent's repository.
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
expect(
subtaskData.repositories?.length,
`subtask should have at least 1 repository; got: ${JSON.stringify(subtaskData.repositories)}`,
).toBeGreaterThanOrEqual(1);
expect(
subtaskData.repositories?.[0]?.repository_id,
`subtask repository_id should match parent's (${seedData.repositoryId})`,
).toBe(seedData.repositoryId);
});
test("user creates subtask via sidebar button", async ({ testPage }) => {
// 1. Create parent task via UI dialog with a simple agent script
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("Subtask Button Parent");
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 });
// 2. Click the parent card to navigate to its session
const parentCard = kanban.taskCardByTitle("Subtask Button Parent");
await expect(parentCard).toBeVisible({ timeout: 10_000 });
await parentCard.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// 3. Wait for the agent to finish
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 4. Open the New Subtask dialog from the sidebar New Task subtask affordance.
await testPage.getByTestId("sidebar-new-subtask").click();
const subtaskTitleInput = testPage.getByTestId("subtask-title-input");
await expect(subtaskTitleInput).toBeVisible();
// Title should be pre-filled with "Parent / Subtask N" pattern
await expect(subtaskTitleInput).toHaveValue(/Subtask Button Parent \/ Subtask \d+/);
// 5. Fill the prompt and submit
const parentUrl = testPage.url();
await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message");
await testPage.getByRole("button", { name: "Create Subtask" }).click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await expect(testPage).not.toHaveURL(parentUrl);
// 6. Go back to kanban — subtask card should be visible with parent badge
await kanban.goto();
const subtaskCard = kanban.taskCardByTitle(/Subtask Button Parent \/ Subtask \d+/);
await expect(subtaskCard).toBeVisible({ timeout: 10_000 });
});
test("user creates subtask in a different repository via the repo chooser", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// 1. Add a second workspace repository so the New Subtask dialog's
// repo chip can be switched to a different repo.
const otherRepoName = "UI Subtask Override Target";
const otherRepoDir = path.join(backend.tmpDir, "repos", "ui-subtask-override");
fs.mkdirSync(otherRepoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: otherRepoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv });
const otherRepo = await apiClient.createRepository(seedData.workspaceId, otherRepoDir, "main", {
name: otherRepoName,
});
// 2. Create a parent task on repo A with a simple agent script so we can
// open its session and reach the subtask split-button.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"UI Override Parent",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 3. Open the New Subtask dialog from the sidebar New Task subtask affordance.
await testPage.getByTestId("sidebar-new-subtask").click();
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
// 4. Open the repo chip and switch the parent's repo for repo B. The
// subtask dialog now uses the same chip-row UX as the create-task
// dialog (RepoChipsRow), so we drive it via repo-chip-trigger and
// the combobox option role.
const repoChip = testPage.getByTestId("repo-chip-trigger").first();
await expect(repoChip).toBeVisible({ timeout: 5_000 });
await repoChip.click();
// The combobox option label includes a truncated-path badge alongside the
// repo name, so match by name substring rather than exact.
await testPage.getByRole("option", { name: new RegExp(otherRepoName) }).click();
// After selection, the chip should now show repo B's name.
await expect(repoChip).toContainText(otherRepoName);
// 5. Submit. Use a unique title so the listTasks poll can find this row.
const subtaskTitle = `UI Override Subtask ${Date.now()}`;
await titleInput.fill(subtaskTitle);
await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message");
await testPage.getByRole("button", { name: "Create Subtask" }).click();
await expect(titleInput).not.toBeVisible({ timeout: 10_000 });
// 6. Find the subtask via API and verify it landed on the override repo,
// not the parent's repo.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 30_000, message: "Subtask should be created from the dialog" },
)
.toBeDefined();
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? [];
expect(
repoIds,
`subtask should target the override repository (${otherRepo.id}), not the parent's (${seedData.repositoryId}); got: ${JSON.stringify(repoIds)}`,
).toContain(otherRepo.id);
expect(repoIds).not.toContain(seedData.repositoryId);
});
test("MCP-created subtask can override parent task repository", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// 1. Create a second workspace repository so the subtask has somewhere
// different to point at than the parent's repository.
const otherRepoDir = path.join(backend.tmpDir, "repos", "e2e-cross-repo");
fs.mkdirSync(otherRepoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: otherRepoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv });
const otherRepo = await apiClient.createRepository(seedData.workspaceId, otherRepoDir, "main", {
name: "E2E Cross-Repo Target",
});
const subtaskTitle = "Cross-Repo Subtask E2E";
const script = [
'e2e:thinking("Creating cross-repo subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify repository override","repository_id":"${otherRepo.id}"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
// 2. Parent task lives in repoA (seedData.repositoryId). The agent script
// asks the MCP tool to create a subtask in repoB instead.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Cross-Repo Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
expect(parentTask.id).toBeTruthy();
// 3. Navigate so the parent execution stays alive while the agent runs.
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// 4. Poll for the subtask the agent is about to create.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 60_000, message: "Subtask should be created by the agent's MCP call" },
)
.toBeDefined();
// 5. Verify the subtask landed on the OTHER repository, not the parent's.
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? [];
expect(
repoIds,
`subtask should target the override repository (${otherRepo.id}), not the parent's (${seedData.repositoryId}); got: ${JSON.stringify(repoIds)}`,
).toContain(otherRepo.id);
expect(repoIds).not.toContain(seedData.repositoryId);
});
test("MCP-created same-repo subtask inherits parent's base_branch (stacked PR ergonomics)", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// 1. Set up a repo with a non-default branch the parent will be pinned to.
// A same-repo subtask should inherit that branch so its work stacks on
// the same starting point as the parent (sibling PRs off the same
// base, useful for stacked PR workflows).
const repoDir = path.join(backend.tmpDir, "repos", "subtask-same-repo-inherit");
fs.mkdirSync(repoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
execSync("git branch feature/parent-branch", { cwd: repoDir, env: gitEnv });
const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "Subtask Same Repo Inherit",
});
const subtaskTitle = `Same-Repo Subtask ${Date.now()}`;
const script = [
'e2e:thinking("Creating subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify base_branch is inherited from parent in same repo"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
// 2. Create the parent pinned to a non-default branch.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Same-Repo Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [{ repository_id: repo.id, base_branch: "feature/parent-branch" }],
},
);
// Sanity: parent persisted the pin.
const parentRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${parentTask.id}`);
const parentData = (await parentRaw.json()) as TaskWithRepos;
expect(parentData.repositories?.[0]?.base_branch).toBe("feature/parent-branch");
// 3. Open the parent so the execution stays alive while the script runs.
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// 4. Poll for the subtask.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 60_000, message: "Subtask should be created by the agent's MCP call" },
)
.toBeDefined();
// 5. The same-repo subtask should inherit the parent's base_branch so the
// agent can stack work on top of the parent's PR. The worktree
// manager's fallback (covered separately in worktree unit tests) is
// the safety net if the inherited branch later goes stale on the
// remote — it is not the primary mechanism.
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
expect(
subtaskData.repositories?.[0]?.repository_id,
"same-repo subtask should inherit the parent's repository_id",
).toBe(repo.id);
expect(
subtaskData.repositories?.[0]?.base_branch,
`same-repo subtask base_branch should inherit parent's 'feature/parent-branch'; got: ${subtaskData.repositories?.[0]?.base_branch}`,
).toBe("feature/parent-branch");
});
test("MCP-created cross-repo subtask uses the new repo's default_branch", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// Parent lives in the seed repo on a non-default branch. The subtask
// explicitly retargets a different repo via repository_id and supplies
// no base_branch. The subtask must anchor to the new repo's
// default_branch ("trunk") and never carry the parent's branch over.
const otherRepoDir = path.join(backend.tmpDir, "repos", "subtask-cross-repo");
fs.mkdirSync(otherRepoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b trunk", { cwd: otherRepoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv });
const otherRepo = await apiClient.createRepository(
seedData.workspaceId,
otherRepoDir,
"trunk",
{
name: "Subtask Cross Repo",
},
);
const subtaskTitle = `Cross-Repo Subtask ${Date.now()}`;
const script = [
'e2e:thinking("Creating cross-repo subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E cross-repo subtask","repository_id":"${otherRepo.id}"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Cross-Repo Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{
timeout: 60_000,
message: "Cross-repo subtask should be created by the agent's MCP call",
},
)
.toBeDefined();
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
expect(subtaskData.repositories?.[0]?.repository_id).toBe(otherRepo.id);
expect(
subtaskData.repositories?.[0]?.base_branch,
`cross-repo subtask base_branch should anchor to new repo's default 'trunk'; got: ${subtaskData.repositories?.[0]?.base_branch}`,
).toBe("trunk");
});
});
/**
* Verifies the New Subtask dialog mirrors the create-task dialog's repo/URL
* features: GitHub URL paste, on-disk discovered repos, and multi-repo rows.
* These tests exercise the shared `RepoChipsRow` + handlers/effects through
* the subtask form-state shim in `new-subtask-form-state.ts`.
*/
test.describe("Subtask dialog feature parity", () => {
test("user creates subtask via pasted GitHub URL", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// 1. Pre-seed a GitHub-backed repo + branches so the GitHub URL flow
// resolves to a known repository_id without performing a real clone.
const repoDir = path.join(backend.tmpDir, "repos", "subtask-gh-url-target");
fs.mkdirSync(repoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
const ghRepo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "subtask-owner/subtask-repo",
provider: "github",
provider_owner: "subtask-owner",
provider_name: "subtask-repo",
});
await apiClient.mockGitHubAddBranches("subtask-owner", "subtask-repo", [{ name: "main" }]);
// 2. Parent task on the seed repo.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Subtask GH URL Parent",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 3. Open the subtask dialog and toggle to GitHub URL mode.
await testPage.getByTestId("sidebar-new-subtask").click();
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
// Switch to Remote tab. The subtask form-state runs
// useRemoteReposSeedEffect (shared with the create-task dialog), which
// auto-seeds an empty chip row on the initial toggle.
const remoteModeBtn = testPage.getByTestId("source-mode-remote");
await expect(remoteModeBtn).toBeVisible({ timeout: 5_000 });
await remoteModeBtn.click();
const chipTrigger = testPage.getByTestId("remote-repo-chip-trigger").first();
await expect(chipTrigger).toBeVisible({ timeout: 5_000 });
await chipTrigger.click();
const urlInput = testPage.getByTestId("remote-paste-url-input").last();
await expect(urlInput).toBeVisible({ timeout: 5_000 });
await urlInput.fill("https://github.com/subtask-owner/subtask-repo");
await urlInput.press("Enter");
// Wait for branch list to resolve so the payload carries `base_branch`.
// The per-chip branch pill is disabled while branches are loading and
// re-enables once the resolver returns options, so this is a positive
// signal that the URL was parsed and branches were fetched.
await expect(testPage.getByTestId("remote-branch-chip-trigger").first()).toBeEnabled({
timeout: 10_000,
});
// 4. Submit.
const subtaskTitle = `Subtask GH URL ${Date.now()}`;
await titleInput.fill(subtaskTitle);
await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message");
await testPage.getByRole("button", { name: "Create Subtask" }).click();
await expect(titleInput).not.toBeVisible({ timeout: 10_000 });
// 5. Verify the subtask resolved to the GitHub-backed repo, not the parent's.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 30_000, message: "Subtask should be created from a GitHub URL" },
)
.toBeDefined();
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? [];
expect(
repoIds,
`subtask should resolve to the GitHub URL's repository (${ghRepo.id}); got: ${JSON.stringify(repoIds)}`,
).toContain(ghRepo.id);
expect(repoIds).not.toContain(seedData.repositoryId);
});
test("subtask repo dropdown lists on-disk discovered repositories", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(60_000);
// 1. Create a git repo on disk (under HOME=tmpDir, where the backend's
// discovery roots default scans) but do NOT register it as a workspace
// repo. The chip dropdown's "on disk" section is fed by the discovery
// endpoint and should pick this up.
const discoveredName = "subtask-discovered-only";
const discoveredDir = path.join(backend.tmpDir, "repos", discoveredName);
fs.mkdirSync(discoveredDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: discoveredDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: discoveredDir, env: gitEnv });
// 2. Parent task so we can open the subtask dialog from its session.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Subtask Discovered Parent",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 3. Sanity-check via the API that backend discovery actually finds the
// on-disk directory. If this fails, the issue is the backend scan, not
// the dialog wiring — tells us where to look when debugging.
const discoverRaw = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/repositories/discover`,
);
const discoverData = (await discoverRaw.json()) as {
repositories: Array<{ path: string; name: string }>;
};
expect(
discoverData.repositories.some((r) => r.path === discoveredDir),
`backend discovery should include ${discoveredDir}; got: ${JSON.stringify(discoverData.repositories.map((r) => r.path))}`,
).toBe(true);
// 4. Open the subtask dialog. The discover effect fires on mount via a
// Next.js server action, so we can't await a direct backend response
// here. Use poll-then-open: poll for the chip option to appear,
// reopening the popover each tick because cmdk's listbox snapshots
// options at open time and won't update if discovery resolves while
// the popover is already showing.
await testPage.getByTestId("sidebar-new-subtask").click();
await expect(testPage.getByTestId("subtask-title-input")).toBeVisible({ timeout: 5_000 });
const discoveredOption = testPage
.getByRole("option", { name: new RegExp(discoveredName, "i") })
.first();
const repoChip = testPage.getByTestId("repo-chip-trigger").first();
await expect
.poll(
async () => {
await repoChip.click();
const visible = await discoveredOption.isVisible().catch(() => false);
if (!visible) {
// Close so the next tick reopens against fresh state.
await testPage.keyboard.press("Escape");
}
return visible;
},
{ timeout: 15_000, intervals: [500, 1000, 1500] },
)
.toBe(true);
await expect(discoveredOption).toContainText("on disk");
});
test("user creates subtask spanning multiple repositories", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// 1. Add a second workspace repository so the chip row can hold two rows.
const otherRepoName = "Subtask MultiRepo Target";
const otherRepoDir = path.join(backend.tmpDir, "repos", "subtask-multi-repo");
fs.mkdirSync(otherRepoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: otherRepoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv });
const otherRepo = await apiClient.createRepository(seedData.workspaceId, otherRepoDir, "main", {
name: otherRepoName,
});
// 2. Parent task on repo A so we can open the subtask dialog from there.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Subtask MultiRepo Parent",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 3. Open the subtask dialog. The first chip is seeded with the parent's
// repo. Click the "+ add repository" button to append a second chip,
// then point that chip at repo B.
await testPage.getByTestId("sidebar-new-subtask").click();
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
await testPage.getByTestId("add-repository").click();
const chipTriggers = testPage.getByTestId("repo-chip-trigger");
await expect(chipTriggers).toHaveCount(2, { timeout: 5_000 });
await chipTriggers.nth(1).click();
await testPage.getByRole("option", { name: new RegExp(otherRepoName) }).click();
await expect(chipTriggers.nth(1)).toContainText(otherRepoName);
// 4. Submit.
const subtaskTitle = `Subtask MultiRepo ${Date.now()}`;
await titleInput.fill(subtaskTitle);
await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message");
await testPage.getByRole("button", { name: "Create Subtask" }).click();
await expect(titleInput).not.toBeVisible({ timeout: 10_000 });
// 5. Verify the subtask carries BOTH repos.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 30_000, message: "Multi-repo subtask should be created from the dialog" },
)
.toBeDefined();
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? [];
expect(
repoIds,
`subtask should target both the parent's repo and the override repo; got: ${JSON.stringify(repoIds)}`,
).toEqual(expect.arrayContaining([seedData.repositoryId, otherRepo.id]));
expect(repoIds).toHaveLength(2);
});
});
/**
* Verifies that when an agent creates a subtask via the MCP create_task tool
* with parent_id="self", the subtask session inherits BOTH the parent session's
* agent_profile_id AND executor_profile_id.
*/
test.describe("Subtask inheritance", () => {
test("MCP-created subtask inherits agent profile and executor profile", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const subtaskTitle = "Inherited Profile Subtask E2E";
const script = [
'e2e:thinking("Creating subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify executor and agent profile inheritance"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
// 1. Create an executor + profile so executor_profile_id is non-empty on
// the parent session — this makes the inheritance assertion meaningful.
const executor = await apiClient.createExecutor("E2E Inherit Executor", "local_pc");
const executorProfile = await apiClient.createExecutorProfile(
executor.id,
"E2E Inherit Profile",
);
// 2. Create parent task via API with explicit executor_profile_id so the
// parent session records it. The mock agent runs the MCP script which
// creates the subtask with parent_id="self". A repository is required
// so the parent (non-ephemeral) task gets a real workspace path.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Executor Inherit Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
executor_profile_id: executorProfile.id,
repository_ids: [seedData.repositoryId],
},
);
expect(parentTask.id).toBeTruthy();
// 3. Navigate to the parent task page so the execution stays alive.
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// 4. Poll until the subtask appears. The agent starts asynchronously
// when created via the HTTP API, so the idle-input indicator may
// appear before the MCP script has finished.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 60_000, message: "Subtask should be created by the agent's MCP call" },
)
.toBeDefined();
// 5. Confirm the parent session has both profiles set as expected.
const parentRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${parentTask.id}/sessions`);
const parentData = (await parentRaw.json()) as { sessions: SessionInfo[] };
const parentSession = parentData.sessions[0];
expect(
parentSession?.agent_profile_id,
`agent_profile_id: got ${parentSession?.agent_profile_id}, want ${seedData.agentProfileId}`,
).toBe(seedData.agentProfileId);
expect(
parentSession?.executor_profile_id,
`executor_profile_id: got ${parentSession?.executor_profile_id}, want ${executorProfile.id}; full session: ${JSON.stringify(parentSession)}`,
).toBe(executorProfile.id);
// 6. Verify subtask was auto-started: must have at least one session.
// Without the fix, autoStartTask finds no agent profile and skips launch →
// the subtask remains sessionless.
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(subtask!.id);
return sessions.length;
},
{ timeout: 30_000, message: "Subtask should be auto-started with an inherited session" },
)
.toBeGreaterThanOrEqual(1);
// 7. Wait for the subtask session to reach a terminal state.
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(subtask!.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for subtask session to complete" },
)
.toBe(true);
// 8. Verify the subtask session inherited BOTH profiles from the parent —
// proving executor/agent profile inheritance worked correctly.
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}/sessions`);
const subtaskData = (await subtaskRaw.json()) as { sessions: SessionInfo[] };
const subtaskSession = subtaskData.sessions[0];
expect(subtaskSession?.agent_profile_id).toBe(parentSession?.agent_profile_id);
expect(subtaskSession?.executor_profile_id).toBe(parentSession?.executor_profile_id);
// Sanity: both are non-empty (so the assertions above are not vacuously true)
expect(subtaskSession?.agent_profile_id).toBeTruthy();
expect(subtaskSession?.executor_profile_id).toBeTruthy();
});
});