7e8b4bb30e
* feat(quick-chat): eager-init agent on profile selection Switch quick-chat backend from `IntentPrepare` to `IntentStart` so the agent process boots immediately when the user picks a profile, instead of waiting for the first prompt. The ACP `session/new` handshake fires before the user types anything, so the slash menu, mode dropdown, and model selector are populated up-front. Fixes the empty-slash-menu UX in quick chat for ACP agents (OpenCode, Claude, Codex, etc.). Frontend picker shows a per-card spinner + disables sibling cards during boot. Rapid profile switches (and other picker-bypassing actions like opening a new tab, switching tabs, or closing the modal) supersede the in-flight start via a monotonic request id; the resolved task is cleaned up via `deleteQuickChatTask` so we never orphan an ephemeral task or its agent process. Backend rollback uses a fresh background context so `DeleteTask` runs even when the request context has already been cancelled by the client. `mock-agent` now emits `available_commands_update` from `NewSession` and `LoadSession` (matching real OpenCode/Claude) so the eager-init pipeline is exercised in e2e. Existing prompt-time emit kept as an idempotent fallback. * test+cleanup(quick-chat): unit tests for useAgentSelection; minor cleanups - Add 5 unit tests for useAgentSelection covering happy path, rapid-pick supersession, reset() during in-flight, and error toast vs silent-on-superseded. Exercises the orphan-task cleanup that the eager-init flow depends on. (claude bot review) - Drop redundant isDisabled alias in AgentPickerView. (claude bot review) - Drop unused ctx parameter from emitAvailableCommandsAfterDelay — it was always called with context.Background() so the Done() branch was dead code. (claude bot review) - Re-format with prettier after the rebase. * fix(quick-chat): use constants.TaskDeleteTimeout for rollback ctx Match the other DeleteTask call sites in task_http_handlers.go (line 851) instead of an ad-hoc 10s timeout. (claude bot review)
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
import { type Locator, type Page } from "@playwright/test";
|
|
import { test, expect } from "../../fixtures/test-base";
|
|
import type { SeedData } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Type text into the TipTap editor while the agent is busy.
|
|
* fill() silently fails on TipTap when the busy placeholder is shown,
|
|
* so we retry clicking and typing until text appears in the editor.
|
|
*/
|
|
async function typeWhileBusy(page: Page, editor: Locator, text: string): Promise<void> {
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
await editor.scrollIntoViewIfNeeded();
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const box = await editor.boundingBox();
|
|
if (!box) throw new Error("Editor bounding box not found");
|
|
await page.mouse.click(box.x + 20, box.y + box.height / 2);
|
|
await page.waitForTimeout(200);
|
|
await page.keyboard.type(text);
|
|
await page.waitForTimeout(100);
|
|
const content = await editor.textContent();
|
|
if (content?.includes(text)) return;
|
|
// Text wasn't entered; select all and clear for retry
|
|
await page.keyboard.press(`${modifier}+a`);
|
|
await page.keyboard.press("Backspace");
|
|
await page.waitForTimeout(200);
|
|
}
|
|
throw new Error(`Failed to type "${text}" into editor after 3 attempts`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quick Chat queue tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function openQuickChatWithAgent(page: Page): Promise<Locator> {
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
await page.keyboard.press(`${modifier}+Shift+q`);
|
|
|
|
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
|
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
|
|
|
const agentPicker = dialog.getByText("Choose an agent to start chatting");
|
|
if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) {
|
|
await dialog.getByLabel("Start new chat").click();
|
|
}
|
|
await expect(agentPicker).toBeVisible({ timeout: 5_000 });
|
|
|
|
const agentCard = dialog
|
|
.locator("button")
|
|
.filter({ has: page.locator(".rounded-md.border") })
|
|
.first();
|
|
await expect(agentCard).toBeVisible({ timeout: 5_000 });
|
|
await agentCard.click();
|
|
|
|
// Wait for chat input to appear AND become editable. Eager init means the
|
|
// agent starts during the picker → tab transition; the input is briefly
|
|
// disabled while the FE store catches up to the RUNNING session state.
|
|
const editor = dialog.locator(".tiptap.ProseMirror");
|
|
await expect(editor).toBeVisible({ timeout: 15_000 });
|
|
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
|
|
return dialog;
|
|
}
|
|
|
|
test.describe("Quick chat queue", () => {
|
|
// Allow 1 retry: the test can be flaky when a previous test cycle's agent process hasn't
|
|
// fully shut down, causing the new session to conflict with a stale execution.
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("queued message indicator appears and message executes after agent turn", async ({
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
|
|
const dialog = await openQuickChatWithAgent(testPage);
|
|
|
|
// Send a slow command so the agent stays busy for 10 seconds.
|
|
const editor = dialog.locator(".tiptap.ProseMirror");
|
|
await editor.click();
|
|
await editor.fill("/slow 10s");
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
await editor.press(`${modifier}+Enter`);
|
|
|
|
// Wait for agent to become busy.
|
|
await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible(
|
|
{
|
|
timeout: 15_000,
|
|
},
|
|
);
|
|
await testPage.waitForTimeout(500);
|
|
|
|
await typeWhileBusy(testPage, editor, "hello world");
|
|
await testPage.keyboard.press(`${modifier}+Enter`);
|
|
|
|
// Verify the queued message indicator with cancel button appears.
|
|
const cancelBtn = dialog.getByTitle("Cancel queued message");
|
|
await expect(cancelBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Wait for the first (slow) response to complete.
|
|
await expect(dialog.getByText("Slow response complete", { exact: false })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// The queued message should auto-execute — wait for the agent turn to finish.
|
|
await expect(
|
|
dialog.locator('[data-placeholder="Continue working on the task..."]'),
|
|
).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
});
|
|
|
|
test("queue message via submit button click", async ({ testPage }) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const dialog = await openQuickChatWithAgent(testPage);
|
|
|
|
// Send a slow command so the agent stays busy for 10 seconds.
|
|
const editor = dialog.locator(".tiptap.ProseMirror");
|
|
await editor.click();
|
|
await editor.fill("/slow 10s");
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
await editor.press(`${modifier}+Enter`);
|
|
|
|
// Wait for agent to become busy.
|
|
await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible(
|
|
{
|
|
timeout: 15_000,
|
|
},
|
|
);
|
|
await testPage.waitForTimeout(500);
|
|
|
|
// Before typing, only the cancel button should be visible (no send button).
|
|
const submitBtn = dialog.getByTestId("submit-message-button");
|
|
await expect(submitBtn).not.toBeVisible();
|
|
await expect(dialog.getByTestId("cancel-agent-button")).toBeVisible();
|
|
|
|
// Type a queued message — the submit button should appear.
|
|
await typeWhileBusy(testPage, editor, "queued via button");
|
|
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Click the submit button (not keyboard shortcut) to queue the message.
|
|
await submitBtn.click();
|
|
|
|
// Verify the queued message indicator with cancel button appears.
|
|
const cancelBtn = dialog.getByTitle("Cancel queued message");
|
|
await expect(cancelBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Verify the cancel-agent button is also visible alongside submit.
|
|
const cancelAgentBtn = dialog.getByTestId("cancel-agent-button");
|
|
await expect(cancelAgentBtn).toBeVisible();
|
|
|
|
// Wait for the first (slow) response to complete and queued message to auto-execute.
|
|
await expect(
|
|
dialog.locator('[data-placeholder="Continue working on the task..."]'),
|
|
).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task session queue tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function seedTaskAndWaitForIdle(
|
|
testPage: Page,
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
description = "/e2e:simple-message",
|
|
): Promise<SessionPage> {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description,
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
|
|
|
return session;
|
|
}
|
|
|
|
test.describe("Task session queue", () => {
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("queue message via submit button on task session page", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const session = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Queue button test",
|
|
);
|
|
|
|
// Send a slow command to keep the agent busy.
|
|
await session.sendMessage("/slow 5s");
|
|
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
|
await testPage.waitForTimeout(500);
|
|
|
|
// Type a message while agent is busy.
|
|
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
|
await typeWhileBusy(testPage, editor, "queued via button");
|
|
|
|
// Both submit and cancel-agent buttons should be visible.
|
|
const submitBtn = testPage.getByTestId("submit-message-button");
|
|
const cancelAgentBtn = testPage.getByTestId("cancel-agent-button");
|
|
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
|
await expect(cancelAgentBtn).toBeVisible();
|
|
|
|
// Click the submit button to queue the message.
|
|
await submitBtn.click();
|
|
|
|
// Verify the queued message indicator appears.
|
|
await expect(testPage.getByTitle("Cancel queued message")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Wait for the queued message to auto-execute and agent to become idle.
|
|
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
|
});
|
|
|
|
test("queue editor textarea scrolls when content is long", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const session = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Queue editor scroll test",
|
|
);
|
|
|
|
// Send a slow command to keep the agent busy.
|
|
await session.sendMessage("/slow 10s");
|
|
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
|
await testPage.waitForTimeout(500);
|
|
|
|
// Type a short message while agent is busy and queue it.
|
|
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
|
await typeWhileBusy(testPage, editor, "short queued msg");
|
|
|
|
const submitBtn = testPage.getByTestId("submit-message-button");
|
|
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
|
await submitBtn.click();
|
|
|
|
// Verify the queued indicator appears, then click edit.
|
|
const editBtn = testPage.getByTitle("Edit message");
|
|
await expect(editBtn).toBeVisible({ timeout: 10_000 });
|
|
await editBtn.click();
|
|
|
|
// The edit textarea should now be visible.
|
|
const textarea = testPage.getByTestId("queue-edit-textarea");
|
|
await expect(textarea).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Fill via native setter + React event so the controlled component updates.
|
|
const longText = Array.from(
|
|
{ length: 30 },
|
|
(_, i) => `Line ${i + 1} of scroll test content`,
|
|
).join("\n");
|
|
await textarea.evaluate((el: HTMLTextAreaElement, text: string) => {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
|
|
setter.call(el, text);
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}, longText);
|
|
|
|
// Allow layout to settle after content change.
|
|
await testPage.waitForTimeout(300);
|
|
|
|
// Verify the textarea has a constrained max-height and is scrollable.
|
|
const metrics = await textarea.evaluate((el: HTMLTextAreaElement) => ({
|
|
scrollHeight: el.scrollHeight,
|
|
clientHeight: el.clientHeight,
|
|
maxHeight: getComputedStyle(el).maxHeight,
|
|
overflowY: getComputedStyle(el).overflowY,
|
|
}));
|
|
|
|
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
|
|
expect(metrics.maxHeight).toBe("200px");
|
|
expect(metrics.overflowY).toBe("auto");
|
|
});
|
|
|
|
test("queue message with plan mode enabled via submit button", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const session = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Queue plan mode test",
|
|
);
|
|
|
|
// Enable plan mode.
|
|
await session.togglePlanMode();
|
|
|
|
// Send a slow command to keep the agent busy.
|
|
await session.sendMessage("/slow 5s");
|
|
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
|
await testPage.waitForTimeout(500);
|
|
|
|
// In plan mode with no typed text, only the cancel button should be visible.
|
|
// The auto-added plan context should NOT cause the send button to appear.
|
|
const submitBtn = testPage.getByTestId("submit-message-button");
|
|
await expect(submitBtn).not.toBeVisible();
|
|
await expect(testPage.getByTestId("cancel-agent-button")).toBeVisible();
|
|
|
|
// Type a message while agent is busy — send button should appear.
|
|
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
|
await typeWhileBusy(testPage, editor, "plan queue test");
|
|
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Click the submit button to queue the message.
|
|
await submitBtn.click();
|
|
|
|
// Verify the queued message indicator shows clean text (no system tags).
|
|
const queueIndicator = testPage.getByTitle("Cancel queued message").locator("..");
|
|
await expect(queueIndicator).toBeVisible({ timeout: 10_000 });
|
|
await expect(queueIndicator).not.toContainText("kandev-system");
|
|
|
|
// Wait for agent to finish processing.
|
|
await expect(session.planModeInput()).toBeVisible({ timeout: 30_000 });
|
|
});
|
|
});
|