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