@zvada/agent-server 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/README.md +34 -4
  3. package/docs/consuming.md +269 -0
  4. package/docs/deploy.md +80 -0
  5. package/docs/harnesses.md +64 -0
  6. package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
  7. package/package.json +23 -3
  8. package/src/client/client.ts +153 -49
  9. package/src/core/agents/acp/acp-agent.ts +9 -0
  10. package/src/core/agents/acp/mappings.ts +3 -3
  11. package/src/core/agents/base.ts +35 -5
  12. package/src/core/agents/claude-code/adapter.ts +116 -26
  13. package/src/core/agents/claude-code/claude-agent.ts +44 -7
  14. package/src/core/agents/claude-code/generator-session.ts +53 -14
  15. package/src/core/agents/claude-code/options.ts +13 -3
  16. package/src/core/agents/claude-code/session-manager.ts +9 -4
  17. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +25 -6
  18. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
  19. package/src/core/agents/types.ts +1 -1
  20. package/src/core/diagnostics.ts +59 -0
  21. package/src/core/index.ts +6 -2
  22. package/src/core/presets.ts +15 -2
  23. package/src/core/provision/pins.ts +5 -1
  24. package/src/core/proxy/anthropic-proxy.ts +76 -3
  25. package/src/core/runtime/agent-runtime.ts +215 -28
  26. package/src/core/runtime/event-processor.ts +51 -26
  27. package/src/core/utils/errors.ts +37 -3
  28. package/src/protocol/config.ts +8 -6
  29. package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
  30. package/src/protocol/factories.ts +106 -10
  31. package/src/protocol/guards.ts +53 -0
  32. package/src/protocol/index.ts +10 -0
  33. package/src/protocol/lifecycle.ts +289 -112
  34. package/src/protocol/meta.ts +14 -0
  35. package/src/protocol/part-input.ts +56 -7
  36. package/src/protocol/parts.ts +125 -10
  37. package/src/protocol/reduce.ts +749 -0
  38. package/src/protocol/selectors.ts +162 -0
  39. package/src/protocol/seq-cursor.ts +87 -0
  40. package/src/protocol/stop-reasons.ts +45 -0
  41. package/src/protocol/time.ts +23 -0
  42. package/src/protocol/tokens.ts +23 -0
  43. package/src/protocol/tool-state.ts +85 -25
  44. package/src/protocol/verify.ts +440 -0
  45. package/src/protocol/vocabulary.ts +18 -0
  46. package/src/protocol/wire.ts +103 -7
  47. package/src/server/acp/binding.ts +23 -2
  48. package/src/server/acp/translate.ts +51 -14
  49. package/src/server/agent-server.ts +109 -6
