summaryrefslogtreecommitdiffhomepage
path: root/packages/message-queue/src/pure.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 02:08:44 +0900
committerAdam Malczewski <[email protected]>2026-06-21 02:08:44 +0900
commitba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92 (patch)
tree21d87eb847cd526a506cf274467fd1359f349705 /packages/message-queue/src/pure.ts
parent75032313a96856a932c109efbbe6b6a7eb782222 (diff)
downloaddispatch-ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92.tar.gz
dispatch-ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92.zip
feat(message-queue): per-conversation queue + steering injection
A per-conversation message queue (new message-queue extension) holds user messages enqueued while a turn generates; delivered mid-turn as steering at the tool-result boundary (or carried to a new turn if no tool call fires). - kernel: RunTurnInput.drainSteering callback (generic; kernel stays pure) - wire 0.7.0->0.8.0: QueuedMessage, QueuePayload, TurnSteeringEvent (additive) - transport-contract 0.11.0->0.12.0: POST /conversations/:id/queue + chat.queue WS op - message-queue ext: queue state + per-conversation custom surface (rendererId message-queue) - session-orchestrator: enqueue facade + drainSteering wiring + post-seal carry - transport-http/ws: queue endpoint + chat.queue op (fixes WsClientMessage exhaustive switch) - host-bin: register message-queue 1043 vitest + 199 transport bun pass; tsc/biome clean; boot smoke clean. FE courier: frontend-message-queue-handoff.md.
Diffstat (limited to 'packages/message-queue/src/pure.ts')
-rw-r--r--packages/message-queue/src/pure.ts105
1 files changed, 105 insertions, 0 deletions
diff --git a/packages/message-queue/src/pure.ts b/packages/message-queue/src/pure.ts
new file mode 100644
index 0000000..834018b
--- /dev/null
+++ b/packages/message-queue/src/pure.ts
@@ -0,0 +1,105 @@
+/**
+ * Pure core for message-queue — zero I/O, zero ambient state.
+ *
+ * Every function is input → output; testable without mocks. State is a plain
+ * `Map<conversationId, QueuedMessage[]>` OWNED by the caller (the service
+ * shell); the pure functions mutate it in place and return snapshots (fresh
+ * array copies), so a caller can never reach into or mutate live state through
+ * a returned value. The id factory + clock are injected (`QueueDeps`) so tests
+ * are deterministic.
+ */
+
+import type { CustomField, SurfaceSpec } from "@dispatch/ui-contract";
+import type { QueuedMessage, QueuePayload } from "@dispatch/wire";
+
+/** The queue state: a per-conversation map of queued messages. */
+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;
+}
+
+/** Surface id this extension contributes (also the manifest + catalog id). */
+export const MESSAGE_QUEUE_SURFACE_ID = "message-queue";
+/** The custom renderer id a frontend switches on to render the queue. */
+export const MESSAGE_QUEUE_RENDERER_ID = "message-queue";
+
+/**
+ * Append a message to a conversation's queue. Mutates `state` and returns the
+ * CURRENT snapshot (post-append) — a fresh array copy, so callers cannot mutate
+ * live state through the returned value.
+ */
+export function enqueue(
+ 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);
+}
+
+/**
+ * Current queue snapshot for a conversation — a fresh array copy. Empty array
+ * 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];
+}
+
+/**
+ * Drain: return all queued messages for a conversation and CLEAR its queue.
+ * Returns a fresh array copy of the drained messages (empty array if the queue
+ * was empty or unknown). The caller (session-orchestrator) combines these into
+ * a steering ChatMessage; this returns the raw `QueuedMessage[]`, NOT a
+ * 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;
+}
+
+/**
+ * Combine drained messages' texts into a single steering string, joined by a
+ * blank line (`\n\n`). Pure — the session-orchestrator builds the final
+ * ChatMessage from this.
+ */
+export function combine(messages: readonly QueuedMessage[]): string {
+ return messages.map((m) => m.text).join("\n\n");
+}
+
+/**
+ * Build the per-conversation surface spec: a single `custom` field whose
+ * payload is the current queue snapshot (`QueuePayload`). An empty `messages`
+ * array (idle conversation / post-drain) renders as an empty list. Pure — no
+ * 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],
+ };
+}