Skip to main content

claude_codes/io/
result.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::fmt;
4
5/// Result message for completed queries
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ResultMessage {
8    pub subtype: ResultSubtype,
9    pub is_error: bool,
10    pub duration_ms: u64,
11    pub duration_api_ms: u64,
12
13    /// Time to first token, in milliseconds.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub ttft_ms: Option<u64>,
16
17    /// Time to first streamed token, in milliseconds.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub ttft_stream_ms: Option<u64>,
20
21    /// Time from session start until the first request was issued, in milliseconds.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub time_to_request_ms: Option<u64>,
24
25    /// Time from spawning a worker/spare until the first request was issued, in milliseconds.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub time_to_request_from_spawn_ms: Option<u64>,
28
29    /// Whether a warm spare process was claimed for this request.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub warm_spare_claimed: Option<bool>,
32
33    /// Epoch-ish timestamp origin used by CLI timing instrumentation.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub time_origin_ms: Option<u64>,
36
37    /// Wall-clock epoch milliseconds when the API request was sent
38    /// (fractional; from CLI timing instrumentation).
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub request_sent_wall_ms: Option<f64>,
41
42    /// Time until the first content frame of the stream arrived, in
43    /// milliseconds (CLI 2.1.260+ timing instrumentation).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub first_content_frame_ms: Option<u64>,
46
47    /// Time until the first stream POST was issued, in milliseconds
48    /// (CLI 2.1.260+).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub first_stream_post_ms: Option<u64>,
51
52    /// Time until the first stream POST was acknowledged, in milliseconds
53    /// (CLI 2.1.260+).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub first_stream_post_ack_ms: Option<u64>,
56
57    /// Wall-clock epoch milliseconds when the first stream POST was issued
58    /// (fractional; CLI 2.1.260+).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub first_stream_post_wall_ms: Option<f64>,
61
62    /// Wire uuid of the user message this result answers.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub user_message_uuid: Option<String>,
65
66    /// Client uuids of every user message whose prompt this turn consumed, in
67    /// consumption order — all members of a prompt batch the host merged into
68    /// this one turn, plus any queued user message folded into the running
69    /// turn between tool rounds. Always contains `user_message_uuid`; at most
70    /// 64 entries. Absent on delivery-failure/zeroed results and from CLIs
71    /// before 2.1.259 (fall back to `user_message_uuid`).
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub user_message_uuids: Vec<String>,
74
75    /// Why this turn was the automatic re-run of a turn a worker restart
76    /// interrupted (`CLAUDE_CODE_RESUME_INTERRUPTED_TURN`): the host's
77    /// `CLAUDE_CODE_RESUME_REASON` when it set one (`host_draining`,
78    /// `checkpoint_restore`, `container_recreated`, ...), else
79    /// `interrupted_turn`. Present on a headless re-run's result, success or
80    /// error; absent on every other turn, on the Remote Control bridge's
81    /// per-turn synthetic results, and from CLIs before 2.1.268.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub resume_reason: Option<String>,
84
85    /// The local slash command that produced this result when the query loop
86    /// was bypassed (success results only). Absent when the turn went to the
87    /// model and from CLIs before 2.1.268.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub local_command: Option<String>,
90
91    /// User-initiated sends still waiting in the command queue when this
92    /// result was produced. Greater than 0 means at least one more user turn
93    /// follows without further input, barring cancellation. Absent on fatal
94    /// startup results, on surfaces without a command queue, and from CLIs
95    /// before 2.1.259.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub queued_turn_count: Option<u64>,
98
99    pub num_turns: i32,
100
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub result: Option<String>,
103
104    #[serde(alias = "sessionId")]
105    pub session_id: String,
106    pub total_cost_usd: f64,
107
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub usage: Option<UsageInfo>,
110
111    /// Tools that were blocked due to permission denials during the session
112    #[serde(default)]
113    pub permission_denials: Vec<PermissionDenial>,
114
115    /// Error messages when `is_error` is true.
116    ///
117    /// Contains human-readable error strings (e.g., "No conversation found with session ID: ...").
118    /// This allows typed access to error conditions without needing to serialize to JSON and search.
119    #[serde(default)]
120    pub errors: Vec<String>,
121
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub uuid: Option<String>,
124
125    /// HTTP status code when the result is an API error (e.g., 429, 500, 529)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub api_error_status: Option<u16>,
128
129    /// Why generation stopped (e.g., end_turn, max_tokens)
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub stop_reason: Option<String>,
132
133    /// Why the session ended (e.g., "completed")
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub terminal_reason: Option<String>,
136
137    /// Delivery sequence of this result within the run: how many results the
138    /// run numbered before this one, starting at 0, in the order the process
139    /// writes them. A result whose write fails still consumes its number, so
140    /// a gap means a result was lost. Distinct from `num_turns`. Numbered by
141    /// the process hosting the run (`claude -p`); a local client relaying a
142    /// cloud session passes the cloud numbering through and its own locally
143    /// built error results carry none. Absent from CLIs before 2.1.268.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub result_index: Option<u64>,
146
147    /// Fast mode toggle state (e.g., "off")
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub fast_mode_state: Option<String>,
150
151    /// Why fast mode can't serve right now. Absent when nothing blocks it.
152    /// A paused-after-rate-limit run is not reported here; it rides
153    /// `fast_mode_state` as `"cooldown"`.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub fast_mode_disabled_reason: Option<FastModeDisabledReason>,
156
157    /// Per-model cost breakdown, keyed by model name (e.g. `"claude-opus-4-8"`).
158    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
159    pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
160
161    /// Subagents started through the Agent tool in this session, as running
162    /// totals. Cumulative like `modelUsage`: read the latest result rather
163    /// than summing across results. Absent from CLIs before 2.1.239.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub subagent_stats: Option<SubagentStats>,
166
167    /// Structured-output payload returned by the model, when enabled.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub structured_output: Option<Value>,
170
171    /// Deferred tool-use termination payload.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub deferred_tool_use: Option<DeferredToolUse>,
174
175    /// Provenance of the message/run.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub origin: Option<super::message_types::MessageOrigin>,
178
179    /// Set by the self-hosted runner on the result it synthesizes when the
180    /// session process fails to start or exits abnormally. Absent on normal
181    /// results and on CLIs before 2.1.266.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub runner_exit: Option<RunnerExit>,
184}
185
186/// How the self-hosted runner's session process terminated, carried on a
187/// synthesized failure [`ResultMessage`] as [`ResultMessage::runner_exit`]
188/// (CLI 2.1.266+).
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct RunnerExit {
191    /// Which phase the process was in when it exited.
192    pub phase: RunnerExitPhase,
193    /// Process exit code. Absent (or wire `null`) when it exited on a signal
194    /// instead, or when the runner did not capture one.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub exit_code: Option<i64>,
197    /// Terminating signal name. Absent (or wire `null`) when it exited with a
198    /// code instead, or when the runner did not capture one.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub signal: Option<String>,
201}
202
203/// Which phase the runner's session process was in when it exited, in
204/// [`RunnerExit::phase`]. Open set — unrecognized values deserialize to
205/// [`RunnerExitPhase::Unknown`].
206#[derive(Debug, Clone, PartialEq, Eq, Hash)]
207pub enum RunnerExitPhase {
208    /// Failed while starting up.
209    Setup,
210    /// Failed once running.
211    Run,
212    /// A phase not yet known to this version of the crate.
213    Unknown(String),
214}
215
216impl RunnerExitPhase {
217    pub fn as_str(&self) -> &str {
218        match self {
219            Self::Setup => "setup",
220            Self::Run => "run",
221            Self::Unknown(s) => s.as_str(),
222        }
223    }
224}
225
226impl std::fmt::Display for RunnerExitPhase {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.write_str(self.as_str())
229    }
230}
231
232impl From<&str> for RunnerExitPhase {
233    fn from(s: &str) -> Self {
234        match s {
235            "setup" => Self::Setup,
236            "run" => Self::Run,
237            other => Self::Unknown(other.to_string()),
238        }
239    }
240}
241
242impl Serialize for RunnerExitPhase {
243    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
244        serializer.serialize_str(self.as_str())
245    }
246}
247
248impl<'de> Deserialize<'de> for RunnerExitPhase {
249    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
250        let s = String::deserialize(deserializer)?;
251        Ok(Self::from(s.as_str()))
252    }
253}
254
255/// Usage and cost for a single model within a session, as found in
256/// [`ResultMessage::model_usage`].
257///
258/// The `extra` field captures any keys the CLI adds that aren't modeled here,
259/// so new wire fields deserialize without error.
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct ModelUsageEntry {
263    #[serde(default)]
264    pub input_tokens: u64,
265    #[serde(default)]
266    pub output_tokens: u64,
267    #[serde(default)]
268    pub cache_read_input_tokens: u64,
269    #[serde(default)]
270    pub cache_creation_input_tokens: u64,
271    #[serde(default, rename = "costUSD")]
272    pub cost_usd: f64,
273    #[serde(default)]
274    pub web_search_requests: u32,
275    #[serde(default)]
276    pub context_window: u64,
277    #[serde(default)]
278    pub max_output_tokens: u64,
279    #[serde(flatten)]
280    pub extra: serde_json::Map<String, serde_json::Value>,
281}
282
283/// Tool use deferred by a terminal result.
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
285pub struct DeferredToolUse {
286    pub id: String,
287    pub name: String,
288    pub input: Value,
289}
290
291/// Running totals for subagents started through the Agent tool, carried on
292/// `result` frames (CLI 2.1.239+) as [`ResultMessage::subagent_stats`].
293///
294/// Cumulative for the session: a resumed session starts fresh, and a
295/// mid-session `/clear` zeroes it — though a background subagent that outlives
296/// the `/clear` still records its outcome, so `completed`, `failed`, and
297/// `killed` can then exceed `spawned`. Forked skills, workflows, teammates,
298/// and other internal agents are not counted.
299#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
300pub struct SubagentStats {
301    /// Subagents actually started; a refused or failed launch is not counted.
302    pub spawned: u64,
303    /// Spawns by the `run_in_background` value the model passed; all count as
304    /// `unset` while the parameter is not offered.
305    pub requested: SubagentSpawnRequests,
306    /// Spawns that started in the background after defaults and session
307    /// settings; the rest blocked the spawning tool call.
308    pub started_in_background: u64,
309    /// Spawns by agent type.
310    #[serde(default)]
311    pub by_type: std::collections::BTreeMap<String, u64>,
312    /// Deepest spawn: 1 = started by the main thread, 2 = by a depth-1
313    /// subagent.
314    pub max_depth: u64,
315    /// Spawns made from inside another subagent (depth > 1).
316    pub spawned_by_subagents: u64,
317    pub completed: u64,
318    pub failed: u64,
319    /// Subagents stopped before finishing, by who stopped them.
320    pub killed: SubagentKillCounts,
321    /// Agent tool calls turned down because a limit was reached (other
322    /// denials, such as an unknown agent type, are not counted).
323    pub refused: SubagentRefusalCounts,
324}
325
326/// Spawn counts by the `run_in_background` value the model passed, carried in
327/// [`SubagentStats::requested`].
328#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
329pub struct SubagentSpawnRequests {
330    pub background: u64,
331    pub foreground: u64,
332    pub unset: u64,
333}
334
335/// Subagents stopped before finishing, carried in [`SubagentStats::killed`].
336/// `parent` = by another agent through TaskStop; `system` = by Claude Code
337/// itself; `user` = any other stop.
338#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
339pub struct SubagentKillCounts {
340    pub parent: u64,
341    pub user: u64,
342    pub system: u64,
343}
344
345/// Agent tool calls refused at a limit, carried in [`SubagentStats::refused`].
346#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
347pub struct SubagentRefusalCounts {
348    pub depth_limit: u64,
349    pub concurrency_limit: u64,
350    pub budget: u64,
351}
352
353/// A record of a tool permission that was denied during the session.
354///
355/// This is included in `ResultMessage.permission_denials` to provide a summary
356/// of all permission denials that occurred.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
358pub struct PermissionDenial {
359    /// The name of the tool that was blocked (e.g., "Bash", "Write")
360    pub tool_name: String,
361
362    /// The input that was passed to the tool
363    pub tool_input: Value,
364
365    /// The unique identifier for this tool use request
366    pub tool_use_id: String,
367}
368
369/// Why fast mode can't serve right now, carried on `result` frames and
370/// `system/init` (CLI 2.1.219+). Absent when nothing blocks fast mode.
371#[derive(Debug, Clone, PartialEq, Eq, Hash)]
372pub enum FastModeDisabledReason {
373    /// Free-tier account.
374    Free,
375    /// Disabled by user preference.
376    Preference,
377    /// Extra-usage purchases are disabled for the account.
378    ExtraUsageDisabled,
379    /// A network error prevented fast-mode eligibility from resolving.
380    NetworkError,
381    /// The CLI could not determine the reason.
382    UnknownReason,
383    /// Not a first-party API session (e.g. Bedrock/Vertex).
384    NotFirstParty,
385    /// Disabled via environment variable.
386    DisabledByEnv,
387    /// The active model does not support fast mode.
388    ModelNotAllowed,
389    /// SDK sessions must opt in to fast mode.
390    SdkOptInRequired,
391    /// Eligibility is still being determined.
392    Pending,
393    /// A reason not yet known to this version of the crate.
394    Unknown(String),
395}
396
397impl FastModeDisabledReason {
398    pub fn as_str(&self) -> &str {
399        match self {
400            Self::Free => "free",
401            Self::Preference => "preference",
402            Self::ExtraUsageDisabled => "extra_usage_disabled",
403            Self::NetworkError => "network_error",
404            Self::UnknownReason => "unknown",
405            Self::NotFirstParty => "not_first_party",
406            Self::DisabledByEnv => "disabled_by_env",
407            Self::ModelNotAllowed => "model_not_allowed",
408            Self::SdkOptInRequired => "sdk_opt_in_required",
409            Self::Pending => "pending",
410            Self::Unknown(s) => s.as_str(),
411        }
412    }
413}
414
415impl fmt::Display for FastModeDisabledReason {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        f.write_str(self.as_str())
418    }
419}
420
421impl From<&str> for FastModeDisabledReason {
422    fn from(s: &str) -> Self {
423        match s {
424            "free" => Self::Free,
425            "preference" => Self::Preference,
426            "extra_usage_disabled" => Self::ExtraUsageDisabled,
427            "network_error" => Self::NetworkError,
428            "unknown" => Self::UnknownReason,
429            "not_first_party" => Self::NotFirstParty,
430            "disabled_by_env" => Self::DisabledByEnv,
431            "model_not_allowed" => Self::ModelNotAllowed,
432            "sdk_opt_in_required" => Self::SdkOptInRequired,
433            "pending" => Self::Pending,
434            other => Self::Unknown(other.to_string()),
435        }
436    }
437}
438
439impl Serialize for FastModeDisabledReason {
440    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
441        serializer.serialize_str(self.as_str())
442    }
443}
444
445impl<'de> Deserialize<'de> for FastModeDisabledReason {
446    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
447        let s = String::deserialize(deserializer)?;
448        Ok(Self::from(s.as_str()))
449    }
450}
451
452/// Result subtypes
453#[derive(Debug, Clone, PartialEq, Eq, Hash)]
454pub enum ResultSubtype {
455    Success,
456    ErrorMaxTurns,
457    ErrorDuringExecution,
458    ErrorMaxBudgetUsd,
459    ErrorMaxStructuredOutputRetries,
460    Unknown(String),
461}
462
463impl ResultSubtype {
464    pub fn as_str(&self) -> &str {
465        match self {
466            Self::Success => "success",
467            Self::ErrorMaxTurns => "error_max_turns",
468            Self::ErrorDuringExecution => "error_during_execution",
469            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
470            Self::ErrorMaxStructuredOutputRetries => "error_max_structured_output_retries",
471            Self::Unknown(s) => s.as_str(),
472        }
473    }
474}
475
476impl fmt::Display for ResultSubtype {
477    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478        f.write_str(self.as_str())
479    }
480}
481
482impl From<&str> for ResultSubtype {
483    fn from(s: &str) -> Self {
484        match s {
485            "success" => Self::Success,
486            "error_max_turns" => Self::ErrorMaxTurns,
487            "error_during_execution" => Self::ErrorDuringExecution,
488            "error_max_budget_usd" => Self::ErrorMaxBudgetUsd,
489            "error_max_structured_output_retries" => Self::ErrorMaxStructuredOutputRetries,
490            other => Self::Unknown(other.to_string()),
491        }
492    }
493}
494
495impl Serialize for ResultSubtype {
496    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
497        serializer.serialize_str(self.as_str())
498    }
499}
500
501impl<'de> Deserialize<'de> for ResultSubtype {
502    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
503        let s = String::deserialize(deserializer)?;
504        Ok(Self::from(s.as_str()))
505    }
506}
507
508/// Usage information for the request.
509///
510/// **These counters are accumulated roll-ups, not snapshots.** The CLI sums
511/// usage field-by-field across every API call ("iteration") in the turn's
512/// tool-use loop, so on a turn with N iterations each counter is the sum of
513/// all N. This is the right number for **cost**, and the wrong number for
514/// **context occupancy**: `cache_read_input_tokens` in particular re-counts
515/// the cached context on every iteration and can exceed the model's context
516/// window several times over on tool-heavy turns. To estimate context
517/// occupancy, use the *last* entry of [`iterations`](Self::iterations)
518/// instead (the CLI itself does exactly this).
519///
520/// Note: the `result` frame's usage covers the **main agent only** — the
521/// subagent (`Task` / sidechain) token rollup the CLI renders as
522/// `<subagent_tokens>` / `<agent_count>` is not carried here or anywhere
523/// else on the wire. Accumulate it from `Task` tool results with
524/// [`SubagentUsageRollup`](crate::SubagentUsageRollup).
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct UsageInfo {
527    /// Sum of fresh (uncached) input tokens across all iterations of the turn.
528    #[serde(default)]
529    pub input_tokens: u32,
530    /// Sum of cache-write input tokens across all iterations of the turn.
531    #[serde(default)]
532    pub cache_creation_input_tokens: u32,
533    /// Sum of cache-read input tokens across all iterations of the turn.
534    /// Re-counts the cached context every iteration — **not** a measure of
535    /// context occupancy; see the type-level docs.
536    #[serde(default)]
537    pub cache_read_input_tokens: u32,
538    /// Sum of output tokens across all iterations of the turn.
539    #[serde(default)]
540    pub output_tokens: u32,
541    #[serde(default)]
542    pub server_tool_use: ServerToolUse,
543    #[serde(default)]
544    pub service_tier: String,
545
546    /// Cache creation breakdown
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
549
550    /// Inference geography (e.g., "not_available")
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub inference_geo: Option<String>,
553
554    /// Per-iteration usage breakdown **within** this turn — one entry per
555    /// API call in the tool-use loop, in order. The last entry reflects the
556    /// final API call and is what the CLI reads for context-size estimates.
557    #[serde(default, skip_serializing_if = "Vec::is_empty")]
558    pub iterations: Vec<UsageIteration>,
559
560    /// Speed tier (e.g., "standard")
561    #[serde(skip_serializing_if = "Option::is_none")]
562    pub speed: Option<String>,
563
564    /// Output-token breakdown (CLI 2.1.232+): currently the thinking-token
565    /// share of `output_tokens`.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub output_tokens_details: Option<OutputTokensDetails>,
568}
569
570/// Breakdown of a turn's output tokens, carried in
571/// [`UsageInfo::output_tokens_details`].
572#[derive(Debug, Clone, Serialize, Deserialize, Default)]
573pub struct OutputTokensDetails {
574    /// Output tokens spent on extended thinking.
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub thinking_tokens: Option<u64>,
577}
578
579/// Usage for a single API call ("iteration") within a turn's tool-use loop.
580///
581/// Carried in [`UsageInfo::iterations`]. The cache fields are optional on
582/// the wire: some frames carry only `input_tokens` + `output_tokens`
583/// (observed with `type: "turn"`), while others carry the full cache
584/// breakdown (observed with `type: "message"` in captured subagent
585/// sessions). The CLI computes its context estimate from
586/// `input_tokens + output_tokens` of the **last** iteration.
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct UsageIteration {
589    #[serde(default)]
590    pub input_tokens: u32,
591    #[serde(default)]
592    pub output_tokens: u32,
593    /// Cache-read input tokens for this iteration, when carried.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub cache_read_input_tokens: Option<u32>,
596    /// Cache-write input tokens for this iteration, when carried.
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub cache_creation_input_tokens: Option<u32>,
599    /// Cache-write breakdown by TTL, when carried.
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
602    /// Iteration kind; `"turn"` and `"message"` observed on the wire.
603    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
604    pub kind: Option<String>,
605}
606
607/// Server tool usage information
608#[derive(Debug, Clone, Default, Serialize, Deserialize)]
609pub struct ServerToolUse {
610    #[serde(default)]
611    pub web_search_requests: u32,
612    /// Number of web fetch requests made
613    #[serde(default)]
614    pub web_fetch_requests: u32,
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::io::ClaudeOutput;
621
622    #[test]
623    fn test_deserialize_result_message() {
624        let json = r#"{
625            "type": "result",
626            "subtype": "success",
627            "is_error": false,
628            "duration_ms": 100,
629            "duration_api_ms": 200,
630            "num_turns": 1,
631            "result": "Done",
632            "session_id": "123",
633            "total_cost_usd": 0.01,
634            "permission_denials": []
635        }"#;
636
637        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
638        assert!(!output.is_error());
639    }
640
641    #[test]
642    fn test_result_subtype_new_and_unknown_values_do_not_fail() {
643        let json = r#"{
644            "type": "result",
645            "subtype": "error_max_budget_usd",
646            "is_error": true,
647            "duration_ms": 100,
648            "duration_api_ms": 200,
649            "num_turns": 1,
650            "session_id": "123",
651            "total_cost_usd": 0.01
652        }"#;
653
654        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
655        let ClaudeOutput::Result(result) = output else {
656            panic!("Expected Result");
657        };
658        assert_eq!(result.subtype, ResultSubtype::ErrorMaxBudgetUsd);
659
660        let json = r#"{
661            "type": "result",
662            "subtype": "future_result_subtype",
663            "is_error": true,
664            "duration_ms": 100,
665            "duration_api_ms": 200,
666            "num_turns": 1,
667            "session_id": "123",
668            "total_cost_usd": 0.01
669        }"#;
670
671        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
672        let ClaudeOutput::Result(result) = output else {
673            panic!("Expected Result");
674        };
675        assert_eq!(
676            result.subtype,
677            ResultSubtype::Unknown("future_result_subtype".to_string())
678        );
679    }
680
681    #[test]
682    fn test_deserialize_result_with_permission_denials() {
683        let json = r#"{
684            "type": "result",
685            "subtype": "success",
686            "is_error": false,
687            "duration_ms": 100,
688            "duration_api_ms": 200,
689            "num_turns": 2,
690            "result": "Done",
691            "session_id": "123",
692            "total_cost_usd": 0.01,
693            "permission_denials": [
694                {
695                    "tool_name": "Bash",
696                    "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
697                    "tool_use_id": "toolu_123"
698                }
699            ]
700        }"#;
701
702        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
703        if let ClaudeOutput::Result(result) = output {
704            assert_eq!(result.permission_denials.len(), 1);
705            assert_eq!(result.permission_denials[0].tool_name, "Bash");
706            assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
707            assert_eq!(
708                result.permission_denials[0]
709                    .tool_input
710                    .get("command")
711                    .unwrap(),
712                "rm -rf /"
713            );
714        } else {
715            panic!("Expected Result");
716        }
717    }
718
719    #[test]
720    fn test_permission_denial_roundtrip() {
721        let denial = PermissionDenial {
722            tool_name: "Write".to_string(),
723            tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
724            tool_use_id: "toolu_456".to_string(),
725        };
726
727        let json = serde_json::to_string(&denial).unwrap();
728        assert!(json.contains("\"tool_name\":\"Write\""));
729        assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
730        assert!(json.contains("/etc/passwd"));
731
732        let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
733        assert_eq!(parsed, denial);
734    }
735
736    #[test]
737    fn test_deserialize_result_message_with_errors() {
738        let json = r#"{
739            "type": "result",
740            "subtype": "error_during_execution",
741            "duration_ms": 0,
742            "duration_api_ms": 0,
743            "is_error": true,
744            "num_turns": 0,
745            "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
746            "total_cost_usd": 0,
747            "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
748            "permission_denials": []
749        }"#;
750
751        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
752        assert!(output.is_error());
753
754        if let ClaudeOutput::Result(res) = output {
755            assert!(res.is_error);
756            assert_eq!(res.errors.len(), 1);
757            assert!(res.errors[0].contains("No conversation found"));
758        } else {
759            panic!("Expected Result message");
760        }
761    }
762
763    #[test]
764    fn test_deserialize_result_message_errors_defaults_empty() {
765        let json = r#"{
766            "type": "result",
767            "subtype": "success",
768            "is_error": false,
769            "duration_ms": 100,
770            "duration_api_ms": 200,
771            "num_turns": 1,
772            "session_id": "123",
773            "total_cost_usd": 0.01
774        }"#;
775
776        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
777        if let ClaudeOutput::Result(res) = output {
778            assert!(res.errors.is_empty());
779        } else {
780            panic!("Expected Result message");
781        }
782    }
783
784    #[test]
785    fn test_result_message_errors_roundtrip() {
786        let json = r#"{
787            "type": "result",
788            "subtype": "error_during_execution",
789            "is_error": true,
790            "duration_ms": 0,
791            "duration_api_ms": 0,
792            "num_turns": 0,
793            "session_id": "test-session",
794            "total_cost_usd": 0.0,
795            "errors": ["Error 1", "Error 2"]
796        }"#;
797
798        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
799        let reserialized = serde_json::to_string(&output).unwrap();
800
801        assert!(reserialized.contains("Error 1"));
802        assert!(reserialized.contains("Error 2"));
803    }
804
805    #[test]
806    fn test_result_with_new_fields() {
807        let json = r#"{
808            "type": "result",
809            "subtype": "success",
810            "is_error": false,
811            "duration_ms": 5000,
812            "duration_api_ms": 4500,
813            "num_turns": 1,
814            "result": "Done",
815            "session_id": "abc",
816            "total_cost_usd": 0.06,
817            "api_error_status": null,
818            "stop_reason": "end_turn",
819            "terminal_reason": "completed",
820            "fast_mode_state": "off",
821            "modelUsage": {
822                "claude-opus-4-7[1m]": {
823                    "inputTokens": 3817,
824                    "outputTokens": 14,
825                    "costUSD": 0.06
826                }
827            },
828            "usage": {
829                "input_tokens": 3817,
830                "output_tokens": 14,
831                "cache_creation_input_tokens": 3540,
832                "cache_read_input_tokens": 0,
833                "server_tool_use": {
834                    "web_search_requests": 0,
835                    "web_fetch_requests": 2
836                },
837                "service_tier": "standard",
838                "inference_geo": "not_available",
839                "speed": "standard",
840                "iterations": [
841                    {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
842                ]
843            }
844        }"#;
845
846        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
847        if let ClaudeOutput::Result(res) = output {
848            assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
849            assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
850            assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
851            let model_usage = res.model_usage.as_ref().unwrap();
852            let entry = model_usage
853                .get("claude-opus-4-7[1m]")
854                .expect("per-model entry present");
855            assert_eq!(entry.input_tokens, 3817);
856            assert_eq!(entry.output_tokens, 14);
857            assert_eq!(entry.cost_usd, 0.06);
858            assert!(res.api_error_status.is_none());
859
860            let usage = res.usage.unwrap();
861            assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
862            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
863            assert_eq!(usage.speed.as_deref(), Some("standard"));
864            assert_eq!(usage.iterations.len(), 1);
865            assert_eq!(usage.iterations[0].input_tokens, 3817);
866            assert_eq!(usage.iterations[0].output_tokens, 14);
867            assert_eq!(usage.iterations[0].kind.as_deref(), Some("turn"));
868        } else {
869            panic!("Expected Result");
870        }
871    }
872
873    #[test]
874    fn test_result_backwards_compatible_without_new_fields() {
875        // Verify old-format messages still parse fine
876        let json = r#"{
877            "type": "result",
878            "subtype": "success",
879            "is_error": false,
880            "duration_ms": 100,
881            "duration_api_ms": 200,
882            "num_turns": 1,
883            "session_id": "abc",
884            "total_cost_usd": 0.01
885        }"#;
886
887        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
888        if let ClaudeOutput::Result(res) = output {
889            assert!(res.api_error_status.is_none());
890            assert!(res.stop_reason.is_none());
891            assert!(res.terminal_reason.is_none());
892            assert!(res.fast_mode_state.is_none());
893            assert!(res.model_usage.is_none());
894        } else {
895            panic!("Expected Result");
896        }
897    }
898
899    #[test]
900    fn test_result_fast_mode_disabled_reason() {
901        let json = r#"{
902            "type":"result","subtype":"success","is_error":false,
903            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
904            "session_id":"s1","total_cost_usd":0.01,
905            "fast_mode_state":"off",
906            "fast_mode_disabled_reason":"sdk_opt_in_required"
907        }"#;
908        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
909        let crate::ClaudeOutput::Result(res) = &output else {
910            panic!("expected Result");
911        };
912        assert_eq!(
913            res.fast_mode_disabled_reason,
914            Some(FastModeDisabledReason::SdkOptInRequired)
915        );
916        assert!(serde_json::to_string(&output)
917            .unwrap()
918            .contains("\"fast_mode_disabled_reason\":\"sdk_opt_in_required\""));
919
920        // Unknown reasons survive decode and round-trip verbatim; the wire
921        // literal "unknown" maps to the typed UnknownReason, not the fallback.
922        assert_eq!(
923            FastModeDisabledReason::from("unknown"),
924            FastModeDisabledReason::UnknownReason
925        );
926        let novel = FastModeDisabledReason::from("solar_flare");
927        assert_eq!(novel, FastModeDisabledReason::Unknown("solar_flare".into()));
928        assert_eq!(novel.as_str(), "solar_flare");
929    }
930
931    #[test]
932    fn test_result_timing_and_user_message_uuid_fields() {
933        let json = r#"{
934            "type":"result","subtype":"success","is_error":false,
935            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
936            "session_id":"s1","total_cost_usd":0.01,
937            "request_sent_wall_ms":1753212345678.25,
938            "user_message_uuid":"um-1"
939        }"#;
940        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
941        let crate::ClaudeOutput::Result(res) = &output else {
942            panic!("expected Result");
943        };
944        assert_eq!(res.request_sent_wall_ms, Some(1753212345678.25));
945        assert_eq!(res.user_message_uuid.as_deref(), Some("um-1"));
946
947        let reserialized = serde_json::to_string(&output).unwrap();
948        assert!(reserialized.contains("\"user_message_uuid\":\"um-1\""));
949    }
950
951    #[test]
952    fn test_result_queued_turn_count_and_user_message_uuids() {
953        // CLI 2.1.259 additive fields on the result frame.
954        let json = r#"{
955            "type":"result","subtype":"success","is_error":false,
956            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
957            "session_id":"s1","total_cost_usd":0.01,
958            "user_message_uuid":"um-2",
959            "user_message_uuids":["um-1","um-2"],
960            "queued_turn_count":3
961        }"#;
962        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
963        let crate::ClaudeOutput::Result(res) = &output else {
964            panic!("expected Result");
965        };
966        assert_eq!(res.user_message_uuids, vec!["um-1", "um-2"]);
967        assert_eq!(res.queued_turn_count, Some(3));
968
969        let reserialized = serde_json::to_string(&output).unwrap();
970        assert!(reserialized.contains("\"user_message_uuids\":[\"um-1\",\"um-2\"]"));
971        assert!(reserialized.contains("\"queued_turn_count\":3"));
972
973        // Both fields are absent from older producers and must default cleanly.
974        let old = r#"{
975            "type":"result","subtype":"success","is_error":false,
976            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
977            "session_id":"s1","total_cost_usd":0.01
978        }"#;
979        let output: crate::ClaudeOutput = serde_json::from_str(old).unwrap();
980        let crate::ClaudeOutput::Result(res) = &output else {
981            panic!("expected Result");
982        };
983        assert!(res.user_message_uuids.is_empty());
984        assert_eq!(res.queued_turn_count, None);
985        let reserialized = serde_json::to_string(&output).unwrap();
986        assert!(!reserialized.contains("user_message_uuids"));
987        assert!(!reserialized.contains("queued_turn_count"));
988    }
989}