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 `--prompt-file` and parses stdout (text output)
723/// and stderr (tool events in `[tool:name]` format) into StreamEvents.
724pub struct RhoHeadless {
725    binary_path: String,
726}
727
728impl RhoHeadless {
729    /// Create a new Rho headless runner
730    pub fn new() -> Result<Self> {
731        let binary_path = find_harness_binary(Harness::Rho)?.to_string();
732        Ok(Self { binary_path })
733    }
734
735    #[cfg(test)]
736    pub fn with_binary_path(path: impl Into<String>) -> Self {
737        Self {
738            binary_path: path.into(),
739        }
740    }
741
742    #[cfg(test)]
743    pub fn binary_path(&self) -> &str {
744        &self.binary_path
745    }
746}
747
748impl HeadlessRunner for RhoHeadless {
749    fn start<'a>(
750        &'a self,
751        task_id: &'a str,
752        prompt: &'a str,
753        working_dir: &'a Path,
754        model: Option<&'a str>,
755    ) -> BoxFuture<'a, Result<SessionHandle>> {
756        Box::pin(async move {
757            let mut cmd = Command::new(&self.binary_path);
758
759            if let Some(m) = model {
760                cmd.arg("--model").arg(m);
761            }
762
763            cmd.arg(prompt);
764            cmd.current_dir(working_dir);
765            cmd.env("SCUD_TASK_ID", task_id);
766            cmd.stdout(Stdio::piped());
767            cmd.stderr(Stdio::piped());
768
769            let mut child = cmd.spawn()?;
770            let (tx, rx) = mpsc::channel(1000);
771
772            let stdout = child.stdout.take().expect("stdout was piped");
773            let stderr = child.stderr.take().expect("stderr was piped");
774            let task_id_stdout = task_id.to_string();
775            let task_id_stderr = task_id.to_string();
776
777            // Parse stdout as text output
778            let tx_stdout = tx.clone();
779            tokio::spawn(async move {
780                let reader = BufReader::new(stdout);
781                let mut lines = reader.lines();
782
783                while let Ok(Some(line)) = lines.next_line().await {
784                    let trimmed = line.trim();
785                    if trimmed == "rho-cli placeholder - CLI structure ready"
786                        || trimmed.contains("rho-cli-stub is a legacy placeholder")
787                    {
788                        let _ = tx_stdout
789                            .send(StreamEvent::error(
790                                "Detected placeholder rho-cli binary. Install/use the functional rho-agent CLI.".to_string(),
791                            ))
792                            .await;
793                        return;
794                    }
795                    if !line.is_empty() {
796                        let _ = tx_stdout
797                            .send(StreamEvent::text_delta(format!("{}\n", line)))
798                            .await;
799                    }
800                }
801                trace!(task_id = %task_id_stdout, "rho stdout stream ended");
802            });
803
804            // Parse stderr for tool events: [tool:name] args / [tool:name] done / [tool:name] ERROR: ...
805            let tx_stderr = tx;
806            tokio::spawn(async move {
807                let reader = BufReader::new(stderr);
808                let mut lines = reader.lines();
809                let mut tool_counter: u64 = 0;
810                let mut session_announced = false;
811
812                while let Ok(Some(line)) = lines.next_line().await {
813                    let trimmed = line.trim();
814
815                    if let Some(rest) = trimmed.strip_prefix("[session:") {
816                        if !session_announced {
817                            if let Some(end_idx) = rest.find(']') {
818                                let session_id = rest[..end_idx].trim();
819                                if !session_id.is_empty() {
820                                    let _ = tx_stderr
821                                        .send(StreamEvent::new(StreamEventKind::SessionAssigned {
822                                            session_id: session_id.to_string(),
823                                        }))
824                                        .await;
825                                    session_announced = true;
826                                }
827                            }
828                        }
829                    } else if let Some(rest) = trimmed.strip_prefix("[tool:") {
830                        if let Some(bracket_end) = rest.find(']') {
831                            let tool_name = &rest[..bracket_end];
832                            let after = rest[bracket_end + 1..].trim();
833
834                            if after == "done" {
835                                let _ = tx_stderr
836                                    .send(StreamEvent::new(StreamEventKind::ToolResult {
837                                        tool_name: tool_name.to_string(),
838                                        tool_id: format!("rho-tool-{}", tool_counter),
839                                        success: true,
840                                    }))
841                                    .await;
842                            } else if let Some(err_msg) = after.strip_prefix("ERROR:") {
843                                let _ = tx_stderr
844                                    .send(StreamEvent::new(StreamEventKind::ToolResult {
845                                        tool_name: tool_name.to_string(),
846                                        tool_id: format!("rho-tool-{}", tool_counter),
847                                        success: false,
848                                    }))
849                                    .await;
850                                debug!(task_id = %task_id_stderr, "rho tool error: {}: {}", tool_name, err_msg.trim());
851                            } else {
852                                // Tool start with args
853                                tool_counter += 1;
854                                let _ = tx_stderr
855                                    .send(StreamEvent::tool_start(
856                                        tool_name,
857                                        &format!("rho-tool-{}", tool_counter),
858                                        after,
859                                    ))
860                                    .await;
861                            }
862                        }
863                    } else if let Some(rest) = trimmed.strip_prefix("[compact]") {
864                        debug!(task_id = %task_id_stderr, "rho compaction: {}", rest.trim());
865                    } else if !trimmed.is_empty() {
866                        trace!(task_id = %task_id_stderr, "rho stderr: {}", trimmed);
867                    }
868                }
869            });
870
871            Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
872        })
873    }
874
875    fn interactive_command(&self, session_id: &str) -> Vec<String> {
876        vec![
877            self.binary_path.clone(),
878            "--resume".to_string(),
879            session_id.to_string(),
880        ]
881    }
882
883    fn harness(&self) -> Harness {
884        Harness::Rho
885    }
886}
887
888/// Enum-based runner that wraps concrete implementations
889///
890/// This provides polymorphism without requiring the trait to be object-safe.
891/// Use this instead of `Box<dyn HeadlessRunner>` when you need to store
892/// or pass around a runner of unknown concrete type.
893pub enum AnyRunner {
894    Claude(ClaudeHeadless),
895    OpenCode(OpenCodeHeadless),
896    Cursor(CursorHeadless),
897    Rho(RhoHeadless),
898    #[cfg(feature = "direct-api")]
899    DirectApi(super::direct_api::DirectApiRunner),
900}
901
902impl AnyRunner {
903    /// Create a DirectApi runner with an explicit provider
904    #[cfg(feature = "direct-api")]
905    pub fn new_direct_api(provider: crate::llm::provider::AgentProvider) -> Self {
906        AnyRunner::DirectApi(super::direct_api::DirectApiRunner::new().with_provider(provider))
907    }
908
909    /// Create a runner for the specified harness
910    pub fn new(harness: Harness) -> Result<Self> {
911        match harness {
912            Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
913            Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
914            Harness::Cursor => Ok(AnyRunner::Cursor(CursorHeadless::new()?)),
915            Harness::Rho => Ok(AnyRunner::Rho(RhoHeadless::new()?)),
916            #[cfg(feature = "direct-api")]
917            Harness::DirectApi => Ok(AnyRunner::DirectApi(
918                super::direct_api::DirectApiRunner::new(),
919            )),
920        }
921    }
922
923    /// Start an agent with a prompt
924    pub async fn start(
925        &self,
926        task_id: &str,
927        prompt: &str,
928        working_dir: &Path,
929        model: Option<&str>,
930    ) -> Result<SessionHandle> {
931        match self {
932            AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
933            AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
934            AnyRunner::Cursor(runner) => runner.start(task_id, prompt, working_dir, model).await,
935            AnyRunner::Rho(runner) => runner.start(task_id, prompt, working_dir, model).await,
936            #[cfg(feature = "direct-api")]
937            AnyRunner::DirectApi(runner) => runner.start(task_id, prompt, working_dir, model).await,
938        }
939    }
940
941    /// Get the command to launch interactive mode for session continuation
942    pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
943        match self {
944            AnyRunner::Claude(runner) => runner.interactive_command(session_id),
945            AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
946            AnyRunner::Cursor(runner) => runner.interactive_command(session_id),
947            AnyRunner::Rho(runner) => runner.interactive_command(session_id),
948            #[cfg(feature = "direct-api")]
949            AnyRunner::DirectApi(runner) => runner.interactive_command(session_id),
950        }
951    }
952
953    /// Get the harness type this runner supports
954    pub fn harness(&self) -> Harness {
955        match self {
956            AnyRunner::Claude(runner) => runner.harness(),
957            AnyRunner::OpenCode(runner) => runner.harness(),
958            AnyRunner::Cursor(runner) => runner.harness(),
959            AnyRunner::Rho(runner) => runner.harness(),
960            #[cfg(feature = "direct-api")]
961            AnyRunner::DirectApi(runner) => runner.harness(),
962        }
963    }
964}
965
966/// Create a headless runner for the specified harness
967///
968/// This is a convenience function that returns an `AnyRunner` enum
969/// which provides a unified interface for all runner implementations.
970pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
971    AnyRunner::new(harness)
972}
973
974/// Parse a line of OpenCode JSON output into a StreamEvent
975///
976/// OpenCode CLI (`opencode run --format json`) outputs newline-delimited JSON events
977/// with the following structure:
978///
979/// - `{"type": "assistant", "message": {"content": [{"text": "..."}]}}` - Text output
980/// - `{"type": "tool_call", "subtype": "started", "tool_call": {"name": "...", "input": {...}}}` - Tool start
981/// - `{"type": "tool_call", "subtype": "completed", "tool_call": {...}, "result": {...}}` - Tool result
982/// - `{"type": "result", "success": true}` - Completion
983/// - `{"type": "error", "message": "..."}` - Error
984/// - `{"type": "session", "session_id": "..."}` - Session assignment
985///
986/// Returns `None` for unparseable or unknown event types (graceful degradation).
987pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
988    let json: serde_json::Value = serde_json::from_str(line).ok()?;
989
990    let event_type = json.get("type")?.as_str()?;
991
992    match event_type {
993        // Assistant text output - may have various content structures
994        "assistant" | "message" | "content" => {
995            // Try multiple paths for text content
996            let text = json
997                .pointer("/message/content/0/text")
998                .or_else(|| json.pointer("/content/0/text"))
999                .or_else(|| json.pointer("/message/text"))
1000                .or_else(|| json.get("text"))
1001                .or_else(|| json.get("delta"))
1002                .and_then(|v| v.as_str())?;
1003            Some(StreamEvent::text_delta(text))
1004        }
1005
1006        // Tool call events with subtype
1007        "tool_call" | "tool_use" => {
1008            let subtype = json
1009                .get("subtype")
1010                .or_else(|| json.get("status"))
1011                .and_then(|v| v.as_str())
1012                .unwrap_or("started");
1013
1014            match subtype {
1015                "started" | "start" | "pending" => {
1016                    // Extract tool name from various possible locations
1017                    let tool_name = json
1018                        .pointer("/tool_call/name")
1019                        .or_else(|| json.pointer("/tool_call/tool"))
1020                        .or_else(|| json.get("name"))
1021                        .or_else(|| json.get("tool"))
1022                        .and_then(|v| v.as_str())
1023                        .unwrap_or("unknown");
1024
1025                    // Extract tool ID
1026                    let tool_id = json
1027                        .pointer("/tool_call/id")
1028                        .or_else(|| json.get("id"))
1029                        .or_else(|| json.get("tool_id"))
1030                        .and_then(|v| v.as_str())
1031                        .unwrap_or("");
1032
1033                    // Extract and summarize input
1034                    let input = json
1035                        .pointer("/tool_call/input")
1036                        .or_else(|| json.get("input"))
1037                        .cloned()
1038                        .unwrap_or(serde_json::Value::Null);
1039                    let input_summary = summarize_json(&input);
1040
1041                    Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
1042                }
1043                "completed" | "complete" | "done" | "success" => {
1044                    let tool_name = json
1045                        .pointer("/tool_call/name")
1046                        .or_else(|| json.get("name"))
1047                        .or_else(|| json.get("tool"))
1048                        .and_then(|v| v.as_str())
1049                        .unwrap_or("");
1050
1051                    let tool_id = json
1052                        .pointer("/tool_call/id")
1053                        .or_else(|| json.get("id"))
1054                        .or_else(|| json.get("tool_id"))
1055                        .and_then(|v| v.as_str())
1056                        .unwrap_or("");
1057
1058                    // Check for error in result
1059                    let success = !json
1060                        .pointer("/result/is_error")
1061                        .or_else(|| json.get("is_error"))
1062                        .or_else(|| json.get("error"))
1063                        .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
1064                        .unwrap_or(false);
1065
1066                    Some(StreamEvent::new(StreamEventKind::ToolResult {
1067                        tool_name: tool_name.to_string(),
1068                        tool_id: tool_id.to_string(),
1069                        success,
1070                    }))
1071                }
1072                "failed" | "error" => {
1073                    let tool_name = json
1074                        .pointer("/tool_call/name")
1075                        .or_else(|| json.get("name"))
1076                        .and_then(|v| v.as_str())
1077                        .unwrap_or("");
1078
1079                    let tool_id = json
1080                        .pointer("/tool_call/id")
1081                        .or_else(|| json.get("id"))
1082                        .and_then(|v| v.as_str())
1083                        .unwrap_or("");
1084
1085                    Some(StreamEvent::new(StreamEventKind::ToolResult {
1086                        tool_name: tool_name.to_string(),
1087                        tool_id: tool_id.to_string(),
1088                        success: false,
1089                    }))
1090                }
1091                _ => None,
1092            }
1093        }
1094
1095        // Completion event
1096        "result" | "done" | "complete" => {
1097            let success = json
1098                .get("success")
1099                .and_then(|v| v.as_bool())
1100                .unwrap_or(true);
1101            Some(StreamEvent::complete(success))
1102        }
1103
1104        // Error event
1105        "error" => {
1106            let message = json
1107                .get("message")
1108                .or_else(|| json.get("error"))
1109                .and_then(|v| v.as_str())
1110                .unwrap_or("Unknown error");
1111            Some(StreamEvent::error(message))
1112        }
1113
1114        // Session assignment
1115        "session" | "session_start" | "init" => {
1116            let session_id = json
1117                .get("session_id")
1118                .or_else(|| json.get("id"))
1119                .and_then(|v| v.as_str())?;
1120            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
1121                session_id: session_id.to_string(),
1122            }))
1123        }
1124
1125        // Unknown event type - return None for graceful handling
1126        _ => None,
1127    }
1128}
1129
1130/// Summarize JSON input for compact display
1131///
1132/// Produces a short, human-readable summary of JSON values:
1133/// - Objects: `{key1, key2, ...}` (max 3 keys)
1134/// - Strings: First 50 chars with ellipsis
1135/// - Other: JSON stringified, truncated
1136fn summarize_json(value: &serde_json::Value) -> String {
1137    match value {
1138        serde_json::Value::Object(obj) => {
1139            let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
1140            if keys.is_empty() {
1141                "{}".to_string()
1142            } else if keys.len() < obj.len() {
1143                format!("{{{},...}}", keys.join(", "))
1144            } else {
1145                format!("{{{}}}", keys.join(", "))
1146            }
1147        }
1148        serde_json::Value::String(s) => {
1149            if s.len() > 50 {
1150                format!("\"{}...\"", &s[..47])
1151            } else {
1152                format!("\"{}\"", s)
1153            }
1154        }
1155        serde_json::Value::Null => String::new(),
1156        serde_json::Value::Array(arr) => {
1157            format!("[{} items]", arr.len())
1158        }
1159        other => {
1160            let s = other.to_string();
1161            if s.len() > 50 {
1162                format!("{}...", &s[..47])
1163            } else {
1164                s
1165            }
1166        }
1167    }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173
1174    // =======================
1175    // Claude event parsing
1176    // =======================
1177
1178    #[test]
1179    fn test_parse_claude_text_delta() {
1180        let line =
1181            r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
1182        let event = parse_claude_event(line);
1183        assert!(matches!(
1184            event,
1185            Some(StreamEvent {
1186                kind: StreamEventKind::TextDelta { ref text },
1187                ..
1188            }) if text == "Hello"
1189        ));
1190    }
1191
1192    #[test]
1193    fn test_parse_claude_tool_use() {
1194        let line =
1195            r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
1196        let event = parse_claude_event(line);
1197        match event {
1198            Some(StreamEvent {
1199                kind:
1200                    StreamEventKind::ToolStart {
1201                        ref tool_name,
1202                        ref tool_id,
1203                        ref input_summary,
1204                    },
1205                ..
1206            }) => {
1207                assert_eq!(tool_name, "Read");
1208                assert_eq!(tool_id, "tool_1");
1209                assert!(input_summary.contains("path"));
1210            }
1211            _ => panic!("Expected ToolStart"),
1212        }
1213    }
1214
1215    #[test]
1216    fn test_parse_claude_error() {
1217        let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
1218        let event = parse_claude_event(line);
1219        match event {
1220            Some(StreamEvent {
1221                kind: StreamEventKind::Error { ref message },
1222                ..
1223            }) => {
1224                assert_eq!(message, "Rate limit exceeded");
1225            }
1226            _ => panic!("Expected Error event"),
1227        }
1228    }
1229
1230    #[test]
1231    fn test_parse_claude_system_init_session() {
1232        let line = r#"{"type":"system","subtype":"init","session_id":"sess-init-123"}"#;
1233        let event = parse_claude_event(line);
1234        match event {
1235            Some(StreamEvent {
1236                kind: StreamEventKind::SessionAssigned { ref session_id },
1237                ..
1238            }) => {
1239                assert_eq!(session_id, "sess-init-123");
1240            }
1241            _ => panic!("Expected SessionAssigned from system init event"),
1242        }
1243    }
1244
1245    #[test]
1246    fn test_parse_claude_result_with_session() {
1247        let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
1248        let event = parse_claude_event(line);
1249        match event {
1250            Some(StreamEvent {
1251                kind: StreamEventKind::SessionAssigned { ref session_id },
1252                ..
1253            }) => {
1254                assert_eq!(session_id, "sess-abc123");
1255            }
1256            _ => panic!("Expected SessionAssigned"),
1257        }
1258    }
1259
1260    #[test]
1261    fn test_parse_claude_result_completion() {
1262        let line = r#"{"type":"result"}"#;
1263        let event = parse_claude_event(line);
1264        assert!(matches!(
1265            event,
1266            Some(StreamEvent {
1267                kind: StreamEventKind::Complete { success: true },
1268                ..
1269            })
1270        ));
1271    }
1272
1273    #[test]
1274    fn test_parse_claude_tool_result() {
1275        let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
1276        let event = parse_claude_event(line);
1277        match event {
1278            Some(StreamEvent {
1279                kind:
1280                    StreamEventKind::ToolResult {
1281                        ref tool_id,
1282                        success,
1283                        ..
1284                    },
1285                ..
1286            }) => {
1287                assert_eq!(tool_id, "tool_1");
1288                assert!(success);
1289            }
1290            _ => panic!("Expected ToolResult"),
1291        }
1292    }
1293
1294    #[test]
1295    fn test_parse_claude_tool_result_error() {
1296        let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
1297        let event = parse_claude_event(line);
1298        match event {
1299            Some(StreamEvent {
1300                kind: StreamEventKind::ToolResult { success, .. },
1301                ..
1302            }) => {
1303                assert!(!success);
1304            }
1305            _ => panic!("Expected ToolResult with failure"),
1306        }
1307    }
1308
1309    #[test]
1310    fn test_parse_claude_unknown_type_returns_none() {
1311        let line = r#"{"type":"unknown_event","data":"test"}"#;
1312        let event = parse_claude_event(line);
1313        assert!(event.is_none());
1314    }
1315
1316    #[test]
1317    fn test_claude_interactive_command() {
1318        let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
1319        let cmd = runner.interactive_command("sess_123");
1320        assert_eq!(cmd[0], "/usr/local/bin/claude");
1321        assert_eq!(cmd[1], "--resume");
1322        assert_eq!(cmd[2], "sess_123");
1323    }
1324
1325    // =======================
1326    // OpenCode event parsing
1327    // =======================
1328
1329    #[test]
1330    fn test_parse_assistant_text_with_message_content() {
1331        let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
1332        let event = parse_opencode_event(line);
1333        assert!(matches!(
1334            event,
1335            Some(StreamEvent {
1336                kind: StreamEventKind::TextDelta { ref text },
1337                ..
1338            }) if text == "Hello world"
1339        ));
1340    }
1341
1342    #[test]
1343    fn test_parse_content_type_with_text() {
1344        let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
1345        let event = parse_opencode_event(line);
1346        assert!(matches!(
1347            event,
1348            Some(StreamEvent {
1349                kind: StreamEventKind::TextDelta { ref text },
1350                ..
1351            }) if text == "Response text"
1352        ));
1353    }
1354
1355    #[test]
1356    fn test_parse_message_type_with_direct_text() {
1357        let line = r#"{"type": "message", "text": "Direct text"}"#;
1358        let event = parse_opencode_event(line);
1359        assert!(matches!(
1360            event,
1361            Some(StreamEvent {
1362                kind: StreamEventKind::TextDelta { ref text },
1363                ..
1364            }) if text == "Direct text"
1365        ));
1366    }
1367
1368    #[test]
1369    fn test_parse_assistant_with_delta_field() {
1370        let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
1371        let event = parse_opencode_event(line);
1372        assert!(matches!(
1373            event,
1374            Some(StreamEvent {
1375                kind: StreamEventKind::TextDelta { ref text },
1376                ..
1377            }) if text == "Streaming chunk"
1378        ));
1379    }
1380
1381    // ===================
1382    // Tool call parsing
1383    // ===================
1384
1385    #[test]
1386    fn test_parse_tool_call_started() {
1387        let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
1388        let event = parse_opencode_event(line);
1389        match event {
1390            Some(StreamEvent {
1391                kind:
1392                    StreamEventKind::ToolStart {
1393                        ref tool_name,
1394                        ref tool_id,
1395                        ref input_summary,
1396                    },
1397                ..
1398            }) => {
1399                assert_eq!(tool_name, "read_file");
1400                assert_eq!(tool_id, "tool_1");
1401                assert!(input_summary.contains("path"));
1402            }
1403            _ => panic!("Expected ToolStart, got {:?}", event),
1404        }
1405    }
1406
1407    #[test]
1408    fn test_parse_tool_use_start() {
1409        let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
1410        let event = parse_opencode_event(line);
1411        match event {
1412            Some(StreamEvent {
1413                kind:
1414                    StreamEventKind::ToolStart {
1415                        ref tool_name,
1416                        ref tool_id,
1417                        ..
1418                    },
1419                ..
1420            }) => {
1421                assert_eq!(tool_name, "bash");
1422                assert_eq!(tool_id, "t123");
1423            }
1424            _ => panic!("Expected ToolStart"),
1425        }
1426    }
1427
1428    #[test]
1429    fn test_parse_tool_call_completed() {
1430        let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
1431        let event = parse_opencode_event(line);
1432        match event {
1433            Some(StreamEvent {
1434                kind:
1435                    StreamEventKind::ToolResult {
1436                        ref tool_name,
1437                        ref tool_id,
1438                        success,
1439                    },
1440                ..
1441            }) => {
1442                assert_eq!(tool_name, "write_file");
1443                assert_eq!(tool_id, "t2");
1444                assert!(success);
1445            }
1446            _ => panic!("Expected ToolResult"),
1447        }
1448    }
1449
1450    #[test]
1451    fn test_parse_tool_call_with_error() {
1452        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
1453        let event = parse_opencode_event(line);
1454        match event {
1455            Some(StreamEvent {
1456                kind: StreamEventKind::ToolResult { success, .. },
1457                ..
1458            }) => {
1459                assert!(!success);
1460            }
1461            _ => panic!("Expected ToolResult with failure"),
1462        }
1463    }
1464
1465    #[test]
1466    fn test_parse_tool_call_failed_subtype() {
1467        let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
1468        let event = parse_opencode_event(line);
1469        match event {
1470            Some(StreamEvent {
1471                kind: StreamEventKind::ToolResult { success, .. },
1472                ..
1473            }) => {
1474                assert!(!success);
1475            }
1476            _ => panic!("Expected failed ToolResult"),
1477        }
1478    }
1479
1480    // ===================
1481    // Completion parsing
1482    // ===================
1483
1484    #[test]
1485    fn test_parse_result_success() {
1486        let line = r#"{"type": "result", "success": true}"#;
1487        let event = parse_opencode_event(line);
1488        assert!(matches!(
1489            event,
1490            Some(StreamEvent {
1491                kind: StreamEventKind::Complete { success: true },
1492                ..
1493            })
1494        ));
1495    }
1496
1497    #[test]
1498    fn test_parse_result_failure() {
1499        let line = r#"{"type": "result", "success": false}"#;
1500        let event = parse_opencode_event(line);
1501        assert!(matches!(
1502            event,
1503            Some(StreamEvent {
1504                kind: StreamEventKind::Complete { success: false },
1505                ..
1506            })
1507        ));
1508    }
1509
1510    #[test]
1511    fn test_parse_done_type() {
1512        let line = r#"{"type": "done"}"#;
1513        let event = parse_opencode_event(line);
1514        assert!(matches!(
1515            event,
1516            Some(StreamEvent {
1517                kind: StreamEventKind::Complete { success: true },
1518                ..
1519            })
1520        ));
1521    }
1522
1523    // ===================
1524    // Error parsing
1525    // ===================
1526
1527    #[test]
1528    fn test_parse_error_with_message() {
1529        let line = r#"{"type": "error", "message": "Connection failed"}"#;
1530        let event = parse_opencode_event(line);
1531        match event {
1532            Some(StreamEvent {
1533                kind: StreamEventKind::Error { ref message },
1534                ..
1535            }) => {
1536                assert_eq!(message, "Connection failed");
1537            }
1538            _ => panic!("Expected Error event"),
1539        }
1540    }
1541
1542    #[test]
1543    fn test_parse_error_with_error_field() {
1544        let line = r#"{"type": "error", "error": "Rate limited"}"#;
1545        let event = parse_opencode_event(line);
1546        match event {
1547            Some(StreamEvent {
1548                kind: StreamEventKind::Error { ref message },
1549                ..
1550            }) => {
1551                assert_eq!(message, "Rate limited");
1552            }
1553            _ => panic!("Expected Error event"),
1554        }
1555    }
1556
1557    // ===================
1558    // Session parsing
1559    // ===================
1560
1561    #[test]
1562    fn test_parse_session_assignment() {
1563        let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1564        let event = parse_opencode_event(line);
1565        match event {
1566            Some(StreamEvent {
1567                kind: StreamEventKind::SessionAssigned { ref session_id },
1568                ..
1569            }) => {
1570                assert_eq!(session_id, "sess_abc123");
1571            }
1572            _ => panic!("Expected SessionAssigned"),
1573        }
1574    }
1575
1576    #[test]
1577    fn test_parse_session_with_id_field() {
1578        let line = r#"{"type": "init", "id": "session_xyz"}"#;
1579        let event = parse_opencode_event(line);
1580        match event {
1581            Some(StreamEvent {
1582                kind: StreamEventKind::SessionAssigned { ref session_id },
1583                ..
1584            }) => {
1585                assert_eq!(session_id, "session_xyz");
1586            }
1587            _ => panic!("Expected SessionAssigned"),
1588        }
1589    }
1590
1591    // ===================
1592    // Edge cases
1593    // ===================
1594
1595    #[test]
1596    fn test_parse_unknown_event_returns_none() {
1597        let line = r#"{"type": "custom_event", "data": "something"}"#;
1598        let event = parse_opencode_event(line);
1599        assert!(event.is_none());
1600    }
1601
1602    #[test]
1603    fn test_parse_invalid_json_returns_none() {
1604        let line = "not json at all";
1605        let event = parse_opencode_event(line);
1606        assert!(event.is_none());
1607    }
1608
1609    #[test]
1610    fn test_parse_missing_type_returns_none() {
1611        let line = r#"{"message": "no type field"}"#;
1612        let event = parse_opencode_event(line);
1613        assert!(event.is_none());
1614    }
1615
1616    #[test]
1617    fn test_parse_empty_json_returns_none() {
1618        let line = "{}";
1619        let event = parse_opencode_event(line);
1620        assert!(event.is_none());
1621    }
1622
1623    // ===================
1624    // JSON summarization
1625    // ===================
1626
1627    #[test]
1628    fn test_summarize_json_object() {
1629        let value = serde_json::json!({"path": "/foo", "content": "bar"});
1630        let summary = summarize_json(&value);
1631        assert!(summary.contains("path"));
1632        assert!(summary.contains("content"));
1633    }
1634
1635    #[test]
1636    fn test_summarize_json_object_truncated() {
1637        let value = serde_json::json!({
1638            "key1": "v1",
1639            "key2": "v2",
1640            "key3": "v3",
1641            "key4": "v4"
1642        });
1643        let summary = summarize_json(&value);
1644        assert!(summary.contains("..."));
1645    }
1646
1647    #[test]
1648    fn test_summarize_json_empty_object() {
1649        let value = serde_json::json!({});
1650        let summary = summarize_json(&value);
1651        assert_eq!(summary, "{}");
1652    }
1653
1654    #[test]
1655    fn test_summarize_json_string() {
1656        let value = serde_json::json!("short string");
1657        let summary = summarize_json(&value);
1658        assert_eq!(summary, "\"short string\"");
1659    }
1660
1661    #[test]
1662    fn test_summarize_json_long_string() {
1663        let long = "a".repeat(100);
1664        let value = serde_json::json!(long);
1665        let summary = summarize_json(&value);
1666        assert!(summary.len() < 60);
1667        assert!(summary.ends_with("...\""));
1668    }
1669
1670    #[test]
1671    fn test_summarize_json_null() {
1672        let value = serde_json::Value::Null;
1673        let summary = summarize_json(&value);
1674        assert_eq!(summary, "");
1675    }
1676
1677    #[test]
1678    fn test_summarize_json_array() {
1679        let value = serde_json::json!([1, 2, 3, 4, 5]);
1680        let summary = summarize_json(&value);
1681        assert_eq!(summary, "[5 items]");
1682    }
1683
1684    #[test]
1685    fn test_summarize_json_number() {
1686        let value = serde_json::json!(42);
1687        let summary = summarize_json(&value);
1688        assert_eq!(summary, "42");
1689    }
1690
1691    // ===================
1692    // Interactive command
1693    // ===================
1694
1695    #[test]
1696    fn test_interactive_command_format() {
1697        let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1698        let cmd = runner.interactive_command("session_123");
1699        assert_eq!(cmd[0], "/usr/local/bin/opencode");
1700        assert_eq!(cmd[1], "attach");
1701        assert!(cmd.contains(&"--session".to_string()));
1702        assert!(cmd.contains(&"session_123".to_string()));
1703    }
1704
1705    // ===================
1706    // OpenCodeHeadless struct tests
1707    // ===================
1708
1709    #[test]
1710    fn test_opencode_headless_with_binary_path() {
1711        let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1712        // Verify harness returns OpenCode
1713        assert!(matches!(runner.harness(), Harness::OpenCode));
1714    }
1715
1716    #[test]
1717    fn test_opencode_interactive_command_structure() {
1718        let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1719        let cmd = runner.interactive_command("sess-xyz-789");
1720
1721        // Should produce: opencode attach http://localhost:4096 --session sess-xyz-789
1722        assert_eq!(cmd.len(), 5);
1723        assert_eq!(cmd[0], "/bin/opencode");
1724        assert_eq!(cmd[1], "attach");
1725        assert_eq!(cmd[2], "http://localhost:4096");
1726        assert_eq!(cmd[3], "--session");
1727        assert_eq!(cmd[4], "sess-xyz-789");
1728    }
1729
1730    #[test]
1731    fn test_opencode_harness_type() {
1732        let runner = OpenCodeHeadless::with_binary_path("opencode");
1733        assert_eq!(runner.harness(), Harness::OpenCode);
1734    }
1735
1736    // ===================
1737    // ClaudeHeadless struct tests
1738    // ===================
1739
1740    #[test]
1741    fn test_claude_headless_with_binary_path() {
1742        let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1743        assert_eq!(runner.binary_path(), "/custom/claude");
1744        assert!(matches!(runner.harness(), Harness::Claude));
1745    }
1746
1747    #[test]
1748    fn test_claude_headless_with_allowed_tools() {
1749        let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1750            .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1751        // The runner should accept the tools (no getter, but constructor works)
1752        assert_eq!(runner.binary_path(), "/bin/claude");
1753    }
1754
1755    #[test]
1756    fn test_claude_interactive_command_structure() {
1757        let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1758        let cmd = runner.interactive_command("sess-abc-123");
1759
1760        // Should produce: claude --resume sess-abc-123
1761        assert_eq!(cmd.len(), 3);
1762        assert_eq!(cmd[0], "/usr/bin/claude");
1763        assert_eq!(cmd[1], "--resume");
1764        assert_eq!(cmd[2], "sess-abc-123");
1765    }
1766
1767    #[test]
1768    fn test_claude_harness_type() {
1769        let runner = ClaudeHeadless::with_binary_path("claude");
1770        assert_eq!(runner.harness(), Harness::Claude);
1771    }
1772
1773    // ===================
1774    // AnyRunner enum tests
1775    // ===================
1776
1777    #[test]
1778    fn test_any_runner_claude_variant() {
1779        let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1780        assert_eq!(runner.harness(), Harness::Claude);
1781
1782        let cmd = runner.interactive_command("session-1");
1783        assert_eq!(cmd[0], "/bin/claude");
1784        assert_eq!(cmd[1], "--resume");
1785    }
1786
1787    #[test]
1788    fn test_any_runner_opencode_variant() {
1789        let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1790        assert_eq!(runner.harness(), Harness::OpenCode);
1791
1792        let cmd = runner.interactive_command("session-2");
1793        assert_eq!(cmd[0], "/bin/opencode");
1794        assert_eq!(cmd[1], "attach");
1795    }
1796
1797    #[test]
1798    fn test_any_runner_rho_variant_resume_command() {
1799        let runner = AnyRunner::Rho(RhoHeadless::with_binary_path("/bin/rho-cli"));
1800        assert_eq!(runner.harness(), Harness::Rho);
1801
1802        let cmd = runner.interactive_command("session-rho-1");
1803        assert_eq!(cmd.len(), 3);
1804        assert_eq!(cmd[0], "/bin/rho-cli");
1805        assert_eq!(cmd[1], "--resume");
1806        assert_eq!(cmd[2], "session-rho-1");
1807    }
1808
1809    #[test]
1810    fn test_any_runner_harness_matches() {
1811        let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1812        let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1813
1814        // Verify harness() returns correct type for each variant
1815        assert!(matches!(claude.harness(), Harness::Claude));
1816        assert!(matches!(opencode.harness(), Harness::OpenCode));
1817    }
1818
1819    // ===================
1820    // Additional OpenCode parsing edge cases
1821    // ===================
1822
1823    #[test]
1824    fn test_parse_opencode_tool_with_pending_status() {
1825        let line =
1826            r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1827        let event = parse_opencode_event(line);
1828        match event {
1829            Some(StreamEvent {
1830                kind:
1831                    StreamEventKind::ToolStart {
1832                        ref tool_name,
1833                        ref tool_id,
1834                        ..
1835                    },
1836                ..
1837            }) => {
1838                assert_eq!(tool_name, "write_file");
1839                assert_eq!(tool_id, "t99");
1840            }
1841            _ => panic!("Expected ToolStart for pending status"),
1842        }
1843    }
1844
1845    #[test]
1846    fn test_parse_opencode_tool_done_status() {
1847        let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1848        let event = parse_opencode_event(line);
1849        match event {
1850            Some(StreamEvent {
1851                kind:
1852                    StreamEventKind::ToolResult {
1853                        ref tool_name,
1854                        success,
1855                        ..
1856                    },
1857                ..
1858            }) => {
1859                assert_eq!(tool_name, "exec");
1860                assert!(success);
1861            }
1862            _ => panic!("Expected ToolResult for done subtype"),
1863        }
1864    }
1865
1866    #[test]
1867    fn test_parse_opencode_tool_success_status() {
1868        let line = r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1869        let event = parse_opencode_event(line);
1870        match event {
1871            Some(StreamEvent {
1872                kind: StreamEventKind::ToolResult { success, .. },
1873                ..
1874            }) => {
1875                assert!(success);
1876            }
1877            _ => panic!("Expected ToolResult for success subtype"),
1878        }
1879    }
1880
1881    #[test]
1882    fn test_parse_opencode_complete_type() {
1883        let line = r#"{"type": "complete", "success": true}"#;
1884        let event = parse_opencode_event(line);
1885        assert!(matches!(
1886            event,
1887            Some(StreamEvent {
1888                kind: StreamEventKind::Complete { success: true },
1889                ..
1890            })
1891        ));
1892    }
1893
1894    #[test]
1895    fn test_parse_opencode_session_start_type() {
1896        let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1897        let event = parse_opencode_event(line);
1898        match event {
1899            Some(StreamEvent {
1900                kind: StreamEventKind::SessionAssigned { ref session_id },
1901                ..
1902            }) => {
1903                assert_eq!(session_id, "sess-start-001");
1904            }
1905            _ => panic!("Expected SessionAssigned for session_start type"),
1906        }
1907    }
1908
1909    #[test]
1910    fn test_parse_opencode_assistant_with_message_text() {
1911        let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1912        let event = parse_opencode_event(line);
1913        assert!(matches!(
1914            event,
1915            Some(StreamEvent {
1916                kind: StreamEventKind::TextDelta { ref text },
1917                ..
1918            }) if text == "Thinking about this..."
1919        ));
1920    }
1921
1922    #[test]
1923    fn test_parse_opencode_tool_call_error_subtype() {
1924        let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1925        let event = parse_opencode_event(line);
1926        match event {
1927            Some(StreamEvent {
1928                kind:
1929                    StreamEventKind::ToolResult {
1930                        ref tool_name,
1931                        success,
1932                        ..
1933                    },
1934                ..
1935            }) => {
1936                assert_eq!(tool_name, "git");
1937                assert!(!success);
1938            }
1939            _ => panic!("Expected failed ToolResult for error subtype"),
1940        }
1941    }
1942
1943    #[test]
1944    fn test_parse_opencode_tool_with_nested_input() {
1945        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"}}}"#;
1946        let event = parse_opencode_event(line);
1947        match event {
1948            Some(StreamEvent {
1949                kind:
1950                    StreamEventKind::ToolStart {
1951                        ref tool_name,
1952                        ref input_summary,
1953                        ..
1954                    },
1955                ..
1956            }) => {
1957                assert_eq!(tool_name, "write_file");
1958                // Input should be summarized with keys
1959                assert!(input_summary.contains("path"));
1960            }
1961            _ => panic!("Expected ToolStart with input summary"),
1962        }
1963    }
1964
1965    #[test]
1966    fn test_parse_opencode_tool_result_with_error_string() {
1967        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1968        let event = parse_opencode_event(line);
1969        match event {
1970            Some(StreamEvent {
1971                kind: StreamEventKind::ToolResult { success, .. },
1972                ..
1973            }) => {
1974                // error field as string should indicate failure
1975                assert!(!success);
1976            }
1977            _ => panic!("Expected failed ToolResult"),
1978        }
1979    }
1980
1981    #[test]
1982    fn test_parse_opencode_unknown_subtype_returns_none() {
1983        let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1984        let event = parse_opencode_event(line);
1985        assert!(event.is_none());
1986    }
1987
1988    // =======================
1989    // Cursor event parsing
1990    // =======================
1991
1992    #[test]
1993    fn test_parse_cursor_system_init() {
1994        let line = r#"{"type":"system","subtype":"init","session_id":"013608ef-dda7-4b38-9741-54fb0323ce1c","model":"Claude 4.5 Opus"}"#;
1995        let event = parse_cursor_event(line);
1996        match event {
1997            Some(StreamEvent {
1998                kind: StreamEventKind::SessionAssigned { ref session_id },
1999                ..
2000            }) => {
2001                assert_eq!(session_id, "013608ef-dda7-4b38-9741-54fb0323ce1c");
2002            }
2003            _ => panic!("Expected SessionAssigned from system init"),
2004        }
2005    }
2006
2007    #[test]
2008    fn test_parse_cursor_tool_call_started() {
2009        let line = r#"{"type":"tool_call","subtype":"started","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py","streamContent":"print(\"Hello\")\n"}}}}"#;
2010        let event = parse_cursor_event(line);
2011        match event {
2012            Some(StreamEvent {
2013                kind:
2014                    StreamEventKind::ToolStart {
2015                        ref tool_name,
2016                        ref tool_id,
2017                        ref input_summary,
2018                    },
2019                ..
2020            }) => {
2021                assert_eq!(tool_name, "Edit");
2022                assert_eq!(tool_id, "toolu_123");
2023                assert!(input_summary.contains("path"));
2024            }
2025            _ => panic!("Expected ToolStart, got {:?}", event),
2026        }
2027    }
2028
2029    #[test]
2030    fn test_parse_cursor_tool_call_completed() {
2031        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}}}}}"#;
2032        let event = parse_cursor_event(line);
2033        match event {
2034            Some(StreamEvent {
2035                kind:
2036                    StreamEventKind::ToolResult {
2037                        ref tool_name,
2038                        ref tool_id,
2039                        success,
2040                    },
2041                ..
2042            }) => {
2043                assert_eq!(tool_name, "Edit");
2044                assert_eq!(tool_id, "toolu_123");
2045                assert!(success);
2046            }
2047            _ => panic!("Expected ToolResult, got {:?}", event),
2048        }
2049    }
2050
2051    #[test]
2052    fn test_parse_cursor_assistant_message() {
2053        let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.py"}]}}"#;
2054        let event = parse_cursor_event(line);
2055        assert!(matches!(
2056            event,
2057            Some(StreamEvent {
2058                kind: StreamEventKind::TextDelta { ref text },
2059                ..
2060            }) if text == "Created hello.py"
2061        ));
2062    }
2063
2064    #[test]
2065    fn test_parse_cursor_result_success() {
2066        let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"Done","session_id":"sess-123"}"#;
2067        let event = parse_cursor_event(line);
2068        assert!(matches!(
2069            event,
2070            Some(StreamEvent {
2071                kind: StreamEventKind::Complete { success: true },
2072                ..
2073            })
2074        ));
2075    }
2076
2077    #[test]
2078    fn test_parse_cursor_result_error() {
2079        let line = r#"{"type":"result","subtype":"error","is_error":true,"result":"Failed"}"#;
2080        let event = parse_cursor_event(line);
2081        assert!(matches!(
2082            event,
2083            Some(StreamEvent {
2084                kind: StreamEventKind::Complete { success: false },
2085                ..
2086            })
2087        ));
2088    }
2089
2090    #[test]
2091    fn test_parse_cursor_user_message_ignored() {
2092        let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}}"#;
2093        let event = parse_cursor_event(line);
2094        assert!(event.is_none());
2095    }
2096
2097    #[test]
2098    fn test_parse_cursor_invalid_json() {
2099        let event = parse_cursor_event("not json");
2100        assert!(event.is_none());
2101    }
2102}