Skip to main content

claus/claudio/
protocol.rs

1//! Claude Code stream-json protocol types.
2//!
3//! Defines envelope types for Claude Code's `--output-format stream-json` protocol. The protocol
4//! wraps Anthropic API types (from [`crate::anthropic`]) with session metadata.
5//!
6//! # Protocol Overview
7//!
8//! Claude Code emits newline-delimited JSON (NDJSON) on stdout. Each line is a JSON object with a
9//! `type` field that determines the message variant:
10//!
11//! - `system` — Session initialization with available tools, model, and configuration
12//! - `stream_event` — Wrapped Anthropic API streaming events (only with --include-partial-messages)
13//! - `assistant` — Complete assistant message after streaming finishes
14//! - `user` — Echoed user messages or tool results
15//! - `result` — Final result with statistics (cost, duration, token usage)
16
17use std::process::ExitStatus;
18
19use serde::{Deserialize, Serialize};
20
21use crate::anthropic::{Content, Message, Role, ServerToolUsage, StreamEvent, StreamingMessage};
22
23/// Error from running Claude Code.
24#[derive(Debug, thiserror::Error)]
25pub enum RunError {
26    /// Process exited with non-zero status.
27    #[error("process exited with {status}")]
28    ProcessFailed {
29        /// Process exit status.
30        status: ExitStatus,
31        /// Captured stderr.
32        stderr: String,
33    },
34    /// No output messages received.
35    #[error("no output messages")]
36    NoMessages,
37    /// Final message was not a result.
38    #[error("final message was not a result")]
39    MissingResult,
40    /// Claude reported an error in the result.
41    #[error("claude error: {}", .messages.last().and_then(|m| match m {
42        OutputMessage::Result(r) => r.result.as_deref(),
43        _ => None,
44    }).unwrap_or("unknown"))]
45    ResultError {
46        /// All messages including the error result.
47        messages: Vec<OutputMessage>,
48    },
49    /// Failed to parse a message.
50    #[error("failed to parse message")]
51    Parse(#[from] serde_json::Error),
52}
53
54/// Parses a single line of Claude Code output.
55///
56/// Returns `None` for empty lines, `Some(Ok(...))` for valid messages, or
57/// `Some(Err(...))` for parse errors.
58pub fn parse_line(line: &str) -> Option<Result<OutputMessage, serde_json::Error>> {
59    if line.is_empty() {
60        return None;
61    }
62    Some(serde_json::from_str(line))
63}
64
65/// Parses Claude Code process output.
66///
67/// Returns an error if:
68/// - The process exited with non-zero status
69/// - No messages were received
70/// - The final message is not a result
71/// - The result indicates an error (`is_error: true`)
72/// - Any message fails to parse
73///
74/// Works with both `std::process::Output` and `tokio::process::Output` (same type).
75///
76/// # Example
77///
78/// ```no_run
79/// use claus::claudio::{CliBuilder, protocol::{parse_output, OutputMessage}};
80///
81/// let output = CliBuilder::headless()
82///     .prompt("Hello")
83///     .build()
84///     .output()
85///     .expect("failed to run");
86///
87/// for msg in parse_output(&output).expect("claude failed") {
88///     match msg {
89///         OutputMessage::Assistant(a) => {
90///             println!("Assistant: {:?}", a.message.content);
91///         }
92///         OutputMessage::Result(r) => {
93///             println!("Done: ${:.4}", r.total_cost_usd);
94///         }
95///         _ => {}
96///     }
97/// }
98/// ```
99pub fn parse_output(output: &std::process::Output) -> Result<Vec<OutputMessage>, RunError> {
100    if !output.status.success() {
101        return Err(RunError::ProcessFailed {
102            status: output.status,
103            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
104        });
105    }
106
107    let stdout = String::from_utf8_lossy(&output.stdout);
108    let messages: Vec<OutputMessage> = stdout
109        .lines()
110        .filter(|line| !line.is_empty())
111        .map(serde_json::from_str)
112        .collect::<Result<_, _>>()?;
113
114    match messages.last() {
115        None => Err(RunError::NoMessages),
116        Some(OutputMessage::Result(r)) if r.is_error => Err(RunError::ResultError { messages }),
117        Some(OutputMessage::Result(_)) => Ok(messages),
118        Some(_) => Err(RunError::MissingResult),
119    }
120}
121
122/// Common envelope fields for Claude Code messages.
123///
124/// Most message types include session metadata. Use `#[serde(flatten)]` to embed these fields.
125#[derive(Clone, Debug, Deserialize)]
126pub struct Envelope {
127    /// Session identifier.
128    pub session_id: String,
129    /// Parent tool use ID if this message is part of a tool execution.
130    #[serde(default)]
131    pub parent_tool_use_id: Option<String>,
132    /// Message UUID.
133    pub uuid: String,
134}
135
136/// Message from Claude Code stdout.
137///
138/// Each line of stdout is a JSON object representing a message, with a
139/// `type` field that determines the variant.
140#[derive(Debug, Deserialize)]
141#[serde(tag = "type", rename_all = "snake_case")]
142pub enum OutputMessage {
143    /// Session initialization.
144    System(SystemMessage),
145    /// Wrapped Anthropic API streaming event.
146    StreamEvent(StreamEventMessage),
147    /// Complete assistant message.
148    Assistant(AssistantMessage),
149    /// Echoed user message or tool result.
150    User(UserMessage),
151    /// Final result of a conversation turn.
152    Result(ResultMessage),
153}
154
155/// System initialization message.
156///
157/// Sent at the start of a session with configuration details.
158#[derive(Clone, Debug, Deserialize)]
159pub struct SystemMessage {
160    /// Message subtype (e.g., `"init"`).
161    pub subtype: String,
162    /// Current working directory.
163    pub cwd: String,
164    /// Available tools.
165    pub tools: Vec<String>,
166    /// Configured MCP servers.
167    #[serde(default)]
168    pub mcp_servers: Vec<McpServerStatus>,
169    /// Model identifier.
170    pub model: String,
171    /// Permission mode.
172    #[serde(rename = "permissionMode")]
173    pub permission_mode: String,
174    /// Available slash commands.
175    #[serde(default)]
176    pub slash_commands: Vec<String>,
177    /// API key source.
178    #[serde(rename = "apiKeySource")]
179    pub api_key_source: String,
180    /// Claude Code version.
181    pub claude_code_version: String,
182    /// Output style.
183    pub output_style: String,
184    /// Available agents.
185    #[serde(default)]
186    pub agents: Vec<String>,
187    /// Available skills.
188    #[serde(default)]
189    pub skills: Vec<String>,
190    /// Loaded plugins.
191    #[serde(default)]
192    pub plugins: Vec<String>,
193    /// Session metadata.
194    #[serde(flatten)]
195    pub envelope: Envelope,
196}
197
198/// MCP server status in system init.
199#[derive(Clone, Debug, Deserialize)]
200pub struct McpServerStatus {
201    /// Server name.
202    pub name: String,
203    /// Connection status.
204    pub status: String,
205}
206
207/// Wrapped Anthropic streaming event.
208///
209/// Contains a [`StreamEvent`] from the Anthropic API along with session metadata.
210#[derive(Debug, Deserialize)]
211pub struct StreamEventMessage {
212    /// The Anthropic streaming event.
213    pub event: StreamEvent,
214    /// Session metadata.
215    #[serde(flatten)]
216    pub envelope: Envelope,
217}
218
219/// Complete assistant message.
220///
221/// Contains a full message from the Anthropic API with response metadata. Sent after all
222/// streaming events for a message have been delivered.
223#[derive(Debug, Deserialize)]
224pub struct AssistantMessage {
225    /// The complete Anthropic message.
226    pub message: StreamingMessage,
227    /// Session metadata.
228    #[serde(flatten)]
229    pub envelope: Envelope,
230}
231
232/// Echoed user message or tool result.
233#[derive(Clone, Debug, Deserialize)]
234pub struct UserMessage {
235    /// The user message content.
236    pub message: Message,
237    /// Session metadata.
238    #[serde(flatten)]
239    pub envelope: Envelope,
240    /// Structured metadata about tool result (present when message contains a tool result).
241    #[serde(default)]
242    pub tool_use_result: Option<serde_json::Value>,
243}
244
245/// Final result of a conversation turn.
246#[derive(Clone, Debug, Deserialize)]
247pub struct ResultMessage {
248    /// Result subtype (`"success"`, `"error_max_turns"`, etc.).
249    pub subtype: String,
250    /// Whether this represents an error.
251    pub is_error: bool,
252    /// Total duration in milliseconds.
253    pub duration_ms: u64,
254    /// API call duration in milliseconds.
255    #[serde(default)]
256    pub duration_api_ms: u64,
257    /// Number of conversation turns.
258    #[serde(default)]
259    pub num_turns: u32,
260    /// Final text result.
261    pub result: Option<String>,
262    /// Total cost in USD.
263    #[serde(default)]
264    pub total_cost_usd: f64,
265    /// Token usage statistics.
266    pub usage: ResultUsage,
267    /// Per-model usage breakdown.
268    #[serde(default, rename = "modelUsage")]
269    pub model_usage: serde_json::Map<String, serde_json::Value>,
270    /// Permission denials during this turn.
271    #[serde(default)]
272    pub permission_denials: Vec<serde_json::Value>,
273    /// Session metadata.
274    #[serde(flatten)]
275    pub envelope: Envelope,
276}
277
278/// Token usage statistics in result message.
279#[derive(Clone, Debug, Default, Deserialize)]
280pub struct ResultUsage {
281    /// Input tokens.
282    #[serde(default)]
283    pub input_tokens: u32,
284    /// Cache creation input tokens.
285    #[serde(default)]
286    pub cache_creation_input_tokens: u32,
287    /// Cache read input tokens.
288    #[serde(default)]
289    pub cache_read_input_tokens: u32,
290    /// Output tokens.
291    #[serde(default)]
292    pub output_tokens: u32,
293    /// Server tool use statistics.
294    #[serde(default)]
295    pub server_tool_use: ServerToolUsage,
296    /// Service tier.
297    #[serde(default)]
298    pub service_tier: String,
299    /// Cache creation breakdown.
300    #[serde(default)]
301    pub cache_creation: CacheCreation,
302}
303
304/// Cache creation breakdown.
305#[derive(Clone, Debug, Default, Deserialize)]
306pub struct CacheCreation {
307    /// Tokens in 1-hour ephemeral cache.
308    #[serde(default)]
309    pub ephemeral_1h_input_tokens: u32,
310    /// Tokens in 5-minute ephemeral cache.
311    #[serde(default)]
312    pub ephemeral_5m_input_tokens: u32,
313}
314
315// --- Input types ---
316
317/// Input message to Claude Code stdin.
318///
319/// Send as newline-delimited JSON when using `--input-format stream-json`.
320#[derive(Clone, Debug, Serialize)]
321pub struct InputMessage {
322    /// Message type (always `"user"`).
323    #[serde(rename = "type")]
324    message_type: &'static str,
325    /// The message content.
326    pub message: Message,
327}
328
329impl InputMessage {
330    /// Creates a text input message.
331    pub fn text(text: impl Into<String>) -> Self {
332        Self {
333            message_type: "user",
334            message: Message::from_text(Role::User, text),
335        }
336    }
337
338    /// Creates an input message with custom content blocks.
339    pub fn with_content(content: Vec<Content>) -> Self {
340        Self {
341            message_type: "user",
342            message: Message {
343                role: Role::User,
344                content,
345            },
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn parse_system_init() {
356        let json = r#"{
357            "type": "system",
358            "subtype": "init",
359            "cwd": "/home/user/project",
360            "session_id": "6484002d-24fe-4f95-ad4b-6bf7130f1fcb",
361            "tools": ["Bash", "Read", "Write"],
362            "mcp_servers": [],
363            "model": "claude-opus-4-5-20251101",
364            "permissionMode": "default",
365            "slash_commands": ["commit"],
366            "apiKeySource": "none",
367            "claude_code_version": "2.1.17",
368            "output_style": "default",
369            "agents": [],
370            "skills": [],
371            "plugins": [],
372            "uuid": "f34a0e91-06ae-426c-9e5c-317a7572ff29"
373        }"#;
374
375        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
376        match msg {
377            OutputMessage::System(sys) => {
378                assert_eq!(sys.subtype, "init");
379                assert_eq!(sys.cwd, "/home/user/project");
380                assert_eq!(sys.tools, vec!["Bash", "Read", "Write"]);
381                assert_eq!(sys.model, "claude-opus-4-5-20251101");
382                assert_eq!(sys.permission_mode, "default");
383            }
384            _ => panic!("expected System variant"),
385        }
386    }
387
388    #[test]
389    fn parse_result_success() {
390        let json = r#"{
391            "type": "result",
392            "subtype": "success",
393            "is_error": false,
394            "duration_ms": 2633,
395            "duration_api_ms": 2600,
396            "num_turns": 1,
397            "result": "hello",
398            "session_id": "6484002d-24fe-4f95-ad4b-6bf7130f1fcb",
399            "total_cost_usd": 0.12779625,
400            "usage": {
401                "input_tokens": 3,
402                "cache_creation_input_tokens": 20429,
403                "cache_read_input_tokens": 0,
404                "output_tokens": 4,
405                "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
406                "service_tier": "standard",
407                "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 20429}
408            },
409            "modelUsage": {},
410            "permission_denials": [],
411            "uuid": "4e5d6b84-6129-47b3-bba6-fdb375aa7b3d"
412        }"#;
413
414        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
415        match msg {
416            OutputMessage::Result(res) => {
417                assert_eq!(res.subtype, "success");
418                assert!(!res.is_error);
419                assert_eq!(res.duration_ms, 2633);
420                assert_eq!(res.result, Some("hello".to_string()));
421                assert_eq!(res.usage.input_tokens, 3);
422                assert_eq!(res.usage.cache_creation_input_tokens, 20429);
423            }
424            _ => panic!("expected Result variant"),
425        }
426    }
427
428    #[test]
429    fn parse_assistant() {
430        let json = r#"{
431            "type": "assistant",
432            "message": {
433                "model": "claude-opus-4-5-20251101",
434                "id": "msg_016erzjGS5oTB6Q8uohJEpAs",
435                "type": "message",
436                "role": "assistant",
437                "content": [{"type": "text", "text": "hello"}],
438                "stop_reason": null,
439                "stop_sequence": null,
440                "usage": {
441                    "input_tokens": 3,
442                    "output_tokens": 1
443                }
444            },
445            "parent_tool_use_id": null,
446            "session_id": "6484002d-24fe-4f95-ad4b-6bf7130f1fcb",
447            "uuid": "9e40ea6e-9f3e-43e1-a6c0-59e9de6c347f"
448        }"#;
449
450        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
451        match msg {
452            OutputMessage::Assistant(asst) => {
453                assert_eq!(asst.message.id, "msg_016erzjGS5oTB6Q8uohJEpAs");
454                assert_eq!(asst.message.content.len(), 1);
455                assert!(asst.envelope.parent_tool_use_id.is_none());
456            }
457            _ => panic!("expected Assistant variant"),
458        }
459    }
460
461    #[test]
462    fn serialize_input_text() {
463        let input = InputMessage::text("hello world");
464        let json = serde_json::to_value(&input).expect("serialize");
465
466        assert_eq!(json["type"], "user");
467        assert_eq!(json["message"]["role"], "user");
468        assert_eq!(json["message"]["content"][0]["type"], "text");
469        assert_eq!(json["message"]["content"][0]["text"], "hello world");
470    }
471
472    #[test]
473    fn parse_user_tool_result() {
474        use crate::anthropic::Role;
475
476        let json = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TV2WdLXaSZwBGgKGPvLEmy","type":"tool_result","content":"hello"}]},"parent_tool_use_id":null,"session_id":"bf7004a5-4781-4c4e-bd35-6f4516db86fd","uuid":"dfc99bb7-55dc-4829-87a8-e9fd6333f970","tool_use_result":{"type":"text","file":{"filePath":"/tmp/hello.txt"}}}"#;
477
478        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
479        match msg {
480            OutputMessage::User(user) => {
481                assert_eq!(user.message.role, Role::User);
482                assert_eq!(user.message.content.len(), 1);
483                assert!(user.tool_use_result.is_some());
484            }
485            _ => panic!("expected User variant"),
486        }
487    }
488
489    /// Test parsing actual Claude CLI output.
490    #[test]
491    fn parse_real_assistant_with_tool_use() {
492        let json = r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","id":"msg_01UQFX7fDMP5CKAWQWTgtodQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TV2WdLXaSZwBGgKGPvLEmy","name":"Read","input":{"file_path":"/tmp/hello.txt"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":22175,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":22175,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"bf7004a5-4781-4c4e-bd35-6f4516db86fd","uuid":"a3c66f24-58f3-4727-b052-961d2205958f"}"#;
493
494        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
495        match msg {
496            OutputMessage::Assistant(asst) => {
497                assert_eq!(asst.message.model, "claude-opus-4-5-20251101");
498                assert_eq!(asst.message.content.len(), 1);
499            }
500            _ => panic!("expected Assistant variant"),
501        }
502    }
503}