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