Files
kandev/apps/web/e2e/tests/task/create-task-workflow-agent-overrides.spec.ts
T
2026-09-19 21:26:36 +01:00

421 lines
17 KiB
TypeScript

import { expect, test } from "../../fixtures/test-base";
import type { Locator, Page } from "@playwright/test";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import {
createOverrideTask,
deleteFixtureTasks,
previewWorkflowMove,
seedWorkflowAgentOverrideFixture,
waitForNewWorkflowProfileSession,
waitForWorkflowMoveLifecycle,
waitForWorkflowStep,
} from "./task-workflow-agent-overrides-helpers";
useRegularMode();
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function taskIdFromUrl(url: string): string {
const match = url.match(/\/t\/([^/?#]+)/);
if (!match) throw new Error(`task id missing from URL: ${url}`);
return match[1];
}
async function selectWorkflow(page: Page, dialog: Locator, workflowName: string) {
await dialog.getByTestId("workflow-selector-trigger").click();
await page
.getByRole("button", { name: new RegExp(`^${escapeRegExp(workflowName)}`) })
.last()
.click();
}
test.describe("task-specific workflow agent overrides", () => {
test("keeps grouped replacements task-local and exposes the planned model on desktop", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
await testPage.setViewportSize({ width: 1440, height: 900 });
const fixture = await seedWorkflowAgentOverrideFixture(
apiClient,
seedData,
"Desktop Overrides",
);
const otherWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Desktop Overrides Other Workflow",
);
await apiClient.createWorkflowStep(otherWorkflow.id, "Other start", 0, {
is_start_step: true,
});
const createdTaskIds: string[] = [];
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: fixture.workflow.id,
task_create_last_used: {
repository_id: seedData.repositoryId,
branch: "main",
agent_profile_id: fixture.profileA.id,
workflow_ids_by_workspace: { [seedData.workspaceId]: fixture.workflow.id },
},
});
try {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
const workflowSelector = dialog.getByTestId("workflow-selector-trigger");
await expect(workflowSelector).toContainText(fixture.workflow.name);
const advanced = dialog.getByTestId("task-create-advanced-settings");
await advanced.getByTestId("task-create-advanced-settings-trigger").click();
const overrides = dialog.getByTestId("task-create-workflow-agent-overrides");
await expect(overrides).toBeVisible({ timeout: 20_000 });
const sourceRow = overrides.getByTestId(
`task-create-workflow-agent-override-${fixture.profileA.id}`,
);
await expect(sourceRow).toBeVisible();
await expect(sourceRow).toContainText("Implement");
await expect(sourceRow).toContainText("PR");
const replacementSelector = sourceRow.getByTestId("agent-profile-selector");
await replacementSelector.click();
await expect(
testPage.getByRole("option", { name: new RegExp(escapeRegExp(fixture.profileB.name)) }),
).toBeVisible();
await testPage
.getByRole("option", { name: new RegExp(escapeRegExp(fixture.profileB.name)) })
.click();
await expect(replacementSelector).toContainText(fixture.profileB.name);
await advanced.getByTestId("task-create-advanced-settings-trigger").click();
await expect(overrides).toBeHidden();
await advanced.getByTestId("task-create-advanced-settings-trigger").click();
await expect(replacementSelector).toContainText(fixture.profileB.name);
await selectWorkflow(testPage, dialog, otherWorkflow.name);
await expect(
dialog.getByTestId(`task-create-workflow-agent-override-${fixture.profileA.id}`),
).toHaveCount(0);
await selectWorkflow(testPage, dialog, fixture.workflow.name);
const restoredRow = dialog.getByTestId(
`task-create-workflow-agent-override-${fixture.profileA.id}`,
);
await expect(restoredRow.getByTestId("agent-profile-selector")).toContainText(
"Use workflow profile",
);
await restoredRow.getByTestId("agent-profile-selector").click();
await testPage
.getByRole("option", { name: new RegExp(escapeRegExp(fixture.profileB.name)) })
.click();
await dialog.getByTestId("task-title-input").fill("Desktop workflow override task");
await dialog.getByTestId("task-description-input").fill("Desktop override description");
await dialog.getByTestId("submit-start-agent-chevron").click();
await testPage.getByTestId("submit-create-without-agent").click();
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
const createdTaskId = taskIdFromUrl(testPage.url());
createdTaskIds.push(createdTaskId);
const stored = await apiClient.getTask(createdTaskId);
expect(stored.workflow_agent_overrides).toMatchObject({
workflow_id: fixture.workflow.id,
steps: [
{
step_id: fixture.implementStep.id,
source_profile_id: fixture.profileA.id,
replacement_profile_id: fixture.profileB.id,
},
],
});
await testPage.reload();
expect((await apiClient.getTask(createdTaskId)).workflow_agent_overrides).toEqual(
stored.workflow_agent_overrides,
);
await kanban.goto();
await kanban.createTaskButton.first().click();
const freshDialog = testPage.getByTestId("create-task-dialog");
await expect(freshDialog).toBeVisible();
await freshDialog.getByTestId("task-create-advanced-settings-trigger").click();
const freshRow = freshDialog
.getByTestId("task-create-workflow-agent-overrides")
.getByTestId(`task-create-workflow-agent-override-${fixture.profileA.id}`);
await expect(freshRow.getByTestId("agent-profile-selector")).toContainText(
"Use workflow profile",
);
await freshDialog.getByRole("button", { name: "Cancel", exact: true }).click();
const runtimeTask = await createOverrideTask(apiClient, seedData, fixture, {
title: "Desktop runtime override",
replacementProfileId: fixture.profileB.id,
});
createdTaskIds.push(runtimeTask.id);
const initialSessionId = await waitForNewWorkflowProfileSession(
apiClient,
runtimeTask.id,
fixture.profileA.id,
);
const sessionPage = new SessionPage(testPage);
await testPage.goto(`/t/${runtimeTask.id}`);
await sessionPage.waitForLoad();
const plannedTopbarStep = testPage.getByTestId("workflow-step-Implement");
await expect(plannedTopbarStep).toBeVisible();
await plannedTopbarStep.hover();
const plannedTopbarPopover = testPage.getByTestId("workflow-step-popover");
await expect(plannedTopbarPopover).toBeVisible();
await expect(plannedTopbarPopover.getByTestId("workflow-move-preview")).toContainText(
"mock-slow",
);
await testPage.mouse.move(0, 0);
const plannedAboveChatButton = testPage.getByTestId("proceed-next-step");
await expect(plannedAboveChatButton).toBeVisible();
await plannedAboveChatButton.hover();
const plannedAboveChatOptions = testPage.getByTestId("proceed-next-step-options");
await expect(plannedAboveChatOptions).toBeVisible();
await expect(plannedAboveChatOptions.getByTestId("workflow-move-preview")).toContainText(
"mock-slow",
);
await testPage.mouse.move(0, 0);
await plannedAboveChatButton.click();
await waitForWorkflowStep(apiClient, runtimeTask.id, fixture.implementStep.id);
await waitForNewWorkflowProfileSession(apiClient, runtimeTask.id, fixture.profileB.id, [
initialSessionId,
]);
await waitForWorkflowMoveLifecycle(apiClient, runtimeTask.id);
await apiClient.moveTask(runtimeTask.id, fixture.workflow.id, fixture.reviewStep.id);
await waitForWorkflowStep(apiClient, runtimeTask.id, fixture.reviewStep.id);
await expect
.poll(() => apiClient.getTask(runtimeTask.id).then((task) => task.primary_session_id))
.toBe(initialSessionId);
await waitForWorkflowMoveLifecycle(apiClient, runtimeTask.id);
await testPage.reload();
await sessionPage.waitForLoad();
const actualTopbarStep = testPage.getByTestId("workflow-step-PR");
await expect(actualTopbarStep).toBeVisible();
await actualTopbarStep.hover();
const actualTopbarPopover = testPage.getByTestId("workflow-step-popover");
await expect(actualTopbarPopover).toBeVisible();
await expect(actualTopbarPopover.getByTestId("workflow-move-preview")).toContainText(
"mock-slow",
);
await testPage.mouse.move(0, 0);
const actualAboveChatButton = testPage.getByTestId("proceed-next-step");
await expect(actualAboveChatButton).toBeVisible();
await actualAboveChatButton.hover();
const actualAboveChatOptions = testPage.getByTestId("proceed-next-step-options");
await expect(actualAboveChatOptions).toBeVisible();
await expect(actualAboveChatOptions.getByTestId("workflow-move-preview")).toContainText(
"mock-slow",
);
await testPage.mouse.move(0, 0);
const planned = await previewWorkflowMove(
apiClient,
createdTaskId,
fixture.workflow.id,
fixture.prStep.id,
);
expect(planned.model.after.known).toBe(true);
expect(planned.model.after.label).toContain("mock-slow");
expect(planned.recipient?.profile_id).toBe(fixture.profileB.id);
} finally {
await deleteFixtureTasks(apiClient, createdTaskIds);
await apiClient.deleteWorkflow(otherWorkflow.id).catch(() => undefined);
}
});
test("routes three interleaved tasks independently through reused workflow sessions", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const fixture = await seedWorkflowAgentOverrideFixture(
apiClient,
seedData,
"Runtime Overrides",
);
const [defaultTask, replacementBTask, replacementCTask] = await Promise.all([
createOverrideTask(apiClient, seedData, fixture, { title: "Runtime default" }),
createOverrideTask(apiClient, seedData, fixture, {
title: "Runtime replacement B",
replacementProfileId: fixture.profileB.id,
}),
createOverrideTask(apiClient, seedData, fixture, {
title: "Runtime replacement C",
replacementProfileId: fixture.profileC.id,
}),
]);
const tasks = [defaultTask, replacementBTask, replacementCTask];
const initialSessionIds = await Promise.all(
tasks.map((task) =>
waitForNewWorkflowProfileSession(apiClient, task.id, fixture.profileA.id),
),
);
try {
expect((await apiClient.getTask(defaultTask.id)).workflow_agent_overrides).toBeUndefined();
expect(
(await apiClient.getTask(replacementBTask.id)).workflow_agent_overrides?.steps,
).toEqual([
expect.objectContaining({
step_id: fixture.implementStep.id,
replacement_profile_id: fixture.profileB.id,
}),
]);
expect(
(await apiClient.getTask(replacementCTask.id)).workflow_agent_overrides?.steps,
).toEqual([
expect.objectContaining({
step_id: fixture.implementStep.id,
replacement_profile_id: fixture.profileC.id,
}),
]);
const plannedBeforeImplement = await previewWorkflowMove(
apiClient,
replacementBTask.id,
fixture.workflow.id,
fixture.prStep.id,
);
expect(plannedBeforeImplement.model.after.label).toContain("mock-slow");
expect(plannedBeforeImplement.recipient?.profile_id).toBe(fixture.profileB.id);
const implementSessionIds = new Map<string, string>();
for (const [task, profile] of [
[replacementBTask, fixture.profileB],
[defaultTask, fixture.profileA],
[replacementCTask, fixture.profileC],
] as const) {
await apiClient.moveTask(task.id, fixture.workflow.id, fixture.implementStep.id);
await waitForWorkflowStep(apiClient, task.id, fixture.implementStep.id);
implementSessionIds.set(
task.id,
await waitForNewWorkflowProfileSession(apiClient, task.id, profile.id, initialSessionIds),
);
await expect
.poll(() => apiClient.getTask(task.id).then((current) => current.primary_session_id), {
timeout: 30_000,
message: `task ${task.title} did not promote its Implement session`,
})
.toBe(implementSessionIds.get(task.id));
await waitForWorkflowMoveLifecycle(apiClient, task.id);
}
for (const task of [replacementCTask, defaultTask, replacementBTask]) {
await apiClient.moveTask(task.id, fixture.workflow.id, fixture.reviewStep.id);
await waitForWorkflowStep(apiClient, task.id, fixture.reviewStep.id);
await expect
.poll(() => apiClient.getTask(task.id).then((current) => current.primary_session_id))
.toBe(initialSessionIds[tasks.indexOf(task)]);
await waitForWorkflowMoveLifecycle(apiClient, task.id);
}
for (const task of [replacementBTask, defaultTask, replacementCTask]) {
await apiClient.moveTask(task.id, fixture.workflow.id, fixture.prStep.id);
await waitForWorkflowStep(apiClient, task.id, fixture.prStep.id);
await expect
.poll(
async () => {
const [current, { sessions }] = await Promise.all([
apiClient.getTask(task.id),
apiClient.listTaskSessions(task.id),
]);
const expected = implementSessionIds.get(task.id);
return current.primary_session_id === expected
? expected
: `primary=${current.primary_session_id}; expected=${expected}; overrides=${JSON.stringify(current.workflow_agent_overrides)}; sessions=${JSON.stringify(sessions.map((session) => ({ id: session.id, profile: session.agent_profile_id, state: session.state, primary: session.is_primary })))}`;
},
{ timeout: 30_000 },
)
.toBe(implementSessionIds.get(task.id));
await waitForWorkflowMoveLifecycle(apiClient, task.id);
}
const sessionPage = new SessionPage(testPage);
for (const task of tasks) {
const { sessions } = await apiClient.listTaskSessions(task.id);
if (sessions.length !== 2) {
throw new Error(
`task ${task.id} has ${sessions.length} sessions before reload: ${JSON.stringify(
sessions.map((session) => ({
id: session.id,
profile: session.agent_profile_id,
state: session.state,
primary: session.is_primary,
})),
)}`,
);
}
await testPage.goto(`/t/${task.id}`);
await sessionPage.waitForLoad();
const sessionTabs = testPage.locator(
"[data-testid^='session-tab-']:not([data-testid^='session-tab-close-'])",
);
await expect(sessionTabs).toHaveCount(2, {
timeout: 30_000,
});
await expect(
sessionPage.sessionTabBySessionId(initialSessionIds[tasks.indexOf(task)]),
).toHaveCount(1);
await expect(
sessionPage.sessionTabBySessionId(implementSessionIds.get(task.id)!),
).toHaveCount(1);
await testPage.reload();
await sessionPage.waitForLoad();
await expect(sessionTabs).toHaveCount(2, {
timeout: 30_000,
});
await expect(
sessionPage.sessionTabBySessionId(initialSessionIds[tasks.indexOf(task)]),
).toHaveCount(1);
await expect(
sessionPage.sessionTabBySessionId(implementSessionIds.get(task.id)!),
).toHaveCount(1);
}
await apiClient.updateWorkflowStep(fixture.implementStep.id, {
agent_profile_id: fixture.profileC.id,
});
const laterStep = await apiClient.createWorkflowStep(fixture.workflow.id, "Later", 4, {
agent_profile_id: fixture.profileC.id,
});
const retainedB = await previewWorkflowMove(
apiClient,
replacementBTask.id,
fixture.workflow.id,
fixture.implementStep.id,
);
expect(retainedB.recipient?.profile_id).toBe(fixture.profileB.id);
const newStep = await previewWorkflowMove(
apiClient,
replacementBTask.id,
fixture.workflow.id,
laterStep.id,
);
expect(newStep.recipient?.profile_id).toBe(fixture.profileC.id);
} finally {
await deleteFixtureTasks(
apiClient,
tasks.map((task) => task.id),
);
await apiClient.deleteWorkflow(fixture.workflow.id).catch(() => undefined);
}
});
});