summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src/queue.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 21:58:29 +0900
committerAdam Malczewski <[email protected]>2026-06-28 21:58:29 +0900
commit01928fe70d22cb7b0a8d20f38b359c8a0048c34b (patch)
treee0ed7cd3ca0b35cf533fb6a7e084b9de7063fc41 /packages/session-orchestrator/src/queue.test.ts
parent6dd9ea9b935e5011c16faed6c869c976cf5ff172 (diff)
downloaddispatch-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/session-orchestrator/src/queue.test.ts')
-rw-r--r--packages/session-orchestrator/src/queue.test.ts180
1 files changed, 180 insertions, 0 deletions
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index a09a441..65cc06f 100644
--- a/packages/session-orchestrator/src/queue.test.ts
+++ b/packages/session-orchestrator/src/queue.test.ts
@@ -593,3 +593,183 @@ describe("enqueue", () => {
await sealed;
});
});
+
+// --- cancelQueuedMessage facade (remove a single queued message by id) ---
+
+describe("cancelQueuedMessage", () => {
+ it("removes a queued message by id → cancelled:true + post-cancel snapshot", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-cancel", "a");
+ queue.enqueue("conv-cancel", "b");
+ const second = queue.getQueue("conv-cancel")[1];
+ if (second === undefined) throw new Error("expected a second enqueued message");
+ const secondId = second.id;
+ expect(queue.getQueue("conv-cancel")).toHaveLength(2);
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-cancel",
+ messageId: secondId,
+ });
+ expect(result.cancelled).toBe(true);
+ expect(result.queue.map((m) => m.id)).not.toContain(secondId);
+ expect(result.queue).toHaveLength(1);
+ expect(queue.getQueue("conv-cancel").map((m) => m.id)).not.toContain(secondId);
+ });
+
+ it("cancel of the only message empties the queue (cancelled:true)", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-only", "solo");
+ const onlyId = queue.getQueue("conv-only")[0]?.id;
+ if (onlyId === undefined) throw new Error("expected an enqueued message");
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-only",
+ messageId: onlyId,
+ });
+ expect(result.cancelled).toBe(true);
+ expect(result.queue).toEqual([]);
+ expect(queue.getQueue("conv-only")).toEqual([]);
+ });
+
+ it("cancel of a missing id → cancelled:false, queue unchanged (idempotent)", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-miss", "a");
+ queue.enqueue("conv-miss", "b");
+ const before = queue.getQueue("conv-miss");
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-miss",
+ messageId: "does-not-exist",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue.map((m) => m.id)).toEqual(before.map((m) => m.id));
+ // live state unchanged
+ expect(queue.getQueue("conv-miss").map((m) => m.id)).toEqual(before.map((m) => m.id));
+ });
+
+ it("cancel on an unknown conversation → cancelled:false, empty queue", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "never-existed",
+ messageId: "anything",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue).toEqual([]);
+ });
+
+ it("no queue ext (resolveQueue undefined) → cancelled:false, empty queue (degraded)", () => {
+ const store = createInMemoryStore();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ // resolveQueue intentionally omitted — feature degrades off
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-noqueue",
+ messageId: "whatever",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue).toEqual([]);
+ });
+
+ it("cancelled message is NOT delivered as steering (never runs)", async () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-steer", "keep-me");
+ const cancelId = queue.enqueue("conv-steer", "cancel-me")[1]?.id;
+ if (cancelId === undefined) throw new Error("expected a second enqueued message");
+ // the first message id (kept)
+ const keepId = queue.getQueue("conv-steer")[0]?.id;
+ if (keepId === undefined) throw new Error("expected a kept message");
+
+ const { captured, drainedMessages, runTurn: captureRunTurn } = createDrainingCaptureRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveQueue: () => queue,
+ });
+
+ // Cancel the second message BEFORE the turn drains.
+ const cancelResult = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-steer",
+ messageId: cancelId,
+ });
+ expect(cancelResult.cancelled).toBe(true);
+ expect(cancelResult.queue.map((m) => m.id)).toEqual([keepId]);
+
+ const events: AgentEvent[] = [];
+ const unsub = orchestrator.subscribe("conv-steer", (e) => events.push(e));
+
+ orchestrator.startTurn({ conversationId: "conv-steer", text: "go" });
+ await waitForSealed(orchestrator, "conv-steer");
+ unsub();
+
+ // The steering drain combined ONLY the kept message — the cancelled one
+ // is absent from the drained text.
+ expect(drainedMessages).toHaveLength(1);
+ const steerMsg = drainedMessages[0];
+ if (steerMsg === undefined) throw new Error("expected a drained message");
+ const chunk = steerMsg.chunks[0];
+ if (chunk === undefined || chunk.type !== "text") throw new Error("expected text chunk");
+ expect(chunk.text).toBe("keep-me");
+ expect(chunk.text).not.toContain("cancel-me");
+
+ // drainSteering was wired + the queue is now empty (the kept one drained).
+ expect(captured[0]?.drainSteering).toBeDefined();
+ expect(queue.getQueue("conv-steer")).toHaveLength(0);
+
+ // The steering event carries only the kept text.
+ const steering = events.find(isSteering);
+ expect(steering?.text).toBe("keep-me");
+ });
+});