# 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.