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