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;
15
16use super::events::{StreamEvent, StreamEventKind};
17use crate::commands::spawn::terminal::{find_harness_binary, Harness};
18
19/// Boxed async result type for dyn-compatible async trait methods
20pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
21
22/// Handle to a running headless session
23pub struct SessionHandle {
24    /// Task ID this session is for
25    pub task_id: String,
26    /// Harness session ID (for continuation)
27    pub session_id: Option<String>,
28    /// Child process
29    child: Child,
30    /// Event receiver
31    pub events: mpsc::Receiver<StreamEvent>,
32}
33
34impl SessionHandle {
35    /// Wait for the session to complete
36    pub async fn wait(mut self) -> Result<bool> {
37        let status = self.child.wait().await?;
38        Ok(status.success())
39    }
40    
41    /// Interrupt the session (send SIGINT)
42    pub fn interrupt(&mut self) -> Result<()> {
43        #[cfg(unix)]
44        {
45            if let Some(pid) = self.child.id() {
46                // Send SIGINT using kill command (avoids nix dependency)
47                let _ = std::process::Command::new("kill")
48                    .arg("-INT")
49                    .arg(pid.to_string())
50                    .status();
51            }
52        }
53
54        #[cfg(not(unix))]
55        {
56            // On non-Unix, just kill the process
57            let _ = self.child.start_kill();
58        }
59
60        Ok(())
61    }
62
63    /// Kill the session immediately
64    pub fn kill(&mut self) -> Result<()> {
65        self.child.start_kill()?;
66        Ok(())
67    }
68
69    /// Get the process ID
70    pub fn pid(&self) -> Option<u32> {
71        self.child.id()
72    }
73}
74
75/// Trait for headless agent execution
76///
77/// Implementations provide the ability to start agents in headless mode
78/// with streaming JSON output, and to generate commands for interactive
79/// session continuation.
80///
81/// This trait uses boxed futures to be dyn-compatible, allowing runtime
82/// polymorphism via `Box<dyn HeadlessRunner>`.
83pub trait HeadlessRunner: Send + Sync {
84    /// Start an agent with a prompt
85    ///
86    /// Returns a SessionHandle that can be used to receive events,
87    /// wait for completion, or interrupt the session.
88    fn start<'a>(
89        &'a self,
90        task_id: &'a str,
91        prompt: &'a str,
92        working_dir: &'a Path,
93        model: Option<&'a str>,
94    ) -> BoxFuture<'a, Result<SessionHandle>>;
95
96    /// Get the command to launch interactive mode for session continuation
97    ///
98    /// Returns the command and arguments needed to resume the session
99    /// interactively (e.g., `claude --resume <session_id>`).
100    fn interactive_command(&self, session_id: &str) -> Vec<String>;
101
102    /// Get the harness type this runner supports
103    fn harness(&self) -> Harness;
104}
105
106/// Claude Code headless runner
107///
108/// Runs Claude Code in headless mode with `--output-format stream-json`,
109/// parsing the streaming JSON events for display in TUI/GUI.
110pub struct ClaudeHeadless {
111    binary_path: String,
112    allowed_tools: Vec<String>,
113}
114
115impl ClaudeHeadless {
116    /// Create a new Claude headless runner
117    ///
118    /// Finds the Claude binary in PATH or common installation locations.
119    pub fn new() -> Result<Self> {
120        let binary_path = find_harness_binary(Harness::Claude)?.to_string();
121        Ok(Self {
122            binary_path,
123            allowed_tools: vec![
124                "Read".to_string(),
125                "Write".to_string(),
126                "Edit".to_string(),
127                "Bash".to_string(),
128                "Glob".to_string(),
129                "Grep".to_string(),
130            ],
131        })
132    }
133
134    /// Create with an explicit binary path (useful for testing)
135    #[cfg(test)]
136    pub fn with_binary_path(path: impl Into<String>) -> Self {
137        Self {
138            binary_path: path.into(),
139            allowed_tools: vec![],
140        }
141    }
142
143    /// Set the allowed tools for this runner
144    pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
145        self.allowed_tools = tools;
146        self
147    }
148
149    /// Get the binary path
150    pub fn binary_path(&self) -> &str {
151        &self.binary_path
152    }
153}
154
155impl HeadlessRunner for ClaudeHeadless {
156    fn start<'a>(
157        &'a self,
158        task_id: &'a str,
159        prompt: &'a str,
160        working_dir: &'a Path,
161        model: Option<&'a str>,
162    ) -> BoxFuture<'a, Result<SessionHandle>> {
163        Box::pin(async move {
164            let mut cmd = Command::new(&self.binary_path);
165
166            // Core headless flags
167            cmd.arg("-p").arg(prompt);
168            cmd.arg("--output-format").arg("stream-json");
169            cmd.arg("--verbose");
170            cmd.arg("--dangerously-skip-permissions");
171
172            // Model selection
173            if let Some(m) = model {
174                cmd.arg("--model").arg(m);
175            }
176
177            // Allowed tools
178            if !self.allowed_tools.is_empty() {
179                cmd.arg("--allowedTools")
180                    .arg(self.allowed_tools.join(","));
181            }
182
183            // Working directory and environment
184            cmd.current_dir(working_dir);
185            cmd.env("SCUD_TASK_ID", task_id);
186
187            // Capture stdout for streaming
188            cmd.stdout(Stdio::piped());
189            cmd.stderr(Stdio::piped());
190
191            let mut child = cmd.spawn()?;
192
193            // Create event channel
194            let (tx, rx) = mpsc::channel(1000);
195
196            // Spawn task to read stdout and parse events
197            let stdout = child.stdout.take().expect("stdout was piped");
198            let task_id_clone = task_id.to_string();
199
200            tokio::spawn(async move {
201                let reader = BufReader::new(stdout);
202                let mut lines = reader.lines();
203
204                while let Ok(Some(line)) = lines.next_line().await {
205                    if let Some(event) = parse_claude_event(&line) {
206                        if tx.send(event).await.is_err() {
207                            break;
208                        }
209                    }
210                }
211
212                // Send completion event
213                let _ = tx.send(StreamEvent::complete(true)).await;
214            });
215
216            Ok(SessionHandle {
217                task_id: task_id_clone,
218                session_id: None, // Will be set when we parse session_id from events
219                child,
220                events: rx,
221            })
222        })
223    }
224
225    fn interactive_command(&self, session_id: &str) -> Vec<String> {
226        vec![
227            self.binary_path.clone(),
228            "--resume".to_string(),
229            session_id.to_string(),
230        ]
231    }
232
233    fn harness(&self) -> Harness {
234        Harness::Claude
235    }
236}
237
238/// Parse a line of Claude stream-json output into a StreamEvent
239fn parse_claude_event(line: &str) -> Option<StreamEvent> {
240    let json: serde_json::Value = serde_json::from_str(line).ok()?;
241
242    let event_type = json.get("type")?.as_str()?;
243
244    match event_type {
245        "stream_event" => {
246            // Check for text delta
247            if let Some(delta) = json.pointer("/event/delta") {
248                if delta.get("type")?.as_str()? == "text_delta" {
249                    let text = delta.get("text")?.as_str()?;
250                    return Some(StreamEvent::text_delta(text));
251                }
252            }
253            None
254        }
255        "assistant" | "content_block_delta" => {
256            // Alternative text delta format
257            if let Some(text) = json.pointer("/delta/text").and_then(|v| v.as_str()) {
258                return Some(StreamEvent::text_delta(text));
259            }
260            if let Some(text) = json.pointer("/content/0/text").and_then(|v| v.as_str()) {
261                return Some(StreamEvent::text_delta(text));
262            }
263            None
264        }
265        "tool_use" => {
266            let tool_name = json.get("name")?.as_str()?;
267            let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
268            let input = json
269                .get("input")
270                .cloned()
271                .unwrap_or(serde_json::Value::Null);
272            let input_summary = summarize_json(&input);
273            Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
274        }
275        "tool_result" => {
276            let tool_id = json
277                .get("tool_use_id")
278                .and_then(|v| v.as_str())
279                .unwrap_or("unknown");
280            let success = !json
281                .get("is_error")
282                .and_then(|v| v.as_bool())
283                .unwrap_or(false);
284            Some(StreamEvent::new(StreamEventKind::ToolResult {
285                tool_name: String::new(), // Not always available
286                tool_id: tool_id.to_string(),
287                success,
288            }))
289        }
290        "result" => {
291            // Check for session_id
292            if let Some(session_id) = json.get("session_id").and_then(|v| v.as_str()) {
293                return Some(StreamEvent::new(StreamEventKind::SessionAssigned {
294                    session_id: session_id.to_string(),
295                }));
296            }
297            // Otherwise treat as completion
298            Some(StreamEvent::complete(true))
299        }
300        "error" => {
301            let message = json
302                .get("error")
303                .and_then(|e| e.as_str())
304                .or_else(|| json.get("message").and_then(|e| e.as_str()))
305                .unwrap_or("Unknown error");
306            Some(StreamEvent::error(message))
307        }
308        _ => None,
309    }
310}
311
312/// OpenCode headless runner
313///
314/// Runs OpenCode CLI in headless mode using `opencode run --format json`
315/// and parses the streaming JSON output into unified StreamEvent types.
316pub struct OpenCodeHeadless {
317    binary_path: String,
318}
319
320impl OpenCodeHeadless {
321    /// Create a new OpenCode headless runner
322    ///
323    /// Locates the OpenCode binary using the standard harness discovery mechanism.
324    pub fn new() -> Result<Self> {
325        let binary_path = find_harness_binary(Harness::OpenCode)?.to_string();
326        Ok(Self { binary_path })
327    }
328
329    /// Create with an explicit binary path (useful for testing)
330    #[cfg(test)]
331    pub fn with_binary_path(path: impl Into<String>) -> Self {
332        Self {
333            binary_path: path.into(),
334        }
335    }
336}
337
338impl HeadlessRunner for OpenCodeHeadless {
339    fn start<'a>(
340        &'a self,
341        task_id: &'a str,
342        prompt: &'a str,
343        working_dir: &'a Path,
344        model: Option<&'a str>,
345    ) -> BoxFuture<'a, Result<SessionHandle>> {
346        Box::pin(async move {
347            // OpenCode uses `run` command with JSON format for headless streaming
348            let mut cmd = Command::new(&self.binary_path);
349
350            cmd.arg("run");
351            cmd.arg("--format").arg("json");
352            cmd.arg("--variant").arg("minimal");
353
354            if let Some(m) = model {
355                cmd.arg("--model").arg(m);
356            }
357
358            cmd.arg(prompt);
359            cmd.current_dir(working_dir);
360            cmd.env("SCUD_TASK_ID", task_id);
361            cmd.stdout(Stdio::piped());
362            cmd.stderr(Stdio::piped());
363
364            let mut child = cmd.spawn()?;
365            let (tx, rx) = mpsc::channel(1000);
366
367            let stdout = child.stdout.take().expect("stdout was piped");
368
369            tokio::spawn(async move {
370                let reader = BufReader::new(stdout);
371                let mut lines = reader.lines();
372
373                while let Ok(Some(line)) = lines.next_line().await {
374                    if let Some(event) = parse_opencode_event(&line) {
375                        if tx.send(event).await.is_err() {
376                            break;
377                        }
378                    }
379                }
380
381                let _ = tx.send(StreamEvent::complete(true)).await;
382            });
383
384            Ok(SessionHandle {
385                task_id: task_id.to_string(),
386                session_id: None,
387                child,
388                events: rx,
389            })
390        })
391    }
392
393    fn interactive_command(&self, session_id: &str) -> Vec<String> {
394        // OpenCode uses attach command for session continuation
395        vec![
396            self.binary_path.clone(),
397            "attach".to_string(),
398            "http://localhost:4096".to_string(),
399            "--session".to_string(),
400            session_id.to_string(),
401        ]
402    }
403
404    fn harness(&self) -> Harness {
405        Harness::OpenCode
406    }
407}
408
409/// Enum-based runner that wraps concrete implementations
410///
411/// This provides polymorphism without requiring the trait to be object-safe.
412/// Use this instead of `Box<dyn HeadlessRunner>` when you need to store
413/// or pass around a runner of unknown concrete type.
414pub enum AnyRunner {
415    Claude(ClaudeHeadless),
416    OpenCode(OpenCodeHeadless),
417}
418
419impl AnyRunner {
420    /// Create a runner for the specified harness
421    pub fn new(harness: Harness) -> Result<Self> {
422        match harness {
423            Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
424            Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
425        }
426    }
427
428    /// Start an agent with a prompt
429    pub async fn start(
430        &self,
431        task_id: &str,
432        prompt: &str,
433        working_dir: &Path,
434        model: Option<&str>,
435    ) -> Result<SessionHandle> {
436        match self {
437            AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
438            AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
439        }
440    }
441
442    /// Get the command to launch interactive mode for session continuation
443    pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
444        match self {
445            AnyRunner::Claude(runner) => runner.interactive_command(session_id),
446            AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
447        }
448    }
449
450    /// Get the harness type this runner supports
451    pub fn harness(&self) -> Harness {
452        match self {
453            AnyRunner::Claude(runner) => runner.harness(),
454            AnyRunner::OpenCode(runner) => runner.harness(),
455        }
456    }
457}
458
459/// Create a headless runner for the specified harness
460///
461/// This is a convenience function that returns an `AnyRunner` enum
462/// which provides a unified interface for all runner implementations.
463pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
464    AnyRunner::new(harness)
465}
466
467/// Parse a line of OpenCode JSON output into a StreamEvent
468///
469/// OpenCode CLI (`opencode run --format json`) outputs newline-delimited JSON events
470/// with the following structure:
471///
472/// - `{"type": "assistant", "message": {"content": [{"text": "..."}]}}` - Text output
473/// - `{"type": "tool_call", "subtype": "started", "tool_call": {"name": "...", "input": {...}}}` - Tool start
474/// - `{"type": "tool_call", "subtype": "completed", "tool_call": {...}, "result": {...}}` - Tool result
475/// - `{"type": "result", "success": true}` - Completion
476/// - `{"type": "error", "message": "..."}` - Error
477/// - `{"type": "session", "session_id": "..."}` - Session assignment
478///
479/// Returns `None` for unparseable or unknown event types (graceful degradation).
480pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
481    let json: serde_json::Value = serde_json::from_str(line).ok()?;
482
483    let event_type = json.get("type")?.as_str()?;
484
485    match event_type {
486        // Assistant text output - may have various content structures
487        "assistant" | "message" | "content" => {
488            // Try multiple paths for text content
489            let text = json
490                .pointer("/message/content/0/text")
491                .or_else(|| json.pointer("/content/0/text"))
492                .or_else(|| json.pointer("/message/text"))
493                .or_else(|| json.get("text"))
494                .or_else(|| json.get("delta"))
495                .and_then(|v| v.as_str())?;
496            Some(StreamEvent::text_delta(text))
497        }
498
499        // Tool call events with subtype
500        "tool_call" | "tool_use" => {
501            let subtype = json
502                .get("subtype")
503                .or_else(|| json.get("status"))
504                .and_then(|v| v.as_str())
505                .unwrap_or("started");
506
507            match subtype {
508                "started" | "start" | "pending" => {
509                    // Extract tool name from various possible locations
510                    let tool_name = json
511                        .pointer("/tool_call/name")
512                        .or_else(|| json.pointer("/tool_call/tool"))
513                        .or_else(|| json.get("name"))
514                        .or_else(|| json.get("tool"))
515                        .and_then(|v| v.as_str())
516                        .unwrap_or("unknown");
517
518                    // Extract tool ID
519                    let tool_id = json
520                        .pointer("/tool_call/id")
521                        .or_else(|| json.get("id"))
522                        .or_else(|| json.get("tool_id"))
523                        .and_then(|v| v.as_str())
524                        .unwrap_or("");
525
526                    // Extract and summarize input
527                    let input = json
528                        .pointer("/tool_call/input")
529                        .or_else(|| json.get("input"))
530                        .cloned()
531                        .unwrap_or(serde_json::Value::Null);
532                    let input_summary = summarize_json(&input);
533
534                    Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
535                }
536                "completed" | "complete" | "done" | "success" => {
537                    let tool_name = json
538                        .pointer("/tool_call/name")
539                        .or_else(|| json.get("name"))
540                        .or_else(|| json.get("tool"))
541                        .and_then(|v| v.as_str())
542                        .unwrap_or("");
543
544                    let tool_id = json
545                        .pointer("/tool_call/id")
546                        .or_else(|| json.get("id"))
547                        .or_else(|| json.get("tool_id"))
548                        .and_then(|v| v.as_str())
549                        .unwrap_or("");
550
551                    // Check for error in result
552                    let success = !json
553                        .pointer("/result/is_error")
554                        .or_else(|| json.get("is_error"))
555                        .or_else(|| json.get("error"))
556                        .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
557                        .unwrap_or(false);
558
559                    Some(StreamEvent::new(StreamEventKind::ToolResult {
560                        tool_name: tool_name.to_string(),
561                        tool_id: tool_id.to_string(),
562                        success,
563                    }))
564                }
565                "failed" | "error" => {
566                    let tool_name = json
567                        .pointer("/tool_call/name")
568                        .or_else(|| json.get("name"))
569                        .and_then(|v| v.as_str())
570                        .unwrap_or("");
571
572                    let tool_id = json
573                        .pointer("/tool_call/id")
574                        .or_else(|| json.get("id"))
575                        .and_then(|v| v.as_str())
576                        .unwrap_or("");
577
578                    Some(StreamEvent::new(StreamEventKind::ToolResult {
579                        tool_name: tool_name.to_string(),
580                        tool_id: tool_id.to_string(),
581                        success: false,
582                    }))
583                }
584                _ => None,
585            }
586        }
587
588        // Completion event
589        "result" | "done" | "complete" => {
590            let success = json
591                .get("success")
592                .and_then(|v| v.as_bool())
593                .unwrap_or(true);
594            Some(StreamEvent::complete(success))
595        }
596
597        // Error event
598        "error" => {
599            let message = json
600                .get("message")
601                .or_else(|| json.get("error"))
602                .and_then(|v| v.as_str())
603                .unwrap_or("Unknown error");
604            Some(StreamEvent::error(message))
605        }
606
607        // Session assignment
608        "session" | "session_start" | "init" => {
609            let session_id = json
610                .get("session_id")
611                .or_else(|| json.get("id"))
612                .and_then(|v| v.as_str())?;
613            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
614                session_id: session_id.to_string(),
615            }))
616        }
617
618        // Unknown event type - return None for graceful handling
619        _ => None,
620    }
621}
622
623/// Summarize JSON input for compact display
624///
625/// Produces a short, human-readable summary of JSON values:
626/// - Objects: `{key1, key2, ...}` (max 3 keys)
627/// - Strings: First 50 chars with ellipsis
628/// - Other: JSON stringified, truncated
629fn summarize_json(value: &serde_json::Value) -> String {
630    match value {
631        serde_json::Value::Object(obj) => {
632            let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
633            if keys.is_empty() {
634                "{}".to_string()
635            } else if keys.len() < obj.len() {
636                format!("{{{},...}}", keys.join(", "))
637            } else {
638                format!("{{{}}}", keys.join(", "))
639            }
640        }
641        serde_json::Value::String(s) => {
642            if s.len() > 50 {
643                format!("\"{}...\"", &s[..47])
644            } else {
645                format!("\"{}\"", s)
646            }
647        }
648        serde_json::Value::Null => String::new(),
649        serde_json::Value::Array(arr) => {
650            format!("[{} items]", arr.len())
651        }
652        other => {
653            let s = other.to_string();
654            if s.len() > 50 {
655                format!("{}...", &s[..47])
656            } else {
657                s
658            }
659        }
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    // =======================
668    // Claude event parsing
669    // =======================
670
671    #[test]
672    fn test_parse_claude_text_delta() {
673        let line =
674            r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
675        let event = parse_claude_event(line);
676        assert!(matches!(
677            event,
678            Some(StreamEvent {
679                kind: StreamEventKind::TextDelta { ref text },
680                ..
681            }) if text == "Hello"
682        ));
683    }
684
685    #[test]
686    fn test_parse_claude_tool_use() {
687        let line =
688            r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
689        let event = parse_claude_event(line);
690        match event {
691            Some(StreamEvent {
692                kind: StreamEventKind::ToolStart {
693                    ref tool_name,
694                    ref tool_id,
695                    ref input_summary,
696                },
697                ..
698            }) => {
699                assert_eq!(tool_name, "Read");
700                assert_eq!(tool_id, "tool_1");
701                assert!(input_summary.contains("path"));
702            }
703            _ => panic!("Expected ToolStart"),
704        }
705    }
706
707    #[test]
708    fn test_parse_claude_error() {
709        let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
710        let event = parse_claude_event(line);
711        match event {
712            Some(StreamEvent {
713                kind: StreamEventKind::Error { ref message },
714                ..
715            }) => {
716                assert_eq!(message, "Rate limit exceeded");
717            }
718            _ => panic!("Expected Error event"),
719        }
720    }
721
722    #[test]
723    fn test_parse_claude_result_with_session() {
724        let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
725        let event = parse_claude_event(line);
726        match event {
727            Some(StreamEvent {
728                kind: StreamEventKind::SessionAssigned { ref session_id },
729                ..
730            }) => {
731                assert_eq!(session_id, "sess-abc123");
732            }
733            _ => panic!("Expected SessionAssigned"),
734        }
735    }
736
737    #[test]
738    fn test_parse_claude_result_completion() {
739        let line = r#"{"type":"result"}"#;
740        let event = parse_claude_event(line);
741        assert!(matches!(
742            event,
743            Some(StreamEvent {
744                kind: StreamEventKind::Complete { success: true },
745                ..
746            })
747        ));
748    }
749
750    #[test]
751    fn test_parse_claude_tool_result() {
752        let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
753        let event = parse_claude_event(line);
754        match event {
755            Some(StreamEvent {
756                kind: StreamEventKind::ToolResult {
757                    ref tool_id,
758                    success,
759                    ..
760                },
761                ..
762            }) => {
763                assert_eq!(tool_id, "tool_1");
764                assert!(success);
765            }
766            _ => panic!("Expected ToolResult"),
767        }
768    }
769
770    #[test]
771    fn test_parse_claude_tool_result_error() {
772        let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
773        let event = parse_claude_event(line);
774        match event {
775            Some(StreamEvent {
776                kind: StreamEventKind::ToolResult { success, .. },
777                ..
778            }) => {
779                assert!(!success);
780            }
781            _ => panic!("Expected ToolResult with failure"),
782        }
783    }
784
785    #[test]
786    fn test_parse_claude_unknown_type_returns_none() {
787        let line = r#"{"type":"unknown_event","data":"test"}"#;
788        let event = parse_claude_event(line);
789        assert!(event.is_none());
790    }
791
792    #[test]
793    fn test_claude_interactive_command() {
794        let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
795        let cmd = runner.interactive_command("sess_123");
796        assert_eq!(cmd[0], "/usr/local/bin/claude");
797        assert_eq!(cmd[1], "--resume");
798        assert_eq!(cmd[2], "sess_123");
799    }
800
801    // =======================
802    // OpenCode event parsing
803    // =======================
804
805    #[test]
806    fn test_parse_assistant_text_with_message_content() {
807        let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
808        let event = parse_opencode_event(line);
809        assert!(matches!(
810            event,
811            Some(StreamEvent {
812                kind: StreamEventKind::TextDelta { ref text },
813                ..
814            }) if text == "Hello world"
815        ));
816    }
817
818    #[test]
819    fn test_parse_content_type_with_text() {
820        let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
821        let event = parse_opencode_event(line);
822        assert!(matches!(
823            event,
824            Some(StreamEvent {
825                kind: StreamEventKind::TextDelta { ref text },
826                ..
827            }) if text == "Response text"
828        ));
829    }
830
831    #[test]
832    fn test_parse_message_type_with_direct_text() {
833        let line = r#"{"type": "message", "text": "Direct text"}"#;
834        let event = parse_opencode_event(line);
835        assert!(matches!(
836            event,
837            Some(StreamEvent {
838                kind: StreamEventKind::TextDelta { ref text },
839                ..
840            }) if text == "Direct text"
841        ));
842    }
843
844    #[test]
845    fn test_parse_assistant_with_delta_field() {
846        let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
847        let event = parse_opencode_event(line);
848        assert!(matches!(
849            event,
850            Some(StreamEvent {
851                kind: StreamEventKind::TextDelta { ref text },
852                ..
853            }) if text == "Streaming chunk"
854        ));
855    }
856
857    // ===================
858    // Tool call parsing
859    // ===================
860
861    #[test]
862    fn test_parse_tool_call_started() {
863        let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
864        let event = parse_opencode_event(line);
865        match event {
866            Some(StreamEvent {
867                kind:
868                    StreamEventKind::ToolStart {
869                        ref tool_name,
870                        ref tool_id,
871                        ref input_summary,
872                    },
873                ..
874            }) => {
875                assert_eq!(tool_name, "read_file");
876                assert_eq!(tool_id, "tool_1");
877                assert!(input_summary.contains("path"));
878            }
879            _ => panic!("Expected ToolStart, got {:?}", event),
880        }
881    }
882
883    #[test]
884    fn test_parse_tool_use_start() {
885        let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
886        let event = parse_opencode_event(line);
887        match event {
888            Some(StreamEvent {
889                kind:
890                    StreamEventKind::ToolStart {
891                        ref tool_name,
892                        ref tool_id,
893                        ..
894                    },
895                ..
896            }) => {
897                assert_eq!(tool_name, "bash");
898                assert_eq!(tool_id, "t123");
899            }
900            _ => panic!("Expected ToolStart"),
901        }
902    }
903
904    #[test]
905    fn test_parse_tool_call_completed() {
906        let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
907        let event = parse_opencode_event(line);
908        match event {
909            Some(StreamEvent {
910                kind:
911                    StreamEventKind::ToolResult {
912                        ref tool_name,
913                        ref tool_id,
914                        success,
915                    },
916                ..
917            }) => {
918                assert_eq!(tool_name, "write_file");
919                assert_eq!(tool_id, "t2");
920                assert!(success);
921            }
922            _ => panic!("Expected ToolResult"),
923        }
924    }
925
926    #[test]
927    fn test_parse_tool_call_with_error() {
928        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
929        let event = parse_opencode_event(line);
930        match event {
931            Some(StreamEvent {
932                kind:
933                    StreamEventKind::ToolResult {
934                        success, ..
935                    },
936                ..
937            }) => {
938                assert!(!success);
939            }
940            _ => panic!("Expected ToolResult with failure"),
941        }
942    }
943
944    #[test]
945    fn test_parse_tool_call_failed_subtype() {
946        let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
947        let event = parse_opencode_event(line);
948        match event {
949            Some(StreamEvent {
950                kind:
951                    StreamEventKind::ToolResult {
952                        success, ..
953                    },
954                ..
955            }) => {
956                assert!(!success);
957            }
958            _ => panic!("Expected failed ToolResult"),
959        }
960    }
961
962    // ===================
963    // Completion parsing
964    // ===================
965
966    #[test]
967    fn test_parse_result_success() {
968        let line = r#"{"type": "result", "success": true}"#;
969        let event = parse_opencode_event(line);
970        assert!(matches!(
971            event,
972            Some(StreamEvent {
973                kind: StreamEventKind::Complete { success: true },
974                ..
975            })
976        ));
977    }
978
979    #[test]
980    fn test_parse_result_failure() {
981        let line = r#"{"type": "result", "success": false}"#;
982        let event = parse_opencode_event(line);
983        assert!(matches!(
984            event,
985            Some(StreamEvent {
986                kind: StreamEventKind::Complete { success: false },
987                ..
988            })
989        ));
990    }
991
992    #[test]
993    fn test_parse_done_type() {
994        let line = r#"{"type": "done"}"#;
995        let event = parse_opencode_event(line);
996        assert!(matches!(
997            event,
998            Some(StreamEvent {
999                kind: StreamEventKind::Complete { success: true },
1000                ..
1001            })
1002        ));
1003    }
1004
1005    // ===================
1006    // Error parsing
1007    // ===================
1008
1009    #[test]
1010    fn test_parse_error_with_message() {
1011        let line = r#"{"type": "error", "message": "Connection failed"}"#;
1012        let event = parse_opencode_event(line);
1013        match event {
1014            Some(StreamEvent {
1015                kind: StreamEventKind::Error { ref message },
1016                ..
1017            }) => {
1018                assert_eq!(message, "Connection failed");
1019            }
1020            _ => panic!("Expected Error event"),
1021        }
1022    }
1023
1024    #[test]
1025    fn test_parse_error_with_error_field() {
1026        let line = r#"{"type": "error", "error": "Rate limited"}"#;
1027        let event = parse_opencode_event(line);
1028        match event {
1029            Some(StreamEvent {
1030                kind: StreamEventKind::Error { ref message },
1031                ..
1032            }) => {
1033                assert_eq!(message, "Rate limited");
1034            }
1035            _ => panic!("Expected Error event"),
1036        }
1037    }
1038
1039    // ===================
1040    // Session parsing
1041    // ===================
1042
1043    #[test]
1044    fn test_parse_session_assignment() {
1045        let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1046        let event = parse_opencode_event(line);
1047        match event {
1048            Some(StreamEvent {
1049                kind: StreamEventKind::SessionAssigned { ref session_id },
1050                ..
1051            }) => {
1052                assert_eq!(session_id, "sess_abc123");
1053            }
1054            _ => panic!("Expected SessionAssigned"),
1055        }
1056    }
1057
1058    #[test]
1059    fn test_parse_session_with_id_field() {
1060        let line = r#"{"type": "init", "id": "session_xyz"}"#;
1061        let event = parse_opencode_event(line);
1062        match event {
1063            Some(StreamEvent {
1064                kind: StreamEventKind::SessionAssigned { ref session_id },
1065                ..
1066            }) => {
1067                assert_eq!(session_id, "session_xyz");
1068            }
1069            _ => panic!("Expected SessionAssigned"),
1070        }
1071    }
1072
1073    // ===================
1074    // Edge cases
1075    // ===================
1076
1077    #[test]
1078    fn test_parse_unknown_event_returns_none() {
1079        let line = r#"{"type": "custom_event", "data": "something"}"#;
1080        let event = parse_opencode_event(line);
1081        assert!(event.is_none());
1082    }
1083
1084    #[test]
1085    fn test_parse_invalid_json_returns_none() {
1086        let line = "not json at all";
1087        let event = parse_opencode_event(line);
1088        assert!(event.is_none());
1089    }
1090
1091    #[test]
1092    fn test_parse_missing_type_returns_none() {
1093        let line = r#"{"message": "no type field"}"#;
1094        let event = parse_opencode_event(line);
1095        assert!(event.is_none());
1096    }
1097
1098    #[test]
1099    fn test_parse_empty_json_returns_none() {
1100        let line = "{}";
1101        let event = parse_opencode_event(line);
1102        assert!(event.is_none());
1103    }
1104
1105    // ===================
1106    // JSON summarization
1107    // ===================
1108
1109    #[test]
1110    fn test_summarize_json_object() {
1111        let value = serde_json::json!({"path": "/foo", "content": "bar"});
1112        let summary = summarize_json(&value);
1113        assert!(summary.contains("path"));
1114        assert!(summary.contains("content"));
1115    }
1116
1117    #[test]
1118    fn test_summarize_json_object_truncated() {
1119        let value = serde_json::json!({
1120            "key1": "v1",
1121            "key2": "v2",
1122            "key3": "v3",
1123            "key4": "v4"
1124        });
1125        let summary = summarize_json(&value);
1126        assert!(summary.contains("..."));
1127    }
1128
1129    #[test]
1130    fn test_summarize_json_empty_object() {
1131        let value = serde_json::json!({});
1132        let summary = summarize_json(&value);
1133        assert_eq!(summary, "{}");
1134    }
1135
1136    #[test]
1137    fn test_summarize_json_string() {
1138        let value = serde_json::json!("short string");
1139        let summary = summarize_json(&value);
1140        assert_eq!(summary, "\"short string\"");
1141    }
1142
1143    #[test]
1144    fn test_summarize_json_long_string() {
1145        let long = "a".repeat(100);
1146        let value = serde_json::json!(long);
1147        let summary = summarize_json(&value);
1148        assert!(summary.len() < 60);
1149        assert!(summary.ends_with("...\""));
1150    }
1151
1152    #[test]
1153    fn test_summarize_json_null() {
1154        let value = serde_json::Value::Null;
1155        let summary = summarize_json(&value);
1156        assert_eq!(summary, "");
1157    }
1158
1159    #[test]
1160    fn test_summarize_json_array() {
1161        let value = serde_json::json!([1, 2, 3, 4, 5]);
1162        let summary = summarize_json(&value);
1163        assert_eq!(summary, "[5 items]");
1164    }
1165
1166    #[test]
1167    fn test_summarize_json_number() {
1168        let value = serde_json::json!(42);
1169        let summary = summarize_json(&value);
1170        assert_eq!(summary, "42");
1171    }
1172
1173    // ===================
1174    // Interactive command
1175    // ===================
1176
1177    #[test]
1178    fn test_interactive_command_format() {
1179        let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1180        let cmd = runner.interactive_command("session_123");
1181        assert_eq!(cmd[0], "/usr/local/bin/opencode");
1182        assert_eq!(cmd[1], "attach");
1183        assert!(cmd.contains(&"--session".to_string()));
1184        assert!(cmd.contains(&"session_123".to_string()));
1185    }
1186
1187    // ===================
1188    // OpenCodeHeadless struct tests
1189    // ===================
1190
1191    #[test]
1192    fn test_opencode_headless_with_binary_path() {
1193        let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1194        // Verify harness returns OpenCode
1195        assert!(matches!(runner.harness(), Harness::OpenCode));
1196    }
1197
1198    #[test]
1199    fn test_opencode_interactive_command_structure() {
1200        let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1201        let cmd = runner.interactive_command("sess-xyz-789");
1202
1203        // Should produce: opencode attach http://localhost:4096 --session sess-xyz-789
1204        assert_eq!(cmd.len(), 5);
1205        assert_eq!(cmd[0], "/bin/opencode");
1206        assert_eq!(cmd[1], "attach");
1207        assert_eq!(cmd[2], "http://localhost:4096");
1208        assert_eq!(cmd[3], "--session");
1209        assert_eq!(cmd[4], "sess-xyz-789");
1210    }
1211
1212    #[test]
1213    fn test_opencode_harness_type() {
1214        let runner = OpenCodeHeadless::with_binary_path("opencode");
1215        assert_eq!(runner.harness(), Harness::OpenCode);
1216    }
1217
1218    // ===================
1219    // ClaudeHeadless struct tests
1220    // ===================
1221
1222    #[test]
1223    fn test_claude_headless_with_binary_path() {
1224        let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1225        assert_eq!(runner.binary_path(), "/custom/claude");
1226        assert!(matches!(runner.harness(), Harness::Claude));
1227    }
1228
1229    #[test]
1230    fn test_claude_headless_with_allowed_tools() {
1231        let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1232            .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1233        // The runner should accept the tools (no getter, but constructor works)
1234        assert_eq!(runner.binary_path(), "/bin/claude");
1235    }
1236
1237    #[test]
1238    fn test_claude_interactive_command_structure() {
1239        let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1240        let cmd = runner.interactive_command("sess-abc-123");
1241
1242        // Should produce: claude --resume sess-abc-123
1243        assert_eq!(cmd.len(), 3);
1244        assert_eq!(cmd[0], "/usr/bin/claude");
1245        assert_eq!(cmd[1], "--resume");
1246        assert_eq!(cmd[2], "sess-abc-123");
1247    }
1248
1249    #[test]
1250    fn test_claude_harness_type() {
1251        let runner = ClaudeHeadless::with_binary_path("claude");
1252        assert_eq!(runner.harness(), Harness::Claude);
1253    }
1254
1255    // ===================
1256    // AnyRunner enum tests
1257    // ===================
1258
1259    #[test]
1260    fn test_any_runner_claude_variant() {
1261        let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1262        assert_eq!(runner.harness(), Harness::Claude);
1263
1264        let cmd = runner.interactive_command("session-1");
1265        assert_eq!(cmd[0], "/bin/claude");
1266        assert_eq!(cmd[1], "--resume");
1267    }
1268
1269    #[test]
1270    fn test_any_runner_opencode_variant() {
1271        let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1272        assert_eq!(runner.harness(), Harness::OpenCode);
1273
1274        let cmd = runner.interactive_command("session-2");
1275        assert_eq!(cmd[0], "/bin/opencode");
1276        assert_eq!(cmd[1], "attach");
1277    }
1278
1279    #[test]
1280    fn test_any_runner_harness_matches() {
1281        let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1282        let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1283
1284        // Verify harness() returns correct type for each variant
1285        assert!(matches!(claude.harness(), Harness::Claude));
1286        assert!(matches!(opencode.harness(), Harness::OpenCode));
1287    }
1288
1289    // ===================
1290    // Additional OpenCode parsing edge cases
1291    // ===================
1292
1293    #[test]
1294    fn test_parse_opencode_tool_with_pending_status() {
1295        let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1296        let event = parse_opencode_event(line);
1297        match event {
1298            Some(StreamEvent {
1299                kind:
1300                    StreamEventKind::ToolStart {
1301                        ref tool_name,
1302                        ref tool_id,
1303                        ..
1304                    },
1305                ..
1306            }) => {
1307                assert_eq!(tool_name, "write_file");
1308                assert_eq!(tool_id, "t99");
1309            }
1310            _ => panic!("Expected ToolStart for pending status"),
1311        }
1312    }
1313
1314    #[test]
1315    fn test_parse_opencode_tool_done_status() {
1316        let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1317        let event = parse_opencode_event(line);
1318        match event {
1319            Some(StreamEvent {
1320                kind:
1321                    StreamEventKind::ToolResult {
1322                        ref tool_name,
1323                        success,
1324                        ..
1325                    },
1326                ..
1327            }) => {
1328                assert_eq!(tool_name, "exec");
1329                assert!(success);
1330            }
1331            _ => panic!("Expected ToolResult for done subtype"),
1332        }
1333    }
1334
1335    #[test]
1336    fn test_parse_opencode_tool_success_status() {
1337        let line =
1338            r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1339        let event = parse_opencode_event(line);
1340        match event {
1341            Some(StreamEvent {
1342                kind: StreamEventKind::ToolResult { success, .. },
1343                ..
1344            }) => {
1345                assert!(success);
1346            }
1347            _ => panic!("Expected ToolResult for success subtype"),
1348        }
1349    }
1350
1351    #[test]
1352    fn test_parse_opencode_complete_type() {
1353        let line = r#"{"type": "complete", "success": true}"#;
1354        let event = parse_opencode_event(line);
1355        assert!(matches!(
1356            event,
1357            Some(StreamEvent {
1358                kind: StreamEventKind::Complete { success: true },
1359                ..
1360            })
1361        ));
1362    }
1363
1364    #[test]
1365    fn test_parse_opencode_session_start_type() {
1366        let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1367        let event = parse_opencode_event(line);
1368        match event {
1369            Some(StreamEvent {
1370                kind: StreamEventKind::SessionAssigned { ref session_id },
1371                ..
1372            }) => {
1373                assert_eq!(session_id, "sess-start-001");
1374            }
1375            _ => panic!("Expected SessionAssigned for session_start type"),
1376        }
1377    }
1378
1379    #[test]
1380    fn test_parse_opencode_assistant_with_message_text() {
1381        let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1382        let event = parse_opencode_event(line);
1383        assert!(matches!(
1384            event,
1385            Some(StreamEvent {
1386                kind: StreamEventKind::TextDelta { ref text },
1387                ..
1388            }) if text == "Thinking about this..."
1389        ));
1390    }
1391
1392    #[test]
1393    fn test_parse_opencode_tool_call_error_subtype() {
1394        let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1395        let event = parse_opencode_event(line);
1396        match event {
1397            Some(StreamEvent {
1398                kind:
1399                    StreamEventKind::ToolResult {
1400                        ref tool_name,
1401                        success,
1402                        ..
1403                    },
1404                ..
1405            }) => {
1406                assert_eq!(tool_name, "git");
1407                assert!(!success);
1408            }
1409            _ => panic!("Expected failed ToolResult for error subtype"),
1410        }
1411    }
1412
1413    #[test]
1414    fn test_parse_opencode_tool_with_nested_input() {
1415        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"}}}"#;
1416        let event = parse_opencode_event(line);
1417        match event {
1418            Some(StreamEvent {
1419                kind:
1420                    StreamEventKind::ToolStart {
1421                        ref tool_name,
1422                        ref input_summary,
1423                        ..
1424                    },
1425                ..
1426            }) => {
1427                assert_eq!(tool_name, "write_file");
1428                // Input should be summarized with keys
1429                assert!(input_summary.contains("path"));
1430            }
1431            _ => panic!("Expected ToolStart with input summary"),
1432        }
1433    }
1434
1435    #[test]
1436    fn test_parse_opencode_tool_result_with_error_string() {
1437        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1438        let event = parse_opencode_event(line);
1439        match event {
1440            Some(StreamEvent {
1441                kind: StreamEventKind::ToolResult { success, .. },
1442                ..
1443            }) => {
1444                // error field as string should indicate failure
1445                assert!(!success);
1446            }
1447            _ => panic!("Expected failed ToolResult"),
1448        }
1449    }
1450
1451    #[test]
1452    fn test_parse_opencode_unknown_subtype_returns_none() {
1453        let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1454        let event = parse_opencode_event(line);
1455        assert!(event.is_none());
1456    }
1457}