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