Skip to main content

forge_primitives/
messages.rs

1//! Top-level stream-json message shapes.
2//!
3//! Every line the `claude --output-format stream-json` binary emits is one
4//! of these variants. SDK's `AssistantMessage`, `UserMessage`,
5//! `SystemMessage` (plus task-lifecycle + mirror-error subclasses),
6//! `ResultMessage`, and `RateLimitEvent`.
7
8use serde::de::Deserializer;
9use serde::ser::Serializer;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::content::ContentBlock;
14use crate::runtime::TerminalReason;
15
16/// Wire fields forge does not model, kept verbatim so a decode is lossless:
17/// what the CLI sent crosses to whoever draws the raw frame even when
18/// nothing here reads it. Reachable through `Message`'s variants and the
19/// values they nest.
20pub type Extras = serde_json::Map<String, Value>;
21
22/// Whether a `parent_tool_use_id` names the dispatch a frame ran under. Set
23/// and non-empty: the wire spells "no dispatch" as null, and every fold that
24/// reads the field guards on the same thing.
25pub fn names_a_dispatch(parent_tool_use_id: Option<&str>) -> bool {
26    parent_tool_use_id.is_some_and(|parent| !parent.trim().is_empty())
27}
28
29/// Whether `message` ran under a sub-agent dispatch, the chat's own skip
30/// rule: such a frame is card material, so a chat read that carried it would
31/// spend its frame budget on a row no page draws.
32pub fn ran_under_a_dispatch(message: &Message) -> bool {
33    match message {
34        Message::Assistant { parent_tool_use_id, .. }
35        | Message::User { parent_tool_use_id, .. } => {
36            names_a_dispatch(parent_tool_use_id.as_deref())
37        }
38        _ => false,
39    }
40}
41
42/// One stream-json message.
43///
44/// Wire-level dispatch on `type` and, for `type="system"`, on `subtype` is
45/// handled by a private shim - users never see it. Every variant here is
46/// the user-facing shape.
47#[derive(Debug, Clone, PartialEq)]
48pub enum Message {
49    /// An assistant turn (may be a partial chunk during streaming).
50    Assistant {
51        /// The nested Anthropic-API-shaped message envelope.
52        message: AssistantEnvelope,
53        /// Session id this turn belongs to.
54        session_id: String,
55        /// Parent tool-use id when this turn is a sub-agent spawned via `Task`.
56        parent_tool_use_id: Option<String>,
57        /// Classification of a failure the CLI attributes to this turn
58        /// (e.g. `rate_limit`, `billing_error`). `None` for successful
59        /// turns. `AssistantMessage.error`
60        ///
61        error: Option<AssistantMessageError>,
62        /// Stable identifier for this assistant turn - the CLI
63        /// `AssistantMessage.uuid`.
64        uuid: Option<String>,
65        /// When the CLI wrote this frame, RFC 3339. The only record of when
66        /// a turn ran that a transcript holds: no result frame reaches one.
67        timestamp: Option<String>,
68        /// Fields of the frame forge does not model, kept verbatim so a
69        /// decode is lossless for whatever draws the raw frame.
70        extras: Extras,
71    },
72
73    /// A user turn - user prompts or tool-result envelopes.
74    User {
75        /// The nested user-message envelope.
76        message: UserEnvelope,
77        /// Session id this turn belongs to.
78        session_id: String,
79        /// Parent tool-use id when this is a sub-agent turn.
80        parent_tool_use_id: Option<String>,
81        /// Stable identifier for this user turn - the CLI
82        /// `UserMessage.uuid`. `None` unless the CLI is configured to
83        /// emit them (`extra_args={"replay-user-messages": None}`).
84        uuid: Option<String>,
85        /// Raw tool-result payload the CLI attaches when this user turn
86        /// reports a tool's output. The CLI `UserMessage.tool_use_result`;
87        /// forge-sdk passes it through as a
88        /// [`Value`] since the upstream type is `dict[str, Any]`.
89        tool_use_result: Option<Value>,
90        /// When the CLI wrote this frame, RFC 3339. The only record of when
91        /// a turn ran that a transcript holds: no result frame reaches one.
92        timestamp: Option<String>,
93        /// The CLI's own mark that nobody typed this turn, folded from every
94        /// spelling the CLI gives it: the wire's `isSynthetic` (itself the
95        /// union of `isMeta`, `isVisibleInTranscriptOnly` and
96        /// `isCompactSummary`), and the transcript row's `isMeta`,
97        /// `isCompactSummary`, `isVisibleInTranscriptOnly` or
98        /// `turnCompanion`. A view that reads it draws the harness speaking
99        /// rather than the reader.
100        synthetic: bool,
101        /// Fields of the frame forge does not model, kept verbatim so a
102        /// decode is lossless for whatever draws the raw frame.
103        extras: Extras,
104    },
105
106    /// Out-of-band system event - `subtype` discriminates (e.g. `"init"`).
107    /// Known task-lifecycle and mirror-error subtypes get their own typed
108    /// variants below; everything else lands here with the raw payload.
109    System {
110        /// System event discriminant (e.g. `"init"`, `"info"`).
111        subtype: String,
112        /// Session id when the event is session-scoped.
113        session_id: Option<String>,
114        /// All other fields on the original message, captured verbatim.
115        data: Value,
116    },
117
118    /// A sub-agent `Task` has started running. Subtype `"task_started"`.
119    /// `TaskStartedMessage`.
120    TaskStarted {
121        /// Stable identifier for this task instance.
122        task_id: String,
123        /// Human-readable description supplied when the task was spawned.
124        description: String,
125        /// Unique identifier for this lifecycle event.
126        uuid: String,
127        /// Session id the task runs in.
128        session_id: String,
129        /// Parent tool-use id if the task was spawned via a tool call.
130        tool_use_id: Option<String>,
131        /// Sub-agent type selector (e.g. `"general-purpose"`).
132        task_type: Option<String>,
133        /// Fields of the frame forge does not model, kept verbatim so a
134        /// decode is lossless for whatever draws the raw frame.
135        extras: Extras,
136    },
137
138    /// Incremental lifecycle update for any long-running tool task
139    /// (backgrounded `Bash`, `Monitor`, sub-agent `Task`). Subtype
140    /// `"task_updated"`; it carries a `patch` object with status /
141    /// end_time deltas. Without a typed variant, the reducer can't
142    /// transition a backgrounded Bash from `running` to `completed`.
143    ///
144    /// **It rides alongside `task_notification`, not instead of it.** The
145    /// wire baseline (`baselines/sdk/2.1.280/backgrounded_bash_lifecycle.jsonl`
146    /// in forge-test-harness) carries `task_started`, this and the ending
147    /// `task_notification` together for one local-bash command, and it is
148    /// that ending frame which names the output file.
149    TaskUpdated {
150        /// Stable identifier for this task instance - same id surface
151        /// as `task_started` / `task_progress` / `task_notification`.
152        task_id: String,
153        /// Incremental patch applied to the task's state. Each field
154        /// is optional because patches may update only some fields.
155        patch: TaskUpdatePatch,
156        /// Unique identifier for this lifecycle event.
157        uuid: String,
158        /// Session id the task runs in.
159        session_id: String,
160        /// Fields of the frame forge does not model, kept verbatim so a
161        /// decode is lossless for whatever draws the raw frame.
162        extras: Extras,
163    },
164
165    /// Periodic progress update while a sub-agent `Task` is in flight.
166    /// Subtype `"task_progress"`. v0.1.64
167    /// `TaskProgressMessage`.
168    TaskProgress {
169        /// Stable identifier for this task instance.
170        task_id: String,
171        /// Human-readable description supplied when the task was spawned.
172        description: String,
173        /// Usage accumulated so far.
174        usage: TaskUsage,
175        /// Unique identifier for this lifecycle event.
176        uuid: String,
177        /// Session id the task runs in.
178        session_id: String,
179        /// Parent tool-use id if the task was spawned via a tool call.
180        tool_use_id: Option<String>,
181        /// Name of the last tool the sub-agent invoked, if any.
182        last_tool_name: Option<String>,
183        /// Workflow tool's per-event snapshot of the
184        /// workflow's phase + agent state. Empty for non-Workflow
185        /// task_progress events. Each event carries the FULL
186        /// snapshot (not a delta), so the renderer can rebuild the
187        /// per-phase tree from a single most-recent event.
188        workflow_progress: Vec<WorkflowProgressEvent>,
189        /// Fields of the frame forge does not model, kept verbatim so a
190        /// decode is lossless for whatever draws the raw frame.
191        extras: Extras,
192    },
193
194    /// Terminal notification when a task completes, fails, or is stopped:
195    /// a sub-agent `Task`, a backgrounded `Bash` or a `Monitor`. Subtype
196    /// `"task_notification"`. v0.1.64 `TaskNotificationMessage`.
197    ///
198    /// **Every task flavour fires it, not only the sub-agent tool.** The
199    /// wire baselines record it ending a backgrounded command
200    /// (`baselines/sdk/2.1.280/backgrounded_bash_lifecycle.jsonl` in
201    /// forge-test-harness), carrying the `output_file` its output went to;
202    /// `monitor.jsonl` under the claude-cli-upgrade skill records the same
203    /// for a Monitor. The `task_started` / `task_updated` pair (see
204    /// [`Self::TaskStarted`], [`Self::TaskUpdated`]) rides alongside while
205    /// the task runs rather than instead of this. Monitor stream events
206    /// also arrive as `Result` frames with
207    /// `origin: {kind: "task-notification"}`.
208    TaskNotification {
209        /// Stable identifier for this task instance.
210        task_id: String,
211        /// How the task ended.
212        status: TaskNotificationStatus,
213        /// Path on disk the task's output went to: its result transcript for
214        /// a sub-agent, the command's own stream for a backgrounded task.
215        /// Empty when the frame carries none - a path is never assumed.
216        output_file: String,
217        /// Short natural-language summary of the outcome.
218        summary: String,
219        /// Unique identifier for this lifecycle event.
220        uuid: String,
221        /// Session id the task ran in.
222        session_id: String,
223        /// Parent tool-use id if the task was spawned via a tool call.
224        tool_use_id: Option<String>,
225        /// Total usage accumulated over the lifetime of the task, if reported.
226        usage: Option<TaskUsage>,
227        /// Fields of the frame forge does not model, kept verbatim so a
228        /// decode is lossless for whatever draws the raw frame.
229        extras: Extras,
230    },
231
232    /// The CLI's heartbeat for a long-running tool call, emitted every
233    /// 30 seconds while the call is in flight. Top-level `tool_progress`.
234    ///
235    /// `tool_use_id` appends `-heartbeat-<n>` to the running call's own
236    /// id, and `parent_tool_use_id` names that call. An informational
237    /// frame the SDK used to drop at the reader; it crosses like any
238    /// other so whoever draws the raw stream can surface liveness on the
239    /// call it belongs to.
240    ToolProgress {
241        /// The running call's id with the CLI's `-heartbeat-<n>` suffix.
242        tool_use_id: String,
243        /// Name of the tool in flight (e.g. `"Bash"`).
244        tool_name: String,
245        /// Seconds since the tool call started.
246        elapsed_time_seconds: f64,
247        /// True on the 30-second cadence heartbeats.
248        heartbeat: bool,
249        /// The tool call this heartbeat belongs to, when it is one the
250        /// CLI parent-stamped (a sub-agent's own call).
251        parent_tool_use_id: Option<String>,
252        /// Session id, when the frame carries one.
253        session_id: Option<String>,
254        /// The frame's own id, when the CLI attaches one.
255        uuid: Option<String>,
256        /// Fields of the frame forge does not model, kept verbatim so a
257        /// decode is lossless for whatever draws the raw frame.
258        extras: Extras,
259    },
260
261    /// CLI-side estimated thinking-token count, fires repeatedly
262    /// during model thinking. Subtype `"thinking_tokens"` (#273).
263    ///
264    /// Both counters are scoped to one **thinking block**, not to the
265    /// turn: the CLI restarts them at each agentic iteration, so a
266    /// turn with two thinking blocks emits a second run beginning at
267    /// 50 again. A per-turn total is therefore the sum of the deltas,
268    /// which is what the turn info row renders.
269    ThinkingTokens {
270        /// Estimated reasoning tokens so far in the current thinking
271        /// block. Restarts at each block within a turn, so this is not
272        /// a turn total and reading it as one understates a multi-block
273        /// turn.
274        estimated_tokens: u64,
275        /// Growth since the previous event in the same thinking block,
276        /// and equal to `estimated_tokens` on a block's first event
277        /// rather than stepping backwards. The CLI emits roughly every
278        /// 50 tokens.
279        estimated_tokens_delta: i64,
280        /// Unique identifier for this thinking-tokens event.
281        uuid: String,
282        /// Session id the event applies to.
283        session_id: String,
284        /// Fields of the frame forge does not model, kept verbatim so a
285        /// decode is lossless for whatever draws the raw frame.
286        extras: Extras,
287    },
288
289    /// Assistant-turn wall-clock + message-count summary emitted at
290    /// the end of each turn. Subtype `"turn_duration"` (#273); decoded
291    /// for wire conformance, and no renderer composes a chip from `ms`.
292    TurnDuration {
293        /// Total wall-clock duration of the turn in milliseconds.
294        /// Wire field is `durationMs` (camelCase).
295        ms: u64,
296        /// Number of assistant + tool messages in the turn. Wire
297        /// field is `messageCount`. Optional because older CLI
298        /// versions may omit it.
299        message_count: Option<u64>,
300        /// Parent tool-use id when the turn is a sub-agent.
301        parent_tool_use_id: Option<String>,
302        /// Session id the event applies to.
303        session_id: String,
304        /// Unique identifier for this turn_duration event.
305        uuid: String,
306        /// Fields of the frame forge does not model, kept verbatim so a
307        /// decode is lossless for whatever draws the raw frame.
308        extras: Extras,
309    },
310
311    /// Stop-hook execution summary surfaced at end-of-turn. Subtype
312    /// `"stop_hook_summary"` (#273). The renderer composes a
313    /// collapsed 1-liner `↳ hook summary · N actions [▶ expand]`
314    /// from `actions` (wire `hookCount`); expanded view enumerates
315    /// `hook_infos`.
316    StopHookSummary {
317        /// Number of hooks that fired. Wire field is `hookCount`.
318        /// Renderer hides the surface entirely when this is 0.
319        actions: u32,
320        /// Per-hook breakdown. Wire field is `hookInfos`. Each entry
321        /// carries the command string + its duration in ms.
322        hook_infos: Vec<StopHookInfo>,
323        /// Errors the hook batch reported, empty when nothing failed.
324        /// Wire field is `hookErrors`; the client's hooks chip draws
325        /// these against the batch rather than against one entry.
326        hook_errors: Vec<String>,
327        /// Whether any hook produced output. Wire field is
328        /// `hasOutput`. Forwarded to the renderer so a zero-actions
329        /// + has-output edge case can be surfaced if needed.
330        has_output: bool,
331        /// Suggestion vs blocking level. Wire field is `level`.
332        level: String,
333        /// Whether any hook prevented turn continuation. Wire field
334        /// is `preventedContinuation`.
335        prevented_continuation: bool,
336        /// Stop reason string from the CLI; usually empty.
337        stop_reason: String,
338        /// `tool_use_id` the hook batch was bound to. Wire field is
339        /// `toolUseID`.
340        tool_use_id: String,
341        /// Parent tool-use id when the hook fires in a sub-agent context.
342        parent_tool_use_id: Option<String>,
343        /// Session id the event applies to.
344        session_id: String,
345        /// Unique identifier for this stop_hook_summary event.
346        uuid: String,
347        /// Fields of the frame forge does not model, kept verbatim so a
348        /// decode is lossless for whatever draws the raw frame.
349        extras: Extras,
350    },
351
352    /// Full background-task set after it changed. Subtype
353    /// `"background_tasks_changed"` (2.1.204); carries the whole list,
354    /// not a delta.
355    BackgroundTasksChanged {
356        /// Every background task after the change; each entry carries
357        /// `task_id` / `task_type` / `description`.
358        tasks: Vec<Value>,
359        /// Unique identifier for this event.
360        uuid: String,
361        /// Session id the event applies to.
362        session_id: String,
363        /// Fields of the frame forge does not model, kept verbatim so a
364        /// decode is lossless for whatever draws the raw frame.
365        extras: Extras,
366    },
367
368    /// Available slash-command set after it changed. Subtype
369    /// `"commands_changed"` (2.1.204), emitted on plugin reload.
370    CommandsChanged {
371        /// Every slash command after the change.
372        commands: Vec<Value>,
373        /// Unique identifier for this event.
374        uuid: String,
375        /// Session id the event applies to.
376        session_id: String,
377        /// Fields of the frame forge does not model, kept verbatim so a
378        /// decode is lossless for whatever draws the raw frame.
379        extras: Extras,
380    },
381
382    /// A hook began executing. Subtype `"hook_started"` (2.1.204).
383    HookStarted {
384        /// Stable id for this hook run, paired with [`Self::HookResponse`].
385        hook_id: String,
386        /// Hook matcher name (e.g. `"SessionStart:startup"`).
387        hook_name: String,
388        /// Hook event that fired it (e.g. `"SessionStart"`).
389        hook_event: String,
390        /// Unique identifier for this event.
391        uuid: String,
392        /// Session id the event applies to.
393        session_id: String,
394        /// Fields of the frame forge does not model, kept verbatim so a
395        /// decode is lossless for whatever draws the raw frame.
396        extras: Extras,
397    },
398
399    /// A hook finished executing. Subtype `"hook_response"` (2.1.204).
400    HookResponse {
401        /// Stable id tying this back to its [`Self::HookStarted`].
402        hook_id: String,
403        /// Hook matcher name (e.g. `"SessionStart:startup"`).
404        hook_name: String,
405        /// Hook event that fired it (e.g. `"SessionStart"`).
406        hook_event: String,
407        /// Outcome tag reported by the CLI (e.g. `"success"`).
408        outcome: String,
409        /// Process exit code of the hook command.
410        exit_code: i64,
411        /// Combined output surfaced to the session.
412        output: String,
413        /// Raw stdout of the hook command.
414        stdout: String,
415        /// Raw stderr of the hook command.
416        stderr: String,
417        /// Unique identifier for this event.
418        uuid: String,
419        /// Session id the event applies to.
420        session_id: String,
421        /// Fields of the frame forge does not model, kept verbatim so a
422        /// decode is lossless for whatever draws the raw frame.
423        extras: Extras,
424    },
425
426    /// A long-running hook emitted interim output. Subtype
427    /// `"hook_progress"` (2.1.263).
428    HookProgress {
429        /// Stable id for this hook run, paired with [`Self::HookResponse`].
430        hook_id: String,
431        /// Hook matcher name (e.g. `"SessionStart:startup"`).
432        hook_name: String,
433        /// Hook event that fired it (e.g. `"SessionStart"`).
434        hook_event: String,
435        /// Raw stdout of the hook command so far.
436        stdout: String,
437        /// Raw stderr of the hook command so far.
438        stderr: String,
439        /// Combined output surfaced to the session so far.
440        output: String,
441        /// Unique identifier for this event.
442        uuid: String,
443        /// Session id the event applies to.
444        session_id: String,
445        /// Fields of the frame forge does not model, kept verbatim so a
446        /// decode is lossless for whatever draws the raw frame.
447        extras: Extras,
448    },
449
450    /// The CLI surfaced a notification to the SDK host. Subtype
451    /// `"notification"` (2.1.263).
452    Notification {
453        /// Notification kind (e.g. `"stop-hook-error"`).
454        key: Option<String>,
455        /// Human-readable notification body.
456        text: String,
457        /// Delivery priority tag (e.g. `"immediate"`).
458        priority: Option<String>,
459        /// Unique identifier for this event.
460        uuid: String,
461        /// Session id the event applies to.
462        session_id: String,
463        /// Fields of the frame forge does not model, kept verbatim so a
464        /// decode is lossless for whatever draws the raw frame.
465        extras: Extras,
466    },
467
468    /// The CLI refused a tool call on permission grounds. Subtype
469    /// `"permission_denied"` (first observed on 2.1.280).
470    PermissionDenied {
471        /// Name of the tool the CLI refused (e.g. `"Bash"`).
472        tool_name: String,
473        /// Id of the `tool_use` block that was refused.
474        tool_use_id: String,
475        /// Machine-readable reason category (e.g. `"other"`). Optional in the
476        /// 2.1.280 schema, beside `agent_id` and `decision_reason`; no
477        /// measured frame omits it.
478        decision_reason_type: Option<String>,
479        /// Reason token the CLI recorded (e.g. `"Contains simple_expansion"`).
480        /// Absent on the `subcommandResults` denial measured here, which names
481        /// the decision class and carries no token.
482        decision_reason: Option<String>,
483        /// Text of the refusal shown to the session.
484        message: String,
485        /// Unique identifier for this event.
486        uuid: String,
487        /// Session id the event applies to.
488        session_id: String,
489        /// Fields of the frame forge does not model, kept verbatim so a
490        /// decode is lossless for whatever draws the raw frame.
491        extras: Extras,
492    },
493
494    /// A compaction finished and the transcript was replaced. Subtype
495    /// `"compact_boundary"`.
496    ///
497    /// The wire nests the counts and the trigger under `compact_metadata`,
498    /// next to preserved-message uuids nothing reads. Only what forge
499    /// reacts to is modelled: every field modelled here is one a later
500    /// CLI can drop the whole frame to the generic bucket over.
501    CompactBoundary {
502        /// What started the compaction: `"manual"` for `/compact`,
503        /// `"auto"` when the context window forced it.
504        trigger: String,
505        /// Context tokens in use immediately before the compaction.
506        pre_tokens: u64,
507        /// Context tokens the session carried after the cut.
508        post_tokens: u64,
509        /// Unique identifier for this event.
510        uuid: String,
511        /// Session id the event applies to.
512        session_id: String,
513        /// Fields of the `compact_metadata` object forge does not model
514        /// (`duration_ms`, `preserved_segment` and friends), kept verbatim
515        /// under their own object on the way back out.
516        metadata_extras: Extras,
517        /// Fields of the frame forge does not model, kept verbatim so a
518        /// decode is lossless for whatever draws the raw frame.
519        extras: Extras,
520    },
521
522    /// A foreground shell command mutated repository state. Subtype
523    /// `"vcs_state_changed"`.
524    ///
525    /// An event, not state: beyond the kind it names at most the branch the
526    /// mutation landed on, so a reader still re-reads the tree for what it
527    /// now holds.
528    VcsStateChanged {
529        /// What class of mutation was observed: `commit`, `push`, `merge`
530        /// or `rebase` today. The CLI calls this an open set and asks
531        /// consumers to treat an unrecognized kind as "something changed",
532        /// so it stays a string.
533        kind: String,
534        /// The branch a commit landed on or a push updated. Absent on merge
535        /// and rebase frames, and on a push whose branch could not be
536        /// attributed.
537        branch: Option<String>,
538        /// The directory the shell finished in, an inner `cd` reflected and
539        /// a `git -C` target not. A hint about where to look.
540        cwd: String,
541        /// Unique identifier for this event.
542        uuid: String,
543        /// Session id the event applies to.
544        session_id: String,
545        /// Fields of the frame forge does not model, kept verbatim so a
546        /// decode is lossless for whatever draws the raw frame.
547        extras: Extras,
548    },
549
550    /// A change from this session went out for review, or linked the
551    /// session to one. Subtype `"code_change_published"`.
552    ///
553    /// The URL fields are scraped from the command's output and the CLI
554    /// calls them unverified, so forge uses the frame as a refresh trigger
555    /// and lets its own lookup fill what a PR row states.
556    CodeChangePublished {
557        /// Forge classification from the URL's shape: `github`,
558        /// `github-enterprise`, `gitlab`, `bitbucket`, `gerrit`, and values
559        /// for other review systems. Open set.
560        provider: String,
561        /// Web URL of the change, as printed in the command output.
562        url: String,
563        /// Repository path from the URL (`owner/name` on GitHub).
564        repo: String,
565        /// Provider-native change number, as a string.
566        identifier: String,
567        /// What the session did that produced the announcement: `created`,
568        /// `edited`, `merged`, `commented`, `closed`, `reopened`, `ready`,
569        /// `draft`, `auto-merge-enabled`, `auto-merge-disabled`, `pushed`,
570        /// `checked-out`, `started`. Absent only from older producers, and
571        /// free-form so a new verb decodes.
572        action: Option<String>,
573        /// The session's working branch when it produced the change. Sent
574        /// for gerrit always, and with `created` on any provider.
575        branch: Option<String>,
576        /// Unique identifier for this event.
577        uuid: String,
578        /// Session id the event applies to.
579        session_id: String,
580        /// Fields of the frame forge does not model, kept verbatim so a
581        /// decode is lossless for whatever draws the raw frame.
582        extras: Extras,
583    },
584
585    /// Rate-limit state transition. The CLI emits this when the current
586    /// rate-limit window changes state (e.g. `allowed` → `allowed_warning`).
587    /// Wire shape mirrors +
588    ///.
589    RateLimitEvent {
590        /// Rate-limit snapshot at the moment of the transition.
591        rate_limit_info: RateLimitInfo,
592        /// Unique identifier for this rate-limit event.
593        uuid: String,
594        /// Session id the event applies to.
595        session_id: String,
596        /// Fields of the frame forge does not model, kept verbatim so a
597        /// decode is lossless for whatever draws the raw frame.
598        extras: Extras,
599    },
600
601    /// The CLI's own lifecycle for one prompt, keyed by the uuid forge
602    /// stamped on the frame that carried it. Top-level `command_lifecycle`.
603    ///
604    /// Emitted only for prompts that carried a `uuid` when written (measured:
605    /// zero frames without one), and it is what lets a view say queued versus
606    /// taken: `queued` lands 1-5 ms after the write, `started` 2-3 ms after
607    /// the turn boundary that delivers the prompt.
608    ///
609    /// `state` stays a free-form string so a state this build has not seen
610    /// decodes without a primitives bump; the known set is `queued`,
611    /// `started`, `completed`, `cancelled`, `discarded`, `refused`.
612    CommandLifecycle {
613        /// The uuid the prompt was sent under - the reader's own, or the
614        /// one forge minted for it.
615        command_uuid: String,
616        /// Where the prompt is in the CLI's queue.
617        state: String,
618        /// Unique identifier for this lifecycle event (the CLI's own).
619        uuid: String,
620        /// Session id the prompt belongs to.
621        session_id: String,
622        /// Fields of the frame forge does not model, kept verbatim so a
623        /// decode is lossless for whatever draws the raw frame.
624        extras: Extras,
625    },
626
627    /// End-of-turn or end-of-session summary with cost and usage.
628    ///
629    /// Only six fields are required on the wire (`subtype`,
630    /// `session_id`, `is_error`, `num_turns`, `duration_ms`,
631    /// `duration_api_ms`); every other field is `Option<...>` because
632    /// the CLI omits them silently when not applicable.
633    Result {
634        /// Result discriminant (e.g. `"success"`, `"error_during_execution"`).
635        subtype: String,
636        /// Session id this turn belongs to.
637        session_id: String,
638        /// True when the turn ended in error.
639        is_error: bool,
640        /// Number of turns in this session so far.
641        num_turns: u64,
642        /// Total wall-clock duration in milliseconds.
643        duration_ms: u64,
644        /// Time spent waiting on the Anthropic API in milliseconds.
645        duration_api_ms: u64,
646        /// Why the turn ended, if the CLI reported a stop reason
647        /// (e.g. `"end_turn"`, `"max_turns"`, `"error"`).
648        stop_reason: Option<String>,
649        /// Total cost so far in USD. `None` when the CLI can't compute
650        /// or doesn't report (free-tier sessions, error-path results).
651        total_cost_usd: Option<f64>,
652        /// Aggregate token usage for the turn. Optional - the CLI
653        /// omits the field on error-path frames.
654        usage: Option<Usage>,
655        /// Plain-text result body when the turn produced one (e.g. the
656        /// assistant's final output).
657        result: Option<String>,
658        /// Structured output when
659        /// `forge_sdk::Options::output_format` was set to a JSON
660        /// schema. Passed through verbatim.
661        structured_output: Option<Value>,
662        /// Per-model usage breakdown. Wire key is camelCase
663        /// `modelUsage` (matches the CLI's `data.get("modelUsage")`).
664        model_usage: Option<Value>,
665        /// Permissions denied during the turn, surfaced so callers can
666        /// audit `can_use_tool` outcomes.
667        permission_denials: Option<Vec<Value>>,
668        /// Non-fatal errors accumulated during the turn.
669        errors: Option<Vec<String>>,
670        /// Unique identifier for this result frame.
671        uuid: Option<String>,
672        /// Why the turn ended at the runtime layer (`completed`,
673        /// `aborted_streaming`, `max_turns`, etc.). The CLI reports
674        /// this on `result` frames; surfaced here so consumers don't
675        /// have to re-parse the wire JSON to read it.
676        terminal_reason: Option<TerminalReason>,
677        /// Fields of the frame forge does not model (`subagent_stats` for
678        /// one), kept verbatim so a decode is lossless.
679        extras: Extras,
680    },
681
682    /// Streaming partial-message event emitted when
683    /// `forge_sdk::Options::include_partial_messages` is set. The
684    /// CLI forwards raw Anthropic-API stream events (`message_start`,
685    /// `content_block_delta`, `message_delta`, `message_stop`) without
686    /// coalescing them into complete turns. SDK's
687    /// `StreamEvent`
688    ///).
689    StreamEvent {
690        /// Unique identifier for this stream event.
691        uuid: String,
692        /// Session the event belongs to.
693        session_id: String,
694        /// Raw Anthropic API stream event payload.
695        event: Value,
696        /// Parent `tool_use` id when the emitting turn is a sub-agent.
697        parent_tool_use_id: Option<String>,
698        /// Fields of the frame forge does not model, kept verbatim so a
699        /// decode is lossless for whatever draws the raw frame.
700        extras: Extras,
701    },
702
703    /// Fatal transport error injected into the message stream when the
704    /// CLI's read loop fails. the CLI emits this at
705    /// as a last-gasp signal before teardown - emitted by the
706    /// CLI's read loop. forge-sdk surfaces it via
707    /// the events stream returned by `forge_sdk::Client::spawn` so callers
708    /// see the failure on the iterator rather than via a side
709    /// channel.
710    Error {
711        /// The failure message as the CLI stringified it.
712        error: String,
713        /// Fields of the frame forge does not model, kept verbatim so a
714        /// decode is lossless for whatever draws the raw frame.
715        extras: Extras,
716    },
717
718    /// Forward-compat fallback: a frame whose top-level `type` value
719    /// forge-sdk doesn't recognise. Surfaced through
720    /// the events stream returned by `forge_sdk::Client::spawn` when the codec
721    /// produces a the SDK's `DecodedLine::Unknown`.
722    /// Library consumers can match this variant to detect upstream CLI
723    /// drift programmatically (telemetry, structured alerts) instead of
724    /// relying on `tracing::warn!` log scraping.
725    ///
726    /// Mirrors the `Unknown` pattern already used by
727    /// [`ContentBlock`] and `forge_sdk::control::ControlRequestKind`.
728    /// Never produced by deserialization - `decode_dispatch` filters
729    /// unknown types into the SDK's `DecodedLine::Unknown`
730    /// before they reach serde - but `Serialize` round-trips `raw`
731    /// verbatim so logs / replay capture the original bytes.
732    Unknown {
733        /// Raw `type` field value as the CLI sent it.
734        type_str: String,
735        /// Full original JSON object - preserved for inspection,
736        /// replay, or rehydration once the new shape is supported.
737        raw: Value,
738    },
739}
740
741impl Message {
742    /// Extract the `session_id` a message is tagged with, when present.
743    ///
744    /// Used by the events stream returned by `forge_sdk::Client::spawn` to bind
745    /// the client's `session_id` field on the first frame that carries
746    /// one - the CLI in stream-json interactive mode only emits
747    /// `system/init` (the canonical session-id source) AFTER both an
748    /// initialize `control_request` AND a user message have been seen,
749    /// so the session id isn't known at spawn time.
750    ///
751    /// Returns `None` for the two variants that aren't session-scoped
752    /// (`Error` and `RateLimitEvent`, which carries no session id of its
753    /// own).
754    pub fn session_id(&self) -> Option<&str> {
755        match self {
756            Message::Assistant { session_id, .. }
757            | Message::User { session_id, .. }
758            | Message::TaskStarted { session_id, .. }
759            | Message::TaskUpdated { session_id, .. }
760            | Message::TaskProgress { session_id, .. }
761            | Message::TaskNotification { session_id, .. }
762            | Message::ThinkingTokens { session_id, .. }
763            | Message::TurnDuration { session_id, .. }
764            | Message::StopHookSummary { session_id, .. }
765            | Message::BackgroundTasksChanged { session_id, .. }
766            | Message::CommandsChanged { session_id, .. }
767            | Message::HookStarted { session_id, .. }
768            | Message::HookProgress { session_id, .. }
769            | Message::PermissionDenied { session_id, .. }
770            | Message::HookResponse { session_id, .. }
771            | Message::Notification { session_id, .. }
772            | Message::CompactBoundary { session_id, .. }
773            | Message::VcsStateChanged { session_id, .. }
774            | Message::CodeChangePublished { session_id, .. }
775            | Message::Result { session_id, .. }
776            | Message::CommandLifecycle { session_id, .. }
777            | Message::StreamEvent { session_id, .. } => Some(session_id.as_str()),
778            Message::System { session_id, .. } | Message::ToolProgress { session_id, .. } => {
779                session_id.as_deref()
780            }
781            Message::RateLimitEvent { .. } | Message::Error { .. } | Message::Unknown { .. } => {
782                None
783            }
784        }
785    }
786
787    /// A user turn forged rather than read off the wire, for prose the model
788    /// received and the CLI does not echo back.
789    ///
790    /// **The id is the prompt's own**, the one its `command_lifecycle` frames
791    /// carry: it is what lets a view hold this row while the prompt waits in
792    /// the queue, and pair it with the page's later copy of the same message -
793    /// which carries the same id, the CLI writing the client's uuid into the
794    /// transcript it delivers. Nothing routes on the empty `session_id`.
795    pub fn display_only_user(text: String, uuid: String) -> Self {
796        Self::display_only_user_with_blocks(text, Vec::new(), uuid)
797    }
798
799    /// [`Self::display_only_user`] with the image blocks a prompt's
800    /// attachments draw as, placed before the words: the caller builds them
801    /// from the attachments it holds, so a dispatch never clones a payload it
802    /// will not draw.
803    pub fn display_only_user_with_blocks(
804        text: String,
805        images: Vec<ContentBlock>,
806        uuid: String,
807    ) -> Self {
808        let mut content = images;
809        content.push(ContentBlock::Text { text, extras: Extras::new() });
810        Message::User {
811            message: UserEnvelope { role: "user".to_owned(), content, extras: Extras::new() },
812            extras: Extras::new(),
813            session_id: String::new(),
814            parent_tool_use_id: None,
815            uuid: Some(uuid),
816            tool_use_result: None,
817            timestamp: None,
818            synthetic: false,
819        }
820    }
821
822    /// Attach `stamp` to a frame that carries none.
823    ///
824    /// **Each kind takes it in the slot the wire's own stamp occupies**, so a
825    /// stamped frame reaches a reader shaped exactly like one the CLI stamped
826    /// itself: the typed field on [`Message::Assistant`] and [`Message::User`],
827    /// the verbatim payload on [`Message::System`] and [`Message::Unknown`],
828    /// and the extras every other variant re-emits at the frame's top level -
829    /// which is where a wire stamp on those already lands.
830    ///
831    /// A frame that carries one is left alone: a receipt is a fallback for a
832    /// row the CLI did not stamp, never a correction of one it did.
833    pub fn stamp_if_absent(&mut self, stamp: &str) {
834        match self {
835            Message::Assistant { timestamp, .. } | Message::User { timestamp, .. } => {
836                if timestamp.is_none() {
837                    *timestamp = Some(stamp.to_owned());
838                }
839            }
840            Message::System { data, .. } | Message::Unknown { raw: data, .. } => {
841                if let Value::Object(map) = data
842                    && !map.contains_key("timestamp")
843                {
844                    map.insert("timestamp".to_owned(), Value::String(stamp.to_owned()));
845                }
846            }
847            // Listed rather than wildcarded so a new variant fails to compile
848            // here until its stamp slot is settled.
849            Message::TaskStarted { extras, .. }
850            | Message::TaskUpdated { extras, .. }
851            | Message::TaskProgress { extras, .. }
852            | Message::TaskNotification { extras, .. }
853            | Message::ToolProgress { extras, .. }
854            | Message::ThinkingTokens { extras, .. }
855            | Message::TurnDuration { extras, .. }
856            | Message::StopHookSummary { extras, .. }
857            | Message::BackgroundTasksChanged { extras, .. }
858            | Message::CommandsChanged { extras, .. }
859            | Message::HookStarted { extras, .. }
860            | Message::HookResponse { extras, .. }
861            | Message::HookProgress { extras, .. }
862            | Message::Notification { extras, .. }
863            | Message::PermissionDenied { extras, .. }
864            | Message::CompactBoundary { extras, .. }
865            | Message::VcsStateChanged { extras, .. }
866            | Message::CodeChangePublished { extras, .. }
867            | Message::RateLimitEvent { extras, .. }
868            | Message::CommandLifecycle { extras, .. }
869            | Message::Result { extras, .. }
870            | Message::StreamEvent { extras, .. }
871            | Message::Error { extras, .. } => {
872                if !extras.contains_key("timestamp") {
873                    extras.insert("timestamp".to_owned(), Value::String(stamp.to_owned()));
874                }
875            }
876        }
877    }
878}
879
880/// The Anthropic-API-shaped envelope inside an `Assistant` message.
881#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
882pub struct AssistantEnvelope {
883    /// Message id from the Anthropic API.
884    pub id: String,
885    /// Fixed value `"assistant"`.
886    pub role: String,
887    /// Model name (e.g. `"claude-opus-4-5"`).
888    pub model: String,
889    /// Content blocks in order (interleaved text + tool-use).
890    pub content: Vec<ContentBlock>,
891    /// Why the turn ended, if it ended.
892    #[serde(default)]
893    pub stop_reason: Option<StopReason>,
894    /// Stop sequence that triggered end-of-turn, if any.
895    #[serde(default)]
896    pub stop_sequence: Option<String>,
897    /// Token usage for this turn. Optional - error-path frames
898    /// don't carry a usage block.
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub usage: Option<Usage>,
901    /// Fields of the envelope forge does not model (`provider`, a
902    /// `container`, context management diagnostics), kept verbatim.
903    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
904    pub extras: Extras,
905}
906
907/// Classification of a failure the CLI attributes to an assistant turn.
908/// `AssistantMessageError` union.
909#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(rename_all = "snake_case")]
911pub enum AssistantMessageError {
912    /// The model couldn't be reached because authentication failed.
913    AuthenticationFailed,
914    /// The account hit a billing problem (e.g. no active credits).
915    BillingError,
916    /// Rate-limit rejection - retry after the window resets.
917    RateLimit,
918    /// The request was rejected as malformed.
919    InvalidRequest,
920    /// Generic server-side error.
921    ServerError,
922    /// Fallback for error classes forge-sdk doesn't yet recognise.
923    #[serde(other)]
924    Unknown,
925}
926
927/// Envelope inside a `User` message.
928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
929pub struct UserEnvelope {
930    /// Fixed value `"user"`.
931    pub role: String,
932    /// Content blocks - usually `ToolResult` blocks when reporting
933    /// tool outputs. Wire shape is `list | str`: a bare string is
934    /// accepted on the way in and normalised into a single
935    /// [`ContentBlock::Text`] block; serialising always emits the
936    /// list form.
937    #[serde(deserialize_with = "deserialize_user_content")]
938    pub content: Vec<ContentBlock>,
939    /// Fields of the envelope forge does not model, kept verbatim.
940    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
941    pub extras: Extras,
942}
943
944fn deserialize_user_content<'de, D>(de: D) -> Result<Vec<ContentBlock>, D::Error>
945where
946    D: serde::Deserializer<'de>,
947{
948    use serde::de::Error as _;
949    let value = Value::deserialize(de)?;
950    match value {
951        Value::String(s) => Ok(vec![ContentBlock::Text { text: s, extras: Extras::new() }]),
952        Value::Array(_) => serde_json::from_value(value).map_err(D::Error::custom),
953        other => {
954            Err(D::Error::custom(format!("user message content must be str or list, got: {other}")))
955        }
956    }
957}
958
959/// Anthropic API's stop-reason enum.
960#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
961#[serde(rename_all = "snake_case")]
962pub enum StopReason {
963    /// Model finished its turn naturally.
964    EndTurn,
965    /// Ran up against `max_tokens`.
966    MaxTokens,
967    /// Hit a stop sequence.
968    StopSequence,
969    /// Model is requesting a tool call; expect a `tool_use` block in content.
970    ToolUse,
971    /// A stop reason forge-sdk doesn't yet recognise.
972    #[serde(other)]
973    Unknown,
974}
975
976/// Rate-limit window status. Wire literal:
977/// `"allowed" | "allowed_warning" | "rejected"`.
978#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
979#[serde(rename_all = "snake_case")]
980pub enum RateLimitStatus {
981    /// Within the window - no restrictions.
982    Allowed,
983    /// Approaching the limit; callers should warn / back off soon.
984    AllowedWarning,
985    /// Limit hit; requests are being refused.
986    Rejected,
987    /// A status forge-sdk doesn't yet recognise.
988    #[serde(other)]
989    Unknown,
990}
991
992/// Which rate-limit window applies.
993#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
994#[serde(rename_all = "snake_case")]
995pub enum RateLimitType {
996    /// Five-hour rolling window.
997    FiveHour,
998    /// Seven-day rolling window (any model).
999    SevenDay,
1000    /// Seven-day window for Opus-class models specifically.
1001    SevenDayOpus,
1002    /// Seven-day window for Sonnet-class models specifically.
1003    SevenDaySonnet,
1004    /// Pay-as-you-go overage window.
1005    Overage,
1006    /// A rate-limit window forge-sdk doesn't yet recognise.
1007    #[serde(other)]
1008    Unknown,
1009}
1010
1011/// Rate-limit snapshot emitted inside a [`Message::RateLimitEvent`].
1012///
1013/// `RateLimitInfo`. Inner
1014/// field names on the wire are camelCase (`resetsAt`, `rateLimitType`,
1015/// `overageStatus`, `overageResetsAt`, `overageDisabledReason`) per the CLI
1016/// spec, while the outer frame uses `snake_case`.
1017#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1018pub struct RateLimitInfo {
1019    /// Current rate-limit status.
1020    pub status: RateLimitStatus,
1021    /// Unix timestamp (seconds) when the rate-limit window resets.
1022    #[serde(default, rename = "resetsAt", skip_serializing_if = "Option::is_none")]
1023    pub resets_at: Option<i64>,
1024    /// Which rate-limit window applies.
1025    #[serde(default, rename = "rateLimitType", skip_serializing_if = "Option::is_none")]
1026    pub rate_limit_type: Option<RateLimitType>,
1027    /// Fraction of the rate limit consumed (0.0 - 1.0).
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub utilization: Option<f64>,
1030    /// Status of overage / pay-as-you-go usage, if applicable.
1031    #[serde(default, rename = "overageStatus", skip_serializing_if = "Option::is_none")]
1032    pub overage_status: Option<RateLimitStatus>,
1033    /// Unix timestamp (seconds) when overage window resets.
1034    #[serde(default, rename = "overageResetsAt", skip_serializing_if = "Option::is_none")]
1035    pub overage_resets_at: Option<i64>,
1036    /// Why overage is unavailable when rejected.
1037    #[serde(default, rename = "overageDisabledReason", skip_serializing_if = "Option::is_none")]
1038    pub overage_disabled_reason: Option<String>,
1039    /// Echo of the raw CLI payload so callers can introspect fields
1040    /// forge-sdk doesn't yet type. serde's `flatten` makes this the
1041    /// catch-all bucket for unknown keys on the wire.
1042    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1043    pub raw: serde_json::Map<String, serde_json::Value>,
1044}
1045
1046/// Token-usage accounting.
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1048pub struct Usage {
1049    /// Input tokens this turn.
1050    #[serde(default, deserialize_with = "null_as_zero")]
1051    pub input_tokens: u64,
1052    /// Output tokens this turn.
1053    #[serde(default, deserialize_with = "null_as_zero")]
1054    pub output_tokens: u64,
1055    /// Tokens written to the prompt cache this turn.
1056    #[serde(default, deserialize_with = "null_as_zero")]
1057    pub cache_creation_input_tokens: u64,
1058    /// Tokens read from the prompt cache this turn.
1059    #[serde(default, deserialize_with = "null_as_zero")]
1060    pub cache_read_input_tokens: u64,
1061    /// Fields of the usage block forge does not count (`service_tier`,
1062    /// `inference_geo`, the thinking-token detail), kept verbatim.
1063    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1064    pub extras: Extras,
1065}
1066
1067/// Read a counter that may arrive as an explicit `null`.
1068///
1069/// `serde(default)` covers an absent key and not a present-but-null
1070/// one, so a backend that reports "no caching happened" as `null`
1071/// rather than `0` fails the whole frame - and a frame that fails to
1072/// decode ends the session's event stream, not just that message.
1073/// Measured: OpenRouter sends `"cache_creation_input_tokens": null`
1074/// for a model with no prompt cache.
1075fn null_as_zero<'de, D>(deserializer: D) -> Result<u64, D::Error>
1076where
1077    D: serde::Deserializer<'de>,
1078{
1079    Ok(Option::<u64>::deserialize(deserializer)?.unwrap_or(0))
1080}
1081
1082/// Per-hook entry inside a `stop_hook_summary` system event (#273).
1083/// Wire fields are camelCase (`durationMs`); the renderer reads
1084/// the `command` text for the expanded body.
1085#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1086pub struct StopHookInfo {
1087    /// Command line that fired.
1088    pub command: String,
1089    /// How long the hook took, in milliseconds. Wire field is
1090    /// `durationMs`; absent on 2.1.263's plugin-injected entries.
1091    #[serde(rename = "durationMs", default, skip_serializing_if = "Option::is_none")]
1092    pub duration_ms: Option<u64>,
1093    /// Fields of the entry forge does not model, kept verbatim.
1094    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1095    pub extras: Extras,
1096}
1097
1098/// Usage counters reported inside task-progress and task-notification frames.
1099///
1100/// `TaskUsage`.
1101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1102pub struct TaskUsage {
1103    /// Tokens consumed across all model calls in this task so far.
1104    pub total_tokens: u64,
1105    /// Number of tool invocations the sub-agent has made.
1106    pub tool_uses: u64,
1107    /// Wall-clock time the task has spent running, in milliseconds.
1108    pub duration_ms: u64,
1109    /// Fields of the usage block forge does not count, kept verbatim.
1110    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1111    pub extras: Extras,
1112}
1113
1114/// Terminal status of a sub-agent `Task` reported via
1115/// [`Message::TaskNotification`]. Wire literal:
1116/// `"completed" | "failed" | "stopped"`.
1117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1118#[serde(rename_all = "snake_case")]
1119pub enum TaskNotificationStatus {
1120    /// Task finished successfully.
1121    Completed,
1122    /// Task exited with an error.
1123    Failed,
1124    /// Task was cancelled before it could finish.
1125    Stopped,
1126    /// A status forge-sdk doesn't yet recognise.
1127    #[serde(other)]
1128    Unknown,
1129}
1130
1131/// Patch payload carried by [`Message::TaskUpdated`]. Fields are
1132/// optional because the CLI emits patches that update only the
1133/// changed fields. `status` is the free-form wire string (e.g.
1134/// `"completed"`, `"running"`, `"killed"`) - keeping it as `String`
1135/// rather than an enum lets the reducer accept future statuses
1136/// without a forge-primitives bump.
1137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1138pub struct TaskUpdatePatch {
1139    /// New status the task transitioned to, if the patch carries one.
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    pub status: Option<String>,
1142    /// Unix-millis wall-clock end time the CLI stamped on the task,
1143    /// if applicable (terminal transitions usually set this).
1144    #[serde(default, skip_serializing_if = "Option::is_none")]
1145    pub end_time: Option<u64>,
1146    /// Fields of the patch forge does not model, kept verbatim.
1147    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1148    pub extras: Extras,
1149}
1150
1151/// Workflow's per-event snapshot of the workflow's
1152/// phase + agent state, ridden via `Message::TaskProgress`'s
1153/// `workflow_progress` field.
1154///
1155/// The CLI fires one `system/task_progress` per workflow-internal
1156/// state change; each event carries the FULL workflow snapshot at
1157/// that instant (not a delta). Two flavours of entry are observed in
1158/// captured wire (see `~/Projects/forge/.claude/skills/claude-cli-upgrade/reference-captures/workflow.jsonl`):
1159///
1160/// 1. `workflow_phase` - phase-level marker emitted when the
1161///    workflow's `phase()` call fires. Carries the phase index +
1162///    title.
1163/// 2. `workflow_agent` - agent-level event tracking an agent call's
1164///    state transition (`start` → `progress` → `done`). Carries the
1165///    parent phase index, the agent's queued model, the running
1166///    tool name (when known), a short prompt preview, and (on
1167///    `done`) the result preview.
1168///
1169/// Both share the same wire envelope discriminated by `type`. The
1170/// type stays open via `#[serde(other)]` on the trailing variant
1171/// so future CLI additions decode cleanly without a primitives
1172/// bump - and an unrecognised TYPE is the one loss stated here: the
1173/// `Other` variant is a unit, so that event's payload does not
1174/// cross.
1175///
1176/// Every modelled field a view does not read stays in `extras`, so
1177/// `queuedAt`, `startedAt`, `lastProgressAt`, `attempt`, `tokens`,
1178/// `toolCalls`, `durationMs`, `model`, `agentId` and `promptPreview`
1179/// all cross with the entry they arrived in.
1180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1181#[serde(tag = "type", rename_all = "snake_case")]
1182pub enum WorkflowProgressEvent {
1183    /// Phase-level marker - emitted when the workflow's `phase()`
1184    /// call fires.
1185    WorkflowPhase {
1186        index: u32,
1187        title: String,
1188        #[serde(flatten)]
1189        extras: Extras,
1190    },
1191    /// Agent-level event - emitted on each state transition for an
1192    /// agent call inside a phase. The modelled fields are the ones a
1193    /// view reads; everything else the CLI sends crosses in `extras`.
1194    WorkflowAgent {
1195        index: u32,
1196        label: String,
1197        /// Phase the agent belongs to. The fields became optional:
1198        /// phase-less agent entries were observed outside the pinned
1199        /// corpus (proxy-routed capture), while the committed corpus
1200        /// still tags every entry. Phase grouping then relies on
1201        /// `workflow_phase` markers alone.
1202        #[serde(rename = "phaseIndex", default, skip_serializing_if = "Option::is_none")]
1203        phase_index: Option<u32>,
1204        #[serde(rename = "phaseTitle", default, skip_serializing_if = "Option::is_none")]
1205        phase_title: Option<String>,
1206        /// Current agent state on the wire: `start`, `progress`,
1207        /// `done`. Free-form string so future states decode
1208        /// without a primitives bump.
1209        state: String,
1210        /// Latest tool the agent invoked, when known. `None` on
1211        /// initial `start` events.
1212        #[serde(rename = "lastToolName", default, skip_serializing_if = "Option::is_none")]
1213        last_tool_name: Option<String>,
1214        /// Short summary of the latest tool's output.
1215        #[serde(rename = "lastToolSummary", default, skip_serializing_if = "Option::is_none")]
1216        last_tool_summary: Option<String>,
1217        /// Final structured-output preview emitted with the
1218        /// `state: done` event. JSON-stringified per the CLI's
1219        /// `resultPreview` field.
1220        #[serde(rename = "resultPreview", default, skip_serializing_if = "Option::is_none")]
1221        result_preview: Option<String>,
1222        #[serde(flatten)]
1223        extras: Extras,
1224    },
1225    /// Unrecognised workflow event - preserved across decode to
1226    /// avoid serde refusing the surrounding `task_progress`. Not
1227    /// surfaced anywhere; the renderer treats unknown event types
1228    /// as no-ops.
1229    #[serde(other)]
1230    Other,
1231}
1232
1233// ---------------------------------------------------------------------------
1234// Wire shim - serde sees this, users never do.
1235//
1236// `Message` has the user-facing variant layout. `MessageRepr` encodes the
1237// actual wire dispatch: first on `type`, then (for `type="system"`) on
1238// `subtype`. The cascade works because:
1239//
1240// * `MessageRepr` is internally-tagged on `type` - serde picks `System(repr)`
1241//   when `type="system"`, the rest via tag rename.
1242// * `SystemRepr` is untagged - serde tries `Typed(TypedSystemRepr)` first
1243//   (which is itself internally-tagged on `subtype`), then falls back to
1244//   `Generic(GenericSystemRepr)` for subtypes we don't recognise.
1245// * `TypedSystemRepr` dispatches the known task-lifecycle subtypes.
1246// * `GenericSystemRepr` captures any other subtype into the opaque
1247//   `data: Value`, which is what `Message::System` surfaces to users.
1248//
1249// `Message::Unknown` is the only variant Serialize/Deserialize don't route
1250// through `MessageRepr` - its `raw` field already carries the original
1251// JSON, so we emit it verbatim instead of fabricating a synthetic wire
1252// shape. Deserialize never produces it; `decode_dispatch` filters unknown
1253// types into `DecodedLine::Unknown` before serde sees them.
1254// ---------------------------------------------------------------------------
1255
1256impl Serialize for Message {
1257    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1258        match self {
1259            Message::Unknown { raw, .. } => raw.serialize(serializer),
1260            other => MessageRepr::from(other.clone()).serialize(serializer),
1261        }
1262    }
1263}
1264
1265impl<'de> Deserialize<'de> for Message {
1266    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1267        MessageRepr::deserialize(deserializer).map(Message::from)
1268    }
1269}
1270
1271#[derive(Debug, Clone, Serialize, Deserialize)]
1272#[serde(tag = "type", rename_all = "snake_case")]
1273enum MessageRepr {
1274    Assistant {
1275        message: AssistantEnvelope,
1276        session_id: String,
1277        #[serde(default)]
1278        parent_tool_use_id: Option<String>,
1279        #[serde(default, skip_serializing_if = "Option::is_none")]
1280        error: Option<AssistantMessageError>,
1281        #[serde(default, skip_serializing_if = "Option::is_none")]
1282        uuid: Option<String>,
1283        #[serde(default, skip_serializing_if = "Option::is_none")]
1284        timestamp: Option<String>,
1285        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1286        extras: Extras,
1287    },
1288    User {
1289        message: UserEnvelope,
1290        session_id: String,
1291        #[serde(default)]
1292        parent_tool_use_id: Option<String>,
1293        #[serde(default, skip_serializing_if = "Option::is_none")]
1294        uuid: Option<String>,
1295        #[serde(default, skip_serializing_if = "Option::is_none")]
1296        tool_use_result: Option<Value>,
1297        #[serde(default, skip_serializing_if = "Option::is_none")]
1298        timestamp: Option<String>,
1299        // The predicate is `not`, so only a set stamp is written: an unmarked
1300        // frame keeps the shape every existing fixture and relayed payload was
1301        // written against.
1302        #[serde(default, rename = "isSynthetic", skip_serializing_if = "std::ops::Not::not")]
1303        synthetic: bool,
1304        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1305        extras: Extras,
1306    },
1307    System(SystemRepr),
1308    RateLimitEvent {
1309        rate_limit_info: RateLimitInfo,
1310        uuid: String,
1311        session_id: String,
1312        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1313        extras: Extras,
1314    },
1315    CommandLifecycle {
1316        command_uuid: String,
1317        state: String,
1318        uuid: String,
1319        session_id: String,
1320        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1321        extras: Extras,
1322    },
1323    Result {
1324        subtype: String,
1325        session_id: String,
1326        is_error: bool,
1327        num_turns: u64,
1328        duration_ms: u64,
1329        duration_api_ms: u64,
1330        #[serde(default, skip_serializing_if = "Option::is_none")]
1331        stop_reason: Option<String>,
1332        #[serde(default, skip_serializing_if = "Option::is_none")]
1333        total_cost_usd: Option<f64>,
1334        #[serde(default, skip_serializing_if = "Option::is_none")]
1335        usage: Option<Usage>,
1336        #[serde(default, skip_serializing_if = "Option::is_none")]
1337        result: Option<String>,
1338        #[serde(default, skip_serializing_if = "Option::is_none")]
1339        structured_output: Option<Value>,
1340        #[serde(default, rename = "modelUsage", skip_serializing_if = "Option::is_none")]
1341        model_usage: Option<Value>,
1342        #[serde(default, skip_serializing_if = "Option::is_none")]
1343        permission_denials: Option<Vec<Value>>,
1344        #[serde(default, skip_serializing_if = "Option::is_none")]
1345        errors: Option<Vec<String>>,
1346        #[serde(default, skip_serializing_if = "Option::is_none")]
1347        uuid: Option<String>,
1348        #[serde(default, skip_serializing_if = "Option::is_none")]
1349        terminal_reason: Option<TerminalReason>,
1350        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1351        extras: Extras,
1352    },
1353    StreamEvent {
1354        uuid: String,
1355        session_id: String,
1356        event: Value,
1357        #[serde(default)]
1358        parent_tool_use_id: Option<String>,
1359        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1360        extras: Extras,
1361    },
1362    /// The CLI's heartbeat for a running tool call. A top-level type of
1363    /// its own, not a `system` subtype.
1364    ToolProgress {
1365        tool_use_id: String,
1366        tool_name: String,
1367        elapsed_time_seconds: f64,
1368        #[serde(default)]
1369        heartbeat: bool,
1370        #[serde(default, skip_serializing_if = "Option::is_none")]
1371        parent_tool_use_id: Option<String>,
1372        #[serde(default, skip_serializing_if = "Option::is_none")]
1373        session_id: Option<String>,
1374        #[serde(default, skip_serializing_if = "Option::is_none")]
1375        uuid: Option<String>,
1376        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1377        extras: Extras,
1378    },
1379    Error {
1380        error: String,
1381        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1382        extras: Extras,
1383    },
1384}
1385
1386/// A transient decode shim: serde tries the typed shape, then the generic
1387/// one, and the value is moved once into a [`Message`]. Never stored or
1388/// cloned, so the byte gap between its arms - what `large_enum_variant`
1389/// measures - is paid nowhere, and boxing the typed arm instead would add
1390/// an allocation per lifecycle frame.
1391#[expect(clippy::large_enum_variant)]
1392#[derive(Debug, Clone, Serialize, Deserialize)]
1393#[serde(untagged)]
1394enum SystemRepr {
1395    Typed(TypedSystemRepr),
1396    Generic(GenericSystemRepr),
1397}
1398
1399#[derive(Debug, Clone, Serialize, Deserialize)]
1400#[serde(tag = "subtype", rename_all = "snake_case")]
1401enum TypedSystemRepr {
1402    TaskStarted {
1403        task_id: String,
1404        description: String,
1405        uuid: String,
1406        session_id: String,
1407        #[serde(default, skip_serializing_if = "Option::is_none")]
1408        tool_use_id: Option<String>,
1409        #[serde(default, skip_serializing_if = "Option::is_none")]
1410        task_type: Option<String>,
1411        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1412        extras: Extras,
1413    },
1414    TaskUpdated {
1415        task_id: String,
1416        #[serde(default)]
1417        patch: TaskUpdatePatch,
1418        uuid: String,
1419        session_id: String,
1420        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1421        extras: Extras,
1422    },
1423    TaskProgress {
1424        task_id: String,
1425        description: String,
1426        usage: TaskUsage,
1427        uuid: String,
1428        session_id: String,
1429        #[serde(default, skip_serializing_if = "Option::is_none")]
1430        tool_use_id: Option<String>,
1431        #[serde(default, skip_serializing_if = "Option::is_none")]
1432        last_tool_name: Option<String>,
1433        /// Workflow tool's per-event snapshot of the
1434        /// workflow's phase + agent state. Present only when the
1435        /// originating tool is `Workflow`; otherwise omitted. Each
1436        /// event is the FULL snapshot (not a delta) of every phase
1437        /// + agent currently known to the workflow.
1438        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439        workflow_progress: Vec<WorkflowProgressEvent>,
1440        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1441        extras: Extras,
1442    },
1443    TaskNotification {
1444        task_id: String,
1445        status: TaskNotificationStatus,
1446        output_file: String,
1447        summary: String,
1448        uuid: String,
1449        session_id: String,
1450        #[serde(default, skip_serializing_if = "Option::is_none")]
1451        tool_use_id: Option<String>,
1452        #[serde(default, skip_serializing_if = "Option::is_none")]
1453        usage: Option<TaskUsage>,
1454        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1455        extras: Extras,
1456    },
1457    ThinkingTokens {
1458        estimated_tokens: u64,
1459        estimated_tokens_delta: i64,
1460        uuid: String,
1461        session_id: String,
1462        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1463        extras: Extras,
1464    },
1465    TurnDuration {
1466        #[serde(rename = "durationMs")]
1467        ms: u64,
1468        #[serde(default, rename = "messageCount", skip_serializing_if = "Option::is_none")]
1469        message_count: Option<u64>,
1470        #[serde(default, skip_serializing_if = "Option::is_none")]
1471        parent_tool_use_id: Option<String>,
1472        session_id: String,
1473        uuid: String,
1474        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1475        extras: Extras,
1476    },
1477    StopHookSummary {
1478        #[serde(rename = "hookCount")]
1479        actions: u32,
1480        #[serde(default, rename = "hookInfos")]
1481        hook_infos: Vec<StopHookInfo>,
1482        #[serde(default, rename = "hookErrors")]
1483        hook_errors: Vec<String>,
1484        #[serde(rename = "hasOutput")]
1485        has_output: bool,
1486        level: String,
1487        #[serde(rename = "preventedContinuation")]
1488        prevented_continuation: bool,
1489        #[serde(rename = "stopReason")]
1490        stop_reason: String,
1491        #[serde(rename = "toolUseID")]
1492        tool_use_id: String,
1493        #[serde(default, skip_serializing_if = "Option::is_none")]
1494        parent_tool_use_id: Option<String>,
1495        session_id: String,
1496        uuid: String,
1497        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1498        extras: Extras,
1499    },
1500    BackgroundTasksChanged {
1501        tasks: Vec<Value>,
1502        uuid: String,
1503        session_id: String,
1504        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1505        extras: Extras,
1506    },
1507    CommandsChanged {
1508        commands: Vec<Value>,
1509        uuid: String,
1510        session_id: String,
1511        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1512        extras: Extras,
1513    },
1514    HookStarted {
1515        hook_id: String,
1516        hook_name: String,
1517        hook_event: String,
1518        uuid: String,
1519        session_id: String,
1520        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1521        extras: Extras,
1522    },
1523    HookResponse {
1524        hook_id: String,
1525        hook_name: String,
1526        hook_event: String,
1527        outcome: String,
1528        exit_code: i64,
1529        output: String,
1530        stdout: String,
1531        stderr: String,
1532        uuid: String,
1533        session_id: String,
1534        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1535        extras: Extras,
1536    },
1537    HookProgress {
1538        hook_id: String,
1539        hook_name: String,
1540        hook_event: String,
1541        stdout: String,
1542        stderr: String,
1543        output: String,
1544        uuid: String,
1545        session_id: String,
1546        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1547        extras: Extras,
1548    },
1549    Notification {
1550        key: Option<String>,
1551        text: String,
1552        priority: Option<String>,
1553        uuid: String,
1554        session_id: String,
1555        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1556        extras: Extras,
1557    },
1558    PermissionDenied {
1559        tool_name: String,
1560        tool_use_id: String,
1561        #[serde(skip_serializing_if = "Option::is_none")]
1562        decision_reason_type: Option<String>,
1563        #[serde(skip_serializing_if = "Option::is_none")]
1564        decision_reason: Option<String>,
1565        message: String,
1566        uuid: String,
1567        session_id: String,
1568        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1569        extras: Extras,
1570    },
1571    CompactBoundary {
1572        compact_metadata: CompactMetadataRepr,
1573        uuid: String,
1574        session_id: String,
1575        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1576        extras: Extras,
1577    },
1578    VcsStateChanged {
1579        kind: String,
1580        #[serde(default, skip_serializing_if = "Option::is_none")]
1581        branch: Option<String>,
1582        cwd: String,
1583        uuid: String,
1584        session_id: String,
1585        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1586        extras: Extras,
1587    },
1588    CodeChangePublished {
1589        provider: String,
1590        url: String,
1591        repo: String,
1592        identifier: String,
1593        #[serde(default, skip_serializing_if = "Option::is_none")]
1594        action: Option<String>,
1595        #[serde(default, skip_serializing_if = "Option::is_none")]
1596        branch: Option<String>,
1597        uuid: String,
1598        session_id: String,
1599        #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1600        extras: Extras,
1601    },
1602}
1603
1604/// The subset of `compact_boundary`'s `compact_metadata` forge reads,
1605/// plus every sibling field it does not: the counts and the preserved
1606/// segment cross verbatim so a lossless read reaches whoever draws them.
1607#[derive(Debug, Clone, Serialize, Deserialize)]
1608struct CompactMetadataRepr {
1609    trigger: String,
1610    pre_tokens: u64,
1611    post_tokens: u64,
1612    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1613    extras: Extras,
1614}
1615
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1617struct GenericSystemRepr {
1618    subtype: String,
1619    #[serde(default, skip_serializing_if = "Option::is_none")]
1620    session_id: Option<String>,
1621    #[serde(flatten)]
1622    data: Value,
1623}
1624
1625impl From<MessageRepr> for Message {
1626    fn from(repr: MessageRepr) -> Self {
1627        match repr {
1628            MessageRepr::Assistant {
1629                message,
1630                session_id,
1631                parent_tool_use_id,
1632                error,
1633                uuid,
1634                timestamp,
1635                extras,
1636            } => Message::Assistant {
1637                message,
1638                session_id,
1639                parent_tool_use_id,
1640                error,
1641                uuid,
1642                timestamp,
1643                extras,
1644            },
1645            MessageRepr::User {
1646                message,
1647                session_id,
1648                parent_tool_use_id,
1649                uuid,
1650                tool_use_result,
1651                timestamp,
1652                synthetic,
1653                extras,
1654            } => Message::User {
1655                message,
1656                session_id,
1657                parent_tool_use_id,
1658                uuid,
1659                tool_use_result,
1660                timestamp,
1661                synthetic,
1662                extras,
1663            },
1664            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskStarted {
1665                task_id,
1666                description,
1667                uuid,
1668                session_id,
1669                tool_use_id,
1670                task_type,
1671                extras,
1672            })) => Message::TaskStarted {
1673                task_id,
1674                description,
1675                uuid,
1676                session_id,
1677                tool_use_id,
1678                task_type,
1679                extras,
1680            },
1681            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskUpdated {
1682                task_id,
1683                patch,
1684                uuid,
1685                session_id,
1686                extras,
1687            })) => Message::TaskUpdated { task_id, patch, uuid, session_id, extras },
1688            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskProgress {
1689                task_id,
1690                description,
1691                usage,
1692                uuid,
1693                session_id,
1694                tool_use_id,
1695                last_tool_name,
1696                workflow_progress,
1697                extras,
1698            })) => Message::TaskProgress {
1699                task_id,
1700                description,
1701                usage,
1702                uuid,
1703                session_id,
1704                tool_use_id,
1705                last_tool_name,
1706                workflow_progress,
1707                extras,
1708            },
1709            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskNotification {
1710                task_id,
1711                status,
1712                output_file,
1713                summary,
1714                uuid,
1715                session_id,
1716                tool_use_id,
1717                usage,
1718                extras,
1719            })) => Message::TaskNotification {
1720                task_id,
1721                status,
1722                output_file,
1723                summary,
1724                uuid,
1725                session_id,
1726                tool_use_id,
1727                usage,
1728                extras,
1729            },
1730            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::ThinkingTokens {
1731                estimated_tokens,
1732                estimated_tokens_delta,
1733                uuid,
1734                session_id,
1735                extras,
1736            })) => Message::ThinkingTokens {
1737                estimated_tokens,
1738                estimated_tokens_delta,
1739                uuid,
1740                session_id,
1741                extras,
1742            },
1743            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TurnDuration {
1744                ms,
1745                message_count,
1746                parent_tool_use_id,
1747                session_id,
1748                uuid,
1749                extras,
1750            })) => Message::TurnDuration {
1751                ms,
1752                message_count,
1753                parent_tool_use_id,
1754                session_id,
1755                uuid,
1756                extras,
1757            },
1758            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::StopHookSummary {
1759                actions,
1760                hook_infos,
1761                hook_errors,
1762                has_output,
1763                level,
1764                prevented_continuation,
1765                stop_reason,
1766                tool_use_id,
1767                parent_tool_use_id,
1768                session_id,
1769                uuid,
1770                extras,
1771            })) => Message::StopHookSummary {
1772                actions,
1773                hook_infos,
1774                hook_errors,
1775                has_output,
1776                level,
1777                prevented_continuation,
1778                stop_reason,
1779                tool_use_id,
1780                parent_tool_use_id,
1781                session_id,
1782                uuid,
1783                extras,
1784            },
1785            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::BackgroundTasksChanged {
1786                tasks,
1787                uuid,
1788                session_id,
1789                extras,
1790            })) => Message::BackgroundTasksChanged { tasks, uuid, session_id, extras },
1791            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CommandsChanged {
1792                commands,
1793                uuid,
1794                session_id,
1795                extras,
1796            })) => Message::CommandsChanged { commands, uuid, session_id, extras },
1797            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookStarted {
1798                hook_id,
1799                hook_name,
1800                hook_event,
1801                uuid,
1802                session_id,
1803                extras,
1804            })) => {
1805                Message::HookStarted { hook_id, hook_name, hook_event, uuid, session_id, extras }
1806            }
1807            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookResponse {
1808                hook_id,
1809                hook_name,
1810                hook_event,
1811                outcome,
1812                exit_code,
1813                output,
1814                stdout,
1815                stderr,
1816                uuid,
1817                session_id,
1818                extras,
1819            })) => Message::HookResponse {
1820                hook_id,
1821                hook_name,
1822                hook_event,
1823                outcome,
1824                exit_code,
1825                output,
1826                stdout,
1827                stderr,
1828                uuid,
1829                session_id,
1830                extras,
1831            },
1832            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookProgress {
1833                hook_id,
1834                hook_name,
1835                hook_event,
1836                stdout,
1837                stderr,
1838                output,
1839                uuid,
1840                session_id,
1841                extras,
1842            })) => Message::HookProgress {
1843                hook_id,
1844                hook_name,
1845                hook_event,
1846                stdout,
1847                stderr,
1848                output,
1849                uuid,
1850                session_id,
1851                extras,
1852            },
1853            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::Notification {
1854                key,
1855                text,
1856                priority,
1857                uuid,
1858                session_id,
1859                extras,
1860            })) => Message::Notification { key, text, priority, uuid, session_id, extras },
1861            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::PermissionDenied {
1862                tool_name,
1863                tool_use_id,
1864                decision_reason_type,
1865                decision_reason,
1866                message,
1867                uuid,
1868                session_id,
1869                extras,
1870            })) => Message::PermissionDenied {
1871                tool_name,
1872                tool_use_id,
1873                decision_reason_type,
1874                decision_reason,
1875                message,
1876                uuid,
1877                session_id,
1878                extras,
1879            },
1880            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CompactBoundary {
1881                compact_metadata:
1882                    CompactMetadataRepr { trigger, pre_tokens, post_tokens, extras: metadata_extras },
1883                uuid,
1884                session_id,
1885                extras,
1886            })) => Message::CompactBoundary {
1887                trigger,
1888                pre_tokens,
1889                post_tokens,
1890                uuid,
1891                session_id,
1892                metadata_extras,
1893                extras,
1894            },
1895            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::VcsStateChanged {
1896                kind,
1897                branch,
1898                cwd,
1899                uuid,
1900                session_id,
1901                extras,
1902            })) => Message::VcsStateChanged { kind, branch, cwd, uuid, session_id, extras },
1903            MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CodeChangePublished {
1904                provider,
1905                url,
1906                repo,
1907                identifier,
1908                action,
1909                branch,
1910                uuid,
1911                session_id,
1912                extras,
1913            })) => Message::CodeChangePublished {
1914                provider,
1915                url,
1916                repo,
1917                identifier,
1918                action,
1919                branch,
1920                uuid,
1921                session_id,
1922                extras,
1923            },
1924            MessageRepr::System(SystemRepr::Generic(GenericSystemRepr {
1925                subtype,
1926                session_id,
1927                data,
1928            })) => {
1929                // The CLI's system message wire shape carries the
1930                // FULL original dict in `data` - including `type`,
1931                // `subtype`, and `session_id`. Rust's serde
1932                // `#[flatten]` on the private `GenericSystemRepr`
1933                // strips those fields because they're claimed by
1934                // explicit sibling fields and the outer tag dispatch.
1935                // Rehydrate so callers reading `data["subtype"]` see
1936                // the unified shape.
1937                let mut full_data = data;
1938                if let Value::Object(map) = &mut full_data {
1939                    map.insert("type".into(), Value::String("system".into()));
1940                    map.insert("subtype".into(), Value::String(subtype.clone()));
1941                    if let Some(sid) = &session_id {
1942                        map.insert("session_id".into(), Value::String(sid.clone()));
1943                    }
1944                }
1945                Message::System { subtype, session_id, data: full_data }
1946            }
1947            MessageRepr::RateLimitEvent { rate_limit_info, uuid, session_id, extras } => {
1948                Message::RateLimitEvent { rate_limit_info, uuid, session_id, extras }
1949            }
1950            MessageRepr::CommandLifecycle { command_uuid, state, uuid, session_id, extras } => {
1951                Message::CommandLifecycle { command_uuid, state, uuid, session_id, extras }
1952            }
1953            MessageRepr::Result {
1954                subtype,
1955                session_id,
1956                is_error,
1957                num_turns,
1958                duration_ms,
1959                duration_api_ms,
1960                stop_reason,
1961                total_cost_usd,
1962                usage,
1963                result,
1964                structured_output,
1965                model_usage,
1966                permission_denials,
1967                errors,
1968                uuid,
1969                terminal_reason,
1970                extras,
1971            } => Message::Result {
1972                subtype,
1973                session_id,
1974                is_error,
1975                num_turns,
1976                duration_ms,
1977                duration_api_ms,
1978                stop_reason,
1979                total_cost_usd,
1980                usage,
1981                result,
1982                structured_output,
1983                model_usage,
1984                permission_denials,
1985                errors,
1986                uuid,
1987                terminal_reason,
1988                extras,
1989            },
1990            MessageRepr::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras } => {
1991                Message::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras }
1992            }
1993            MessageRepr::ToolProgress {
1994                tool_use_id,
1995                tool_name,
1996                elapsed_time_seconds,
1997                heartbeat,
1998                parent_tool_use_id,
1999                session_id,
2000                uuid,
2001                extras,
2002            } => Message::ToolProgress {
2003                tool_use_id,
2004                tool_name,
2005                elapsed_time_seconds,
2006                heartbeat,
2007                parent_tool_use_id,
2008                session_id,
2009                uuid,
2010                extras,
2011            },
2012            MessageRepr::Error { error, extras } => Message::Error { error, extras },
2013        }
2014    }
2015}
2016
2017impl From<Message> for MessageRepr {
2018    fn from(msg: Message) -> Self {
2019        match msg {
2020            Message::Assistant {
2021                message,
2022                session_id,
2023                parent_tool_use_id,
2024                error,
2025                uuid,
2026                timestamp,
2027                extras,
2028            } => MessageRepr::Assistant {
2029                message,
2030                session_id,
2031                parent_tool_use_id,
2032                error,
2033                uuid,
2034                timestamp,
2035                extras,
2036            },
2037            Message::User {
2038                message,
2039                session_id,
2040                parent_tool_use_id,
2041                uuid,
2042                tool_use_result,
2043                timestamp,
2044                synthetic,
2045                extras,
2046            } => MessageRepr::User {
2047                message,
2048                session_id,
2049                parent_tool_use_id,
2050                uuid,
2051                tool_use_result,
2052                timestamp,
2053                synthetic,
2054                extras,
2055            },
2056            Message::System { subtype, session_id, data } => {
2057                // `data` now carries the full shape (including `type`,
2058                // `subtype`, `session_id`). On the way back out, strip
2059                // those keys from the flatten payload so the outer
2060                // tag-dispatch + explicit sibling fields don't produce
2061                // duplicates on the wire.
2062                let mut flat = data;
2063                if let Value::Object(map) = &mut flat {
2064                    map.remove("type");
2065                    map.remove("subtype");
2066                    map.remove("session_id");
2067                }
2068                MessageRepr::System(SystemRepr::Generic(GenericSystemRepr {
2069                    subtype,
2070                    session_id,
2071                    data: flat,
2072                }))
2073            }
2074            Message::TaskStarted {
2075                task_id,
2076                description,
2077                uuid,
2078                session_id,
2079                tool_use_id,
2080                task_type,
2081                extras,
2082            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskStarted {
2083                task_id,
2084                description,
2085                uuid,
2086                session_id,
2087                tool_use_id,
2088                task_type,
2089                extras,
2090            })),
2091            Message::TaskUpdated { task_id, patch, uuid, session_id, extras } => {
2092                MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskUpdated {
2093                    task_id,
2094                    patch,
2095                    uuid,
2096                    session_id,
2097                    extras,
2098                }))
2099            }
2100            Message::TaskProgress {
2101                task_id,
2102                description,
2103                usage,
2104                uuid,
2105                session_id,
2106                tool_use_id,
2107                last_tool_name,
2108                workflow_progress,
2109                extras,
2110            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskProgress {
2111                task_id,
2112                description,
2113                usage,
2114                uuid,
2115                session_id,
2116                tool_use_id,
2117                last_tool_name,
2118                workflow_progress,
2119                extras,
2120            })),
2121            Message::TaskNotification {
2122                task_id,
2123                status,
2124                output_file,
2125                summary,
2126                uuid,
2127                session_id,
2128                tool_use_id,
2129                usage,
2130                extras,
2131            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskNotification {
2132                task_id,
2133                status,
2134                output_file,
2135                summary,
2136                uuid,
2137                session_id,
2138                tool_use_id,
2139                usage,
2140                extras,
2141            })),
2142            Message::ThinkingTokens {
2143                estimated_tokens,
2144                estimated_tokens_delta,
2145                uuid,
2146                session_id,
2147                extras,
2148            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::ThinkingTokens {
2149                estimated_tokens,
2150                estimated_tokens_delta,
2151                uuid,
2152                session_id,
2153                extras,
2154            })),
2155            Message::TurnDuration {
2156                ms,
2157                message_count,
2158                parent_tool_use_id,
2159                session_id,
2160                uuid,
2161                extras,
2162            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TurnDuration {
2163                ms,
2164                message_count,
2165                parent_tool_use_id,
2166                session_id,
2167                uuid,
2168                extras,
2169            })),
2170            Message::StopHookSummary {
2171                actions,
2172                hook_infos,
2173                hook_errors,
2174                has_output,
2175                level,
2176                prevented_continuation,
2177                stop_reason,
2178                tool_use_id,
2179                parent_tool_use_id,
2180                session_id,
2181                uuid,
2182                extras,
2183            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::StopHookSummary {
2184                actions,
2185                hook_infos,
2186                hook_errors,
2187                has_output,
2188                level,
2189                prevented_continuation,
2190                stop_reason,
2191                tool_use_id,
2192                parent_tool_use_id,
2193                session_id,
2194                uuid,
2195                extras,
2196            })),
2197            Message::BackgroundTasksChanged { tasks, uuid, session_id, extras } => {
2198                MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::BackgroundTasksChanged {
2199                    tasks,
2200                    uuid,
2201                    session_id,
2202                    extras,
2203                }))
2204            }
2205            Message::CommandsChanged { commands, uuid, session_id, extras } => {
2206                MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CommandsChanged {
2207                    commands,
2208                    uuid,
2209                    session_id,
2210                    extras,
2211                }))
2212            }
2213            Message::HookStarted { hook_id, hook_name, hook_event, uuid, session_id, extras } => {
2214                MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookStarted {
2215                    hook_id,
2216                    hook_name,
2217                    hook_event,
2218                    uuid,
2219                    session_id,
2220                    extras,
2221                }))
2222            }
2223            Message::HookResponse {
2224                hook_id,
2225                hook_name,
2226                hook_event,
2227                outcome,
2228                exit_code,
2229                output,
2230                stdout,
2231                stderr,
2232                uuid,
2233                session_id,
2234                extras,
2235            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookResponse {
2236                hook_id,
2237                hook_name,
2238                hook_event,
2239                outcome,
2240                exit_code,
2241                output,
2242                stdout,
2243                stderr,
2244                uuid,
2245                session_id,
2246                extras,
2247            })),
2248            Message::HookProgress {
2249                hook_id,
2250                hook_name,
2251                hook_event,
2252                stdout,
2253                stderr,
2254                output,
2255                uuid,
2256                session_id,
2257                extras,
2258            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookProgress {
2259                hook_id,
2260                hook_name,
2261                hook_event,
2262                stdout,
2263                stderr,
2264                output,
2265                uuid,
2266                session_id,
2267                extras,
2268            })),
2269            Message::Notification { key, text, priority, uuid, session_id, extras } => {
2270                MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::Notification {
2271                    key,
2272                    text,
2273                    priority,
2274                    uuid,
2275                    session_id,
2276                    extras,
2277                }))
2278            }
2279            Message::PermissionDenied {
2280                tool_name,
2281                tool_use_id,
2282                decision_reason_type,
2283                decision_reason,
2284                message,
2285                uuid,
2286                session_id,
2287                extras,
2288            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::PermissionDenied {
2289                tool_name,
2290                tool_use_id,
2291                decision_reason_type,
2292                decision_reason,
2293                message,
2294                uuid,
2295                session_id,
2296                extras,
2297            })),
2298            Message::CompactBoundary {
2299                trigger,
2300                pre_tokens,
2301                post_tokens,
2302                uuid,
2303                session_id,
2304                metadata_extras,
2305                extras,
2306            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CompactBoundary {
2307                compact_metadata: CompactMetadataRepr {
2308                    trigger,
2309                    pre_tokens,
2310                    post_tokens,
2311                    extras: metadata_extras,
2312                },
2313                uuid,
2314                session_id,
2315                extras,
2316            })),
2317            Message::VcsStateChanged { kind, branch, cwd, uuid, session_id, extras } => {
2318                MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::VcsStateChanged {
2319                    kind,
2320                    branch,
2321                    cwd,
2322                    uuid,
2323                    session_id,
2324                    extras,
2325                }))
2326            }
2327            Message::CodeChangePublished {
2328                provider,
2329                url,
2330                repo,
2331                identifier,
2332                action,
2333                branch,
2334                uuid,
2335                session_id,
2336                extras,
2337            } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CodeChangePublished {
2338                provider,
2339                url,
2340                repo,
2341                identifier,
2342                action,
2343                branch,
2344                uuid,
2345                session_id,
2346                extras,
2347            })),
2348            Message::RateLimitEvent { rate_limit_info, uuid, session_id, extras } => {
2349                MessageRepr::RateLimitEvent { rate_limit_info, uuid, session_id, extras }
2350            }
2351            Message::CommandLifecycle { command_uuid, state, uuid, session_id, extras } => {
2352                MessageRepr::CommandLifecycle { command_uuid, state, uuid, session_id, extras }
2353            }
2354            Message::Result {
2355                subtype,
2356                session_id,
2357                is_error,
2358                num_turns,
2359                duration_ms,
2360                duration_api_ms,
2361                stop_reason,
2362                total_cost_usd,
2363                usage,
2364                result,
2365                structured_output,
2366                model_usage,
2367                permission_denials,
2368                errors,
2369                uuid,
2370                terminal_reason,
2371                extras,
2372            } => MessageRepr::Result {
2373                subtype,
2374                session_id,
2375                is_error,
2376                num_turns,
2377                duration_ms,
2378                duration_api_ms,
2379                stop_reason,
2380                total_cost_usd,
2381                usage,
2382                result,
2383                structured_output,
2384                model_usage,
2385                permission_denials,
2386                errors,
2387                uuid,
2388                terminal_reason,
2389                extras,
2390            },
2391            Message::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras } => {
2392                MessageRepr::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras }
2393            }
2394            Message::ToolProgress {
2395                tool_use_id,
2396                tool_name,
2397                elapsed_time_seconds,
2398                heartbeat,
2399                parent_tool_use_id,
2400                session_id,
2401                uuid,
2402                extras,
2403            } => MessageRepr::ToolProgress {
2404                tool_use_id,
2405                tool_name,
2406                elapsed_time_seconds,
2407                heartbeat,
2408                parent_tool_use_id,
2409                session_id,
2410                uuid,
2411                extras,
2412            },
2413            Message::Error { error, extras } => MessageRepr::Error { error, extras },
2414            // Defensive sentinel - `Serialize` for `Message` special-cases
2415            // `Unknown` to emit `raw` verbatim, so this branch is dead code
2416            // at runtime. Kept to keep the `From` impl total without
2417            // `unreachable!()` (banned by the workspace lint set).
2418            Message::Unknown { type_str, .. } => MessageRepr::Error {
2419                error: format!(
2420                    "Message::Unknown {{ type_str: {type_str:?} }} \
2421                     cannot be encoded via MessageRepr"
2422                ),
2423                extras: Extras::new(),
2424            },
2425        }
2426    }
2427}
2428
2429#[cfg(test)]
2430mod tests_result_message_fields {
2431    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
2432    #[allow(unused_imports)]
2433    use super::*;
2434
2435    use crate::Message;
2436    use serde_json::json;
2437
2438    /// Minimum-viable result frame - only the six required fields.
2439    /// The CLI emits this on error-path turns; forge-sdk must accept
2440    /// it.
2441    #[test]
2442    fn minimal_result_parses_without_cost_or_usage() {
2443        let raw = json!({
2444            "type": "result",
2445            "subtype": "success",
2446            "duration_ms": 10,
2447            "duration_api_ms": 8,
2448            "is_error": false,
2449            "num_turns": 1,
2450            "session_id": "sess-min",
2451        });
2452        let msg: Message = serde_json::from_value(raw).expect("parse");
2453        match msg {
2454            Message::Result {
2455                total_cost_usd,
2456                usage,
2457                stop_reason,
2458                result,
2459                structured_output,
2460                model_usage,
2461                permission_denials,
2462                errors,
2463                uuid,
2464                ..
2465            } => {
2466                assert!(total_cost_usd.is_none());
2467                assert!(usage.is_none());
2468                assert!(stop_reason.is_none());
2469                assert!(result.is_none());
2470                assert!(structured_output.is_none());
2471                assert!(model_usage.is_none());
2472                assert!(permission_denials.is_none());
2473                assert!(errors.is_none());
2474                assert!(uuid.is_none());
2475            }
2476            other => panic!("expected Result, got {other:?}"),
2477        }
2478    }
2479
2480    /// Full payload - every optional field populated. Exercises the
2481    /// `modelUsage` camelCase wire key and captures the result body, the
2482    /// permission-denial vector, and the error vector.
2483    #[test]
2484    fn full_result_parses_and_surfaces_every_field() {
2485        let raw = json!({
2486            "type": "result",
2487            "subtype": "success",
2488            "duration_ms": 5000,
2489            "duration_api_ms": 4200,
2490            "is_error": false,
2491            "num_turns": 7,
2492            "session_id": "sess-full",
2493            "stop_reason": "end_turn",
2494            "total_cost_usd": 0.123,
2495            "usage": {
2496                "input_tokens": 100,
2497                "output_tokens": 50,
2498                "cache_creation_input_tokens": 0,
2499                "cache_read_input_tokens": 0
2500            },
2501            "result": "hello world",
2502            "structured_output": { "answer": 42 },
2503            "modelUsage": {
2504                "claude-sonnet-4-6": { "input_tokens": 60, "output_tokens": 30 }
2505            },
2506            "permission_denials": [
2507                { "tool_name": "Bash", "reason": "dry run" }
2508            ],
2509            "errors": ["warning: slow"],
2510            "uuid": "res-1"
2511        });
2512        let msg: Message = serde_json::from_value(raw).expect("parse");
2513        let Message::Result {
2514            stop_reason,
2515            total_cost_usd,
2516            usage,
2517            result,
2518            structured_output,
2519            model_usage,
2520            permission_denials,
2521            errors,
2522            uuid,
2523            ..
2524        } = msg
2525        else {
2526            panic!("expected Result");
2527        };
2528        assert_eq!(stop_reason.as_deref(), Some("end_turn"));
2529        assert_eq!(total_cost_usd, Some(0.123));
2530        assert!(usage.is_some());
2531        assert_eq!(result.as_deref(), Some("hello world"));
2532        assert_eq!(structured_output, Some(json!({ "answer": 42 })));
2533        assert!(model_usage.is_some(), "modelUsage should decode");
2534        assert_eq!(model_usage.as_ref().unwrap()["claude-sonnet-4-6"]["input_tokens"], 60);
2535        assert_eq!(
2536            permission_denials.as_deref().map(<[_]>::len),
2537            Some(1),
2538            "permission_denials should surface"
2539        );
2540        assert_eq!(errors.as_deref(), Some(vec!["warning: slow".to_string()]).as_deref());
2541        assert_eq!(uuid.as_deref(), Some("res-1"));
2542    }
2543
2544    /// modelUsage must serialize back out as camelCase on the wire - the
2545    /// typical caller-side scenario is round-tripping a decoded result
2546    /// through session-store persistence.
2547    #[test]
2548    fn result_model_usage_roundtrips_as_camel_case() {
2549        let raw = json!({
2550            "type": "result",
2551            "subtype": "success",
2552            "duration_ms": 1,
2553            "duration_api_ms": 1,
2554            "is_error": false,
2555            "num_turns": 1,
2556            "session_id": "sess-rt",
2557            "modelUsage": { "claude-sonnet-4-6": { "input_tokens": 1 } }
2558        });
2559        let msg: Message = serde_json::from_value(raw.clone()).expect("parse");
2560        let re = serde_json::to_value(&msg).expect("serialize");
2561        assert_eq!(
2562            re["modelUsage"]["claude-sonnet-4-6"]["input_tokens"], 1,
2563            "must preserve camelCase on the way out"
2564        );
2565        assert!(re.get("model_usage").is_none(), "snake_case key must NOT leak");
2566    }
2567}
2568
2569#[cfg(test)]
2570mod tests_message_extras {
2571    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
2572    #[allow(unused_imports)]
2573    use super::*;
2574
2575    use crate::{AssistantMessageError, Message};
2576    use serde_json::json;
2577
2578    /// A backend with no prompt cache reports the cache counters as
2579    /// `null` rather than `0`. Verbatim `usage` block measured from
2580    /// OpenRouter serving `z-ai/glm-5.3-flash`; a decode failure here
2581    /// ends the whole event stream, so the session dies mid-turn.
2582    #[test]
2583    fn usage_counters_decode_when_the_backend_sends_null() {
2584        let measured = serde_json::json!({
2585            "input_tokens": 13,
2586            "output_tokens": 24,
2587            "output_tokens_details": { "thinking_tokens": 24 },
2588            "cache_creation_input_tokens": null,
2589            "cache_read_input_tokens": 0,
2590            "cache_creation": null,
2591            "inference_geo": null,
2592            "server_tool_use": null,
2593            "service_tier": null,
2594            "speed": "standard",
2595            "cost": 1.395e-05,
2596            "is_byok": false
2597        });
2598
2599        let usage: crate::Usage =
2600            serde_json::from_value(measured).expect("a null cache counter must not fail the frame");
2601
2602        assert_eq!(usage.cache_creation_input_tokens, 0, "a null cache counter reads as zero");
2603        assert_eq!(usage.input_tokens, 13, "and the counters beside it still decode");
2604        assert_eq!(usage.output_tokens, 24, "and the counters beside it still decode");
2605    }
2606
2607    #[test]
2608    fn assistant_error_enum_wire_names() {
2609        // Each variant must serialize to its wire literal.
2610        for (variant, wire) in [
2611            (AssistantMessageError::AuthenticationFailed, "authentication_failed"),
2612            (AssistantMessageError::BillingError, "billing_error"),
2613            (AssistantMessageError::RateLimit, "rate_limit"),
2614            (AssistantMessageError::InvalidRequest, "invalid_request"),
2615            (AssistantMessageError::ServerError, "server_error"),
2616            (AssistantMessageError::Unknown, "unknown"),
2617        ] {
2618            let encoded = serde_json::to_value(variant).expect("serialize");
2619            assert_eq!(encoded, json!(wire), "{variant:?} must wire as '{wire}'");
2620            let decoded: AssistantMessageError =
2621                serde_json::from_value(json!(wire)).expect("deserialize");
2622            assert_eq!(decoded, variant);
2623        }
2624    }
2625
2626    #[test]
2627    fn assistant_frame_decodes_error_and_uuid_outer_fields() {
2628        let raw = json!({
2629            "type": "assistant",
2630            "session_id": "sess-err",
2631            "uuid": "asst-uuid-1",
2632            "error": "rate_limit",
2633            "message": {
2634                "id": "msg_01",
2635                "role": "assistant",
2636                "model": "claude-opus-4-5",
2637                "content": [{"type": "text", "text": "throttled"}],
2638                "usage": {
2639                    "input_tokens": 0,
2640                    "output_tokens": 0,
2641                    "cache_creation_input_tokens": 0,
2642                    "cache_read_input_tokens": 0
2643                }
2644            }
2645        });
2646        let msg: Message = serde_json::from_value(raw).expect("parse");
2647        match msg {
2648            Message::Assistant { error, uuid, .. } => {
2649                assert_eq!(error, Some(AssistantMessageError::RateLimit));
2650                assert_eq!(uuid.as_deref(), Some("asst-uuid-1"));
2651            }
2652            other => panic!("expected Assistant, got {other:?}"),
2653        }
2654    }
2655
2656    #[test]
2657    fn assistant_frame_without_usage_now_parses() {
2658        // Usage is optional on the wire - error-path assistant
2659        // frames omit it. forge-sdk must parse them (regression
2660        // guard against the pre-2026-04-22 required-`usage` shape).
2661        let raw = json!({
2662            "type": "assistant",
2663            "session_id": "sess",
2664            "message": {
2665                "id": "msg_err",
2666                "role": "assistant",
2667                "model": "claude-opus-4-5",
2668                "content": []
2669            }
2670        });
2671        let msg: Message = serde_json::from_value(raw).expect("parse");
2672        match msg {
2673            Message::Assistant { message, .. } => {
2674                assert!(message.usage.is_none());
2675            }
2676            other => panic!("expected Assistant, got {other:?}"),
2677        }
2678    }
2679
2680    #[test]
2681    fn user_frame_decodes_uuid_and_tool_use_result() {
2682        let raw = json!({
2683            "type": "user",
2684            "session_id": "sess-usr",
2685            "uuid": "user-uuid-1",
2686            "tool_use_result": {"stdout": "ok"},
2687            "message": {
2688                "role": "user",
2689                "content": [
2690                    {"type": "tool_result", "tool_use_id": "toolu_01", "content": "ok", "is_error": false}
2691                ]
2692            }
2693        });
2694        let msg: Message = serde_json::from_value(raw).expect("parse");
2695        match msg {
2696            Message::User { uuid, tool_use_result, .. } => {
2697                assert_eq!(uuid.as_deref(), Some("user-uuid-1"));
2698                assert_eq!(tool_use_result, Some(json!({"stdout": "ok"})));
2699            }
2700            other => panic!("expected User, got {other:?}"),
2701        }
2702    }
2703
2704    /// The CLI stamps a user frame whose words nobody typed, and the
2705    /// view layer cannot tell the harness speaking from the reader
2706    /// without it. The reminder the harness injects is built with
2707    /// `isMeta` internally and the emitter writes that as `isSynthetic`
2708    /// (measured shape: the one user frame carrying text in
2709    /// `.claude/skills/claude-cli-upgrade/reference-captures/skill.jsonl`).
2710    #[test]
2711    fn user_frame_keeps_the_clis_synthetic_stamp() {
2712        let raw = json!({
2713            "type": "user",
2714            "session_id": "sess-usr",
2715            "uuid": "user-uuid-2",
2716            "isSynthetic": true,
2717            "message": {
2718                "role": "user",
2719                "content": [{
2720                    "type": "text",
2721                    "text": "Skill /unslop was loaded earlier (see the invoked-skills reminder above); this is a NEW invocation."
2722                }]
2723            }
2724        });
2725
2726        let msg: Message = serde_json::from_value(raw).expect("parse");
2727        let encoded = serde_json::to_value(&msg).expect("encode");
2728
2729        assert_eq!(
2730            encoded.get("isSynthetic"),
2731            Some(&json!(true)),
2732            "the stamp must survive decode and re-encode, or every view reads these words as \
2733             the reader's own: {encoded}",
2734        );
2735
2736        // The carrier that is not the reminder: the compaction summary, whose
2737        // frame the CLI emits with the mark computed from `isCompactSummary`.
2738        // Shape taken from `baselines/sdk/2.1.280/compact.jsonl`.
2739        let summary = json!({
2740            "type": "user",
2741            "session_id": "sess-summary",
2742            "uuid": "user-uuid-3",
2743            "isSynthetic": true,
2744            "isReplay": false,
2745            "message": {
2746                "role": "user",
2747                "content": "This session is being continued from a previous conversation that ran out of context."
2748            }
2749        });
2750
2751        let msg: Message = serde_json::from_value(summary).expect("parse");
2752        let encoded = serde_json::to_value(&msg).expect("encode");
2753
2754        assert_eq!(
2755            encoded.get("isSynthetic"),
2756            Some(&json!(true)),
2757            "a summary frame is nobody's typed words either: {encoded}",
2758        );
2759    }
2760
2761    /// Only the CLI's own wire spelling stamps a frame. A companion row's
2762    /// `turnCompanion` is a disk field: `VFe` folds `isMeta`,
2763    /// `isVisibleInTranscriptOnly` and `isCompactSummary` into the wire's
2764    /// `isSynthetic`, and no outbound constructor writes anything else, so a
2765    /// frame stamped by `turnCompanion` alone is a disk-shaped capture rather
2766    /// than the CLI's bytes.
2767    #[test]
2768    fn the_wire_stamps_only_with_its_own_spelling() {
2769        let raw = json!({
2770            "type": "user",
2771            "session_id": "sess-usr",
2772            "turnCompanion": true,
2773            "message": {"role": "user", "content": "companion text"}
2774        });
2775
2776        let decoded: Message = serde_json::from_value(raw).expect("parse");
2777        let encoded = serde_json::to_value(&decoded).expect("encode");
2778
2779        assert_eq!(
2780            encoded.get("isSynthetic"),
2781            None,
2782            "a disk field must not stamp a frame: {encoded}",
2783        );
2784    }
2785
2786    /// The stamp is present or absent, never `false`: every user frame
2787    /// forge has ever relayed is unmarked, so emitting the key on all of
2788    /// them would reshape the socket payload and the fixtures written
2789    /// against it.
2790    #[test]
2791    fn unmarked_user_frames_carry_no_synthetic_key() {
2792        let raw = json!({
2793            "type": "user",
2794            "session_id": "sess-usr",
2795            "message": {"role": "user", "content": "typed by hand"}
2796        });
2797
2798        let decoded: Message = serde_json::from_value(raw).expect("parse");
2799        let encoded = serde_json::to_value(&decoded).expect("encode");
2800
2801        assert_eq!(
2802            encoded.get("isSynthetic"),
2803            None,
2804            "a frame nobody stamped must not grow the key: {encoded}",
2805        );
2806    }
2807
2808    #[test]
2809    fn unknown_wire_values_decode_to_the_catch_all_variant() {
2810        // serde(other) on these wire enums must absorb a value the CLI
2811        // adds later so a new literal degrades to Unknown instead of
2812        // failing the whole frame decode. Feed a genuinely-foreign
2813        // string: a tautological `"unknown"` would round-trip by name
2814        // even without the catch-all, so it wouldn't guard the attribute.
2815        let foreign = || json!("some_future_value_forge_does_not_know");
2816        assert_eq!(
2817            serde_json::from_value::<AssistantMessageError>(foreign()).expect("decode"),
2818            AssistantMessageError::Unknown
2819        );
2820        assert_eq!(
2821            serde_json::from_value::<StopReason>(foreign()).expect("decode"),
2822            StopReason::Unknown
2823        );
2824        assert_eq!(
2825            serde_json::from_value::<RateLimitStatus>(foreign()).expect("decode"),
2826            RateLimitStatus::Unknown
2827        );
2828        assert_eq!(
2829            serde_json::from_value::<RateLimitType>(foreign()).expect("decode"),
2830            RateLimitType::Unknown
2831        );
2832        assert_eq!(
2833            serde_json::from_value::<TaskNotificationStatus>(foreign()).expect("decode"),
2834            TaskNotificationStatus::Unknown
2835        );
2836        assert_eq!(
2837            serde_json::from_value::<crate::runtime::TerminalReason>(foreign()).expect("decode"),
2838            crate::runtime::TerminalReason::Unknown
2839        );
2840    }
2841
2842    // ----------------------------------------------------------------
2843    // #273: CLI 2.1.156 system events (thinking_tokens / turn_duration
2844    // / stop_hook_summary). Wire shapes captured in
2845    // crates/forge-test-harness/baselines/sdk/2.1.156/* and pinned by
2846    // the roundtrip tests below.
2847    // ----------------------------------------------------------------
2848
2849    #[test]
2850    fn thinking_tokens_decodes_from_wire_shape() {
2851        // Wire shape (verbatim from
2852        // `baselines/sdk/2.1.156/subagent_*` captures): fields are
2853        // snake_case (NOT camelCase as the EPIC body suggested).
2854        let raw = json!({
2855            "type": "system",
2856            "subtype": "thinking_tokens",
2857            "estimated_tokens": 1234,
2858            "estimated_tokens_delta": 56,
2859            "uuid": "tt-uuid",
2860            "session_id": "sess-tt",
2861        });
2862        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
2863        let Message::ThinkingTokens {
2864            estimated_tokens,
2865            estimated_tokens_delta,
2866            uuid,
2867            session_id,
2868            ..
2869        } = msg
2870        else {
2871            panic!("expected ThinkingTokens, got {msg:?}");
2872        };
2873        assert_eq!(estimated_tokens, 1234);
2874        assert_eq!(estimated_tokens_delta, 56);
2875        assert_eq!(uuid, "tt-uuid");
2876        assert_eq!(session_id, "sess-tt");
2877        // Roundtrip: re-serialize and confirm the wire-shape JSON matches.
2878        let encoded = serde_json::to_value(&Message::ThinkingTokens {
2879            estimated_tokens: 1234,
2880            estimated_tokens_delta: 56,
2881            uuid: "tt-uuid".to_owned(),
2882            session_id: "sess-tt".to_owned(),
2883            extras: Extras::new(),
2884        })
2885        .expect("encode");
2886        assert_eq!(encoded, raw);
2887    }
2888
2889    #[test]
2890    fn turn_duration_decodes_from_wire_shape() {
2891        // Wire shape: `durationMs` camelCase, plus optional
2892        // `message_count` and `parent_tool_use_id`. Variant field
2893        // `ms: u64` maps via #[serde(rename = "durationMs")].
2894        let raw = json!({
2895            "type": "system",
2896            "subtype": "turn_duration",
2897            "durationMs": 31051,
2898            "messageCount": 29,
2899            "parent_tool_use_id": "uuid_25",
2900            "session_id": "sess-td",
2901            "uuid": "td-uuid",
2902        });
2903        let msg: Message = serde_json::from_value(raw).expect("decode");
2904        let Message::TurnDuration {
2905            ms, message_count, parent_tool_use_id, session_id, uuid, ..
2906        } = msg
2907        else {
2908            panic!("expected TurnDuration, got {msg:?}");
2909        };
2910        assert_eq!(ms, 31051);
2911        assert_eq!(message_count, Some(29));
2912        assert_eq!(parent_tool_use_id.as_deref(), Some("uuid_25"));
2913        assert_eq!(session_id, "sess-td");
2914        assert_eq!(uuid, "td-uuid");
2915    }
2916
2917    #[test]
2918    fn turn_duration_roundtrip_emits_wire_camel_case_keys() {
2919        let msg = Message::TurnDuration {
2920            ms: 1500,
2921            message_count: Some(3),
2922            parent_tool_use_id: None,
2923            session_id: "sess-rt".to_owned(),
2924            uuid: "rt-uuid".to_owned(),
2925            extras: Extras::new(),
2926        };
2927        let encoded = serde_json::to_value(&msg).expect("encode");
2928        // durationMs / messageCount preserved.
2929        assert_eq!(encoded.get("durationMs").and_then(serde_json::Value::as_u64), Some(1500));
2930        assert_eq!(encoded.get("messageCount").and_then(serde_json::Value::as_u64), Some(3));
2931        // parent_tool_use_id: None skipped from the output entirely.
2932        assert!(
2933            encoded.get("parent_tool_use_id").is_none(),
2934            "None-valued parent_tool_use_id must NOT serialize",
2935        );
2936    }
2937
2938    /// Frame is verbatim from the `compact` capture, extras included:
2939    /// the unmodelled `compact_metadata` siblings and
2940    /// `logical_parent_uuid` must not block the typed match, and must
2941    /// cross verbatim - a decode that keeps only what this build reads
2942    /// is a decode that drops CLI data.
2943    #[test]
2944    fn compact_boundary_decodes_the_three_fields_forge_reads() {
2945        let raw = json!({
2946            "type": "system",
2947            "subtype": "compact_boundary",
2948            "session_id": "sess-cb",
2949            "uuid": "cb-uuid",
2950            "compact_metadata": {
2951                "trigger": "manual",
2952                "pre_tokens": 68031,
2953                "post_tokens": 9149,
2954                "cumulative_dropped_tokens": 58882,
2955                "duration_ms": 48928,
2956                "preserved_segment": {"head_uuid": "h", "anchor_uuid": "a", "tail_uuid": "t"},
2957                "preserved_messages": {"anchor_uuid": "a", "uuids": ["h"], "all_uuids": ["h", "t"]},
2958            },
2959            "logical_parent_uuid": "lp-uuid",
2960        });
2961        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
2962        let Message::CompactBoundary {
2963            trigger,
2964            pre_tokens,
2965            post_tokens,
2966            uuid,
2967            session_id,
2968            metadata_extras,
2969            extras,
2970        } = msg
2971        else {
2972            panic!("compact_boundary must decode typed, not into System; got {msg:?}");
2973        };
2974        assert_eq!(trigger, "manual", "trigger drives the pending-compact clear");
2975        assert_eq!(pre_tokens, 68031, "pre_tokens is the number the usage panel shows");
2976        assert_eq!(post_tokens, 9149, "post_tokens is what the row says was carried after the cut");
2977        assert_eq!(uuid, "cb-uuid");
2978        assert_eq!(session_id, "sess-cb");
2979        assert_eq!(
2980            metadata_extras.get("duration_ms"),
2981            Some(&json!(48928)),
2982            "a metadata sibling crosses too; it is a fact about the cut, not noise",
2983        );
2984        assert_eq!(
2985            extras.get("logical_parent_uuid"),
2986            Some(&json!("lp-uuid")),
2987            "and a frame-level sibling crosses beside it",
2988        );
2989
2990        // The extras come back out where they came in: siblings of the
2991        // metadata object stay nested under it, the frame's own stay at
2992        // the top level.
2993        let encoded = serde_json::to_value(&Message::CompactBoundary {
2994            trigger: "manual".to_owned(),
2995            pre_tokens: 68031,
2996            post_tokens: 9149,
2997            uuid: "cb-uuid".to_owned(),
2998            session_id: "sess-cb".to_owned(),
2999            metadata_extras,
3000            extras,
3001        })
3002        .expect("encode");
3003        assert_eq!(
3004            encoded,
3005            json!({
3006                "type": "system",
3007                "subtype": "compact_boundary",
3008                "session_id": "sess-cb",
3009                "uuid": "cb-uuid",
3010                "compact_metadata": {
3011                    "trigger": "manual",
3012                    "pre_tokens": 68031,
3013                    "post_tokens": 9149,
3014                    "cumulative_dropped_tokens": 58882,
3015                    "duration_ms": 48928,
3016                    "preserved_segment": {"head_uuid": "h", "anchor_uuid": "a", "tail_uuid": "t"},
3017                    "preserved_messages": {"anchor_uuid": "a", "uuids": ["h"], "all_uuids": ["h", "t"]},
3018                },
3019                "logical_parent_uuid": "lp-uuid",
3020            }),
3021            "encode re-nests the metadata siblings and keeps the frame's own at the top",
3022        );
3023    }
3024
3025    /// The guard hard rule 9 buys by typing this subtype: a rename of
3026    /// either modelled field drops the frame into the generic bucket,
3027    /// which `EXPECTED_GENERIC_SYSTEM_SUBTYPES` does not list, so the
3028    /// next live capture fails instead of a view quietly losing the
3029    /// trigger. `preTokens` is the plausible drift - the transcript
3030    /// scan already carries an alias for that spelling - and the frame
3031    /// carries every other field, so that rename is the only thing that
3032    /// can drop it.
3033    #[test]
3034    fn a_renamed_pre_tokens_falls_to_the_generic_bucket() {
3035        let msg: Message = serde_json::from_value(json!({
3036            "type": "system",
3037            "subtype": "compact_boundary",
3038            "session_id": "sess-cb",
3039            "uuid": "cb-uuid",
3040            "compact_metadata": {"trigger": "manual", "preTokens": 68031, "post_tokens": 9149},
3041        }))
3042        .expect("decode");
3043        let Message::System { subtype, .. } = msg else {
3044            panic!("drifted metadata must NOT satisfy the typed match; got {msg:?}");
3045        };
3046        assert_eq!(subtype, "compact_boundary", "and it stays identifiable as the same subtype");
3047    }
3048
3049    #[test]
3050    fn background_tasks_changed_encode_round_trips() {
3051        let msg = Message::BackgroundTasksChanged {
3052            tasks: vec![
3053                json!({"task_id": "t1", "task_type": "local_workflow", "description": "d"}),
3054            ],
3055            uuid: "bg-uuid".to_owned(),
3056            session_id: "sess-bg".to_owned(),
3057            extras: Extras::new(),
3058        };
3059        let encoded = serde_json::to_value(&msg).expect("encode");
3060        assert_eq!(encoded["type"], "system");
3061        assert_eq!(encoded["subtype"], "background_tasks_changed");
3062        let decoded: Message = serde_json::from_value(encoded).expect("decode");
3063        assert_eq!(decoded, msg);
3064    }
3065
3066    #[test]
3067    fn commands_changed_encode_round_trips() {
3068        let msg = Message::CommandsChanged {
3069            commands: vec![json!({"name": "audit", "description": "sweep"})],
3070            uuid: "cmd-uuid".to_owned(),
3071            session_id: "sess-cmd".to_owned(),
3072            extras: Extras::new(),
3073        };
3074        let encoded = serde_json::to_value(&msg).expect("encode");
3075        assert_eq!(encoded["type"], "system");
3076        assert_eq!(encoded["subtype"], "commands_changed");
3077        let decoded: Message = serde_json::from_value(encoded).expect("decode");
3078        assert_eq!(decoded, msg);
3079    }
3080
3081    #[test]
3082    fn hook_started_encode_round_trips() {
3083        let msg = Message::HookStarted {
3084            hook_id: "h1".to_owned(),
3085            extras: Extras::new(),
3086            hook_name: "SessionStart:startup".to_owned(),
3087            hook_event: "SessionStart".to_owned(),
3088            uuid: "hs-uuid".to_owned(),
3089            session_id: "sess-hs".to_owned(),
3090        };
3091        let encoded = serde_json::to_value(&msg).expect("encode");
3092        assert_eq!(encoded["type"], "system");
3093        assert_eq!(encoded["subtype"], "hook_started");
3094        let decoded: Message = serde_json::from_value(encoded).expect("decode");
3095        assert_eq!(decoded, msg);
3096    }
3097
3098    #[test]
3099    fn hook_response_encode_round_trips() {
3100        let msg = Message::HookResponse {
3101            hook_id: "h1".to_owned(),
3102            hook_name: "SessionStart:startup".to_owned(),
3103            hook_event: "SessionStart".to_owned(),
3104            outcome: "success".to_owned(),
3105            exit_code: 0,
3106            output: "body".to_owned(),
3107            stdout: "body".to_owned(),
3108            stderr: String::new(),
3109            uuid: "hr-uuid".to_owned(),
3110            session_id: "sess-hr".to_owned(),
3111            extras: Extras::new(),
3112        };
3113        let encoded = serde_json::to_value(&msg).expect("encode");
3114        assert_eq!(encoded["type"], "system");
3115        assert_eq!(encoded["subtype"], "hook_response");
3116        let decoded: Message = serde_json::from_value(encoded).expect("decode");
3117        assert_eq!(decoded, msg);
3118    }
3119
3120    #[test]
3121    fn stop_hook_summary_decodes_from_wire_shape() {
3122        // Wire shape (verbatim from `baselines/sdk/2.1.156/...`):
3123        // rich object with `hookCount` + `hookInfos` array + optional
3124        // `parent_tool_use_id`. The renderer reads `hookCount`
3125        // (mapped to `actions: u32`) for the collapsed 1-liner and
3126        // `hookInfos` for the expanded body. No top-level `summary`
3127        // string in the wire; the EPIC's nominal `summary: Option<String>`
3128        // is preserved as an optional defensive field for future shapes
3129        // that might add one.
3130        let raw = json!({
3131            "type": "system",
3132            "subtype": "stop_hook_summary",
3133            "hasOutput": true,
3134            "hookCount": 2,
3135            "hookErrors": [],
3136            "hookInfos": [
3137                {"command": "bash ~/.claude/hooks/cmux-notify.sh", "durationMs": 980},
3138                {"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh", "durationMs": 17},
3139            ],
3140            "level": "suggestion",
3141            "parent_tool_use_id": "uuid_2",
3142            "preventedContinuation": false,
3143            "session_id": "session_0",
3144            "stopReason": "",
3145            "toolUseID": "5e586a7f",
3146            "uuid": "uuid_3",
3147        });
3148        let msg: Message = serde_json::from_value(raw).expect("decode");
3149        let Message::StopHookSummary {
3150            actions, hook_infos, parent_tool_use_id, session_id, ..
3151        } = msg
3152        else {
3153            panic!("expected StopHookSummary, got {msg:?}");
3154        };
3155        assert_eq!(actions, 2, "hookCount -> actions");
3156        assert_eq!(hook_infos.len(), 2);
3157        assert_eq!(hook_infos[0].command, "bash ~/.claude/hooks/cmux-notify.sh");
3158        assert_eq!(hook_infos[0].duration_ms, Some(980));
3159        assert_eq!(parent_tool_use_id.as_deref(), Some("uuid_2"));
3160        assert_eq!(session_id, "session_0");
3161    }
3162
3163    #[test]
3164    fn stop_hook_summary_errors_cross_the_crate() {
3165        // The CLI sends `hookErrors` on every stop_hook_summary row, empty
3166        // when nothing failed, and the client's hooks chip draws them - so the
3167        // key has to survive decode and re-encode. This row is a real failing
3168        // one, the ralph-wiggum plugin's directory gone.
3169        let raw = json!({
3170            "type": "system",
3171            "subtype": "stop_hook_summary",
3172            "hookCount": 1,
3173            "hookInfos": [{"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh", "durationMs": 0}],
3174            "hookErrors": [
3175                "Failed to run: Plugin directory does not exist: /Users/vedhavyas/.claude/plugins/cache/claude-code-plugins/ralph-wiggum/1.0.0 (ralph-wiggum@claude-code-plugins \u{2014} run /plugin to reinstall)"
3176            ],
3177            "hookAdditionalContext": [],
3178            "preventedContinuation": false,
3179            "stopReason": "",
3180            "hasOutput": true,
3181            "level": "suggestion",
3182            "toolUseID": "8bdfbd8a-a578-441d-97ff-4d8a2923e1e7",
3183            "session_id": "3dc2afa8-fdd1-40ff-bc7d-ffaace19246a",
3184            "uuid": "225cae5c-f638-4b31-afb2-700b8303dc16",
3185        });
3186        let msg: Message = serde_json::from_value(raw).expect("decode");
3187        let Message::StopHookSummary { .. } = &msg else {
3188            panic!("expected the typed variant, got {msg:?}");
3189        };
3190        let encoded = serde_json::to_value(&msg).expect("encode");
3191        assert_eq!(
3192            encoded["hookErrors"],
3193            json!([
3194                "Failed to run: Plugin directory does not exist: /Users/vedhavyas/.claude/plugins/cache/claude-code-plugins/ralph-wiggum/1.0.0 (ralph-wiggum@claude-code-plugins \u{2014} run /plugin to reinstall)"
3195            ]),
3196            "the key a client reads is on the wire, not only on the CLI's bytes"
3197        );
3198
3199        // Empty is the shape most rows carry, and the key is present rather
3200        // than omitted: the socket contract records the key set, and a skipped
3201        // empty would leave it absent there.
3202        let quiet = json!({
3203            "type": "system",
3204            "subtype": "stop_hook_summary",
3205            "hookCount": 2,
3206            "hookInfos": [{"command": "bash hook.sh", "durationMs": 980}],
3207            "hookErrors": [],
3208            "level": "suggestion",
3209            "preventedContinuation": false,
3210            "stopReason": "",
3211            "hasOutput": true,
3212            "toolUseID": "5e586a7f",
3213            "session_id": "session_0",
3214            "uuid": "uuid_3",
3215        });
3216        let msg: Message = serde_json::from_value(quiet).expect("decode");
3217        let Message::StopHookSummary { .. } = &msg else {
3218            panic!("expected the typed variant, got {msg:?}");
3219        };
3220        let encoded = serde_json::to_value(&msg).expect("encode");
3221        assert_eq!(encoded["hookErrors"], json!([]), "and empty crosses as empty");
3222    }
3223
3224    #[test]
3225    fn stop_hook_summary_entry_without_duration_decodes_typed() {
3226        // 2.1.263 emits hookInfos entries without `durationMs` (observed
3227        // for plugin-injected hooks), and prompt-driven hooks add
3228        // `promptText`. The entry must still decode as the typed
3229        // variant, not fall to the generic system bucket.
3230        let raw = json!({
3231            "type": "system",
3232            "subtype": "stop_hook_summary",
3233            "hasOutput": true,
3234            "hookCount": 2,
3235            "hookErrors": [],
3236            "hookInfos": [
3237                {"command": "bash ~/.claude/hooks/cmux-notify.sh", "durationMs": 980},
3238                {"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reminder.sh",
3239                 "promptText": "Check verification completeness"},
3240            ],
3241            "level": "suggestion",
3242            "preventedContinuation": false,
3243            "session_id": "session_0",
3244            "stopReason": "",
3245            "toolUseID": "5e586a7f",
3246            "uuid": "uuid_3",
3247        });
3248        let msg: Message = serde_json::from_value(raw).expect("decode");
3249        let Message::StopHookSummary { actions, hook_infos, .. } = msg else {
3250            panic!("expected StopHookSummary, got {msg:?}");
3251        };
3252        assert_eq!(actions, 2);
3253        assert_eq!(hook_infos[0].duration_ms, Some(980));
3254        assert_eq!(hook_infos[1].duration_ms, None, "durationMs may be absent");
3255        assert_eq!(hook_infos[1].command, "${CLAUDE_PLUGIN_ROOT}/hooks/reminder.sh");
3256    }
3257
3258    #[test]
3259    fn workflow_task_progress_decodes_workflow_progress_array() {
3260        // Wire shape from `~/Projects/forge/.claude/skills/claude-cli-upgrade/reference-captures/workflow.jsonl`.
3261        // Each `system/task_progress` for a Workflow tool carries a
3262        // FULL `workflow_progress` snapshot - phase markers + the
3263        // currently active agent's state.
3264        let raw = json!({
3265            "type": "system",
3266            "subtype": "task_progress",
3267            "task_id": "woc6i1sab",
3268            "tool_use_id": "toolu_01XapnWmqm6an1tJYxJn72xs",
3269            "description": "Ping: ping",
3270            "usage": {"total_tokens": 54707, "tool_uses": 1, "duration_ms": 4826},
3271            "last_tool_name": "ping",
3272            "summary": "Minimal one-agent workflow: ask for a fixed structured fact",
3273            "workflow_progress": [
3274                {"type": "workflow_phase", "index": 1, "title": "Ping"},
3275                {
3276                    "type": "workflow_agent",
3277                    "index": 1,
3278                    "label": "ping",
3279                    "phaseIndex": 1,
3280                    "phaseTitle": "Ping",
3281                    "state": "done",
3282                    "lastToolName": "StructuredOutput",
3283                    "lastToolSummary": "pong",
3284                    "resultPreview": "{\"answer\":\"pong\",\"confidence\":1}",
3285                    "agentId": "abf",
3286                    "model": "claude-opus-4-7",
3287                    "queuedAt": 1_780_047_148_162_u64,
3288                    "startedAt": 1_780_047_148_173_u64,
3289                    "attempt": 1,
3290                    "promptPreview": "Reply with the single word pong.",
3291                    "lastProgressAt": 1_780_047_153_041_u64,
3292                    "tokens": 54707,
3293                    "toolCalls": 1,
3294                    "durationMs": 4868,
3295                },
3296            ],
3297            "uuid": "8f030768-6fda-4ea9-a122-9beb16e8d6e3",
3298            "session_id": "session_z",
3299        });
3300        let msg: Message = serde_json::from_value(raw).expect("decode");
3301        let Message::TaskProgress { workflow_progress, task_id, tool_use_id, .. } = msg else {
3302            panic!("expected TaskProgress");
3303        };
3304        assert_eq!(task_id, "woc6i1sab");
3305        assert_eq!(tool_use_id.as_deref(), Some("toolu_01XapnWmqm6an1tJYxJn72xs"));
3306        assert_eq!(workflow_progress.len(), 2);
3307        let WorkflowProgressEvent::WorkflowPhase { index, title, .. } = &workflow_progress[0]
3308        else {
3309            panic!("first event must be WorkflowPhase, got {:?}", workflow_progress[0]);
3310        };
3311        assert_eq!(*index, 1);
3312        assert_eq!(title, "Ping");
3313        let WorkflowProgressEvent::WorkflowAgent {
3314            phase_index,
3315            phase_title,
3316            state,
3317            last_tool_name,
3318            last_tool_summary,
3319            result_preview,
3320            extras,
3321            ..
3322        } = &workflow_progress[1]
3323        else {
3324            panic!("second event must be WorkflowAgent, got {:?}", workflow_progress[1]);
3325        };
3326        assert_eq!(*phase_index, Some(1));
3327        assert_eq!(phase_title, &Some("Ping".to_string()));
3328        assert_eq!(state, "done");
3329        assert_eq!(last_tool_name.as_deref(), Some("StructuredOutput"));
3330        assert_eq!(last_tool_summary.as_deref(), Some("pong"));
3331        assert_eq!(result_preview.as_deref(), Some("{\"answer\":\"pong\",\"confidence\":1}"));
3332        assert_eq!(
3333            extras.get("agentId"),
3334            Some(&json!("abf")),
3335            "the entry's unmodelled facts (agentId, model, timings, tokens) stay on the entry",
3336        );
3337        assert_eq!(extras.get("tokens"), Some(&json!(54_707)));
3338        assert_eq!(extras.get("model"), Some(&json!("claude-opus-4-7")));
3339
3340        // And they come back out on the entry they arrived in, not at the
3341        // frame's own level.
3342        let encoded = serde_json::to_value(&Message::TaskProgress {
3343            task_id: "woc6i1sab".to_owned(),
3344            description: String::new(),
3345            usage: TaskUsage {
3346                total_tokens: 0,
3347                tool_uses: 0,
3348                duration_ms: 0,
3349                extras: Extras::new(),
3350            },
3351            uuid: "u".to_owned(),
3352            session_id: "s".to_owned(),
3353            tool_use_id: None,
3354            last_tool_name: None,
3355            workflow_progress: vec![WorkflowProgressEvent::WorkflowAgent {
3356                index: 2,
3357                label: "pong".to_owned(),
3358                phase_index: Some(1),
3359                phase_title: None,
3360                state: "done".to_owned(),
3361                last_tool_name: None,
3362                last_tool_summary: None,
3363                result_preview: None,
3364                extras: extras.clone(),
3365            }],
3366            extras: Extras::new(),
3367        })
3368        .expect("encode");
3369        assert_eq!(encoded["workflow_progress"][0]["agentId"], "abf");
3370        assert_eq!(encoded.get("agentId"), None, "an entry's extras never leak up to the frame");
3371    }
3372
3373    #[test]
3374    fn workflow_task_progress_without_workflow_progress_decodes_with_empty_default() {
3375        // Non-Workflow task_progress events omit the `workflow_progress`
3376        // field; serde default fills with empty vec so the existing
3377        // sub-agent task_progress handler stays untouched.
3378        let raw = json!({
3379            "type": "system",
3380            "subtype": "task_progress",
3381            "task_id": "t-sub",
3382            "description": "subagent halfway",
3383            "usage": {"total_tokens": 10, "tool_uses": 1, "duration_ms": 100},
3384            "uuid": "u-sub",
3385            "session_id": "sess",
3386        });
3387        let msg: Message = serde_json::from_value(raw).expect("decode");
3388        let Message::TaskProgress { workflow_progress, .. } = msg else {
3389            panic!("expected TaskProgress");
3390        };
3391        assert!(workflow_progress.is_empty());
3392    }
3393
3394    #[test]
3395    fn workflow_progress_unknown_type_decodes_as_other() {
3396        // Future CLI may add new workflow event types. Confirm they
3397        // decode as the `Other` variant rather than failing the
3398        // surrounding `task_progress` decode.
3399        let raw = json!({"type": "workflow_future", "anything": 42});
3400        let event: WorkflowProgressEvent = serde_json::from_value(raw).expect("decode");
3401        assert!(matches!(event, WorkflowProgressEvent::Other));
3402    }
3403
3404    #[test]
3405    fn stop_hook_summary_with_zero_hooks_decodes_cleanly() {
3406        // The renderer hides the surface when `actions == 0`. Confirm
3407        // the decoder still produces a clean variant with empty
3408        // `hook_infos`.
3409        let raw = json!({
3410            "type": "system",
3411            "subtype": "stop_hook_summary",
3412            "hasOutput": false,
3413            "hookCount": 0,
3414            "hookErrors": [],
3415            "hookInfos": [],
3416            "level": "suggestion",
3417            "preventedContinuation": false,
3418            "session_id": "session_z",
3419            "stopReason": "",
3420            "toolUseID": "tu-z",
3421            "uuid": "uuid_z",
3422        });
3423        let msg: Message = serde_json::from_value(raw).expect("decode");
3424        let Message::StopHookSummary { actions, hook_infos, .. } = msg else {
3425            panic!("expected StopHookSummary");
3426        };
3427        assert_eq!(actions, 0);
3428        assert!(hook_infos.is_empty());
3429    }
3430
3431    #[test]
3432    fn background_tasks_changed_decodes_as_typed_variant() {
3433        let raw = json!({
3434            "type": "system",
3435            "subtype": "background_tasks_changed",
3436            "tasks": [{
3437                "task_id": "wfm8lm8vx",
3438                "task_type": "local_workflow",
3439                "description": "Fan out two trivial agents returning ping and pong",
3440            }],
3441            "uuid": "145d5225-f94f-411a-b76b-d7bef6506eff",
3442            "session_id": "c35950bc-376e-4c74-be2d-8f31f32c613b",
3443        });
3444        let msg: Message = serde_json::from_value(raw).expect("decode");
3445        let Message::BackgroundTasksChanged { tasks, session_id, .. } = msg else {
3446            panic!("expected BackgroundTasksChanged, got {msg:?}");
3447        };
3448        assert_eq!(tasks.len(), 1);
3449        assert_eq!(tasks[0]["task_id"], "wfm8lm8vx");
3450        assert_eq!(session_id, "c35950bc-376e-4c74-be2d-8f31f32c613b");
3451    }
3452
3453    #[test]
3454    fn commands_changed_decodes_as_typed_variant() {
3455        let raw = json!({
3456            "type": "system",
3457            "subtype": "commands_changed",
3458            "commands": [{"name": "audit", "description": "sweep a codebase"}],
3459            "uuid": "72af9cde-8c41-434d-baea-f2353a66dab8",
3460            "session_id": "35f88fef-f4c4-4c35-bffa-e363335aa2ac",
3461        });
3462        let msg: Message = serde_json::from_value(raw).expect("decode");
3463        let Message::CommandsChanged { commands, .. } = msg else {
3464            panic!("expected CommandsChanged, got {msg:?}");
3465        };
3466        assert_eq!(commands.len(), 1);
3467        assert_eq!(commands[0]["name"], "audit");
3468    }
3469
3470    #[test]
3471    fn hook_started_decodes_as_typed_variant() {
3472        let raw = json!({
3473            "type": "system",
3474            "subtype": "hook_started",
3475            "hook_id": "cc3b0e3e-f894-43dd-9819-e594a4aa4904",
3476            "hook_name": "SessionStart:startup",
3477            "hook_event": "SessionStart",
3478            "uuid": "0a58da42-e1bd-4b87-9d50-711800d43388",
3479            "session_id": "c35950bc-376e-4c74-be2d-8f31f32c613b",
3480        });
3481        let msg: Message = serde_json::from_value(raw).expect("decode");
3482        let Message::HookStarted { hook_name, hook_event, hook_id, .. } = msg else {
3483            panic!("expected HookStarted, got {msg:?}");
3484        };
3485        assert_eq!(hook_name, "SessionStart:startup");
3486        assert_eq!(hook_event, "SessionStart");
3487        assert_eq!(hook_id, "cc3b0e3e-f894-43dd-9819-e594a4aa4904");
3488    }
3489
3490    #[test]
3491    fn hook_response_decodes_as_typed_variant() {
3492        let raw = json!({
3493            "type": "system",
3494            "subtype": "hook_response",
3495            "hook_id": "946738fb-ca47-4c50-861f-d763fd50eab9",
3496            "hook_name": "SessionStart:startup",
3497            "hook_event": "SessionStart",
3498            "output": "index body",
3499            "outcome": "success",
3500            "exit_code": 0,
3501            "stderr": "",
3502            "stdout": "index body",
3503            "uuid": "d6867063-339c-469d-803d-169cddc5b4eb",
3504            "session_id": "c35950bc-376e-4c74-be2d-8f31f32c613b",
3505        });
3506        let msg: Message = serde_json::from_value(raw).expect("decode");
3507        let Message::HookResponse { outcome, exit_code, stdout, .. } = msg else {
3508            panic!("expected HookResponse, got {msg:?}");
3509        };
3510        assert_eq!(outcome, "success");
3511        assert_eq!(exit_code, 0);
3512        assert_eq!(stdout, "index body");
3513    }
3514
3515    #[test]
3516    fn hook_progress_decodes_as_typed_variant() {
3517        let raw = json!({
3518            "type": "system",
3519            "subtype": "hook_progress",
3520            "hook_id": "012697b9-e191-42e6-9385-cee11f7a17d3",
3521            "hook_name": "SessionStart:startup",
3522            "hook_event": "SessionStart",
3523            "stdout": "{\"async\": true}",
3524            "stderr": "",
3525            "output": "{\"async\": true}",
3526            "uuid": "28d6a071-ae36-4cb8-bbdb-86686acea753",
3527            "session_id": "e30daa8a-1702-4afd-8379-cab1d235935e",
3528        });
3529        let msg: Message = serde_json::from_value(raw).expect("decode");
3530        let Message::HookProgress { hook_id, hook_name, hook_event, stdout, .. } = msg else {
3531            panic!("expected HookProgress, got {msg:?}");
3532        };
3533        assert_eq!(hook_id, "012697b9-e191-42e6-9385-cee11f7a17d3");
3534        assert_eq!(hook_name, "SessionStart:startup");
3535        assert_eq!(hook_event, "SessionStart");
3536        assert_eq!(stdout, "{\"async\": true}");
3537    }
3538
3539    #[test]
3540    fn permission_denied_decodes_as_typed_variant() {
3541        // Shape first observed on 2.1.280.
3542        let raw = json!({
3543            "type": "system",
3544            "subtype": "permission_denied",
3545            "tool_name": "Bash",
3546            "tool_use_id": "toolu_01FCzjj9ZBDd3HA6HF1LaBGE",
3547            "decision_reason_type": "other",
3548            "decision_reason": "Contains simple_expansion",
3549            "message": "Contains simple_expansion",
3550            "uuid": "8c7161ee-b318-485e-b733-92cea085ac5b",
3551            "session_id": "4513c0dc-06a8-4c91-add5-9fdad5d00783",
3552        });
3553        let msg: Message = serde_json::from_value(raw).expect("decode");
3554        let Message::PermissionDenied { tool_name, tool_use_id, decision_reason, .. } = msg else {
3555            panic!("expected PermissionDenied, got {msg:?}");
3556        };
3557        assert_eq!(tool_name, "Bash");
3558        assert_eq!(tool_use_id, "toolu_01FCzjj9ZBDd3HA6HF1LaBGE");
3559        assert_eq!(decision_reason.as_deref(), Some("Contains simple_expansion"));
3560    }
3561
3562    #[test]
3563    fn a_denial_missing_its_optional_fields_still_decodes_typed() {
3564        // A `subcommandResults` refusal names the decision class and no
3565        // token, and carries the agent a sub-agent's refusal belongs to -
3566        // measured on 2.1.280. Requiring the token dropped the whole frame to
3567        // the generic system bucket, where nothing could read its fields.
3568        let raw = json!({
3569            "type": "system",
3570            "subtype": "permission_denied",
3571            "tool_name": "Bash",
3572            "tool_use_id": "call_ea708f6c475448e193a787b0",
3573            "agent_id": "a942e0767f027631a",
3574            "decision_reason_type": "subcommandResults",
3575            "message": "Permission to use Bash with command rm -rf /tmp/x has been denied.",
3576            "uuid": "5b03282c-f2fe-4795-93a8-b56f61822725",
3577            "session_id": "f1492219-e1a5-4c39-8cec-ff692498cbc8",
3578        });
3579        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
3580        let Message::PermissionDenied { decision_reason, extras, .. } = msg else {
3581            panic!("expected PermissionDenied, got {msg:?}");
3582        };
3583        assert_eq!(decision_reason, None, "the token is absent, and absent is not a failure");
3584        assert_eq!(
3585            extras.get("agent_id").and_then(serde_json::Value::as_str),
3586            Some("a942e0767f027631a"),
3587            "and the agent stays reachable through the unmodelled fields",
3588        );
3589        assert_eq!(
3590            serde_json::to_value(serde_json::from_value::<Message>(raw).expect("decode"))
3591                .expect("encode"),
3592            json!({
3593                "type": "system",
3594                "subtype": "permission_denied",
3595                "tool_name": "Bash",
3596                "tool_use_id": "call_ea708f6c475448e193a787b0",
3597                "agent_id": "a942e0767f027631a",
3598                "decision_reason_type": "subcommandResults",
3599                "message": "Permission to use Bash with command rm -rf /tmp/x has been denied.",
3600                "uuid": "5b03282c-f2fe-4795-93a8-b56f61822725",
3601                "session_id": "f1492219-e1a5-4c39-8cec-ff692498cbc8",
3602            }),
3603            "and it round-trips byte for byte: no key the CLI omitted is invented",
3604        );
3605
3606        // **`decision_reason_type` is optional in the schema, not yet in a
3607        // measured frame.** No capture omits it, so this half pins the schema
3608        // rather than a reproduction: a refusal without it must still be the
3609        // typed frame, or its agent is unreadable and a sub-agent's refusal
3610        // falls back to the session's level.
3611        let bare = json!({
3612            "type": "system",
3613            "subtype": "permission_denied",
3614            "tool_name": "Bash",
3615            "tool_use_id": "call_bare",
3616            "agent_id": "a942e0767f027631a",
3617            "message": "Permission to use Bash has been denied.",
3618            "uuid": "u-bare",
3619            "session_id": "s",
3620        });
3621        let msg: Message = serde_json::from_value(bare.clone()).expect("decode");
3622        assert!(
3623            matches!(
3624                &msg,
3625                Message::PermissionDenied { decision_reason_type: None, extras, .. }
3626                    if extras.get("agent_id").and_then(serde_json::Value::as_str)
3627                        == Some("a942e0767f027631a")
3628            ),
3629            "the decision class is optional too, and the agent stays reachable: {msg:?}",
3630        );
3631        assert_eq!(
3632            serde_json::to_value(&msg).expect("encode"),
3633            bare,
3634            "and the frame it re-emits is the frame it was given",
3635        );
3636    }
3637
3638    #[test]
3639    fn workflow_task_progress_agent_without_phase_decodes_typed() {
3640        // Agent entries can arrive without `phaseIndex`/`phaseTitle`
3641        // (observed outside the pinned corpus). The entry must still
3642        // decode as a typed WorkflowAgent, not fall to the generic
3643        // system bucket.
3644        let raw = json!({
3645            "type": "system",
3646            "subtype": "task_progress",
3647            "task_id": "wq8nlqkoi",
3648            "tool_use_id": "call_17ac8f53355f48eba45c1a39",
3649            "description": "ping",
3650            "usage": {"total_tokens": 0, "tool_uses": 0, "duration_ms": 27},
3651            "last_tool_name": "ping",
3652            "summary": "Two trivial agents in parallel",
3653            "workflow_progress": [
3654                {
3655                    "type": "workflow_agent",
3656                    "index": 1,
3657                    "label": "ping",
3658                    "agentId": "a05487a5d7a14d1db",
3659                    "model": "claude-opus-4-8",
3660                    "state": "start",
3661                    "startedAt": 1_788_848_411_456_u64,
3662                    "queuedAt": 1_788_848_411_455_u64,
3663                    "attempt": 1,
3664                    "promptPreview": "Reply with the single word ping.",
3665                    "lastProgressAt": 1_788_848_411_456_u64,
3666                },
3667            ],
3668            "uuid": "uuid_a",
3669            "session_id": "session_a",
3670        });
3671        let msg: Message = serde_json::from_value(raw).expect("decode");
3672        let Message::TaskProgress { workflow_progress, .. } = msg else {
3673            panic!("expected TaskProgress, got {msg:?}");
3674        };
3675        let WorkflowProgressEvent::WorkflowAgent { phase_index, phase_title, state, .. } =
3676            &workflow_progress[0]
3677        else {
3678            panic!("expected WorkflowAgent, got {:?}", workflow_progress[0]);
3679        };
3680        assert_eq!(*phase_index, None);
3681        assert_eq!(phase_title, &None);
3682        assert_eq!(state, "start");
3683    }
3684
3685    #[test]
3686    fn notification_decodes_as_typed_variant() {
3687        let raw = json!({
3688            "type": "system",
3689            "subtype": "notification",
3690            "key": "stop-hook-error",
3691            "text": "Stop hook error occurred \u{b7} ctrl+o to see",
3692            "priority": "immediate",
3693            "session_id": "e0aed9bb-b0de-43d5-8ded-4c5c1778f0fd",
3694            "uuid": "9d893480-969b-4f02-9ce6-666f87184fa0",
3695        });
3696        let msg: Message = serde_json::from_value(raw).expect("decode");
3697        let Message::Notification { key, text, priority, .. } = msg else {
3698            panic!("expected Notification, got {msg:?}");
3699        };
3700        assert_eq!(key.as_deref(), Some("stop-hook-error"));
3701        assert_eq!(text, "Stop hook error occurred \u{b7} ctrl+o to see");
3702        assert_eq!(priority.as_deref(), Some("immediate"));
3703    }
3704
3705    #[test]
3706    fn status_subtype_stays_generic_system() {
3707        // `status` is heterogeneous (compacting / compact-result /
3708        // permissionMode); it deliberately stays on the generic
3709        // catch-all rather than a typed variant.
3710        let raw = json!({
3711            "type": "system",
3712            "subtype": "status",
3713            "status": null,
3714            "permissionMode": "plan",
3715            "uuid": "c77b2ac7-825f-4fb6-a7e5-3d6fd940b293",
3716            "session_id": "2b5e2c9f-70c7-4df5-81fa-1b5507ce96a4",
3717        });
3718        let msg: Message = serde_json::from_value(raw).expect("decode");
3719        let Message::System { subtype, data, .. } = msg else {
3720            panic!("expected generic System, got {msg:?}");
3721        };
3722        assert_eq!(subtype, "status");
3723        assert_eq!(data["permissionMode"], "plan");
3724    }
3725
3726    /// The CLI's `task_started` carries four fields forge models nothing
3727    /// of: the sub-agent's TYPE, whether the CLI backgrounded it, the
3728    /// dispatch depth, and the prompt. A client folding the raw frame
3729    /// needs all four, and the socket carries only what the decode kept.
3730    /// Line verbatim from the live parallel-dispatch capture. No modelled
3731    /// field here is optional-with-a-null, so the whole frame pins as
3732    /// parse-equality rather than key by key.
3733    #[test]
3734    fn a_task_started_frame_keeps_the_fields_no_view_reads_yet() {
3735        let raw = json!({
3736            "type": "system",
3737            "subtype": "task_started",
3738            "task_id": "aef3c170a790e8ca7",
3739            "tool_use_id": "call_b7855529e53e4747bd1f5747",
3740            "description": "alpha-work",
3741            "subagent_type": "general-purpose",
3742            "is_backgrounded": false,
3743            "spawn_depth": 1,
3744            "task_type": "local_agent",
3745            "prompt": "First write one short sentence of setup text.",
3746            "uuid": "af3bfd20-5ae3-48f5-b092-be3fb3017e79",
3747            "session_id": "ee9485a0-93c8-46c7-940d-3acaa74c60cf",
3748        });
3749        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
3750        let Message::TaskStarted { task_id, tool_use_id, .. } = &msg else {
3751            panic!("expected TaskStarted, got {msg:?}");
3752        };
3753        assert_eq!(task_id, "aef3c170a790e8ca7", "the modelled fields still decode");
3754        assert_eq!(
3755            tool_use_id.as_deref(),
3756            Some("call_b7855529e53e4747bd1f5747"),
3757            "and the dispatch link stays the link",
3758        );
3759
3760        let encoded = serde_json::to_value(&msg).expect("encode");
3761        assert_eq!(
3762            encoded, raw,
3763            "every field the CLI sent must survive decode and re-serialization",
3764        );
3765    }
3766
3767    /// Every frame a sub-agent produces is stamped with the agent type and
3768    /// the dispatch's description, and the frame carries the CLI's request
3769    /// id. None is modelled. The nested envelope and usage carry keys forge
3770    /// does not read either, and the tool_use block carries `caller` -
3771    /// all verbatim from the live capture.
3772    #[test]
3773    fn a_subagent_inner_assistant_frame_keeps_its_type_description_and_nested_extra() {
3774        let raw = json!({
3775            "type": "assistant",
3776            "message": {
3777                "id": "msg_011CfLJAJ6Ks5wkwmWpki9J9",
3778                "role": "assistant",
3779                "model": "claude-opus-5",
3780                "provider": "firstParty",
3781                "container": null,
3782                "content": [{
3783                    "type": "tool_use",
3784                    "id": "toolu_01PRrDjCCJb6F7rS3TQXzWch",
3785                    "name": "Bash",
3786                    "input": {"command": "echo forge-subagent-ok"},
3787                    "caller": {"type": "direct"},
3788                }],
3789                "stop_reason": "tool_use",
3790                "usage": {
3791                    "input_tokens": 2,
3792                    "output_tokens": 31,
3793                    "service_tier": "standard",
3794                    "inference_geo": "not_available",
3795                },
3796            },
3797            "parent_tool_use_id": "toolu_017brkbqcpsQAmHTWV6unGpQ",
3798            "session_id": "b84b585a-da00-4eb8-827d-935aacfef568",
3799            "uuid": "0e72be3a-647d-4e4b-bf27-6a91a6db5ee6",
3800            "timestamp": "2026-09-23T11:31:44.125Z",
3801            "request_id": "req_011CfL5GYC4ALgjAdC1ekWhH",
3802            "subagent_type": "general-purpose",
3803            "task_description": "Run echo command",
3804        });
3805        let msg: Message = serde_json::from_value(raw).expect("decode");
3806        let Message::Assistant { parent_tool_use_id, .. } = &msg else {
3807            panic!("expected Assistant, got {msg:?}");
3808        };
3809        assert_eq!(
3810            parent_tool_use_id.as_deref(),
3811            Some("toolu_017brkbqcpsQAmHTWV6unGpQ"),
3812            "the parent link is modelled and stays",
3813        );
3814
3815        let encoded = serde_json::to_value(&msg).expect("encode");
3816        assert_eq!(encoded["subagent_type"], "general-purpose", "the agent type survives");
3817        assert_eq!(encoded["task_description"], "Run echo command", "the dispatch's name survives");
3818        assert_eq!(
3819            encoded["request_id"], "req_011CfL5GYC4ALgjAdC1ekWhH",
3820            "the request id survives"
3821        );
3822        assert_eq!(
3823            encoded["message"]["provider"], "firstParty",
3824            "an unmodelled envelope key survives"
3825        );
3826        assert_eq!(encoded["message"]["container"], Value::Null, "including an explicit null");
3827        assert_eq!(
3828            encoded["message"]["usage"]["service_tier"], "standard",
3829            "and the usage detail survives beside the counters forge counts",
3830        );
3831        assert_eq!(
3832            encoded["message"]["content"][0]["caller"]["type"], "direct",
3833            "and an unmodelled key inside the content block's own object survives",
3834        );
3835    }
3836
3837    /// The sub-agent's own user frames carry the same two stamps. The
3838    /// tool_result block inside is what the parent dispatch body answers
3839    /// with, and its content is already an opaque value.
3840    #[test]
3841    fn a_subagent_inner_user_frame_keeps_its_type_and_description() {
3842        let raw = json!({
3843            "type": "user",
3844            "message": {
3845                "role": "user",
3846                "content": [{
3847                    "tool_use_id": "toolu_01PRrDjCCJb6F7rS3TQXzWch",
3848                    "type": "tool_result",
3849                    "content": "forge-subagent-ok",
3850                    "is_error": false,
3851                }],
3852            },
3853            "parent_tool_use_id": "toolu_017brkbqcpsQAmHTWV6unGpQ",
3854            "session_id": "b84b585a-da00-4eb8-827d-935aacfef568",
3855            "uuid": "83f5bfa9-1510-4bbe-a0db-d3a0ceb54f12",
3856            "timestamp": "2026-09-23T11:31:46.071Z",
3857            "subagent_type": "general-purpose",
3858            "task_description": "Run echo command",
3859        });
3860        let msg: Message = serde_json::from_value(raw).expect("decode");
3861        let Message::User { parent_tool_use_id, .. } = &msg else {
3862            panic!("expected User, got {msg:?}");
3863        };
3864        assert_eq!(parent_tool_use_id.as_deref(), Some("toolu_017brkbqcpsQAmHTWV6unGpQ"));
3865
3866        let encoded = serde_json::to_value(&msg).expect("encode");
3867        assert_eq!(encoded["subagent_type"], "general-purpose", "the agent type survives");
3868        assert_eq!(encoded["task_description"], "Run echo command", "the dispatch's name survives");
3869        assert_eq!(
3870            encoded["message"]["content"][0]["content"], "forge-subagent-ok",
3871            "and the inner result stays whole",
3872        );
3873    }
3874
3875    /// A turn's `result` carries the CLI's own roll-up of the sub-agent
3876    /// work it ran, and latency fields forge does not model. Neither has a
3877    /// reader today; both must cross so a client can fold them.
3878    #[test]
3879    fn a_result_frame_keeps_the_subagent_rollup_and_latency_fields() {
3880        let raw = json!({
3881            "type": "result",
3882            "subtype": "success",
3883            "is_error": false,
3884            "num_turns": 3,
3885            "duration_ms": 12000,
3886            "duration_api_ms": 9000,
3887            "result": "done",
3888            "session_id": "ee9485a0-93c8-46c7-940d-3acaa74c60cf",
3889            "subagent_stats": {
3890                "spawned": 2,
3891                "requested": {"background": 1, "foreground": 1, "unset": 0},
3892                "started_in_background": 1,
3893                "max_depth": 1,
3894                "spawned_by_subagents": 0,
3895                "completed": 2,
3896                "failed": 0,
3897                "killed": {"parent": 0, "user": 0, "system": 0},
3898                "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0},
3899                "by_type": {"general-purpose": 2},
3900            },
3901            "ttft_ms": 1200,
3902            "queued_turn_count": 0,
3903        });
3904        let msg: Message = serde_json::from_value(raw).expect("decode");
3905        let Message::Result { num_turns, .. } = &msg else {
3906            panic!("expected Result, got {msg:?}");
3907        };
3908        assert_eq!(*num_turns, 3, "the modelled fields still decode");
3909
3910        let encoded = serde_json::to_value(&msg).expect("encode");
3911        assert_eq!(encoded["subagent_stats"]["spawned"], 2, "the roll-up survives");
3912        assert_eq!(
3913            encoded["subagent_stats"]["by_type"]["general-purpose"], 2,
3914            "including the per-type tally",
3915        );
3916        assert_eq!(encoded["ttft_ms"], 1200, "and the latency field survives");
3917    }
3918
3919    /// One frame per remaining category, each built with a key this build
3920    /// does not model, so every repr the decode touches has at least one
3921    /// round-trip pinning that its extras cross.
3922    #[test]
3923    fn every_frame_category_keeps_an_unmodelled_key() {
3924        let cases = [
3925            (
3926                json!({
3927                    "type": "rate_limit_event",
3928                    "rate_limit_info": {"status": "allowed", "utilization": 0.25},
3929                    "uuid": "rl-1",
3930                    "session_id": "s",
3931                    "window_started_at": 1_790_000_000,
3932                }),
3933                "window_started_at",
3934            ),
3935            (
3936                json!({
3937                    "type": "command_lifecycle",
3938                    "command_uuid": "c-1",
3939                    "state": "queued",
3940                    "uuid": "cl-1",
3941                    "session_id": "s",
3942                    "queue_position": 2,
3943                }),
3944                "queue_position",
3945            ),
3946            (
3947                json!({
3948                    "type": "stream_event",
3949                    "uuid": "se-1",
3950                    "session_id": "s",
3951                    "event": {"type": "message_start"},
3952                    "parent_tool_use_id": "toolu_dispatch",
3953                    "ttl_ms": 30_000,
3954                }),
3955                "ttl_ms",
3956            ),
3957            (json!({"type": "error", "error": "pipe broken", "recoverable": false}), "recoverable"),
3958        ];
3959        for (raw, extra) in cases {
3960            let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
3961            let encoded = serde_json::to_value(&msg).expect("encode");
3962            assert_eq!(
3963                encoded.get(extra),
3964                raw.get(extra),
3965                "{extra} must survive decode and re-serialization for {}",
3966                raw["type"],
3967            );
3968        }
3969    }
3970
3971    /// The nested values a view folds numbers out of keep their own
3972    /// unmodelled keys too: a task's usage block and a task patch are
3973    /// re-serialized under the same object they arrived in.
3974    #[test]
3975    fn a_task_usage_and_patch_keep_their_unmodelled_keys() {
3976        let raw = json!({
3977            "type": "system",
3978            "subtype": "task_progress",
3979            "task_id": "t-1",
3980            "description": "d",
3981            "usage": {"total_tokens": 1, "tool_uses": 2, "duration_ms": 3, "paused_ms": 4},
3982            "uuid": "u-1",
3983            "session_id": "s",
3984        });
3985        let msg: Message = serde_json::from_value(raw).expect("decode");
3986        let encoded = serde_json::to_value(&msg).expect("encode");
3987        assert_eq!(
3988            encoded["usage"]["paused_ms"], 4,
3989            "an unmodelled usage counter crosses under its own object",
3990        );
3991
3992        let raw = json!({
3993            "type": "system",
3994            "subtype": "task_updated",
3995            "task_id": "t-1",
3996            "patch": {"status": "completed", "end_time": 1, "paused_ms": 4},
3997            "uuid": "u-2",
3998            "session_id": "s",
3999        });
4000        let msg: Message = serde_json::from_value(raw).expect("decode");
4001        let encoded = serde_json::to_value(&msg).expect("encode");
4002        assert_eq!(
4003            encoded["patch"]["paused_ms"], 4,
4004            "and an unmodelled patch key crosses the same way",
4005        );
4006    }
4007
4008    /// `task_progress` re-fires per tool the sub-agent calls, stamped with
4009    /// the same agent type. The usage counters and last tool name are
4010    /// modelled; the type is not.
4011    #[test]
4012    fn a_task_progress_frame_keeps_the_subagent_type() {
4013        let raw = json!({
4014            "type": "system",
4015            "subtype": "task_progress",
4016            "task_id": "aef3c170a790e8ca7",
4017            "tool_use_id": "call_b7855529e53e4747bd1f5747",
4018            "description": "Running Echo alpha-one",
4019            "subagent_type": "general-purpose",
4020            "usage": {"total_tokens": 10314, "tool_uses": 1, "duration_ms": 1669},
4021            "last_tool_name": "Bash",
4022            "uuid": "c8128321-b588-45d3-9b69-23cb638345ce",
4023            "session_id": "ee9485a0-93c8-46c7-940d-3acaa74c60cf",
4024        });
4025        let msg: Message = serde_json::from_value(raw).expect("decode");
4026        let Message::TaskProgress { usage, last_tool_name, .. } = &msg else {
4027            panic!("expected TaskProgress, got {msg:?}");
4028        };
4029        assert_eq!(usage.tool_uses, 1, "the modelled usage still decodes");
4030        assert_eq!(last_tool_name.as_deref(), Some("Bash"));
4031
4032        let encoded = serde_json::to_value(&msg).expect("encode");
4033        assert_eq!(encoded["subagent_type"], "general-purpose", "the agent type survives");
4034    }
4035}
4036
4037#[cfg(test)]
4038mod tests_stamp {
4039    use serde_json::json;
4040
4041    use super::Message;
4042
4043    /// The frames a turn runs as, in the shapes the CLI sends and the shapes
4044    /// a transcript read synthesizes, each with the stamp it must carry once
4045    /// the fallback has run.
4046    ///
4047    /// **One kind per stamp slot**, since the slot is what the rule decides:
4048    /// the typed field (user, assistant), the verbatim payload (a generic
4049    /// system subtype) and the extras every typed variant re-emits - the
4050    /// counter frame a running turn is full of, the hook summary, the
4051    /// boundary, and the result. The last two entries are the other half of
4052    /// the rule: a frame that already carries a stamp keeps it whichever slot
4053    /// the stamp sits in.
4054    fn kinds() -> Vec<(&'static str, serde_json::Value, &'static str)> {
4055        vec![
4056            (
4057                "user",
4058                json!({
4059                    "type": "user",
4060                    "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
4061                    "session_id": "s1",
4062                }),
4063                AT,
4064            ),
4065            (
4066                "assistant",
4067                json!({
4068                    "type": "assistant",
4069                    "uuid": "a1",
4070                    "timestamp": "2026-10-15T05:18:15.544Z",
4071                    "session_id": "s1",
4072                    "message": {
4073                        "id": "m1",
4074                        "role": "assistant",
4075                        "model": "claude-opus-5",
4076                        "content": [{"type": "text", "text": "working"}],
4077                    },
4078                }),
4079                "2026-10-15T05:18:15.544Z",
4080            ),
4081            (
4082                "system",
4083                json!({
4084                    "type": "system",
4085                    "subtype": "hook_drift_probe",
4086                    "session_id": "s1",
4087                    "detail": "an unmodelled subtype",
4088                }),
4089                AT,
4090            ),
4091            (
4092                "system the sender stamped",
4093                json!({
4094                    "type": "system",
4095                    "subtype": "hook_drift_probe",
4096                    "session_id": "s1",
4097                    "detail": "an unmodelled subtype",
4098                    "timestamp": "2026-10-15T05:19:00.100Z",
4099                }),
4100                "2026-10-15T05:19:00.100Z",
4101            ),
4102            (
4103                "thinking_tokens",
4104                json!({
4105                    "type": "system",
4106                    "subtype": "thinking_tokens",
4107                    "estimated_tokens": 40,
4108                    "estimated_tokens_delta": 40,
4109                    "uuid": "t1",
4110                    "session_id": "s1",
4111                    "user_message_uuid": "u1",
4112                }),
4113                AT,
4114            ),
4115            (
4116                "stop_hook_summary",
4117                json!({
4118                    "type": "system",
4119                    "subtype": "stop_hook_summary",
4120                    "hookCount": 1,
4121                    "hookInfos": [],
4122                    "hookErrors": [],
4123                    "hasOutput": false,
4124                    "level": "suggestion",
4125                    "preventedContinuation": false,
4126                    "stopReason": "",
4127                    "toolUseID": "tu1",
4128                    "uuid": "h1",
4129                    "session_id": "s1",
4130                }),
4131                AT,
4132            ),
4133            (
4134                "compact_boundary",
4135                json!({
4136                    "type": "system",
4137                    "subtype": "compact_boundary",
4138                    "uuid": "b1",
4139                    "session_id": "s1",
4140                    "compact_metadata": {"trigger": "auto", "pre_tokens": 10, "post_tokens": 2},
4141                }),
4142                AT,
4143            ),
4144            (
4145                "result",
4146                json!({
4147                    "type": "result",
4148                    "subtype": "success",
4149                    "session_id": "s1",
4150                    "is_error": false,
4151                    "num_turns": 1,
4152                    "duration_ms": 12000,
4153                    "duration_api_ms": 9000,
4154                }),
4155                AT,
4156            ),
4157            (
4158                "result the sender stamped",
4159                json!({
4160                    "type": "result",
4161                    "subtype": "success",
4162                    "session_id": "s1",
4163                    "is_error": false,
4164                    "num_turns": 1,
4165                    "duration_ms": 12000,
4166                    "duration_api_ms": 9000,
4167                    "timestamp": "2026-10-15T05:19:40.632Z",
4168                }),
4169                "2026-10-15T05:19:40.632Z",
4170            ),
4171        ]
4172    }
4173
4174    const AT: &str = "2026-10-15T05:18:05.217Z";
4175
4176    /// **Every kind a turn holds comes off the wire with a stamp a client can
4177    /// read at the frame's top level** - and a frame that already carries one
4178    /// keeps it, because a receipt is a fallback and never a correction.
4179    ///
4180    /// The client's fold anchors a running turn's clock on the first frame
4181    /// that carries one, so a kind that cannot hold a stamp is a turn whose
4182    /// clock starts at the wrong row - or at no row at all.
4183    #[test]
4184    fn every_kind_carries_a_stamp_at_the_top_level_it_is_read_from() {
4185        for (kind, raw, want) in kinds() {
4186            let mut msg: Message = serde_json::from_value(raw).expect("decode");
4187            msg.stamp_if_absent(AT);
4188
4189            let encoded = serde_json::to_value(&msg).expect("encode");
4190            assert_eq!(
4191                encoded["timestamp"].as_str(),
4192                Some(want),
4193                "{kind} must carry its stamp where a reader finds it",
4194            );
4195        }
4196    }
4197
4198    /// One frame stamped twice keeps the first stamp: the CLI's own is the
4199    /// frame's, and forge's receipt only stands in where there is none.
4200    #[test]
4201    fn a_second_stamp_never_overwrites_the_first() {
4202        let mut msg: Message = serde_json::from_value(json!({
4203            "type": "user",
4204            "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
4205            "session_id": "s1",
4206        }))
4207        .expect("decode");
4208
4209        msg.stamp_if_absent(AT);
4210        msg.stamp_if_absent("2026-10-15T06:00:00.000Z");
4211
4212        let encoded = serde_json::to_value(&msg).expect("encode");
4213        assert_eq!(
4214            encoded["timestamp"].as_str(),
4215            Some(AT),
4216            "the first stamp is the row's own; a later one does not walk it forward",
4217        );
4218    }
4219
4220    /// The commit and merge frames below are verbatim from the committed
4221    /// `vcs_state_changed` baseline. **`branch` is absent on merge and
4222    /// rebase**, measured on the capture - a fold that required it would
4223    /// refuse two of the four kinds.
4224    #[test]
4225    fn vcs_state_changed_decodes_every_captured_kind() {
4226        let commit = json!({
4227            "type": "system",
4228            "subtype": "vcs_state_changed",
4229            "kind": "commit",
4230            "branch": "main",
4231            "cwd": "/tmp/repo",
4232            "uuid": "u1",
4233            "session_id": "s1",
4234        });
4235        let msg: Message = serde_json::from_value(commit.clone()).expect("decode");
4236        let Message::VcsStateChanged { kind, branch, cwd, uuid, session_id, .. } = msg else {
4237            panic!("expected VcsStateChanged, got {msg:?}");
4238        };
4239        assert_eq!(kind, "commit");
4240        assert_eq!(branch.as_deref(), Some("main"));
4241        assert_eq!(cwd, "/tmp/repo");
4242        assert_eq!(uuid, "u1");
4243        assert_eq!(session_id, "s1");
4244
4245        let merged = json!({
4246            "type": "system",
4247            "subtype": "vcs_state_changed",
4248            "kind": "merge",
4249            "cwd": "/tmp/repo",
4250            "uuid": "u2",
4251            "session_id": "s1",
4252        });
4253        let msg: Message = serde_json::from_value(merged).expect("decode");
4254        let Message::VcsStateChanged { kind, branch, .. } = msg else {
4255            panic!("a branchless mutation still reaches the typed variant");
4256        };
4257        assert_eq!(kind, "merge");
4258        assert_eq!(branch, None);
4259    }
4260
4261    /// Verbatim from the committed `code_change_published` baseline.
4262    #[test]
4263    fn code_change_published_decodes_the_captured_frame() {
4264        let raw = json!({
4265            "type": "system",
4266            "subtype": "code_change_published",
4267            "provider": "github",
4268            "url": "https://github.com/example/example/pull/123",
4269            "repo": "example/example",
4270            "identifier": "123",
4271            "action": "created",
4272            "branch": "main",
4273            "uuid": "u1",
4274            "session_id": "s1",
4275        });
4276        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
4277        let Message::CodeChangePublished {
4278            provider, url, repo, identifier, action, branch, ..
4279        } = &msg
4280        else {
4281            panic!("expected CodeChangePublished, got {msg:?}");
4282        };
4283        assert_eq!(provider, "github");
4284        assert_eq!(url, "https://github.com/example/example/pull/123");
4285        assert_eq!(repo, "example/example");
4286        assert_eq!(identifier, "123");
4287        assert_eq!(action.as_deref(), Some("created"));
4288        assert_eq!(branch.as_deref(), Some("main"));
4289
4290        // A decode that keeps only what this build reads is a decode that
4291        // drops CLI data: both frames go back out byte-identical.
4292        let encoded = serde_json::to_value(&msg).expect("encode");
4293        assert_eq!(encoded, raw);
4294    }
4295
4296    /// The CLI calls both `kind` and `provider` open sets, and `action`
4297    /// carries verbs this build has no arm for. None of the three may fail a
4298    /// decode or be replaced by a catch-all marker.
4299    #[test]
4300    fn repository_frames_keep_values_this_build_does_not_know() {
4301        let raw = json!({
4302            "type": "system",
4303            "subtype": "vcs_state_changed",
4304            "kind": "worktree-moved",
4305            "branch": "topic",
4306            "cwd": "/tmp/repo",
4307            "uuid": "u1",
4308            "session_id": "s1",
4309        });
4310        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
4311        let Message::VcsStateChanged { kind, .. } = &msg else {
4312            panic!("expected VcsStateChanged, got {msg:?}");
4313        };
4314        assert_eq!(kind, "worktree-moved", "an unknown kind is carried, not refused");
4315        assert_eq!(serde_json::to_value(&msg).expect("encode"), raw);
4316
4317        let raw = json!({
4318            "type": "system",
4319            "subtype": "code_change_published",
4320            "provider": "some-new-forge",
4321            "url": "https://example.test/change/9",
4322            "repo": "a/b",
4323            "identifier": "9",
4324            "action": "auto-merge-enabled",
4325            "uuid": "u2",
4326            "session_id": "s1",
4327        });
4328        let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
4329        let Message::CodeChangePublished { provider, action, .. } = &msg else {
4330            panic!("expected CodeChangePublished, got {msg:?}");
4331        };
4332        assert_eq!(provider, "some-new-forge");
4333        assert_eq!(action.as_deref(), Some("auto-merge-enabled"));
4334        assert_eq!(serde_json::to_value(&msg).expect("encode"), raw);
4335    }
4336}