summaryrefslogtreecommitdiffhomepage
path: root/packages/message-queue/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/message-queue/src')
-rw-r--r--packages/message-queue/src/extension.ts106
-rw-r--r--packages/message-queue/src/index.ts27
-rw-r--r--packages/message-queue/src/pure.test.ts317
-rw-r--r--packages/message-queue/src/pure.ts110
-rw-r--r--packages/message-queue/src/service.test.ts237
-rw-r--r--packages/message-queue/src/service.ts127
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;
+ },
+ };
}