Skip to main content

atomr_agents_coding_cli_vendor_claude/
parser.rs

1//! Translates Claude Code's `--output-format stream-json` NDJSON into
2//! normalized `CodingCliEvent`s.
3//!
4//! The schema is publicly documented at
5//! <https://code.claude.com/docs/en/headless>. We normalize each
6//! known envelope into the common event model, and pass anything else
7//! through as `RawVendorEvent` so the UI never silently drops data.
8
9use atomr_agents_coding_cli_core::{
10    CliEventParser, CliVendorKind, CodingCliEvent, FinishReason, McpServerInit, ParseError,
11    ToolDescriptorInit,
12};
13use serde::Deserialize;
14use serde_json::Value;
15
16#[derive(Default)]
17pub struct ClaudeParser;
18
19impl ClaudeParser {
20    pub fn new() -> Self {
21        Self
22    }
23}
24
25impl CliEventParser for ClaudeParser {
26    fn parse_line(&mut self, line: &str) -> Result<Vec<CodingCliEvent>, ParseError> {
27        let trimmed = line.trim();
28        if trimmed.is_empty() {
29            return Ok(Vec::new());
30        }
31        let value: Value = serde_json::from_str(trimmed)?;
32        Ok(normalize(&value))
33    }
34
35    fn flush(&mut self) -> Result<Vec<CodingCliEvent>, ParseError> {
36        Ok(Vec::new())
37    }
38}
39
40fn normalize(v: &Value) -> Vec<CodingCliEvent> {
41    let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
42    match kind {
43        "system" => normalize_system(v),
44        "stream_event" => normalize_stream_event(v),
45        "tool_use" => vec![normalize_tool_use(v)],
46        "tool_result" => vec![normalize_tool_result(v)],
47        "result" => vec![normalize_result(v)],
48        _ => vec![CodingCliEvent::RawVendorEvent {
49            vendor: CliVendorKind::Claude,
50            payload: v.clone(),
51        }],
52    }
53}
54
55fn normalize_system(v: &Value) -> Vec<CodingCliEvent> {
56    let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("");
57    match subtype {
58        "init" => {
59            #[derive(Deserialize)]
60            struct Tool {
61                #[serde(default)]
62                name: String,
63                #[serde(default)]
64                description: Option<String>,
65            }
66            #[derive(Deserialize)]
67            struct Mcp {
68                #[serde(default)]
69                name: String,
70                #[serde(default)]
71                status: Option<String>,
72            }
73            let tools: Vec<Tool> = v
74                .get("tools")
75                .and_then(|t| serde_json::from_value(t.clone()).ok())
76                .unwrap_or_default();
77            let mcp: Vec<Mcp> = v
78                .get("mcp_servers")
79                .and_then(|t| serde_json::from_value(t.clone()).ok())
80                .unwrap_or_default();
81            vec![CodingCliEvent::SystemInit {
82                tools: tools
83                    .into_iter()
84                    .map(|t| ToolDescriptorInit {
85                        name: t.name,
86                        description: t.description,
87                    })
88                    .collect(),
89                mcp_servers: mcp
90                    .into_iter()
91                    .map(|m| McpServerInit {
92                        name: m.name,
93                        status: m.status,
94                    })
95                    .collect(),
96                plugins: Vec::new(),
97            }]
98        }
99        "api_retry" | "api_error_retry" => {
100            let attempt = v.get("attempt").and_then(Value::as_u64).unwrap_or(0) as u32;
101            let delay_ms = v.get("delay_ms").and_then(Value::as_u64).unwrap_or(0);
102            let reason = v
103                .get("error")
104                .and_then(Value::as_str)
105                .unwrap_or("retry")
106                .to_string();
107            vec![CodingCliEvent::ApiRetry {
108                attempt,
109                delay_ms,
110                reason,
111            }]
112        }
113        _ => vec![CodingCliEvent::RawVendorEvent {
114            vendor: CliVendorKind::Claude,
115            payload: v.clone(),
116        }],
117    }
118}
119
120/// `stream_event` envelopes carry partial assistant text deltas via
121/// `.event.delta.text`. Other stream events (start, stop) are passed
122/// through as raw to keep the schema honest.
123fn normalize_stream_event(v: &Value) -> Vec<CodingCliEvent> {
124    if let Some(text) = v
125        .pointer("/event/delta/text")
126        .and_then(Value::as_str)
127    {
128        return vec![CodingCliEvent::AssistantTextDelta {
129            text: text.to_string(),
130        }];
131    }
132    if let Some(text) = v
133        .pointer("/delta/text")
134        .and_then(Value::as_str)
135    {
136        return vec![CodingCliEvent::AssistantTextDelta {
137            text: text.to_string(),
138        }];
139    }
140    vec![CodingCliEvent::RawVendorEvent {
141        vendor: CliVendorKind::Claude,
142        payload: v.clone(),
143    }]
144}
145
146fn normalize_tool_use(v: &Value) -> CodingCliEvent {
147    let id = v
148        .get("id")
149        .or_else(|| v.get("tool_use_id"))
150        .and_then(Value::as_str)
151        .unwrap_or("")
152        .to_string();
153    let name = v.get("name").and_then(Value::as_str).unwrap_or("").to_string();
154    let input = v.get("input").cloned().unwrap_or(Value::Null);
155    CodingCliEvent::ToolCallStarted {
156        tool_call_id: id,
157        name,
158        input,
159    }
160}
161
162fn normalize_tool_result(v: &Value) -> CodingCliEvent {
163    let id = v
164        .get("tool_use_id")
165        .or_else(|| v.get("id"))
166        .and_then(Value::as_str)
167        .unwrap_or("")
168        .to_string();
169    let error = v
170        .get("error")
171        .and_then(Value::as_str)
172        .map(|s| s.to_string());
173    let output = v.get("content").or_else(|| v.get("output")).cloned();
174    CodingCliEvent::ToolCallFinished {
175        tool_call_id: id,
176        output,
177        error,
178    }
179}
180
181fn normalize_result(v: &Value) -> CodingCliEvent {
182    let result_text = v.get("result").and_then(Value::as_str).map(|s| s.to_string());
183    let is_error = v.get("is_error").and_then(Value::as_bool).unwrap_or(false);
184    CodingCliEvent::RunFinished {
185        reason: if is_error {
186            FinishReason::ProcessError
187        } else {
188            FinishReason::Completed
189        },
190        result_text,
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn parses_system_init() {
200        let mut p = ClaudeParser::new();
201        let line = r#"{"type":"system","subtype":"init","tools":[{"name":"Bash","description":"Run shell"},{"name":"Read"}],"mcp_servers":[{"name":"linear","status":"connected"}]}"#;
202        let events = p.parse_line(line).unwrap();
203        assert_eq!(events.len(), 1);
204        match &events[0] {
205            CodingCliEvent::SystemInit { tools, mcp_servers, .. } => {
206                assert_eq!(tools.len(), 2);
207                assert_eq!(tools[0].name, "Bash");
208                assert_eq!(mcp_servers.len(), 1);
209            }
210            ev => panic!("expected SystemInit, got {ev:?}"),
211        }
212    }
213
214    #[test]
215    fn parses_text_delta() {
216        let mut p = ClaudeParser::new();
217        let line = r#"{"type":"stream_event","event":{"delta":{"text":"Hello"}}}"#;
218        let events = p.parse_line(line).unwrap();
219        assert!(matches!(
220            &events[0],
221            CodingCliEvent::AssistantTextDelta { text } if text == "Hello"
222        ));
223    }
224
225    #[test]
226    fn parses_tool_use_and_result() {
227        let mut p = ClaudeParser::new();
228        let line1 = r#"{"type":"tool_use","id":"toolu_01","name":"Read","input":{"path":"a.rs"}}"#;
229        let line2 = r#"{"type":"tool_result","tool_use_id":"toolu_01","content":"file contents"}"#;
230        let ev1 = p.parse_line(line1).unwrap();
231        let ev2 = p.parse_line(line2).unwrap();
232        assert!(matches!(&ev1[0], CodingCliEvent::ToolCallStarted { name, .. } if name == "Read"));
233        assert!(matches!(&ev2[0], CodingCliEvent::ToolCallFinished { error: None, .. }));
234    }
235
236    #[test]
237    fn parses_result_envelope() {
238        let mut p = ClaudeParser::new();
239        let line = r#"{"type":"result","result":"All done","is_error":false}"#;
240        let ev = p.parse_line(line).unwrap();
241        assert!(matches!(
242            &ev[0],
243            CodingCliEvent::RunFinished {
244                reason: FinishReason::Completed,
245                result_text: Some(t)
246            } if t == "All done"
247        ));
248    }
249
250    #[test]
251    fn unknown_type_passes_through_as_raw() {
252        let mut p = ClaudeParser::new();
253        let line = r#"{"type":"new_event_we_dont_know_yet","payload":42}"#;
254        let ev = p.parse_line(line).unwrap();
255        assert!(matches!(&ev[0], CodingCliEvent::RawVendorEvent { .. }));
256    }
257
258    #[test]
259    fn empty_line_is_ignored() {
260        let mut p = ClaudeParser::new();
261        assert!(p.parse_line("").unwrap().is_empty());
262        assert!(p.parse_line("   ").unwrap().is_empty());
263    }
264}