Files
kandev/apps/web/e2e/tests/chat/message-queue.spec.ts
T
José Almeida 0743477d48 feat: persist N-entry FIFO message queue (#864)
* feat(orchestrator): persist N-entry FIFO message queue

Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.

Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.

* feat(web): N-entry queue state, API, and optimistic hook

Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).

* feat(web): inline ghost-message queue UI with sender chips

Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.

* fix(queue): address review — auth guard, TOCTOU, error UX

- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
  the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
  so the UpdateContent ownership guard always runs against a non-empty owner;
  inter-task entries (`queued_by="agent"`) can no longer be overwritten by
  user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
  (prior split released the lock between tail-check and Insert, allowing two
  same-sender callers to race past coalescing). TransferSession now shifts
  source positions past the destination's max so transferred entries always
  drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
  → "message already sent"); exit edit mode on failure instead of leaving the
  textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
  resets to `metaMax ?? 0` so the next status_changed event lands authoritative
  capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
  alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.

* test(web): align queue e2e selectors with new ghost-message UI

The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.

* fix(queue): address second review pass — auth, drain race, tests

- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
  outright, closing the gap a hostile WS client could exploit by
  impersonating the inter-task sender identity to overwrite agent
  entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
  `MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
  when two concurrent take callers race past the busy-timeout the
  loser sees 0 affected rows and now returns `nil, nil` instead of
  the already-drained message (would otherwise dispatch the same
  prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
  error path so the wire shape stays consistent (frontend already
  guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
  (queue_full / entry_not_found / generic / Error pass-through);
  `session-slice.upsert.test.ts` covers `setQueueEntries`,
  `removeQueueEntry`, and `clearQueueStatus`. Backend gains an
  agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
  errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
  with `_, _ = ...`.

* fix(queue): scope update/remove by session_id (cross-session deletion guard)

The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.

- `Repository.UpdateContent` and `Repository.DeleteByID` now require
  `sessionID` and add `AND session_id = ?` to the SQL guard. The memory
  repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
  WS handlers thread `req.SessionID` through (already validated as
  non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
  `removeQueuedEntry` params so callers fail at compile time, matching
  the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
  with an attacker-supplied session_id but a real entry id from another
  session must return `ErrEntryNotFound` and leave the victim entry
  untouched. SQLite repo gets parallel coverage.

* fix(queue): propagate transfer error + tighten WSError narrowing

- `Service.TransferSession` now returns an error so callers can fail
  closed instead of silently leaving entries orphaned on the previous
  session. The two existing call sites pick the right policy:
  `switchSessionForStep` (reusing an existing session) propagates the
  error so the workflow switch aborts; the new-session path logs and
  continues, since the new session is already committed and rolling
  back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
  without a `code` field are still mapped to a meaningful `Error.message`
  via `rethrowQueueError` (instead of "[object Object]"). Native
  `Error` instances explicitly skip the WS-error branch so they pass
  through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
  so a missing throw fails the test instead of silently passing.

* fix(queue): check TransferSession error in service tests

The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
2026-05-11 22:40:36 +01:00

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("Remove 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("Remove 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("Remove 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 queued 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 ghost list shows clean text (no system tags).
const queueIndicator = testPage.getByTestId("queued-ghost-list");
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 });
});
});