Skip to main content

edda_conductor/agent/
stream.rs

1use crate::agent::launcher::PhaseResult;
2use anyhow::Result;
3use serde::Deserialize;
4use tokio::io::{AsyncBufReadExt, BufReader};
5use tokio::process::ChildStdout;
6
7/// Relevant fields from Claude Code's stream-json output.
8/// Protocol is undocumented — derived from testing Claude Code.
9/// Uses `#[serde(other)]` to gracefully ignore unknown message types.
10#[derive(Debug, Deserialize)]
11#[serde(tag = "type")]
12pub enum StreamMessage {
13    #[serde(rename = "system")]
14    System {
15        subtype: String,
16        #[serde(default)]
17        session_id: Option<String>,
18        #[serde(default)]
19        tools: Option<Vec<String>>,
20        #[serde(default)]
21        model: Option<String>,
22    },
23    #[serde(rename = "assistant")]
24    Assistant { message: serde_json::Value },
25    #[serde(rename = "user")]
26    User {
27        #[serde(default)]
28        message: serde_json::Value,
29        #[serde(default)]
30        tool_use_result: Option<serde_json::Value>,
31    },
32    #[serde(rename = "result")]
33    Result {
34        subtype: String,
35        #[serde(default)]
36        total_cost_usd: Option<f64>,
37        #[serde(default)]
38        error: Option<String>,
39        #[serde(default, rename = "result")]
40        result_text: Option<String>,
41    },
42    /// Catch-all for unknown types — prevents deserialization failures.
43    #[serde(other)]
44    Unknown,
45}
46
47/// Extracted result info from the last Result message.
48#[derive(Debug, Clone)]
49pub struct ResultInfo {
50    pub subtype: String,
51    pub total_cost_usd: Option<f64>,
52    pub error: Option<String>,
53    pub result_text: Option<String>,
54}
55
56/// Aggregated output from monitoring a Claude Code stream.
57#[derive(Debug)]
58pub struct MonitorResult {
59    pub total_cost_usd: f64,
60    pub result: Option<ResultInfo>,
61    pub result_text: Option<String>,
62}
63
64/// Reads Claude Code's `--output-format stream-json` stdout line by line,
65/// extracting cost and result info.
66pub struct StreamMonitor {
67    reader: BufReader<ChildStdout>,
68    total_cost_usd: f64,
69    messages: Vec<StreamMessage>,
70    verbose: bool,
71    tee_writer: Option<std::io::BufWriter<std::fs::File>>,
72}
73
74impl StreamMonitor {
75    pub fn new(stdout: ChildStdout) -> Self {
76        Self {
77            reader: BufReader::new(stdout),
78            total_cost_usd: 0.0,
79            messages: Vec::new(),
80            verbose: false,
81            tee_writer: None,
82        }
83    }
84
85    /// Enable verbose mode: print live activity as the agent works.
86    pub fn with_verbose(mut self, verbose: bool) -> Self {
87        self.verbose = verbose;
88        self
89    }
90
91    /// Tee raw stdout lines to a file (transcript capture).
92    /// Best-effort: if the file can't be opened, tee is silently skipped.
93    pub fn with_tee(mut self, path: Option<std::path::PathBuf>) -> Self {
94        if let Some(p) = path {
95            if let Some(parent) = p.parent() {
96                let _ = std::fs::create_dir_all(parent);
97            }
98            if let Ok(file) = std::fs::OpenOptions::new()
99                .create(true)
100                .append(true)
101                .open(&p)
102            {
103                self.tee_writer = Some(std::io::BufWriter::new(file));
104            }
105        }
106        self
107    }
108
109    /// Read all lines until EOF. Returns aggregated result.
110    pub async fn run(&mut self) -> Result<MonitorResult> {
111        let mut line = String::new();
112        loop {
113            line.clear();
114            let n = self.reader.read_line(&mut line).await?;
115            if n == 0 {
116                break;
117            } // EOF
118
119            // Tee raw line to transcript file
120            if let Some(ref mut w) = self.tee_writer {
121                use std::io::Write;
122                let _ = w.write_all(line.as_bytes());
123            }
124
125            let trimmed = line.trim();
126            if trimmed.is_empty() {
127                continue;
128            }
129
130            if let Ok(msg) = serde_json::from_str::<StreamMessage>(trimmed) {
131                if self.verbose {
132                    print_live(&msg);
133                }
134                if let StreamMessage::Result {
135                    total_cost_usd: Some(cost),
136                    ..
137                } = &msg
138                {
139                    self.total_cost_usd = *cost;
140                }
141                self.messages.push(msg);
142            }
143            // Non-JSON lines silently ignored (stderr leakage, debug output, etc.)
144        }
145
146        let result_info = self.messages.iter().rev().find_map(|m| match m {
147            StreamMessage::Result {
148                subtype,
149                total_cost_usd,
150                error,
151                result_text,
152            } => Some(ResultInfo {
153                subtype: subtype.clone(),
154                total_cost_usd: *total_cost_usd,
155                error: error.clone(),
156                result_text: result_text.clone(),
157            }),
158            _ => None,
159        });
160        let result_text = result_info.as_ref().and_then(|r| r.result_text.clone());
161
162        Ok(MonitorResult {
163            total_cost_usd: self.total_cost_usd,
164            result: result_info,
165            result_text,
166        })
167    }
168}
169
170/// Print a human-readable live line for a stream message.
171fn print_live(msg: &StreamMessage) {
172    match msg {
173        StreamMessage::System { model: Some(m), .. } => {
174            println!("  🔌 Model: {m}");
175        }
176        StreamMessage::System { model: None, .. } => {}
177        StreamMessage::Assistant { message } => {
178            // Extract tool_use calls from content array
179            if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
180                for item in content {
181                    if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
182                        let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("?");
183                        let input = item.get("input");
184                        let detail = match name {
185                            "Write" => input
186                                .and_then(|i| i.get("file_path"))
187                                .and_then(|p| p.as_str())
188                                .map(shorten_path)
189                                .unwrap_or_default(),
190                            "Edit" => input
191                                .and_then(|i| i.get("file_path"))
192                                .and_then(|p| p.as_str())
193                                .map(shorten_path)
194                                .unwrap_or_default(),
195                            "Read" => input
196                                .and_then(|i| i.get("file_path"))
197                                .and_then(|p| p.as_str())
198                                .map(shorten_path)
199                                .unwrap_or_default(),
200                            "Bash" => input
201                                .and_then(|i| i.get("command"))
202                                .and_then(|c| c.as_str())
203                                .map(|c| truncate(c, 60))
204                                .unwrap_or_default(),
205                            _ => String::new(),
206                        };
207                        let icon = match name {
208                            "Write" => "📝",
209                            "Edit" => "✏️",
210                            "Read" => "📖",
211                            "Bash" => "🔧",
212                            "Grep" | "Glob" => "🔍",
213                            "WebSearch" | "WebFetch" => "🌐",
214                            _ => "🔨",
215                        };
216                        if detail.is_empty() {
217                            println!("  {icon} {name}");
218                        } else {
219                            println!("  {icon} {name}: {detail}");
220                        }
221                    } else if item.get("type").and_then(|t| t.as_str()) == Some("text") {
222                        if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
223                            // Only print short assistant text (final summary, not long explanations)
224                            let trimmed = text.trim();
225                            if !trimmed.is_empty() && trimmed.len() < 200 {
226                                println!("  💬 {}", truncate(trimmed, 80));
227                            }
228                        }
229                    }
230                }
231            }
232        }
233        StreamMessage::Result {
234            subtype,
235            total_cost_usd,
236            ..
237        } => {
238            let cost_str = total_cost_usd
239                .map(|c| format!(" (${c:.3})"))
240                .unwrap_or_default();
241            println!("  📊 Result: {subtype}{cost_str}");
242        }
243        _ => {}
244    }
245}
246
247/// Shorten a file path to just the last 2 components.
248fn shorten_path(path: &str) -> String {
249    let normalized = path.replace('\\', "/");
250    let parts: Vec<&str> = normalized.split('/').collect();
251    if parts.len() <= 2 {
252        parts.join("/")
253    } else {
254        parts[parts.len() - 2..].join("/")
255    }
256}
257
258/// Truncate a string to max_len, adding "..." if truncated.
259fn truncate(s: &str, max_len: usize) -> String {
260    let s = s.replace('\n', " ").replace('\r', "");
261    if s.len() <= max_len {
262        s
263    } else {
264        format!("{}...", &s[..s.floor_char_boundary(max_len)])
265    }
266}
267
268/// Classify a monitor result + exit code into a PhaseResult.
269pub fn classify_result(monitor: &MonitorResult, exit_code: Option<i32>) -> PhaseResult {
270    match &monitor.result {
271        Some(info) => match info.subtype.as_str() {
272            "success" => PhaseResult::AgentDone {
273                cost_usd: info.total_cost_usd,
274                result_text: monitor.result_text.clone(),
275            },
276            "error_max_turns" => PhaseResult::MaxTurns {
277                cost_usd: info.total_cost_usd,
278            },
279            "error_max_budget_usd" => PhaseResult::BudgetExceeded {
280                cost_usd: info.total_cost_usd,
281            },
282            "error_during_execution" => PhaseResult::AgentCrash {
283                error: info.error.clone().unwrap_or_else(|| "unknown".into()),
284            },
285            other => PhaseResult::AgentCrash {
286                error: format!("unknown result subtype: {other}"),
287            },
288        },
289        None => PhaseResult::AgentCrash {
290            error: format!(
291                "agent exited with code {} without result",
292                exit_code.unwrap_or(-1)
293            ),
294        },
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn parse_system_init() {
304        let json = r#"{"type":"system","subtype":"init","session_id":"abc-123","model":"claude-sonnet-4-5-20250929"}"#;
305        let msg: StreamMessage = serde_json::from_str(json).unwrap();
306        match msg {
307            StreamMessage::System {
308                subtype,
309                session_id,
310                model,
311                ..
312            } => {
313                assert_eq!(subtype, "init");
314                assert_eq!(session_id.unwrap(), "abc-123");
315                assert_eq!(model.unwrap(), "claude-sonnet-4-5-20250929");
316            }
317            other => panic!("expected System, got {:?}", other),
318        }
319    }
320
321    #[test]
322    fn parse_assistant() {
323        let json = r#"{"type":"assistant","message":{"content":"hello"}}"#;
324        let msg: StreamMessage = serde_json::from_str(json).unwrap();
325        assert!(matches!(msg, StreamMessage::Assistant { .. }));
326    }
327
328    #[test]
329    fn parse_result_success() {
330        let json = r#"{"type":"result","subtype":"success","total_cost_usd":0.42,"error":null}"#;
331        let msg: StreamMessage = serde_json::from_str(json).unwrap();
332        match msg {
333            StreamMessage::Result {
334                subtype,
335                total_cost_usd,
336                error,
337                ..
338            } => {
339                assert_eq!(subtype, "success");
340                assert!((total_cost_usd.unwrap() - 0.42).abs() < 0.001);
341                assert!(error.is_none());
342            }
343            other => panic!("expected Result, got {:?}", other),
344        }
345    }
346
347    #[test]
348    fn parse_result_error() {
349        let json = r#"{"type":"result","subtype":"error_during_execution","total_cost_usd":0.10,"error":"tool failed"}"#;
350        let msg: StreamMessage = serde_json::from_str(json).unwrap();
351        match msg {
352            StreamMessage::Result { subtype, error, .. } => {
353                assert_eq!(subtype, "error_during_execution");
354                assert_eq!(error.unwrap(), "tool failed");
355            }
356            other => panic!("expected Result, got {:?}", other),
357        }
358    }
359
360    #[test]
361    fn parse_unknown_type_gracefully() {
362        let json = r#"{"type":"future_new_type","data":"whatever"}"#;
363        let msg: StreamMessage = serde_json::from_str(json).unwrap();
364        assert!(matches!(msg, StreamMessage::Unknown));
365    }
366
367    #[test]
368    fn classify_success() {
369        let monitor = MonitorResult {
370            total_cost_usd: 0.5,
371            result: Some(ResultInfo {
372                subtype: "success".into(),
373                total_cost_usd: Some(0.5),
374                error: None,
375                result_text: None,
376            }),
377            result_text: None,
378        };
379        let r = classify_result(&monitor, Some(0));
380        assert!(
381            matches!(r, PhaseResult::AgentDone { cost_usd: Some(c), .. } if (c - 0.5).abs() < 0.001)
382        );
383    }
384
385    #[test]
386    fn classify_max_turns() {
387        let monitor = MonitorResult {
388            total_cost_usd: 1.0,
389            result: Some(ResultInfo {
390                subtype: "error_max_turns".into(),
391                total_cost_usd: Some(1.0),
392                error: None,
393                result_text: None,
394            }),
395            result_text: None,
396        };
397        let r = classify_result(&monitor, Some(1));
398        assert!(matches!(r, PhaseResult::MaxTurns { .. }));
399    }
400
401    #[test]
402    fn classify_budget_exceeded() {
403        let monitor = MonitorResult {
404            total_cost_usd: 2.0,
405            result: Some(ResultInfo {
406                subtype: "error_max_budget_usd".into(),
407                total_cost_usd: Some(2.0),
408                error: None,
409                result_text: None,
410            }),
411            result_text: None,
412        };
413        let r = classify_result(&monitor, Some(1));
414        assert!(matches!(r, PhaseResult::BudgetExceeded { .. }));
415    }
416
417    #[test]
418    fn classify_execution_error() {
419        let monitor = MonitorResult {
420            total_cost_usd: 0.1,
421            result: Some(ResultInfo {
422                subtype: "error_during_execution".into(),
423                total_cost_usd: Some(0.1),
424                error: Some("tool crashed".into()),
425                result_text: None,
426            }),
427            result_text: None,
428        };
429        let r = classify_result(&monitor, Some(1));
430        match r {
431            PhaseResult::AgentCrash { error } => assert_eq!(error, "tool crashed"),
432            other => panic!("expected AgentCrash, got {:?}", other),
433        }
434    }
435
436    #[test]
437    fn classify_unknown_subtype() {
438        let monitor = MonitorResult {
439            total_cost_usd: 0.0,
440            result: Some(ResultInfo {
441                subtype: "new_error_type".into(),
442                total_cost_usd: None,
443                error: None,
444                result_text: None,
445            }),
446            result_text: None,
447        };
448        let r = classify_result(&monitor, Some(2));
449        assert!(matches!(r, PhaseResult::AgentCrash { .. }));
450    }
451
452    #[test]
453    fn classify_no_result_message() {
454        let monitor = MonitorResult {
455            total_cost_usd: 0.0,
456            result: None,
457            result_text: None,
458        };
459        let r = classify_result(&monitor, Some(137));
460        match r {
461            PhaseResult::AgentCrash { error } => {
462                assert!(error.contains("137"));
463            }
464            other => panic!("expected AgentCrash, got {:?}", other),
465        }
466    }
467
468    #[test]
469    fn classify_no_result_no_exit_code() {
470        let monitor = MonitorResult {
471            total_cost_usd: 0.0,
472            result: None,
473            result_text: None,
474        };
475        let r = classify_result(&monitor, None);
476        match r {
477            PhaseResult::AgentCrash { error } => {
478                assert!(error.contains("-1"));
479            }
480            other => panic!("expected AgentCrash, got {:?}", other),
481        }
482    }
483
484    #[tokio::test]
485    async fn tee_captures_raw_lines() {
486        use tokio::process::Command;
487
488        let dir = tempfile::tempdir().unwrap();
489        let tee_path = dir.path().join("transcript.jsonl");
490
491        // Spawn a process that writes lines to stdout (cross-platform)
492        let mut child = if cfg!(windows) {
493            Command::new("cmd")
494                .args(["/C", "echo line_one & echo line_two"])
495                .stdout(std::process::Stdio::piped())
496                .stderr(std::process::Stdio::null())
497                .spawn()
498                .unwrap()
499        } else {
500            Command::new("sh")
501                .args(["-c", "echo line_one; echo line_two"])
502                .stdout(std::process::Stdio::piped())
503                .stderr(std::process::Stdio::null())
504                .spawn()
505                .unwrap()
506        };
507
508        let stdout = child.stdout.take().unwrap();
509        let mut monitor = StreamMonitor::new(stdout).with_tee(Some(tee_path.clone()));
510        // Lines won't parse as JSON — that's fine, tee captures raw output regardless
511        let _result = monitor.run().await.unwrap();
512
513        // Tee file captured raw lines (drop monitor to flush BufWriter)
514        drop(monitor);
515        let content = std::fs::read_to_string(&tee_path).unwrap();
516        assert!(
517            content.contains("line_one"),
518            "tee should capture raw lines: {content}"
519        );
520        assert!(
521            content.contains("line_two"),
522            "tee should capture both lines: {content}"
523        );
524    }
525}