ralph_adapters/
claude_stream.rs

1//! Claude stream event types for parsing `--output-format stream-json` output.
2//!
3//! When invoked with `--output-format stream-json`, Claude emits newline-delimited
4//! JSON events. This module provides typed Rust structures for deserializing
5//! and processing these events.
6
7use serde::{Deserialize, Serialize};
8
9/// Events emitted by Claude's `--output-format stream-json`.
10///
11/// Each line of output is a JSON object with a `type` field that determines
12/// the event variant.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ClaudeStreamEvent {
16    /// Session initialization - first event emitted.
17    System {
18        session_id: String,
19        model: String,
20        #[serde(default)]
21        tools: Vec<serde_json::Value>,
22    },
23
24    /// Claude's response - contains text or tool invocations.
25    Assistant {
26        message: AssistantMessage,
27        #[serde(default)]
28        usage: Option<Usage>,
29    },
30
31    /// Tool results returned to Claude.
32    User {
33        message: UserMessage,
34    },
35
36    /// Session complete - final event with stats.
37    Result {
38        duration_ms: u64,
39        total_cost_usd: f64,
40        num_turns: u32,
41        is_error: bool,
42    },
43}
44
45/// Message content from Claude's assistant responses.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct AssistantMessage {
48    pub content: Vec<ContentBlock>,
49}
50
51/// Message content from tool results (user turn).
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct UserMessage {
54    pub content: Vec<UserContentBlock>,
55}
56
57/// Content blocks in assistant messages.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59#[serde(tag = "type", rename_all = "snake_case")]
60pub enum ContentBlock {
61    /// Plain text output from Claude.
62    Text { text: String },
63    /// Tool invocation by Claude.
64    ToolUse {
65        id: String,
66        name: String,
67        input: serde_json::Value,
68    },
69}
70
71/// Content blocks in user messages (tool results).
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum UserContentBlock {
75    /// Result from a tool invocation.
76    ToolResult {
77        tool_use_id: String,
78        content: String,
79    },
80}
81
82/// Token usage statistics.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct Usage {
85    pub input_tokens: u64,
86    pub output_tokens: u64,
87}
88
89/// Parses NDJSON lines from Claude's stream output.
90pub struct ClaudeStreamParser;
91
92impl ClaudeStreamParser {
93    /// Parse a single line of NDJSON output.
94    ///
95    /// Returns `None` for empty lines or malformed JSON (logged at debug level).
96    pub fn parse_line(line: &str) -> Option<ClaudeStreamEvent> {
97        let trimmed = line.trim();
98        if trimmed.is_empty() {
99            return None;
100        }
101
102        match serde_json::from_str::<ClaudeStreamEvent>(trimmed) {
103            Ok(event) => Some(event),
104            Err(e) => {
105                tracing::debug!(
106                    "Skipping malformed JSON line: {} (error: {})",
107                    truncate(trimmed, 100),
108                    e
109                );
110                None
111            }
112        }
113    }
114}
115
116/// Truncates a string to a maximum length, adding "..." if truncated.
117fn truncate(s: &str, max_len: usize) -> String {
118    if s.len() <= max_len {
119        s.to_string()
120    } else {
121        format!("{}...", &s[..max_len])
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_parse_system_event() {
131        let json = r#"{"type":"system","session_id":"abc123","model":"claude-opus","tools":[]}"#;
132        let event = ClaudeStreamParser::parse_line(json).unwrap();
133
134        match event {
135            ClaudeStreamEvent::System { session_id, model, tools } => {
136                assert_eq!(session_id, "abc123");
137                assert_eq!(model, "claude-opus");
138                assert!(tools.is_empty());
139            }
140            _ => panic!("Expected System event"),
141        }
142    }
143
144    #[test]
145    fn test_parse_assistant_text() {
146        let json = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"#;
147        let event = ClaudeStreamParser::parse_line(json).unwrap();
148
149        match event {
150            ClaudeStreamEvent::Assistant { message, .. } => {
151                assert_eq!(message.content.len(), 1);
152                match &message.content[0] {
153                    ContentBlock::Text { text } => assert_eq!(text, "Hello world"),
154                    ContentBlock::ToolUse { .. } => panic!("Expected Text content"),
155                }
156            }
157            _ => panic!("Expected Assistant event"),
158        }
159    }
160
161    #[test]
162    fn test_parse_assistant_tool_use() {
163        let json = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool_1","name":"bash","input":{"command":"ls"}}]}}"#;
164        let event = ClaudeStreamParser::parse_line(json).unwrap();
165
166        match event {
167            ClaudeStreamEvent::Assistant { message, .. } => {
168                assert_eq!(message.content.len(), 1);
169                match &message.content[0] {
170                    ContentBlock::ToolUse { id, name, input } => {
171                        assert_eq!(id, "tool_1");
172                        assert_eq!(name, "bash");
173                        assert_eq!(input["command"], "ls");
174                    }
175                    ContentBlock::Text { .. } => panic!("Expected ToolUse content"),
176                }
177            }
178            _ => panic!("Expected Assistant event"),
179        }
180    }
181
182    #[test]
183    fn test_parse_user_tool_result() {
184        let json = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","content":"file.txt"}]}}"#;
185        let event = ClaudeStreamParser::parse_line(json).unwrap();
186
187        match event {
188            ClaudeStreamEvent::User { message } => {
189                assert_eq!(message.content.len(), 1);
190                match &message.content[0] {
191                    UserContentBlock::ToolResult { tool_use_id, content } => {
192                        assert_eq!(tool_use_id, "tool_1");
193                        assert_eq!(content, "file.txt");
194                    }
195                }
196            }
197            _ => panic!("Expected User event"),
198        }
199    }
200
201    #[test]
202    fn test_parse_result_event() {
203        let json = r#"{"type":"result","duration_ms":5000,"total_cost_usd":0.02,"num_turns":2,"is_error":false}"#;
204        let event = ClaudeStreamParser::parse_line(json).unwrap();
205
206        match event {
207            ClaudeStreamEvent::Result { duration_ms, total_cost_usd, num_turns, is_error } => {
208                assert_eq!(duration_ms, 5000);
209                assert!((total_cost_usd - 0.02).abs() < f64::EPSILON);
210                assert_eq!(num_turns, 2);
211                assert!(!is_error);
212            }
213            _ => panic!("Expected Result event"),
214        }
215    }
216
217    #[test]
218    fn test_parse_empty_line() {
219        assert!(ClaudeStreamParser::parse_line("").is_none());
220        assert!(ClaudeStreamParser::parse_line("   ").is_none());
221        assert!(ClaudeStreamParser::parse_line("\n").is_none());
222    }
223
224    #[test]
225    fn test_parse_malformed_json() {
226        assert!(ClaudeStreamParser::parse_line("{not valid json}").is_none());
227        assert!(ClaudeStreamParser::parse_line("plain text").is_none());
228        assert!(ClaudeStreamParser::parse_line("{\"type\":\"unknown\"}").is_none());
229    }
230
231    #[test]
232    fn test_truncate_helper() {
233        assert_eq!(truncate("short", 10), "short");
234        assert_eq!(truncate("this is a long string", 10), "this is a ...");
235    }
236}