Skip to main content

scud/commands/spawn/headless/
runner.rs

1//! Headless runner implementations for different harnesses
2//!
3//! This module provides the `HeadlessRunner` trait and implementations for running
4//! AI coding agents (Claude Code and OpenCode) in headless/non-interactive mode,
5//! parsing their streaming JSON output into unified `StreamEvent` types.
6
7use anyhow::Result;
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::process::Stdio;
12use tokio::io::{AsyncBufReadExt, BufReader};
13use tokio::process::{Child, Command};
14use tokio::sync::mpsc;
15use tracing::{debug, trace};
16
17use super::events::{StreamEvent, StreamEventKind};
18use crate::commands::spawn::terminal::{find_harness_binary, Harness};
19
20/// Boxed async result type for dyn-compatible async trait methods
21pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
22
23/// Process backing a headless session - either an OS child process or a tokio task.
24///
25/// Call [`kill()`](SessionProcess::kill) to terminate the process.
26pub enum SessionProcess {
27    /// Standard child process (Claude, OpenCode, Cursor harnesses)
28    Child(Child),
29    /// Tokio task (direct API runner)
30    #[cfg(feature = "direct-api")]
31    Task(tokio::task::JoinHandle<()>),
32}
33
34impl SessionProcess {
35    /// Kill the backing process immediately.
36    pub fn kill(&mut self) -> Result<()> {
37        match self {
38            SessionProcess::Child(child) => {
39                child.start_kill()?;
40                Ok(())
41            }
42            #[cfg(feature = "direct-api")]
43            SessionProcess::Task(handle) => {
44                handle.abort();
45                Ok(())
46            }
47        }
48    }
49
50    /// Wait for the backing process/task to complete.
51    pub async fn wait(&mut self) -> Result<bool> {
52        match self {
53            SessionProcess::Child(child) => {
54                let status = child.wait().await?;
55                Ok(status.success())
56            }
57            #[cfg(feature = "direct-api")]
58            SessionProcess::Task(handle) => {
59                let _ = handle.await;
60                Ok(true)
61            }
62        }
63    }
64}
65
66/// Handle to a running headless session
67pub struct SessionHandle {
68    /// Task ID this session is for
69    pub task_id: String,
70    /// Harness session ID (for continuation)
71    pub session_id: Option<String>,
72    /// Backing process or task
73    process: SessionProcess,
74    /// Event receiver
75    pub events: mpsc::Receiver<StreamEvent>,
76}
77
78impl SessionHandle {
79    /// Create a SessionHandle backed by a child process
80    pub fn from_child(task_id: String, child: Child, events: mpsc::Receiver<StreamEvent>) -> Self {
81        Self {
82            task_id,
83            session_id: None,
84            process: SessionProcess::Child(child),
85            events,
86        }
87    }
88
89    /// Create a SessionHandle backed by a tokio task
90    #[cfg(feature = "direct-api")]
91    pub fn from_task(
92        task_id: String,
93        events: mpsc::Receiver<StreamEvent>,
94        handle: tokio::task::JoinHandle<()>,
95    ) -> Self {
96        Self {
97            task_id,
98            session_id: None,
99            process: SessionProcess::Task(handle),
100            events,
101        }
102    }
103
104    /// Wait for the session to complete
105    pub async fn wait(self) -> Result<bool> {
106        match self.process {
107            SessionProcess::Child(mut child) => {
108                let status = child.wait().await?;
109                Ok(status.success())
110            }
111            #[cfg(feature = "direct-api")]
112            SessionProcess::Task(handle) => {
113                let _ = handle.await;
114                Ok(true)
115            }
116        }
117    }
118
119    /// Interrupt the session (send SIGINT)
120    pub fn interrupt(&mut self) -> Result<()> {
121        match &mut self.process {
122            SessionProcess::Child(child) => {
123                #[cfg(unix)]
124                {
125                    if let Some(pid) = child.id() {
126                        // Send SIGINT using kill command (avoids nix dependency)
127                        let _ = std::process::Command::new("kill")
128                            .arg("-INT")
129                            .arg(pid.to_string())
130                            .status();
131                    }
132                }
133
134                #[cfg(not(unix))]
135                {
136                    // On non-Unix, just kill the process
137                    let _ = child.start_kill();
138                }
139
140                Ok(())
141            }
142            #[cfg(feature = "direct-api")]
143            SessionProcess::Task(handle) => {
144                handle.abort();
145                Ok(())
146            }
147        }
148    }
149
150    /// Kill the session immediately
151    pub fn kill(&mut self) -> Result<()> {
152        match &mut self.process {
153            SessionProcess::Child(child) => {
154                child.start_kill()?;
155                Ok(())
156            }
157            #[cfg(feature = "direct-api")]
158            SessionProcess::Task(handle) => {
159                handle.abort();
160                Ok(())
161            }
162        }
163    }
164
165    /// Decompose the session into its event receiver and a killable process handle.
166    ///
167    /// This allows the caller to own the events stream and process separately,
168    /// e.g. for bridging events to a different channel while retaining the
169    /// ability to kill the subprocess on cancellation.
170    pub fn into_parts(self) -> (mpsc::Receiver<StreamEvent>, SessionProcess) {
171        (self.events, self.process)
172    }
173
174    /// Get the process ID
175    pub fn pid(&self) -> Option<u32> {
176        match &self.process {
177            SessionProcess::Child(child) => child.id(),
178            #[cfg(feature = "direct-api")]
179            SessionProcess::Task(_) => None,
180        }
181    }
182}
183
184/// Trait for headless agent execution
185///
186/// Implementations provide the ability to start agents in headless mode
187/// with streaming JSON output, and to generate commands for interactive
188/// session continuation.
189///
190/// This trait uses boxed futures to be dyn-compatible, allowing runtime
191/// polymorphism via `Box<dyn HeadlessRunner>`.
192pub trait HeadlessRunner: Send + Sync {
193    /// Start an agent with a prompt
194    ///
195    /// Returns a SessionHandle that can be used to receive events,
196    /// wait for completion, or interrupt the session.
197    fn start<'a>(
198        &'a self,
199        task_id: &'a str,
200        prompt: &'a str,
201        working_dir: &'a Path,
202        model: Option<&'a str>,
203    ) -> BoxFuture<'a, Result<SessionHandle>>;
204
205    /// Get the command to launch interactive mode for session continuation
206    ///
207    /// Returns the command and arguments needed to resume the session
208    /// interactively (e.g., `claude --resume <session_id>`).
209    fn interactive_command(&self, session_id: &str) -> Vec<String>;
210
211    /// Get the harness type this runner supports
212    fn harness(&self) -> Harness;
213}
214
215/// Claude Code headless runner
216///
217/// Runs Claude Code in headless mode with `--output-format stream-json`,
218/// parsing the streaming JSON events for display in TUI/GUI.
219pub struct ClaudeHeadless {
220    binary_path: String,
221    allowed_tools: Vec<String>,
222}
223
224impl ClaudeHeadless {
225    /// Create a new Claude headless runner
226    ///
227    /// Finds the Claude binary in PATH or common installation locations.
228    pub fn new() -> Result<Self> {
229        let binary_path = find_harness_binary(Harness::Claude)?.to_string();
230        Ok(Self {
231            binary_path,
232            allowed_tools: vec![
233                "Read".to_string(),
234                "Write".to_string(),
235                "Edit".to_string(),
236                "Bash".to_string(),
237                "Glob".to_string(),
238                "Grep".to_string(),
239            ],
240        })
241    }
242
243    /// Create with an explicit binary path (useful for testing)
244    #[cfg(test)]
245    pub fn with_binary_path(path: impl Into<String>) -> Self {
246        Self {
247            binary_path: path.into(),
248            allowed_tools: vec![],
249        }
250    }
251
252    /// Set the allowed tools for this runner
253    pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
254        self.allowed_tools = tools;
255        self
256    }
257
258    /// Get the binary path
259    pub fn binary_path(&self) -> &str {
260        &self.binary_path
261    }
262}
263
264impl HeadlessRunner for ClaudeHeadless {
265    fn start<'a>(
266        &'a self,
267        task_id: &'a str,
268        prompt: &'a str,
269        working_dir: &'a Path,
270        model: Option<&'a str>,
271    ) -> BoxFuture<'a, Result<SessionHandle>> {
272        Box::pin(async move {
273            let mut cmd = Command::new(&self.binary_path);
274
275            // Core headless flags
276            cmd.arg("-p").arg(prompt);
277            cmd.arg("--output-format").arg("stream-json");
278            cmd.arg("--verbose");
279            cmd.arg("--include-partial-messages");
280            cmd.arg("--dangerously-skip-permissions");
281
282            // Model selection
283            if let Some(m) = model {
284                cmd.arg("--model").arg(m);
285            }
286
287            // Allowed tools
288            if !self.allowed_tools.is_empty() {
289                cmd.arg("--allowedTools").arg(self.allowed_tools.join(","));
290            }
291
292            // Working directory and environment
293            cmd.current_dir(working_dir);
294            cmd.env("SCUD_TASK_ID", task_id);
295            // Allow nested Claude sessions (e.g. attractor pipelines from within Claude Code)
296            cmd.env_remove("CLAUDECODE");
297
298            // Capture stdout for streaming
299            cmd.stdout(Stdio::piped());
300            cmd.stderr(Stdio::piped());
301
302            let mut child = cmd.spawn()?;
303
304            // Create event channel
305            let (tx, rx) = mpsc::channel(1000);
306
307            // Spawn task to read stdout and parse events
308            let stdout = child.stdout.take().expect("stdout was piped");
309            let task_id_clone = task_id.to_string();
310            let task_id_for_events = task_id.to_string();
311
312            tokio::spawn(async move {
313                let reader = BufReader::new(stdout);
314                let mut lines = reader.lines();
315
316                while let Ok(Some(line)) = lines.next_line().await {
317                    if let Some(event) = parse_claude_event(&line) {
318                        trace!(task_id = %task_id_for_events, "claude event: {:?}", event.kind);
319                        if tx.send(event).await.is_err() {
320                            break;
321                        }
322                    } else if !line.trim().is_empty() {
323                        debug!(task_id = %task_id_for_events, "claude: unparsed line: {}", if line.len() > 200 { &line[..200] } else { &line });
324                    }
325                }
326
327                // Send completion event
328                let _ = tx.send(StreamEvent::complete(true)).await;
329            });
330
331            Ok(SessionHandle::from_child(task_id_clone, child, rx))
332        })
333    }
334
335    fn interactive_command(&self, session_id: &str) -> Vec<String> {
336        vec![
337            self.binary_path.clone(),
338            "--resume".to_string(),
339            session_id.to_string(),
340        ]
341    }
342
343    fn harness(&self) -> Harness {
344        Harness::Claude
345    }
346}
347
348/// Parse a line of Claude stream-json output into a StreamEvent
349fn parse_claude_event(line: &str) -> Option<StreamEvent> {
350    let json: serde_json::Value = serde_json::from_str(line).ok()?;
351
352    let event_type = json.get("type")?.as_str()?;
353
354    match event_type {
355        "system" => {
356            // System init event carries session_id (same pattern as Cursor parser)
357            let session_id = json.get("session_id").and_then(|v| v.as_str())?;
358            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
359                session_id: session_id.to_string(),
360            }))
361        }
362        "stream_event" => {
363            // Check for text delta (streaming incremental tokens)
364            if let Some(delta) = json.pointer("/event/delta") {
365                if delta.get("type")?.as_str()? == "text_delta" {
366                    let text = delta.get("text")?.as_str()?;
367                    return Some(StreamEvent::text_delta(text));
368                }
369            }
370            None
371        }
372        "content_block_delta" => {
373            // Streaming delta format (incremental tokens)
374            if let Some(text) = json.pointer("/delta/text").and_then(|v| v.as_str()) {
375                return Some(StreamEvent::text_delta(text));
376            }
377            None
378        }
379        "assistant" => {
380            // "assistant" events are full message snapshots (not incremental deltas).
381            // When --include-partial-messages is used, these duplicate text already
382            // captured from "stream_event" deltas. Skip them to avoid double-counting.
383            None
384        }
385        "tool_use" => {
386            let tool_name = json.get("name")?.as_str()?;
387            let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
388            let input = json
389                .get("input")
390                .cloned()
391                .unwrap_or(serde_json::Value::Null);
392            let input_summary = summarize_json(&input);
393            Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
394        }
395        "tool_result" => {
396            let tool_id = json
397                .get("tool_use_id")
398                .and_then(|v| v.as_str())
399                .unwrap_or("unknown");
400            let success = !json
401                .get("is_error")
402                .and_then(|v| v.as_bool())
403                .unwrap_or(false);
404            Some(StreamEvent::new(StreamEventKind::ToolResult {
405                tool_name: String::new(), // Not always available
406                tool_id: tool_id.to_string(),
407                success,
408            }))
409        }
410        "result" => {
411            // If the result carries a session_id, emit SessionAssigned
412            if let Some(session_id) = json.get("session_id").and_then(|v| v.as_str()) {
413                return Some(StreamEvent::new(StreamEventKind::SessionAssigned {
414                    session_id: session_id.to_string(),
415                }));
416            }
417            let is_error = json
418                .get("is_error")
419                .and_then(|v| v.as_bool())
420                .unwrap_or(false);
421            // The "result" field contains the full text, but this duplicates
422            // what was already captured from "assistant" or "stream_event" events.
423            // We only signal completion here.
424            Some(StreamEvent::complete(!is_error))
425        }
426        "error" => {
427            let message = json
428                .get("error")
429                .and_then(|e| e.as_str())
430                .or_else(|| json.get("message").and_then(|e| e.as_str()))
431                .unwrap_or("Unknown error");
432            Some(StreamEvent::error(message))
433        }
434        _ => None,
435    }
436}
437
438/// Parse a line of Cursor Agent CLI stream-json output into a StreamEvent
439///
440/// Cursor Agent CLI (`agent -p --output-format stream-json`) outputs newline-delimited
441/// JSON events with the following structure:
442///
443/// - `{"type":"system","subtype":"init","session_id":"..."}` - Session init
444/// - `{"type":"tool_call","subtype":"started","call_id":"...","tool_call":{"editToolCall":{...}}}` - Tool start
445/// - `{"type":"tool_call","subtype":"completed","call_id":"...","tool_call":{...}}` - Tool result
446/// - `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}` - Text output
447/// - `{"type":"result","subtype":"success","is_error":false,"result":"...","session_id":"..."}` - Completion
448/// - `{"type":"user",...}` - User message echo (ignored)
449fn parse_cursor_event(line: &str) -> Option<StreamEvent> {
450    let json: serde_json::Value = serde_json::from_str(line).ok()?;
451    let event_type = json.get("type")?.as_str()?;
452
453    match event_type {
454        "system" => {
455            // System init event carries session_id
456            let session_id = json.get("session_id").and_then(|v| v.as_str())?;
457            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
458                session_id: session_id.to_string(),
459            }))
460        }
461        "tool_call" => {
462            let subtype = json
463                .get("subtype")
464                .and_then(|v| v.as_str())
465                .unwrap_or("started");
466            let call_id = json.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
467
468            // Extract tool name from Cursor's *ToolCall nested structure
469            let tool_name = json
470                .get("tool_call")
471                .and_then(|tc| tc.as_object())
472                .and_then(|obj| obj.keys().next())
473                .map(|k| {
474                    // Convert "editToolCall" -> "Edit", "bashToolCall" -> "Bash", etc.
475                    k.trim_end_matches("ToolCall")
476                        .chars()
477                        .next()
478                        .map(|c| {
479                            let mut s = c.to_uppercase().to_string();
480                            s.push_str(&k.trim_end_matches("ToolCall")[c.len_utf8()..]);
481                            s
482                        })
483                        .unwrap_or_else(|| k.to_string())
484                })
485                .unwrap_or_else(|| "tool".to_string());
486
487            match subtype {
488                "started" => {
489                    // Extract args summary from the tool call
490                    let input_summary = json
491                        .get("tool_call")
492                        .and_then(|tc| tc.as_object())
493                        .and_then(|obj| obj.values().next())
494                        .and_then(|v| v.get("args"))
495                        .map(summarize_json)
496                        .unwrap_or_default();
497                    Some(StreamEvent::tool_start(&tool_name, call_id, &input_summary))
498                }
499                "completed" => {
500                    let success = json
501                        .get("tool_call")
502                        .and_then(|tc| tc.as_object())
503                        .and_then(|obj| obj.values().next())
504                        .and_then(|v| v.get("result"))
505                        .map(|r| r.get("success").is_some())
506                        .unwrap_or(true);
507                    Some(StreamEvent::new(StreamEventKind::ToolResult {
508                        tool_name,
509                        tool_id: call_id.to_string(),
510                        success,
511                    }))
512                }
513                _ => None,
514            }
515        }
516        "assistant" => {
517            let text = json
518                .pointer("/message/content/0/text")
519                .and_then(|v| v.as_str())?;
520            Some(StreamEvent::text_delta(text))
521        }
522        "result" => {
523            let is_error = json
524                .get("is_error")
525                .and_then(|v| v.as_bool())
526                .unwrap_or(false);
527            Some(StreamEvent::complete(!is_error))
528        }
529        // Ignore user message echo and unknown types
530        _ => None,
531    }
532}
533
534/// OpenCode headless runner
535///
536/// Runs OpenCode CLI in headless mode using `opencode run --format json`
537/// and parses the streaming JSON output into unified StreamEvent types.
538pub struct OpenCodeHeadless {
539    binary_path: String,
540}
541
542impl OpenCodeHeadless {
543    /// Create a new OpenCode headless runner
544    ///
545    /// Locates the OpenCode binary using the standard harness discovery mechanism.
546    pub fn new() -> Result<Self> {
547        let binary_path = find_harness_binary(Harness::OpenCode)?.to_string();
548        Ok(Self { binary_path })
549    }
550
551    /// Create with an explicit binary path (useful for testing)
552    #[cfg(test)]
553    pub fn with_binary_path(path: impl Into<String>) -> Self {
554        Self {
555            binary_path: path.into(),
556        }
557    }
558}
559
560impl HeadlessRunner for OpenCodeHeadless {
561    fn start<'a>(
562        &'a self,
563        task_id: &'a str,
564        prompt: &'a str,
565        working_dir: &'a Path,
566        model: Option<&'a str>,
567    ) -> BoxFuture<'a, Result<SessionHandle>> {
568        Box::pin(async move {
569            // OpenCode uses `run` command with JSON format for headless streaming
570            let mut cmd = Command::new(&self.binary_path);
571
572            cmd.arg("run");
573            cmd.arg("--format").arg("json");
574            cmd.arg("--variant").arg("minimal");
575
576            if let Some(m) = model {
577                cmd.arg("--model").arg(m);
578            }
579
580            cmd.arg(prompt);
581            cmd.current_dir(working_dir);
582            cmd.env("SCUD_TASK_ID", task_id);
583            cmd.stdout(Stdio::piped());
584            cmd.stderr(Stdio::piped());
585
586            let mut child = cmd.spawn()?;
587            let (tx, rx) = mpsc::channel(1000);
588
589            let stdout = child.stdout.take().expect("stdout was piped");
590            let task_id_for_events = task_id.to_string();
591
592            tokio::spawn(async move {
593                let reader = BufReader::new(stdout);
594                let mut lines = reader.lines();
595
596                while let Ok(Some(line)) = lines.next_line().await {
597                    if let Some(event) = parse_opencode_event(&line) {
598                        trace!(task_id = %task_id_for_events, "opencode event: {:?}", event.kind);
599                        if tx.send(event).await.is_err() {
600                            break;
601                        }
602                    } else if !line.trim().is_empty() {
603                        debug!(task_id = %task_id_for_events, "opencode: unparsed line: {}", if line.len() > 200 { &line[..200] } else { &line });
604                    }
605                }
606
607                let _ = tx.send(StreamEvent::complete(true)).await;
608            });
609
610            Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
611        })
612    }
613
614    fn interactive_command(&self, session_id: &str) -> Vec<String> {
615        // OpenCode uses attach command for session continuation
616        vec![
617            self.binary_path.clone(),
618            "attach".to_string(),
619            "http://localhost:4096".to_string(),
620            "--session".to_string(),
621            session_id.to_string(),
622        ]
623    }
624
625    fn harness(&self) -> Harness {
626        Harness::OpenCode
627    }
628}
629
630/// Cursor Agent headless runner
631///
632/// Runs Cursor Agent CLI in headless mode with `-p` flag,
633/// parsing the streaming JSON output into unified StreamEvent types.
634pub struct CursorHeadless {
635    binary_path: String,
636}
637
638impl CursorHeadless {
639    /// Create a new Cursor headless runner
640    pub fn new() -> Result<Self> {
641        let binary_path = find_harness_binary(Harness::Cursor)?.to_string();
642        Ok(Self { binary_path })
643    }
644}
645
646impl HeadlessRunner for CursorHeadless {
647    fn start<'a>(
648        &'a self,
649        task_id: &'a str,
650        prompt: &'a str,
651        working_dir: &'a Path,
652        model: Option<&'a str>,
653    ) -> BoxFuture<'a, Result<SessionHandle>> {
654        Box::pin(async move {
655            let mut cmd = Command::new(&self.binary_path);
656
657            cmd.arg("-p");
658
659            if let Some(m) = model {
660                cmd.arg("--model").arg(m);
661            }
662
663            // Request streaming JSON output
664            cmd.arg("--output-format").arg("stream-json");
665            cmd.arg(prompt);
666            cmd.current_dir(working_dir);
667            cmd.env("SCUD_TASK_ID", task_id);
668            cmd.stdout(Stdio::piped());
669            cmd.stderr(Stdio::piped());
670
671            let mut child = cmd.spawn()?;
672            let (tx, rx) = mpsc::channel(1000);
673
674            let stdout = child.stdout.take().expect("stdout was piped");
675            let task_id_for_events = task_id.to_string();
676
677            tokio::spawn(async move {
678                let reader = BufReader::new(stdout);
679                let mut lines = reader.lines();
680
681                while let Ok(Some(line)) = lines.next_line().await {
682                    // Try Cursor-specific parsing first
683                    if let Some(event) = parse_cursor_event(&line) {
684                        trace!(task_id = %task_id_for_events, "cursor event: {:?}", event.kind);
685                        if tx.send(event).await.is_err() {
686                            break;
687                        }
688                    } else if !line.trim().is_empty() {
689                        if serde_json::from_str::<serde_json::Value>(&line).is_err() {
690                            // Non-JSON output treated as text
691                            let _ = tx
692                                .send(StreamEvent::text_delta(format!("{}\n", line)))
693                                .await;
694                        } else {
695                            debug!(task_id = %task_id_for_events, "cursor: unparsed json: {}", if line.len() > 200 { &line[..200] } else { &line });
696                        }
697                    }
698                }
699
700                let _ = tx.send(StreamEvent::complete(true)).await;
701            });
702
703            Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
704        })
705    }
706
707    fn interactive_command(&self, session_id: &str) -> Vec<String> {
708        vec![
709            self.binary_path.clone(),
710            "--resume".to_string(),
711            session_id.to_string(),
712        ]
713    }
714
715    fn harness(&self) -> Harness {
716        Harness::Cursor
717    }
718}
719
720/// Rho CLI headless runner
721///
722/// Runs rho-cli with `--output-format stream-json` and parses the
723/// newline-delimited JSON events into unified `StreamEvent` types.
724pub struct RhoHeadless {
725    binary_path: String,
726    model: Option<String>,
727}
728
729impl RhoHeadless {
730    /// Create a new Rho headless runner
731    pub fn new(model: Option<String>) -> Result<Self> {
732        let binary_path = find_harness_binary(Harness::Rho)?.to_string();
733        Ok(Self {
734            binary_path,
735            model,
736        })
737    }
738
739    #[cfg(test)]
740    pub fn with_binary_path(path: impl Into<String>) -> Self {
741        Self {
742            binary_path: path.into(),
743            model: None,
744        }
745    }
746
747    #[cfg(test)]
748    pub fn binary_path(&self) -> &str {
749        &self.binary_path
750    }
751}
752
753impl HeadlessRunner for RhoHeadless {
754    fn start<'a>(
755        &'a self,
756        task_id: &'a str,
757        prompt: &'a str,
758        working_dir: &'a Path,
759        model: Option<&'a str>,
760    ) -> BoxFuture<'a, Result<SessionHandle>> {
761        Box::pin(async move {
762            let mut cmd = Command::new(&self.binary_path);
763
764            // Use stream-json output for structured event parsing
765            cmd.arg("--output-format").arg("stream-json");
766            cmd.arg("-p").arg(prompt);
767            cmd.arg("-C").arg(working_dir);
768
769            // Model: prefer the per-call override, fall back to constructor model
770            let effective_model = model.or(self.model.as_deref());
771            if let Some(m) = effective_model {
772                cmd.arg("--model").arg(m);
773            }
774
775            cmd.current_dir(working_dir);
776            cmd.env("SCUD_TASK_ID", task_id);
777            cmd.stdout(Stdio::piped());
778            cmd.stderr(Stdio::piped());
779
780            let mut child = cmd.spawn()?;
781            let (tx, rx) = mpsc::channel(1000);
782
783            let stdout = child.stdout.take().expect("stdout was piped");
784            let task_id_for_events = task_id.to_string();
785
786            // Parse stdout as newline-delimited JSON (stream-json format)
787            tokio::spawn(async move {
788                let reader = BufReader::new(stdout);
789                let mut lines = reader.lines();
790
791                while let Ok(Some(line)) = lines.next_line().await {
792                    if let Some(event) = parse_rho_event(&line) {
793                        trace!(task_id = %task_id_for_events, "rho event: {:?}", event.kind);
794                        if tx.send(event).await.is_err() {
795                            break;
796                        }
797                    } else if !line.trim().is_empty() {
798                        debug!(task_id = %task_id_for_events, "rho: unparsed line: {}", if line.len() > 200 { &line[..200] } else { &line });
799                    }
800                }
801
802                // Send completion event (in case rho exited without a "complete" event)
803                let _ = tx.send(StreamEvent::complete(true)).await;
804            });
805
806            Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
807        })
808    }
809
810    fn interactive_command(&self, session_id: &str) -> Vec<String> {
811        vec![
812            self.binary_path.clone(),
813            "--resume".to_string(),
814            session_id.to_string(),
815        ]
816    }
817
818    fn harness(&self) -> Harness {
819        Harness::Rho
820    }
821}
822
823/// Parse a line of rho stream-json output into a StreamEvent
824///
825/// Rho CLI (`rho-cli --output-format stream-json`) outputs newline-delimited
826/// JSON events with the following structure:
827///
828/// - `{"type":"session","session_id":"<uuid>"}` - Session assignment
829/// - `{"type":"text_delta","text":"..."}` - Incremental text output
830/// - `{"type":"tool_start","tool_name":"read","tool_id":"tc_1","input_summary":"..."}` - Tool start
831/// - `{"type":"tool_result","tool_name":"read","tool_id":"tc_1","success":true}` - Tool result
832/// - `{"type":"complete","success":true,"session_id":"<uuid>"}` - Completion
833/// - `{"type":"error","message":"..."}` - Error
834fn parse_rho_event(line: &str) -> Option<StreamEvent> {
835    let json: serde_json::Value = serde_json::from_str(line).ok()?;
836
837    let event_type = json.get("type")?.as_str()?;
838
839    match event_type {
840        "session" => {
841            let session_id = json.get("session_id").and_then(|v| v.as_str())?;
842            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
843                session_id: session_id.to_string(),
844            }))
845        }
846        "text_delta" => {
847            let text = json.get("text").and_then(|v| v.as_str())?;
848            Some(StreamEvent::text_delta(text))
849        }
850        "tool_start" => {
851            let tool_name = json
852                .get("tool_name")
853                .and_then(|v| v.as_str())
854                .unwrap_or("unknown");
855            let tool_id = json
856                .get("tool_id")
857                .and_then(|v| v.as_str())
858                .unwrap_or("");
859            let input_summary = json
860                .get("input_summary")
861                .and_then(|v| v.as_str())
862                .unwrap_or("");
863            Some(StreamEvent::tool_start(tool_name, tool_id, input_summary))
864        }
865        "tool_result" => {
866            let tool_name = json
867                .get("tool_name")
868                .and_then(|v| v.as_str())
869                .unwrap_or("")
870                .to_string();
871            let tool_id = json
872                .get("tool_id")
873                .and_then(|v| v.as_str())
874                .unwrap_or("")
875                .to_string();
876            let success = json
877                .get("success")
878                .and_then(|v| v.as_bool())
879                .unwrap_or(true);
880            Some(StreamEvent::new(StreamEventKind::ToolResult {
881                tool_name,
882                tool_id,
883                success,
884            }))
885        }
886        "complete" => {
887            let success = json
888                .get("success")
889                .and_then(|v| v.as_bool())
890                .unwrap_or(true);
891            Some(StreamEvent::complete(success))
892        }
893        "error" => {
894            let message = json
895                .get("message")
896                .and_then(|v| v.as_str())
897                .unwrap_or("Unknown error");
898            Some(StreamEvent::error(message))
899        }
900        _ => None,
901    }
902}
903
904/// Enum-based runner that wraps concrete implementations
905///
906/// This provides polymorphism without requiring the trait to be object-safe.
907/// Use this instead of `Box<dyn HeadlessRunner>` when you need to store
908/// or pass around a runner of unknown concrete type.
909pub enum AnyRunner {
910    Claude(ClaudeHeadless),
911    OpenCode(OpenCodeHeadless),
912    Cursor(CursorHeadless),
913    Rho(RhoHeadless),
914    #[cfg(feature = "direct-api")]
915    DirectApi(super::direct_api::DirectApiRunner),
916}
917
918impl AnyRunner {
919    /// Create a DirectApi runner with an explicit provider
920    #[cfg(feature = "direct-api")]
921    pub fn new_direct_api(provider: crate::llm::provider::AgentProvider) -> Self {
922        AnyRunner::DirectApi(super::direct_api::DirectApiRunner::new().with_provider(provider))
923    }
924
925    /// Create a runner for the specified harness
926    pub fn new(harness: Harness) -> Result<Self> {
927        match harness {
928            Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
929            Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
930            Harness::Cursor => Ok(AnyRunner::Cursor(CursorHeadless::new()?)),
931            Harness::Rho => Ok(AnyRunner::Rho(RhoHeadless::new(None)?)),
932            #[cfg(feature = "direct-api")]
933            Harness::DirectApi => Ok(AnyRunner::DirectApi(
934                super::direct_api::DirectApiRunner::new(),
935            )),
936        }
937    }
938
939    /// Start an agent with a prompt
940    pub async fn start(
941        &self,
942        task_id: &str,
943        prompt: &str,
944        working_dir: &Path,
945        model: Option<&str>,
946    ) -> Result<SessionHandle> {
947        match self {
948            AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
949            AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
950            AnyRunner::Cursor(runner) => runner.start(task_id, prompt, working_dir, model).await,
951            AnyRunner::Rho(runner) => runner.start(task_id, prompt, working_dir, model).await,
952            #[cfg(feature = "direct-api")]
953            AnyRunner::DirectApi(runner) => runner.start(task_id, prompt, working_dir, model).await,
954        }
955    }
956
957    /// Get the command to launch interactive mode for session continuation
958    pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
959        match self {
960            AnyRunner::Claude(runner) => runner.interactive_command(session_id),
961            AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
962            AnyRunner::Cursor(runner) => runner.interactive_command(session_id),
963            AnyRunner::Rho(runner) => runner.interactive_command(session_id),
964            #[cfg(feature = "direct-api")]
965            AnyRunner::DirectApi(runner) => runner.interactive_command(session_id),
966        }
967    }
968
969    /// Get the harness type this runner supports
970    pub fn harness(&self) -> Harness {
971        match self {
972            AnyRunner::Claude(runner) => runner.harness(),
973            AnyRunner::OpenCode(runner) => runner.harness(),
974            AnyRunner::Cursor(runner) => runner.harness(),
975            AnyRunner::Rho(runner) => runner.harness(),
976            #[cfg(feature = "direct-api")]
977            AnyRunner::DirectApi(runner) => runner.harness(),
978        }
979    }
980}
981
982/// Create a headless runner for the specified harness
983///
984/// This is a convenience function that returns an `AnyRunner` enum
985/// which provides a unified interface for all runner implementations.
986pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
987    AnyRunner::new(harness)
988}
989
990/// Parse a line of OpenCode JSON output into a StreamEvent
991///
992/// OpenCode CLI (`opencode run --format json`) outputs newline-delimited JSON events
993/// with the following structure:
994///
995/// - `{"type": "assistant", "message": {"content": [{"text": "..."}]}}` - Text output
996/// - `{"type": "tool_call", "subtype": "started", "tool_call": {"name": "...", "input": {...}}}` - Tool start
997/// - `{"type": "tool_call", "subtype": "completed", "tool_call": {...}, "result": {...}}` - Tool result
998/// - `{"type": "result", "success": true}` - Completion
999/// - `{"type": "error", "message": "..."}` - Error
1000/// - `{"type": "session", "session_id": "..."}` - Session assignment
1001///
1002/// Returns `None` for unparseable or unknown event types (graceful degradation).
1003pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
1004    let json: serde_json::Value = serde_json::from_str(line).ok()?;
1005
1006    let event_type = json.get("type")?.as_str()?;
1007
1008    match event_type {
1009        // Assistant text output - may have various content structures
1010        "assistant" | "message" | "content" => {
1011            // Try multiple paths for text content
1012            let text = json
1013                .pointer("/message/content/0/text")
1014                .or_else(|| json.pointer("/content/0/text"))
1015                .or_else(|| json.pointer("/message/text"))
1016                .or_else(|| json.get("text"))
1017                .or_else(|| json.get("delta"))
1018                .and_then(|v| v.as_str())?;
1019            Some(StreamEvent::text_delta(text))
1020        }
1021
1022        // Tool call events with subtype
1023        "tool_call" | "tool_use" => {
1024            let subtype = json
1025                .get("subtype")
1026                .or_else(|| json.get("status"))
1027                .and_then(|v| v.as_str())
1028                .unwrap_or("started");
1029
1030            match subtype {
1031                "started" | "start" | "pending" => {
1032                    // Extract tool name from various possible locations
1033                    let tool_name = json
1034                        .pointer("/tool_call/name")
1035                        .or_else(|| json.pointer("/tool_call/tool"))
1036                        .or_else(|| json.get("name"))
1037                        .or_else(|| json.get("tool"))
1038                        .and_then(|v| v.as_str())
1039                        .unwrap_or("unknown");
1040
1041                    // Extract tool ID
1042                    let tool_id = json
1043                        .pointer("/tool_call/id")
1044                        .or_else(|| json.get("id"))
1045                        .or_else(|| json.get("tool_id"))
1046                        .and_then(|v| v.as_str())
1047                        .unwrap_or("");
1048
1049                    // Extract and summarize input
1050                    let input = json
1051                        .pointer("/tool_call/input")
1052                        .or_else(|| json.get("input"))
1053                        .cloned()
1054                        .unwrap_or(serde_json::Value::Null);
1055                    let input_summary = summarize_json(&input);
1056
1057                    Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
1058                }
1059                "completed" | "complete" | "done" | "success" => {
1060                    let tool_name = json
1061                        .pointer("/tool_call/name")
1062                        .or_else(|| json.get("name"))
1063                        .or_else(|| json.get("tool"))
1064                        .and_then(|v| v.as_str())
1065                        .unwrap_or("");
1066
1067                    let tool_id = json
1068                        .pointer("/tool_call/id")
1069                        .or_else(|| json.get("id"))
1070                        .or_else(|| json.get("tool_id"))
1071                        .and_then(|v| v.as_str())
1072                        .unwrap_or("");
1073
1074                    // Check for error in result
1075                    let success = !json
1076                        .pointer("/result/is_error")
1077                        .or_else(|| json.get("is_error"))
1078                        .or_else(|| json.get("error"))
1079                        .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
1080                        .unwrap_or(false);
1081
1082                    Some(StreamEvent::new(StreamEventKind::ToolResult {
1083                        tool_name: tool_name.to_string(),
1084                        tool_id: tool_id.to_string(),
1085                        success,
1086                    }))
1087                }
1088                "failed" | "error" => {
1089                    let tool_name = json
1090                        .pointer("/tool_call/name")
1091                        .or_else(|| json.get("name"))
1092                        .and_then(|v| v.as_str())
1093                        .unwrap_or("");
1094
1095                    let tool_id = json
1096                        .pointer("/tool_call/id")
1097                        .or_else(|| json.get("id"))
1098                        .and_then(|v| v.as_str())
1099                        .unwrap_or("");
1100
1101                    Some(StreamEvent::new(StreamEventKind::ToolResult {
1102                        tool_name: tool_name.to_string(),
1103                        tool_id: tool_id.to_string(),
1104                        success: false,
1105                    }))
1106                }
1107                _ => None,
1108            }
1109        }
1110
1111        // Completion event
1112        "result" | "done" | "complete" => {
1113            let success = json
1114                .get("success")
1115                .and_then(|v| v.as_bool())
1116                .unwrap_or(true);
1117            Some(StreamEvent::complete(success))
1118        }
1119
1120        // Error event
1121        "error" => {
1122            let message = json
1123                .get("message")
1124                .or_else(|| json.get("error"))
1125                .and_then(|v| v.as_str())
1126                .unwrap_or("Unknown error");
1127            Some(StreamEvent::error(message))
1128        }
1129
1130        // Session assignment
1131        "session" | "session_start" | "init" => {
1132            let session_id = json
1133                .get("session_id")
1134                .or_else(|| json.get("id"))
1135                .and_then(|v| v.as_str())?;
1136            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
1137                session_id: session_id.to_string(),
1138            }))
1139        }
1140
1141        // Unknown event type - return None for graceful handling
1142        _ => None,
1143    }
1144}
1145
1146/// Summarize JSON input for compact display
1147///
1148/// Produces a short, human-readable summary of JSON values:
1149/// - Objects: `{key1, key2, ...}` (max 3 keys)
1150/// - Strings: First 50 chars with ellipsis
1151/// - Other: JSON stringified, truncated
1152fn summarize_json(value: &serde_json::Value) -> String {
1153    match value {
1154        serde_json::Value::Object(obj) => {
1155            let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
1156            if keys.is_empty() {
1157                "{}".to_string()
1158            } else if keys.len() < obj.len() {
1159                format!("{{{},...}}", keys.join(", "))
1160            } else {
1161                format!("{{{}}}", keys.join(", "))
1162            }
1163        }
1164        serde_json::Value::String(s) => {
1165            if s.len() > 50 {
1166                format!("\"{}...\"", &s[..47])
1167            } else {
1168                format!("\"{}\"", s)
1169            }
1170        }
1171        serde_json::Value::Null => String::new(),
1172        serde_json::Value::Array(arr) => {
1173            format!("[{} items]", arr.len())
1174        }
1175        other => {
1176            let s = other.to_string();
1177            if s.len() > 50 {
1178                format!("{}...", &s[..47])
1179            } else {
1180                s
1181            }
1182        }
1183    }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189
1190    // =======================
1191    // Claude event parsing
1192    // =======================
1193
1194    #[test]
1195    fn test_parse_claude_text_delta() {
1196        let line =
1197            r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
1198        let event = parse_claude_event(line);
1199        assert!(matches!(
1200            event,
1201            Some(StreamEvent {
1202                kind: StreamEventKind::TextDelta { ref text },
1203                ..
1204            }) if text == "Hello"
1205        ));
1206    }
1207
1208    #[test]
1209    fn test_parse_claude_tool_use() {
1210        let line =
1211            r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
1212        let event = parse_claude_event(line);
1213        match event {
1214            Some(StreamEvent {
1215                kind:
1216                    StreamEventKind::ToolStart {
1217                        ref tool_name,
1218                        ref tool_id,
1219                        ref input_summary,
1220                    },
1221                ..
1222            }) => {
1223                assert_eq!(tool_name, "Read");
1224                assert_eq!(tool_id, "tool_1");
1225                assert!(input_summary.contains("path"));
1226            }
1227            _ => panic!("Expected ToolStart"),
1228        }
1229    }
1230
1231    #[test]
1232    fn test_parse_claude_error() {
1233        let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
1234        let event = parse_claude_event(line);
1235        match event {
1236            Some(StreamEvent {
1237                kind: StreamEventKind::Error { ref message },
1238                ..
1239            }) => {
1240                assert_eq!(message, "Rate limit exceeded");
1241            }
1242            _ => panic!("Expected Error event"),
1243        }
1244    }
1245
1246    #[test]
1247    fn test_parse_claude_system_init_session() {
1248        let line = r#"{"type":"system","subtype":"init","session_id":"sess-init-123"}"#;
1249        let event = parse_claude_event(line);
1250        match event {
1251            Some(StreamEvent {
1252                kind: StreamEventKind::SessionAssigned { ref session_id },
1253                ..
1254            }) => {
1255                assert_eq!(session_id, "sess-init-123");
1256            }
1257            _ => panic!("Expected SessionAssigned from system init event"),
1258        }
1259    }
1260
1261    #[test]
1262    fn test_parse_claude_result_with_session() {
1263        let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
1264        let event = parse_claude_event(line);
1265        match event {
1266            Some(StreamEvent {
1267                kind: StreamEventKind::SessionAssigned { ref session_id },
1268                ..
1269            }) => {
1270                assert_eq!(session_id, "sess-abc123");
1271            }
1272            _ => panic!("Expected SessionAssigned"),
1273        }
1274    }
1275
1276    #[test]
1277    fn test_parse_claude_result_completion() {
1278        let line = r#"{"type":"result"}"#;
1279        let event = parse_claude_event(line);
1280        assert!(matches!(
1281            event,
1282            Some(StreamEvent {
1283                kind: StreamEventKind::Complete { success: true },
1284                ..
1285            })
1286        ));
1287    }
1288
1289    #[test]
1290    fn test_parse_claude_tool_result() {
1291        let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
1292        let event = parse_claude_event(line);
1293        match event {
1294            Some(StreamEvent {
1295                kind:
1296                    StreamEventKind::ToolResult {
1297                        ref tool_id,
1298                        success,
1299                        ..
1300                    },
1301                ..
1302            }) => {
1303                assert_eq!(tool_id, "tool_1");
1304                assert!(success);
1305            }
1306            _ => panic!("Expected ToolResult"),
1307        }
1308    }
1309
1310    #[test]
1311    fn test_parse_claude_tool_result_error() {
1312        let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
1313        let event = parse_claude_event(line);
1314        match event {
1315            Some(StreamEvent {
1316                kind: StreamEventKind::ToolResult { success, .. },
1317                ..
1318            }) => {
1319                assert!(!success);
1320            }
1321            _ => panic!("Expected ToolResult with failure"),
1322        }
1323    }
1324
1325    #[test]
1326    fn test_parse_claude_unknown_type_returns_none() {
1327        let line = r#"{"type":"unknown_event","data":"test"}"#;
1328        let event = parse_claude_event(line);
1329        assert!(event.is_none());
1330    }
1331
1332    #[test]
1333    fn test_claude_interactive_command() {
1334        let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
1335        let cmd = runner.interactive_command("sess_123");
1336        assert_eq!(cmd[0], "/usr/local/bin/claude");
1337        assert_eq!(cmd[1], "--resume");
1338        assert_eq!(cmd[2], "sess_123");
1339    }
1340
1341    // =======================
1342    // OpenCode event parsing
1343    // =======================
1344
1345    #[test]
1346    fn test_parse_assistant_text_with_message_content() {
1347        let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
1348        let event = parse_opencode_event(line);
1349        assert!(matches!(
1350            event,
1351            Some(StreamEvent {
1352                kind: StreamEventKind::TextDelta { ref text },
1353                ..
1354            }) if text == "Hello world"
1355        ));
1356    }
1357
1358    #[test]
1359    fn test_parse_content_type_with_text() {
1360        let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
1361        let event = parse_opencode_event(line);
1362        assert!(matches!(
1363            event,
1364            Some(StreamEvent {
1365                kind: StreamEventKind::TextDelta { ref text },
1366                ..
1367            }) if text == "Response text"
1368        ));
1369    }
1370
1371    #[test]
1372    fn test_parse_message_type_with_direct_text() {
1373        let line = r#"{"type": "message", "text": "Direct text"}"#;
1374        let event = parse_opencode_event(line);
1375        assert!(matches!(
1376            event,
1377            Some(StreamEvent {
1378                kind: StreamEventKind::TextDelta { ref text },
1379                ..
1380            }) if text == "Direct text"
1381        ));
1382    }
1383
1384    #[test]
1385    fn test_parse_assistant_with_delta_field() {
1386        let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
1387        let event = parse_opencode_event(line);
1388        assert!(matches!(
1389            event,
1390            Some(StreamEvent {
1391                kind: StreamEventKind::TextDelta { ref text },
1392                ..
1393            }) if text == "Streaming chunk"
1394        ));
1395    }
1396
1397    // ===================
1398    // Tool call parsing
1399    // ===================
1400
1401    #[test]
1402    fn test_parse_tool_call_started() {
1403        let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
1404        let event = parse_opencode_event(line);
1405        match event {
1406            Some(StreamEvent {
1407                kind:
1408                    StreamEventKind::ToolStart {
1409                        ref tool_name,
1410                        ref tool_id,
1411                        ref input_summary,
1412                    },
1413                ..
1414            }) => {
1415                assert_eq!(tool_name, "read_file");
1416                assert_eq!(tool_id, "tool_1");
1417                assert!(input_summary.contains("path"));
1418            }
1419            _ => panic!("Expected ToolStart, got {:?}", event),
1420        }
1421    }
1422
1423    #[test]
1424    fn test_parse_tool_use_start() {
1425        let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
1426        let event = parse_opencode_event(line);
1427        match event {
1428            Some(StreamEvent {
1429                kind:
1430                    StreamEventKind::ToolStart {
1431                        ref tool_name,
1432                        ref tool_id,
1433                        ..
1434                    },
1435                ..
1436            }) => {
1437                assert_eq!(tool_name, "bash");
1438                assert_eq!(tool_id, "t123");
1439            }
1440            _ => panic!("Expected ToolStart"),
1441        }
1442    }
1443
1444    #[test]
1445    fn test_parse_tool_call_completed() {
1446        let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
1447        let event = parse_opencode_event(line);
1448        match event {
1449            Some(StreamEvent {
1450                kind:
1451                    StreamEventKind::ToolResult {
1452                        ref tool_name,
1453                        ref tool_id,
1454                        success,
1455                    },
1456                ..
1457            }) => {
1458                assert_eq!(tool_name, "write_file");
1459                assert_eq!(tool_id, "t2");
1460                assert!(success);
1461            }
1462            _ => panic!("Expected ToolResult"),
1463        }
1464    }
1465
1466    #[test]
1467    fn test_parse_tool_call_with_error() {
1468        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
1469        let event = parse_opencode_event(line);
1470        match event {
1471            Some(StreamEvent {
1472                kind: StreamEventKind::ToolResult { success, .. },
1473                ..
1474            }) => {
1475                assert!(!success);
1476            }
1477            _ => panic!("Expected ToolResult with failure"),
1478        }
1479    }
1480
1481    #[test]
1482    fn test_parse_tool_call_failed_subtype() {
1483        let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
1484        let event = parse_opencode_event(line);
1485        match event {
1486            Some(StreamEvent {
1487                kind: StreamEventKind::ToolResult { success, .. },
1488                ..
1489            }) => {
1490                assert!(!success);
1491            }
1492            _ => panic!("Expected failed ToolResult"),
1493        }
1494    }
1495
1496    // ===================
1497    // Completion parsing
1498    // ===================
1499
1500    #[test]
1501    fn test_parse_result_success() {
1502        let line = r#"{"type": "result", "success": true}"#;
1503        let event = parse_opencode_event(line);
1504        assert!(matches!(
1505            event,
1506            Some(StreamEvent {
1507                kind: StreamEventKind::Complete { success: true },
1508                ..
1509            })
1510        ));
1511    }
1512
1513    #[test]
1514    fn test_parse_result_failure() {
1515        let line = r#"{"type": "result", "success": false}"#;
1516        let event = parse_opencode_event(line);
1517        assert!(matches!(
1518            event,
1519            Some(StreamEvent {
1520                kind: StreamEventKind::Complete { success: false },
1521                ..
1522            })
1523        ));
1524    }
1525
1526    #[test]
1527    fn test_parse_done_type() {
1528        let line = r#"{"type": "done"}"#;
1529        let event = parse_opencode_event(line);
1530        assert!(matches!(
1531            event,
1532            Some(StreamEvent {
1533                kind: StreamEventKind::Complete { success: true },
1534                ..
1535            })
1536        ));
1537    }
1538
1539    // ===================
1540    // Error parsing
1541    // ===================
1542
1543    #[test]
1544    fn test_parse_error_with_message() {
1545        let line = r#"{"type": "error", "message": "Connection failed"}"#;
1546        let event = parse_opencode_event(line);
1547        match event {
1548            Some(StreamEvent {
1549                kind: StreamEventKind::Error { ref message },
1550                ..
1551            }) => {
1552                assert_eq!(message, "Connection failed");
1553            }
1554            _ => panic!("Expected Error event"),
1555        }
1556    }
1557
1558    #[test]
1559    fn test_parse_error_with_error_field() {
1560        let line = r#"{"type": "error", "error": "Rate limited"}"#;
1561        let event = parse_opencode_event(line);
1562        match event {
1563            Some(StreamEvent {
1564                kind: StreamEventKind::Error { ref message },
1565                ..
1566            }) => {
1567                assert_eq!(message, "Rate limited");
1568            }
1569            _ => panic!("Expected Error event"),
1570        }
1571    }
1572
1573    // ===================
1574    // Session parsing
1575    // ===================
1576
1577    #[test]
1578    fn test_parse_session_assignment() {
1579        let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1580        let event = parse_opencode_event(line);
1581        match event {
1582            Some(StreamEvent {
1583                kind: StreamEventKind::SessionAssigned { ref session_id },
1584                ..
1585            }) => {
1586                assert_eq!(session_id, "sess_abc123");
1587            }
1588            _ => panic!("Expected SessionAssigned"),
1589        }
1590    }
1591
1592    #[test]
1593    fn test_parse_session_with_id_field() {
1594        let line = r#"{"type": "init", "id": "session_xyz"}"#;
1595        let event = parse_opencode_event(line);
1596        match event {
1597            Some(StreamEvent {
1598                kind: StreamEventKind::SessionAssigned { ref session_id },
1599                ..
1600            }) => {
1601                assert_eq!(session_id, "session_xyz");
1602            }
1603            _ => panic!("Expected SessionAssigned"),
1604        }
1605    }
1606
1607    // ===================
1608    // Edge cases
1609    // ===================
1610
1611    #[test]
1612    fn test_parse_unknown_event_returns_none() {
1613        let line = r#"{"type": "custom_event", "data": "something"}"#;
1614        let event = parse_opencode_event(line);
1615        assert!(event.is_none());
1616    }
1617
1618    #[test]
1619    fn test_parse_invalid_json_returns_none() {
1620        let line = "not json at all";
1621        let event = parse_opencode_event(line);
1622        assert!(event.is_none());
1623    }
1624
1625    #[test]
1626    fn test_parse_missing_type_returns_none() {
1627        let line = r#"{"message": "no type field"}"#;
1628        let event = parse_opencode_event(line);
1629        assert!(event.is_none());
1630    }
1631
1632    #[test]
1633    fn test_parse_empty_json_returns_none() {
1634        let line = "{}";
1635        let event = parse_opencode_event(line);
1636        assert!(event.is_none());
1637    }
1638
1639    // ===================
1640    // JSON summarization
1641    // ===================
1642
1643    #[test]
1644    fn test_summarize_json_object() {
1645        let value = serde_json::json!({"path": "/foo", "content": "bar"});
1646        let summary = summarize_json(&value);
1647        assert!(summary.contains("path"));
1648        assert!(summary.contains("content"));
1649    }
1650
1651    #[test]
1652    fn test_summarize_json_object_truncated() {
1653        let value = serde_json::json!({
1654            "key1": "v1",
1655            "key2": "v2",
1656            "key3": "v3",
1657            "key4": "v4"
1658        });
1659        let summary = summarize_json(&value);
1660        assert!(summary.contains("..."));
1661    }
1662
1663    #[test]
1664    fn test_summarize_json_empty_object() {
1665        let value = serde_json::json!({});
1666        let summary = summarize_json(&value);
1667        assert_eq!(summary, "{}");
1668    }
1669
1670    #[test]
1671    fn test_summarize_json_string() {
1672        let value = serde_json::json!("short string");
1673        let summary = summarize_json(&value);
1674        assert_eq!(summary, "\"short string\"");
1675    }
1676
1677    #[test]
1678    fn test_summarize_json_long_string() {
1679        let long = "a".repeat(100);
1680        let value = serde_json::json!(long);
1681        let summary = summarize_json(&value);
1682        assert!(summary.len() < 60);
1683        assert!(summary.ends_with("...\""));
1684    }
1685
1686    #[test]
1687    fn test_summarize_json_null() {
1688        let value = serde_json::Value::Null;
1689        let summary = summarize_json(&value);
1690        assert_eq!(summary, "");
1691    }
1692
1693    #[test]
1694    fn test_summarize_json_array() {
1695        let value = serde_json::json!([1, 2, 3, 4, 5]);
1696        let summary = summarize_json(&value);
1697        assert_eq!(summary, "[5 items]");
1698    }
1699
1700    #[test]
1701    fn test_summarize_json_number() {
1702        let value = serde_json::json!(42);
1703        let summary = summarize_json(&value);
1704        assert_eq!(summary, "42");
1705    }
1706
1707    // ===================
1708    // Interactive command
1709    // ===================
1710
1711    #[test]
1712    fn test_interactive_command_format() {
1713        let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1714        let cmd = runner.interactive_command("session_123");
1715        assert_eq!(cmd[0], "/usr/local/bin/opencode");
1716        assert_eq!(cmd[1], "attach");
1717        assert!(cmd.contains(&"--session".to_string()));
1718        assert!(cmd.contains(&"session_123".to_string()));
1719    }
1720
1721    // ===================
1722    // OpenCodeHeadless struct tests
1723    // ===================
1724
1725    #[test]
1726    fn test_opencode_headless_with_binary_path() {
1727        let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1728        // Verify harness returns OpenCode
1729        assert!(matches!(runner.harness(), Harness::OpenCode));
1730    }
1731
1732    #[test]
1733    fn test_opencode_interactive_command_structure() {
1734        let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1735        let cmd = runner.interactive_command("sess-xyz-789");
1736
1737        // Should produce: opencode attach http://localhost:4096 --session sess-xyz-789
1738        assert_eq!(cmd.len(), 5);
1739        assert_eq!(cmd[0], "/bin/opencode");
1740        assert_eq!(cmd[1], "attach");
1741        assert_eq!(cmd[2], "http://localhost:4096");
1742        assert_eq!(cmd[3], "--session");
1743        assert_eq!(cmd[4], "sess-xyz-789");
1744    }
1745
1746    #[test]
1747    fn test_opencode_harness_type() {
1748        let runner = OpenCodeHeadless::with_binary_path("opencode");
1749        assert_eq!(runner.harness(), Harness::OpenCode);
1750    }
1751
1752    // ===================
1753    // ClaudeHeadless struct tests
1754    // ===================
1755
1756    #[test]
1757    fn test_claude_headless_with_binary_path() {
1758        let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1759        assert_eq!(runner.binary_path(), "/custom/claude");
1760        assert!(matches!(runner.harness(), Harness::Claude));
1761    }
1762
1763    #[test]
1764    fn test_claude_headless_with_allowed_tools() {
1765        let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1766            .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1767        // The runner should accept the tools (no getter, but constructor works)
1768        assert_eq!(runner.binary_path(), "/bin/claude");
1769    }
1770
1771    #[test]
1772    fn test_claude_interactive_command_structure() {
1773        let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1774        let cmd = runner.interactive_command("sess-abc-123");
1775
1776        // Should produce: claude --resume sess-abc-123
1777        assert_eq!(cmd.len(), 3);
1778        assert_eq!(cmd[0], "/usr/bin/claude");
1779        assert_eq!(cmd[1], "--resume");
1780        assert_eq!(cmd[2], "sess-abc-123");
1781    }
1782
1783    #[test]
1784    fn test_claude_harness_type() {
1785        let runner = ClaudeHeadless::with_binary_path("claude");
1786        assert_eq!(runner.harness(), Harness::Claude);
1787    }
1788
1789    // ===================
1790    // AnyRunner enum tests
1791    // ===================
1792
1793    #[test]
1794    fn test_any_runner_claude_variant() {
1795        let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1796        assert_eq!(runner.harness(), Harness::Claude);
1797
1798        let cmd = runner.interactive_command("session-1");
1799        assert_eq!(cmd[0], "/bin/claude");
1800        assert_eq!(cmd[1], "--resume");
1801    }
1802
1803    #[test]
1804    fn test_any_runner_opencode_variant() {
1805        let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1806        assert_eq!(runner.harness(), Harness::OpenCode);
1807
1808        let cmd = runner.interactive_command("session-2");
1809        assert_eq!(cmd[0], "/bin/opencode");
1810        assert_eq!(cmd[1], "attach");
1811    }
1812
1813    #[test]
1814    fn test_any_runner_rho_variant_resume_command() {
1815        let runner = AnyRunner::Rho(RhoHeadless::with_binary_path("/bin/rho-cli"));
1816        assert_eq!(runner.harness(), Harness::Rho);
1817
1818        let cmd = runner.interactive_command("session-rho-1");
1819        assert_eq!(cmd.len(), 3);
1820        assert_eq!(cmd[0], "/bin/rho-cli");
1821        assert_eq!(cmd[1], "--resume");
1822        assert_eq!(cmd[2], "session-rho-1");
1823    }
1824
1825    #[test]
1826    fn test_any_runner_harness_matches() {
1827        let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1828        let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1829
1830        // Verify harness() returns correct type for each variant
1831        assert!(matches!(claude.harness(), Harness::Claude));
1832        assert!(matches!(opencode.harness(), Harness::OpenCode));
1833    }
1834
1835    // ===================
1836    // Additional OpenCode parsing edge cases
1837    // ===================
1838
1839    #[test]
1840    fn test_parse_opencode_tool_with_pending_status() {
1841        let line =
1842            r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1843        let event = parse_opencode_event(line);
1844        match event {
1845            Some(StreamEvent {
1846                kind:
1847                    StreamEventKind::ToolStart {
1848                        ref tool_name,
1849                        ref tool_id,
1850                        ..
1851                    },
1852                ..
1853            }) => {
1854                assert_eq!(tool_name, "write_file");
1855                assert_eq!(tool_id, "t99");
1856            }
1857            _ => panic!("Expected ToolStart for pending status"),
1858        }
1859    }
1860
1861    #[test]
1862    fn test_parse_opencode_tool_done_status() {
1863        let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1864        let event = parse_opencode_event(line);
1865        match event {
1866            Some(StreamEvent {
1867                kind:
1868                    StreamEventKind::ToolResult {
1869                        ref tool_name,
1870                        success,
1871                        ..
1872                    },
1873                ..
1874            }) => {
1875                assert_eq!(tool_name, "exec");
1876                assert!(success);
1877            }
1878            _ => panic!("Expected ToolResult for done subtype"),
1879        }
1880    }
1881
1882    #[test]
1883    fn test_parse_opencode_tool_success_status() {
1884        let line = r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1885        let event = parse_opencode_event(line);
1886        match event {
1887            Some(StreamEvent {
1888                kind: StreamEventKind::ToolResult { success, .. },
1889                ..
1890            }) => {
1891                assert!(success);
1892            }
1893            _ => panic!("Expected ToolResult for success subtype"),
1894        }
1895    }
1896
1897    #[test]
1898    fn test_parse_opencode_complete_type() {
1899        let line = r#"{"type": "complete", "success": true}"#;
1900        let event = parse_opencode_event(line);
1901        assert!(matches!(
1902            event,
1903            Some(StreamEvent {
1904                kind: StreamEventKind::Complete { success: true },
1905                ..
1906            })
1907        ));
1908    }
1909
1910    #[test]
1911    fn test_parse_opencode_session_start_type() {
1912        let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1913        let event = parse_opencode_event(line);
1914        match event {
1915            Some(StreamEvent {
1916                kind: StreamEventKind::SessionAssigned { ref session_id },
1917                ..
1918            }) => {
1919                assert_eq!(session_id, "sess-start-001");
1920            }
1921            _ => panic!("Expected SessionAssigned for session_start type"),
1922        }
1923    }
1924
1925    #[test]
1926    fn test_parse_opencode_assistant_with_message_text() {
1927        let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1928        let event = parse_opencode_event(line);
1929        assert!(matches!(
1930            event,
1931            Some(StreamEvent {
1932                kind: StreamEventKind::TextDelta { ref text },
1933                ..
1934            }) if text == "Thinking about this..."
1935        ));
1936    }
1937
1938    #[test]
1939    fn test_parse_opencode_tool_call_error_subtype() {
1940        let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1941        let event = parse_opencode_event(line);
1942        match event {
1943            Some(StreamEvent {
1944                kind:
1945                    StreamEventKind::ToolResult {
1946                        ref tool_name,
1947                        success,
1948                        ..
1949                    },
1950                ..
1951            }) => {
1952                assert_eq!(tool_name, "git");
1953                assert!(!success);
1954            }
1955            _ => panic!("Expected failed ToolResult for error subtype"),
1956        }
1957    }
1958
1959    #[test]
1960    fn test_parse_opencode_tool_with_nested_input() {
1961        let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "write_file", "id": "t100", "input": {"path": "src/lib.rs", "content": "// Code here", "mode": "overwrite"}}}"#;
1962        let event = parse_opencode_event(line);
1963        match event {
1964            Some(StreamEvent {
1965                kind:
1966                    StreamEventKind::ToolStart {
1967                        ref tool_name,
1968                        ref input_summary,
1969                        ..
1970                    },
1971                ..
1972            }) => {
1973                assert_eq!(tool_name, "write_file");
1974                // Input should be summarized with keys
1975                assert!(input_summary.contains("path"));
1976            }
1977            _ => panic!("Expected ToolStart with input summary"),
1978        }
1979    }
1980
1981    #[test]
1982    fn test_parse_opencode_tool_result_with_error_string() {
1983        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1984        let event = parse_opencode_event(line);
1985        match event {
1986            Some(StreamEvent {
1987                kind: StreamEventKind::ToolResult { success, .. },
1988                ..
1989            }) => {
1990                // error field as string should indicate failure
1991                assert!(!success);
1992            }
1993            _ => panic!("Expected failed ToolResult"),
1994        }
1995    }
1996
1997    #[test]
1998    fn test_parse_opencode_unknown_subtype_returns_none() {
1999        let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
2000        let event = parse_opencode_event(line);
2001        assert!(event.is_none());
2002    }
2003
2004    // =======================
2005    // Cursor event parsing
2006    // =======================
2007
2008    #[test]
2009    fn test_parse_cursor_system_init() {
2010        let line = r#"{"type":"system","subtype":"init","session_id":"013608ef-dda7-4b38-9741-54fb0323ce1c","model":"Claude 4.5 Opus"}"#;
2011        let event = parse_cursor_event(line);
2012        match event {
2013            Some(StreamEvent {
2014                kind: StreamEventKind::SessionAssigned { ref session_id },
2015                ..
2016            }) => {
2017                assert_eq!(session_id, "013608ef-dda7-4b38-9741-54fb0323ce1c");
2018            }
2019            _ => panic!("Expected SessionAssigned from system init"),
2020        }
2021    }
2022
2023    #[test]
2024    fn test_parse_cursor_tool_call_started() {
2025        let line = r#"{"type":"tool_call","subtype":"started","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py","streamContent":"print(\"Hello\")\n"}}}}"#;
2026        let event = parse_cursor_event(line);
2027        match event {
2028            Some(StreamEvent {
2029                kind:
2030                    StreamEventKind::ToolStart {
2031                        ref tool_name,
2032                        ref tool_id,
2033                        ref input_summary,
2034                    },
2035                ..
2036            }) => {
2037                assert_eq!(tool_name, "Edit");
2038                assert_eq!(tool_id, "toolu_123");
2039                assert!(input_summary.contains("path"));
2040            }
2041            _ => panic!("Expected ToolStart, got {:?}", event),
2042        }
2043    }
2044
2045    #[test]
2046    fn test_parse_cursor_tool_call_completed() {
2047        let line = r#"{"type":"tool_call","subtype":"completed","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py"},"result":{"success":{"path":"/tmp/hello.py","linesAdded":1}}}}}"#;
2048        let event = parse_cursor_event(line);
2049        match event {
2050            Some(StreamEvent {
2051                kind:
2052                    StreamEventKind::ToolResult {
2053                        ref tool_name,
2054                        ref tool_id,
2055                        success,
2056                    },
2057                ..
2058            }) => {
2059                assert_eq!(tool_name, "Edit");
2060                assert_eq!(tool_id, "toolu_123");
2061                assert!(success);
2062            }
2063            _ => panic!("Expected ToolResult, got {:?}", event),
2064        }
2065    }
2066
2067    #[test]
2068    fn test_parse_cursor_assistant_message() {
2069        let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.py"}]}}"#;
2070        let event = parse_cursor_event(line);
2071        assert!(matches!(
2072            event,
2073            Some(StreamEvent {
2074                kind: StreamEventKind::TextDelta { ref text },
2075                ..
2076            }) if text == "Created hello.py"
2077        ));
2078    }
2079
2080    #[test]
2081    fn test_parse_cursor_result_success() {
2082        let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"Done","session_id":"sess-123"}"#;
2083        let event = parse_cursor_event(line);
2084        assert!(matches!(
2085            event,
2086            Some(StreamEvent {
2087                kind: StreamEventKind::Complete { success: true },
2088                ..
2089            })
2090        ));
2091    }
2092
2093    #[test]
2094    fn test_parse_cursor_result_error() {
2095        let line = r#"{"type":"result","subtype":"error","is_error":true,"result":"Failed"}"#;
2096        let event = parse_cursor_event(line);
2097        assert!(matches!(
2098            event,
2099            Some(StreamEvent {
2100                kind: StreamEventKind::Complete { success: false },
2101                ..
2102            })
2103        ));
2104    }
2105
2106    #[test]
2107    fn test_parse_cursor_user_message_ignored() {
2108        let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}}"#;
2109        let event = parse_cursor_event(line);
2110        assert!(event.is_none());
2111    }
2112
2113    #[test]
2114    fn test_parse_cursor_invalid_json() {
2115        let event = parse_cursor_event("not json");
2116        assert!(event.is_none());
2117    }
2118
2119    // =======================
2120    // Rho event parsing
2121    // =======================
2122
2123    #[test]
2124    fn test_parse_rho_session() {
2125        let line = r#"{"type":"session","session_id":"abc-123-def"}"#;
2126        let event = parse_rho_event(line);
2127        match event {
2128            Some(StreamEvent {
2129                kind: StreamEventKind::SessionAssigned { ref session_id },
2130                ..
2131            }) => {
2132                assert_eq!(session_id, "abc-123-def");
2133            }
2134            _ => panic!("Expected SessionAssigned"),
2135        }
2136    }
2137
2138    #[test]
2139    fn test_parse_rho_text_delta() {
2140        let line = r#"{"type":"text_delta","text":"Hello world"}"#;
2141        let event = parse_rho_event(line);
2142        assert!(matches!(
2143            event,
2144            Some(StreamEvent {
2145                kind: StreamEventKind::TextDelta { ref text },
2146                ..
2147            }) if text == "Hello world"
2148        ));
2149    }
2150
2151    #[test]
2152    fn test_parse_rho_tool_start() {
2153        let line =
2154            r#"{"type":"tool_start","tool_name":"read","tool_id":"tc_1","input_summary":"src/main.rs"}"#;
2155        let event = parse_rho_event(line);
2156        match event {
2157            Some(StreamEvent {
2158                kind:
2159                    StreamEventKind::ToolStart {
2160                        ref tool_name,
2161                        ref tool_id,
2162                        ref input_summary,
2163                    },
2164                ..
2165            }) => {
2166                assert_eq!(tool_name, "read");
2167                assert_eq!(tool_id, "tc_1");
2168                assert_eq!(input_summary, "src/main.rs");
2169            }
2170            _ => panic!("Expected ToolStart"),
2171        }
2172    }
2173
2174    #[test]
2175    fn test_parse_rho_tool_result() {
2176        let line = r#"{"type":"tool_result","tool_name":"read","tool_id":"tc_1","success":true}"#;
2177        let event = parse_rho_event(line);
2178        match event {
2179            Some(StreamEvent {
2180                kind:
2181                    StreamEventKind::ToolResult {
2182                        ref tool_name,
2183                        ref tool_id,
2184                        success,
2185                    },
2186                ..
2187            }) => {
2188                assert_eq!(tool_name, "read");
2189                assert_eq!(tool_id, "tc_1");
2190                assert!(success);
2191            }
2192            _ => panic!("Expected ToolResult"),
2193        }
2194    }
2195
2196    #[test]
2197    fn test_parse_rho_tool_result_failure() {
2198        let line =
2199            r#"{"type":"tool_result","tool_name":"bash","tool_id":"tc_2","success":false}"#;
2200        let event = parse_rho_event(line);
2201        match event {
2202            Some(StreamEvent {
2203                kind: StreamEventKind::ToolResult { success, .. },
2204                ..
2205            }) => {
2206                assert!(!success);
2207            }
2208            _ => panic!("Expected ToolResult"),
2209        }
2210    }
2211
2212    #[test]
2213    fn test_parse_rho_complete() {
2214        let line = r#"{"type":"complete","success":true,"session_id":"abc-123"}"#;
2215        let event = parse_rho_event(line);
2216        assert!(matches!(
2217            event,
2218            Some(StreamEvent {
2219                kind: StreamEventKind::Complete { success: true },
2220                ..
2221            })
2222        ));
2223    }
2224
2225    #[test]
2226    fn test_parse_rho_complete_failure() {
2227        let line = r#"{"type":"complete","success":false}"#;
2228        let event = parse_rho_event(line);
2229        assert!(matches!(
2230            event,
2231            Some(StreamEvent {
2232                kind: StreamEventKind::Complete { success: false },
2233                ..
2234            })
2235        ));
2236    }
2237
2238    #[test]
2239    fn test_parse_rho_error() {
2240        let line = r#"{"type":"error","message":"Rate limit exceeded"}"#;
2241        let event = parse_rho_event(line);
2242        match event {
2243            Some(StreamEvent {
2244                kind: StreamEventKind::Error { ref message },
2245                ..
2246            }) => {
2247                assert_eq!(message, "Rate limit exceeded");
2248            }
2249            _ => panic!("Expected Error event"),
2250        }
2251    }
2252
2253    #[test]
2254    fn test_parse_rho_unknown_type() {
2255        let line = r#"{"type":"unknown_event","data":"something"}"#;
2256        let event = parse_rho_event(line);
2257        assert!(event.is_none());
2258    }
2259
2260    #[test]
2261    fn test_parse_rho_invalid_json() {
2262        let event = parse_rho_event("not json at all");
2263        assert!(event.is_none());
2264    }
2265}