summaryrefslogtreecommitdiffhomepage
path: root/frontend-cancel-queued-message-handoff.md
blob: b92abd42e379b5e6d23b1375a11481e43393b531 (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
# FE handoff — cancel a queued message

Courier this to `../frontend` (cross-repo contract change; `lsp references` does
not span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing
breaks.

## What shipped (backend)

A per-message **cancel** for the steering message queue: while a turn is
GENERATING and a user message is sitting in the queue (waiting to be delivered
as steering at the next tool-result boundary, or carried into a new turn), the
client can **cancel a single queued message by id** so it never runs. The
message is removed from the queue and is never delivered as steering and never
carried into a new turn.

This complements the existing `chat.queue` enqueue (see
`frontend-message-queue-handoff.md`). Enqueue adds; cancel removes one.

Versions: `@dispatch/transport-contract` `0.23.0 → 0.24.0`. Bump the pinned
`file:` dep. (`@dispatch/wire` is unchanged — `QueuedMessage` already has the
`id` the cancel targets; no new wire type was needed.)

## The two entry points (pick one — same backend behavior)

### 1. WebSocket op: `chat.queue.cancel` (what the FE should use)

```ts
interface ChatQueueCancelMessage {
  readonly type: "chat.queue.cancel";
  readonly conversationId: string;
  readonly messageId: string; // the stable QueuedMessage.id (from the queue surface / enqueue response)
}
```
(additive to `WsClientMessage`.)

- **Fire-and-forget**, exactly like `chat.queue`. On success the server emits
  NOTHING back — the `message-queue` SURFACE updates (the cancelled message
  leaves the `payload.messages` snapshot). On failure (missing/empty
  `conversationId` or `messageId`) the server replies `chat.error`
  (`{ type: "chat.error"; conversationId?; message }`).
- **Idempotent:** cancelling a message that is no longer queued (already
  drained/delivered as steering, or already cancelled, or never existed, or an
  unknown conversation) is a **silent no-op** — no surface update, no error. So
  a client may optimistically remove the row from its queue UI on click and
  fire-and-forget the cancel; if the message was already gone, nothing breaks.
- **`messageId`** is the stable client-visible `QueuedMessage.id` you already
  render from the queue surface snapshot (or got back from `chat.queue` /
  `POST /conversations/:id/queue`'s `queue[]`).

### 2. HTTP path (for the CLI / non-WS clients; the FE uses the WS op above)

`DELETE /conversations/:id/queue/:messageId` (no request body) → `QueueCancelResponse`:

```ts
interface QueueCancelResponse {
  readonly conversationId: string;
  readonly cancelled: boolean;                  // true = a message was found + removed
  readonly queue: readonly QueuedMessage[];     // post-cancel snapshot
}
```
- `cancelled: true` — the message was in the queue and has been removed (it will
  never run).
- `cancelled: false` — the message was NOT in the queue (already
  drained/delivered, never existed, unknown conversation) OR the message-queue
  extension isn't loaded (degraded). Still HTTP **200** (idempotent — not an
  error).
- `queue` is the post-cancel snapshot (empty when no queue extension is loaded).

## How the FE confirms a cancel

The queue is control/state on the **surface** channel (NOT the chat stream), so
cancel is confirmed the same way enqueue is — by the `message-queue` surface
updating:

1. You already **subscribe to the `message-queue` surface** (scope
   `conversation`) and render `payload.messages` (`QueuedMessage[]`) with the
   `rendererId: "message-queue"` custom renderer (per
   `frontend-message-queue-handoff.md`).
2. On cancel, the surface pushes a **full new spec** whose `payload.messages`
   no longer contains the cancelled id. The cancelled message simply leaves the
   queue list — render the new snapshot.
3. **No new `AgentEvent`** is emitted for a cancel. The cancelled message never
   appears in the transcript (it was never delivered as steering — that's the
   point). If a message was already drained (delivered as a `steering` bubble or
   carried into a new turn's `user-message`) before the cancel arrived, the
   cancel is a no-op (`cancelled: false`) and the transcript is unchanged.

## UX suggestion

- Render a **cancel (×) affordance** on each pending row in the queue UI (the
  surface snapshot gives you the `id` to target). On click → send
  `chat.queue.cancel { conversationId, messageId }`.
- Optimistically remove the row from the queue UI on click; the surface update
  will confirm it (or, if the message was already drained, the surface already
  shows it gone — no harm).
- Expect a `chat.error` only for a malformed send (empty `conversationId` /
  `messageId`) — in practice a client that sends the id it just rendered will
  never hit this.

## Race notes (safe by construction)

- **Cancel vs. drain (steering delivery):** if the kernel drains the queue at a
  tool-result boundary in the instant between the user clicking cancel and the
  server processing it, the message is already gone — the cancel returns
  `cancelled: false` (a no-op). The drained message was delivered as a
  `steering` event and is in the transcript; the cancel correctly did nothing.
  No double-delivery, no error.
- **Cancel vs. post-seal carry:** same — if the turn sealed and the queue was
  carried into a new turn before the cancel ran, the message is already the new
  turn's opening `user-message`; the cancel is a no-op.
- **Cancel is scoped per conversation:** cancelling on conversation A never
  touches conversation B's queue.

## What we need the FE to do

1. **Bump pinned dep:** `@dispatch/transport-contract` → `0.24.0`.
2. **Add `chat.queue.cancel`** to the FE's `WsClientMessage` union (it is
   additive — no exhaustive switch breaks; if the FE has one, add the
   `chat.queue.cancel` case to its WS dispatcher).
3. **Cancel affordance per queued row:** a × / cancel button on each pending
   message in the queue UI that sends
   `chat.queue.cancel { conversationId, messageId }` using the row's `id`.
   Optimistically remove the row; reconcile from the surface update.
4. **No new event handling** — the existing `message-queue` surface subscription
   already reflects the post-cancel snapshot; just render it. No `steering` /
   transcript change for a cancelled message (it never runs).

## Notes / known gaps

- **No CLI command.** The CLI's `send --queue` enqueues but there is no CLI
  command to list the queue or obtain a `messageId`, so a CLI `cancel` was not
  added (it would have no way to discover a message id). The HTTP
  `DELETE /conversations/:id/queue/:messageId` is available for any non-WS
  client that already knows the id (e.g. from a prior
  `POST /conversations/:id/queue` response's `queue[]`).
- **Close-with-queued-messages** (the open product question noted in
  `frontend-message-queue-handoff.md`) is unchanged by this feature: an
  explicit `POST /conversations/:id/close` still aborts the in-flight turn and
  the carry still fires. Cancel is a separate, per-message affordance that does
  not touch the turn.