diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 21:58:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 21:58:29 +0900 |
| commit | 01928fe70d22cb7b0a8d20f38b359c8a0048c34b (patch) | |
| tree | e0ed7cd3ca0b35cf533fb6a7e084b9de7063fc41 /packages/transport-ws/src | |
| parent | 6dd9ea9b935e5011c16faed6c869c976cf5ff172 (diff) | |
| download | dispatch-01928fe70d22cb7b0a8d20f38b359c8a0048c34b.tar.gz dispatch-01928fe70d22cb7b0a8d20f38b359c8a0048c34b.zip | |
feat(message-queue): cancel a queued steering message by id
Add the ability to cancel/close a single queued message so it never runs:
while a turn is GENERATING and a user message sits in the steering queue
(waiting for delivery at the next tool-result boundary or carry into a new
turn), a client can remove one message by id. The cancelled message is never
delivered as steering and never carried into a new turn. Complements the
existing chat.queue enqueue (enqueue adds; cancel removes one).
Layers (all additive; nothing existing breaks):
- message-queue pure core: `cancel(state, conversationId, messageId)` —
splices a single message out by id, returns the post-cancel snapshot.
Idempotent (no-op if not found). Drops the key when the queue empties
(mirrors drain).
- message-queue service: `MessageQueueService.cancel()` — wraps the pure op,
pushes a surface update ONLY on a real change (queue shrank); a missing-id
cancel is a no-op with no surface push (mirrors drain's no-notify-on-empty).
- session-orchestrator: `cancelQueuedMessage({ conversationId, messageId })`
-> `{ cancelled, queue }`. The single entry transports call; resolves the
queue lazily (same as enqueue). Degrades to `{ cancelled: false, queue: [] }`
when the message-queue extension isn't loaded. `cancelled` is derived from
the queue length delta (true iff a message was removed).
API contract (documented for the frontend agent; see
frontend-cancel-queued-message-handoff.md):
HTTP: DELETE /conversations/:id/queue/:messageId
-> 200 QueueCancelResponse { conversationId, cancelled, queue }
(cancelled:false is a 200 idempotent no-op, not an error)
WS: chat.queue.cancel { type:"chat.queue.cancel", conversationId, messageId }
(additive to WsClientMessage). Fire-and-forget like chat.queue: success
is confirmed by the message-queue SURFACE updating (the cancelled
message leaves payload.messages). A missing-id cancel is a silent
no-op (no surface update, no error). Malformed (empty conversationId/
messageId) -> chat.error.
No new AgentEvent: a cancelled message never appears in the transcript (it
never runs). The existing message-queue surface already reflects the
post-cancel snapshot. Race-safe by construction: if the kernel drains the
queue (steering) or carries it into a new turn before the cancel runs, the
message is already gone -> cancel returns cancelled:false (a no-op).
Version bump: @dispatch/transport-contract 0.23.0 -> 0.24.0 (additive:
QueueCancelResponse + ChatQueueCancelMessage added to WsClientMessage).
@dispatch/wire unchanged (QueuedMessage.id is the cancel target).
No CLI command added: the CLI has no queue-listing affordance to discover a
messageId, so a CLI cancel would have no input source. The HTTP DELETE is
available for any non-WS client that knows the id (e.g. from a prior enqueue
response's queue[]).
Verification: tsc -b EXIT 0; vitest 2024 passed / 6 skipped (25 new tests);
biome EXIT 0 (0 errors).
Diffstat (limited to 'packages/transport-ws/src')
| -rw-r--r-- | packages/transport-ws/src/extension.ts | 17 | ||||
| -rw-r--r-- | packages/transport-ws/src/router.test.ts | 55 | ||||
| -rw-r--r-- | packages/transport-ws/src/router.ts | 50 |
3 files changed, 121 insertions, 1 deletions
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index d26712b..88f721e 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -345,6 +345,23 @@ export function createTransportWsExtension(): Extension { break; } + case "chat-queue-cancel": { + // Fire-and-forget: success is confirmed by the message-queue + // SURFACE updating (the cancelled message leaves the snapshot), + // NOT by a reply here. Cancelling a message that is no longer + // queued is a silent no-op (no surface update, no error). + const cancelResult = orchestrator.cancelQueuedMessage({ + conversationId: result.conversationId, + messageId: result.messageId, + }); + logger.info?.("transport-ws: chat.queue.cancel accepted", { + conversationId: result.conversationId, + messageId: result.messageId, + cancelled: cancelResult.cancelled, + }); + break; + } + case "chat-error": { logger.warn?.("transport-ws: malformed chat.send", { reason: result.errorMessage, diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index 3c3e70b..19b5bb5 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -604,6 +604,59 @@ describe("routeClientMessage", () => { }); }); + describe("chat.queue.cancel", () => { + it("routes a valid chat.queue.cancel → { kind: 'chat-queue-cancel', conversationId, messageId }", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue.cancel", + conversationId: "conv-1", + messageId: "q-42", + }); + + expect(result).toEqual({ + kind: "chat-queue-cancel", + conversationId: "conv-1", + messageId: "q-42", + }); + }); + + it("rejects empty conversationId → chat-error (no cancel signal)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue.cancel", + conversationId: "", + messageId: "q-42", + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("non-empty string"); + expect(result.errorMessage).toContain("conversationId"); + }); + + it("rejects empty messageId → chat-error (no cancel signal)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + for (const messageId of ["", undefined as unknown as string]) { + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue.cancel", + conversationId: "conv-1", + messageId, + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("non-empty string"); + expect(result.errorMessage).toContain("messageId"); + } + }); + }); + describe("exhaustive switch (regression guard for Wave-0 fan-out)", () => { // Every WsClientMessage variant must route to a defined result with a // known kind — no fall-through / undefined return. If the union is @@ -622,6 +675,7 @@ describe("routeClientMessage", () => { { type: "chat.subscribe", conversationId: "c1" }, { type: "chat.unsubscribe", conversationId: "c1" }, { type: "chat.queue", conversationId: "c1", text: "steer" }, + { type: "chat.queue.cancel", conversationId: "c1", messageId: "m1" }, ]; const validKinds = new Set<RouteResult["kind"]>([ @@ -631,6 +685,7 @@ describe("routeClientMessage", () => { "chat-subscribe", "chat-unsubscribe", "chat-queue", + "chat-queue-cancel", ]); for (const msg of samples) { diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index 0caf305..014db96 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -9,6 +9,7 @@ import type { SurfaceContext, SurfaceRegistry } from "@dispatch/surface-registry"; import type { + ChatQueueCancelMessage, ChatQueueMessage, ChatSendMessage, ChatSubscribeMessage, @@ -99,6 +100,20 @@ export interface ChatQueueRouteResult { readonly workspaceId?: string; } +/** + * The effect a validated chat.queue.cancel should produce. The shell calls + * `orchestrator.cancelQueuedMessage({ conversationId, messageId })` and emits + * NOTHING back (fire-and-forget): success is confirmed by the message-queue + * SURFACE updating (the cancelled message leaves the snapshot). Cancelling a + * message that is no longer queued is a silent no-op (no surface update, no + * error). Mirrors `ChatQueueRouteResult`'s fire-and-forget style. + */ +export interface ChatQueueCancelRouteResult { + readonly kind: "chat-queue-cancel"; + readonly conversationId: string; + readonly messageId: string; +} + /** The effect any client WS message should produce. */ export type RouteResult = | SurfaceRouteResult @@ -106,7 +121,8 @@ export type RouteResult = | ChatRouteError | ChatSubscribeRouteResult | ChatUnsubscribeRouteResult - | ChatQueueRouteResult; + | ChatQueueRouteResult + | ChatQueueCancelRouteResult; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -152,6 +168,8 @@ export function routeClientMessage( return handleChatUnsubscribe(msg); case "chat.queue": return handleChatQueue(msg); + case "chat.queue.cancel": + return handleChatQueueCancel(msg); } } @@ -253,6 +271,36 @@ function handleChatQueue(msg: ChatQueueMessage): ChatQueueRouteResult | ChatRout }; } +/** + * Validate a chat.queue.cancel: both `conversationId` and `messageId` must be + * non-empty strings. Invalid → `chat-error` (the shell replies with `chat.error`, + * same style as a malformed `chat.queue`; the orchestrator is never called). + * Valid → `chat-queue-cancel` (the shell calls `orchestrator.cancelQueuedMessage`). + */ +function handleChatQueueCancel( + msg: ChatQueueCancelMessage, +): ChatQueueCancelRouteResult | ChatRouteError { + if (typeof msg.conversationId !== "string" || msg.conversationId.length === 0) { + return { + kind: "chat-error", + conversationId: msg.conversationId, + errorMessage: "chat.queue.cancel requires a non-empty string `conversationId`", + }; + } + if (typeof msg.messageId !== "string" || msg.messageId.length === 0) { + return { + kind: "chat-error", + conversationId: msg.conversationId, + errorMessage: "chat.queue.cancel requires a non-empty string `messageId`", + }; + } + return { + kind: "chat-queue-cancel", + conversationId: msg.conversationId, + messageId: msg.messageId, + }; +} + // ── Per-message handlers ──────────────────────────────────────────────────── function handleSubscribe( |
