summaryrefslogtreecommitdiffhomepage
path: root/frontend-message-queue-handoff.md
blob: 2d5522010306f4ec777107bc5bddc67ffa0e43b9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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.