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