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