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