diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 02:08:44 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 02:08:44 +0900 |
| commit | ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92 (patch) | |
| tree | 21d87eb847cd526a506cf274467fd1359f349705 /packages/message-queue/src/pure.ts | |
| parent | 75032313a96856a932c109efbbe6b6a7eb782222 (diff) | |
| download | dispatch-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.ts | 105 |
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], + }; +} |
