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