summaryrefslogtreecommitdiffhomepage
path: root/frontend-message-queue-handoff.md
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 /frontend-message-queue-handoff.md
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 'frontend-message-queue-handoff.md')
-rw-r--r--frontend-message-queue-handoff.md189
1 files changed, 189 insertions, 0 deletions
diff --git a/frontend-message-queue-handoff.md b/frontend-message-queue-handoff.md
new file mode 100644
index 0000000..2d55220
--- /dev/null
+++ b/frontend-message-queue-handoff.md
@@ -0,0 +1,189 @@
+# FE handoff — message queue + steering injection
+
+Courier this to `../dispatch-web` (cross-repo contract change; `lsp references` does
+not span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks.
+
+## What shipped (backend)
+
+A per-conversation **message queue** + **steering** feature. While a turn is
+GENERATING, a client can enqueue a user message onto the conversation's queue;
+it is delivered mid-turn as **steering** — injected at the next tool-result
+boundary so the model sees it alongside the tool results and can adjust course.
+If the turn ends with a non-empty queue (no tool call fired), the queue is
+carried into a NEW turn as its opening prompt.
+
+- **`message queue`** — the per-conversation buffer (owned by a new
+ `@dispatch/message-queue` extension). Transient + in-memory; the queue is
+ NOT on the chat stream — it is exposed to the frontend as a per-conversation
+ SURFACE (see below).
+- **`steering`** — a user message injected into an in-flight turn at the
+ tool-result boundary (drawn from the queue). Emitted on the chat stream as a
+ new `steering` `AgentEvent` so it appears in the transcript live.
+
+Versions: `@dispatch/wire` `0.7.0 → 0.8.0`, `@dispatch/transport-contract`
+`0.11.0 → 0.12.0`. Bump the pinned `file:` deps. (`@dispatch/ui-contract` is
+unchanged — the queue uses the existing `custom` surface field kind.)
+
+## Wire types (in `@dispatch/wire`, re-exported by `@dispatch/transport-contract`)
+
+```ts
+/** A message held in the conversation's queue, awaiting steering delivery. */
+interface QueuedMessage {
+ readonly id: string; // stable, client-visible (UI key + dedup)
+ readonly text: string;
+ readonly queuedAt: number; // epoch-ms
+}
+
+/** Payload of the message-queue surface's `custom` field (see below). */
+interface QueuePayload {
+ readonly messages: readonly QueuedMessage[];
+}
+
+/** New `AgentEvent` variant (additive to the union). */
+interface TurnSteeringEvent {
+ readonly type: "steering";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly text: string; // the combined text of all drained messages
+}
+```
+
+## How the frontend reads queue STATE: a surface (NOT the chat stream)
+
+The queue is control/state, so it rides the **surface** channel (like
+cache-warming), not the chat event stream. The `message-queue` extension
+contributes a per-conversation surface:
+
+- **Surface id:** `"message-queue"`; **scope:** `"conversation"` (subscribe with
+ the `conversationId`).
+- **One `custom` field**, `rendererId: "message-queue"`, `payload: QueuePayload`
+ (`{ messages: QueuedMessage[] }` — the current queue snapshot).
+- The surface updates (full new spec) on every change: enqueue (queue grew) and
+ drain (queue emptied). An idle conversation's queue is empty → the field's
+ `messages` is `[]`.
+
+So: **subscribe** to the `message-queue` surface per conversation and render
+the queue list from `payload.messages`. You need a bespoke renderer for
+`rendererId: "message-queue"` (the `custom` escape hatch — see the loaded-
+extensions `table` renderer precedent). The surface is **read-only** (no
+`invoke` actions); enqueuing is a chat op (below).
+
+## How the frontend ENQUEUES: the `chat.queue` WS op
+
+```ts
+interface ChatQueueMessage {
+ readonly type: "chat.queue";
+ readonly conversationId: string;
+ readonly text: string;
+}
+```
+(additive to `WsClientMessage`.)
+
+- **Fire-and-forget.** On success the server emits NOTHING back — the
+ `message-queue` SURFACE updates (the new message appears in the snapshot).
+ On failure (empty/missing `text`, unknown conversation) the server replies
+ `chat.error` (`{ type: "chat.error"; conversationId?; message }`).
+- **`text` must be non-empty** after trim (the server 400/errors otherwise).
+- **Auto-start when idle (server-owned decision):** if NO turn is active for the
+ conversation, `chat.queue` does NOT queue — it STARTS A NEW TURN with the
+ message as its opening prompt (equivalent to `chat.send`). The sender is
+ auto-subscribed and the turn's events stream as `chat.delta`s (the opening
+ `user-message` carries the text). So a single `chat.queue` op works for both
+ "steer during generation" and "send" — you don't need to pick. When a turn IS
+ active, the message is appended to the queue (surface updates) and delivered
+ at the next tool-result boundary.
+
+## How the frontend shows steering in the TRANSCRIPT: the `steering` event
+
+When the kernel drains a non-empty queue at a tool-result boundary, the
+session-orchestrator emits a **`steering`** `AgentEvent` on the chat stream
+(arrives inside a `chat.delta` `{ event }`, like every other `AgentEvent`):
+
+```ts
+{ type: "chat.delta", event: { type: "steering", conversationId, turnId, text } }
+```
+
+- Render `text` as a **user bubble in the transcript**, positioned after the
+ tool-call/tool-result it followed (it is a user message the model saw mid-turn,
+ alongside the tool results). One `steering` event per drain; `text` is the
+ combined text of all messages drained at that boundary (joined by a blank
+ line).
+- **Move, don't duplicate:** the drained messages were already shown in the
+ queue surface; when the surface then updates to empty (the drain cleared the
+ queue), they should leave the queue UI (they now live in the transcript as the
+ `steering` bubble). A simple rule: on `steering`, append the bubble to the
+ transcript; the surface's subsequent empty snapshot clears the queue UI.
+- **Late-join safe:** like `user-message`, `steering` is buffered into the
+ in-flight turn's event buffer, so a client that subscribes mid-turn (or a
+ second device) sees it before seal (mirrors the CR-3 `user-message` fix).
+ (Carry-to-new-turn, below, does NOT emit `steering` — the new turn's
+ `user-message` covers it.)
+
+## Carry to a new turn (no `steering` event)
+
+If a turn ENDS with a non-empty queue (the model finished without making a tool
+call, so no tool-result boundary was hit), the orchestrator drains the queue,
+combines the messages, and **starts a NEW turn** whose opening prompt is the
+combined text. You will see: the old turn's `done` + `turn-sealed`, then a new
+`turn-start` + `user-message` carrying the combined text (rendered as the new
+turn's normal user bubble). The queue surface also clears (empty snapshot). No
+`steering` event in this case — handle the carried text as an ordinary new-turn
+user message.
+
+## HTTP path (for the CLI / non-WS clients; the FE uses the WS op above)
+
+`POST /conversations/:id/queue` with body `QueueRequest { text }` → `QueueResponse`:
+
+```ts
+interface QueueResponse {
+ readonly conversationId: string;
+ readonly startedTurn: boolean; // true = was idle, a new turn started
+ readonly queue: readonly QueuedMessage[]; // snapshot after the enqueue
+}
+```
+- Empty/whitespace `text` → HTTP 400 `{ error }`.
+- `startedTurn: true` means no turn was active and the enqueue started one (the
+ message is the turn's opening prompt, NOT a queued steering message).
+- `startedTurn: false` means a turn was active and the message was queued (the
+ `queue` snapshot includes it).
+
+## What we need the FE to do
+
+1. **Bump pinned deps:** `@dispatch/wire` → `0.8.0`, `@dispatch/transport-contract`
+ → `0.12.0`.
+2. **Queue UI (per conversation):** subscribe to the `message-queue` surface
+ (scope `conversation`) and render `payload.messages` (`QueuedMessage[]`) with a
+ `rendererId: "message-queue"` custom renderer — a list of pending messages
+ with their text (and maybe `queuedAt` as a timestamp). Empty `messages` =
+ nothing to show (hide the panel).
+3. **Enqueue affordance:** while a turn is generating, show an input that sends
+ `chat.queue { conversationId, text }` (NOT `chat.send` — `chat.queue` is the
+ steering entry; it auto-starts a turn if idle, so it's safe to offer it
+ whenever the user wants to add input). Trim/validate non-empty client-side
+ too; expect a `chat.error` on failure.
+4. **Steering bubble:** handle the new `steering` `AgentEvent` (type `"steering"`)
+ on the `chat.delta` stream → render `event.text` as a user bubble in the
+ transcript after the tool calls; clear the queue UI when the surface updates
+ to empty.
+5. **Carry:** no special handling — a carried queue surfaces as a normal new
+ turn (`turn-start` + `user-message`); just let the existing new-turn flow
+ render it. The queue surface clears automatically.
+
+## Notes / known gaps
+
+- **Live end-to-end (a real steering turn via a tool-calling model) is not yet
+ exercised** — the logic is unit/integration tested and the app boots clean with
+ the `message-queue` extension registered, but a live `chat.queue` → tool-call
+ → `steering` event flow against a real model has not been run. Worth a live
+ smoke once the FE wires it (or ask the backend to run one).
+- **Close-with-queued-messages (open product question):** if a client
+ `POST /conversations/:id/close` (explicit tab close) while the queue is
+ non-empty, the in-flight turn aborts and the carry currently STILL fires
+ (starting a new turn on the closed conversation). This may or may not be
+ desired (does closing discard pending steering, or honor it?). Backend flag
+ for a decision; if "discard on close" is wanted, the backend will gate the
+ carry on `finishReason !== "aborted"`. No FE action either way — just be aware
+ a closed conversation might briefly start a turn from a queued message.
+- **`steering` is additive** to the `AgentEvent` union — no exhaustive switches
+ broke on the backend (verified: `tsc -b` EXIT 0). If the FE has an exhaustive
+ switch on `AgentEvent`, add a `steering` case.