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/// Parse a line of Cursor Agent CLI stream-json output into a StreamEvent
313///
314/// Cursor Agent CLI (`agent -p --output-format stream-json`) outputs newline-delimited
315/// JSON events with the following structure:
316///
317/// - `{"type":"system","subtype":"init","session_id":"..."}` - Session init
318/// - `{"type":"tool_call","subtype":"started","call_id":"...","tool_call":{"editToolCall":{...}}}` - Tool start
319/// - `{"type":"tool_call","subtype":"completed","call_id":"...","tool_call":{...}}` - Tool result
320/// - `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}` - Text output
321/// - `{"type":"result","subtype":"success","is_error":false,"result":"...","session_id":"..."}` - Completion
322/// - `{"type":"user",...}` - User message echo (ignored)
323fn parse_cursor_event(line: &str) -> Option<StreamEvent> {
324    let json: serde_json::Value = serde_json::from_str(line).ok()?;
325    let event_type = json.get("type")?.as_str()?;
326
327    match event_type {
328        "system" => {
329            // System init event carries session_id
330            let session_id = json.get("session_id").and_then(|v| v.as_str())?;
331            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
332                session_id: session_id.to_string(),
333            }))
334        }
335        "tool_call" => {
336            let subtype = json.get("subtype").and_then(|v| v.as_str()).unwrap_or("started");
337            let call_id = json
338                .get("call_id")
339                .and_then(|v| v.as_str())
340                .unwrap_or("");
341
342            // Extract tool name from Cursor's *ToolCall nested structure
343            let tool_name = json
344                .get("tool_call")
345                .and_then(|tc| tc.as_object())
346                .and_then(|obj| obj.keys().next())
347                .map(|k| {
348                    // Convert "editToolCall" -> "Edit", "bashToolCall" -> "Bash", etc.
349                    k.trim_end_matches("ToolCall")
350                        .chars()
351                        .next()
352                        .map(|c| {
353                            let mut s = c.to_uppercase().to_string();
354                            s.push_str(&k.trim_end_matches("ToolCall")[c.len_utf8()..]);
355                            s
356                        })
357                        .unwrap_or_else(|| k.to_string())
358                })
359                .unwrap_or_else(|| "tool".to_string());
360
361            match subtype {
362                "started" => {
363                    // Extract args summary from the tool call
364                    let input_summary = json
365                        .get("tool_call")
366                        .and_then(|tc| tc.as_object())
367                        .and_then(|obj| obj.values().next())
368                        .and_then(|v| v.get("args"))
369                        .map(|args| summarize_json(args))
370                        .unwrap_or_default();
371                    Some(StreamEvent::tool_start(&tool_name, call_id, &input_summary))
372                }
373                "completed" => {
374                    let success = json
375                        .get("tool_call")
376                        .and_then(|tc| tc.as_object())
377                        .and_then(|obj| obj.values().next())
378                        .and_then(|v| v.get("result"))
379                        .map(|r| r.get("success").is_some())
380                        .unwrap_or(true);
381                    Some(StreamEvent::new(StreamEventKind::ToolResult {
382                        tool_name,
383                        tool_id: call_id.to_string(),
384                        success,
385                    }))
386                }
387                _ => None,
388            }
389        }
390        "assistant" => {
391            let text = json
392                .pointer("/message/content/0/text")
393                .and_then(|v| v.as_str())?;
394            Some(StreamEvent::text_delta(text))
395        }
396        "result" => {
397            let is_error = json
398                .get("is_error")
399                .and_then(|v| v.as_bool())
400                .unwrap_or(false);
401            Some(StreamEvent::complete(!is_error))
402        }
403        // Ignore user message echo and unknown types
404        _ => None,
405    }
406}
407
408/// OpenCode headless runner
409///
410/// Runs OpenCode CLI in headless mode using `opencode run --format json`
411/// and parses the streaming JSON output into unified StreamEvent types.
412pub struct OpenCodeHeadless {
413    binary_path: String,
414}
415
416impl OpenCodeHeadless {
417    /// Create a new OpenCode headless runner
418    ///
419    /// Locates the OpenCode binary using the standard harness discovery mechanism.
420    pub fn new() -> Result<Self> {
421        let binary_path = find_harness_binary(Harness::OpenCode)?.to_string();
422        Ok(Self { binary_path })
423    }
424
425    /// Create with an explicit binary path (useful for testing)
426    #[cfg(test)]
427    pub fn with_binary_path(path: impl Into<String>) -> Self {
428        Self {
429            binary_path: path.into(),
430        }
431    }
432}
433
434impl HeadlessRunner for OpenCodeHeadless {
435    fn start<'a>(
436        &'a self,
437        task_id: &'a str,
438        prompt: &'a str,
439        working_dir: &'a Path,
440        model: Option<&'a str>,
441    ) -> BoxFuture<'a, Result<SessionHandle>> {
442        Box::pin(async move {
443            // OpenCode uses `run` command with JSON format for headless streaming
444            let mut cmd = Command::new(&self.binary_path);
445
446            cmd.arg("run");
447            cmd.arg("--format").arg("json");
448            cmd.arg("--variant").arg("minimal");
449
450            if let Some(m) = model {
451                cmd.arg("--model").arg(m);
452            }
453
454            cmd.arg(prompt);
455            cmd.current_dir(working_dir);
456            cmd.env("SCUD_TASK_ID", task_id);
457            cmd.stdout(Stdio::piped());
458            cmd.stderr(Stdio::piped());
459
460            let mut child = cmd.spawn()?;
461            let (tx, rx) = mpsc::channel(1000);
462
463            let stdout = child.stdout.take().expect("stdout was piped");
464
465            tokio::spawn(async move {
466                let reader = BufReader::new(stdout);
467                let mut lines = reader.lines();
468
469                while let Ok(Some(line)) = lines.next_line().await {
470                    if let Some(event) = parse_opencode_event(&line) {
471                        if tx.send(event).await.is_err() {
472                            break;
473                        }
474                    }
475                }
476
477                let _ = tx.send(StreamEvent::complete(true)).await;
478            });
479
480            Ok(SessionHandle {
481                task_id: task_id.to_string(),
482                session_id: None,
483                child,
484                events: rx,
485            })
486        })
487    }
488
489    fn interactive_command(&self, session_id: &str) -> Vec<String> {
490        // OpenCode uses attach command for session continuation
491        vec![
492            self.binary_path.clone(),
493            "attach".to_string(),
494            "http://localhost:4096".to_string(),
495            "--session".to_string(),
496            session_id.to_string(),
497        ]
498    }
499
500    fn harness(&self) -> Harness {
501        Harness::OpenCode
502    }
503}
504
505/// Cursor Agent headless runner
506///
507/// Runs Cursor Agent CLI in headless mode with `-p` flag,
508/// parsing the streaming JSON output into unified StreamEvent types.
509pub struct CursorHeadless {
510    binary_path: String,
511}
512
513impl CursorHeadless {
514    /// Create a new Cursor headless runner
515    pub fn new() -> Result<Self> {
516        let binary_path = find_harness_binary(Harness::Cursor)?.to_string();
517        Ok(Self { binary_path })
518    }
519}
520
521impl HeadlessRunner for CursorHeadless {
522    fn start<'a>(
523        &'a self,
524        task_id: &'a str,
525        prompt: &'a str,
526        working_dir: &'a Path,
527        model: Option<&'a str>,
528    ) -> BoxFuture<'a, Result<SessionHandle>> {
529        Box::pin(async move {
530            let mut cmd = Command::new(&self.binary_path);
531
532            cmd.arg("-p");
533
534            if let Some(m) = model {
535                cmd.arg("--model").arg(m);
536            }
537
538            // Request streaming JSON output
539            cmd.arg("--output-format").arg("stream-json");
540            cmd.arg(prompt);
541            cmd.current_dir(working_dir);
542            cmd.env("SCUD_TASK_ID", task_id);
543            cmd.stdout(Stdio::piped());
544            cmd.stderr(Stdio::piped());
545
546            let mut child = cmd.spawn()?;
547            let (tx, rx) = mpsc::channel(1000);
548
549            let stdout = child.stdout.take().expect("stdout was piped");
550
551            tokio::spawn(async move {
552                let reader = BufReader::new(stdout);
553                let mut lines = reader.lines();
554
555                while let Ok(Some(line)) = lines.next_line().await {
556                    // Try Cursor-specific parsing first
557                    if let Some(event) = parse_cursor_event(&line) {
558                        if tx.send(event).await.is_err() {
559                            break;
560                        }
561                    } else if !line.trim().is_empty()
562                        && serde_json::from_str::<serde_json::Value>(&line).is_err()
563                    {
564                        // Only treat non-JSON output as text (skip unrecognized JSON events)
565                        // Append \n since BufReader::lines() strips it
566                        let _ = tx.send(StreamEvent::text_delta(&format!("{}\n", line))).await;
567                    }
568                }
569
570                let _ = tx.send(StreamEvent::complete(true)).await;
571            });
572
573            Ok(SessionHandle {
574                task_id: task_id.to_string(),
575                session_id: None,
576                child,
577                events: rx,
578            })
579        })
580    }
581
582    fn interactive_command(&self, session_id: &str) -> Vec<String> {
583        vec![
584            self.binary_path.clone(),
585            "--resume".to_string(),
586            session_id.to_string(),
587        ]
588    }
589
590    fn harness(&self) -> Harness {
591        Harness::Cursor
592    }
593}
594
595/// Enum-based runner that wraps concrete implementations
596///
597/// This provides polymorphism without requiring the trait to be object-safe.
598/// Use this instead of `Box<dyn HeadlessRunner>` when you need to store
599/// or pass around a runner of unknown concrete type.
600pub enum AnyRunner {
601    Claude(ClaudeHeadless),
602    OpenCode(OpenCodeHeadless),
603    Cursor(CursorHeadless),
604}
605
606impl AnyRunner {
607    /// Create a runner for the specified harness
608    pub fn new(harness: Harness) -> Result<Self> {
609        match harness {
610            Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
611            Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
612            Harness::Cursor => Ok(AnyRunner::Cursor(CursorHeadless::new()?)),
613        }
614    }
615
616    /// Start an agent with a prompt
617    pub async fn start(
618        &self,
619        task_id: &str,
620        prompt: &str,
621        working_dir: &Path,
622        model: Option<&str>,
623    ) -> Result<SessionHandle> {
624        match self {
625            AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
626            AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
627            AnyRunner::Cursor(runner) => runner.start(task_id, prompt, working_dir, model).await,
628        }
629    }
630
631    /// Get the command to launch interactive mode for session continuation
632    pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
633        match self {
634            AnyRunner::Claude(runner) => runner.interactive_command(session_id),
635            AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
636            AnyRunner::Cursor(runner) => runner.interactive_command(session_id),
637        }
638    }
639
640    /// Get the harness type this runner supports
641    pub fn harness(&self) -> Harness {
642        match self {
643            AnyRunner::Claude(runner) => runner.harness(),
644            AnyRunner::OpenCode(runner) => runner.harness(),
645            AnyRunner::Cursor(runner) => runner.harness(),
646        }
647    }
648}
649
650/// Create a headless runner for the specified harness
651///
652/// This is a convenience function that returns an `AnyRunner` enum
653/// which provides a unified interface for all runner implementations.
654pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
655    AnyRunner::new(harness)
656}
657
658/// Parse a line of OpenCode JSON output into a StreamEvent
659///
660/// OpenCode CLI (`opencode run --format json`) outputs newline-delimited JSON events
661/// with the following structure:
662///
663/// - `{"type": "assistant", "message": {"content": [{"text": "..."}]}}` - Text output
664/// - `{"type": "tool_call", "subtype": "started", "tool_call": {"name": "...", "input": {...}}}` - Tool start
665/// - `{"type": "tool_call", "subtype": "completed", "tool_call": {...}, "result": {...}}` - Tool result
666/// - `{"type": "result", "success": true}` - Completion
667/// - `{"type": "error", "message": "..."}` - Error
668/// - `{"type": "session", "session_id": "..."}` - Session assignment
669///
670/// Returns `None` for unparseable or unknown event types (graceful degradation).
671pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
672    let json: serde_json::Value = serde_json::from_str(line).ok()?;
673
674    let event_type = json.get("type")?.as_str()?;
675
676    match event_type {
677        // Assistant text output - may have various content structures
678        "assistant" | "message" | "content" => {
679            // Try multiple paths for text content
680            let text = json
681                .pointer("/message/content/0/text")
682                .or_else(|| json.pointer("/content/0/text"))
683                .or_else(|| json.pointer("/message/text"))
684                .or_else(|| json.get("text"))
685                .or_else(|| json.get("delta"))
686                .and_then(|v| v.as_str())?;
687            Some(StreamEvent::text_delta(text))
688        }
689
690        // Tool call events with subtype
691        "tool_call" | "tool_use" => {
692            let subtype = json
693                .get("subtype")
694                .or_else(|| json.get("status"))
695                .and_then(|v| v.as_str())
696                .unwrap_or("started");
697
698            match subtype {
699                "started" | "start" | "pending" => {
700                    // Extract tool name from various possible locations
701                    let tool_name = json
702                        .pointer("/tool_call/name")
703                        .or_else(|| json.pointer("/tool_call/tool"))
704                        .or_else(|| json.get("name"))
705                        .or_else(|| json.get("tool"))
706                        .and_then(|v| v.as_str())
707                        .unwrap_or("unknown");
708
709                    // Extract tool ID
710                    let tool_id = json
711                        .pointer("/tool_call/id")
712                        .or_else(|| json.get("id"))
713                        .or_else(|| json.get("tool_id"))
714                        .and_then(|v| v.as_str())
715                        .unwrap_or("");
716
717                    // Extract and summarize input
718                    let input = json
719                        .pointer("/tool_call/input")
720                        .or_else(|| json.get("input"))
721                        .cloned()
722                        .unwrap_or(serde_json::Value::Null);
723                    let input_summary = summarize_json(&input);
724
725                    Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
726                }
727                "completed" | "complete" | "done" | "success" => {
728                    let tool_name = json
729                        .pointer("/tool_call/name")
730                        .or_else(|| json.get("name"))
731                        .or_else(|| json.get("tool"))
732                        .and_then(|v| v.as_str())
733                        .unwrap_or("");
734
735                    let tool_id = json
736                        .pointer("/tool_call/id")
737                        .or_else(|| json.get("id"))
738                        .or_else(|| json.get("tool_id"))
739                        .and_then(|v| v.as_str())
740                        .unwrap_or("");
741
742                    // Check for error in result
743                    let success = !json
744                        .pointer("/result/is_error")
745                        .or_else(|| json.get("is_error"))
746                        .or_else(|| json.get("error"))
747                        .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
748                        .unwrap_or(false);
749
750                    Some(StreamEvent::new(StreamEventKind::ToolResult {
751                        tool_name: tool_name.to_string(),
752                        tool_id: tool_id.to_string(),
753                        success,
754                    }))
755                }
756                "failed" | "error" => {
757                    let tool_name = json
758                        .pointer("/tool_call/name")
759                        .or_else(|| json.get("name"))
760                        .and_then(|v| v.as_str())
761                        .unwrap_or("");
762
763                    let tool_id = json
764                        .pointer("/tool_call/id")
765                        .or_else(|| json.get("id"))
766                        .and_then(|v| v.as_str())
767                        .unwrap_or("");
768
769                    Some(StreamEvent::new(StreamEventKind::ToolResult {
770                        tool_name: tool_name.to_string(),
771                        tool_id: tool_id.to_string(),
772                        success: false,
773                    }))
774                }
775                _ => None,
776            }
777        }
778
779        // Completion event
780        "result" | "done" | "complete" => {
781            let success = json
782                .get("success")
783                .and_then(|v| v.as_bool())
784                .unwrap_or(true);
785            Some(StreamEvent::complete(success))
786        }
787
788        // Error event
789        "error" => {
790            let message = json
791                .get("message")
792                .or_else(|| json.get("error"))
793                .and_then(|v| v.as_str())
794                .unwrap_or("Unknown error");
795            Some(StreamEvent::error(message))
796        }
797
798        // Session assignment
799        "session" | "session_start" | "init" => {
800            let session_id = json
801                .get("session_id")
802                .or_else(|| json.get("id"))
803                .and_then(|v| v.as_str())?;
804            Some(StreamEvent::new(StreamEventKind::SessionAssigned {
805                session_id: session_id.to_string(),
806            }))
807        }
808
809        // Unknown event type - return None for graceful handling
810        _ => None,
811    }
812}
813
814/// Summarize JSON input for compact display
815///
816/// Produces a short, human-readable summary of JSON values:
817/// - Objects: `{key1, key2, ...}` (max 3 keys)
818/// - Strings: First 50 chars with ellipsis
819/// - Other: JSON stringified, truncated
820fn summarize_json(value: &serde_json::Value) -> String {
821    match value {
822        serde_json::Value::Object(obj) => {
823            let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
824            if keys.is_empty() {
825                "{}".to_string()
826            } else if keys.len() < obj.len() {
827                format!("{{{},...}}", keys.join(", "))
828            } else {
829                format!("{{{}}}", keys.join(", "))
830            }
831        }
832        serde_json::Value::String(s) => {
833            if s.len() > 50 {
834                format!("\"{}...\"", &s[..47])
835            } else {
836                format!("\"{}\"", s)
837            }
838        }
839        serde_json::Value::Null => String::new(),
840        serde_json::Value::Array(arr) => {
841            format!("[{} items]", arr.len())
842        }
843        other => {
844            let s = other.to_string();
845            if s.len() > 50 {
846                format!("{}...", &s[..47])
847            } else {
848                s
849            }
850        }
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    // =======================
859    // Claude event parsing
860    // =======================
861
862    #[test]
863    fn test_parse_claude_text_delta() {
864        let line =
865            r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
866        let event = parse_claude_event(line);
867        assert!(matches!(
868            event,
869            Some(StreamEvent {
870                kind: StreamEventKind::TextDelta { ref text },
871                ..
872            }) if text == "Hello"
873        ));
874    }
875
876    #[test]
877    fn test_parse_claude_tool_use() {
878        let line =
879            r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
880        let event = parse_claude_event(line);
881        match event {
882            Some(StreamEvent {
883                kind: StreamEventKind::ToolStart {
884                    ref tool_name,
885                    ref tool_id,
886                    ref input_summary,
887                },
888                ..
889            }) => {
890                assert_eq!(tool_name, "Read");
891                assert_eq!(tool_id, "tool_1");
892                assert!(input_summary.contains("path"));
893            }
894            _ => panic!("Expected ToolStart"),
895        }
896    }
897
898    #[test]
899    fn test_parse_claude_error() {
900        let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
901        let event = parse_claude_event(line);
902        match event {
903            Some(StreamEvent {
904                kind: StreamEventKind::Error { ref message },
905                ..
906            }) => {
907                assert_eq!(message, "Rate limit exceeded");
908            }
909            _ => panic!("Expected Error event"),
910        }
911    }
912
913    #[test]
914    fn test_parse_claude_result_with_session() {
915        let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
916        let event = parse_claude_event(line);
917        match event {
918            Some(StreamEvent {
919                kind: StreamEventKind::SessionAssigned { ref session_id },
920                ..
921            }) => {
922                assert_eq!(session_id, "sess-abc123");
923            }
924            _ => panic!("Expected SessionAssigned"),
925        }
926    }
927
928    #[test]
929    fn test_parse_claude_result_completion() {
930        let line = r#"{"type":"result"}"#;
931        let event = parse_claude_event(line);
932        assert!(matches!(
933            event,
934            Some(StreamEvent {
935                kind: StreamEventKind::Complete { success: true },
936                ..
937            })
938        ));
939    }
940
941    #[test]
942    fn test_parse_claude_tool_result() {
943        let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
944        let event = parse_claude_event(line);
945        match event {
946            Some(StreamEvent {
947                kind: StreamEventKind::ToolResult {
948                    ref tool_id,
949                    success,
950                    ..
951                },
952                ..
953            }) => {
954                assert_eq!(tool_id, "tool_1");
955                assert!(success);
956            }
957            _ => panic!("Expected ToolResult"),
958        }
959    }
960
961    #[test]
962    fn test_parse_claude_tool_result_error() {
963        let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
964        let event = parse_claude_event(line);
965        match event {
966            Some(StreamEvent {
967                kind: StreamEventKind::ToolResult { success, .. },
968                ..
969            }) => {
970                assert!(!success);
971            }
972            _ => panic!("Expected ToolResult with failure"),
973        }
974    }
975
976    #[test]
977    fn test_parse_claude_unknown_type_returns_none() {
978        let line = r#"{"type":"unknown_event","data":"test"}"#;
979        let event = parse_claude_event(line);
980        assert!(event.is_none());
981    }
982
983    #[test]
984    fn test_claude_interactive_command() {
985        let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
986        let cmd = runner.interactive_command("sess_123");
987        assert_eq!(cmd[0], "/usr/local/bin/claude");
988        assert_eq!(cmd[1], "--resume");
989        assert_eq!(cmd[2], "sess_123");
990    }
991
992    // =======================
993    // OpenCode event parsing
994    // =======================
995
996    #[test]
997    fn test_parse_assistant_text_with_message_content() {
998        let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
999        let event = parse_opencode_event(line);
1000        assert!(matches!(
1001            event,
1002            Some(StreamEvent {
1003                kind: StreamEventKind::TextDelta { ref text },
1004                ..
1005            }) if text == "Hello world"
1006        ));
1007    }
1008
1009    #[test]
1010    fn test_parse_content_type_with_text() {
1011        let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
1012        let event = parse_opencode_event(line);
1013        assert!(matches!(
1014            event,
1015            Some(StreamEvent {
1016                kind: StreamEventKind::TextDelta { ref text },
1017                ..
1018            }) if text == "Response text"
1019        ));
1020    }
1021
1022    #[test]
1023    fn test_parse_message_type_with_direct_text() {
1024        let line = r#"{"type": "message", "text": "Direct text"}"#;
1025        let event = parse_opencode_event(line);
1026        assert!(matches!(
1027            event,
1028            Some(StreamEvent {
1029                kind: StreamEventKind::TextDelta { ref text },
1030                ..
1031            }) if text == "Direct text"
1032        ));
1033    }
1034
1035    #[test]
1036    fn test_parse_assistant_with_delta_field() {
1037        let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
1038        let event = parse_opencode_event(line);
1039        assert!(matches!(
1040            event,
1041            Some(StreamEvent {
1042                kind: StreamEventKind::TextDelta { ref text },
1043                ..
1044            }) if text == "Streaming chunk"
1045        ));
1046    }
1047
1048    // ===================
1049    // Tool call parsing
1050    // ===================
1051
1052    #[test]
1053    fn test_parse_tool_call_started() {
1054        let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
1055        let event = parse_opencode_event(line);
1056        match event {
1057            Some(StreamEvent {
1058                kind:
1059                    StreamEventKind::ToolStart {
1060                        ref tool_name,
1061                        ref tool_id,
1062                        ref input_summary,
1063                    },
1064                ..
1065            }) => {
1066                assert_eq!(tool_name, "read_file");
1067                assert_eq!(tool_id, "tool_1");
1068                assert!(input_summary.contains("path"));
1069            }
1070            _ => panic!("Expected ToolStart, got {:?}", event),
1071        }
1072    }
1073
1074    #[test]
1075    fn test_parse_tool_use_start() {
1076        let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
1077        let event = parse_opencode_event(line);
1078        match event {
1079            Some(StreamEvent {
1080                kind:
1081                    StreamEventKind::ToolStart {
1082                        ref tool_name,
1083                        ref tool_id,
1084                        ..
1085                    },
1086                ..
1087            }) => {
1088                assert_eq!(tool_name, "bash");
1089                assert_eq!(tool_id, "t123");
1090            }
1091            _ => panic!("Expected ToolStart"),
1092        }
1093    }
1094
1095    #[test]
1096    fn test_parse_tool_call_completed() {
1097        let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
1098        let event = parse_opencode_event(line);
1099        match event {
1100            Some(StreamEvent {
1101                kind:
1102                    StreamEventKind::ToolResult {
1103                        ref tool_name,
1104                        ref tool_id,
1105                        success,
1106                    },
1107                ..
1108            }) => {
1109                assert_eq!(tool_name, "write_file");
1110                assert_eq!(tool_id, "t2");
1111                assert!(success);
1112            }
1113            _ => panic!("Expected ToolResult"),
1114        }
1115    }
1116
1117    #[test]
1118    fn test_parse_tool_call_with_error() {
1119        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
1120        let event = parse_opencode_event(line);
1121        match event {
1122            Some(StreamEvent {
1123                kind:
1124                    StreamEventKind::ToolResult {
1125                        success, ..
1126                    },
1127                ..
1128            }) => {
1129                assert!(!success);
1130            }
1131            _ => panic!("Expected ToolResult with failure"),
1132        }
1133    }
1134
1135    #[test]
1136    fn test_parse_tool_call_failed_subtype() {
1137        let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
1138        let event = parse_opencode_event(line);
1139        match event {
1140            Some(StreamEvent {
1141                kind:
1142                    StreamEventKind::ToolResult {
1143                        success, ..
1144                    },
1145                ..
1146            }) => {
1147                assert!(!success);
1148            }
1149            _ => panic!("Expected failed ToolResult"),
1150        }
1151    }
1152
1153    // ===================
1154    // Completion parsing
1155    // ===================
1156
1157    #[test]
1158    fn test_parse_result_success() {
1159        let line = r#"{"type": "result", "success": true}"#;
1160        let event = parse_opencode_event(line);
1161        assert!(matches!(
1162            event,
1163            Some(StreamEvent {
1164                kind: StreamEventKind::Complete { success: true },
1165                ..
1166            })
1167        ));
1168    }
1169
1170    #[test]
1171    fn test_parse_result_failure() {
1172        let line = r#"{"type": "result", "success": false}"#;
1173        let event = parse_opencode_event(line);
1174        assert!(matches!(
1175            event,
1176            Some(StreamEvent {
1177                kind: StreamEventKind::Complete { success: false },
1178                ..
1179            })
1180        ));
1181    }
1182
1183    #[test]
1184    fn test_parse_done_type() {
1185        let line = r#"{"type": "done"}"#;
1186        let event = parse_opencode_event(line);
1187        assert!(matches!(
1188            event,
1189            Some(StreamEvent {
1190                kind: StreamEventKind::Complete { success: true },
1191                ..
1192            })
1193        ));
1194    }
1195
1196    // ===================
1197    // Error parsing
1198    // ===================
1199
1200    #[test]
1201    fn test_parse_error_with_message() {
1202        let line = r#"{"type": "error", "message": "Connection failed"}"#;
1203        let event = parse_opencode_event(line);
1204        match event {
1205            Some(StreamEvent {
1206                kind: StreamEventKind::Error { ref message },
1207                ..
1208            }) => {
1209                assert_eq!(message, "Connection failed");
1210            }
1211            _ => panic!("Expected Error event"),
1212        }
1213    }
1214
1215    #[test]
1216    fn test_parse_error_with_error_field() {
1217        let line = r#"{"type": "error", "error": "Rate limited"}"#;
1218        let event = parse_opencode_event(line);
1219        match event {
1220            Some(StreamEvent {
1221                kind: StreamEventKind::Error { ref message },
1222                ..
1223            }) => {
1224                assert_eq!(message, "Rate limited");
1225            }
1226            _ => panic!("Expected Error event"),
1227        }
1228    }
1229
1230    // ===================
1231    // Session parsing
1232    // ===================
1233
1234    #[test]
1235    fn test_parse_session_assignment() {
1236        let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1237        let event = parse_opencode_event(line);
1238        match event {
1239            Some(StreamEvent {
1240                kind: StreamEventKind::SessionAssigned { ref session_id },
1241                ..
1242            }) => {
1243                assert_eq!(session_id, "sess_abc123");
1244            }
1245            _ => panic!("Expected SessionAssigned"),
1246        }
1247    }
1248
1249    #[test]
1250    fn test_parse_session_with_id_field() {
1251        let line = r#"{"type": "init", "id": "session_xyz"}"#;
1252        let event = parse_opencode_event(line);
1253        match event {
1254            Some(StreamEvent {
1255                kind: StreamEventKind::SessionAssigned { ref session_id },
1256                ..
1257            }) => {
1258                assert_eq!(session_id, "session_xyz");
1259            }
1260            _ => panic!("Expected SessionAssigned"),
1261        }
1262    }
1263
1264    // ===================
1265    // Edge cases
1266    // ===================
1267
1268    #[test]
1269    fn test_parse_unknown_event_returns_none() {
1270        let line = r#"{"type": "custom_event", "data": "something"}"#;
1271        let event = parse_opencode_event(line);
1272        assert!(event.is_none());
1273    }
1274
1275    #[test]
1276    fn test_parse_invalid_json_returns_none() {
1277        let line = "not json at all";
1278        let event = parse_opencode_event(line);
1279        assert!(event.is_none());
1280    }
1281
1282    #[test]
1283    fn test_parse_missing_type_returns_none() {
1284        let line = r#"{"message": "no type field"}"#;
1285        let event = parse_opencode_event(line);
1286        assert!(event.is_none());
1287    }
1288
1289    #[test]
1290    fn test_parse_empty_json_returns_none() {
1291        let line = "{}";
1292        let event = parse_opencode_event(line);
1293        assert!(event.is_none());
1294    }
1295
1296    // ===================
1297    // JSON summarization
1298    // ===================
1299
1300    #[test]
1301    fn test_summarize_json_object() {
1302        let value = serde_json::json!({"path": "/foo", "content": "bar"});
1303        let summary = summarize_json(&value);
1304        assert!(summary.contains("path"));
1305        assert!(summary.contains("content"));
1306    }
1307
1308    #[test]
1309    fn test_summarize_json_object_truncated() {
1310        let value = serde_json::json!({
1311            "key1": "v1",
1312            "key2": "v2",
1313            "key3": "v3",
1314            "key4": "v4"
1315        });
1316        let summary = summarize_json(&value);
1317        assert!(summary.contains("..."));
1318    }
1319
1320    #[test]
1321    fn test_summarize_json_empty_object() {
1322        let value = serde_json::json!({});
1323        let summary = summarize_json(&value);
1324        assert_eq!(summary, "{}");
1325    }
1326
1327    #[test]
1328    fn test_summarize_json_string() {
1329        let value = serde_json::json!("short string");
1330        let summary = summarize_json(&value);
1331        assert_eq!(summary, "\"short string\"");
1332    }
1333
1334    #[test]
1335    fn test_summarize_json_long_string() {
1336        let long = "a".repeat(100);
1337        let value = serde_json::json!(long);
1338        let summary = summarize_json(&value);
1339        assert!(summary.len() < 60);
1340        assert!(summary.ends_with("...\""));
1341    }
1342
1343    #[test]
1344    fn test_summarize_json_null() {
1345        let value = serde_json::Value::Null;
1346        let summary = summarize_json(&value);
1347        assert_eq!(summary, "");
1348    }
1349
1350    #[test]
1351    fn test_summarize_json_array() {
1352        let value = serde_json::json!([1, 2, 3, 4, 5]);
1353        let summary = summarize_json(&value);
1354        assert_eq!(summary, "[5 items]");
1355    }
1356
1357    #[test]
1358    fn test_summarize_json_number() {
1359        let value = serde_json::json!(42);
1360        let summary = summarize_json(&value);
1361        assert_eq!(summary, "42");
1362    }
1363
1364    // ===================
1365    // Interactive command
1366    // ===================
1367
1368    #[test]
1369    fn test_interactive_command_format() {
1370        let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1371        let cmd = runner.interactive_command("session_123");
1372        assert_eq!(cmd[0], "/usr/local/bin/opencode");
1373        assert_eq!(cmd[1], "attach");
1374        assert!(cmd.contains(&"--session".to_string()));
1375        assert!(cmd.contains(&"session_123".to_string()));
1376    }
1377
1378    // ===================
1379    // OpenCodeHeadless struct tests
1380    // ===================
1381
1382    #[test]
1383    fn test_opencode_headless_with_binary_path() {
1384        let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1385        // Verify harness returns OpenCode
1386        assert!(matches!(runner.harness(), Harness::OpenCode));
1387    }
1388
1389    #[test]
1390    fn test_opencode_interactive_command_structure() {
1391        let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1392        let cmd = runner.interactive_command("sess-xyz-789");
1393
1394        // Should produce: opencode attach http://localhost:4096 --session sess-xyz-789
1395        assert_eq!(cmd.len(), 5);
1396        assert_eq!(cmd[0], "/bin/opencode");
1397        assert_eq!(cmd[1], "attach");
1398        assert_eq!(cmd[2], "http://localhost:4096");
1399        assert_eq!(cmd[3], "--session");
1400        assert_eq!(cmd[4], "sess-xyz-789");
1401    }
1402
1403    #[test]
1404    fn test_opencode_harness_type() {
1405        let runner = OpenCodeHeadless::with_binary_path("opencode");
1406        assert_eq!(runner.harness(), Harness::OpenCode);
1407    }
1408
1409    // ===================
1410    // ClaudeHeadless struct tests
1411    // ===================
1412
1413    #[test]
1414    fn test_claude_headless_with_binary_path() {
1415        let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1416        assert_eq!(runner.binary_path(), "/custom/claude");
1417        assert!(matches!(runner.harness(), Harness::Claude));
1418    }
1419
1420    #[test]
1421    fn test_claude_headless_with_allowed_tools() {
1422        let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1423            .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1424        // The runner should accept the tools (no getter, but constructor works)
1425        assert_eq!(runner.binary_path(), "/bin/claude");
1426    }
1427
1428    #[test]
1429    fn test_claude_interactive_command_structure() {
1430        let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1431        let cmd = runner.interactive_command("sess-abc-123");
1432
1433        // Should produce: claude --resume sess-abc-123
1434        assert_eq!(cmd.len(), 3);
1435        assert_eq!(cmd[0], "/usr/bin/claude");
1436        assert_eq!(cmd[1], "--resume");
1437        assert_eq!(cmd[2], "sess-abc-123");
1438    }
1439
1440    #[test]
1441    fn test_claude_harness_type() {
1442        let runner = ClaudeHeadless::with_binary_path("claude");
1443        assert_eq!(runner.harness(), Harness::Claude);
1444    }
1445
1446    // ===================
1447    // AnyRunner enum tests
1448    // ===================
1449
1450    #[test]
1451    fn test_any_runner_claude_variant() {
1452        let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1453        assert_eq!(runner.harness(), Harness::Claude);
1454
1455        let cmd = runner.interactive_command("session-1");
1456        assert_eq!(cmd[0], "/bin/claude");
1457        assert_eq!(cmd[1], "--resume");
1458    }
1459
1460    #[test]
1461    fn test_any_runner_opencode_variant() {
1462        let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1463        assert_eq!(runner.harness(), Harness::OpenCode);
1464
1465        let cmd = runner.interactive_command("session-2");
1466        assert_eq!(cmd[0], "/bin/opencode");
1467        assert_eq!(cmd[1], "attach");
1468    }
1469
1470    #[test]
1471    fn test_any_runner_harness_matches() {
1472        let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1473        let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1474
1475        // Verify harness() returns correct type for each variant
1476        assert!(matches!(claude.harness(), Harness::Claude));
1477        assert!(matches!(opencode.harness(), Harness::OpenCode));
1478    }
1479
1480    // ===================
1481    // Additional OpenCode parsing edge cases
1482    // ===================
1483
1484    #[test]
1485    fn test_parse_opencode_tool_with_pending_status() {
1486        let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1487        let event = parse_opencode_event(line);
1488        match event {
1489            Some(StreamEvent {
1490                kind:
1491                    StreamEventKind::ToolStart {
1492                        ref tool_name,
1493                        ref tool_id,
1494                        ..
1495                    },
1496                ..
1497            }) => {
1498                assert_eq!(tool_name, "write_file");
1499                assert_eq!(tool_id, "t99");
1500            }
1501            _ => panic!("Expected ToolStart for pending status"),
1502        }
1503    }
1504
1505    #[test]
1506    fn test_parse_opencode_tool_done_status() {
1507        let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1508        let event = parse_opencode_event(line);
1509        match event {
1510            Some(StreamEvent {
1511                kind:
1512                    StreamEventKind::ToolResult {
1513                        ref tool_name,
1514                        success,
1515                        ..
1516                    },
1517                ..
1518            }) => {
1519                assert_eq!(tool_name, "exec");
1520                assert!(success);
1521            }
1522            _ => panic!("Expected ToolResult for done subtype"),
1523        }
1524    }
1525
1526    #[test]
1527    fn test_parse_opencode_tool_success_status() {
1528        let line =
1529            r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1530        let event = parse_opencode_event(line);
1531        match event {
1532            Some(StreamEvent {
1533                kind: StreamEventKind::ToolResult { success, .. },
1534                ..
1535            }) => {
1536                assert!(success);
1537            }
1538            _ => panic!("Expected ToolResult for success subtype"),
1539        }
1540    }
1541
1542    #[test]
1543    fn test_parse_opencode_complete_type() {
1544        let line = r#"{"type": "complete", "success": true}"#;
1545        let event = parse_opencode_event(line);
1546        assert!(matches!(
1547            event,
1548            Some(StreamEvent {
1549                kind: StreamEventKind::Complete { success: true },
1550                ..
1551            })
1552        ));
1553    }
1554
1555    #[test]
1556    fn test_parse_opencode_session_start_type() {
1557        let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1558        let event = parse_opencode_event(line);
1559        match event {
1560            Some(StreamEvent {
1561                kind: StreamEventKind::SessionAssigned { ref session_id },
1562                ..
1563            }) => {
1564                assert_eq!(session_id, "sess-start-001");
1565            }
1566            _ => panic!("Expected SessionAssigned for session_start type"),
1567        }
1568    }
1569
1570    #[test]
1571    fn test_parse_opencode_assistant_with_message_text() {
1572        let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1573        let event = parse_opencode_event(line);
1574        assert!(matches!(
1575            event,
1576            Some(StreamEvent {
1577                kind: StreamEventKind::TextDelta { ref text },
1578                ..
1579            }) if text == "Thinking about this..."
1580        ));
1581    }
1582
1583    #[test]
1584    fn test_parse_opencode_tool_call_error_subtype() {
1585        let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1586        let event = parse_opencode_event(line);
1587        match event {
1588            Some(StreamEvent {
1589                kind:
1590                    StreamEventKind::ToolResult {
1591                        ref tool_name,
1592                        success,
1593                        ..
1594                    },
1595                ..
1596            }) => {
1597                assert_eq!(tool_name, "git");
1598                assert!(!success);
1599            }
1600            _ => panic!("Expected failed ToolResult for error subtype"),
1601        }
1602    }
1603
1604    #[test]
1605    fn test_parse_opencode_tool_with_nested_input() {
1606        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"}}}"#;
1607        let event = parse_opencode_event(line);
1608        match event {
1609            Some(StreamEvent {
1610                kind:
1611                    StreamEventKind::ToolStart {
1612                        ref tool_name,
1613                        ref input_summary,
1614                        ..
1615                    },
1616                ..
1617            }) => {
1618                assert_eq!(tool_name, "write_file");
1619                // Input should be summarized with keys
1620                assert!(input_summary.contains("path"));
1621            }
1622            _ => panic!("Expected ToolStart with input summary"),
1623        }
1624    }
1625
1626    #[test]
1627    fn test_parse_opencode_tool_result_with_error_string() {
1628        let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1629        let event = parse_opencode_event(line);
1630        match event {
1631            Some(StreamEvent {
1632                kind: StreamEventKind::ToolResult { success, .. },
1633                ..
1634            }) => {
1635                // error field as string should indicate failure
1636                assert!(!success);
1637            }
1638            _ => panic!("Expected failed ToolResult"),
1639        }
1640    }
1641
1642    #[test]
1643    fn test_parse_opencode_unknown_subtype_returns_none() {
1644        let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1645        let event = parse_opencode_event(line);
1646        assert!(event.is_none());
1647    }
1648
1649    // =======================
1650    // Cursor event parsing
1651    // =======================
1652
1653    #[test]
1654    fn test_parse_cursor_system_init() {
1655        let line = r#"{"type":"system","subtype":"init","session_id":"013608ef-dda7-4b38-9741-54fb0323ce1c","model":"Claude 4.5 Opus"}"#;
1656        let event = parse_cursor_event(line);
1657        match event {
1658            Some(StreamEvent {
1659                kind: StreamEventKind::SessionAssigned { ref session_id },
1660                ..
1661            }) => {
1662                assert_eq!(session_id, "013608ef-dda7-4b38-9741-54fb0323ce1c");
1663            }
1664            _ => panic!("Expected SessionAssigned from system init"),
1665        }
1666    }
1667
1668    #[test]
1669    fn test_parse_cursor_tool_call_started() {
1670        let line = r#"{"type":"tool_call","subtype":"started","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py","streamContent":"print(\"Hello\")\n"}}}}"#;
1671        let event = parse_cursor_event(line);
1672        match event {
1673            Some(StreamEvent {
1674                kind:
1675                    StreamEventKind::ToolStart {
1676                        ref tool_name,
1677                        ref tool_id,
1678                        ref input_summary,
1679                    },
1680                ..
1681            }) => {
1682                assert_eq!(tool_name, "Edit");
1683                assert_eq!(tool_id, "toolu_123");
1684                assert!(input_summary.contains("path"));
1685            }
1686            _ => panic!("Expected ToolStart, got {:?}", event),
1687        }
1688    }
1689
1690    #[test]
1691    fn test_parse_cursor_tool_call_completed() {
1692        let line = r#"{"type":"tool_call","subtype":"completed","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py"},"result":{"success":{"path":"/tmp/hello.py","linesAdded":1}}}}}"#;
1693        let event = parse_cursor_event(line);
1694        match event {
1695            Some(StreamEvent {
1696                kind:
1697                    StreamEventKind::ToolResult {
1698                        ref tool_name,
1699                        ref tool_id,
1700                        success,
1701                    },
1702                ..
1703            }) => {
1704                assert_eq!(tool_name, "Edit");
1705                assert_eq!(tool_id, "toolu_123");
1706                assert!(success);
1707            }
1708            _ => panic!("Expected ToolResult, got {:?}", event),
1709        }
1710    }
1711
1712    #[test]
1713    fn test_parse_cursor_assistant_message() {
1714        let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.py"}]}}"#;
1715        let event = parse_cursor_event(line);
1716        assert!(matches!(
1717            event,
1718            Some(StreamEvent {
1719                kind: StreamEventKind::TextDelta { ref text },
1720                ..
1721            }) if text == "Created hello.py"
1722        ));
1723    }
1724
1725    #[test]
1726    fn test_parse_cursor_result_success() {
1727        let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"Done","session_id":"sess-123"}"#;
1728        let event = parse_cursor_event(line);
1729        assert!(matches!(
1730            event,
1731            Some(StreamEvent {
1732                kind: StreamEventKind::Complete { success: true },
1733                ..
1734            })
1735        ));
1736    }
1737
1738    #[test]
1739    fn test_parse_cursor_result_error() {
1740        let line = r#"{"type":"result","subtype":"error","is_error":true,"result":"Failed"}"#;
1741        let event = parse_cursor_event(line);
1742        assert!(matches!(
1743            event,
1744            Some(StreamEvent {
1745                kind: StreamEventKind::Complete { success: false },
1746                ..
1747            })
1748        ));
1749    }
1750
1751    #[test]
1752    fn test_parse_cursor_user_message_ignored() {
1753        let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}}"#;
1754        let event = parse_cursor_event(line);
1755        assert!(event.is_none());
1756    }
1757
1758    #[test]
1759    fn test_parse_cursor_invalid_json() {
1760        let event = parse_cursor_event("not json");
1761        assert!(event.is_none());
1762    }
1763}