Skip to main content

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 { message: UserMessage },
33
34    /// Session complete - final event with stats.
35    Result {
36        duration_ms: u64,
37        total_cost_usd: f64,
38        num_turns: u32,
39        is_error: bool,
40    },
41}
42
43/// Message content from Claude's assistant responses.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct AssistantMessage {
46    pub content: Vec<ContentBlock>,
47}
48
49/// Message content from tool results (user turn).
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct UserMessage {
52    pub content: Vec<UserContentBlock>,
53}
54
55/// Content blocks in assistant messages.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum ContentBlock {
59    /// Plain text output from Claude.
60    Text { text: String },
61    /// Tool invocation by Claude.
62    ToolUse {
63        id: String,
64        name: String,
65        input: serde_json::Value,
66    },
67}
68
69/// Content blocks in user messages (tool results).
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum UserContentBlock {
73    /// Result from a tool invocation.
74    ToolResult {
75        tool_use_id: String,
76        content: String,
77    },
78}
79
80/// Token usage statistics.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct Usage {
83    pub input_tokens: u64,
84    pub output_tokens: u64,
85}
86
87/// Parses NDJSON lines from Claude's stream output.
88pub struct ClaudeStreamParser;
89
90impl ClaudeStreamParser {
91    /// Parse a single line of NDJSON output.
92    ///
93    /// Returns `None` for empty lines or malformed JSON (logged at debug level).
94    pub fn parse_line(line: &str) -> Option<ClaudeStreamEvent> {
95        let trimmed = line.trim();
96        if trimmed.is_empty() {
97            return None;
98        }
99
100        match serde_json::from_str::<ClaudeStreamEvent>(trimmed) {
101            Ok(event) => Some(event),
102            Err(e) => {
103                tracing::debug!(
104                    "Skipping malformed JSON line: {} (error: {})",
105                    truncate(trimmed, 100),
106                    e
107                );
108                None
109            }
110        }
111    }
112}
113
114/// Truncates a string to a maximum length, adding "..." if truncated.
115fn truncate(s: &str, max_len: usize) -> String {
116    if s.len() <= max_len {
117        s.to_string()
118    } else {
119        // Find the last valid char boundary at or before max_len
120        let boundary = s
121            .char_indices()
122            .take_while(|(i, _)| *i < max_len)
123            .last()
124            .map(|(i, c)| i + c.len_utf8())
125            .unwrap_or(0);
126        format!("{}...", &s[..boundary])
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_parse_system_event() {
136        let json = r#"{"type":"system","session_id":"abc123","model":"claude-opus","tools":[]}"#;
137        let event = ClaudeStreamParser::parse_line(json).unwrap();
138
139        match event {
140            ClaudeStreamEvent::System {
141                session_id,
142                model,
143                tools,
144            } => {
145                assert_eq!(session_id, "abc123");
146                assert_eq!(model, "claude-opus");
147                assert!(tools.is_empty());
148            }
149            _ => panic!("Expected System event"),
150        }
151    }
152
153    #[test]
154    fn test_parse_assistant_text() {
155        let json =
156            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"#;
157        let event = ClaudeStreamParser::parse_line(json).unwrap();
158
159        match event {
160            ClaudeStreamEvent::Assistant { message, .. } => {
161                assert_eq!(message.content.len(), 1);
162                match &message.content[0] {
163                    ContentBlock::Text { text } => assert_eq!(text, "Hello world"),
164                    ContentBlock::ToolUse { .. } => panic!("Expected Text content"),
165                }
166            }
167            _ => panic!("Expected Assistant event"),
168        }
169    }
170
171    #[test]
172    fn test_parse_assistant_tool_use() {
173        let json = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool_1","name":"bash","input":{"command":"ls"}}]}}"#;
174        let event = ClaudeStreamParser::parse_line(json).unwrap();
175
176        match event {
177            ClaudeStreamEvent::Assistant { message, .. } => {
178                assert_eq!(message.content.len(), 1);
179                match &message.content[0] {
180                    ContentBlock::ToolUse { id, name, input } => {
181                        assert_eq!(id, "tool_1");
182                        assert_eq!(name, "bash");
183                        assert_eq!(input["command"], "ls");
184                    }
185                    ContentBlock::Text { .. } => panic!("Expected ToolUse content"),
186                }
187            }
188            _ => panic!("Expected Assistant event"),
189        }
190    }
191
192    #[test]
193    fn test_parse_user_tool_result() {
194        let json = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","content":"file.txt"}]}}"#;
195        let event = ClaudeStreamParser::parse_line(json).unwrap();
196
197        match event {
198            ClaudeStreamEvent::User { message } => {
199                assert_eq!(message.content.len(), 1);
200                match &message.content[0] {
201                    UserContentBlock::ToolResult {
202                        tool_use_id,
203                        content,
204                    } => {
205                        assert_eq!(tool_use_id, "tool_1");
206                        assert_eq!(content, "file.txt");
207                    }
208                }
209            }
210            _ => panic!("Expected User event"),
211        }
212    }
213
214    #[test]
215    fn test_parse_result_event() {
216        let json = r#"{"type":"result","duration_ms":5000,"total_cost_usd":0.02,"num_turns":2,"is_error":false}"#;
217        let event = ClaudeStreamParser::parse_line(json).unwrap();
218
219        match event {
220            ClaudeStreamEvent::Result {
221                duration_ms,
222                total_cost_usd,
223                num_turns,
224                is_error,
225            } => {
226                assert_eq!(duration_ms, 5000);
227                assert!((total_cost_usd - 0.02).abs() < f64::EPSILON);
228                assert_eq!(num_turns, 2);
229                assert!(!is_error);
230            }
231            _ => panic!("Expected Result event"),
232        }
233    }
234
235    #[test]
236    fn test_parse_empty_line() {
237        assert!(ClaudeStreamParser::parse_line("").is_none());
238        assert!(ClaudeStreamParser::parse_line("   ").is_none());
239        assert!(ClaudeStreamParser::parse_line("\n").is_none());
240    }
241
242    #[test]
243    fn test_parse_malformed_json() {
244        assert!(ClaudeStreamParser::parse_line("{not valid json}").is_none());
245        assert!(ClaudeStreamParser::parse_line("plain text").is_none());
246        assert!(ClaudeStreamParser::parse_line("{\"type\":\"unknown\"}").is_none());
247    }
248
249    #[test]
250    fn test_truncate_helper() {
251        assert_eq!(truncate("short", 10), "short");
252        assert_eq!(truncate("this is a long string", 10), "this is a ...");
253    }
254
255    #[test]
256    fn test_truncate_utf8_boundary() {
257        // The arrow character → is 3 bytes (E2 86 92 in UTF-8)
258        // This string: "hello→world" has bytes:
259        //   h(0) e(1) l(2) l(3) o(4) →(5,6,7) w(8) o(9) r(10) l(11) d(12)
260        // Truncating at byte 6 or 7 would land INSIDE the → character
261        let s = "hello→world";
262
263        // Truncate at max_len=6, which is inside the → character (bytes 5-7)
264        // This should NOT panic - it should truncate to "hello" (before the →)
265        let result = truncate(s, 6);
266
267        // The result should be truncated at a valid UTF-8 boundary
268        // Expected: "hello..." (truncated before the multi-byte char)
269        assert!(result.ends_with("..."), "Should end with ellipsis");
270        assert!(result.len() < s.len(), "Should be truncated");
271
272        // Verify the result is valid UTF-8 (won't panic on iteration)
273        for _ in result.chars() {}
274    }
275
276    #[test]
277    fn test_truncate_utf8_emoji() {
278        // Emoji like 🦀 is 4 bytes (F0 9F A6 80)
279        // "hi🦀" = h(0) i(1) 🦀(2,3,4,5)
280        // Truncating at byte 3, 4, or 5 would panic
281        let s = "hi🦀bye";
282
283        // Truncate at max_len=4, which is inside the 🦀 (bytes 2-5)
284        let result = truncate(s, 4);
285
286        // Should truncate to "hi..." (before the emoji)
287        assert!(result.ends_with("..."));
288
289        // Verify valid UTF-8
290        for _ in result.chars() {}
291    }
292}