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 /// Per-model usage and cost of the calls this process counted since it
493 /// wrote its previous result (since it started, for its first), keyed and
494 /// shaped like `modelUsage` and covering the same calls. Sum it over a
495 /// conversation's results instead of subtracting `modelUsage` totals: each
496 /// call is stated on at most one result, whatever a resume or a kill does
497 /// to `modelUsage`. Calls that finish after a result (a background
498 /// subagent, a late title call) are stated on the next result. Nothing is
499 /// carried across processes, so calls counted after a process's last
500 /// result (including a turn in flight when it is killed) are stated
501 /// nowhere. A model with nothing counted is left out; `{}` means nothing
502 /// was counted, and absent is not `{}`. Set by stream-json and `--sdk-url`
503 /// runs only; not set on hand-numbered error results (start-up failures,
504 /// errors that escape the run). Absent from CLIs before 2.1.309. Marked
505 /// `@internal` upstream.
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub model_usage_since_last_result: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
508
509 /// Subagents started through the Agent tool in this session, as running
510 /// totals. Cumulative like `modelUsage`: read the latest result rather
511 /// than summing across results. Absent from CLIs before 2.1.239.
512 #[serde(skip_serializing_if = "Option::is_none")]
513 pub subagent_stats: Option<SubagentStats>,
514
515 /// How many times a safety system has stopped one of this session's
516 /// model calls (main loop, subagents, forked skills, compaction and other
517 /// internal calls), as a running total since the session started in this
518 /// process: a `refusal` stop reason, a refusal answered on a fallback
519 /// model, output the API's content filter stopped, and (where the build
520 /// can tell) a server-side safety monitor block. Never sum across
521 /// results. Not saved, so a resumed session or a mid-session `/clear`
522 /// starts again at 0: over a stored log any result above 0 means a stop
523 /// happened, and a later 0 does not cancel it. Absent from CLIs before
524 /// 2.1.292. Marked `@internal` upstream.
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub safety_stops: Option<u64>,
527
528 /// Structured-output payload returned by the model, when enabled.
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub structured_output: Option<Value>,
531
532 /// Deferred tool-use termination payload.
533 #[serde(skip_serializing_if = "Option::is_none")]
534 pub deferred_tool_use: Option<DeferredToolUse>,
535
536 /// Provenance of the message/run.
537 #[serde(skip_serializing_if = "Option::is_none")]
538 pub origin: Option<super::message_types::MessageOrigin>,
539
540 /// Set by the self-hosted runner on the result it synthesizes when the
541 /// session process fails to start or exits abnormally. Absent on normal
542 /// results and on CLIs before 2.1.266.
543 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub runner_exit: Option<RunnerExit>,
545
546 /// Why Claude Code refused to start. Set on the zeroed
547 /// `error_during_execution` result a stream-json run writes before
548 /// exiting on a known startup failure; `errors` carries the same text as
549 /// stderr. Failures that used to end with stderr alone write that result
550 /// only when the host sets `CLAUDE_CODE_STARTUP_FAILURE_RESULTS`. Absent
551 /// on every other result, on startup failures without a known cause, and
552 /// from CLIs before 2.1.274.
553 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub startup_failure_reason: Option<StartupFailureReason>,
555}
556
557/// [`ResultMessage::input_attachments_detail`] — what the
558/// `input_attachments` phase did on the turn (CLI 2.1.288+).
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
560pub struct InputAttachmentsDetail {
561 /// Label of the attachment producer that took longest from its start to
562 /// its result, among those that began within the phase.
563 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub slowest_producer: Option<String>,
565 /// How long [`Self::slowest_producer`] took. Well above
566 /// [`Self::cpu_ms`], that producer most likely waited (on a file, the
567 /// network or a child process).
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub slowest_producer_ms: Option<u64>,
570 /// `time_to_request_cpu_ms`'s counter over this phase alone. Absent
571 /// wherever `time_to_request_cpu_ms` is absent, and when the turn's
572 /// first model request went out before the phase ended.
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub cpu_ms: Option<u64>,
575 /// `time_to_request_major_faults`'s counter over this phase alone.
576 /// Absent where [`Self::cpu_ms`] is.
577 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub major_faults: Option<u64>,
579 /// How many files the `changed_files` producer set out to read again in
580 /// full because their modification time was later than the one recorded
581 /// when the conversation last wrote or edited them. Absent when the
582 /// conversation's record of files was empty or the producer did not run.
583 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub changed_files_reread: Option<u64>,
585 /// How the `changed_files` producer's compares ended in the main
586 /// thread's pass (a subagent's are not counted). Absent when nothing was
587 /// counted (CLI 2.1.300+).
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub touch_compare: Option<TouchCompareCounts>,
590}
591
592/// [`InputAttachmentsDetail::touch_compare`] — how the `changed_files`
593/// producer's compares of touched files (size, then bytes, against what the
594/// conversation last saw) ended. Whole numbers; a zero count is left out
595/// (CLI 2.1.300+).
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
597pub struct TouchCompareCounts {
598 /// The text had not changed, so it was not read again.
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub same: Option<u64>,
601 /// The text had changed, so it was read again.
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub differs: Option<u64>,
604 /// Those of `same` and `differs` reached through the hosted container's
605 /// outputs link (`/mnt/user-data/outputs` → `/mnt/attach/outputs`).
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub via_outputs_link: Option<u64>,
608 /// Not compared, because the conversation has seen only part of the
609 /// file; read again.
610 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub partial_view: Option<u64>,
612 /// Not compared, because it was compared once already since the
613 /// conversation last read or wrote it; read again.
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub already_compared: Option<u64>,
616 /// The file could not be shown to be the one its path names (a symbolic
617 /// link on the way, an unjudged outputs-link landing, or the file swapped
618 /// or removed meanwhile); read again.
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub link_on_path: Option<u64>,
621 /// The open met a symbolic link; read again.
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub leaf_link: Option<u64>,
624 /// The file has a second hard link; read again.
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub nlink: Option<u64>,
627 /// Not a regular file; read again.
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub not_regular: Option<u64>,
630 /// Too large to compare; read again.
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub oversize: Option<u64>,
633 /// The file was gone.
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub enoent: Option<u64>,
636 /// Anything else, errors included; read again.
637 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub other: Option<u64>,
639}
640
641/// [`ResultMessage::resume_store_confirm_detail`] — how the
642/// `resume_store_confirm` phase was spent (CLI 2.1.290+). The four `_ms`
643/// values sum to that phase exactly.
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
645pub struct ResumeStoreConfirmDetail {
646 /// The transcript write, until the carried lines are in the queue of the
647 /// lane that uploads transcript rows.
648 pub write_ms: u64,
649 /// A POST was out that held only rows queued before the hand-off's write
650 /// began.
651 pub ahead_ms: u64,
652 /// Any other POST was out, each retry included.
653 pub post_ms: u64,
654 /// No POST was out (backoff, batching, a busy event loop), plus rounding
655 /// and any POST still out when the wait was cut short.
656 pub other_ms: u64,
657 /// POST attempts that ended between the start of the write and the end
658 /// of the wait.
659 pub posts: u64,
660 /// Those of [`Self::posts`] that did not go through.
661 pub failed_posts: u64,
662 /// Rows the lane had accepted and the store had not yet confirmed when
663 /// the write began.
664 pub rows_ahead: u64,
665 /// Rows queued since then that a POST delivered, the carried lines among
666 /// them.
667 pub rows: u64,
668}
669
670/// Why Claude Code refused to start, carried as
671/// [`ResultMessage::startup_failure_reason`] so a host can offer the fix
672/// instead of a retry (CLI 2.1.274+).
673#[derive(Debug, Clone, PartialEq, Eq, Hash)]
674pub enum StartupFailureReason {
675 /// Managed settings pin a first-party or Cloud gateway sign-in, and an
676 /// Anthropic API key or auth token is configured instead.
677 OrgPinApiKeyConflict,
678 /// Managed settings list the API providers this machine may use
679 /// (`allowedProviders`), and the session is set up for one that is not
680 /// listed (CLI 2.1.285+).
681 ProviderNotAllowed,
682 /// The sign-in's organization could not be verified against the pin
683 /// (network, or a revoked token).
684 OrgVerifyFailed,
685 /// The sign-in belongs to an organization the pin does not allow.
686 OrgPinMismatch,
687 /// Managed policy settings could not be read, the pin names no
688 /// organization, or managed model settings (`deniedModels`, or an
689 /// `availableModels` list matched exactly) block the default model and
690 /// leave no allowed model to use instead.
691 ManagedSettingsInvalid,
692 /// Managed settings the organization requires could not be loaded.
693 RemoteSettingsRequiredUnavailable,
694 /// The Cloud gateway ended this sign-in.
695 GatewaySigninRequired,
696 /// The Cloud gateway refused managed settings for this account.
697 GatewayAccessDenied,
698 /// A proxy setting is not a complete URL.
699 ProxyInvalid,
700 /// The per-user temp directory is unsafe or could not be created.
701 TempDirUnusable,
702 /// The working directory was deleted, moved or cannot be read.
703 CwdUnavailable,
704 /// Windows has no shell tool: Git Bash is missing, and PowerShell is
705 /// missing or turned off by `CLAUDE_CODE_USE_POWERSHELL_TOOL`.
706 ShellToolMissing,
707 /// The conversation to resume or continue is running as a background
708 /// session.
709 SessionHeldByBackground,
710 /// The resume was refused because the session's worktree failed its
711 /// safety checks or the resume was launched from inside it; `errors`
712 /// says whether a re-run continues without the worktree.
713 WorktreeResumeRefused,
714 /// The session's worktree could not be verified right now; retrying may
715 /// succeed.
716 WorktreeUnverified,
717 /// This Claude Code version is below the minimum Anthropic requires.
718 CliVersionTooOld,
719 /// Bypass permissions mode was requested while running as root.
720 BypassRoot,
721 /// The organization requires its policy limits and managed settings
722 /// before a session starts, and they could not be loaded (network,
723 /// proxy, or an Anthropic error); retrying may succeed (CLI 2.1.293+).
724 OrgConfigRequiredUnavailable,
725 /// The organization requires its policy limits and managed settings, and
726 /// Anthropic refused them for this sign-in (expired or revoked session,
727 /// or the organization does not allow Claude Code for this account);
728 /// signing in again or an admin's change is the fix, not a retry
729 /// (CLI 2.1.293+).
730 OrgConfigRefused,
731 /// A cause not yet known to this version of the crate.
732 Unknown(String),
733}
734
735impl StartupFailureReason {
736 pub fn as_str(&self) -> &str {
737 match self {
738 Self::OrgPinApiKeyConflict => "org_pin_api_key_conflict",
739 Self::ProviderNotAllowed => "provider_not_allowed",
740 Self::OrgVerifyFailed => "org_verify_failed",
741 Self::OrgPinMismatch => "org_pin_mismatch",
742 Self::ManagedSettingsInvalid => "managed_settings_invalid",
743 Self::RemoteSettingsRequiredUnavailable => "remote_settings_required_unavailable",
744 Self::GatewaySigninRequired => "gateway_signin_required",
745 Self::GatewayAccessDenied => "gateway_access_denied",
746 Self::ProxyInvalid => "proxy_invalid",
747 Self::TempDirUnusable => "temp_dir_unusable",
748 Self::CwdUnavailable => "cwd_unavailable",
749 Self::ShellToolMissing => "shell_tool_missing",
750 Self::SessionHeldByBackground => "session_held_by_background",
751 Self::WorktreeResumeRefused => "worktree_resume_refused",
752 Self::WorktreeUnverified => "worktree_unverified",
753 Self::CliVersionTooOld => "cli_version_too_old",
754 Self::BypassRoot => "bypass_root",
755 Self::OrgConfigRequiredUnavailable => "org_config_required_unavailable",
756 Self::OrgConfigRefused => "org_config_refused",
757 Self::Unknown(s) => s.as_str(),
758 }
759 }
760}
761
762impl fmt::Display for StartupFailureReason {
763 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
764 f.write_str(self.as_str())
765 }
766}
767
768impl From<&str> for StartupFailureReason {
769 fn from(s: &str) -> Self {
770 match s {
771 "org_pin_api_key_conflict" => Self::OrgPinApiKeyConflict,
772 "provider_not_allowed" => Self::ProviderNotAllowed,
773 "org_verify_failed" => Self::OrgVerifyFailed,
774 "org_pin_mismatch" => Self::OrgPinMismatch,
775 "managed_settings_invalid" => Self::ManagedSettingsInvalid,
776 "remote_settings_required_unavailable" => Self::RemoteSettingsRequiredUnavailable,
777 "gateway_signin_required" => Self::GatewaySigninRequired,
778 "gateway_access_denied" => Self::GatewayAccessDenied,
779 "proxy_invalid" => Self::ProxyInvalid,
780 "temp_dir_unusable" => Self::TempDirUnusable,
781 "cwd_unavailable" => Self::CwdUnavailable,
782 "shell_tool_missing" => Self::ShellToolMissing,
783 "session_held_by_background" => Self::SessionHeldByBackground,
784 "worktree_resume_refused" => Self::WorktreeResumeRefused,
785 "worktree_unverified" => Self::WorktreeUnverified,
786 "cli_version_too_old" => Self::CliVersionTooOld,
787 "bypass_root" => Self::BypassRoot,
788 "org_config_required_unavailable" => Self::OrgConfigRequiredUnavailable,
789 "org_config_refused" => Self::OrgConfigRefused,
790 other => Self::Unknown(other.to_string()),
791 }
792 }
793}
794
795impl Serialize for StartupFailureReason {
796 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
797 serializer.serialize_str(self.as_str())
798 }
799}
800
801impl<'de> Deserialize<'de> for StartupFailureReason {
802 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
803 let s = String::deserialize(deserializer)?;
804 Ok(Self::from(s.as_str()))
805 }
806}
807
808/// How the self-hosted runner's session process terminated, carried on a
809/// synthesized failure [`ResultMessage`] as [`ResultMessage::runner_exit`]
810/// (CLI 2.1.266+).
811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
812pub struct RunnerExit {
813 /// Which phase the process was in when it exited.
814 pub phase: RunnerExitPhase,
815 /// Process exit code. Absent (or wire `null`) when it exited on a signal
816 /// instead, or when the runner did not capture one.
817 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub exit_code: Option<i64>,
819 /// Terminating signal name. Absent (or wire `null`) when it exited with a
820 /// code instead, or when the runner did not capture one.
821 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub signal: Option<String>,
823}
824
825/// Which phase the runner's session process was in when it exited, in
826/// [`RunnerExit::phase`]. Open set — unrecognized values deserialize to
827/// [`RunnerExitPhase::Unknown`].
828#[derive(Debug, Clone, PartialEq, Eq, Hash)]
829pub enum RunnerExitPhase {
830 /// Failed while starting up.
831 Setup,
832 /// Failed once running.
833 Run,
834 /// A phase not yet known to this version of the crate.
835 Unknown(String),
836}
837
838impl RunnerExitPhase {
839 pub fn as_str(&self) -> &str {
840 match self {
841 Self::Setup => "setup",
842 Self::Run => "run",
843 Self::Unknown(s) => s.as_str(),
844 }
845 }
846}
847
848impl std::fmt::Display for RunnerExitPhase {
849 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
850 f.write_str(self.as_str())
851 }
852}
853
854impl From<&str> for RunnerExitPhase {
855 fn from(s: &str) -> Self {
856 match s {
857 "setup" => Self::Setup,
858 "run" => Self::Run,
859 other => Self::Unknown(other.to_string()),
860 }
861 }
862}
863
864impl Serialize for RunnerExitPhase {
865 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
866 serializer.serialize_str(self.as_str())
867 }
868}
869
870impl<'de> Deserialize<'de> for RunnerExitPhase {
871 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
872 let s = String::deserialize(deserializer)?;
873 Ok(Self::from(s.as_str()))
874 }
875}
876
877/// Usage and cost for a single model within a session, as found in
878/// [`ResultMessage::model_usage`] and
879/// [`ResultMessage::model_usage_since_last_result`].
880///
881/// The `extra` field captures any keys the CLI adds that aren't modeled here,
882/// so new wire fields deserialize without error.
883#[derive(Debug, Clone, Default, Serialize, Deserialize)]
884#[serde(rename_all = "camelCase")]
885pub struct ModelUsageEntry {
886 #[serde(default)]
887 pub input_tokens: u64,
888 #[serde(default)]
889 pub output_tokens: u64,
890 #[serde(default)]
891 pub cache_read_input_tokens: u64,
892 #[serde(default)]
893 pub cache_creation_input_tokens: u64,
894 #[serde(default, rename = "costUSD")]
895 pub cost_usd: f64,
896 #[serde(default)]
897 pub web_search_requests: u32,
898 #[serde(default)]
899 pub context_window: u64,
900 #[serde(default)]
901 pub max_output_tokens: u64,
902 #[serde(flatten)]
903 pub extra: serde_json::Map<String, serde_json::Value>,
904}
905
906/// Tool use deferred by a terminal result.
907#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
908pub struct DeferredToolUse {
909 pub id: String,
910 pub name: String,
911 pub input: Value,
912}
913
914/// Running totals for subagents started through the Agent tool, carried on
915/// `result` frames (CLI 2.1.239+) as [`ResultMessage::subagent_stats`].
916///
917/// Cumulative for the session: a resumed session starts fresh, and a
918/// mid-session `/clear` zeroes it — though a background subagent that outlives
919/// the `/clear` still records its outcome, so `completed`, `failed`, and
920/// `killed` can then exceed `spawned`. Forked skills, workflows, teammates,
921/// and other internal agents are not counted.
922#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
923pub struct SubagentStats {
924 /// Subagents actually started; a refused or failed launch is not counted.
925 pub spawned: u64,
926 /// Spawns by the `run_in_background` value the model passed; all count as
927 /// `unset` while the parameter is not offered (background tasks disabled,
928 /// or subagents forced into the background: in a terminal, the desktop
929 /// app or the VS Code extension, or with `CLAUDE_CODE_FORK_SUBAGENT=1`).
930 pub requested: SubagentSpawnRequests,
931 /// Spawns that started in the background after defaults and session
932 /// settings; the rest blocked the spawning tool call.
933 pub started_in_background: u64,
934 /// Spawns by agent type.
935 #[serde(default)]
936 pub by_type: std::collections::BTreeMap<String, u64>,
937 /// Deepest spawn: 1 = started by the main thread, 2 = by a depth-1
938 /// subagent.
939 pub max_depth: u64,
940 /// Spawns made from inside another subagent (depth > 1).
941 pub spawned_by_subagents: u64,
942 pub completed: u64,
943 pub failed: u64,
944 /// Subagents stopped before finishing, by who stopped them.
945 pub killed: SubagentKillCounts,
946 /// Agent tool calls turned down because a limit was reached (other
947 /// denials, such as an unknown agent type, are not counted).
948 pub refused: SubagentRefusalCounts,
949}
950
951/// Spawn counts by the `run_in_background` value the model passed, carried in
952/// [`SubagentStats::requested`].
953#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
954pub struct SubagentSpawnRequests {
955 pub background: u64,
956 pub foreground: u64,
957 pub unset: u64,
958}
959
960/// Subagents stopped before finishing, carried in [`SubagentStats::killed`].
961/// `parent` = by another agent through TaskStop; `system` = by Claude Code
962/// itself; `user` = any other stop.
963#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
964pub struct SubagentKillCounts {
965 pub parent: u64,
966 pub user: u64,
967 pub system: u64,
968}
969
970/// Agent tool calls refused at a limit, carried in [`SubagentStats::refused`].
971#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
972pub struct SubagentRefusalCounts {
973 pub depth_limit: u64,
974 pub concurrency_limit: u64,
975 pub budget: u64,
976}
977
978/// A record of a tool permission that was denied during the session.
979///
980/// This is included in `ResultMessage.permission_denials` to provide a summary
981/// of all permission denials that occurred.
982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
983pub struct PermissionDenial {
984 /// The name of the tool that was blocked (e.g., "Bash", "Write")
985 pub tool_name: String,
986
987 /// The input that was passed to the tool
988 pub tool_input: Value,
989
990 /// The unique identifier for this tool use request
991 pub tool_use_id: String,
992}
993
994/// What the first stream POST of a turn was queued behind, carried as
995/// [`ResultMessage::first_stream_post_queued_behind`] (CLI 2.1.278+).
996/// Internal timing instrumentation; the CLI does not describe the values.
997#[derive(Debug, Clone, PartialEq, Eq, Hash)]
998pub enum StreamPostQueuedBehind {
999 /// `durable_post`
1000 DurablePost,
1001 /// `ephemeral_post`
1002 EphemeralPost,
1003 /// `retry_backoff`
1004 RetryBackoff,
1005 /// `hold`
1006 Hold,
1007 /// `none`
1008 None,
1009 /// A value not yet known to this version of the crate.
1010 Unknown(String),
1011}
1012
1013impl StreamPostQueuedBehind {
1014 pub fn as_str(&self) -> &str {
1015 match self {
1016 Self::DurablePost => "durable_post",
1017 Self::EphemeralPost => "ephemeral_post",
1018 Self::RetryBackoff => "retry_backoff",
1019 Self::Hold => "hold",
1020 Self::None => "none",
1021 Self::Unknown(s) => s.as_str(),
1022 }
1023 }
1024}
1025
1026impl fmt::Display for StreamPostQueuedBehind {
1027 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1028 f.write_str(self.as_str())
1029 }
1030}
1031
1032impl From<&str> for StreamPostQueuedBehind {
1033 fn from(s: &str) -> Self {
1034 match s {
1035 "durable_post" => Self::DurablePost,
1036 "ephemeral_post" => Self::EphemeralPost,
1037 "retry_backoff" => Self::RetryBackoff,
1038 "hold" => Self::Hold,
1039 "none" => Self::None,
1040 other => Self::Unknown(other.to_string()),
1041 }
1042 }
1043}
1044
1045impl Serialize for StreamPostQueuedBehind {
1046 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1047 serializer.serialize_str(self.as_str())
1048 }
1049}
1050
1051impl<'de> Deserialize<'de> for StreamPostQueuedBehind {
1052 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1053 let s = String::deserialize(deserializer)?;
1054 Ok(Self::from(s.as_str()))
1055 }
1056}
1057
1058/// What a turn was given to settle before its own prompt, carried as
1059/// [`ResultMessage::turn_start_resume_kind`] (CLI 2.1.287+). A turn given
1060/// more than one reports the first in this order.
1061#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1062pub enum TurnStartResumeKind {
1063 /// `orphaned_permission` — a permission answer whose pending request was
1064 /// lost (as when a worker restarts), so the tool call is settled with
1065 /// that answer.
1066 OrphanedPermission,
1067 /// `turn_handoff` — a turn in progress that the session's client handed
1068 /// over.
1069 TurnHandoff,
1070 /// `deferred_tool_use` — a call a `PreToolUse` hook deferred.
1071 DeferredToolUse,
1072 /// `none` — nothing to settle (`resume_other` is still reported, at
1073 /// about 0).
1074 None,
1075 /// A value not yet known to this version of the crate.
1076 Unknown(String),
1077}
1078
1079impl TurnStartResumeKind {
1080 pub fn as_str(&self) -> &str {
1081 match self {
1082 Self::OrphanedPermission => "orphaned_permission",
1083 Self::TurnHandoff => "turn_handoff",
1084 Self::DeferredToolUse => "deferred_tool_use",
1085 Self::None => "none",
1086 Self::Unknown(s) => s.as_str(),
1087 }
1088 }
1089}
1090
1091impl fmt::Display for TurnStartResumeKind {
1092 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1093 f.write_str(self.as_str())
1094 }
1095}
1096
1097impl From<&str> for TurnStartResumeKind {
1098 fn from(s: &str) -> Self {
1099 match s {
1100 "orphaned_permission" => Self::OrphanedPermission,
1101 "turn_handoff" => Self::TurnHandoff,
1102 "deferred_tool_use" => Self::DeferredToolUse,
1103 "none" => Self::None,
1104 other => Self::Unknown(other.to_string()),
1105 }
1106 }
1107}
1108
1109impl Serialize for TurnStartResumeKind {
1110 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1111 serializer.serialize_str(self.as_str())
1112 }
1113}
1114
1115impl<'de> Deserialize<'de> for TurnStartResumeKind {
1116 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1117 let s = String::deserialize(deserializer)?;
1118 Ok(Self::from(s.as_str()))
1119 }
1120}
1121
1122/// Why fast mode can't serve right now, carried on `result` frames and
1123/// `system/init` (CLI 2.1.219+). Absent when nothing blocks fast mode.
1124#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1125pub enum FastModeDisabledReason {
1126 /// Free-tier account.
1127 Free,
1128 /// Disabled by user preference.
1129 Preference,
1130 /// Extra-usage purchases are disabled for the account.
1131 ExtraUsageDisabled,
1132 /// A network error prevented fast-mode eligibility from resolving.
1133 NetworkError,
1134 /// The CLI could not determine the reason.
1135 UnknownReason,
1136 /// Not a first-party API session (e.g. Bedrock/Vertex).
1137 NotFirstParty,
1138 /// Disabled via environment variable.
1139 DisabledByEnv,
1140 /// The active model does not support fast mode.
1141 ModelNotAllowed,
1142 /// SDK sessions must opt in to fast mode.
1143 SdkOptInRequired,
1144 /// Eligibility is still being determined.
1145 Pending,
1146 /// A reason not yet known to this version of the crate.
1147 Unknown(String),
1148}
1149
1150impl FastModeDisabledReason {
1151 pub fn as_str(&self) -> &str {
1152 match self {
1153 Self::Free => "free",
1154 Self::Preference => "preference",
1155 Self::ExtraUsageDisabled => "extra_usage_disabled",
1156 Self::NetworkError => "network_error",
1157 Self::UnknownReason => "unknown",
1158 Self::NotFirstParty => "not_first_party",
1159 Self::DisabledByEnv => "disabled_by_env",
1160 Self::ModelNotAllowed => "model_not_allowed",
1161 Self::SdkOptInRequired => "sdk_opt_in_required",
1162 Self::Pending => "pending",
1163 Self::Unknown(s) => s.as_str(),
1164 }
1165 }
1166}
1167
1168impl fmt::Display for FastModeDisabledReason {
1169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1170 f.write_str(self.as_str())
1171 }
1172}
1173
1174impl From<&str> for FastModeDisabledReason {
1175 fn from(s: &str) -> Self {
1176 match s {
1177 "free" => Self::Free,
1178 "preference" => Self::Preference,
1179 "extra_usage_disabled" => Self::ExtraUsageDisabled,
1180 "network_error" => Self::NetworkError,
1181 "unknown" => Self::UnknownReason,
1182 "not_first_party" => Self::NotFirstParty,
1183 "disabled_by_env" => Self::DisabledByEnv,
1184 "model_not_allowed" => Self::ModelNotAllowed,
1185 "sdk_opt_in_required" => Self::SdkOptInRequired,
1186 "pending" => Self::Pending,
1187 other => Self::Unknown(other.to_string()),
1188 }
1189 }
1190}
1191
1192impl Serialize for FastModeDisabledReason {
1193 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1194 serializer.serialize_str(self.as_str())
1195 }
1196}
1197
1198impl<'de> Deserialize<'de> for FastModeDisabledReason {
1199 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1200 let s = String::deserialize(deserializer)?;
1201 Ok(Self::from(s.as_str()))
1202 }
1203}
1204
1205/// Result subtypes
1206#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1207pub enum ResultSubtype {
1208 Success,
1209 ErrorMaxTurns,
1210 ErrorDuringExecution,
1211 ErrorMaxBudgetUsd,
1212 ErrorMaxStructuredOutputRetries,
1213 Unknown(String),
1214}
1215
1216impl ResultSubtype {
1217 pub fn as_str(&self) -> &str {
1218 match self {
1219 Self::Success => "success",
1220 Self::ErrorMaxTurns => "error_max_turns",
1221 Self::ErrorDuringExecution => "error_during_execution",
1222 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
1223 Self::ErrorMaxStructuredOutputRetries => "error_max_structured_output_retries",
1224 Self::Unknown(s) => s.as_str(),
1225 }
1226 }
1227}
1228
1229impl fmt::Display for ResultSubtype {
1230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1231 f.write_str(self.as_str())
1232 }
1233}
1234
1235impl From<&str> for ResultSubtype {
1236 fn from(s: &str) -> Self {
1237 match s {
1238 "success" => Self::Success,
1239 "error_max_turns" => Self::ErrorMaxTurns,
1240 "error_during_execution" => Self::ErrorDuringExecution,
1241 "error_max_budget_usd" => Self::ErrorMaxBudgetUsd,
1242 "error_max_structured_output_retries" => Self::ErrorMaxStructuredOutputRetries,
1243 other => Self::Unknown(other.to_string()),
1244 }
1245 }
1246}
1247
1248impl Serialize for ResultSubtype {
1249 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1250 serializer.serialize_str(self.as_str())
1251 }
1252}
1253
1254impl<'de> Deserialize<'de> for ResultSubtype {
1255 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1256 let s = String::deserialize(deserializer)?;
1257 Ok(Self::from(s.as_str()))
1258 }
1259}
1260
1261/// Usage information for the request.
1262///
1263/// **These counters are accumulated roll-ups, not snapshots.** The CLI sums
1264/// usage field-by-field across every API call ("iteration") in the turn's
1265/// tool-use loop, so on a turn with N iterations each counter is the sum of
1266/// all N. This is the right number for **cost**, and the wrong number for
1267/// **context occupancy**: `cache_read_input_tokens` in particular re-counts
1268/// the cached context on every iteration and can exceed the model's context
1269/// window several times over on tool-heavy turns. To estimate context
1270/// occupancy, use the *last* entry of [`iterations`](Self::iterations)
1271/// instead (the CLI itself does exactly this).
1272///
1273/// Note: the `result` frame's usage covers the **main agent only** — the
1274/// subagent (`Task` / sidechain) token rollup the CLI renders as
1275/// `<subagent_tokens>` / `<agent_count>` is not carried here or anywhere
1276/// else on the wire. Accumulate it from `Task` tool results with
1277/// [`SubagentUsageRollup`](crate::SubagentUsageRollup).
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279pub struct UsageInfo {
1280 /// Sum of fresh (uncached) input tokens across all iterations of the turn.
1281 #[serde(default)]
1282 pub input_tokens: u32,
1283 /// Sum of cache-write input tokens across all iterations of the turn.
1284 #[serde(default)]
1285 pub cache_creation_input_tokens: u32,
1286 /// Sum of cache-read input tokens across all iterations of the turn.
1287 /// Re-counts the cached context every iteration — **not** a measure of
1288 /// context occupancy; see the type-level docs.
1289 #[serde(default)]
1290 pub cache_read_input_tokens: u32,
1291 /// Sum of output tokens across all iterations of the turn.
1292 #[serde(default)]
1293 pub output_tokens: u32,
1294 #[serde(default)]
1295 pub server_tool_use: ServerToolUse,
1296 #[serde(default)]
1297 pub service_tier: String,
1298
1299 /// Cache creation breakdown
1300 #[serde(skip_serializing_if = "Option::is_none")]
1301 pub cache_creation: Option<super::message_types::CacheCreationDetails>,
1302
1303 /// Inference geography (e.g., "not_available")
1304 #[serde(skip_serializing_if = "Option::is_none")]
1305 pub inference_geo: Option<String>,
1306
1307 /// Per-iteration usage breakdown **within** this turn — one entry per
1308 /// API call in the tool-use loop, in order. The last entry reflects the
1309 /// final API call and is what the CLI reads for context-size estimates.
1310 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1311 pub iterations: Vec<UsageIteration>,
1312
1313 /// Speed tier (e.g., "standard")
1314 #[serde(skip_serializing_if = "Option::is_none")]
1315 pub speed: Option<String>,
1316
1317 /// Output-token breakdown (CLI 2.1.232+): currently the thinking-token
1318 /// share of `output_tokens`.
1319 #[serde(skip_serializing_if = "Option::is_none")]
1320 pub output_tokens_details: Option<OutputTokensDetails>,
1321
1322 /// Outcome of a fallback-credit token the request presented; `null` on
1323 /// the wire when it presented none (CLI 2.1.285+).
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1325 pub fallback_credit: Option<Value>,
1326}
1327
1328/// Breakdown of a turn's output tokens, carried in
1329/// [`UsageInfo::output_tokens_details`].
1330#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1331pub struct OutputTokensDetails {
1332 /// Output tokens spent on extended thinking.
1333 #[serde(skip_serializing_if = "Option::is_none")]
1334 pub thinking_tokens: Option<u64>,
1335}
1336
1337/// Usage for a single API call ("iteration") within a turn's tool-use loop.
1338///
1339/// Carried in [`UsageInfo::iterations`]. The cache fields are optional on
1340/// the wire: some frames carry only `input_tokens` + `output_tokens`
1341/// (observed with `type: "turn"`), while others carry the full cache
1342/// breakdown (observed with `type: "message"` in captured subagent
1343/// sessions). The CLI computes its context estimate from
1344/// `input_tokens + output_tokens` of the **last** iteration.
1345#[derive(Debug, Clone, Serialize, Deserialize)]
1346pub struct UsageIteration {
1347 #[serde(default)]
1348 pub input_tokens: u32,
1349 #[serde(default)]
1350 pub output_tokens: u32,
1351 /// Cache-read input tokens for this iteration, when carried.
1352 #[serde(default, skip_serializing_if = "Option::is_none")]
1353 pub cache_read_input_tokens: Option<u32>,
1354 /// Cache-write input tokens for this iteration, when carried.
1355 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 pub cache_creation_input_tokens: Option<u32>,
1357 /// Cache-write breakdown by TTL, when carried.
1358 #[serde(default, skip_serializing_if = "Option::is_none")]
1359 pub cache_creation: Option<super::message_types::CacheCreationDetails>,
1360 /// Iteration kind; `"turn"` and `"message"` observed on the wire.
1361 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
1362 pub kind: Option<String>,
1363}
1364
1365/// Server tool usage information
1366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1367pub struct ServerToolUse {
1368 #[serde(default)]
1369 pub web_search_requests: u32,
1370 /// Number of web fetch requests made
1371 #[serde(default)]
1372 pub web_fetch_requests: u32,
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378 use crate::io::ClaudeOutput;
1379
1380 #[test]
1381 fn test_deserialize_result_message() {
1382 let json = r#"{
1383 "type": "result",
1384 "subtype": "success",
1385 "is_error": false,
1386 "duration_ms": 100,
1387 "duration_api_ms": 200,
1388 "num_turns": 1,
1389 "result": "Done",
1390 "session_id": "123",
1391 "total_cost_usd": 0.01,
1392 "permission_denials": []
1393 }"#;
1394
1395 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1396 assert!(!output.is_error());
1397 }
1398
1399 #[test]
1400 fn test_result_subtype_new_and_unknown_values_do_not_fail() {
1401 let json = r#"{
1402 "type": "result",
1403 "subtype": "error_max_budget_usd",
1404 "is_error": true,
1405 "duration_ms": 100,
1406 "duration_api_ms": 200,
1407 "num_turns": 1,
1408 "session_id": "123",
1409 "total_cost_usd": 0.01
1410 }"#;
1411
1412 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1413 let ClaudeOutput::Result(result) = output else {
1414 panic!("Expected Result");
1415 };
1416 assert_eq!(result.subtype, ResultSubtype::ErrorMaxBudgetUsd);
1417
1418 let json = r#"{
1419 "type": "result",
1420 "subtype": "future_result_subtype",
1421 "is_error": true,
1422 "duration_ms": 100,
1423 "duration_api_ms": 200,
1424 "num_turns": 1,
1425 "session_id": "123",
1426 "total_cost_usd": 0.01
1427 }"#;
1428
1429 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1430 let ClaudeOutput::Result(result) = output else {
1431 panic!("Expected Result");
1432 };
1433 assert_eq!(
1434 result.subtype,
1435 ResultSubtype::Unknown("future_result_subtype".to_string())
1436 );
1437 }
1438
1439 #[test]
1440 fn test_deserialize_result_with_permission_denials() {
1441 let json = r#"{
1442 "type": "result",
1443 "subtype": "success",
1444 "is_error": false,
1445 "duration_ms": 100,
1446 "duration_api_ms": 200,
1447 "num_turns": 2,
1448 "result": "Done",
1449 "session_id": "123",
1450 "total_cost_usd": 0.01,
1451 "permission_denials": [
1452 {
1453 "tool_name": "Bash",
1454 "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
1455 "tool_use_id": "toolu_123"
1456 }
1457 ]
1458 }"#;
1459
1460 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1461 if let ClaudeOutput::Result(result) = output {
1462 assert_eq!(result.permission_denials.len(), 1);
1463 assert_eq!(result.permission_denials[0].tool_name, "Bash");
1464 assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
1465 assert_eq!(
1466 result.permission_denials[0]
1467 .tool_input
1468 .get("command")
1469 .unwrap(),
1470 "rm -rf /"
1471 );
1472 } else {
1473 panic!("Expected Result");
1474 }
1475 }
1476
1477 #[test]
1478 fn test_permission_denial_roundtrip() {
1479 let denial = PermissionDenial {
1480 tool_name: "Write".to_string(),
1481 tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
1482 tool_use_id: "toolu_456".to_string(),
1483 };
1484
1485 let json = serde_json::to_string(&denial).unwrap();
1486 assert!(json.contains("\"tool_name\":\"Write\""));
1487 assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
1488 assert!(json.contains("/etc/passwd"));
1489
1490 let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
1491 assert_eq!(parsed, denial);
1492 }
1493
1494 #[test]
1495 fn test_deserialize_result_message_with_errors() {
1496 let json = r#"{
1497 "type": "result",
1498 "subtype": "error_during_execution",
1499 "duration_ms": 0,
1500 "duration_api_ms": 0,
1501 "is_error": true,
1502 "num_turns": 0,
1503 "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
1504 "total_cost_usd": 0,
1505 "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
1506 "permission_denials": []
1507 }"#;
1508
1509 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1510 assert!(output.is_error());
1511
1512 if let ClaudeOutput::Result(res) = output {
1513 assert!(res.is_error);
1514 assert_eq!(res.errors.len(), 1);
1515 assert!(res.errors[0].contains("No conversation found"));
1516 } else {
1517 panic!("Expected Result message");
1518 }
1519 }
1520
1521 #[test]
1522 fn test_deserialize_result_message_errors_defaults_empty() {
1523 let json = r#"{
1524 "type": "result",
1525 "subtype": "success",
1526 "is_error": false,
1527 "duration_ms": 100,
1528 "duration_api_ms": 200,
1529 "num_turns": 1,
1530 "session_id": "123",
1531 "total_cost_usd": 0.01
1532 }"#;
1533
1534 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1535 if let ClaudeOutput::Result(res) = output {
1536 assert!(res.errors.is_empty());
1537 } else {
1538 panic!("Expected Result message");
1539 }
1540 }
1541
1542 #[test]
1543 fn test_result_message_errors_roundtrip() {
1544 let json = r#"{
1545 "type": "result",
1546 "subtype": "error_during_execution",
1547 "is_error": true,
1548 "duration_ms": 0,
1549 "duration_api_ms": 0,
1550 "num_turns": 0,
1551 "session_id": "test-session",
1552 "total_cost_usd": 0.0,
1553 "errors": ["Error 1", "Error 2"]
1554 }"#;
1555
1556 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1557 let reserialized = serde_json::to_string(&output).unwrap();
1558
1559 assert!(reserialized.contains("Error 1"));
1560 assert!(reserialized.contains("Error 2"));
1561 }
1562
1563 #[test]
1564 fn test_result_with_new_fields() {
1565 let json = r#"{
1566 "type": "result",
1567 "subtype": "success",
1568 "is_error": false,
1569 "duration_ms": 5000,
1570 "duration_api_ms": 4500,
1571 "num_turns": 1,
1572 "result": "Done",
1573 "session_id": "abc",
1574 "total_cost_usd": 0.06,
1575 "api_error_status": null,
1576 "stop_reason": "end_turn",
1577 "terminal_reason": "completed",
1578 "fast_mode_state": "off",
1579 "modelUsage": {
1580 "claude-opus-4-7[1m]": {
1581 "inputTokens": 3817,
1582 "outputTokens": 14,
1583 "costUSD": 0.06
1584 }
1585 },
1586 "usage": {
1587 "input_tokens": 3817,
1588 "output_tokens": 14,
1589 "cache_creation_input_tokens": 3540,
1590 "cache_read_input_tokens": 0,
1591 "server_tool_use": {
1592 "web_search_requests": 0,
1593 "web_fetch_requests": 2
1594 },
1595 "service_tier": "standard",
1596 "inference_geo": "not_available",
1597 "speed": "standard",
1598 "iterations": [
1599 {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
1600 ]
1601 }
1602 }"#;
1603
1604 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1605 if let ClaudeOutput::Result(res) = output {
1606 assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
1607 assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
1608 assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
1609 let model_usage = res.model_usage.as_ref().unwrap();
1610 let entry = model_usage
1611 .get("claude-opus-4-7[1m]")
1612 .expect("per-model entry present");
1613 assert_eq!(entry.input_tokens, 3817);
1614 assert_eq!(entry.output_tokens, 14);
1615 assert_eq!(entry.cost_usd, 0.06);
1616 assert!(res.api_error_status.is_none());
1617
1618 let usage = res.usage.unwrap();
1619 assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
1620 assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1621 assert_eq!(usage.speed.as_deref(), Some("standard"));
1622 assert_eq!(usage.iterations.len(), 1);
1623 assert_eq!(usage.iterations[0].input_tokens, 3817);
1624 assert_eq!(usage.iterations[0].output_tokens, 14);
1625 assert_eq!(usage.iterations[0].kind.as_deref(), Some("turn"));
1626 } else {
1627 panic!("Expected Result");
1628 }
1629 }
1630
1631 #[test]
1632 fn test_result_backwards_compatible_without_new_fields() {
1633 // Verify old-format messages still parse fine
1634 let json = r#"{
1635 "type": "result",
1636 "subtype": "success",
1637 "is_error": false,
1638 "duration_ms": 100,
1639 "duration_api_ms": 200,
1640 "num_turns": 1,
1641 "session_id": "abc",
1642 "total_cost_usd": 0.01
1643 }"#;
1644
1645 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1646 if let ClaudeOutput::Result(res) = output {
1647 assert!(res.api_error_status.is_none());
1648 assert!(res.stop_reason.is_none());
1649 assert!(res.terminal_reason.is_none());
1650 assert!(res.fast_mode_state.is_none());
1651 assert!(res.model_usage.is_none());
1652 } else {
1653 panic!("Expected Result");
1654 }
1655 }
1656
1657 #[test]
1658 fn test_result_fast_mode_disabled_reason() {
1659 let json = r#"{
1660 "type":"result","subtype":"success","is_error":false,
1661 "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1662 "session_id":"s1","total_cost_usd":0.01,
1663 "fast_mode_state":"off",
1664 "fast_mode_disabled_reason":"sdk_opt_in_required"
1665 }"#;
1666 let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1667 let crate::ClaudeOutput::Result(res) = &output else {
1668 panic!("expected Result");
1669 };
1670 assert_eq!(
1671 res.fast_mode_disabled_reason,
1672 Some(FastModeDisabledReason::SdkOptInRequired)
1673 );
1674 assert!(serde_json::to_string(&output)
1675 .unwrap()
1676 .contains("\"fast_mode_disabled_reason\":\"sdk_opt_in_required\""));
1677
1678 // Unknown reasons survive decode and round-trip verbatim; the wire
1679 // literal "unknown" maps to the typed UnknownReason, not the fallback.
1680 assert_eq!(
1681 FastModeDisabledReason::from("unknown"),
1682 FastModeDisabledReason::UnknownReason
1683 );
1684 let novel = FastModeDisabledReason::from("solar_flare");
1685 assert_eq!(novel, FastModeDisabledReason::Unknown("solar_flare".into()));
1686 assert_eq!(novel.as_str(), "solar_flare");
1687 }
1688
1689 #[test]
1690 fn test_result_timing_and_user_message_uuid_fields() {
1691 let json = r#"{
1692 "type":"result","subtype":"success","is_error":false,
1693 "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1694 "session_id":"s1","total_cost_usd":0.01,
1695 "request_sent_wall_ms":1753212345678.25,
1696 "user_message_uuid":"um-1"
1697 }"#;
1698 let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1699 let crate::ClaudeOutput::Result(res) = &output else {
1700 panic!("expected Result");
1701 };
1702 assert_eq!(res.request_sent_wall_ms, Some(1753212345678.25));
1703 assert_eq!(res.user_message_uuid.as_deref(), Some("um-1"));
1704
1705 let reserialized = serde_json::to_string(&output).unwrap();
1706 assert!(reserialized.contains("\"user_message_uuid\":\"um-1\""));
1707 }
1708
1709 #[test]
1710 fn test_result_queued_turn_count_and_user_message_uuids() {
1711 // CLI 2.1.259 additive fields on the result frame.
1712 let json = r#"{
1713 "type":"result","subtype":"success","is_error":false,
1714 "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1715 "session_id":"s1","total_cost_usd":0.01,
1716 "user_message_uuid":"um-2",
1717 "user_message_uuids":["um-1","um-2"],
1718 "queued_turn_count":3
1719 }"#;
1720 let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1721 let crate::ClaudeOutput::Result(res) = &output else {
1722 panic!("expected Result");
1723 };
1724 assert_eq!(res.user_message_uuids, vec!["um-1", "um-2"]);
1725 assert_eq!(res.queued_turn_count, Some(3));
1726
1727 let reserialized = serde_json::to_string(&output).unwrap();
1728 assert!(reserialized.contains("\"user_message_uuids\":[\"um-1\",\"um-2\"]"));
1729 assert!(reserialized.contains("\"queued_turn_count\":3"));
1730
1731 // Both fields are absent from older producers and must default cleanly.
1732 let old = r#"{
1733 "type":"result","subtype":"success","is_error":false,
1734 "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1735 "session_id":"s1","total_cost_usd":0.01
1736 }"#;
1737 let output: crate::ClaudeOutput = serde_json::from_str(old).unwrap();
1738 let crate::ClaudeOutput::Result(res) = &output else {
1739 panic!("expected Result");
1740 };
1741 assert!(res.user_message_uuids.is_empty());
1742 assert_eq!(res.queued_turn_count, None);
1743 let reserialized = serde_json::to_string(&output).unwrap();
1744 assert!(!reserialized.contains("user_message_uuids"));
1745 assert!(!reserialized.contains("queued_turn_count"));
1746 }
1747}