Skip to main content

claude_codes/io/
result.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::fmt;
4
5/// Result message for completed queries
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ResultMessage {
8    pub subtype: ResultSubtype,
9    pub is_error: bool,
10    pub duration_ms: u64,
11    pub duration_api_ms: u64,
12
13    /// Time to first token, in milliseconds.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub ttft_ms: Option<u64>,
16
17    /// Time to first streamed token, in milliseconds.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub ttft_stream_ms: Option<u64>,
20
21    /// Time from session start until the first request was issued, in milliseconds.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub time_to_request_ms: Option<u64>,
24
25    /// Time from spawning a worker/spare until the first request was issued, in milliseconds.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub time_to_request_from_spawn_ms: Option<u64>,
28
29    /// Whether a warm spare process was claimed for this request.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub warm_spare_claimed: Option<bool>,
32
33    /// Epoch-ish timestamp origin used by CLI timing instrumentation.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub time_origin_ms: Option<u64>,
36
37    /// Wall-clock epoch milliseconds when the API request was sent
38    /// (fractional; from CLI timing instrumentation).
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub request_sent_wall_ms: Option<f64>,
41
42    /// Time until the first content frame of the stream arrived, in
43    /// milliseconds (CLI 2.1.260+ timing instrumentation).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub first_content_frame_ms: Option<u64>,
46
47    /// Time until the first stream POST was issued, in milliseconds
48    /// (CLI 2.1.260+).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub first_stream_post_ms: Option<u64>,
51
52    /// Time until the first stream POST was acknowledged, in milliseconds
53    /// (CLI 2.1.260+).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub first_stream_post_ack_ms: Option<u64>,
56
57    /// Time the first stream POST spent waiting in the outbound queue, in
58    /// milliseconds (CLI 2.1.278+).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub first_stream_post_queue_wait_ms: Option<u64>,
61
62    /// What the first stream POST was queued behind (CLI 2.1.278+).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub first_stream_post_queued_behind: Option<StreamPostQueuedBehind>,
65
66    /// Wall-clock epoch milliseconds when the first stream POST was issued
67    /// (fractional; CLI 2.1.260+).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub first_stream_post_wall_ms: Option<f64>,
70
71    /// Wall-clock epoch milliseconds when the triggering send's frame arrived
72    /// on a session reading its input from the session server's SSE stream
73    /// (`--sdk-url`), before the input loop read it (CLI 2.1.278+). With
74    /// [`frame_enqueued_wall_ms`](Self::frame_enqueued_wall_ms) and
75    /// [`turn_started_wall_ms`](Self::turn_started_wall_ms) it splits the
76    /// stretch between the server's persist and `request_sent_wall_ms` into
77    /// transit, the input loop's handling of the frame, its wait on the
78    /// command queue, and the turn's own work. Present only together with
79    /// `request_sent_wall_ms`, and only when the transport recorded the
80    /// receipt; absent on a local stdin host.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub frame_received_wall_ms: Option<f64>,
83
84    /// Wall-clock epoch milliseconds when the input loop put the triggering
85    /// send's frame on the command queue (CLI 2.1.278+). Present exactly
86    /// when [`frame_received_wall_ms`](Self::frame_received_wall_ms) is.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub frame_enqueued_wall_ms: Option<f64>,
89
90    /// How the triggering send's frame spent the time between
91    /// [`frame_received_wall_ms`](Self::frame_received_wall_ms) and
92    /// [`frame_enqueued_wall_ms`](Self::frame_enqueued_wall_ms), in integer
93    /// milliseconds per intake step (CLI 2.1.282+): `before_read` (until the
94    /// input loop read the frame, including any wait behind earlier frames
95    /// and a first message's wait for the worker to finish starting),
96    /// `dedup`, `flag_settle`, `receive_hook`, `attachments`,
97    /// `admission_wait` (held behind a pending model switch, a pending MCP
98    /// server-set change, start-up work the worker still owed its first
99    /// turn, or an earlier held frame), `admit_check` (the host's decision
100    /// whether to queue the frame, when it had to be awaited) and `other`.
101    /// Steps that did not run are absent; `before_read` and `other` are
102    /// always present. The values sum to the enqueued-minus-received gap
103    /// unless a step came out negative and was clamped to 0. Open set —
104    /// keep unrecognized step names. Present exactly when
105    /// `frame_received_wall_ms` is, except from older producers.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub frame_intake_phases_ms: Option<std::collections::BTreeMap<String, u64>>,
108
109    /// Wall-clock epoch milliseconds when the turn's clock started: the
110    /// anchor `duration_ms`, `ttft_ms`, `ttft_stream_ms`,
111    /// `time_to_request_ms`, `first_content_frame_ms`, `first_stream_post_ms`
112    /// and `first_stream_post_ack_ms` are measured from (CLI 2.1.278+).
113    /// Present exactly when
114    /// [`frame_received_wall_ms`](Self::frame_received_wall_ms) is.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub turn_started_wall_ms: Option<f64>,
117
118    /// Time until the first text POST was issued, in milliseconds
119    /// (CLI 2.1.278+).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub first_text_post_ms: Option<u64>,
122
123    /// Wall-clock epoch milliseconds when the first text POST was issued
124    /// (fractional; CLI 2.1.278+).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub first_text_post_wall_ms: Option<f64>,
127
128    /// Wire uuid of the user message this result answers.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub user_message_uuid: Option<String>,
131
132    /// Client uuids of every user message whose prompt this turn consumed, in
133    /// consumption order — all members of a prompt batch the host merged into
134    /// this one turn, plus any queued user message folded into the running
135    /// turn between tool rounds. Always contains `user_message_uuid`; at most
136    /// 64 entries. Absent on delivery-failure/zeroed results and from CLIs
137    /// before 2.1.259 (fall back to `user_message_uuid`).
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub user_message_uuids: Vec<String>,
140
141    /// Why this turn was the automatic re-run of a turn a worker restart
142    /// interrupted (`CLAUDE_CODE_RESUME_INTERRUPTED_TURN`): the host's
143    /// `CLAUDE_CODE_RESUME_REASON` when it set one (`host_draining`,
144    /// `checkpoint_restore`, `container_recreated`, ...), else
145    /// `interrupted_turn`. Present on a headless re-run's result, success or
146    /// error; absent on every other turn, on the Remote Control bridge's
147    /// per-turn synthetic results, and from CLIs before 2.1.268.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub resume_reason: Option<String>,
150
151    /// The local slash command that produced this result when the query loop
152    /// was bypassed (success results only). Absent when the turn went to the
153    /// model and from CLIs before 2.1.268.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub local_command: Option<String>,
156
157    /// User-initiated sends still waiting in the command queue when this
158    /// result was produced. Greater than 0 means at least one more user turn
159    /// follows without further input, barring cancellation. Absent on fatal
160    /// startup results, on surfaces without a command queue, and from CLIs
161    /// before 2.1.259.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub queued_turn_count: Option<u64>,
164
165    pub num_turns: i32,
166
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub result: Option<String>,
169
170    #[serde(alias = "sessionId")]
171    pub session_id: String,
172    pub total_cost_usd: f64,
173
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub usage: Option<UsageInfo>,
176
177    /// Tools that were blocked due to permission denials during the session
178    #[serde(default)]
179    pub permission_denials: Vec<PermissionDenial>,
180
181    /// Error messages when `is_error` is true.
182    ///
183    /// Contains human-readable error strings (e.g., "No conversation found with session ID: ...").
184    /// This allows typed access to error conditions without needing to serialize to JSON and search.
185    #[serde(default)]
186    pub errors: Vec<String>,
187
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub uuid: Option<String>,
190
191    /// HTTP status code when the result is an API error (e.g., 429, 500, 529)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub api_error_status: Option<u16>,
194
195    /// The `api_error_code` of the API error that ended the turn (see
196    /// `AssistantMessage::api_error_code`): the server's
197    /// `error.details.error_code` when it is an identifier. Absent when the
198    /// turn did not end on an API error, when the response carried no code,
199    /// and from CLIs before 2.1.274.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub api_error_code: Option<String>,
202
203    /// Why generation stopped (e.g., end_turn, max_tokens)
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub stop_reason: Option<String>,
206
207    /// Why the session ended (e.g., "completed")
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub terminal_reason: Option<String>,
210
211    /// Delivery sequence of this result within the run: how many results the
212    /// run numbered before this one, starting at 0, in the order the process
213    /// writes them. A result whose write fails still consumes its number, so
214    /// a gap means a result was lost. Distinct from `num_turns`. Numbered by
215    /// the process hosting the run (`claude -p`); a local client relaying a
216    /// cloud session passes the cloud numbering through and its own locally
217    /// built error results carry none. Absent from CLIs before 2.1.268.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub result_index: Option<u64>,
220
221    /// Fast mode toggle state (e.g., "off")
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub fast_mode_state: Option<String>,
224
225    /// Why fast mode can't serve right now. Absent when nothing blocks it.
226    /// A paused-after-rate-limit run is not reported here; it rides
227    /// `fast_mode_state` as `"cooldown"`.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub fast_mode_disabled_reason: Option<FastModeDisabledReason>,
230
231    /// Per-model cost breakdown, keyed by model name (e.g. `"claude-opus-4-8"`).
232    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
233    pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
234
235    /// Subagents started through the Agent tool in this session, as running
236    /// totals. Cumulative like `modelUsage`: read the latest result rather
237    /// than summing across results. Absent from CLIs before 2.1.239.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub subagent_stats: Option<SubagentStats>,
240
241    /// Structured-output payload returned by the model, when enabled.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub structured_output: Option<Value>,
244
245    /// Deferred tool-use termination payload.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub deferred_tool_use: Option<DeferredToolUse>,
248
249    /// Provenance of the message/run.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub origin: Option<super::message_types::MessageOrigin>,
252
253    /// Set by the self-hosted runner on the result it synthesizes when the
254    /// session process fails to start or exits abnormally. Absent on normal
255    /// results and on CLIs before 2.1.266.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub runner_exit: Option<RunnerExit>,
258
259    /// Why Claude Code refused to start. Set on the zeroed
260    /// `error_during_execution` result a stream-json run writes before
261    /// exiting on a known startup failure; `errors` carries the same text as
262    /// stderr. Failures that used to end with stderr alone write that result
263    /// only when the host sets `CLAUDE_CODE_STARTUP_FAILURE_RESULTS`. Absent
264    /// on every other result, on startup failures without a known cause, and
265    /// from CLIs before 2.1.274.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub startup_failure_reason: Option<StartupFailureReason>,
268}
269
270/// Why Claude Code refused to start, carried as
271/// [`ResultMessage::startup_failure_reason`] so a host can offer the fix
272/// instead of a retry (CLI 2.1.274+).
273#[derive(Debug, Clone, PartialEq, Eq, Hash)]
274pub enum StartupFailureReason {
275    /// Managed settings pin a first-party or Cloud gateway sign-in, and an
276    /// Anthropic API key or auth token is configured instead.
277    OrgPinApiKeyConflict,
278    /// The sign-in's organization could not be verified against the pin
279    /// (network, or a revoked token).
280    OrgVerifyFailed,
281    /// The sign-in belongs to an organization the pin does not allow.
282    OrgPinMismatch,
283    /// Managed policy settings could not be read, or the pin names no
284    /// organization.
285    ManagedSettingsInvalid,
286    /// Managed settings the organization requires could not be loaded.
287    RemoteSettingsRequiredUnavailable,
288    /// The Cloud gateway ended this sign-in.
289    GatewaySigninRequired,
290    /// The Cloud gateway refused managed settings for this account.
291    GatewayAccessDenied,
292    /// A proxy setting is not a complete URL.
293    ProxyInvalid,
294    /// The per-user temp directory is unsafe or could not be created.
295    TempDirUnusable,
296    /// The working directory was deleted, moved or cannot be read.
297    CwdUnavailable,
298    /// Windows has no shell tool: Git Bash is missing, and PowerShell is
299    /// missing or turned off by `CLAUDE_CODE_USE_POWERSHELL_TOOL`.
300    ShellToolMissing,
301    /// The conversation to resume or continue is running as a background
302    /// session.
303    SessionHeldByBackground,
304    /// The resume was refused because the session's worktree failed its
305    /// safety checks or the resume was launched from inside it; `errors`
306    /// says whether a re-run continues without the worktree.
307    WorktreeResumeRefused,
308    /// The session's worktree could not be verified right now; retrying may
309    /// succeed.
310    WorktreeUnverified,
311    /// This Claude Code version is below the minimum Anthropic requires.
312    CliVersionTooOld,
313    /// Bypass permissions mode was requested while running as root.
314    BypassRoot,
315    /// A cause not yet known to this version of the crate.
316    Unknown(String),
317}
318
319impl StartupFailureReason {
320    pub fn as_str(&self) -> &str {
321        match self {
322            Self::OrgPinApiKeyConflict => "org_pin_api_key_conflict",
323            Self::OrgVerifyFailed => "org_verify_failed",
324            Self::OrgPinMismatch => "org_pin_mismatch",
325            Self::ManagedSettingsInvalid => "managed_settings_invalid",
326            Self::RemoteSettingsRequiredUnavailable => "remote_settings_required_unavailable",
327            Self::GatewaySigninRequired => "gateway_signin_required",
328            Self::GatewayAccessDenied => "gateway_access_denied",
329            Self::ProxyInvalid => "proxy_invalid",
330            Self::TempDirUnusable => "temp_dir_unusable",
331            Self::CwdUnavailable => "cwd_unavailable",
332            Self::ShellToolMissing => "shell_tool_missing",
333            Self::SessionHeldByBackground => "session_held_by_background",
334            Self::WorktreeResumeRefused => "worktree_resume_refused",
335            Self::WorktreeUnverified => "worktree_unverified",
336            Self::CliVersionTooOld => "cli_version_too_old",
337            Self::BypassRoot => "bypass_root",
338            Self::Unknown(s) => s.as_str(),
339        }
340    }
341}
342
343impl fmt::Display for StartupFailureReason {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        f.write_str(self.as_str())
346    }
347}
348
349impl From<&str> for StartupFailureReason {
350    fn from(s: &str) -> Self {
351        match s {
352            "org_pin_api_key_conflict" => Self::OrgPinApiKeyConflict,
353            "org_verify_failed" => Self::OrgVerifyFailed,
354            "org_pin_mismatch" => Self::OrgPinMismatch,
355            "managed_settings_invalid" => Self::ManagedSettingsInvalid,
356            "remote_settings_required_unavailable" => Self::RemoteSettingsRequiredUnavailable,
357            "gateway_signin_required" => Self::GatewaySigninRequired,
358            "gateway_access_denied" => Self::GatewayAccessDenied,
359            "proxy_invalid" => Self::ProxyInvalid,
360            "temp_dir_unusable" => Self::TempDirUnusable,
361            "cwd_unavailable" => Self::CwdUnavailable,
362            "shell_tool_missing" => Self::ShellToolMissing,
363            "session_held_by_background" => Self::SessionHeldByBackground,
364            "worktree_resume_refused" => Self::WorktreeResumeRefused,
365            "worktree_unverified" => Self::WorktreeUnverified,
366            "cli_version_too_old" => Self::CliVersionTooOld,
367            "bypass_root" => Self::BypassRoot,
368            other => Self::Unknown(other.to_string()),
369        }
370    }
371}
372
373impl Serialize for StartupFailureReason {
374    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
375        serializer.serialize_str(self.as_str())
376    }
377}
378
379impl<'de> Deserialize<'de> for StartupFailureReason {
380    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
381        let s = String::deserialize(deserializer)?;
382        Ok(Self::from(s.as_str()))
383    }
384}
385
386/// How the self-hosted runner's session process terminated, carried on a
387/// synthesized failure [`ResultMessage`] as [`ResultMessage::runner_exit`]
388/// (CLI 2.1.266+).
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
390pub struct RunnerExit {
391    /// Which phase the process was in when it exited.
392    pub phase: RunnerExitPhase,
393    /// Process exit code. Absent (or wire `null`) when it exited on a signal
394    /// instead, or when the runner did not capture one.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub exit_code: Option<i64>,
397    /// Terminating signal name. Absent (or wire `null`) when it exited with a
398    /// code instead, or when the runner did not capture one.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub signal: Option<String>,
401}
402
403/// Which phase the runner's session process was in when it exited, in
404/// [`RunnerExit::phase`]. Open set — unrecognized values deserialize to
405/// [`RunnerExitPhase::Unknown`].
406#[derive(Debug, Clone, PartialEq, Eq, Hash)]
407pub enum RunnerExitPhase {
408    /// Failed while starting up.
409    Setup,
410    /// Failed once running.
411    Run,
412    /// A phase not yet known to this version of the crate.
413    Unknown(String),
414}
415
416impl RunnerExitPhase {
417    pub fn as_str(&self) -> &str {
418        match self {
419            Self::Setup => "setup",
420            Self::Run => "run",
421            Self::Unknown(s) => s.as_str(),
422        }
423    }
424}
425
426impl std::fmt::Display for RunnerExitPhase {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        f.write_str(self.as_str())
429    }
430}
431
432impl From<&str> for RunnerExitPhase {
433    fn from(s: &str) -> Self {
434        match s {
435            "setup" => Self::Setup,
436            "run" => Self::Run,
437            other => Self::Unknown(other.to_string()),
438        }
439    }
440}
441
442impl Serialize for RunnerExitPhase {
443    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
444        serializer.serialize_str(self.as_str())
445    }
446}
447
448impl<'de> Deserialize<'de> for RunnerExitPhase {
449    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
450        let s = String::deserialize(deserializer)?;
451        Ok(Self::from(s.as_str()))
452    }
453}
454
455/// Usage and cost for a single model within a session, as found in
456/// [`ResultMessage::model_usage`].
457///
458/// The `extra` field captures any keys the CLI adds that aren't modeled here,
459/// so new wire fields deserialize without error.
460#[derive(Debug, Clone, Default, Serialize, Deserialize)]
461#[serde(rename_all = "camelCase")]
462pub struct ModelUsageEntry {
463    #[serde(default)]
464    pub input_tokens: u64,
465    #[serde(default)]
466    pub output_tokens: u64,
467    #[serde(default)]
468    pub cache_read_input_tokens: u64,
469    #[serde(default)]
470    pub cache_creation_input_tokens: u64,
471    #[serde(default, rename = "costUSD")]
472    pub cost_usd: f64,
473    #[serde(default)]
474    pub web_search_requests: u32,
475    #[serde(default)]
476    pub context_window: u64,
477    #[serde(default)]
478    pub max_output_tokens: u64,
479    #[serde(flatten)]
480    pub extra: serde_json::Map<String, serde_json::Value>,
481}
482
483/// Tool use deferred by a terminal result.
484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
485pub struct DeferredToolUse {
486    pub id: String,
487    pub name: String,
488    pub input: Value,
489}
490
491/// Running totals for subagents started through the Agent tool, carried on
492/// `result` frames (CLI 2.1.239+) as [`ResultMessage::subagent_stats`].
493///
494/// Cumulative for the session: a resumed session starts fresh, and a
495/// mid-session `/clear` zeroes it — though a background subagent that outlives
496/// the `/clear` still records its outcome, so `completed`, `failed`, and
497/// `killed` can then exceed `spawned`. Forked skills, workflows, teammates,
498/// and other internal agents are not counted.
499#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
500pub struct SubagentStats {
501    /// Subagents actually started; a refused or failed launch is not counted.
502    pub spawned: u64,
503    /// Spawns by the `run_in_background` value the model passed; all count as
504    /// `unset` while the parameter is not offered.
505    pub requested: SubagentSpawnRequests,
506    /// Spawns that started in the background after defaults and session
507    /// settings; the rest blocked the spawning tool call.
508    pub started_in_background: u64,
509    /// Spawns by agent type.
510    #[serde(default)]
511    pub by_type: std::collections::BTreeMap<String, u64>,
512    /// Deepest spawn: 1 = started by the main thread, 2 = by a depth-1
513    /// subagent.
514    pub max_depth: u64,
515    /// Spawns made from inside another subagent (depth > 1).
516    pub spawned_by_subagents: u64,
517    pub completed: u64,
518    pub failed: u64,
519    /// Subagents stopped before finishing, by who stopped them.
520    pub killed: SubagentKillCounts,
521    /// Agent tool calls turned down because a limit was reached (other
522    /// denials, such as an unknown agent type, are not counted).
523    pub refused: SubagentRefusalCounts,
524}
525
526/// Spawn counts by the `run_in_background` value the model passed, carried in
527/// [`SubagentStats::requested`].
528#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
529pub struct SubagentSpawnRequests {
530    pub background: u64,
531    pub foreground: u64,
532    pub unset: u64,
533}
534
535/// Subagents stopped before finishing, carried in [`SubagentStats::killed`].
536/// `parent` = by another agent through TaskStop; `system` = by Claude Code
537/// itself; `user` = any other stop.
538#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
539pub struct SubagentKillCounts {
540    pub parent: u64,
541    pub user: u64,
542    pub system: u64,
543}
544
545/// Agent tool calls refused at a limit, carried in [`SubagentStats::refused`].
546#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
547pub struct SubagentRefusalCounts {
548    pub depth_limit: u64,
549    pub concurrency_limit: u64,
550    pub budget: u64,
551}
552
553/// A record of a tool permission that was denied during the session.
554///
555/// This is included in `ResultMessage.permission_denials` to provide a summary
556/// of all permission denials that occurred.
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
558pub struct PermissionDenial {
559    /// The name of the tool that was blocked (e.g., "Bash", "Write")
560    pub tool_name: String,
561
562    /// The input that was passed to the tool
563    pub tool_input: Value,
564
565    /// The unique identifier for this tool use request
566    pub tool_use_id: String,
567}
568
569/// What the first stream POST of a turn was queued behind, carried as
570/// [`ResultMessage::first_stream_post_queued_behind`] (CLI 2.1.278+).
571/// Internal timing instrumentation; the CLI does not describe the values.
572#[derive(Debug, Clone, PartialEq, Eq, Hash)]
573pub enum StreamPostQueuedBehind {
574    /// `durable_post`
575    DurablePost,
576    /// `ephemeral_post`
577    EphemeralPost,
578    /// `retry_backoff`
579    RetryBackoff,
580    /// `hold`
581    Hold,
582    /// `none`
583    None,
584    /// A value not yet known to this version of the crate.
585    Unknown(String),
586}
587
588impl StreamPostQueuedBehind {
589    pub fn as_str(&self) -> &str {
590        match self {
591            Self::DurablePost => "durable_post",
592            Self::EphemeralPost => "ephemeral_post",
593            Self::RetryBackoff => "retry_backoff",
594            Self::Hold => "hold",
595            Self::None => "none",
596            Self::Unknown(s) => s.as_str(),
597        }
598    }
599}
600
601impl fmt::Display for StreamPostQueuedBehind {
602    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
603        f.write_str(self.as_str())
604    }
605}
606
607impl From<&str> for StreamPostQueuedBehind {
608    fn from(s: &str) -> Self {
609        match s {
610            "durable_post" => Self::DurablePost,
611            "ephemeral_post" => Self::EphemeralPost,
612            "retry_backoff" => Self::RetryBackoff,
613            "hold" => Self::Hold,
614            "none" => Self::None,
615            other => Self::Unknown(other.to_string()),
616        }
617    }
618}
619
620impl Serialize for StreamPostQueuedBehind {
621    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
622        serializer.serialize_str(self.as_str())
623    }
624}
625
626impl<'de> Deserialize<'de> for StreamPostQueuedBehind {
627    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
628        let s = String::deserialize(deserializer)?;
629        Ok(Self::from(s.as_str()))
630    }
631}
632
633/// Why fast mode can't serve right now, carried on `result` frames and
634/// `system/init` (CLI 2.1.219+). Absent when nothing blocks fast mode.
635#[derive(Debug, Clone, PartialEq, Eq, Hash)]
636pub enum FastModeDisabledReason {
637    /// Free-tier account.
638    Free,
639    /// Disabled by user preference.
640    Preference,
641    /// Extra-usage purchases are disabled for the account.
642    ExtraUsageDisabled,
643    /// A network error prevented fast-mode eligibility from resolving.
644    NetworkError,
645    /// The CLI could not determine the reason.
646    UnknownReason,
647    /// Not a first-party API session (e.g. Bedrock/Vertex).
648    NotFirstParty,
649    /// Disabled via environment variable.
650    DisabledByEnv,
651    /// The active model does not support fast mode.
652    ModelNotAllowed,
653    /// SDK sessions must opt in to fast mode.
654    SdkOptInRequired,
655    /// Eligibility is still being determined.
656    Pending,
657    /// A reason not yet known to this version of the crate.
658    Unknown(String),
659}
660
661impl FastModeDisabledReason {
662    pub fn as_str(&self) -> &str {
663        match self {
664            Self::Free => "free",
665            Self::Preference => "preference",
666            Self::ExtraUsageDisabled => "extra_usage_disabled",
667            Self::NetworkError => "network_error",
668            Self::UnknownReason => "unknown",
669            Self::NotFirstParty => "not_first_party",
670            Self::DisabledByEnv => "disabled_by_env",
671            Self::ModelNotAllowed => "model_not_allowed",
672            Self::SdkOptInRequired => "sdk_opt_in_required",
673            Self::Pending => "pending",
674            Self::Unknown(s) => s.as_str(),
675        }
676    }
677}
678
679impl fmt::Display for FastModeDisabledReason {
680    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
681        f.write_str(self.as_str())
682    }
683}
684
685impl From<&str> for FastModeDisabledReason {
686    fn from(s: &str) -> Self {
687        match s {
688            "free" => Self::Free,
689            "preference" => Self::Preference,
690            "extra_usage_disabled" => Self::ExtraUsageDisabled,
691            "network_error" => Self::NetworkError,
692            "unknown" => Self::UnknownReason,
693            "not_first_party" => Self::NotFirstParty,
694            "disabled_by_env" => Self::DisabledByEnv,
695            "model_not_allowed" => Self::ModelNotAllowed,
696            "sdk_opt_in_required" => Self::SdkOptInRequired,
697            "pending" => Self::Pending,
698            other => Self::Unknown(other.to_string()),
699        }
700    }
701}
702
703impl Serialize for FastModeDisabledReason {
704    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
705        serializer.serialize_str(self.as_str())
706    }
707}
708
709impl<'de> Deserialize<'de> for FastModeDisabledReason {
710    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
711        let s = String::deserialize(deserializer)?;
712        Ok(Self::from(s.as_str()))
713    }
714}
715
716/// Result subtypes
717#[derive(Debug, Clone, PartialEq, Eq, Hash)]
718pub enum ResultSubtype {
719    Success,
720    ErrorMaxTurns,
721    ErrorDuringExecution,
722    ErrorMaxBudgetUsd,
723    ErrorMaxStructuredOutputRetries,
724    Unknown(String),
725}
726
727impl ResultSubtype {
728    pub fn as_str(&self) -> &str {
729        match self {
730            Self::Success => "success",
731            Self::ErrorMaxTurns => "error_max_turns",
732            Self::ErrorDuringExecution => "error_during_execution",
733            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
734            Self::ErrorMaxStructuredOutputRetries => "error_max_structured_output_retries",
735            Self::Unknown(s) => s.as_str(),
736        }
737    }
738}
739
740impl fmt::Display for ResultSubtype {
741    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
742        f.write_str(self.as_str())
743    }
744}
745
746impl From<&str> for ResultSubtype {
747    fn from(s: &str) -> Self {
748        match s {
749            "success" => Self::Success,
750            "error_max_turns" => Self::ErrorMaxTurns,
751            "error_during_execution" => Self::ErrorDuringExecution,
752            "error_max_budget_usd" => Self::ErrorMaxBudgetUsd,
753            "error_max_structured_output_retries" => Self::ErrorMaxStructuredOutputRetries,
754            other => Self::Unknown(other.to_string()),
755        }
756    }
757}
758
759impl Serialize for ResultSubtype {
760    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
761        serializer.serialize_str(self.as_str())
762    }
763}
764
765impl<'de> Deserialize<'de> for ResultSubtype {
766    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
767        let s = String::deserialize(deserializer)?;
768        Ok(Self::from(s.as_str()))
769    }
770}
771
772/// Usage information for the request.
773///
774/// **These counters are accumulated roll-ups, not snapshots.** The CLI sums
775/// usage field-by-field across every API call ("iteration") in the turn's
776/// tool-use loop, so on a turn with N iterations each counter is the sum of
777/// all N. This is the right number for **cost**, and the wrong number for
778/// **context occupancy**: `cache_read_input_tokens` in particular re-counts
779/// the cached context on every iteration and can exceed the model's context
780/// window several times over on tool-heavy turns. To estimate context
781/// occupancy, use the *last* entry of [`iterations`](Self::iterations)
782/// instead (the CLI itself does exactly this).
783///
784/// Note: the `result` frame's usage covers the **main agent only** — the
785/// subagent (`Task` / sidechain) token rollup the CLI renders as
786/// `<subagent_tokens>` / `<agent_count>` is not carried here or anywhere
787/// else on the wire. Accumulate it from `Task` tool results with
788/// [`SubagentUsageRollup`](crate::SubagentUsageRollup).
789#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct UsageInfo {
791    /// Sum of fresh (uncached) input tokens across all iterations of the turn.
792    #[serde(default)]
793    pub input_tokens: u32,
794    /// Sum of cache-write input tokens across all iterations of the turn.
795    #[serde(default)]
796    pub cache_creation_input_tokens: u32,
797    /// Sum of cache-read input tokens across all iterations of the turn.
798    /// Re-counts the cached context every iteration — **not** a measure of
799    /// context occupancy; see the type-level docs.
800    #[serde(default)]
801    pub cache_read_input_tokens: u32,
802    /// Sum of output tokens across all iterations of the turn.
803    #[serde(default)]
804    pub output_tokens: u32,
805    #[serde(default)]
806    pub server_tool_use: ServerToolUse,
807    #[serde(default)]
808    pub service_tier: String,
809
810    /// Cache creation breakdown
811    #[serde(skip_serializing_if = "Option::is_none")]
812    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
813
814    /// Inference geography (e.g., "not_available")
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub inference_geo: Option<String>,
817
818    /// Per-iteration usage breakdown **within** this turn — one entry per
819    /// API call in the tool-use loop, in order. The last entry reflects the
820    /// final API call and is what the CLI reads for context-size estimates.
821    #[serde(default, skip_serializing_if = "Vec::is_empty")]
822    pub iterations: Vec<UsageIteration>,
823
824    /// Speed tier (e.g., "standard")
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub speed: Option<String>,
827
828    /// Output-token breakdown (CLI 2.1.232+): currently the thinking-token
829    /// share of `output_tokens`.
830    #[serde(skip_serializing_if = "Option::is_none")]
831    pub output_tokens_details: Option<OutputTokensDetails>,
832}
833
834/// Breakdown of a turn's output tokens, carried in
835/// [`UsageInfo::output_tokens_details`].
836#[derive(Debug, Clone, Serialize, Deserialize, Default)]
837pub struct OutputTokensDetails {
838    /// Output tokens spent on extended thinking.
839    #[serde(skip_serializing_if = "Option::is_none")]
840    pub thinking_tokens: Option<u64>,
841}
842
843/// Usage for a single API call ("iteration") within a turn's tool-use loop.
844///
845/// Carried in [`UsageInfo::iterations`]. The cache fields are optional on
846/// the wire: some frames carry only `input_tokens` + `output_tokens`
847/// (observed with `type: "turn"`), while others carry the full cache
848/// breakdown (observed with `type: "message"` in captured subagent
849/// sessions). The CLI computes its context estimate from
850/// `input_tokens + output_tokens` of the **last** iteration.
851#[derive(Debug, Clone, Serialize, Deserialize)]
852pub struct UsageIteration {
853    #[serde(default)]
854    pub input_tokens: u32,
855    #[serde(default)]
856    pub output_tokens: u32,
857    /// Cache-read input tokens for this iteration, when carried.
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub cache_read_input_tokens: Option<u32>,
860    /// Cache-write input tokens for this iteration, when carried.
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub cache_creation_input_tokens: Option<u32>,
863    /// Cache-write breakdown by TTL, when carried.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
866    /// Iteration kind; `"turn"` and `"message"` observed on the wire.
867    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
868    pub kind: Option<String>,
869}
870
871/// Server tool usage information
872#[derive(Debug, Clone, Default, Serialize, Deserialize)]
873pub struct ServerToolUse {
874    #[serde(default)]
875    pub web_search_requests: u32,
876    /// Number of web fetch requests made
877    #[serde(default)]
878    pub web_fetch_requests: u32,
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::io::ClaudeOutput;
885
886    #[test]
887    fn test_deserialize_result_message() {
888        let json = r#"{
889            "type": "result",
890            "subtype": "success",
891            "is_error": false,
892            "duration_ms": 100,
893            "duration_api_ms": 200,
894            "num_turns": 1,
895            "result": "Done",
896            "session_id": "123",
897            "total_cost_usd": 0.01,
898            "permission_denials": []
899        }"#;
900
901        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
902        assert!(!output.is_error());
903    }
904
905    #[test]
906    fn test_result_subtype_new_and_unknown_values_do_not_fail() {
907        let json = r#"{
908            "type": "result",
909            "subtype": "error_max_budget_usd",
910            "is_error": true,
911            "duration_ms": 100,
912            "duration_api_ms": 200,
913            "num_turns": 1,
914            "session_id": "123",
915            "total_cost_usd": 0.01
916        }"#;
917
918        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
919        let ClaudeOutput::Result(result) = output else {
920            panic!("Expected Result");
921        };
922        assert_eq!(result.subtype, ResultSubtype::ErrorMaxBudgetUsd);
923
924        let json = r#"{
925            "type": "result",
926            "subtype": "future_result_subtype",
927            "is_error": true,
928            "duration_ms": 100,
929            "duration_api_ms": 200,
930            "num_turns": 1,
931            "session_id": "123",
932            "total_cost_usd": 0.01
933        }"#;
934
935        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
936        let ClaudeOutput::Result(result) = output else {
937            panic!("Expected Result");
938        };
939        assert_eq!(
940            result.subtype,
941            ResultSubtype::Unknown("future_result_subtype".to_string())
942        );
943    }
944
945    #[test]
946    fn test_deserialize_result_with_permission_denials() {
947        let json = r#"{
948            "type": "result",
949            "subtype": "success",
950            "is_error": false,
951            "duration_ms": 100,
952            "duration_api_ms": 200,
953            "num_turns": 2,
954            "result": "Done",
955            "session_id": "123",
956            "total_cost_usd": 0.01,
957            "permission_denials": [
958                {
959                    "tool_name": "Bash",
960                    "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
961                    "tool_use_id": "toolu_123"
962                }
963            ]
964        }"#;
965
966        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
967        if let ClaudeOutput::Result(result) = output {
968            assert_eq!(result.permission_denials.len(), 1);
969            assert_eq!(result.permission_denials[0].tool_name, "Bash");
970            assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
971            assert_eq!(
972                result.permission_denials[0]
973                    .tool_input
974                    .get("command")
975                    .unwrap(),
976                "rm -rf /"
977            );
978        } else {
979            panic!("Expected Result");
980        }
981    }
982
983    #[test]
984    fn test_permission_denial_roundtrip() {
985        let denial = PermissionDenial {
986            tool_name: "Write".to_string(),
987            tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
988            tool_use_id: "toolu_456".to_string(),
989        };
990
991        let json = serde_json::to_string(&denial).unwrap();
992        assert!(json.contains("\"tool_name\":\"Write\""));
993        assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
994        assert!(json.contains("/etc/passwd"));
995
996        let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
997        assert_eq!(parsed, denial);
998    }
999
1000    #[test]
1001    fn test_deserialize_result_message_with_errors() {
1002        let json = r#"{
1003            "type": "result",
1004            "subtype": "error_during_execution",
1005            "duration_ms": 0,
1006            "duration_api_ms": 0,
1007            "is_error": true,
1008            "num_turns": 0,
1009            "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
1010            "total_cost_usd": 0,
1011            "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
1012            "permission_denials": []
1013        }"#;
1014
1015        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1016        assert!(output.is_error());
1017
1018        if let ClaudeOutput::Result(res) = output {
1019            assert!(res.is_error);
1020            assert_eq!(res.errors.len(), 1);
1021            assert!(res.errors[0].contains("No conversation found"));
1022        } else {
1023            panic!("Expected Result message");
1024        }
1025    }
1026
1027    #[test]
1028    fn test_deserialize_result_message_errors_defaults_empty() {
1029        let json = r#"{
1030            "type": "result",
1031            "subtype": "success",
1032            "is_error": false,
1033            "duration_ms": 100,
1034            "duration_api_ms": 200,
1035            "num_turns": 1,
1036            "session_id": "123",
1037            "total_cost_usd": 0.01
1038        }"#;
1039
1040        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1041        if let ClaudeOutput::Result(res) = output {
1042            assert!(res.errors.is_empty());
1043        } else {
1044            panic!("Expected Result message");
1045        }
1046    }
1047
1048    #[test]
1049    fn test_result_message_errors_roundtrip() {
1050        let json = r#"{
1051            "type": "result",
1052            "subtype": "error_during_execution",
1053            "is_error": true,
1054            "duration_ms": 0,
1055            "duration_api_ms": 0,
1056            "num_turns": 0,
1057            "session_id": "test-session",
1058            "total_cost_usd": 0.0,
1059            "errors": ["Error 1", "Error 2"]
1060        }"#;
1061
1062        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1063        let reserialized = serde_json::to_string(&output).unwrap();
1064
1065        assert!(reserialized.contains("Error 1"));
1066        assert!(reserialized.contains("Error 2"));
1067    }
1068
1069    #[test]
1070    fn test_result_with_new_fields() {
1071        let json = r#"{
1072            "type": "result",
1073            "subtype": "success",
1074            "is_error": false,
1075            "duration_ms": 5000,
1076            "duration_api_ms": 4500,
1077            "num_turns": 1,
1078            "result": "Done",
1079            "session_id": "abc",
1080            "total_cost_usd": 0.06,
1081            "api_error_status": null,
1082            "stop_reason": "end_turn",
1083            "terminal_reason": "completed",
1084            "fast_mode_state": "off",
1085            "modelUsage": {
1086                "claude-opus-4-7[1m]": {
1087                    "inputTokens": 3817,
1088                    "outputTokens": 14,
1089                    "costUSD": 0.06
1090                }
1091            },
1092            "usage": {
1093                "input_tokens": 3817,
1094                "output_tokens": 14,
1095                "cache_creation_input_tokens": 3540,
1096                "cache_read_input_tokens": 0,
1097                "server_tool_use": {
1098                    "web_search_requests": 0,
1099                    "web_fetch_requests": 2
1100                },
1101                "service_tier": "standard",
1102                "inference_geo": "not_available",
1103                "speed": "standard",
1104                "iterations": [
1105                    {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
1106                ]
1107            }
1108        }"#;
1109
1110        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1111        if let ClaudeOutput::Result(res) = output {
1112            assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
1113            assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
1114            assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
1115            let model_usage = res.model_usage.as_ref().unwrap();
1116            let entry = model_usage
1117                .get("claude-opus-4-7[1m]")
1118                .expect("per-model entry present");
1119            assert_eq!(entry.input_tokens, 3817);
1120            assert_eq!(entry.output_tokens, 14);
1121            assert_eq!(entry.cost_usd, 0.06);
1122            assert!(res.api_error_status.is_none());
1123
1124            let usage = res.usage.unwrap();
1125            assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
1126            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1127            assert_eq!(usage.speed.as_deref(), Some("standard"));
1128            assert_eq!(usage.iterations.len(), 1);
1129            assert_eq!(usage.iterations[0].input_tokens, 3817);
1130            assert_eq!(usage.iterations[0].output_tokens, 14);
1131            assert_eq!(usage.iterations[0].kind.as_deref(), Some("turn"));
1132        } else {
1133            panic!("Expected Result");
1134        }
1135    }
1136
1137    #[test]
1138    fn test_result_backwards_compatible_without_new_fields() {
1139        // Verify old-format messages still parse fine
1140        let json = r#"{
1141            "type": "result",
1142            "subtype": "success",
1143            "is_error": false,
1144            "duration_ms": 100,
1145            "duration_api_ms": 200,
1146            "num_turns": 1,
1147            "session_id": "abc",
1148            "total_cost_usd": 0.01
1149        }"#;
1150
1151        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1152        if let ClaudeOutput::Result(res) = output {
1153            assert!(res.api_error_status.is_none());
1154            assert!(res.stop_reason.is_none());
1155            assert!(res.terminal_reason.is_none());
1156            assert!(res.fast_mode_state.is_none());
1157            assert!(res.model_usage.is_none());
1158        } else {
1159            panic!("Expected Result");
1160        }
1161    }
1162
1163    #[test]
1164    fn test_result_fast_mode_disabled_reason() {
1165        let json = r#"{
1166            "type":"result","subtype":"success","is_error":false,
1167            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1168            "session_id":"s1","total_cost_usd":0.01,
1169            "fast_mode_state":"off",
1170            "fast_mode_disabled_reason":"sdk_opt_in_required"
1171        }"#;
1172        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1173        let crate::ClaudeOutput::Result(res) = &output else {
1174            panic!("expected Result");
1175        };
1176        assert_eq!(
1177            res.fast_mode_disabled_reason,
1178            Some(FastModeDisabledReason::SdkOptInRequired)
1179        );
1180        assert!(serde_json::to_string(&output)
1181            .unwrap()
1182            .contains("\"fast_mode_disabled_reason\":\"sdk_opt_in_required\""));
1183
1184        // Unknown reasons survive decode and round-trip verbatim; the wire
1185        // literal "unknown" maps to the typed UnknownReason, not the fallback.
1186        assert_eq!(
1187            FastModeDisabledReason::from("unknown"),
1188            FastModeDisabledReason::UnknownReason
1189        );
1190        let novel = FastModeDisabledReason::from("solar_flare");
1191        assert_eq!(novel, FastModeDisabledReason::Unknown("solar_flare".into()));
1192        assert_eq!(novel.as_str(), "solar_flare");
1193    }
1194
1195    #[test]
1196    fn test_result_timing_and_user_message_uuid_fields() {
1197        let json = r#"{
1198            "type":"result","subtype":"success","is_error":false,
1199            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1200            "session_id":"s1","total_cost_usd":0.01,
1201            "request_sent_wall_ms":1753212345678.25,
1202            "user_message_uuid":"um-1"
1203        }"#;
1204        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1205        let crate::ClaudeOutput::Result(res) = &output else {
1206            panic!("expected Result");
1207        };
1208        assert_eq!(res.request_sent_wall_ms, Some(1753212345678.25));
1209        assert_eq!(res.user_message_uuid.as_deref(), Some("um-1"));
1210
1211        let reserialized = serde_json::to_string(&output).unwrap();
1212        assert!(reserialized.contains("\"user_message_uuid\":\"um-1\""));
1213    }
1214
1215    #[test]
1216    fn test_result_queued_turn_count_and_user_message_uuids() {
1217        // CLI 2.1.259 additive fields on the result frame.
1218        let json = r#"{
1219            "type":"result","subtype":"success","is_error":false,
1220            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1221            "session_id":"s1","total_cost_usd":0.01,
1222            "user_message_uuid":"um-2",
1223            "user_message_uuids":["um-1","um-2"],
1224            "queued_turn_count":3
1225        }"#;
1226        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1227        let crate::ClaudeOutput::Result(res) = &output else {
1228            panic!("expected Result");
1229        };
1230        assert_eq!(res.user_message_uuids, vec!["um-1", "um-2"]);
1231        assert_eq!(res.queued_turn_count, Some(3));
1232
1233        let reserialized = serde_json::to_string(&output).unwrap();
1234        assert!(reserialized.contains("\"user_message_uuids\":[\"um-1\",\"um-2\"]"));
1235        assert!(reserialized.contains("\"queued_turn_count\":3"));
1236
1237        // Both fields are absent from older producers and must default cleanly.
1238        let old = r#"{
1239            "type":"result","subtype":"success","is_error":false,
1240            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1241            "session_id":"s1","total_cost_usd":0.01
1242        }"#;
1243        let output: crate::ClaudeOutput = serde_json::from_str(old).unwrap();
1244        let crate::ClaudeOutput::Result(res) = &output else {
1245            panic!("expected Result");
1246        };
1247        assert!(res.user_message_uuids.is_empty());
1248        assert_eq!(res.queued_turn_count, None);
1249        let reserialized = serde_json::to_string(&output).unwrap();
1250        assert!(!reserialized.contains("user_message_uuids"));
1251        assert!(!reserialized.contains("queued_turn_count"));
1252    }
1253}