1260 lines
51 KiB
TypeScript
1260 lines
51 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { Locator } from "@playwright/test";
|
|
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_id?: string;
|
|
executor_profile_id: string;
|
|
state: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
type RuntimeConfig = {
|
|
model?: string;
|
|
mode?: string;
|
|
config_options?: Record<string, string>;
|
|
};
|
|
|
|
function runtimeConfigFromMetadata(
|
|
metadata: Record<string, unknown> | undefined,
|
|
key: string,
|
|
): RuntimeConfig {
|
|
return (metadata?.[key] as RuntimeConfig | undefined) ?? {};
|
|
}
|
|
|
|
function firstRuntimeValue(...values: Array<string | undefined>): string | undefined {
|
|
return values.find((value) => value);
|
|
}
|
|
|
|
function effectiveRuntimeConfig(session: SessionInfo): RuntimeConfig {
|
|
const snapshot = runtimeConfigFromMetadata(session.metadata, "agent_profile_snapshot");
|
|
const runtime = runtimeConfigFromMetadata(session.metadata, "runtime_config");
|
|
const overrides = runtimeConfigFromMetadata(session.metadata, "runtime_config_overrides");
|
|
const configOptions = {
|
|
...(runtime.config_options ?? snapshot.config_options ?? {}),
|
|
...(overrides.config_options ?? {}),
|
|
};
|
|
delete configOptions.model;
|
|
delete configOptions.mode;
|
|
return {
|
|
model: firstRuntimeValue(overrides.model, runtime.model, snapshot.model),
|
|
mode: firstRuntimeValue(
|
|
session.metadata?.session_mode as string | undefined,
|
|
overrides.mode,
|
|
runtime.mode,
|
|
snapshot.mode,
|
|
),
|
|
config_options: configOptions,
|
|
};
|
|
}
|
|
|
|
async function selectRepositoryFromChip(repoChip: Locator, option: Locator, name: string) {
|
|
await expect(async () => {
|
|
if (!(await option.isVisible())) {
|
|
await repoChip.click();
|
|
}
|
|
await expect(option).toBeVisible({ timeout: 3_000 });
|
|
await option.click({ timeout: 3_000 });
|
|
await expect(repoChip).toContainText(name, { timeout: 3_000 });
|
|
}).toPass({ timeout: 15_000 });
|
|
}
|
|
|
|
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 the sidebar task context menu", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
prCapture,
|
|
}) => {
|
|
// 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],
|
|
executor_profile_id: seedData.worktreeExecutorProfileId,
|
|
},
|
|
);
|
|
|
|
// 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 session.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
await session.openCreateSubtaskForSidebarTask("Subtask Parent");
|
|
|
|
// The compact NewSubtaskDialog should open with pre-filled title containing numeric suffix
|
|
const dialog = testPage.getByTestId("new-subtask-dialog");
|
|
await expect(dialog.getByTestId("autopilot-toggle-row")).toBeVisible();
|
|
await expect(testPage.locator('[data-slot="tooltip-content"][data-state="open"]')).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(dialog.getByTestId("autopilot-info")).not.toBeFocused();
|
|
await expect(dialog.getByRole("switch", { name: "Autopilot" })).toHaveAttribute(
|
|
"aria-checked",
|
|
"false",
|
|
);
|
|
await dialog.getByTestId("autopilot-info").hover();
|
|
await expect(testPage.getByRole("tooltip")).toContainText(
|
|
"The agent works independently and asks the parent task only when a critical decision blocks progress.",
|
|
);
|
|
|
|
const titleInput = testPage.getByTestId("subtask-title-input");
|
|
await expect(titleInput).toBeVisible({ timeout: 5_000 });
|
|
await expect(titleInput).toHaveValue(/Subtask Parent \/ Subtask \d+/);
|
|
|
|
const parentBranchBadge = dialog.getByText("Same branch as current session", { exact: true });
|
|
await expect(parentBranchBadge).toBeVisible();
|
|
await expect(dialog.getByTestId("subtask-workspace-mode-inherit")).toHaveAttribute(
|
|
"aria-checked",
|
|
"true",
|
|
);
|
|
await dialog.getByTestId("subtask-workspace-mode-new").click();
|
|
await expect(parentBranchBadge).toHaveCount(0);
|
|
await expect(dialog.getByTestId("repo-chip-trigger")).toBeVisible();
|
|
await expect(dialog.getByTestId("branch-chip-trigger")).toBeVisible();
|
|
await testPage.mouse.move(0, 0);
|
|
await dialog.getByTestId("subtask-title-input").click();
|
|
await expect(testPage.getByRole("tooltip")).toHaveCount(0);
|
|
await prCapture.screenshot("desktop-subtask-isolated-workspace", {
|
|
caption: "Desktop New Subtask dialog with isolated workspace controls",
|
|
});
|
|
|
|
// 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("does not focus autopilot help when subtask titles are generated", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const initialSettings = await apiClient.getUserSettings();
|
|
try {
|
|
await apiClient.saveUserSettings({ agent_generated_task_titles: true });
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Generated-title subtask parent",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
await session.openCreateSubtaskForSidebarTask("Generated-title subtask parent");
|
|
|
|
const dialog = testPage.getByTestId("new-subtask-dialog");
|
|
await expect(dialog.getByTestId("subtask-title-input")).toHaveCount(0);
|
|
await expect(dialog.getByTestId("subtask-context-autopilot-row")).toBeVisible();
|
|
const contextTrigger = dialog
|
|
.getByTestId("subtask-context-autopilot-row")
|
|
.locator('[data-slot="select-trigger"]');
|
|
const contextHeight = await contextTrigger.evaluate((element) =>
|
|
Math.round(element.getBoundingClientRect().height),
|
|
);
|
|
const autopilotHeight = await dialog
|
|
.getByTestId("autopilot-toggle-row")
|
|
.evaluate((element) => Math.round(element.getBoundingClientRect().height));
|
|
// Bounding boxes are rounded to CSS pixels. Allow the one-pixel
|
|
// difference produced by fractional layout at different viewport
|
|
// scales while still checking that the rows remain aligned.
|
|
expect(Math.abs(autopilotHeight - contextHeight)).toBeLessThanOrEqual(1);
|
|
await expect(dialog.getByTestId("autopilot-info")).not.toBeFocused();
|
|
await expect(dialog.getByTestId("subtask-prompt-input")).toBeFocused();
|
|
await expect(
|
|
testPage.locator('[data-slot="tooltip-content"][data-state="open"]'),
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await apiClient.saveUserSettings({
|
|
agent_generated_task_titles: Boolean(initialSettings.settings.agent_generated_task_titles),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
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. Sidebar task creation navigates directly to the parent session.
|
|
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 the sidebar task context menu", 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. Sidebar task creation navigates directly to the parent session.
|
|
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 parent task's context menu.
|
|
await session.openCreateSubtaskForSidebarTask("Subtask Button Parent");
|
|
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 task context menu.
|
|
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 session.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
// 3. Open the New Subtask dialog from the parent task's context menu.
|
|
await session.openCreateSubtaskForSidebarTask("UI Override Parent");
|
|
|
|
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 });
|
|
// The combobox option label includes a truncated-path badge alongside the
|
|
// repo name, so match by name substring rather than exact. Retry opening
|
|
// and selecting as one transaction because WS-driven dialog re-renders can
|
|
// detach the option between those two actions.
|
|
await selectRepositoryFromChip(
|
|
repoChip,
|
|
testPage.getByRole("option", { name: new RegExp(otherRepoName) }),
|
|
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 session.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
// 3. Open the subtask dialog and toggle to GitHub URL mode.
|
|
await session.openCreateSubtaskForSidebarTask("Subtask GH URL Parent");
|
|
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-repo-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 session.waitForChatIdle({ 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 an
|
|
// action wrapper, 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 session.openCreateSubtaskForSidebarTask("Subtask Discovered Parent");
|
|
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 session.waitForChatIdle({ 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 session.openCreateSubtaskForSidebarTask("Subtask MultiRepo Parent");
|
|
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("a changed second session creates top-level and subtask tasks with its runtime", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(150_000);
|
|
|
|
const { agents } = await apiClient.listAgents();
|
|
const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0];
|
|
expect(agent).toBeTruthy();
|
|
const creatorProfile = await apiClient.createAgentProfile(
|
|
agent!.id,
|
|
"Creator Session Runtime E2E",
|
|
{
|
|
model: "mock-fast",
|
|
mode: "default",
|
|
config_options: { effort: "medium", profile_only: "stale" },
|
|
},
|
|
);
|
|
const executor = await apiClient.createExecutor("Creator Runtime Executor E2E", "local_pc");
|
|
const executorProfile = await apiClient.createExecutorProfile(
|
|
executor.id,
|
|
"Creator Runtime Executor Profile E2E",
|
|
);
|
|
const subtaskTitle = `Creator runtime subtask ${Date.now()}`;
|
|
const topLevelTitle = `Creator runtime top-level ${Date.now()}`;
|
|
|
|
try {
|
|
const parentTask = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Creator Runtime Parent E2E",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
executor_profile_id: executorProfile.id,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
await testPage.goto(`/t/${parentTask.id}`);
|
|
await new SessionPage(testPage).waitForLoad();
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(parentTask.id);
|
|
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
|
},
|
|
{
|
|
timeout: 30_000,
|
|
message: "Parent session should finish before the second session starts",
|
|
},
|
|
)
|
|
.toBe(true);
|
|
|
|
const firstSession = (await apiClient.listTaskSessions(parentTask.id)).sessions[0];
|
|
expect(firstSession?.agent_profile_id).toBe(seedData.agentProfileId);
|
|
const second = await apiClient.launchSession({
|
|
task_id: parentTask.id,
|
|
agent_profile_id: creatorProfile.id,
|
|
executor_id: firstSession?.executor_id,
|
|
executor_profile_id: firstSession?.executor_profile_id,
|
|
prompt: "/e2e:simple-message",
|
|
});
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(parentTask.id);
|
|
return sessions.find((session) => session.id === second.session_id)?.state ?? "";
|
|
},
|
|
{ timeout: 45_000, message: "Creator session should finish before runtime changes" },
|
|
)
|
|
.toBe("WAITING_FOR_INPUT");
|
|
|
|
await apiClient.setSessionModel(second.session_id, "mock-smart");
|
|
await apiClient.setSessionMode(second.session_id, "plan-mock");
|
|
await apiClient.setSessionConfigOption(second.session_id, "effort", "max");
|
|
|
|
const script = [
|
|
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"creator runtime subtask"})`,
|
|
`e2e:mcp:kandev:create_task_kandev({"workspace_id":"${seedData.workspaceId}","workflow_id":"${seedData.workflowId}","title":"${topLevelTitle}","description":"creator runtime top-level","repository_id":"${seedData.repositoryId}"})`,
|
|
'e2e:message("Done.")',
|
|
].join("\n");
|
|
await apiClient.addUserMessage(parentTask.id, second.session_id, script);
|
|
|
|
let subtask: { id: string } | undefined;
|
|
let topLevel: { id: string } | undefined;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
|
subtask = tasks.find((task) => task.title === subtaskTitle);
|
|
topLevel = tasks.find((task) => task.title === topLevelTitle);
|
|
return Boolean(subtask && topLevel);
|
|
},
|
|
{ timeout: 60_000, message: "Both MCP-created task shapes should exist" },
|
|
)
|
|
.toBe(true);
|
|
|
|
for (const task of [subtask!, topLevel!]) {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return sessions[0]?.agent_profile_id ?? "";
|
|
},
|
|
{ timeout: 45_000, message: `Task ${task.id} should start with the creator profile` },
|
|
)
|
|
.toBe(creatorProfile.id);
|
|
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
const createdSession = sessions[0] as SessionInfo;
|
|
expect(effectiveRuntimeConfig(createdSession)).toEqual({
|
|
model: "mock-smart",
|
|
mode: "plan-mock",
|
|
config_options: { effort: "max" },
|
|
});
|
|
}
|
|
|
|
const { sessions: subtaskSessions } = await apiClient.listTaskSessions(subtask!.id);
|
|
expect(subtaskSessions[0]?.executor_profile_id).toBe(executorProfile.id);
|
|
} finally {
|
|
await apiClient.deleteAgentProfile(creatorProfile.id, true);
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|