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