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            // Cursor headless uses the same CLI pattern as Claude (prompt-based)
426            // For now, fall back to Claude runner as the interface is similar
427            Harness::Cursor => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
428        }
429    }
430
431    /// Start an agent with a prompt
432    pub async fn start(
433        &self,
434        task_id: &str,
435        prompt: &str,
436        working_dir: &Path,
437        model: Option<&str>,
438    ) -> Result<SessionHandle> {
439        match self {
440            AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
441            AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
442        }
443    }
444
445    /// Get the command to launch interactive mode for session continuation
446    pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
447        match self {
448            AnyRunner::Claude(runner) => runner.interactive_command(session_id),
449            AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
450        }
451    }
452
453    /// Get the harness type this runner supports
454    pub fn harness(&self) -> Harness {
455        match self {
456            AnyRunner::Claude(runner) => runner.harness(),
457            AnyRunner::OpenCode(runner) => runner.harness(),
458        }
459    }
460}
461
462/// Create a headless runner for the specified harness
463///
464/// This is a convenience function that returns an `AnyRunner` enum
465/// which provides a unified interface for all runner implementations.
466pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
467    AnyRunner::new(harness)
468}
469
470/// Parse a line of OpenCode JSON output into a StreamEvent
471///
472/// OpenCode CLI (`opencode run --format json`) outputs newline-delimited JSON events
473/// with the following structure:
474///
475/// - `{"type": "assistant", "message": {"content": [{"text": "..."}]}}` - Text output
476/// - `{"type": "tool_call", "subtype": "started", "tool_call": {"name": "...", "input": {...}}}` - Tool start
477/// - `{"type": "tool_call", "subtype": "completed", "tool_call": {...}, "result": {...}}` - Tool result
478/// - `{"type": "result", "success": true}` - Completion
479/// - `{"type": "error", "message": "..."}` - Error
480/// - `{"type": "session", "session_id": "..."}` - Session assignment
481///
482/// Returns `None` for unparseable or unknown event types (graceful degradation).
483pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
484    let json: serde_json::Value = serde_json::from_str(line).ok()?;
485
486    let event_type = json.get("type")?.as_str()?;
487
488    match event_type {
489        // Assistant text output - may have various content structures
490        "assistant" | "message" | "content" => {
491            // Try multiple paths for text content
492            let text = json
493                .pointer("/message/content/0/text")
494                .or_else(|| json.pointer("/content/0/text"))
495                .or_else(|| json.pointer("/message/text"))
496                .or_else(|| json.get("text"))
497                .or_else(|| json.get("delta"))
498                .and_then(|v| v.as_str())?;
499            Some(StreamEvent::text_delta(text))
500        }
501
502        // Tool call events with subtype
503        "tool_call" | "tool_use" => {
504            let subtype = json
505                .get("subtype")
506                .or_else(|| json.get("status"))
507                .and_then(|v| v.as_str())
508                .unwrap_or("started");
509
510            match subtype {
511                "started" | "start" | "pending" => {
512                    // Extract tool name from various possible locations
513                    let tool_name = json
514                        .pointer("/tool_call/name")
515                        .or_else(|| json.pointer("/tool_call/tool"))
516                        .or_else(|| json.get("name"))
517                        .or_else(|| json.get("tool"))
518                        .and_then(|v| v.as_str())
519                        .unwrap_or("unknown");
520
521                    // Extract tool ID
522                    let tool_id = json
523                        .pointer("/tool_call/id")
524                        .or_else(|| json.get("id"))
525                        .or_else(|| json.get("tool_id"))
526                        .and_then(|v| v.as_str())
527                        .unwrap_or("");
528
529                    // Extract and summarize input
530                    let input = json
531                        .pointer("/tool_call/input")
532                        .or_else(|| json.get("input"))
533                        .cloned()
534                        .unwrap_or(serde_json::Value::Null);
535                    let input_summary = summarize_json(&input);
536
537                    Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
538                }
539                "completed" | "complete" | "done" | "success" => {
540                    let tool_name = json
541                        .pointer("/tool_call/name")
542                        .or_else(|| json.get("name"))
543                        .or_else(|| json.get("tool"))
544                        .and_then(|v| v.as_str())
545                        .unwrap_or("");
546
547                    let tool_id = json
548                        .pointer("/tool_call/id")
549                        .or_else(|| json.get("id"))
550                        .or_else(|| json.get("tool_id"))
551                        .and_then(|v| v.as_str())
552                        .unwrap_or("");
553
554                    // Check for error in result
555                    let success = !json
556                        .pointer("/result/is_error")
557                        .or_else(|| json.get("is_error"))
558                        .or_else(|| json.get("error"))
559                        .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
560                        .unwrap_or(false);
561
562                    Some(StreamEvent::new(StreamEventKind::ToolResult {
563                        tool_name: tool_name.to_string(),
564                        tool_id: tool_id.to_string(),
565                        success,
566                    }))
567                }
568                "failed" | "error" => {
569                    let tool_name = json
570                        .pointer("/tool_call/name")
571                        .or_else(|| json.get("name"))
572                        .and_then(|v| v.as_str())
573                        .unwrap_or("");
574
575                    let tool_id = json
576                        .pointer("/tool_call/id")
577                        .or_else(|| json.get("id"))
578                        .and_then(|v| v.as_str())
579                        .unwrap_or("");
580
581                    Some(StreamEvent::new(StreamEventKind::ToolResult {
582                        tool_name: tool_name.to_string(),
583                        tool_id: tool_id.to_string(),
584                        success: false,
585                    }))
586                }
587                _ => None,
588            }
589        }
590
591        // Completion event
592        "result" | "done" | "complete" => {
593            let success = json
594                .get("success")
595                .and_then(|v| v.as_bool())
596                .unwrap_or(true);
597            Some(StreamEvent::complete(success))
598        }
599
600        // Error event
601        "error" => {
602            let message = json
603                .get("message")
604                .or_else(|| json.get("error"))
605                .and_then(|v| v.as_str())
606                .unwrap_or("Unknown error");
607            Some(StreamEvent::error(message))
608        }
609
610        // Session assignment
611        "session" | "session_start" | "init" => {
612            let session_id = json
613                .get("session_id")
614                .or_else(|| json.get("id"))
615                .and_then(|v| v.as_str())?;
616            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
617                session_id: session_id.to_string(),
618            }))
619        }
620
621        // Unknown event type - return None for graceful handling
622        _ => None,
623    }
624}
625
626/// Summarize JSON input for compact display
627///
628/// Produces a short, human-readable summary of JSON values:
629/// - Objects: `{key1, key2, ...}` (max 3 keys)
630/// - Strings: First 50 chars with ellipsis
631/// - Other: JSON stringified, truncated
632fn summarize_json(value: &serde_json::Value) -> String {
633    match value {
634        serde_json::Value::Object(obj) => {
635            let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
636            if keys.is_empty() {
637                "{}".to_string()
638            } else if keys.len() < obj.len() {
639                format!("{{{},...}}", keys.join(", "))
640            } else {
641                format!("{{{}}}", keys.join(", "))
642            }
643        }
644        serde_json::Value::String(s) => {
645            if s.len() > 50 {
646                format!("\"{}...\"", &s[..47])
647            } else {
648                format!("\"{}\"", s)
649            }
650        }
651        serde_json::Value::Null => String::new(),
652        serde_json::Value::Array(arr) => {
653            format!("[{} items]", arr.len())
654        }
655        other => {
656            let s = other.to_string();
657            if s.len() > 50 {
658                format!("{}...", &s[..47])
659            } else {
660                s
661            }
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    // =======================
671    // Claude event parsing
672    // =======================
673
674    #[test]
675    fn test_parse_claude_text_delta() {
676        let line =
677            r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
678        let event = parse_claude_event(line);
679        assert!(matches!(
680            event,
681            Some(StreamEvent {
682                kind: StreamEventKind::TextDelta { ref text },
683                ..
684            }) if text == "Hello"
685        ));
686    }
687
688    #[test]
689    fn test_parse_claude_tool_use() {
690        let line =
691            r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
692        let event = parse_claude_event(line);
693        match event {
694            Some(StreamEvent {
695                kind: StreamEventKind::ToolStart {
696                    ref tool_name,
697                    ref tool_id,
698                    ref input_summary,
699                },
700                ..
701            }) => {
702                assert_eq!(tool_name, "Read");
703                assert_eq!(tool_id, "tool_1");
704                assert!(input_summary.contains("path"));
705            }
706            _ => panic!("Expected ToolStart"),
707        }
708    }
709
710    #[test]
711    fn test_parse_claude_error() {
712        let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
713        let event = parse_claude_event(line);
714        match event {
715            Some(StreamEvent {
716                kind: StreamEventKind::Error { ref message },
717                ..
718            }) => {
719                assert_eq!(message, "Rate limit exceeded");
720            }
721            _ => panic!("Expected Error event"),
722        }
723    }
724
725    #[test]
726    fn test_parse_claude_result_with_session() {
727        let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
728        let event = parse_claude_event(line);
729        match event {
730            Some(StreamEvent {
731                kind: StreamEventKind::SessionAssigned { ref session_id },
732                ..
733            }) => {
734                assert_eq!(session_id, "sess-abc123");
735            }
736            _ => panic!("Expected SessionAssigned"),
737        }
738    }
739
740    #[test]
741    fn test_parse_claude_result_completion() {
742        let line = r#"{"type":"result"}"#;
743        let event = parse_claude_event(line);
744        assert!(matches!(
745            event,
746            Some(StreamEvent {
747                kind: StreamEventKind::Complete { success: true },
748                ..
749            })
750        ));
751    }
752
753    #[test]
754    fn test_parse_claude_tool_result() {
755        let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
756        let event = parse_claude_event(line);
757        match event {
758            Some(StreamEvent {
759                kind: StreamEventKind::ToolResult {
760                    ref tool_id,
761                    success,
762                    ..
763                },
764                ..
765            }) => {
766                assert_eq!(tool_id, "tool_1");
767                assert!(success);
768            }
769            _ => panic!("Expected ToolResult"),
770        }
771    }
772
773    #[test]
774    fn test_parse_claude_tool_result_error() {
775        let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
776        let event = parse_claude_event(line);
777        match event {
778            Some(StreamEvent {
779                kind: StreamEventKind::ToolResult { success, .. },
780                ..
781            }) => {
782                assert!(!success);
783            }
784            _ => panic!("Expected ToolResult with failure"),
785        }
786    }
787
788    #[test]
789    fn test_parse_claude_unknown_type_returns_none() {
790        let line = r#"{"type":"unknown_event","data":"test"}"#;
791        let event = parse_claude_event(line);
792        assert!(event.is_none());
793    }
794
795    #[test]
796    fn test_claude_interactive_command() {
797        let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
798        let cmd = runner.interactive_command("sess_123");
799        assert_eq!(cmd[0], "/usr/local/bin/claude");
800        assert_eq!(cmd[1], "--resume");
801        assert_eq!(cmd[2], "sess_123");
802    }
803
804    // =======================
805    // OpenCode event parsing
806    // =======================
807
808    #[test]
809    fn test_parse_assistant_text_with_message_content() {
810        let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
811        let event = parse_opencode_event(line);
812        assert!(matches!(
813            event,
814            Some(StreamEvent {
815                kind: StreamEventKind::TextDelta { ref text },
816                ..
817            }) if text == "Hello world"
818        ));
819    }
820
821    #[test]
822    fn test_parse_content_type_with_text() {
823        let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
824        let event = parse_opencode_event(line);
825        assert!(matches!(
826            event,
827            Some(StreamEvent {
828                kind: StreamEventKind::TextDelta { ref text },
829                ..
830            }) if text == "Response text"
831        ));
832    }
833
834    #[test]
835    fn test_parse_message_type_with_direct_text() {
836        let line = r#"{"type": "message", "text": "Direct text"}"#;
837        let event = parse_opencode_event(line);
838        assert!(matches!(
839            event,
840            Some(StreamEvent {
841                kind: StreamEventKind::TextDelta { ref text },
842                ..
843            }) if text == "Direct text"
844        ));
845    }
846
847    #[test]
848    fn test_parse_assistant_with_delta_field() {
849        let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
850        let event = parse_opencode_event(line);
851        assert!(matches!(
852            event,
853            Some(StreamEvent {
854                kind: StreamEventKind::TextDelta { ref text },
855                ..
856            }) if text == "Streaming chunk"
857        ));
858    }
859
860    // ===================
861    // Tool call parsing
862    // ===================
863
864    #[test]
865    fn test_parse_tool_call_started() {
866        let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
867        let event = parse_opencode_event(line);
868        match event {
869            Some(StreamEvent {
870                kind:
871                    StreamEventKind::ToolStart {
872                        ref tool_name,
873                        ref tool_id,
874                        ref input_summary,
875                    },
876                ..
877            }) => {
878                assert_eq!(tool_name, "read_file");
879                assert_eq!(tool_id, "tool_1");
880                assert!(input_summary.contains("path"));
881            }
882            _ => panic!("Expected ToolStart, got {:?}", event),
883        }
884    }
885
886    #[test]
887    fn test_parse_tool_use_start() {
888        let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
889        let event = parse_opencode_event(line);
890        match event {
891            Some(StreamEvent {
892                kind:
893                    StreamEventKind::ToolStart {
894                        ref tool_name,
895                        ref tool_id,
896                        ..
897                    },
898                ..
899            }) => {
900                assert_eq!(tool_name, "bash");
901                assert_eq!(tool_id, "t123");
902            }
903            _ => panic!("Expected ToolStart"),
904        }
905    }
906
907    #[test]
908    fn test_parse_tool_call_completed() {
909        let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
910        let event = parse_opencode_event(line);
911        match event {
912            Some(StreamEvent {
913                kind:
914                    StreamEventKind::ToolResult {
915                        ref tool_name,
916                        ref tool_id,
917                        success,
918                    },
919                ..
920            }) => {
921                assert_eq!(tool_name, "write_file");
922                assert_eq!(tool_id, "t2");
923                assert!(success);
924            }
925            _ => panic!("Expected ToolResult"),
926        }
927    }
928
929    #[test]
930    fn test_parse_tool_call_with_error() {
931        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
932        let event = parse_opencode_event(line);
933        match event {
934            Some(StreamEvent {
935                kind:
936                    StreamEventKind::ToolResult {
937                        success, ..
938                    },
939                ..
940            }) => {
941                assert!(!success);
942            }
943            _ => panic!("Expected ToolResult with failure"),
944        }
945    }
946
947    #[test]
948    fn test_parse_tool_call_failed_subtype() {
949        let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
950        let event = parse_opencode_event(line);
951        match event {
952            Some(StreamEvent {
953                kind:
954                    StreamEventKind::ToolResult {
955                        success, ..
956                    },
957                ..
958            }) => {
959                assert!(!success);
960            }
961            _ => panic!("Expected failed ToolResult"),
962        }
963    }
964
965    // ===================
966    // Completion parsing
967    // ===================
968
969    #[test]
970    fn test_parse_result_success() {
971        let line = r#"{"type": "result", "success": true}"#;
972        let event = parse_opencode_event(line);
973        assert!(matches!(
974            event,
975            Some(StreamEvent {
976                kind: StreamEventKind::Complete { success: true },
977                ..
978            })
979        ));
980    }
981
982    #[test]
983    fn test_parse_result_failure() {
984        let line = r#"{"type": "result", "success": false}"#;
985        let event = parse_opencode_event(line);
986        assert!(matches!(
987            event,
988            Some(StreamEvent {
989                kind: StreamEventKind::Complete { success: false },
990                ..
991            })
992        ));
993    }
994
995    #[test]
996    fn test_parse_done_type() {
997        let line = r#"{"type": "done"}"#;
998        let event = parse_opencode_event(line);
999        assert!(matches!(
1000            event,
1001            Some(StreamEvent {
1002                kind: StreamEventKind::Complete { success: true },
1003                ..
1004            })
1005        ));
1006    }
1007
1008    // ===================
1009    // Error parsing
1010    // ===================
1011
1012    #[test]
1013    fn test_parse_error_with_message() {
1014        let line = r#"{"type": "error", "message": "Connection failed"}"#;
1015        let event = parse_opencode_event(line);
1016        match event {
1017            Some(StreamEvent {
1018                kind: StreamEventKind::Error { ref message },
1019                ..
1020            }) => {
1021                assert_eq!(message, "Connection failed");
1022            }
1023            _ => panic!("Expected Error event"),
1024        }
1025    }
1026
1027    #[test]
1028    fn test_parse_error_with_error_field() {
1029        let line = r#"{"type": "error", "error": "Rate limited"}"#;
1030        let event = parse_opencode_event(line);
1031        match event {
1032            Some(StreamEvent {
1033                kind: StreamEventKind::Error { ref message },
1034                ..
1035            }) => {
1036                assert_eq!(message, "Rate limited");
1037            }
1038            _ => panic!("Expected Error event"),
1039        }
1040    }
1041
1042    // ===================
1043    // Session parsing
1044    // ===================
1045
1046    #[test]
1047    fn test_parse_session_assignment() {
1048        let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1049        let event = parse_opencode_event(line);
1050        match event {
1051            Some(StreamEvent {
1052                kind: StreamEventKind::SessionAssigned { ref session_id },
1053                ..
1054            }) => {
1055                assert_eq!(session_id, "sess_abc123");
1056            }
1057            _ => panic!("Expected SessionAssigned"),
1058        }
1059    }
1060
1061    #[test]
1062    fn test_parse_session_with_id_field() {
1063        let line = r#"{"type": "init", "id": "session_xyz"}"#;
1064        let event = parse_opencode_event(line);
1065        match event {
1066            Some(StreamEvent {
1067                kind: StreamEventKind::SessionAssigned { ref session_id },
1068                ..
1069            }) => {
1070                assert_eq!(session_id, "session_xyz");
1071            }
1072            _ => panic!("Expected SessionAssigned"),
1073        }
1074    }
1075
1076    // ===================
1077    // Edge cases
1078    // ===================
1079
1080    #[test]
1081    fn test_parse_unknown_event_returns_none() {
1082        let line = r#"{"type": "custom_event", "data": "something"}"#;
1083        let event = parse_opencode_event(line);
1084        assert!(event.is_none());
1085    }
1086
1087    #[test]
1088    fn test_parse_invalid_json_returns_none() {
1089        let line = "not json at all";
1090        let event = parse_opencode_event(line);
1091        assert!(event.is_none());
1092    }
1093
1094    #[test]
1095    fn test_parse_missing_type_returns_none() {
1096        let line = r#"{"message": "no type field"}"#;
1097        let event = parse_opencode_event(line);
1098        assert!(event.is_none());
1099    }
1100
1101    #[test]
1102    fn test_parse_empty_json_returns_none() {
1103        let line = "{}";
1104        let event = parse_opencode_event(line);
1105        assert!(event.is_none());
1106    }
1107
1108    // ===================
1109    // JSON summarization
1110    // ===================
1111
1112    #[test]
1113    fn test_summarize_json_object() {
1114        let value = serde_json::json!({"path": "/foo", "content": "bar"});
1115        let summary = summarize_json(&value);
1116        assert!(summary.contains("path"));
1117        assert!(summary.contains("content"));
1118    }
1119
1120    #[test]
1121    fn test_summarize_json_object_truncated() {
1122        let value = serde_json::json!({
1123            "key1": "v1",
1124            "key2": "v2",
1125            "key3": "v3",
1126            "key4": "v4"
1127        });
1128        let summary = summarize_json(&value);
1129        assert!(summary.contains("..."));
1130    }
1131
1132    #[test]
1133    fn test_summarize_json_empty_object() {
1134        let value = serde_json::json!({});
1135        let summary = summarize_json(&value);
1136        assert_eq!(summary, "{}");
1137    }
1138
1139    #[test]
1140    fn test_summarize_json_string() {
1141        let value = serde_json::json!("short string");
1142        let summary = summarize_json(&value);
1143        assert_eq!(summary, "\"short string\"");
1144    }
1145
1146    #[test]
1147    fn test_summarize_json_long_string() {
1148        let long = "a".repeat(100);
1149        let value = serde_json::json!(long);
1150        let summary = summarize_json(&value);
1151        assert!(summary.len() < 60);
1152        assert!(summary.ends_with("...\""));
1153    }
1154
1155    #[test]
1156    fn test_summarize_json_null() {
1157        let value = serde_json::Value::Null;
1158        let summary = summarize_json(&value);
1159        assert_eq!(summary, "");
1160    }
1161
1162    #[test]
1163    fn test_summarize_json_array() {
1164        let value = serde_json::json!([1, 2, 3, 4, 5]);
1165        let summary = summarize_json(&value);
1166        assert_eq!(summary, "[5 items]");
1167    }
1168
1169    #[test]
1170    fn test_summarize_json_number() {
1171        let value = serde_json::json!(42);
1172        let summary = summarize_json(&value);
1173        assert_eq!(summary, "42");
1174    }
1175
1176    // ===================
1177    // Interactive command
1178    // ===================
1179
1180    #[test]
1181    fn test_interactive_command_format() {
1182        let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1183        let cmd = runner.interactive_command("session_123");
1184        assert_eq!(cmd[0], "/usr/local/bin/opencode");
1185        assert_eq!(cmd[1], "attach");
1186        assert!(cmd.contains(&"--session".to_string()));
1187        assert!(cmd.contains(&"session_123".to_string()));
1188    }
1189
1190    // ===================
1191    // OpenCodeHeadless struct tests
1192    // ===================
1193
1194    #[test]
1195    fn test_opencode_headless_with_binary_path() {
1196        let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1197        // Verify harness returns OpenCode
1198        assert!(matches!(runner.harness(), Harness::OpenCode));
1199    }
1200
1201    #[test]
1202    fn test_opencode_interactive_command_structure() {
1203        let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1204        let cmd = runner.interactive_command("sess-xyz-789");
1205
1206        // Should produce: opencode attach http://localhost:4096 --session sess-xyz-789
1207        assert_eq!(cmd.len(), 5);
1208        assert_eq!(cmd[0], "/bin/opencode");
1209        assert_eq!(cmd[1], "attach");
1210        assert_eq!(cmd[2], "http://localhost:4096");
1211        assert_eq!(cmd[3], "--session");
1212        assert_eq!(cmd[4], "sess-xyz-789");
1213    }
1214
1215    #[test]
1216    fn test_opencode_harness_type() {
1217        let runner = OpenCodeHeadless::with_binary_path("opencode");
1218        assert_eq!(runner.harness(), Harness::OpenCode);
1219    }
1220
1221    // ===================
1222    // ClaudeHeadless struct tests
1223    // ===================
1224
1225    #[test]
1226    fn test_claude_headless_with_binary_path() {
1227        let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1228        assert_eq!(runner.binary_path(), "/custom/claude");
1229        assert!(matches!(runner.harness(), Harness::Claude));
1230    }
1231
1232    #[test]
1233    fn test_claude_headless_with_allowed_tools() {
1234        let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1235            .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1236        // The runner should accept the tools (no getter, but constructor works)
1237        assert_eq!(runner.binary_path(), "/bin/claude");
1238    }
1239
1240    #[test]
1241    fn test_claude_interactive_command_structure() {
1242        let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1243        let cmd = runner.interactive_command("sess-abc-123");
1244
1245        // Should produce: claude --resume sess-abc-123
1246        assert_eq!(cmd.len(), 3);
1247        assert_eq!(cmd[0], "/usr/bin/claude");
1248        assert_eq!(cmd[1], "--resume");
1249        assert_eq!(cmd[2], "sess-abc-123");
1250    }
1251
1252    #[test]
1253    fn test_claude_harness_type() {
1254        let runner = ClaudeHeadless::with_binary_path("claude");
1255        assert_eq!(runner.harness(), Harness::Claude);
1256    }
1257
1258    // ===================
1259    // AnyRunner enum tests
1260    // ===================
1261
1262    #[test]
1263    fn test_any_runner_claude_variant() {
1264        let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1265        assert_eq!(runner.harness(), Harness::Claude);
1266
1267        let cmd = runner.interactive_command("session-1");
1268        assert_eq!(cmd[0], "/bin/claude");
1269        assert_eq!(cmd[1], "--resume");
1270    }
1271
1272    #[test]
1273    fn test_any_runner_opencode_variant() {
1274        let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1275        assert_eq!(runner.harness(), Harness::OpenCode);
1276
1277        let cmd = runner.interactive_command("session-2");
1278        assert_eq!(cmd[0], "/bin/opencode");
1279        assert_eq!(cmd[1], "attach");
1280    }
1281
1282    #[test]
1283    fn test_any_runner_harness_matches() {
1284        let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1285        let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1286
1287        // Verify harness() returns correct type for each variant
1288        assert!(matches!(claude.harness(), Harness::Claude));
1289        assert!(matches!(opencode.harness(), Harness::OpenCode));
1290    }
1291
1292    // ===================
1293    // Additional OpenCode parsing edge cases
1294    // ===================
1295
1296    #[test]
1297    fn test_parse_opencode_tool_with_pending_status() {
1298        let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1299        let event = parse_opencode_event(line);
1300        match event {
1301            Some(StreamEvent {
1302                kind:
1303                    StreamEventKind::ToolStart {
1304                        ref tool_name,
1305                        ref tool_id,
1306                        ..
1307                    },
1308                ..
1309            }) => {
1310                assert_eq!(tool_name, "write_file");
1311                assert_eq!(tool_id, "t99");
1312            }
1313            _ => panic!("Expected ToolStart for pending status"),
1314        }
1315    }
1316
1317    #[test]
1318    fn test_parse_opencode_tool_done_status() {
1319        let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1320        let event = parse_opencode_event(line);
1321        match event {
1322            Some(StreamEvent {
1323                kind:
1324                    StreamEventKind::ToolResult {
1325                        ref tool_name,
1326                        success,
1327                        ..
1328                    },
1329                ..
1330            }) => {
1331                assert_eq!(tool_name, "exec");
1332                assert!(success);
1333            }
1334            _ => panic!("Expected ToolResult for done subtype"),
1335        }
1336    }
1337
1338    #[test]
1339    fn test_parse_opencode_tool_success_status() {
1340        let line =
1341            r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1342        let event = parse_opencode_event(line);
1343        match event {
1344            Some(StreamEvent {
1345                kind: StreamEventKind::ToolResult { success, .. },
1346                ..
1347            }) => {
1348                assert!(success);
1349            }
1350            _ => panic!("Expected ToolResult for success subtype"),
1351        }
1352    }
1353
1354    #[test]
1355    fn test_parse_opencode_complete_type() {
1356        let line = r#"{"type": "complete", "success": true}"#;
1357        let event = parse_opencode_event(line);
1358        assert!(matches!(
1359            event,
1360            Some(StreamEvent {
1361                kind: StreamEventKind::Complete { success: true },
1362                ..
1363            })
1364        ));
1365    }
1366
1367    #[test]
1368    fn test_parse_opencode_session_start_type() {
1369        let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1370        let event = parse_opencode_event(line);
1371        match event {
1372            Some(StreamEvent {
1373                kind: StreamEventKind::SessionAssigned { ref session_id },
1374                ..
1375            }) => {
1376                assert_eq!(session_id, "sess-start-001");
1377            }
1378            _ => panic!("Expected SessionAssigned for session_start type"),
1379        }
1380    }
1381
1382    #[test]
1383    fn test_parse_opencode_assistant_with_message_text() {
1384        let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1385        let event = parse_opencode_event(line);
1386        assert!(matches!(
1387            event,
1388            Some(StreamEvent {
1389                kind: StreamEventKind::TextDelta { ref text },
1390                ..
1391            }) if text == "Thinking about this..."
1392        ));
1393    }
1394
1395    #[test]
1396    fn test_parse_opencode_tool_call_error_subtype() {
1397        let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1398        let event = parse_opencode_event(line);
1399        match event {
1400            Some(StreamEvent {
1401                kind:
1402                    StreamEventKind::ToolResult {
1403                        ref tool_name,
1404                        success,
1405                        ..
1406                    },
1407                ..
1408            }) => {
1409                assert_eq!(tool_name, "git");
1410                assert!(!success);
1411            }
1412            _ => panic!("Expected failed ToolResult for error subtype"),
1413        }
1414    }
1415
1416    #[test]
1417    fn test_parse_opencode_tool_with_nested_input() {
1418        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"}}}"#;
1419        let event = parse_opencode_event(line);
1420        match event {
1421            Some(StreamEvent {
1422                kind:
1423                    StreamEventKind::ToolStart {
1424                        ref tool_name,
1425                        ref input_summary,
1426                        ..
1427                    },
1428                ..
1429            }) => {
1430                assert_eq!(tool_name, "write_file");
1431                // Input should be summarized with keys
1432                assert!(input_summary.contains("path"));
1433            }
1434            _ => panic!("Expected ToolStart with input summary"),
1435        }
1436    }
1437
1438    #[test]
1439    fn test_parse_opencode_tool_result_with_error_string() {
1440        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1441        let event = parse_opencode_event(line);
1442        match event {
1443            Some(StreamEvent {
1444                kind: StreamEventKind::ToolResult { success, .. },
1445                ..
1446            }) => {
1447                // error field as string should indicate failure
1448                assert!(!success);
1449            }
1450            _ => panic!("Expected failed ToolResult"),
1451        }
1452    }
1453
1454    #[test]
1455    fn test_parse_opencode_unknown_subtype_returns_none() {
1456        let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1457        let event = parse_opencode_event(line);
1458        assert!(event.is_none());
1459    }
1460}