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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
|
# `@dispatch/wire` — in-repo reference (read THIS, not node_modules)
> MIRRORS the backend's `@dispatch/wire` package source so headless FE agents can read the wire
> 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]` (compaction). Regenerate
> whenever `@dispatch/wire` changes.
>
> **2026-06-22 delta (compaction handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):**
> adds `CompactionResult` — the result of a compaction operation (`summary`, `messagesSummarized`,
> `messagesKept`). The summary text is the model's output; the FE doesn't render it directly (it
> becomes the conversation's first system message after compaction).
>
> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):**
> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle
> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` =
> exists, not generating; `closed` = dismissed (hidden from the tab bar). Transitions are
> backend-owned: `idle → active` on turn start, `active → idle` on turn settle, `→ closed` on
> `POST /conversations/:id/close`. Pushed to all WS clients via `conversation.statusChanged`
> (see `[email protected]`).
>
> **2026-06-21 delta (conversation.open handoff — package bumped `0.8.0` → `0.9.0`, ADDITIVE):**
> adds `ConversationMeta` — metadata for a conversation (id, title, createdAt, lastActivityAt),
> returned by `GET /conversations` (the list endpoint, see `[email protected]`).
>
> **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
> `"low" | "medium" | "high" | "xhigh" | "max"`. Provider-agnostic; the Anthropic provider maps
> levels to extended-thinking token budgets (low 4096 · medium 10240 · high 16384 · xhigh 32768 ·
> max 65536); providers without a thinking knob ignore it. Resolution is SERVER-owned (do not
> re-implement): per-turn `ChatRequest.reasoningEffort` override → persisted per-conversation value
> (`GET`/`PUT /conversations/:id/reasoning-effort`, see `[email protected]`) → default
> `"high"`. Higher levels mean longer runs of `reasoning-delta` events before the first text delta.
> See the `ReasoningEffort` definition below.
>
> **2026-06-12 delta (CR-5 history windowing — package bumped `0.6.0` → `0.6.1`, DOC-ONLY):** the
> per-conversation `seq` numbering is now a WRITTEN CONTRACTUAL GUARANTEE on `StoredChunk`:
> **1-based, monotonic, gap-free** — a conversation's first chunk is always `seq === 1` and
> numbering never skips. A client holding only a windowed suffix of the log derives "older chunks
> exist server-side" purely from `oldestLoaded.seq > 1` (no `earliestSeq`/`hasOlder` field exists).
>
> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a
> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`)
> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of
> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join
> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt
> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that
> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below.
>
> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL
> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the
> turn's FINAL step `inputTokens + outputTokens` (current context occupancy), NOT the aggregate
> `usage` (which overcounts multi-step turns). The two carriers are equal for the same turn. Current
> value = the LATEST turn's `contextSize`; `undefined` ⇒ render "unknown", never `0`. See the field
> doc-comments on `TurnMetrics`/`TurnDoneEvent` below.
>
> **0.3.0 changes (token + timing metrics):**
> - **Live per-step/per-turn telemetry on the event stream** (transient — NOT persisted):
> `TurnUsageEvent` gained an OPTIONAL `stepId?` (attribute tokens per step). A NEW
> `TurnStepCompleteEvent` (`type: "step-complete"`, REQUIRED `stepId`) carries the per-step
> generation timing `ttftMs?` / `decodeMs?` / `genTotalMs?` (all optional — present only when the
> runtime had a clock; `ttftMs`/`decodeMs` additionally require a first content token). `TurnDoneEvent`
> gained an OPTIONAL `durationMs?` (total turn wall-clock) + OPTIONAL `usage?` (aggregate across
> steps). `TurnToolResultEvent` gained an OPTIONAL `durationMs?` (tool execution time).
> - **Durable, replayable metrics** (persisted, keyed per turn): NEW `StepMetrics` + `TurnMetrics`
> — the persisted counterparts of the live `usage` + `step-complete` + `done` packets. Served by
> `GET /conversations/:id/metrics` (see `transport-contract.reference.md`). Build the SAME
> `TurnMetrics` shape from the live events for the in-flight turn; the durable endpoint supplies it
> for sealed turns. TPS is derived (`usage.outputTokens / (genTotalMs / 1000)`), not on the wire.
> - **0.2.0 (still current — step grouping):** `ToolCallChunk`/`ToolResultChunk` carry an OPTIONAL
> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` carry a REQUIRED `stepId: StepId`.
> Group batched/parallel tool calls by `stepId` equality. Live: read `event.stepId`. Replay: read
> `storedChunk.chunk.stepId` (NOT the envelope; tolerate absence). `StoredChunk` envelope is
> UNCHANGED (`{ seq, role, chunk }` — carries NO `turnId`).
```ts
/**
* @dispatch/wire — pure wire types shared by the kernel, the transport
* contract, and out-of-repo clients (the web frontend).
*
* Types ONLY: zero runtime, zero `@dispatch/*` dependencies, so a client can
* depend on the wire without pulling the kernel runtime.
*/
// ─── Conversation model ─────────────────────────────────────────────────────
/** Who produced a message. */
export type Role = "system" | "user" | "assistant" | "tool";
/** Opaque identifier for a turn (one user→assistant cycle). */
export type TurnId = string & { readonly __brand: "TurnId" };
/**
* Opaque identifier for a step (one LLM round-trip within a turn). It is the
* authoritative grouping key for the tool calls a model batches together in a
* single step (parallel/batched calls): every `tool-call`/`tool-result` event
* and every persisted tool chunk (`ToolCallChunk`/`ToolResultChunk`) from the
* same step carries the SAME `stepId`, so a client groups a batch purely by
* equality — identically on the live stream and in replayed history. Per-turn
* unique and gap-free in step order; treat it as opaque (do not parse it). The
* runtime derives it deterministically from the turn id + 0-based step index.
*/
export type StepId = string & { readonly __brand: "StepId" };
/**
* A chunk is one ordered piece of a message — the atomic unit of the
* append-only conversation log. Discriminated by `type`.
*/
export type Chunk =
| TextChunk
| ThinkingChunk
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
| SystemChunk;
/** A piece of plain text content from the assistant or user. */
export interface TextChunk {
readonly type: "text";
readonly text: string;
}
/** A piece of model reasoning / thinking content (e.g. extended thinking). */
export interface ThinkingChunk {
readonly type: "thinking";
readonly text: string;
}
/**
* A model's request to run a tool. The kernel routes by `name`; the tool
* implementation never sees this directly — it receives parsed `input` via
* `ToolContract.execute`.
*/
export interface ToolCallChunk {
readonly type: "tool-call";
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
/**
* The step that produced this call — generation provenance stamped by the
* runtime when the model emits the call (NOT storage metadata like `seq`,
* which is why it lives on the chunk and travels with it through persistence
* and replay). Tool calls a model batches together in one step share the same
* `stepId`: the grouping key for rendering a parallel batch as one unit, and
* equal to the `stepId` on the matching `tool-call` AgentEvent. Optional:
* absent on chunks reconstructed outside a turn and on rows persisted before
* this field existed, so a consumer must tolerate its absence (render
* ungrouped).
*/
readonly stepId?: StepId;
}
/**
* The result of a tool execution, attributed to the originating tool-call id.
* The kernel guarantees every tool-call chunk gets exactly one result chunk
* (synthesized if interrupted — see reconcile).
*/
export interface ToolResultChunk {
readonly type: "tool-result";
readonly toolCallId: string;
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
/**
* The step that produced the originating call — equal to the `stepId` on the
* matching `tool-call` chunk (same `toolCallId`) and on the `tool-result`
* AgentEvent, so a consumer groups a step's calls with their results.
* Generation provenance, not storage metadata (see `ToolCallChunk.stepId`).
* Optional for the same reasons; `reconcile` copies it from the originating
* call onto a synthesized (interrupted) result.
*/
readonly stepId?: StepId;
}
/** An error that occurred during generation or tool dispatch. */
export interface ErrorChunk {
readonly type: "error";
readonly message: string;
readonly code?: string;
}
/**
* A system-injected message (e.g. system prompt, context assembly output).
* Kept distinct from text so the log records provenance.
*/
export interface SystemChunk {
readonly type: "system";
readonly text: string;
}
/**
* A chat message: a role plus an ordered sequence of chunks. Messages are the
* unit passed to and from the provider; chunks are the unit persisted and
* rendered.
*/
export interface ChatMessage {
readonly role: Role;
readonly chunks: readonly Chunk[];
}
/**
* A persisted chunk plus its sync metadata. The append-only conversation log
* stamps every chunk with a **1-based**, monotonic, gap-free, per-conversation
* `seq` (the sync cursor, assigned in append order) and records the `role` of
* the message it belongs to. This makes a flat seq-ordered stream both
* incrementally syncable ("give me chunks after seq N") and regroupable into
* messages by the client.
*
* The 1-based start is a CONTRACTUAL GUARANTEE (not an implementation detail):
* a conversation's first chunk is always `seq === 1` and numbering never skips,
* so a client holding only a windowed suffix of the log can derive "older
* chunks exist server-side" purely from `oldestLoaded.seq > 1` — no separate
* has-older flag is needed (or provided). `chunk` is the content unit — `Chunk` carries no storage/sync cursor
* (`seq` lives here on the envelope, not on the chunk, since it is assigned by
* the store and the provider has no use for it). A chunk MAY still carry
* generation provenance assigned at production time (e.g. a tool chunk's
* `stepId`), which is intrinsic to the content and so travels with it.
*/
export interface StoredChunk {
readonly seq: number;
readonly role: Role;
readonly chunk: Chunk;
}
// ─── Reasoning effort ───────────────────────────────────────────────────────
/**
* The per-request thinking-depth knob: how much extended thinking / reasoning
* the model should spend before answering. Provider-agnostic ladder; each
* provider maps a level to its native knob in its own code (e.g. an Anthropic
* provider maps it to a `thinking.budget_tokens` value) and MAY ignore levels
* (or the field entirely) that its backend cannot express.
*
* Resolution (owned by the session-orchestrator): per-turn request value →
* persisted per-conversation value → default `"high"`.
*/
export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
// ─── Usage ──────────────────────────────────────────────────────────────────
/**
* Token usage counters for a single step. All fields are counts of tokens.
* Cache fields are optional because not all providers expose cache metrics.
*/
export interface Usage {
readonly inputTokens: number;
readonly outputTokens: number;
readonly cacheReadTokens?: number;
readonly cacheWriteTokens?: number;
}
// ─── Persisted metrics ───────────────────────────────────────────────────────
/**
* Durable per-step metrics for a completed step — the persisted, replayable
* counterpart of the live `usage` + `step-complete` events. Combines the step's
* token usage with its generation timing so a client reopening a past
* conversation renders the same per-step token/latency breakdown it would have
* seen live. Built from the turn's events, stored by `conversation-store`, and
* served by `GET /conversations/:id/metrics`.
*/
export interface StepMetrics {
readonly stepId: StepId;
/** The step's token usage (all four counters; cache fields optional per `Usage`). */
readonly usage: Usage;
/** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */
readonly ttftMs?: number;
/** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */
readonly decodeMs?: number;
/** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */
readonly genTotalMs?: number;
}
/**
* Durable per-turn metrics for a completed (sealed) turn — the persisted,
* replayable counterpart of the live `done` event's aggregate `usage` +
* `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all
* steps; `steps` carries each step's `StepMetrics` in step order. Stored by
* `conversation-store` keyed by `turnId` and served by
* `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried
* on every `AgentEvent`, the join key to the live stream.)
*/
export interface TurnMetrics {
readonly turnId: string;
/** Aggregate token usage across all steps in the turn. */
readonly usage: Usage;
/** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */
readonly durationMs?: number;
/** Per-step metrics in step order. */
readonly steps: readonly StepMetrics[];
/**
* **Context size** — tokens the conversation occupies as of this turn: the
* turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`),
* NOT the aggregate `usage` (which sums per-step prompts and overcounts a
* multi-step turn). The persisted, replayable counterpart of
* `TurnDoneEvent.contextSize` and equal to it for the same turn. A client
* reopening a past conversation reads the LAST turn's `contextSize` as the
* current context usage. Optional: absent when no per-step usage was available.
*/
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 ─────────────────────────────────────────────────────────
/**
* The union of all events the runtime emits outward during a turn.
* Consumers (transport, persistence, notifications) pattern-match on `type`.
*/
export type AgentEvent =
| StatusEvent
| TurnStartEvent
| TurnInputEvent
| TurnTextDeltaEvent
| TurnReasoningDeltaEvent
| TurnToolCallEvent
| TurnToolResultEvent
| TurnToolOutputEvent
| TurnUsageEvent
| TurnStepCompleteEvent
| TurnErrorEvent
| TurnDoneEvent
| TurnSealedEvent
| TurnSteeringEvent;
/** Status change for a conversation (e.g. idle → running). */
export interface StatusEvent {
readonly type: "status";
readonly conversationId: string;
readonly status: string;
}
/** A turn has begun. */
export interface TurnStartEvent {
readonly type: "turn-start";
readonly conversationId: string;
readonly turnId: string;
}
/**
* The user prompt that opened this turn, surfaced INTO the turn's outward event
* stream so a WATCHER (subscribed but not the sender) can render the prompt
* mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE
* as the FIRST event of the turn (before `turn-start`); buffered + replayed to
* every subscriber (live + late-join). The sender echoes its own prompt
* optimistically, so DE-DUP against that (by text); a pure watcher renders it
* directly. Carries the raw `text` passed to the provider. (Turn-scoped: it
* carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.)
*/
export interface TurnInputEvent {
readonly type: "user-message";
readonly conversationId: string;
readonly turnId: string;
readonly text: string;
}
/** Incremental text content from the model during a turn. */
export interface TurnTextDeltaEvent {
readonly type: "text-delta";
readonly conversationId: string;
readonly turnId: string;
readonly delta: string;
}
/** Incremental reasoning / thinking content during a turn. */
export interface TurnReasoningDeltaEvent {
readonly type: "reasoning-delta";
readonly conversationId: string;
readonly turnId: string;
readonly delta: string;
}
/** The model has requested a tool to be run. */
export interface TurnToolCallEvent {
readonly type: "tool-call";
readonly conversationId: string;
readonly turnId: string;
/**
* The step that produced this call. Tool calls a model batches together in
* one step share the same `stepId` — the grouping key for rendering a
* parallel batch as one unit. Matches the `stepId` on the matching
* `tool-result` event and on the persisted tool chunk
* (`StoredChunk.chunk.stepId`).
*/
readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
}
/** A tool has completed execution. */
export interface TurnToolResultEvent {
readonly type: "tool-result";
readonly conversationId: string;
readonly turnId: string;
/**
* The step that produced the originating call. Equal to the `stepId` on the
* matching `tool-call` event (same `toolCallId`) and on the persisted tool
* chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with
* their results.
*/
readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
/**
* How long the tool took to execute (dispatch → result), in milliseconds —
* the backend's authoritative execution time, distinct from any client-side
* wall-clock. Optional: present only when the runtime was given a clock.
*/
readonly durationMs?: number;
}
/** Streaming output from a tool execution (e.g. shell stdout/stderr). */
export interface TurnToolOutputEvent {
readonly type: "tool-output";
readonly conversationId: string;
readonly turnId: string;
readonly toolCallId: string;
readonly data: string;
readonly stream: "stdout" | "stderr";
}
/** Token usage for the current step or turn. */
export interface TurnUsageEvent {
readonly type: "usage";
readonly conversationId: string;
readonly turnId: string;
/**
* The step this usage report belongs to, so a consumer can attribute tokens
* per step (and join with the matching `step-complete` timing by `stepId`).
* Optional: absent when the runtime had no step context, and on usage emitted
* before this field existed.
*/
readonly stepId?: StepId;
readonly usage: Usage;
}
/**
* A step (one LLM round-trip) has completed — the authoritative per-step metrics
* packet, emitted once at the step's end (after the generation stream finishes),
* so its timing is final (unlike `usage`, which may arrive mid-stream). Carries
* the step's generation timing; join to the step's tokens via `stepId` on the
* `usage` event. All timing fields are optional: present only when the runtime
* was given a clock, and `ttftMs`/`decodeMs` additionally require that a first
* content token (text or reasoning) was observed this step.
*/
export interface TurnStepCompleteEvent {
readonly type: "step-complete";
readonly conversationId: string;
readonly turnId: string;
readonly stepId: StepId;
/** Time to first token: stream start → first text/reasoning delta. */
readonly ttftMs?: number;
/** Decode time: first token → stream end (generation total − TTFT). */
readonly decodeMs?: number;
/**
* Total generation time for the step: stream start → stream end. Present
* whenever a clock was available, even if no first token was seen (in which
* case `ttftMs`/`decodeMs` are absent). When a first token was seen,
* `genTotalMs === ttftMs + decodeMs`.
*/
readonly genTotalMs?: number;
}
/** An error occurred during the turn. */
export interface TurnErrorEvent {
readonly type: "error";
readonly conversationId: string;
readonly turnId: string;
readonly message: string;
readonly code?: string;
}
/** The turn has completed (model finished generating). */
export interface TurnDoneEvent {
readonly type: "done";
readonly conversationId: string;
readonly turnId: string;
readonly reason: string;
/**
* Total wall-clock duration of the turn (turn start → turn end), in
* milliseconds. Optional: present only when the runtime was given a clock.
*/
readonly durationMs?: number;
/**
* Aggregate token usage across all steps in the turn — a convenience total so
* a consumer need not sum the per-step `usage` events. Optional (absent if the
* provider reported no usage).
*/
readonly usage?: Usage;
/**
* **Context size** — tokens the conversation occupies right now: the turn's
* FINAL step `inputTokens + outputTokens` (the prompt sent into the last LLM
* round-trip plus that round-trip's output). This is the "tokens in context"
* figure a client renders as the chat's current context usage, and a client
* treats the LATEST turn's value as the live total.
*
* Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's
* `inputTokens`, which overcounts a multi-step / tool-calling turn because every
* step re-prefills the growing prompt — the final step's input already includes
* all prior context, so its input+output is the true occupancy. Optional: absent
* when no per-step usage was observed this turn (mirrors `usage`). A later field
* will carry the model's max context-window LIMIT; this is only the current size.
*/
readonly contextSize?: number;
}
/**
* The turn has been sealed — all chunks persisted, history is final.
* This is the hook point for post-turn extensions (compaction, cache-warm).
*/
export interface TurnSealedEvent {
readonly type: "turn-sealed";
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;
}
// ─── Conversation metadata ───────────────────────────────────────────────────
/**
* The per-conversation lifecycle status. `active` = a turn is generating;
* `idle` = exists, not generating; `closed` = dismissed (hidden from the tab
* bar, not deleted). Transitions are backend-owned and pushed via the
* `conversation.statusChanged` WS message (see `transport-contract`).
*/
export type ConversationStatus = "active" | "idle" | "closed";
/**
* Metadata for a conversation, returned by `GET /conversations` (the list
* endpoint). The title defaults to the first user message (truncated) and can
* be set via `PUT /conversations/:id/title`. `createdAt` is set on first write;
* `lastActivityAt` is updated on every append.
*/
export interface ConversationMeta {
readonly id: string;
readonly createdAt: number;
readonly lastActivityAt: number;
readonly title: string;
readonly status: ConversationStatus;
/** Points to the archive conversation with full pre-compaction history. */
readonly compactedFrom?: string;
}
// ─── Compaction ──────────────────────────────────────────────────────────────
/**
* Result of a compaction operation. `summary` is the text the model produced;
* `messagesKept` is how many recent messages were retained after the summary;
* `messagesSummarized` is how many old messages were replaced by the summary.
*/
export interface CompactionResult {
readonly summary: string;
readonly newConversationId: string;
readonly messagesSummarized: number;
readonly messagesKept: number;
}
```
|