Files
kandev/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts
T

251 lines
9.8 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { WsWatcher } from "../../helpers/causal-waits";
import { waitForAgentMessage, waitForSessionState } from "../../helpers/session";
import { waitForComposerQueueMode } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
import { SidebarFilterPopoverPage } from "../../pages/sidebar-filter-popover";
// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5
// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.6
export async function expectSendNowWorkflowRunning(
page: Page,
api: ApiClient,
seed: SeedData,
mobile: boolean,
): Promise<void> {
const workflow = await api.createWorkflow(seed.workspaceId, "Queued turn state");
const review = await api.createWorkflowStep(workflow.id, "Review", 0, { is_start_step: true });
const working = await api.createWorkflowStep(workflow.id, "In Progress", 1);
await api.createWorkflowStep(workflow.id, "Done", 2);
const task = await api.createTaskWithAgent(
seed.workspaceId,
"Queued workflow state",
seed.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: workflow.id,
workflow_step_id: review.id,
repository_ids: [seed.repositoryId],
},
);
if (!task.session_id) throw new Error("Task did not return a session ID");
const sessionId = task.session_id;
await page.goto(`/t/${task.id}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle();
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "queued workflow session should settle before queue setup",
});
await api.updateWorkflowStep(review.id, {
events: { on_turn_start: [{ type: "move_to_step", config: { step_id: working.id } }] },
});
const identity = await api.getQueueSessionIdentity(task.id, sessionId);
await api.setQueueAutoRun(identity, false);
await api.queueMessage(
identity,
[
'e2e:message("replacement is active")',
// Flush the text buffer before holding the mock turn open.
'e2e:tool_use("Check", {})',
'e2e:tool_result("checked")',
"e2e:delay(15000)",
'e2e:message("replacement finished")',
].join("\n"),
);
const chat = session.activeChat();
const chip = chat.getByTestId("queue-chip");
if (mobile) await chip.tap();
else await chip.click();
const row = chat.getByTestId("queue-entry");
await expect(row).toHaveCount(1);
if (!mobile) await row.hover();
const sendNow = row.getByTestId("queue-entry-send-now");
if (mobile) await sendNow.tap();
else await sendNow.click();
const agentBodies = chat.locator("[data-agent-message-body][data-message-id]");
await expect(agentBodies.filter({ hasText: "replacement is active" })).toBeVisible();
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "RUNNING",
message: "Send Now should keep the queued workflow turn running",
});
const runningTask = await api.getTask(task.id);
expect(runningTask.state).toBe("IN_PROGRESS");
expect(runningTask.workflow_step_id).toBe(working.id);
await expect(session.cancelAgentButton()).toBeVisible();
if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap();
const surface = mobile
? page.getByRole("dialog", { name: "Tasks", exact: true })
: session.sidebar;
const filters = new SidebarFilterPopoverPage(page);
if (mobile) await surface.getByTestId("sidebar-filter-gear").tap();
else await filters.open();
await filters.setGroup("State");
await filters.close();
await expect(
surface.locator('[data-testid="sidebar-group-header"][data-group-key="IN_PROGRESS"]'),
).toBeVisible();
await expect(
surface.locator(`[data-task-row-id="${task.id}"]`).getByTestId("task-state-running"),
).toBeVisible();
if (mobile) await page.keyboard.press("Escape");
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "queued workflow turn should settle after its replacement prompt",
});
await expect(agentBodies.filter({ hasText: "replacement finished" })).toBeVisible();
await expect.poll(async () => (await api.getTask(task.id)).state).toBe("REVIEW");
await expect(session.cancelAgentButton()).toBeHidden();
}
function activeQueuedMessage(
marker: string,
holdMs = 15_000,
completionMarker = `${marker} finished`,
): string {
return [
`e2e:message("${marker}")`,
'e2e:tool_use("Check", {})',
'e2e:tool_result("checked")',
`e2e:delay(${holdMs})`,
`e2e:message("${completionMarker}")`,
].join("\n");
}
// @covers AC-UI-MESSAGE-QUEUE-SEND-NOW-001.1
// @covers AC-UI-MESSAGE-QUEUE-SEND-NOW-001.2
// @covers AC-UI-MESSAGE-QUEUE-SEND-NOW-001.9
// @covers AC-UI-MESSAGE-QUEUE-SEND-NOW-001.11
export async function expectSendNowInterruptsRunningFIFOTurn(
page: Page,
api: ApiClient,
seed: SeedData,
mobile: boolean,
gateway: WsWatcher,
): Promise<void> {
const task = await api.createTaskWithAgent(
seed.workspaceId,
mobile ? "Mobile FIFO Send Now" : "Desktop FIFO Send Now",
seed.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seed.workflowId,
workflow_step_id: seed.startStepId,
repository_ids: [seed.repositoryId],
},
);
if (!task.session_id) throw new Error("Task did not return a session ID");
const sessionId = task.session_id;
const identity = await api.getQueueSessionIdentity(task.id, sessionId);
await api.setQueueAutoMerge(identity, false);
await api.setQueueAutoRun(identity, true);
await page.goto(`/t/${task.id}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "FIFO Send Now fixture should start idle",
});
// The initial turn only opens a busy window. A is delivered later by the
// ordinary Auto-run path, which is the behavior under test.
if (mobile) {
await session.sendMessageViaButton("/slow 3s");
} else {
await session.sendMessage("/slow 3s");
}
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await waitForComposerQueueMode(page);
const markerA = "fifo A response";
const markerB = "fifo B response";
const markerC = "fifo C response";
const completionA = "fifo A finished";
const completionB = "fifo B finished";
const completionC = "fifo C finished";
await api.queueMessage(identity, activeQueuedMessage(markerA, 15_000, completionA));
await waitForAgentMessage(api, sessionId, markerA, 60_000);
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "RUNNING",
message: "FIFO A should own the running turn before B is queued",
});
const workflowStepBefore = (await api.getTask(task.id)).workflow_step_id;
await api.queueMessage(identity, activeQueuedMessage(markerB, 10_000, completionB));
await api.queueMessage(identity, activeQueuedMessage(markerC, 250, completionC));
const chat = session.activeChat();
const chip = chat.getByTestId("queue-chip");
if (mobile) await chip.tap();
else await chip.click();
const panel = chat.getByTestId("queued-ghost-list");
await expect(panel).toBeVisible();
await expect(panel.getByTestId("queue-entry-text")).toHaveCount(2);
const target = panel.getByTestId("queue-entry").filter({ hasText: markerB });
await expect(target).toBeVisible();
if (!mobile) await target.hover();
const sendNow = target.getByTestId("queue-entry-send-now");
await expect(sendNow).toBeVisible();
await expect(sendNow).toBeEnabled();
if (mobile) {
const box = await sendNow.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
}
const sendNowResponse = gateway.waitForResponse("message.queue.send_now");
if (mobile) await sendNow.tap();
else await sendNow.click();
await sendNowResponse;
await expect(panel.getByTestId("queue-entry-text")).toHaveCount(1);
await expect(panel.getByTestId("queue-entry-text")).toContainText(markerC);
await expect(panel.getByTestId("queue-auto-run")).toHaveAttribute("data-state", "checked");
await expect(chat.getByTestId("user-message-bubble").filter({ hasText: markerB })).toHaveCount(1);
await waitForAgentMessage(api, sessionId, markerB, 30_000);
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "RUNNING",
message: "Send Now replacement B should remain active while C is pending",
});
expect((await api.getTask(task.id)).workflow_step_id).toBe(workflowStepBefore);
await expect(panel.getByTestId("queue-entry-text")).toHaveCount(1);
await expect(panel.getByTestId("queue-entry-text")).toContainText(markerC);
await waitForAgentMessage(api, sessionId, markerC, 60_000);
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "FIFO C should settle after replacement B",
});
await expect.poll(async () => (await api.getQueueStatus(identity)).count).toBe(0);
const agentBodies = chat.locator("[data-agent-message-body][data-message-id]");
await expect(agentBodies.filter({ hasText: markerA })).toHaveCount(1);
await expect(agentBodies.filter({ hasText: markerB })).toHaveCount(1);
await expect(agentBodies.filter({ hasText: markerC })).toHaveCount(1);
await expect(agentBodies.filter({ hasText: completionA })).toHaveCount(0);
await expect(agentBodies.filter({ hasText: completionB })).toHaveCount(1);
await expect(agentBodies.filter({ hasText: completionC })).toHaveCount(1);
await expect(chat).not.toContainText("Turn cancelled by user");
}