0743477d48
* 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.
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("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 });
|
|
});
|
|
});
|