@@ -0,0 +1,749 @@
1
+ import { z } from "zod";
2
+ // The package ships raw .ts — keep imports exactly-used, or every consumer
3
+ // compiling with noUnusedLocals inherits the noise as build errors.
4
+ import { ErrorCategorySchema, ErrorInfoSchema } from "./errors.ts";
5
+ import { isUnknownEvent } from "./guards.ts";
6
+ import { AgentHarnessSchema } from "./harness.ts";
7
+ import {
8
+ type CompactionStatus,
9
+ CompactionStatusSchema,
10
+ CompactionTriggerSchema,
11
+ type DecodedLifecycleEvent,
12
+ PermissionOptionSchema,
13
+ PermissionOutcomeSchema,
14
+ PermissionToolCallSchema,
15
+ SessionEndReasonSchema,
16
+ StopReasonSchema,
17
+ type UnknownEvent,
18
+ } from "./lifecycle.ts";
19
+ import { type Part, PartSchema, type UnknownPart } from "./parts.ts";
20
+ import { EpochMsSchema } from "./time.ts";
21
+ import { DEFAULT_TOKEN_USAGE, TokenUsageSchema, addTokenUsage } from "./tokens.ts";
22
+
23
+ /**
24
+ * The canonical LifecycleEvent → conversation-state reducer.
25
+ *
26
+ * Every consumer of the stream ends up writing this fold (a UI store, a
27
+ * persistence shim, a CLI renderer — deus, agnt, and this repo's own CLI each
28
+ * had one). This is the reference implementation of the stream's consumption
29
+ * rules, so products inherit them instead of re-deriving them:
30
+ *
31
+ * - `message.part` snapshots UPSERT by `part.id` — including a tool part
32
+ * completing after its message (or turn) ended: the event's `messageId`
33
+ * names the ORIGINAL message, and the upsert lands there.
34
+ * - deltas are additive streaming aids; part snapshots are authoritative and
35
+ * replace whatever the deltas built (field-ownership rule:
36
+ * `delta("A"), delta("B"), snapshot{text:"C"}` folds to `"C"`).
37
+ * - permission requests resolve exactly once (turn end / cancel resolves
38
+ * stragglers as cancelled) — `pendingPermissions()` is what a UI renders.
39
+ * - at `turn.ended{stopReason:"cancelled"}` every non-terminal tool part of
40
+ * that turn transitions to `status:"cancelled"` (cancellation closure).
41
+ * - `session.compaction` is an ID-addressed POSITIONAL entity: its first
42
+ * event anchors it in the `timeline`; later upserts advance it in place.
43
+ * - `turn.ended.tokens` is the turn's billing total (summed into `totals`);
44
+ * `session.usage` is the live context gauge — different numbers, both kept.
45
+ * - unknown event types are preserved in `unknownEvents` (Law 6), never
46
+ * dropped.
47
+ *
48
+ * Pure and copy-on-write: `reduceConversation` never mutates its input; every
49
+ * changed slice (timeline entry, parts array, turn) is a fresh object, so the
50
+ * state is directly usable in identity-diffing UIs (React et al).
51
+ *
52
+ * Feed each event exactly once, in `seq` order — the wire client already
53
+ * dedupes and heals gaps; embedded consumers get ordered events from the sink.
54
+ * (`createSeqCursor()` is that discipline on its own, for consumers folding
55
+ * envelopes they received some other way.)
56
+ *
57
+ * Sinks that WRITE rather than render use `reduceConversationWithChanges`: the
58
+ * same fold, plus the list of what it touched (see `ConversationChange`).
59
+ */
60
+
61
+ const UnknownPartSchema = z.looseObject({
62
+ type: z.string(),
63
+ id: z.string(),
64
+ sessionId: z.string(),
65
+ messageId: z.string(),
66
+ parentToolCallId: z.string().optional(),
67
+ raw: z.record(z.string(), z.unknown()),
68
+ }) as unknown as z.ZodType<UnknownPart>;
69
+
70
+ export const ConversationMessageSchema = z.object({
71
+ kind: z.literal("message"),
72
+ messageId: z.string(),
73
+ sessionId: z.string(),
74
+ turnId: z.string(),
75
+ outputIndex: z.number(),
76
+ role: z.enum(["user", "assistant"]),
77
+ /** Set when this is a sub-agent's output — nest it under that tool call. */
78
+ parentToolCallId: z.string().optional(),
79
+ model: z.string().optional(),
80
+ parts: z.array(z.union([PartSchema, UnknownPartSchema])),
81
+ startedAt: EpochMsSchema,
82
+ endedAt: EpochMsSchema.optional(),
83
+ });
84
+ export type ConversationMessage = z.infer<typeof ConversationMessageSchema>;
85
+
86
+ /**
87
+ * The folded compaction entity. Upserts MERGE (a later event that omits
88
+ * `summary`/`preTokens` keeps the ones already known) — deliberately unlike
89
+ * `message.part`, whose snapshots fully REPLACE. A compaction's fields arrive
90
+ * from different harness events at different times ("started, 150k in" then
91
+ * "done, here's the summary"), so replace-semantics would make every producer
92
+ * restate everything or lose it. The cost is stated plainly: a producer cannot
93
+ * clear a compaction field once set.
94
+ */
95
+ export const ConversationCompactionSchema = z.object({
96
+ kind: z.literal("compaction"),
97
+ compactionId: z.string(),
98
+ turnId: z.string(),
99
+ status: CompactionStatusSchema,
100
+ trigger: CompactionTriggerSchema.optional(),
101
+ preTokens: z.number().optional(),
102
+ postTokens: z.number().optional(),
103
+ summary: z.string().optional(),
104
+ abstractsIds: z.array(z.string()).optional(),
105
+ timestamp: EpochMsSchema,
106
+ });
107
+ export type ConversationCompaction = z.infer<typeof ConversationCompactionSchema>;
108
+
109
+ /** The ordered render list: messages and compactions interleaved in stream order. */
110
+ export const TimelineEntrySchema = z.discriminatedUnion("kind", [
111
+ ConversationMessageSchema,
112
+ ConversationCompactionSchema,
113
+ ]);
114
+ export type TimelineEntry = z.infer<typeof TimelineEntrySchema>;
115
+
116
+ export const ConversationTurnSchema = z.object({
117
+ turnId: z.string(),
118
+ /** Derived view state, not wire vocabulary. */
119
+ status: z.enum(["active", "ended"]),
120
+ stopReason: StopReasonSchema.optional(),
121
+ finishReason: z.string().optional(),
122
+ tokens: TokenUsageSchema.optional(),
123
+ cost: z.number().optional(),
124
+ /** Terminal error from `turn.ended`, when the turn failed. */
125
+ error: ErrorInfoSchema.optional(),
126
+ /** Non-terminal `error` events attributed to this turn. */
127
+ errors: z.array(
128
+ z.object({
129
+ category: ErrorCategorySchema,
130
+ message: z.string(),
131
+ recoverable: z.boolean(),
132
+ timestamp: EpochMsSchema,
133
+ }),
134
+ ),
135
+ startedAt: EpochMsSchema,
136
+ endedAt: EpochMsSchema.optional(),
137
+ });
138
+ export type ConversationTurn = z.infer<typeof ConversationTurnSchema>;
139
+
140
+ export const ConversationPermissionSchema = z.object({
141
+ requestId: z.string(),
142
+ turnId: z.string(),
143
+ title: z.string(),
144
+ toolCall: PermissionToolCallSchema.optional(),
145
+ options: z.array(PermissionOptionSchema),
146
+ requestedAt: EpochMsSchema,
147
+ /** Absent while the request is pending. */
148
+ outcome: PermissionOutcomeSchema.optional(),
149
+ resolvedAt: EpochMsSchema.optional(),
150
+ });
151
+ export type ConversationPermission = z.infer<typeof ConversationPermissionSchema>;
152
+
153
+ const UnknownEventSchema = z.looseObject({
154
+ type: z.string(),
155
+ sessionId: z.string().optional(),
156
+ raw: z.record(z.string(), z.unknown()),
157
+ }) as unknown as z.ZodType<UnknownEvent>;
158
+
159
+ export const ConversationStateSchema = z.object({
160
+ sessionId: z.string().optional(),
161
+ /** Persist this and pass it back as `config.resumeSessionId` to resume. */
162
+ nativeSessionId: z.string().optional(),
163
+ harness: AgentHarnessSchema.optional(),
164
+ model: z.string().optional(),
165
+ /** From the last resume-requesting turn: false = fresh-session fallback. */
166
+ resumed: z.boolean().optional(),
167
+ timeline: z.array(TimelineEntrySchema),
168
+ turns: z.array(ConversationTurnSchema),
169
+ permissions: z.array(ConversationPermissionSchema),
170
+ /** Live context gauge (`session.usage`) — how full the window is NOW. */
171
+ usage: z
172
+ .object({
173
+ used: z.number(),
174
+ size: z.number().optional(),
175
+ cost: z.number().optional(),
176
+ updatedAt: EpochMsSchema,
177
+ })
178
+ .optional(),
179
+ /** Sum of every ended turn's billing tokens. */
180
+ totals: TokenUsageSchema,
181
+ totalCost: z.number().optional(),
182
+ /** `error` events that carried no turnId. */
183
+ errors: z.array(
184
+ z.object({
185
+ category: ErrorCategorySchema,
186
+ message: z.string(),
187
+ recoverable: z.boolean(),
188
+ timestamp: EpochMsSchema,
189
+ }),
190
+ ),
191
+ /** The harness session is over (`session.ended`). */
192
+ ended: z.object({ reason: SessionEndReasonSchema, timestamp: EpochMsSchema }).optional(),
193
+ /** Unknown event types, preserved in arrival order (Law 6). */
194
+ unknownEvents: z.array(UnknownEventSchema),
195
+ /**
196
+ * Raw JSON accumulated from `tool_input` deltas per part id, for UIs that
197
+ * stream tool input as it is typed. Cleared when the part's snapshot
198
+ * arrives with the parsed input (the authoritative form).
199
+ */
200
+ toolInputJson: z.record(z.string(), z.string()),
201
+ });
202
+ export type ConversationState = z.infer<typeof ConversationStateSchema>;
203
+
204
+ /** A fresh, empty conversation state. */
205
+ export function emptyConversation(): ConversationState {
206
+ return {
207
+ timeline: [],
208
+ turns: [],
209
+ permissions: [],
210
+ // The same zero as `addTokenUsage` produces, so "nothing yet" and "summed
211
+ // nothing" are the same shape — a UI never has to handle two empties.
212
+ totals: { ...DEFAULT_TOKEN_USAGE },
213
+ errors: [],
214
+ unknownEvents: [],
215
+ toolInputJson: {},
216
+ };
217
+ }
218
+
219
+ /** The permission requests still awaiting an answer — render these. */
220
+ export function pendingPermissions(state: ConversationState): ConversationPermission[] {
221
+ return state.permissions.filter((p) => p.outcome === undefined);
222
+ }
223
+
224
+ /** The message entries of the timeline, in order (convenience accessor). */
225
+ export function conversationMessages(state: ConversationState): ConversationMessage[] {
226
+ return state.timeline.filter((e): e is ConversationMessage => e.kind === "message");
227
+ }
228
+
229
+ /**
230
+ * What one fold actually touched — the seam between "the stream says this" and
231
+ * "so write these rows / invalidate these queries".
232
+ *
233
+ * Emitted by `reduceConversationWithChanges`, which INSTRUMENTS the fold
234
+ * rather than diffing states: every mutation site reports itself, so a change
235
+ * list is exactly the set of writes the reducer performed, in the order it
236
+ * performed them. A no-op fold (a replayed duplicate) reports nothing.
237
+ *
238
+ * The addressing is the wire's: `messageId`/`partId`/`turnId`/`requestId` are
239
+ * the same ids the events carry, so a sink can upsert by them without holding
240
+ * a second index. `outputIndex`/`partIndex` are the event's positions when the
241
+ * change came from a `message.part`; for the parts the reducer closes itself
242
+ * (cancellation closure) they are the message's `outputIndex` and the part's
243
+ * position in that message — the same numbers for any stream that arrived in
244
+ * order.
245
+ */
246
+ export const ConversationChangeSchema = z.discriminatedUnion("kind", [
247
+ /** A message was added to the timeline (started, or shelled in by a part). */
248
+ z.object({ kind: z.literal("message-upserted"), messageId: z.string(), turnId: z.string() }),
249
+ /** A part snapshot landed in its message (or a delta extended one in place). */
250
+ z.object({
251
+ kind: z.literal("part-upserted"),
252
+ messageId: z.string(),
253
+ partId: z.string(),
254
+ outputIndex: z.number(),
255
+ partIndex: z.number(),
256
+ }),
257
+ /** A message got its `endedAt` (once — a repeat is not a change). */
258
+ z.object({ kind: z.literal("message-ended"), messageId: z.string() }),
259
+ /** Streamed tool-input JSON was appended to `toolInputJson[partId]`. */
260
+ z.object({ kind: z.literal("delta-buffered"), partId: z.string() }),
261
+ /** A turn was opened, ended, or gained a non-terminal error. */
262
+ z.object({ kind: z.literal("turn-updated"), turnId: z.string() }),
263
+ z.object({ kind: z.literal("compaction-upserted"), compactionId: z.string() }),
264
+ /** A permission was requested, answered, or cancelled by its turn ending. */
265
+ z.object({ kind: z.literal("permission-updated"), requestId: z.string() }),
266
+ /** The context gauge (`session.usage`) or the billing totals moved. */
267
+ z.object({ kind: z.literal("usage-updated") }),
268
+ /**
269
+ * Session-scoped facts changed: the `session.created` identity fields, or
270
+ * an `error` event with no turn to attribute (it lands in `state.errors`).
271
+ */
272
+ z.object({ kind: z.literal("session-meta-updated") }),
273
+ z.object({ kind: z.literal("session-ended") }),
274
+ z.object({ kind: z.literal("unknown-event-recorded") }),
275
+ ]);
276
+ export type ConversationChange = z.infer<typeof ConversationChangeSchema>;
277
+
278
+ /** Where a mutation site reports itself. */
279
+ type ChangeRecorder = (change: ConversationChange) => void;
280
+
281
+ /** The recorder the public `reduceConversation` passes — zero behavior change. */
282
+ const IGNORE_CHANGES: ChangeRecorder = () => {};
283
+
284
+ /** Replace the message with id `messageId` via `update`; no-op when absent. */
285
+ function withMessage(
286
+ state: ConversationState,
287
+ messageId: string,
288
+ update: (message: ConversationMessage) => ConversationMessage,
289
+ ): ConversationState {
290
+ // Search from the end: the message being updated is almost always recent.
291
+ for (let i = state.timeline.length - 1; i >= 0; i--) {
292
+ const entry = state.timeline[i] as TimelineEntry;
293
+ if (entry.kind !== "message" || entry.messageId !== messageId) continue;
294
+ const updated = update(entry);
295
+ // Identity short-circuit: an update that declined (a repeated
296
+ // message.ended, a delta with no open snapshot) is a true no-op —
297
+ // returning the SAME state object makes "no changes recorded" and
298
+ // "state unchanged" the same fact, which sinks and tests rely on.
299
+ if (updated === entry) return state;
300
+ const timeline = state.timeline.slice();
301
+ timeline[i] = updated;
302
+ return { ...state, timeline };
303
+ }
304
+ return state;
305
+ }
306
+
307
+ /** Append a fresh message unless its id is already known (replay-safe). */
308
+ function openMessage(
309
+ state: ConversationState,
310
+ init: {
311
+ messageId: string;
312
+ sessionId: string;
313
+ turnId: string;
314
+ outputIndex: number;
315
+ timestamp: number;
316
+ role: "user" | "assistant";
317
+ parentToolCallId?: string;
318
+ model?: string;
319
+ },
320
+ record: ChangeRecorder,
321
+ ): ConversationState {
322
+ if (state.timeline.some((e) => e.kind === "message" && e.messageId === init.messageId)) {
323
+ return state;
324
+ }
325
+ record({ kind: "message-upserted", messageId: init.messageId, turnId: init.turnId });
326
+ const message: ConversationMessage = {
327
+ kind: "message",
328
+ messageId: init.messageId,
329
+ sessionId: init.sessionId,
330
+ turnId: init.turnId,
331
+ outputIndex: init.outputIndex,
332
+ role: init.role,
333
+ ...(init.parentToolCallId !== undefined && { parentToolCallId: init.parentToolCallId }),
334
+ ...(init.model !== undefined && { model: init.model }),
335
+ parts: [],
336
+ startedAt: init.timestamp,
337
+ };
338
+ return { ...state, timeline: [...state.timeline, message] };
339
+ }
340
+
341
+ /** Resolve an ended turn's still-pending permissions as cancelled. */
342
+ function cancelPendingForTurn(
343
+ permissions: ConversationPermission[],
344
+ turnId: string,
345
+ at: number,
346
+ record: ChangeRecorder,
347
+ ): ConversationPermission[] {
348
+ if (!permissions.some((p) => p.turnId === turnId && p.outcome === undefined)) {
349
+ return permissions;
350
+ }
351
+ return permissions.map((p) => {
352
+ if (p.turnId !== turnId || p.outcome !== undefined) return p;
353
+ record({ kind: "permission-updated", requestId: p.requestId });
354
+ return { ...p, outcome: { outcome: "cancelled" as const }, resolvedAt: at };
355
+ });
356
+ }
357
+
358
+ function upsertPart(
359
+ parts: Array<Part | UnknownPart>,
360
+ part: Part | UnknownPart,
361
+ ): Array<Part | UnknownPart> {
362
+ const index = parts.findIndex((p) => p.id === part.id);
363
+ if (index === -1) return [...parts, part];
364
+ const next = parts.slice();
365
+ next[index] = part;
366
+ return next;
367
+ }
368
+
369
+ /**
370
+ * `_meta` key holding the partial JSON a PENDING tool was still streaming when
371
+ * the turn was cancelled. The `cancelled` state carries a parsed `input`,
372
+ * which a pending call never had — without this the only record of what the
373
+ * call was about is dropped, and a cancel-heavy UI renders an empty tool card.
374
+ * Namespaced per Law 4: root names on canonical types stay reserved.
375
+ */
376
+ export const PARTIAL_INPUT_META_KEY = "agent-server/partialInput";
377
+
378
+ /**
379
+ * Cancellation closure: transition every non-terminal tool part of the turn's
380
+ * messages to `status:"cancelled"` — guarantees a consistent terminal state
381
+ * even when the harness died mid-tool and could emit nothing.
382
+ */
383
+ function closeCancelledTools(
384
+ state: ConversationState,
385
+ turnId: string,
386
+ at: number,
387
+ record: ChangeRecorder,
388
+ ): ConversationState {
389
+ let changed = false;
390
+ const timeline = state.timeline.map((entry) => {
391
+ if (entry.kind !== "message" || entry.turnId !== turnId) return entry;
392
+ let messageChanged = false;
393
+ const parts = entry.parts.map((part, partIndex): Part | UnknownPart => {
394
+ if ("raw" in part || part.type !== "tool") return part;
395
+ const open = part.state;
396
+ if (open.status !== "pending" && open.status !== "in_progress") return part;
397
+ messageChanged = true;
398
+ record({
399
+ kind: "part-upserted",
400
+ messageId: entry.messageId,
401
+ partId: part.id,
402
+ outputIndex: entry.outputIndex,
403
+ partIndex,
404
+ });
405
+ // The half-streamed input survives the cancel. Its source is the
406
+ // SNAPSHOT's partialInput when the adapter re-stated one, else the
407
+ // fold's own delta accumulator — shipped adapters stream tool input
408
+ // as deltas without re-snapshotting the pending tool, so without the
409
+ // fallback the preservation never fires on a real cancel.
410
+ const streamed =
411
+ open.status === "pending" ? open.partialInput || state.toolInputJson[part.id] : undefined;
412
+ const meta = streamed ? { ...open._meta, [PARTIAL_INPUT_META_KEY]: streamed } : open._meta;
413
+ return {
414
+ ...part,
415
+ state: {
416
+ status: "cancelled" as const,
417
+ input: open.status === "in_progress" ? open.input : {},
418
+ // A pending tool never started, so the cancel stamps both ends.
419
+ time: { start: open.status === "in_progress" ? open.time.start : at, end: at },
420
+ ...(meta !== undefined && { _meta: meta }),
421
+ },
422
+ };
423
+ });
424
+ if (!messageChanged) return entry;
425
+ changed = true;
426
+ return { ...entry, parts };
427
+ });
428
+ return changed ? { ...state, timeline } : state;
429
+ }
430
+
431
+ /** Fold one lifecycle event into the conversation. Pure; returns a new state. */
432
+ export function reduceConversation(
433
+ state: ConversationState,
434
+ event: DecodedLifecycleEvent | UnknownEvent,
435
+ ): ConversationState {
436
+ return reduceWith(state, event, IGNORE_CHANGES);
437
+ }
438
+
439
+ /**
440
+ * The same fold, plus the list of what it touched — for sinks that must WRITE
441
+ * rather than re-render: a persistence layer deciding which rows to upsert, a
442
+ * cache deciding which queries to invalidate, a projection keeping a second
443
+ * store in step.
444
+ *
445
+ * The changes are recorded BY the fold, not diffed from its output: there is
446
+ * exactly one implementation of the consumption rules, and `reduceConversation`
447
+ * is this function with the recorder thrown away. That is what makes the two
448
+ * impossible to drift apart — a new mutation site either reports itself or is
449
+ * invisible to every sink at once, which the tests pin.
450
+ *
451
+ * Ordering within one event is causal: the message shell before the part that
452
+ * forced it, the turn before the permissions its end cancelled. SQL semantics
453
+ * (ON CONFLICT vs REPLACE, COALESCE guards) stay yours — they are schema
454
+ * knowledge, not fold knowledge.
455
+ */
456
+ export function reduceConversationWithChanges(
457
+ state: ConversationState,
458
+ event: DecodedLifecycleEvent | UnknownEvent,
459
+ ): { state: ConversationState; changes: ConversationChange[] } {
460
+ const changes: ConversationChange[] = [];
461
+ const next = reduceWith(state, event, (change) => changes.push(change));
462
+ return { state: next, changes };
463
+ }
464
+
465
+ function reduceWith(
466
+ state: ConversationState,
467
+ event: DecodedLifecycleEvent | UnknownEvent,
468
+ record: ChangeRecorder,
469
+ ): ConversationState {
470
+ if (isUnknownEvent(event)) {
471
+ record({ kind: "unknown-event-recorded" });
472
+ return { ...state, unknownEvents: [...state.unknownEvents, event] };
473
+ }
474
+ switch (event.type) {
475
+ case "session.created":
476
+ record({ kind: "session-meta-updated" });
477
+ return {
478
+ ...state,
479
+ sessionId: event.sessionId,
480
+ nativeSessionId: event.nativeSessionId,
481
+ harness: event.harness,
482
+ ...(event.model !== undefined && { model: event.model }),
483
+ ...(event.resumed !== undefined && { resumed: event.resumed }),
484
+ };
485
+
486
+ case "session.ended":
487
+ record({ kind: "session-ended" });
488
+ return { ...state, ended: { reason: event.reason, timestamp: event.timestamp } };
489
+
490
+ case "turn.started": {
491
+ const existing = state.turns.findIndex((t) => t.turnId === event.turnId);
492
+ if (existing !== -1) return state; // replay overlap — the turn is known
493
+ const turn: ConversationTurn = {
494
+ turnId: event.turnId,
495
+ status: "active",
496
+ errors: [],
497
+ startedAt: event.timestamp,
498
+ };
499
+ record({ kind: "turn-updated", turnId: event.turnId });
500
+ return { ...state, turns: [...state.turns, turn] };
501
+ }
502
+
503
+ case "message.started":
504
+ return openMessage(state, { ...event, role: event.role }, record);
505
+
506
+ case "message.part": {
507
+ // Tolerance for the contract's stated exception: a part may arrive for
508
+ // a message whose start this consumer never saw — open a shell for it.
509
+ const ensured = openMessage(state, { ...event, role: "assistant" }, record);
510
+ let upserted = withMessage(ensured, event.messageId, (message) => ({
511
+ ...message,
512
+ parts: upsertPart(message.parts, event.part),
513
+ }));
514
+ record({
515
+ kind: "part-upserted",
516
+ messageId: event.messageId,
517
+ partId: event.part.id,
518
+ outputIndex: event.outputIndex,
519
+ partIndex: event.partIndex,
520
+ });
521
+ // Redelivery convergence: a snapshot replayed AFTER its turn already
522
+ // ended cancelled would re-open the tool (the replayed turn.ended is a
523
+ // no-op, so the closure never re-runs) — re-apply the closure so a
524
+ // whole-turn redelivery folds to the same terminal state.
525
+ const owningTurn = upserted.turns.find((t) => t.turnId === event.turnId);
526
+ if (
527
+ owningTurn?.status === "ended" &&
528
+ !("raw" in event.part) &&
529
+ event.part.type === "tool" &&
530
+ (event.part.state.status === "pending" || event.part.state.status === "in_progress")
531
+ ) {
532
+ upserted = closeCancelledTools(
533
+ upserted,
534
+ event.turnId,
535
+ owningTurn.endedAt ?? event.timestamp,
536
+ record,
537
+ );
538
+ }
539
+ // The snapshot's input is authoritative — drop the streamed JSON.
540
+ if (
541
+ !("raw" in event.part) &&
542
+ event.part.type === "tool" &&
543
+ upserted.toolInputJson[event.part.id] !== undefined
544
+ ) {
545
+ const { [event.part.id]: _dropped, ...toolInputJson } = upserted.toolInputJson;
546
+ return { ...upserted, toolInputJson };
547
+ }
548
+ return upserted;
549
+ }
550
+
551
+ case "message.part.delta": {
552
+ const { delta } = event;
553
+ if (delta.type === "tool_input") {
554
+ record({ kind: "delta-buffered", partId: event.partId });
555
+ return {
556
+ ...state,
557
+ toolInputJson: {
558
+ ...state.toolInputJson,
559
+ [event.partId]: (state.toolInputJson[event.partId] ?? "") + delta.input,
560
+ },
561
+ };
562
+ }
563
+ return withMessage(state, event.messageId, (message) => {
564
+ const index = message.parts.findIndex((p) => p.id === event.partId);
565
+ if (index === -1) return message; // no open snapshot yet — the next snapshot carries the text
566
+ const part = message.parts[index] as Part | UnknownPart;
567
+ if ("raw" in part) return message;
568
+ if (part.type !== "text" && part.type !== "reasoning") return message;
569
+ const parts = message.parts.slice();
570
+ parts[index] = { ...part, text: part.text + delta.text };
571
+ record({
572
+ kind: "part-upserted",
573
+ messageId: event.messageId,
574
+ partId: event.partId,
575
+ outputIndex: event.outputIndex,
576
+ partIndex: event.partIndex,
577
+ });
578
+ return { ...message, parts };
579
+ });
580
+ }
581
+
582
+ case "message.ended":
583
+ return withMessage(state, event.messageId, (message) => {
584
+ if (message.endedAt !== undefined) return message;
585
+ record({ kind: "message-ended", messageId: event.messageId });
586
+ return { ...message, endedAt: event.timestamp };
587
+ });
588
+
589
+ case "turn.ended": {
590
+ const index = state.turns.findIndex((t) => t.turnId === event.turnId);
591
+ const base: ConversationTurn = (state.turns[index] as ConversationTurn | undefined) ?? {
592
+ turnId: event.turnId,
593
+ status: "active",
594
+ errors: [],
595
+ startedAt: event.timestamp,
596
+ };
597
+ if (base.status === "ended") return state; // replay overlap
598
+ const turn: ConversationTurn = {
599
+ ...base,
600
+ status: "ended",
601
+ stopReason: event.stopReason,
602
+ ...(event.finishReason !== undefined && { finishReason: event.finishReason }),
603
+ ...(event.tokens !== undefined && { tokens: event.tokens }),
604
+ ...(event.cost !== undefined && { cost: event.cost }),
605
+ ...(event.error !== undefined && { error: event.error }),
606
+ endedAt: event.timestamp,
607
+ };
608
+ const turns = index === -1 ? [...state.turns, turn] : state.turns.slice();
609
+ if (index !== -1) turns[index] = turn;
610
+ record({ kind: "turn-updated", turnId: event.turnId });
611
+ // Terminal-permission invariant: normally a no-op (the engine emits
612
+ // permission.resolved first) — load-bearing on anomalous/partial
613
+ // streams so no prompt of an ended turn dangles as pending.
614
+ const permissions = cancelPendingForTurn(
615
+ state.permissions,
616
+ event.turnId,
617
+ event.timestamp,
618
+ record,
619
+ );
620
+ // The turn's billing rolls into the session aggregate.
621
+ if (event.tokens !== undefined || event.cost !== undefined) {
622
+ record({ kind: "usage-updated" });
623
+ }
624
+ let next: ConversationState = {
625
+ ...state,
626
+ turns,
627
+ permissions,
628
+ ...(event.tokens !== undefined && { totals: addTokenUsage(state.totals, event.tokens) }),
629
+ ...(event.cost !== undefined && { totalCost: (state.totalCost ?? 0) + event.cost }),
630
+ };
631
+ // A turn's end closes whatever it left in flight — for EVERY terminal,
632
+ // not just user cancels. The contract already says so (§7's
633
+ // late-parts-are-terminal-tools rule: nothing non-terminal may follow
634
+ // turn.ended), and an error/max_tokens end that leaves a tool
635
+ // `in_progress` otherwise renders as a spinner that never stops in
636
+ // every consumer. `cancelled` is the truthful closed status: the tool
637
+ // did not fail — the turn died before it settled.
638
+ next = closeCancelledTools(next, event.turnId, event.timestamp, record);
639
+ return next;
640
+ }
641
+
642
+ case "session.usage":
643
+ // Sticky merge: harnesses report `size`/`cost` intermittently (Claude
644
+ // only on the final result) — a fresher `used` must not erase the last
645
+ // known window size or cumulative cost. Consumers inherit this instead
646
+ // of re-deriving it (deus carried a SQL COALESCE for exactly this).
647
+ record({ kind: "usage-updated" });
648
+ return {
649
+ ...state,
650
+ usage: {
651
+ used: event.used,
652
+ ...(event.size !== undefined
653
+ ? { size: event.size }
654
+ : state.usage?.size !== undefined
655
+ ? { size: state.usage.size }
656
+ : {}),
657
+ ...(event.cost !== undefined
658
+ ? { cost: event.cost }
659
+ : state.usage?.cost !== undefined
660
+ ? { cost: state.usage.cost }
661
+ : {}),
662
+ updatedAt: event.timestamp,
663
+ },
664
+ };
665
+
666
+ case "session.compaction": {
667
+ const index = state.timeline.findIndex(
668
+ (e) => e.kind === "compaction" && e.compactionId === event.compactionId,
669
+ );
670
+ const entry: ConversationCompaction = {
671
+ kind: "compaction",
672
+ compactionId: event.compactionId,
673
+ turnId: event.turnId,
674
+ status: event.status as CompactionStatus,
675
+ ...(event.trigger !== undefined && { trigger: event.trigger }),
676
+ ...(event.preTokens !== undefined && { preTokens: event.preTokens }),
677
+ ...(event.postTokens !== undefined && { postTokens: event.postTokens }),
678
+ ...(event.summary !== undefined && { summary: event.summary }),
679
+ ...(event.abstractsIds !== undefined && { abstractsIds: event.abstractsIds }),
680
+ timestamp: event.timestamp,
681
+ };
682
+ record({ kind: "compaction-upserted", compactionId: event.compactionId });
683
+ if (index === -1) {
684
+ // First appearance anchors the entity's position in the timeline.
685
+ return { ...state, timeline: [...state.timeline, entry] };
686
+ }
687
+ // Upsert in place — the entity never moves.
688
+ const existing = state.timeline[index] as ConversationCompaction;
689
+ const timeline = state.timeline.slice();
690
+ timeline[index] = {
691
+ ...existing,
692
+ ...entry,
693
+ // Preserve first-seen anchor semantics: keep the original timestamp.
694
+ timestamp: existing.timestamp,
695
+ };
696
+ return { ...state, timeline };
697
+ }
698
+
699
+ case "permission.requested": {
700
+ if (state.permissions.some((p) => p.requestId === event.requestId)) return state;
701
+ const permission: ConversationPermission = {
702
+ requestId: event.requestId,
703
+ turnId: event.turnId,
704
+ title: event.title,
705
+ ...(event.toolCall !== undefined && { toolCall: event.toolCall }),
706
+ options: event.options,
707
+ requestedAt: event.timestamp,
708
+ };
709
+ record({ kind: "permission-updated", requestId: event.requestId });
710
+ return { ...state, permissions: [...state.permissions, permission] };
711
+ }
712
+
713
+ case "permission.resolved": {
714
+ const index = state.permissions.findIndex((p) => p.requestId === event.requestId);
715
+ if (index === -1) return state;
716
+ const existing = state.permissions[index] as ConversationPermission;
717
+ if (existing.outcome !== undefined) return state; // resolves exactly once
718
+ const permissions = state.permissions.slice();
719
+ permissions[index] = { ...existing, outcome: event.outcome, resolvedAt: event.timestamp };
720
+ record({ kind: "permission-updated", requestId: event.requestId });
721
+ return { ...state, permissions };
722
+ }
723
+
724
+ case "error": {
725
+ const entry = {
726
+ category: event.category,
727
+ message: event.message,
728
+ recoverable: event.recoverable,
729
+ timestamp: event.timestamp,
730
+ };
731
+ if (event.turnId !== undefined) {
732
+ const index = state.turns.findIndex((t) => t.turnId === event.turnId);
733
+ if (index !== -1) {
734
+ const turn = state.turns[index] as ConversationTurn;
735
+ const turns = state.turns.slice();
736
+ turns[index] = { ...turn, errors: [...turn.errors, entry] };
737
+ record({ kind: "turn-updated", turnId: event.turnId });
738
+ return { ...state, turns };
739
+ }
740
+ }
741
+ // No turn to attribute it to — a session-scoped fact.
742
+ record({ kind: "session-meta-updated" });
743
+ return { ...state, errors: [...state.errors, entry] };
744
+ }
745
+
746
+ case "raw":
747
+ return state;
748
+ }
749
+ }