1use crate::agent::launcher::PhaseResult;
2use anyhow::Result;
3use serde::Deserialize;
4use tokio::io::{AsyncBufReadExt, BufReader};
5use tokio::process::ChildStdout;
6
7#[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 #[serde(other)]
44 Unknown,
45}
46
47#[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#[derive(Debug)]
58pub struct MonitorResult {
59 pub total_cost_usd: f64,
60 pub result: Option<ResultInfo>,
61 pub result_text: Option<String>,
62}
63
64pub 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 pub fn with_verbose(mut self, verbose: bool) -> Self {
87 self.verbose = verbose;
88 self
89 }
90
91 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 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 } 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 }
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
170fn 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 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 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
247fn 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
258fn 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
268pub 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 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 let _result = monitor.run().await.unwrap();
512
513 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}