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