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