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