Skip to main content

claude_codes/io/
result.rs

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