diff options
Diffstat (limited to 'packages/message-queue/src')
| -rw-r--r-- | packages/message-queue/src/extension.ts | 106 | ||||
| -rw-r--r-- | packages/message-queue/src/index.ts | 27 | ||||
| -rw-r--r-- | packages/message-queue/src/pure.test.ts | 317 | ||||
| -rw-r--r-- | packages/message-queue/src/pure.ts | 110 | ||||
| -rw-r--r-- | packages/message-queue/src/service.test.ts | 237 | ||||
| -rw-r--r-- | packages/message-queue/src/service.ts | 127 |
6 files changed, 570 insertions, 354 deletions
diff --git a/packages/message-queue/src/extension.ts b/packages/message-queue/src/extension.ts index 26d19ca..29979be 100644 --- a/packages/message-queue/src/extension.ts +++ b/packages/message-queue/src/extension.ts @@ -12,71 +12,71 @@ import { buildQueueSpec, MESSAGE_QUEUE_SURFACE_ID } from "./pure.js"; import { createMessageQueueService, messageQueueHandle } from "./service.js"; export const manifest: Manifest = { - id: "message-queue", - name: "Message Queue", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["surface-registry"], - capabilities: {}, - contributes: { - services: ["message-queue"], - }, + id: "message-queue", + name: "Message Queue", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["surface-registry"], + capabilities: {}, + contributes: { + services: ["message-queue"], + }, }; export function activate(host: HostAPI): void { - const registry = host.getService(surfaceRegistryHandle); + const registry = host.getService(surfaceRegistryHandle); - const subscribers = new Set<() => void>(); + const subscribers = new Set<() => void>(); - const service = createMessageQueueService({ - id: () => crypto.randomUUID(), - now: () => Date.now(), - notify: () => { - for (const sub of subscribers) { - sub(); - } - }, - logger: host.logger, - }); + const service = createMessageQueueService({ + id: () => crypto.randomUUID(), + now: () => Date.now(), + notify: () => { + for (const sub of subscribers) { + sub(); + } + }, + logger: host.logger, + }); - host.provideService(messageQueueHandle, service); + host.provideService(messageQueueHandle, service); - function getSpec(context?: SurfaceContext): SurfaceSpec { - const convId = context?.conversationId; - const messages = convId === undefined ? [] : service.getQueue(convId); - return buildQueueSpec(messages); - } + function getSpec(context?: SurfaceContext): SurfaceSpec { + const convId = context?.conversationId; + const messages = convId === undefined ? [] : service.getQueue(convId); + return buildQueueSpec(messages); + } - function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void { - // The message-queue surface is read-only: a client renders the queue and - // the session-orchestrator drains it. No client-facing actions. - } + function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void { + // The message-queue surface is read-only: a client renders the queue and + // the session-orchestrator drains it. No client-facing actions. + } - const provider: SurfaceProvider = { - catalogEntry: { - id: MESSAGE_QUEUE_SURFACE_ID, - region: "side", - title: "Message Queue", - scope: "conversation", - }, - getSpec, - invoke, - subscribe(onChange) { - subscribers.add(onChange); - return () => { - subscribers.delete(onChange); - }; - }, - }; + const provider: SurfaceProvider = { + catalogEntry: { + id: MESSAGE_QUEUE_SURFACE_ID, + region: "side", + title: "Message Queue", + scope: "conversation", + }, + getSpec, + invoke, + subscribe(onChange) { + subscribers.add(onChange); + return () => { + subscribers.delete(onChange); + }; + }, + }; - registry.register(provider); + registry.register(provider); - host.logger.info("message-queue: registered"); + host.logger.info("message-queue: registered"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/message-queue/src/index.ts b/packages/message-queue/src/index.ts index 79bbb25..ae0868b 100644 --- a/packages/message-queue/src/index.ts +++ b/packages/message-queue/src/index.ts @@ -9,19 +9,20 @@ export type { QueuedMessage, QueuePayload } from "@dispatch/wire"; export { extension, manifest } from "./extension.js"; export { - buildQueueSpec, - combine, - drain, - enqueue, - getQueue, - MESSAGE_QUEUE_RENDERER_ID, - MESSAGE_QUEUE_SURFACE_ID, - type MessageQueueState, - type QueueDeps, + buildQueueSpec, + cancel, + combine, + drain, + enqueue, + getQueue, + MESSAGE_QUEUE_RENDERER_ID, + MESSAGE_QUEUE_SURFACE_ID, + type MessageQueueState, + type QueueDeps, } from "./pure.js"; export { - createMessageQueueService, - type MessageQueueDeps, - type MessageQueueService, - messageQueueHandle, + createMessageQueueService, + type MessageQueueDeps, + type MessageQueueService, + messageQueueHandle, } from "./service.js"; diff --git a/packages/message-queue/src/pure.test.ts b/packages/message-queue/src/pure.test.ts index ff1a08f..4fca0fa 100644 --- a/packages/message-queue/src/pure.test.ts +++ b/packages/message-queue/src/pure.test.ts @@ -1,144 +1,223 @@ import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - buildQueueSpec, - combine, - drain, - enqueue, - getQueue, - MESSAGE_QUEUE_RENDERER_ID, - MESSAGE_QUEUE_SURFACE_ID, - type MessageQueueState, - type QueueDeps, + buildQueueSpec, + cancel, + combine, + drain, + enqueue, + getQueue, + MESSAGE_QUEUE_RENDERER_ID, + MESSAGE_QUEUE_SURFACE_ID, + type MessageQueueState, + type QueueDeps, } from "./pure.js"; function makeDeps(): QueueDeps { - let idCounter = 0; - let now = 1_000; - return { - id: () => `id-${++idCounter}`, - now: () => (now += 10), - }; + let idCounter = 0; + let now = 1_000; + return { + id: () => `id-${++idCounter}`, + now: () => (now += 10), + }; } describe("enqueue", () => { - it("enqueue appends + returns snapshot with the new message (id unique, queuedAt set)", () => { - const state: MessageQueueState = new Map(); - const deps = makeDeps(); - - const first = enqueue(state, "c1", "first", deps); - expect(first).toHaveLength(1); - const firstMsg = first[0]; - if (firstMsg === undefined) throw new Error("expected a message"); - expect(firstMsg.id).toBe("id-1"); - expect(firstMsg.text).toBe("first"); - expect(firstMsg.queuedAt).toBe(1_010); - - const second = enqueue(state, "c1", "second", deps); - expect(second).toHaveLength(2); - const secondMsg = second[1]; - if (secondMsg === undefined) throw new Error("expected a message"); - expect(secondMsg.id).toBe("id-2"); // unique per message - expect(secondMsg.text).toBe("second"); - expect(secondMsg.queuedAt).toBe(1_020); - - // a separate conversation does not share state - const other = enqueue(state, "c2", "other", deps); - expect(other).toHaveLength(1); - expect(getQueue(state, "c2")).toHaveLength(1); - }); + it("enqueue appends + returns snapshot with the new message (id unique, queuedAt set)", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + + const first = enqueue(state, "c1", "first", deps); + expect(first).toHaveLength(1); + const firstMsg = first[0]; + if (firstMsg === undefined) throw new Error("expected a message"); + expect(firstMsg.id).toBe("id-1"); + expect(firstMsg.text).toBe("first"); + expect(firstMsg.queuedAt).toBe(1_010); + + const second = enqueue(state, "c1", "second", deps); + expect(second).toHaveLength(2); + const secondMsg = second[1]; + if (secondMsg === undefined) throw new Error("expected a message"); + expect(secondMsg.id).toBe("id-2"); // unique per message + expect(secondMsg.text).toBe("second"); + expect(secondMsg.queuedAt).toBe(1_020); + + // a separate conversation does not share state + const other = enqueue(state, "c2", "other", deps); + expect(other).toHaveLength(1); + expect(getQueue(state, "c2")).toHaveLength(1); + }); }); describe("getQueue", () => { - it("getQueue returns current snapshot; empty array when none", () => { - const state: MessageQueueState = new Map(); - const deps = makeDeps(); + it("getQueue returns current snapshot; empty array when none", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); - expect(getQueue(state, "unknown")).toEqual([]); + expect(getQueue(state, "unknown")).toEqual([]); - enqueue(state, "c1", "x", deps); - enqueue(state, "c1", "y", deps); - const snap = getQueue(state, "c1"); - expect(snap.map((m) => m.text)).toEqual(["x", "y"]); + enqueue(state, "c1", "x", deps); + enqueue(state, "c1", "y", deps); + const snap = getQueue(state, "c1"); + expect(snap.map((m) => m.text)).toEqual(["x", "y"]); - // returns a COPY — mutating it does not affect live state - snap.push({ id: "evil", text: "mutate", queuedAt: 0 }); - expect(getQueue(state, "c1")).toHaveLength(2); - }); + // returns a COPY — mutating it does not affect live state + snap.push({ id: "evil", text: "mutate", queuedAt: 0 }); + expect(getQueue(state, "c1")).toHaveLength(2); + }); }); describe("drain", () => { - it("drain returns all + clears; second drain returns []", () => { - const state: MessageQueueState = new Map(); - const deps = makeDeps(); - enqueue(state, "c1", "a", deps); - enqueue(state, "c1", "b", deps); - - const drained = drain(state, "c1"); - expect(drained.map((m) => m.text)).toEqual(["a", "b"]); - - // cleared - expect(getQueue(state, "c1")).toEqual([]); - expect(drain(state, "c1")).toEqual([]); - }); - - it("drain on an empty queue returns [] and is a no-op", () => { - const state: MessageQueueState = new Map(); - - // never had a queue - expect(drain(state, "c1")).toEqual([]); - expect(getQueue(state, "c1")).toEqual([]); - - // and after a drain that emptied it, draining again is a no-op - const deps = makeDeps(); - enqueue(state, "c1", "once", deps); - drain(state, "c1"); - expect(drain(state, "c1")).toEqual([]); - expect(getQueue(state, "c1")).toEqual([]); - }); + it("drain returns all + clears; second drain returns []", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); + enqueue(state, "c1", "b", deps); + + const drained = drain(state, "c1"); + expect(drained.map((m) => m.text)).toEqual(["a", "b"]); + + // cleared + expect(getQueue(state, "c1")).toEqual([]); + expect(drain(state, "c1")).toEqual([]); + }); + + it("drain on an empty queue returns [] and is a no-op", () => { + const state: MessageQueueState = new Map(); + + // never had a queue + expect(drain(state, "c1")).toEqual([]); + expect(getQueue(state, "c1")).toEqual([]); + + // and after a drain that emptied it, draining again is a no-op + const deps = makeDeps(); + enqueue(state, "c1", "once", deps); + drain(state, "c1"); + expect(drain(state, "c1")).toEqual([]); + expect(getQueue(state, "c1")).toEqual([]); + }); +}); + +describe("cancel", () => { + it("removes the matching message and returns the post-cancel snapshot", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); // id-1 + enqueue(state, "c1", "b", deps); // id-2 + enqueue(state, "c1", "c", deps); // id-3 + + const snapshot = cancel(state, "c1", "id-2"); + expect(snapshot.map((m) => m.id)).toEqual(["id-1", "id-3"]); + expect(snapshot.map((m) => m.text)).toEqual(["a", "c"]); + // live state reflects the removal + expect(getQueue(state, "c1").map((m) => m.id)).toEqual(["id-1", "id-3"]); + }); + + it("removing the only message drops the key (queue is empty + clean)", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "only", deps); // id-1 + + const snapshot = cancel(state, "c1", "id-1"); + expect(snapshot).toEqual([]); + expect(getQueue(state, "c1")).toEqual([]); + // key removed so a fresh getQueue is a clean empty (not a lingering [] key) + expect(state.has("c1")).toBe(false); + }); + + it("returns a COPY — mutating the snapshot does not affect live state", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); + enqueue(state, "c1", "b", deps); + + const snapshot = cancel(state, "c1", "id-1"); + snapshot.push({ id: "evil", text: "mutate", queuedAt: 0 }); + expect(getQueue(state, "c1")).toHaveLength(1); + }); + + it("is idempotent — cancelling a missing id is a no-op (returns snapshot without it)", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); // id-1 + + // unknown message id + const snapshot = cancel(state, "c1", "nope"); + expect(snapshot.map((m) => m.id)).toEqual(["id-1"]); + expect(getQueue(state, "c1")).toHaveLength(1); + + // a second cancel of the already-removed id-1 (re-add then cancel twice) + cancel(state, "c1", "id-1"); + expect(getQueue(state, "c1")).toEqual([]); + expect(cancel(state, "c1", "id-1")).toEqual([]); // already gone — no-op + }); + + it("is scoped per conversation — cancelling on one conversation does not affect another", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); // id-1 + enqueue(state, "c2", "b", deps); // id-2 + + const snapshot = cancel(state, "c1", "id-1"); + expect(snapshot).toEqual([]); + expect(getQueue(state, "c1")).toEqual([]); + // c2 untouched + expect(getQueue(state, "c2").map((m) => m.id)).toEqual(["id-2"]); + }); + + it("cancelling on an unknown / empty conversation is a no-op (returns [])", () => { + const state: MessageQueueState = new Map(); + expect(cancel(state, "unknown", "anything")).toEqual([]); + // unknown id on a conversation that exists but is empty post-drain + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); + drain(state, "c1"); // empties + deletes the key + expect(cancel(state, "c1", "id-1")).toEqual([]); + }); }); describe("combine", () => { - it("combine joins texts with blank-line separator", () => { - const msgs: QueuedMessage[] = [ - { id: "1", text: "alpha", queuedAt: 1 }, - { id: "2", text: "beta", queuedAt: 2 }, - ]; - expect(combine(msgs)).toBe("alpha\n\nbeta"); - expect(combine([])).toBe(""); - expect(combine([{ id: "1", text: "solo", queuedAt: 1 }])).toBe("solo"); - }); + it("combine joins texts with blank-line separator", () => { + const msgs: QueuedMessage[] = [ + { id: "1", text: "alpha", queuedAt: 1 }, + { id: "2", text: "beta", queuedAt: 2 }, + ]; + expect(combine(msgs)).toBe("alpha\n\nbeta"); + expect(combine([])).toBe(""); + expect(combine([{ id: "1", text: "solo", queuedAt: 1 }])).toBe("solo"); + }); }); describe("buildQueueSpec", () => { - it("renders a custom field with the queue snapshot", () => { - const msgs: QueuedMessage[] = [ - { id: "1", text: "a", queuedAt: 1 }, - { id: "2", text: "b", queuedAt: 2 }, - ]; - const spec = buildQueueSpec(msgs); - expect(spec.id).toBe(MESSAGE_QUEUE_SURFACE_ID); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Message Queue"); - expect(spec.fields).toHaveLength(1); - - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - expect(field.rendererId).toBe(MESSAGE_QUEUE_RENDERER_ID); - const payload = field.payload as { messages: readonly QueuedMessage[] }; - expect(payload.messages).toHaveLength(2); - expect(payload.messages.map((m) => m.text)).toEqual(["a", "b"]); - }); - - it("renders an empty list for an empty queue", () => { - const spec = buildQueueSpec([]); - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - const payload = field.payload as { messages: readonly QueuedMessage[] }; - expect(payload.messages).toEqual([]); - }); + it("renders a custom field with the queue snapshot", () => { + const msgs: QueuedMessage[] = [ + { id: "1", text: "a", queuedAt: 1 }, + { id: "2", text: "b", queuedAt: 2 }, + ]; + const spec = buildQueueSpec(msgs); + expect(spec.id).toBe(MESSAGE_QUEUE_SURFACE_ID); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Message Queue"); + expect(spec.fields).toHaveLength(1); + + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + expect(field.rendererId).toBe(MESSAGE_QUEUE_RENDERER_ID); + const payload = field.payload as { messages: readonly QueuedMessage[] }; + expect(payload.messages).toHaveLength(2); + expect(payload.messages.map((m) => m.text)).toEqual(["a", "b"]); + }); + + it("renders an empty list for an empty queue", () => { + const spec = buildQueueSpec([]); + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + const payload = field.payload as { messages: readonly QueuedMessage[] }; + expect(payload.messages).toEqual([]); + }); }); diff --git a/packages/message-queue/src/pure.ts b/packages/message-queue/src/pure.ts index 834018b..ffb9dc2 100644 --- a/packages/message-queue/src/pure.ts +++ b/packages/message-queue/src/pure.ts @@ -17,10 +17,10 @@ export type MessageQueueState = Map<string, QueuedMessage[]>; /** Injected effectful factories kept out of the pure core. */ export interface QueueDeps { - /** Stable (client-visible) id factory for UI keying + dedup. */ - readonly id: () => string; - /** Clock returning epoch-ms for `queuedAt`. */ - readonly now: () => number; + /** Stable (client-visible) id factory for UI keying + dedup. */ + readonly id: () => string; + /** Clock returning epoch-ms for `queuedAt`. */ + readonly now: () => number; } /** Surface id this extension contributes (also the manifest + catalog id). */ @@ -34,19 +34,19 @@ export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; * live state through the returned value. */ export function enqueue( - state: MessageQueueState, - conversationId: string, - text: string, - deps: QueueDeps, + state: MessageQueueState, + conversationId: string, + text: string, + deps: QueueDeps, ): QueuedMessage[] { - const message: QueuedMessage = { id: deps.id(), text, queuedAt: deps.now() }; - const existing = state.get(conversationId); - if (existing === undefined) { - state.set(conversationId, [message]); - } else { - existing.push(message); - } - return getQueue(state, conversationId); + const message: QueuedMessage = { id: deps.id(), text, queuedAt: deps.now() }; + const existing = state.get(conversationId); + if (existing === undefined) { + state.set(conversationId, [message]); + } else { + existing.push(message); + } + return getQueue(state, conversationId); } /** @@ -54,9 +54,9 @@ export function enqueue( * if the conversation has no queue / is unknown. */ export function getQueue(state: MessageQueueState, conversationId: string): QueuedMessage[] { - const existing = state.get(conversationId); - if (existing === undefined) return []; - return [...existing]; + const existing = state.get(conversationId); + if (existing === undefined) return []; + return [...existing]; } /** @@ -67,11 +67,47 @@ export function getQueue(state: MessageQueueState, conversationId: string): Queu * ChatMessage. */ export function drain(state: MessageQueueState, conversationId: string): QueuedMessage[] { - const existing = state.get(conversationId); - if (existing === undefined || existing.length === 0) return []; - const drained = [...existing]; - state.delete(conversationId); - return drained; + const existing = state.get(conversationId); + if (existing === undefined || existing.length === 0) return []; + const drained = [...existing]; + state.delete(conversationId); + return drained; +} + +/** + * Cancel: remove a SINGLE queued message by id from a conversation's queue so + * it never runs (never delivered as steering, never carried into a new turn). + * Returns the post-cancel queue snapshot (a fresh array copy). Idempotent — if + * no message with `messageId` exists in the conversation's queue (already + * drained/delivered, never existed, unknown conversation) the queue is + * unchanged and the returned snapshot simply does not contain it; the caller + * distinguishes "removed" from "not found" via the `cancelWithFlag` helper + * (this returns the snapshot only, like the other pure ops). + * + * The cancelled message is dropped entirely — it is NOT returned (the caller + * does not need it; the surface re-renders from the snapshot). Mutates `state` + * in place (splices the message out of the conversation's array). + */ +export function cancel( + state: MessageQueueState, + conversationId: string, + messageId: string, +): QueuedMessage[] { + const existing = state.get(conversationId); + if (existing === undefined || existing.length === 0) { + return getQueue(state, conversationId); + } + const idx = existing.findIndex((m) => m.id === messageId); + if (idx === -1) { + return getQueue(state, conversationId); + } + existing.splice(idx, 1); + // If the queue is now empty, drop the key so `getQueue` stays a clean empty + // array (mirrors `drain` deleting the key on empty). + if (existing.length === 0) { + state.delete(conversationId); + } + return getQueue(state, conversationId); } /** @@ -80,7 +116,7 @@ export function drain(state: MessageQueueState, conversationId: string): QueuedM * ChatMessage from this. */ export function combine(messages: readonly QueuedMessage[]): string { - return messages.map((m) => m.text).join("\n\n"); + return messages.map((m) => m.text).join("\n\n"); } /** @@ -90,16 +126,16 @@ export function combine(messages: readonly QueuedMessage[]): string { * I/O; the surface-registry re-fetches this on every notify. */ export function buildQueueSpec(messages: readonly QueuedMessage[]): SurfaceSpec { - const payload: QueuePayload = { messages }; - const field: CustomField = { - kind: "custom", - rendererId: MESSAGE_QUEUE_RENDERER_ID, - payload, - }; - return { - id: MESSAGE_QUEUE_SURFACE_ID, - region: "side", - title: "Message Queue", - fields: [field], - }; + const payload: QueuePayload = { messages }; + const field: CustomField = { + kind: "custom", + rendererId: MESSAGE_QUEUE_RENDERER_ID, + payload, + }; + return { + id: MESSAGE_QUEUE_SURFACE_ID, + region: "side", + title: "Message Queue", + fields: [field], + }; } diff --git a/packages/message-queue/src/service.test.ts b/packages/message-queue/src/service.test.ts index 04cde91..086414e 100644 --- a/packages/message-queue/src/service.test.ts +++ b/packages/message-queue/src/service.test.ts @@ -5,97 +5,166 @@ import { buildQueueSpec, combine } from "./pure.js"; import { createMessageQueueService, type MessageQueueDeps } from "./service.js"; interface Recorder extends MessageQueueDeps { - readonly calls: { value: number }; + readonly calls: { value: number }; } function makeDeps(): Recorder { - let idCounter = 0; - let now = 1_000; - const calls = { value: 0 }; - return { - id: () => `q-${++idCounter}`, - now: () => (now += 10), - notify: () => { - calls.value += 1; - }, - calls, - }; + let idCounter = 0; + let now = 1_000; + const calls = { value: 0 }; + return { + id: () => `q-${++idCounter}`, + now: () => (now += 10), + notify: () => { + calls.value += 1; + }, + calls, + }; } function queueMessages(spec: SurfaceSpec): readonly QueuedMessage[] { - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - return (field.payload as QueuePayload).messages; + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + return (field.payload as QueuePayload).messages; } describe("message-queue service", () => { - it("enqueue pushes a surface update (snapshot grew)", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - expect(deps.calls.value).toBe(0); - - const snapshot = svc.enqueue("c1", "hello"); - expect(deps.calls.value).toBe(1); // surface update pushed - expect(snapshot).toHaveLength(1); - const first = snapshot[0]; - if (first === undefined) throw new Error("expected a message"); - expect(first.text).toBe("hello"); - expect(typeof first.id).toBe("string"); - expect(first.queuedAt).toBe(1_010); - - // the surface spec reflects the grown snapshot - expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toHaveLength(1); - }); - - it("drain pushes a surface update (empty)", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - svc.enqueue("c1", "a"); - svc.enqueue("c1", "b"); - expect(deps.calls.value).toBe(2); // two enqueues notified - - const drained = svc.drain("c1"); - expect(deps.calls.value).toBe(3); // drain pushed a surface update - expect(drained).toHaveLength(2); - expect(drained.map((m) => m.text)).toEqual(["a", "b"]); - - // cleared + surface now shows empty - expect(svc.getQueue("c1")).toEqual([]); - expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toEqual([]); - }); - - it("drain on empty does NOT push a surface update (already empty)", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - - expect(svc.drain("c1")).toEqual([]); - expect(deps.calls.value).toBe(0); // no notify — no change - - // after a real drain empties it, a further drain is a no-op (no notify) - svc.enqueue("c1", "x"); - expect(deps.calls.value).toBe(1); - svc.drain("c1"); - expect(deps.calls.value).toBe(2); - svc.drain("c1"); - expect(deps.calls.value).toBe(2); // unchanged — queue was already empty - }); - - it("getQueue returns current snapshot; empty for unknown conversation", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - expect(svc.getQueue("nope")).toEqual([]); - svc.enqueue("c1", "hi"); - expect(svc.getQueue("c1")).toHaveLength(1); - }); - - it("drain returns the raw QueuedMessage[] the orchestrator combines", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - svc.enqueue("c1", "alpha"); - svc.enqueue("c1", "beta"); - const drained = svc.drain("c1"); - expect(combine(drained)).toBe("alpha\n\nbeta"); - }); + it("enqueue pushes a surface update (snapshot grew)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + expect(deps.calls.value).toBe(0); + + const snapshot = svc.enqueue("c1", "hello"); + expect(deps.calls.value).toBe(1); // surface update pushed + expect(snapshot).toHaveLength(1); + const first = snapshot[0]; + if (first === undefined) throw new Error("expected a message"); + expect(first.text).toBe("hello"); + expect(typeof first.id).toBe("string"); + expect(first.queuedAt).toBe(1_010); + + // the surface spec reflects the grown snapshot + expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toHaveLength(1); + }); + + it("drain pushes a surface update (empty)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "a"); + svc.enqueue("c1", "b"); + expect(deps.calls.value).toBe(2); // two enqueues notified + + const drained = svc.drain("c1"); + expect(deps.calls.value).toBe(3); // drain pushed a surface update + expect(drained).toHaveLength(2); + expect(drained.map((m) => m.text)).toEqual(["a", "b"]); + + // cleared + surface now shows empty + expect(svc.getQueue("c1")).toEqual([]); + expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toEqual([]); + }); + + it("drain on empty does NOT push a surface update (already empty)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + + expect(svc.drain("c1")).toEqual([]); + expect(deps.calls.value).toBe(0); // no notify — no change + + // after a real drain empties it, a further drain is a no-op (no notify) + svc.enqueue("c1", "x"); + expect(deps.calls.value).toBe(1); + svc.drain("c1"); + expect(deps.calls.value).toBe(2); + svc.drain("c1"); + expect(deps.calls.value).toBe(2); // unchanged — queue was already empty + }); + + it("getQueue returns current snapshot; empty for unknown conversation", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + expect(svc.getQueue("nope")).toEqual([]); + svc.enqueue("c1", "hi"); + expect(svc.getQueue("c1")).toHaveLength(1); + }); + + it("drain returns the raw QueuedMessage[] the orchestrator combines", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "alpha"); + svc.enqueue("c1", "beta"); + const drained = svc.drain("c1"); + expect(combine(drained)).toBe("alpha\n\nbeta"); + }); +}); + +describe("message-queue service cancel", () => { + it("cancel removes the message and pushes a surface update (queue shrank)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "a"); // q-1 + svc.enqueue("c1", "b"); // q-2 + svc.enqueue("c1", "c"); // q-3 + expect(deps.calls.value).toBe(3); // three enqueues notified + + const snapshot = svc.cancel("c1", "q-2"); + expect(deps.calls.value).toBe(4); // cancel pushed a surface update + expect(snapshot.map((m) => m.id)).toEqual(["q-1", "q-3"]); + + // live state reflects the removal + expect(svc.getQueue("c1").map((m) => m.id)).toEqual(["q-1", "q-3"]); + }); + + it("cancel of the only message empties the queue + pushes a surface update", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "only"); // q-1 + expect(deps.calls.value).toBe(1); + + const snapshot = svc.cancel("c1", "q-1"); + expect(deps.calls.value).toBe(2); // surface update (queue → empty) + expect(snapshot).toEqual([]); + expect(svc.getQueue("c1")).toEqual([]); + }); + + it("cancel of a missing id does NOT push a surface update (no change)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "a"); // q-1 + expect(deps.calls.value).toBe(1); + + // unknown message id — no-op, no notify + const snapshot = svc.cancel("c1", "nope"); + expect(deps.calls.value).toBe(1); // unchanged — no change + expect(snapshot.map((m) => m.id)).toEqual(["q-1"]); + + // unknown conversation — also a no-op, no notify + expect(svc.cancel("nope", "q-1")).toEqual([]); + expect(deps.calls.value).toBe(1); // still unchanged + }); + + it("cancel after a drain (queue empty) is a no-op with no surface update", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "a"); // q-1 + svc.drain("c1"); + expect(deps.calls.value).toBe(2); // enqueue + drain + + expect(svc.cancel("c1", "q-1")).toEqual([]); + expect(deps.calls.value).toBe(2); // no notify — queue was already empty + }); + + it("cancel is scoped per conversation", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "a"); // q-1 + svc.enqueue("c2", "b"); // q-2 + + const snapshot = svc.cancel("c1", "q-1"); + expect(snapshot).toEqual([]); + expect(svc.getQueue("c1")).toEqual([]); + expect(svc.getQueue("c2").map((m) => m.id)).toEqual(["q-2"]); + }); }); diff --git a/packages/message-queue/src/service.ts b/packages/message-queue/src/service.ts index 91d0fc5..db12fd8 100644 --- a/packages/message-queue/src/service.ts +++ b/packages/message-queue/src/service.ts @@ -11,24 +11,37 @@ import { defineService, type Logger, type ServiceHandle } from "@dispatch/kernel"; import type { QueuedMessage } from "@dispatch/wire"; import type { MessageQueueState, QueueDeps } from "./pure.js"; -import { drain as drainQueue, enqueue as enqueueMessage, getQueue as readQueue } from "./pure.js"; +import { + cancel as cancelMessage, + drain as drainQueue, + enqueue as enqueueMessage, + getQueue as readQueue, +} from "./pure.js"; /** * The message-queue service interface. Obtained via * `host.getService(messageQueueHandle)`. */ export interface MessageQueueService { - /** Append a message; return the CURRENT queue snapshot (post-append). */ - enqueue(conversationId: string, text: string): QueuedMessage[]; - /** Current queue snapshot (empty array if none / unknown conversation). */ - getQueue(conversationId: string): QueuedMessage[]; - /** - * Drain: return all queued messages, CLEAR the queue, and push a surface - * update (empty). Returns the raw `QueuedMessage[]` (NOT a ChatMessage — - * the caller combines + builds the ChatMessage). Empty array if the queue - * was empty (and then NO surface update is pushed — no change). - */ - drain(conversationId: string): QueuedMessage[]; + /** Append a message; return the CURRENT queue snapshot (post-append). */ + enqueue(conversationId: string, text: string): QueuedMessage[]; + /** Current queue snapshot (empty array if none / unknown conversation). */ + getQueue(conversationId: string): QueuedMessage[]; + /** + * Drain: return all queued messages, CLEAR the queue, and push a surface + * update (empty). Returns the raw `QueuedMessage[]` (NOT a ChatMessage — + * the caller combines + builds the ChatMessage). Empty array if the queue + * was empty (and then NO surface update is pushed — no change). + */ + drain(conversationId: string): QueuedMessage[]; + /** + * Cancel: remove a SINGLE queued message by id so it never runs (never + * delivered as steering, never carried into a new turn). Returns the + * post-cancel queue snapshot. A surface update is pushed ONLY when a message + * was actually removed (the queue shrank); cancelling a missing id is a + * no-op that pushes nothing (no change). Idempotent. + */ + cancel(conversationId: string, messageId: string): QueuedMessage[]; } /** @@ -36,19 +49,19 @@ export interface MessageQueueService { * session-orchestrator imports to reach the queue — no string-keyed lookup. */ export const messageQueueHandle: ServiceHandle<MessageQueueService> = - defineService<MessageQueueService>("message-queue"); + defineService<MessageQueueService>("message-queue"); /** Deps for the service shell — the pure core's deps plus the surface notifier. */ export interface MessageQueueDeps extends QueueDeps { - /** - * Notify the surface-registry that the queue changed, so the transport - * re-fetches + pushes a full new SurfaceUpdate. Called ONLY on a real - * change (enqueue → grew; drain-non-empty → emptied); a no-op drain of an - * already-empty queue does NOT notify (no change → no surface push). - */ - readonly notify: () => void; - /** Injected host logger (optional in tests). Logs counts/lengths at debug, never bodies. */ - readonly logger?: Logger; + /** + * Notify the surface-registry that the queue changed, so the transport + * re-fetches + pushes a full new SurfaceUpdate. Called ONLY on a real + * change (enqueue → grew; drain-non-empty → emptied); a no-op drain of an + * already-empty queue does NOT notify (no change → no surface push). + */ + readonly notify: () => void; + /** Injected host logger (optional in tests). Logs counts/lengths at debug, never bodies. */ + readonly logger?: Logger; } /** @@ -58,31 +71,49 @@ export interface MessageQueueDeps extends QueueDeps { * in this closure, reachable only through the returned service. */ export function createMessageQueueService(deps: MessageQueueDeps): MessageQueueService { - const state: MessageQueueState = new Map(); + const state: MessageQueueState = new Map(); - return { - enqueue(conversationId, text) { - const snapshot = enqueueMessage(state, conversationId, text, deps); - deps.logger?.debug("message-queue: enqueued", { - conversationId, - queueLen: snapshot.length, - textLen: text.length, - }); - deps.notify(); - return snapshot; - }, - getQueue(conversationId) { - return readQueue(state, conversationId); - }, - drain(conversationId) { - const drained = drainQueue(state, conversationId); - if (drained.length === 0) return drained; - deps.logger?.debug("message-queue: drained", { - conversationId, - count: drained.length, - }); - deps.notify(); - return drained; - }, - }; + return { + enqueue(conversationId, text) { + const snapshot = enqueueMessage(state, conversationId, text, deps); + deps.logger?.debug("message-queue: enqueued", { + conversationId, + queueLen: snapshot.length, + textLen: text.length, + }); + deps.notify(); + return snapshot; + }, + getQueue(conversationId) { + return readQueue(state, conversationId); + }, + drain(conversationId) { + const drained = drainQueue(state, conversationId); + if (drained.length === 0) return drained; + deps.logger?.debug("message-queue: drained", { + conversationId, + count: drained.length, + }); + deps.notify(); + return drained; + }, + cancel(conversationId, messageId) { + // Notify ONLY on a real change (the queue shrank). Compare the pre-cancel + // length to the post-cancel snapshot — a missing id is a no-op that pushes + // no surface update (mirrors drain's no-notify-on-empty rule). + const beforeLen = readQueue(state, conversationId).length; + const snapshot = cancelMessage(state, conversationId, messageId); + if (snapshot.length === beforeLen) { + // nothing removed — no change, no surface push + return snapshot; + } + deps.logger?.debug("message-queue: cancelled", { + conversationId, + messageId, + queueLen: snapshot.length, + }); + deps.notify(); + return snapshot; + }, + }; } |
