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