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