diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 02:19:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 02:19:54 +0900 |
| commit | d98a63ce17519983dcf58c27432723e2f4b96e75 (patch) | |
| tree | 21a4e043d040984aa62fd2ba81ca3349ce01f5c4 /.dispatch | |
| parent | 9c90105b6cfede0f3327169718300c649bb0531a (diff) | |
| download | dispatch-web-d98a63ce17519983dcf58c27432723e2f4b96e75.tar.gz dispatch-web-d98a63ce17519983dcf58c27432723e2f4b96e75.zip | |
feat(chat): message queue + steering — mid-turn injection at tool-result boundaries
Consume the message-queue + steering handoff ([email protected], [email protected]).
Re-pinned file: deps + re-mirrored .dispatch/*.reference.md.
- fold steering AgentEvent into the transcript as a provisional user bubble
(after the tool-result it followed; no de-dup — the queue surface carried it)
- add rendererId: "message-queue" custom renderer (pure parser + MessageQueueList)
rendered as a compact panel above the Composer (hidden when queue is empty)
- add ChatStore.queueMessage / AppStore.queueMessage — sends chat.queue WS op
(trim/validate non-empty; auto-starts a turn if idle)
- Composer switches to chat.queue while generating (button → Queue, placeholder
→ Steer the conversation...)
- exhaustiveness guards updated for steering + chat.queue
- carry-to-new-turn needs no special handling (normal new turn)
664 tests green.
Diffstat (limited to '.dispatch')
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 94 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 85 |
2 files changed, 172 insertions, 7 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 1c3d993..18d1a3d 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -5,10 +5,31 @@ > hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — > this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (reasoning effort shipped). -> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see +> **Orchestrator:** SNAPSHOT of `[email protected]` (message queue + steering). +> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see > `ui-contract.reference.md`). > +> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** +> adds the enqueue surface for the per-conversation message queue (the wire types `QueuedMessage` / +> `QueuePayload` + the new `steering` `AgentEvent` live in `[email protected]`, re-exported here). Two +> additive shapes: +> 1. **WS `chat.queue` op** — `ChatQueueMessage { type: "chat.queue"; conversationId; text }` (a +> new `WsClientMessage` union member). 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 +> `text`, unknown conversation) the server replies `chat.error`. **Auto-start when idle +> (server-owned):** if no turn is active, `chat.queue` does NOT queue — it STARTS A NEW TURN with +> the message as its opening prompt (equivalent to `chat.send`). So a single op works for both +> "steer during generation" and "send"; the client doesn't pick. `text` must be non-empty after trim. +> 2. **HTTP `POST /conversations/:id/queue`** — body `QueueRequest { text }` → `QueueResponse +> { conversationId; startedTurn: boolean; queue: QueuedMessage[] }`. `startedTurn: true` = was +> idle, a new turn started (the message is the turn's opening prompt, NOT a queued steering +> message); `startedTurn: false` = a turn was active, the message was queued (the `queue` +> snapshot includes it). Empty/whitespace `text` → HTTP 400 `{ error }`. The FE uses the WS op. +> +> The queue is read via a per-conversation SURFACE (`message-queue`, scope `conversation`; one +> `custom` field, `rendererId: "message-queue"`, `payload: QueuePayload`) — NOT via the chat stream. +> See the handoff for the full flow (steering event, carry-to-new-turn, move-vs-duplicate). +> > **2026-06-12 delta (reasoning-effort handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):** > the thinking-depth knob (`ReasoningEffort`, re-exported from `[email protected]`) lands in TWO scopes, > resolved server-side per turn (per-turn override → persisted conversation value → default @@ -135,6 +156,11 @@ - `POST /conversations/:id/close` — no body → `200 CloseConversationResponse`. The EXPLICIT tab-close affordance: aborts any in-flight turn (persists the partial; seals with `finishReason: "aborted"`) AND stops + disables cache-warming (persisted OFF). Idempotent (`abortedTurn: false` when idle/unknown). +- `POST /conversations/:id/queue` — body `QueueRequest { text }` → `200 QueueResponse`. Enqueue a user + message for mid-turn steering delivery (the WS `chat.queue` op is the FE's path). When a turn is + active, the message is queued + delivered at the next tool-result boundary (a `steering` `AgentEvent` + fires; the message-queue SURFACE updates). When idle, the enqueue STARTS a new turn with the message + as its opening prompt (`startedTurn: true`). Empty/whitespace `text` → `400 { error }`. - `GET /metrics/throughput?period=day|week|month&date=<...>` — `ThroughputResponse` (token-weighted tokens/sec per model over the window). Not part of cache-warming; listed for completeness. - `GET /conversations/:id/cwd` — `CwdResponse` (`cwd` is `null` until set). @@ -172,10 +198,17 @@ */ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; -import type { AgentEvent, ReasoningEffort, StoredChunk, TurnMetrics } from "@dispatch/wire"; +import type { + AgentEvent, + QueuedMessage, + ReasoningEffort, + StoredChunk, + TurnMetrics, +} from "@dispatch/wire"; export type { AgentEvent, + QueuedMessage, ReasoningEffort, StepMetrics, StoredChunk, @@ -395,6 +428,41 @@ export interface CloseConversationResponse { readonly abortedTurn: boolean; } +// ─── Message queue (steering) ───────────────────────────────────────────────── + +/** + * Request body for `POST /conversations/:id/queue` — enqueue a user message + * onto a conversation's message queue for mid-turn steering delivery. + * + * When a turn is ACTIVE for the conversation, the message is appended to the + * queue (the message-queue extension's per-conversation SURFACE updates) and + * delivered at the next tool-result boundary as a steering message the model + * sees alongside the tool results (a `steering` `AgentEvent` is emitted). When + * NO turn is active, enqueuing instead STARTS a new turn with the message as its + * opening prompt (equivalent to `POST /chat`) — so a fire-and-forget enqueue + * works regardless of generation state. The resolved queue + whether a turn was + * started are returned in `QueueResponse`. + * + * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise. + */ +export interface QueueRequest { + readonly text: string; +} + +/** + * Response body for `POST /conversations/:id/queue` — the conversation's queue + * snapshot AFTER the enqueue, so a client renders the queue from this alone. + * `conversationId` echoes the path. `startedTurn` is true when no turn was + * active and the enqueue started a new turn (the message is now the turn's + * opening prompt, not a queued steering message); the turn's events stream on + * the chat channel as usual. + */ +export interface QueueResponse { + readonly conversationId: string; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; +} + // ─── Per-conversation LSP status ────────────────────────────────────────────── /** The connection state of a single language server for a workspace. */ @@ -550,6 +618,23 @@ export interface ChatUnsubscribeMessage { } /** + * Client → server: enqueue a message onto a conversation's message queue while + * a turn is generating (steering). The WebSocket counterpart of the HTTP + * `POST /conversations/:id/queue` (`QueueRequest`). Fire-and-forget: success is + * confirmed by the message-queue SURFACE updating (the FE renders the queue + * from the surface, not from a reply here); a failure (malformed/empty text, + * unknown conversation) arrives as a `chat.error`. When no turn is active, the + * enqueue starts a new turn (the turn's events stream as `chat.delta`s), so a + * client reuses this op for both "queue while generating" and "send" (the + * latter being equivalent to `chat.send`). + */ +export interface ChatQueueMessage { + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; +} + +/** * Every client → server WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A server discriminates on `type`. */ @@ -557,7 +642,8 @@ export type WsClientMessage = | SurfaceClientMessage | ChatSendMessage | ChatSubscribeMessage - | ChatUnsubscribeMessage; + | ChatUnsubscribeMessage + | ChatQueueMessage; /** * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 34984d2..c2c4d43 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,8 +4,31 @@ > types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission > prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (reasoning effort — the thinking-depth knob). -> Regenerate whenever `@dispatch/wire` changes. +> **Orchestrator:** SNAPSHOT of `[email protected]` (message queue + steering). Regenerate +> whenever `@dispatch/wire` changes. +> +> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.7.0` → `0.8.0`, ADDITIVE):** +> adds the per-conversation **message queue** + **steering** feature. While a turn is GENERATING, +> a client enqueues a user message (via the `chat.queue` WS op or `POST /conversations/:id/queue`, +> see `[email protected]`); 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 (no `steering` event — the new turn's `user-message` covers it). +> +> Adds: +> - **`QueuedMessage`** (`{ id, text, queuedAt }`) — a message held in the queue (stable id for UI +> keying + dedup). +> - **`QueuePayload`** (`{ messages: QueuedMessage[] }`) — the payload of the message-queue +> extension's per-conversation `custom` surface field (`rendererId: "message-queue"`). Carried on +> the SURFACE channel (NOT the chat stream) — the queue is control/state. Empty `messages` = empty +> queue. See `transport-contract.reference.md` for the surface + the enqueue op. +> - **`TurnSteeringEvent`** (`{ type: "steering"; conversationId; turnId; text }`) — a NEW +> `AgentEvent` union member, emitted on the chat stream when the kernel drains a non-empty queue +> at a tool-result boundary. Render `text` as a USER bubble in the transcript (positioned after +> the tool-result it followed); the queue surface separately clears on drain. One event per drain; +> `text` is the combined text of all drained messages. Late-join safe (buffered into the in-flight +> turn's event buffer, mirroring `user-message`). Carry-to-new-turn does NOT emit `steering`. +> ADDITIVE to the union — if you have an exhaustive `AgentEvent` switch, add a `steering` case. > > **2026-06-12 delta (reasoning-effort handoff — package bumped `0.6.1` → `0.7.0`, ADDITIVE):** > adds the **`ReasoningEffort`** type — the per-request thinking-depth ladder @@ -284,6 +307,37 @@ export interface TurnMetrics { readonly contextSize?: number; } +// ─── Message queue + steering ─────────────────────────────────────────────── + +/** + * A user message held in a conversation's message queue, awaiting mid-turn + * steering delivery. The message-queue extension owns the queue and exposes it + * as a per-conversation `custom` surface field; this type is the shared shape + * the surface payload, the enqueue response, and the extension's service all + * use (so a separate frontend repo can depend on the wire alone to render it). + */ +export interface QueuedMessage { + /** Stable id (client-visible) for UI keying + dedup. */ + readonly id: string; + /** The message text the client enqueued. */ + readonly text: string; + /** When the message was enqueued (epoch-ms). */ + readonly queuedAt: number; +} + +/** + * The payload of the message-queue extension's per-conversation `custom` + * surface field (`rendererId: "message-queue"`): the current queue snapshot a + * frontend renders. Carried on the SURFACE channel (NOT the chat stream) — the + * queue is control/state, distinct from turn content. An empty `messages` + * array means the queue is empty (no pending steering). The frontend moves a + * message from this queue surface into the transcript when it is drained (the + * surface clears) and/or when the matching `TurnSteeringEvent` arrives. + */ +export interface QueuePayload { + readonly messages: readonly QueuedMessage[]; +} + // ─── Outward events ───────────────────────────────────────────────────────── /** @@ -303,7 +357,8 @@ export type AgentEvent = | TurnStepCompleteEvent | TurnErrorEvent | TurnDoneEvent - | TurnSealedEvent; + | TurnSealedEvent + | TurnSteeringEvent; /** Status change for a conversation (e.g. idle → running). */ export interface StatusEvent { @@ -498,4 +553,28 @@ export interface TurnSealedEvent { readonly conversationId: string; readonly turnId: string; } + +/** + * A steering message was injected into an in-flight turn at the tool-result + * boundary (the model sees it alongside the tool results and may adjust + * course). Drawn from the conversation's message queue (which the drain + * clears); the cleared queue arrives as a message-queue SURFACE update, while + * THIS event carries the injected `text` so a frontend can place a user bubble + * in the transcript live — and so a late-joining watcher sees it before seal + * (mirroring `TurnInputEvent` for the opening prompt; emitted into the + * in-flight buffer by the session-orchestrator). + * + * Emitted by the session-orchestrator (in its `drainSteering` wrapper) only + * when the kernel drained a non-empty queue at a tool-result boundary. If the + * turn instead ENDS with a non-empty queue (no tool call fired), the queue is + * carried into a NEW turn whose opening `user-message` event covers the + * transcript — so no `steering` event is emitted in that case. One `steering` + * event per drain; the combined text of all drained messages. + */ +export interface TurnSteeringEvent { + readonly type: "steering"; + readonly conversationId: string; + readonly turnId: string; + readonly text: string; +} ``` |
