summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 02:19:54 +0900
committerAdam Malczewski <[email protected]>2026-06-21 02:19:54 +0900
commitd98a63ce17519983dcf58c27432723e2f4b96e75 (patch)
tree21a4e043d040984aa62fd2ba81ca3349ce01f5c4 /.dispatch
parent9c90105b6cfede0f3327169718300c649bb0531a (diff)
downloaddispatch-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.md94
-rw-r--r--.dispatch/wire.reference.md85
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;
+}
```