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