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 /frontend-message-queue-handoff.md | |
| 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 'frontend-message-queue-handoff.md')
| -rw-r--r-- | frontend-message-queue-handoff.md | 189 |
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. |
