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