1use anyhow::Result;
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::process::Stdio;
12use tokio::io::{AsyncBufReadExt, BufReader};
13use tokio::process::{Child, Command};
14use tokio::sync::mpsc;
15use tracing::{debug, trace};
16
17use super::events::{StreamEvent, StreamEventKind};
18use crate::commands::spawn::terminal::{find_harness_binary, Harness};
19
20pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
22
23pub enum SessionProcess {
27 Child(Child),
29 #[cfg(feature = "direct-api")]
31 Task(tokio::task::JoinHandle<()>),
32}
33
34impl SessionProcess {
35 pub fn kill(&mut self) -> Result<()> {
37 match self {
38 SessionProcess::Child(child) => {
39 child.start_kill()?;
40 Ok(())
41 }
42 #[cfg(feature = "direct-api")]
43 SessionProcess::Task(handle) => {
44 handle.abort();
45 Ok(())
46 }
47 }
48 }
49}
50
51pub struct SessionHandle {
53 pub task_id: String,
55 pub session_id: Option<String>,
57 process: SessionProcess,
59 pub events: mpsc::Receiver<StreamEvent>,
61}
62
63impl SessionHandle {
64 pub fn from_child(task_id: String, child: Child, events: mpsc::Receiver<StreamEvent>) -> Self {
66 Self {
67 task_id,
68 session_id: None,
69 process: SessionProcess::Child(child),
70 events,
71 }
72 }
73
74 #[cfg(feature = "direct-api")]
76 pub fn from_task(
77 task_id: String,
78 events: mpsc::Receiver<StreamEvent>,
79 handle: tokio::task::JoinHandle<()>,
80 ) -> Self {
81 Self {
82 task_id,
83 session_id: None,
84 process: SessionProcess::Task(handle),
85 events,
86 }
87 }
88
89 pub async fn wait(self) -> Result<bool> {
91 match self.process {
92 SessionProcess::Child(mut child) => {
93 let status = child.wait().await?;
94 Ok(status.success())
95 }
96 #[cfg(feature = "direct-api")]
97 SessionProcess::Task(handle) => {
98 let _ = handle.await;
99 Ok(true)
100 }
101 }
102 }
103
104 pub fn interrupt(&mut self) -> Result<()> {
106 match &mut self.process {
107 SessionProcess::Child(child) => {
108 #[cfg(unix)]
109 {
110 if let Some(pid) = child.id() {
111 let _ = std::process::Command::new("kill")
113 .arg("-INT")
114 .arg(pid.to_string())
115 .status();
116 }
117 }
118
119 #[cfg(not(unix))]
120 {
121 let _ = child.start_kill();
123 }
124
125 Ok(())
126 }
127 #[cfg(feature = "direct-api")]
128 SessionProcess::Task(handle) => {
129 handle.abort();
130 Ok(())
131 }
132 }
133 }
134
135 pub fn kill(&mut self) -> Result<()> {
137 match &mut self.process {
138 SessionProcess::Child(child) => {
139 child.start_kill()?;
140 Ok(())
141 }
142 #[cfg(feature = "direct-api")]
143 SessionProcess::Task(handle) => {
144 handle.abort();
145 Ok(())
146 }
147 }
148 }
149
150 pub fn into_parts(self) -> (mpsc::Receiver<StreamEvent>, SessionProcess) {
156 (self.events, self.process)
157 }
158
159 pub fn pid(&self) -> Option<u32> {
161 match &self.process {
162 SessionProcess::Child(child) => child.id(),
163 #[cfg(feature = "direct-api")]
164 SessionProcess::Task(_) => None,
165 }
166 }
167}
168
169pub trait HeadlessRunner: Send + Sync {
178 fn start<'a>(
183 &'a self,
184 task_id: &'a str,
185 prompt: &'a str,
186 working_dir: &'a Path,
187 model: Option<&'a str>,
188 ) -> BoxFuture<'a, Result<SessionHandle>>;
189
190 fn interactive_command(&self, session_id: &str) -> Vec<String>;
195
196 fn harness(&self) -> Harness;
198}
199
200pub struct ClaudeHeadless {
205 binary_path: String,
206 allowed_tools: Vec<String>,
207}
208
209impl ClaudeHeadless {
210 pub fn new() -> Result<Self> {
214 let binary_path = find_harness_binary(Harness::Claude)?.to_string();
215 Ok(Self {
216 binary_path,
217 allowed_tools: vec![
218 "Read".to_string(),
219 "Write".to_string(),
220 "Edit".to_string(),
221 "Bash".to_string(),
222 "Glob".to_string(),
223 "Grep".to_string(),
224 ],
225 })
226 }
227
228 #[cfg(test)]
230 pub fn with_binary_path(path: impl Into<String>) -> Self {
231 Self {
232 binary_path: path.into(),
233 allowed_tools: vec![],
234 }
235 }
236
237 pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
239 self.allowed_tools = tools;
240 self
241 }
242
243 pub fn binary_path(&self) -> &str {
245 &self.binary_path
246 }
247}
248
249impl HeadlessRunner for ClaudeHeadless {
250 fn start<'a>(
251 &'a self,
252 task_id: &'a str,
253 prompt: &'a str,
254 working_dir: &'a Path,
255 model: Option<&'a str>,
256 ) -> BoxFuture<'a, Result<SessionHandle>> {
257 Box::pin(async move {
258 let mut cmd = Command::new(&self.binary_path);
259
260 cmd.arg("-p").arg(prompt);
262 cmd.arg("--output-format").arg("stream-json");
263 cmd.arg("--verbose");
264 cmd.arg("--include-partial-messages");
265 cmd.arg("--dangerously-skip-permissions");
266
267 if let Some(m) = model {
269 cmd.arg("--model").arg(m);
270 }
271
272 if !self.allowed_tools.is_empty() {
274 cmd.arg("--allowedTools")
275 .arg(self.allowed_tools.join(","));
276 }
277
278 cmd.current_dir(working_dir);
280 cmd.env("SCUD_TASK_ID", task_id);
281 cmd.env_remove("CLAUDECODE");
283
284 cmd.stdout(Stdio::piped());
286 cmd.stderr(Stdio::piped());
287
288 let mut child = cmd.spawn()?;
289
290 let (tx, rx) = mpsc::channel(1000);
292
293 let stdout = child.stdout.take().expect("stdout was piped");
295 let task_id_clone = task_id.to_string();
296 let task_id_for_events = task_id.to_string();
297
298 tokio::spawn(async move {
299 let reader = BufReader::new(stdout);
300 let mut lines = reader.lines();
301
302 while let Ok(Some(line)) = lines.next_line().await {
303 if let Some(event) = parse_claude_event(&line) {
304 trace!(task_id = %task_id_for_events, "claude event: {:?}", event.kind);
305 if tx.send(event).await.is_err() {
306 break;
307 }
308 } else if !line.trim().is_empty() {
309 debug!(task_id = %task_id_for_events, "claude: unparsed line: {}", if line.len() > 200 { &line[..200] } else { &line });
310 }
311 }
312
313 let _ = tx.send(StreamEvent::complete(true)).await;
315 });
316
317 Ok(SessionHandle::from_child(task_id_clone, child, rx))
318 })
319 }
320
321 fn interactive_command(&self, session_id: &str) -> Vec<String> {
322 vec![
323 self.binary_path.clone(),
324 "--resume".to_string(),
325 session_id.to_string(),
326 ]
327 }
328
329 fn harness(&self) -> Harness {
330 Harness::Claude
331 }
332}
333
334fn parse_claude_event(line: &str) -> Option<StreamEvent> {
336 let json: serde_json::Value = serde_json::from_str(line).ok()?;
337
338 let event_type = json.get("type")?.as_str()?;
339
340 match event_type {
341 "system" => {
342 let session_id = json.get("session_id").and_then(|v| v.as_str())?;
344 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
345 session_id: session_id.to_string(),
346 }))
347 }
348 "stream_event" => {
349 if let Some(delta) = json.pointer("/event/delta") {
351 if delta.get("type")?.as_str()? == "text_delta" {
352 let text = delta.get("text")?.as_str()?;
353 return Some(StreamEvent::text_delta(text));
354 }
355 }
356 None
357 }
358 "content_block_delta" => {
359 if let Some(text) = json.pointer("/delta/text").and_then(|v| v.as_str()) {
361 return Some(StreamEvent::text_delta(text));
362 }
363 None
364 }
365 "assistant" => {
366 None
370 }
371 "tool_use" => {
372 let tool_name = json.get("name")?.as_str()?;
373 let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
374 let input = json
375 .get("input")
376 .cloned()
377 .unwrap_or(serde_json::Value::Null);
378 let input_summary = summarize_json(&input);
379 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
380 }
381 "tool_result" => {
382 let tool_id = json
383 .get("tool_use_id")
384 .and_then(|v| v.as_str())
385 .unwrap_or("unknown");
386 let success = !json
387 .get("is_error")
388 .and_then(|v| v.as_bool())
389 .unwrap_or(false);
390 Some(StreamEvent::new(StreamEventKind::ToolResult {
391 tool_name: String::new(), tool_id: tool_id.to_string(),
393 success,
394 }))
395 }
396 "result" => {
397 if let Some(session_id) = json.get("session_id").and_then(|v| v.as_str()) {
399 return Some(StreamEvent::new(StreamEventKind::SessionAssigned {
400 session_id: session_id.to_string(),
401 }));
402 }
403 let is_error = json.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false);
404 Some(StreamEvent::complete(!is_error))
408 }
409 "error" => {
410 let message = json
411 .get("error")
412 .and_then(|e| e.as_str())
413 .or_else(|| json.get("message").and_then(|e| e.as_str()))
414 .unwrap_or("Unknown error");
415 Some(StreamEvent::error(message))
416 }
417 _ => None,
418 }
419}
420
421fn parse_cursor_event(line: &str) -> Option<StreamEvent> {
433 let json: serde_json::Value = serde_json::from_str(line).ok()?;
434 let event_type = json.get("type")?.as_str()?;
435
436 match event_type {
437 "system" => {
438 let session_id = json.get("session_id").and_then(|v| v.as_str())?;
440 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
441 session_id: session_id.to_string(),
442 }))
443 }
444 "tool_call" => {
445 let subtype = json.get("subtype").and_then(|v| v.as_str()).unwrap_or("started");
446 let call_id = json
447 .get("call_id")
448 .and_then(|v| v.as_str())
449 .unwrap_or("");
450
451 let tool_name = json
453 .get("tool_call")
454 .and_then(|tc| tc.as_object())
455 .and_then(|obj| obj.keys().next())
456 .map(|k| {
457 k.trim_end_matches("ToolCall")
459 .chars()
460 .next()
461 .map(|c| {
462 let mut s = c.to_uppercase().to_string();
463 s.push_str(&k.trim_end_matches("ToolCall")[c.len_utf8()..]);
464 s
465 })
466 .unwrap_or_else(|| k.to_string())
467 })
468 .unwrap_or_else(|| "tool".to_string());
469
470 match subtype {
471 "started" => {
472 let input_summary = json
474 .get("tool_call")
475 .and_then(|tc| tc.as_object())
476 .and_then(|obj| obj.values().next())
477 .and_then(|v| v.get("args"))
478 .map(summarize_json)
479 .unwrap_or_default();
480 Some(StreamEvent::tool_start(&tool_name, call_id, &input_summary))
481 }
482 "completed" => {
483 let success = json
484 .get("tool_call")
485 .and_then(|tc| tc.as_object())
486 .and_then(|obj| obj.values().next())
487 .and_then(|v| v.get("result"))
488 .map(|r| r.get("success").is_some())
489 .unwrap_or(true);
490 Some(StreamEvent::new(StreamEventKind::ToolResult {
491 tool_name,
492 tool_id: call_id.to_string(),
493 success,
494 }))
495 }
496 _ => None,
497 }
498 }
499 "assistant" => {
500 let text = json
501 .pointer("/message/content/0/text")
502 .and_then(|v| v.as_str())?;
503 Some(StreamEvent::text_delta(text))
504 }
505 "result" => {
506 let is_error = json
507 .get("is_error")
508 .and_then(|v| v.as_bool())
509 .unwrap_or(false);
510 Some(StreamEvent::complete(!is_error))
511 }
512 _ => None,
514 }
515}
516
517pub struct OpenCodeHeadless {
522 binary_path: String,
523}
524
525impl OpenCodeHeadless {
526 pub fn new() -> Result<Self> {
530 let binary_path = find_harness_binary(Harness::OpenCode)?.to_string();
531 Ok(Self { binary_path })
532 }
533
534 #[cfg(test)]
536 pub fn with_binary_path(path: impl Into<String>) -> Self {
537 Self {
538 binary_path: path.into(),
539 }
540 }
541}
542
543impl HeadlessRunner for OpenCodeHeadless {
544 fn start<'a>(
545 &'a self,
546 task_id: &'a str,
547 prompt: &'a str,
548 working_dir: &'a Path,
549 model: Option<&'a str>,
550 ) -> BoxFuture<'a, Result<SessionHandle>> {
551 Box::pin(async move {
552 let mut cmd = Command::new(&self.binary_path);
554
555 cmd.arg("run");
556 cmd.arg("--format").arg("json");
557 cmd.arg("--variant").arg("minimal");
558
559 if let Some(m) = model {
560 cmd.arg("--model").arg(m);
561 }
562
563 cmd.arg(prompt);
564 cmd.current_dir(working_dir);
565 cmd.env("SCUD_TASK_ID", task_id);
566 cmd.stdout(Stdio::piped());
567 cmd.stderr(Stdio::piped());
568
569 let mut child = cmd.spawn()?;
570 let (tx, rx) = mpsc::channel(1000);
571
572 let stdout = child.stdout.take().expect("stdout was piped");
573 let task_id_for_events = task_id.to_string();
574
575 tokio::spawn(async move {
576 let reader = BufReader::new(stdout);
577 let mut lines = reader.lines();
578
579 while let Ok(Some(line)) = lines.next_line().await {
580 if let Some(event) = parse_opencode_event(&line) {
581 trace!(task_id = %task_id_for_events, "opencode event: {:?}", event.kind);
582 if tx.send(event).await.is_err() {
583 break;
584 }
585 } else if !line.trim().is_empty() {
586 debug!(task_id = %task_id_for_events, "opencode: unparsed line: {}", if line.len() > 200 { &line[..200] } else { &line });
587 }
588 }
589
590 let _ = tx.send(StreamEvent::complete(true)).await;
591 });
592
593 Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
594 })
595 }
596
597 fn interactive_command(&self, session_id: &str) -> Vec<String> {
598 vec![
600 self.binary_path.clone(),
601 "attach".to_string(),
602 "http://localhost:4096".to_string(),
603 "--session".to_string(),
604 session_id.to_string(),
605 ]
606 }
607
608 fn harness(&self) -> Harness {
609 Harness::OpenCode
610 }
611}
612
613pub struct CursorHeadless {
618 binary_path: String,
619}
620
621impl CursorHeadless {
622 pub fn new() -> Result<Self> {
624 let binary_path = find_harness_binary(Harness::Cursor)?.to_string();
625 Ok(Self { binary_path })
626 }
627}
628
629impl HeadlessRunner for CursorHeadless {
630 fn start<'a>(
631 &'a self,
632 task_id: &'a str,
633 prompt: &'a str,
634 working_dir: &'a Path,
635 model: Option<&'a str>,
636 ) -> BoxFuture<'a, Result<SessionHandle>> {
637 Box::pin(async move {
638 let mut cmd = Command::new(&self.binary_path);
639
640 cmd.arg("-p");
641
642 if let Some(m) = model {
643 cmd.arg("--model").arg(m);
644 }
645
646 cmd.arg("--output-format").arg("stream-json");
648 cmd.arg(prompt);
649 cmd.current_dir(working_dir);
650 cmd.env("SCUD_TASK_ID", task_id);
651 cmd.stdout(Stdio::piped());
652 cmd.stderr(Stdio::piped());
653
654 let mut child = cmd.spawn()?;
655 let (tx, rx) = mpsc::channel(1000);
656
657 let stdout = child.stdout.take().expect("stdout was piped");
658 let task_id_for_events = task_id.to_string();
659
660 tokio::spawn(async move {
661 let reader = BufReader::new(stdout);
662 let mut lines = reader.lines();
663
664 while let Ok(Some(line)) = lines.next_line().await {
665 if let Some(event) = parse_cursor_event(&line) {
667 trace!(task_id = %task_id_for_events, "cursor event: {:?}", event.kind);
668 if tx.send(event).await.is_err() {
669 break;
670 }
671 } else if !line.trim().is_empty() {
672 if serde_json::from_str::<serde_json::Value>(&line).is_err() {
673 let _ = tx.send(StreamEvent::text_delta(format!("{}\n", line))).await;
675 } else {
676 debug!(task_id = %task_id_for_events, "cursor: unparsed json: {}", if line.len() > 200 { &line[..200] } else { &line });
677 }
678 }
679 }
680
681 let _ = tx.send(StreamEvent::complete(true)).await;
682 });
683
684 Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
685 })
686 }
687
688 fn interactive_command(&self, session_id: &str) -> Vec<String> {
689 vec![
690 self.binary_path.clone(),
691 "--resume".to_string(),
692 session_id.to_string(),
693 ]
694 }
695
696 fn harness(&self) -> Harness {
697 Harness::Cursor
698 }
699}
700
701pub struct RhoHeadless {
706 binary_path: String,
707}
708
709impl RhoHeadless {
710 pub fn new() -> Result<Self> {
712 let binary_path = find_harness_binary(Harness::Rho)?.to_string();
713 Ok(Self { binary_path })
714 }
715}
716
717impl HeadlessRunner for RhoHeadless {
718 fn start<'a>(
719 &'a self,
720 task_id: &'a str,
721 prompt: &'a str,
722 working_dir: &'a Path,
723 model: Option<&'a str>,
724 ) -> BoxFuture<'a, Result<SessionHandle>> {
725 Box::pin(async move {
726 let mut cmd = Command::new(&self.binary_path);
727
728 if let Some(m) = model {
729 cmd.arg("--model").arg(m);
730 }
731
732 cmd.arg(prompt);
733 cmd.current_dir(working_dir);
734 cmd.env("SCUD_TASK_ID", task_id);
735 cmd.stdout(Stdio::piped());
736 cmd.stderr(Stdio::piped());
737
738 let mut child = cmd.spawn()?;
739 let (tx, rx) = mpsc::channel(1000);
740
741 let stdout = child.stdout.take().expect("stdout was piped");
742 let stderr = child.stderr.take().expect("stderr was piped");
743 let task_id_stdout = task_id.to_string();
744 let task_id_stderr = task_id.to_string();
745
746 let tx_stdout = tx.clone();
748 tokio::spawn(async move {
749 let reader = BufReader::new(stdout);
750 let mut lines = reader.lines();
751
752 while let Ok(Some(line)) = lines.next_line().await {
753 if !line.is_empty() {
754 let _ = tx_stdout.send(StreamEvent::text_delta(format!("{}\n", line))).await;
755 }
756 }
757 trace!(task_id = %task_id_stdout, "rho stdout stream ended");
758 });
759
760 let tx_stderr = tx;
762 tokio::spawn(async move {
763 let reader = BufReader::new(stderr);
764 let mut lines = reader.lines();
765 let mut tool_counter: u64 = 0;
766
767 while let Ok(Some(line)) = lines.next_line().await {
768 let trimmed = line.trim();
769
770 if let Some(rest) = trimmed.strip_prefix("[tool:") {
771 if let Some(bracket_end) = rest.find(']') {
772 let tool_name = &rest[..bracket_end];
773 let after = rest[bracket_end + 1..].trim();
774
775 if after == "done" {
776 let _ = tx_stderr.send(StreamEvent::new(StreamEventKind::ToolResult {
777 tool_name: tool_name.to_string(),
778 tool_id: format!("rho-tool-{}", tool_counter),
779 success: true,
780 })).await;
781 } else if let Some(err_msg) = after.strip_prefix("ERROR:") {
782 let _ = tx_stderr.send(StreamEvent::new(StreamEventKind::ToolResult {
783 tool_name: tool_name.to_string(),
784 tool_id: format!("rho-tool-{}", tool_counter),
785 success: false,
786 })).await;
787 debug!(task_id = %task_id_stderr, "rho tool error: {}: {}", tool_name, err_msg.trim());
788 } else {
789 tool_counter += 1;
791 let _ = tx_stderr.send(StreamEvent::tool_start(
792 tool_name,
793 &format!("rho-tool-{}", tool_counter),
794 after,
795 )).await;
796 }
797 }
798 } else if let Some(rest) = trimmed.strip_prefix("[compact]") {
799 debug!(task_id = %task_id_stderr, "rho compaction: {}", rest.trim());
800 } else if !trimmed.is_empty() {
801 trace!(task_id = %task_id_stderr, "rho stderr: {}", trimmed);
802 }
803 }
804
805 let _ = tx_stderr.send(StreamEvent::complete(true)).await;
806 });
807
808 Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
809 })
810 }
811
812 fn interactive_command(&self, _session_id: &str) -> Vec<String> {
813 vec![self.binary_path.clone()]
815 }
816
817 fn harness(&self) -> Harness {
818 Harness::Rho
819 }
820}
821
822pub enum AnyRunner {
828 Claude(ClaudeHeadless),
829 OpenCode(OpenCodeHeadless),
830 Cursor(CursorHeadless),
831 Rho(RhoHeadless),
832 #[cfg(feature = "direct-api")]
833 DirectApi(super::direct_api::DirectApiRunner),
834}
835
836impl AnyRunner {
837 #[cfg(feature = "direct-api")]
839 pub fn new_direct_api(provider: crate::llm::provider::AgentProvider) -> Self {
840 AnyRunner::DirectApi(
841 super::direct_api::DirectApiRunner::new().with_provider(provider),
842 )
843 }
844
845 pub fn new(harness: Harness) -> Result<Self> {
847 match harness {
848 Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
849 Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
850 Harness::Cursor => Ok(AnyRunner::Cursor(CursorHeadless::new()?)),
851 Harness::Rho => Ok(AnyRunner::Rho(RhoHeadless::new()?)),
852 #[cfg(feature = "direct-api")]
853 Harness::DirectApi => Ok(AnyRunner::DirectApi(
854 super::direct_api::DirectApiRunner::new(),
855 )),
856 }
857 }
858
859 pub async fn start(
861 &self,
862 task_id: &str,
863 prompt: &str,
864 working_dir: &Path,
865 model: Option<&str>,
866 ) -> Result<SessionHandle> {
867 match self {
868 AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
869 AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
870 AnyRunner::Cursor(runner) => runner.start(task_id, prompt, working_dir, model).await,
871 AnyRunner::Rho(runner) => runner.start(task_id, prompt, working_dir, model).await,
872 #[cfg(feature = "direct-api")]
873 AnyRunner::DirectApi(runner) => {
874 runner.start(task_id, prompt, working_dir, model).await
875 }
876 }
877 }
878
879 pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
881 match self {
882 AnyRunner::Claude(runner) => runner.interactive_command(session_id),
883 AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
884 AnyRunner::Cursor(runner) => runner.interactive_command(session_id),
885 AnyRunner::Rho(runner) => runner.interactive_command(session_id),
886 #[cfg(feature = "direct-api")]
887 AnyRunner::DirectApi(runner) => runner.interactive_command(session_id),
888 }
889 }
890
891 pub fn harness(&self) -> Harness {
893 match self {
894 AnyRunner::Claude(runner) => runner.harness(),
895 AnyRunner::OpenCode(runner) => runner.harness(),
896 AnyRunner::Cursor(runner) => runner.harness(),
897 AnyRunner::Rho(runner) => runner.harness(),
898 #[cfg(feature = "direct-api")]
899 AnyRunner::DirectApi(runner) => runner.harness(),
900 }
901 }
902}
903
904pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
909 AnyRunner::new(harness)
910}
911
912pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
926 let json: serde_json::Value = serde_json::from_str(line).ok()?;
927
928 let event_type = json.get("type")?.as_str()?;
929
930 match event_type {
931 "assistant" | "message" | "content" => {
933 let text = json
935 .pointer("/message/content/0/text")
936 .or_else(|| json.pointer("/content/0/text"))
937 .or_else(|| json.pointer("/message/text"))
938 .or_else(|| json.get("text"))
939 .or_else(|| json.get("delta"))
940 .and_then(|v| v.as_str())?;
941 Some(StreamEvent::text_delta(text))
942 }
943
944 "tool_call" | "tool_use" => {
946 let subtype = json
947 .get("subtype")
948 .or_else(|| json.get("status"))
949 .and_then(|v| v.as_str())
950 .unwrap_or("started");
951
952 match subtype {
953 "started" | "start" | "pending" => {
954 let tool_name = json
956 .pointer("/tool_call/name")
957 .or_else(|| json.pointer("/tool_call/tool"))
958 .or_else(|| json.get("name"))
959 .or_else(|| json.get("tool"))
960 .and_then(|v| v.as_str())
961 .unwrap_or("unknown");
962
963 let tool_id = json
965 .pointer("/tool_call/id")
966 .or_else(|| json.get("id"))
967 .or_else(|| json.get("tool_id"))
968 .and_then(|v| v.as_str())
969 .unwrap_or("");
970
971 let input = json
973 .pointer("/tool_call/input")
974 .or_else(|| json.get("input"))
975 .cloned()
976 .unwrap_or(serde_json::Value::Null);
977 let input_summary = summarize_json(&input);
978
979 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
980 }
981 "completed" | "complete" | "done" | "success" => {
982 let tool_name = json
983 .pointer("/tool_call/name")
984 .or_else(|| json.get("name"))
985 .or_else(|| json.get("tool"))
986 .and_then(|v| v.as_str())
987 .unwrap_or("");
988
989 let tool_id = json
990 .pointer("/tool_call/id")
991 .or_else(|| json.get("id"))
992 .or_else(|| json.get("tool_id"))
993 .and_then(|v| v.as_str())
994 .unwrap_or("");
995
996 let success = !json
998 .pointer("/result/is_error")
999 .or_else(|| json.get("is_error"))
1000 .or_else(|| json.get("error"))
1001 .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
1002 .unwrap_or(false);
1003
1004 Some(StreamEvent::new(StreamEventKind::ToolResult {
1005 tool_name: tool_name.to_string(),
1006 tool_id: tool_id.to_string(),
1007 success,
1008 }))
1009 }
1010 "failed" | "error" => {
1011 let tool_name = json
1012 .pointer("/tool_call/name")
1013 .or_else(|| json.get("name"))
1014 .and_then(|v| v.as_str())
1015 .unwrap_or("");
1016
1017 let tool_id = json
1018 .pointer("/tool_call/id")
1019 .or_else(|| json.get("id"))
1020 .and_then(|v| v.as_str())
1021 .unwrap_or("");
1022
1023 Some(StreamEvent::new(StreamEventKind::ToolResult {
1024 tool_name: tool_name.to_string(),
1025 tool_id: tool_id.to_string(),
1026 success: false,
1027 }))
1028 }
1029 _ => None,
1030 }
1031 }
1032
1033 "result" | "done" | "complete" => {
1035 let success = json
1036 .get("success")
1037 .and_then(|v| v.as_bool())
1038 .unwrap_or(true);
1039 Some(StreamEvent::complete(success))
1040 }
1041
1042 "error" => {
1044 let message = json
1045 .get("message")
1046 .or_else(|| json.get("error"))
1047 .and_then(|v| v.as_str())
1048 .unwrap_or("Unknown error");
1049 Some(StreamEvent::error(message))
1050 }
1051
1052 "session" | "session_start" | "init" => {
1054 let session_id = json
1055 .get("session_id")
1056 .or_else(|| json.get("id"))
1057 .and_then(|v| v.as_str())?;
1058 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
1059 session_id: session_id.to_string(),
1060 }))
1061 }
1062
1063 _ => None,
1065 }
1066}
1067
1068fn summarize_json(value: &serde_json::Value) -> String {
1075 match value {
1076 serde_json::Value::Object(obj) => {
1077 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
1078 if keys.is_empty() {
1079 "{}".to_string()
1080 } else if keys.len() < obj.len() {
1081 format!("{{{},...}}", keys.join(", "))
1082 } else {
1083 format!("{{{}}}", keys.join(", "))
1084 }
1085 }
1086 serde_json::Value::String(s) => {
1087 if s.len() > 50 {
1088 format!("\"{}...\"", &s[..47])
1089 } else {
1090 format!("\"{}\"", s)
1091 }
1092 }
1093 serde_json::Value::Null => String::new(),
1094 serde_json::Value::Array(arr) => {
1095 format!("[{} items]", arr.len())
1096 }
1097 other => {
1098 let s = other.to_string();
1099 if s.len() > 50 {
1100 format!("{}...", &s[..47])
1101 } else {
1102 s
1103 }
1104 }
1105 }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1117 fn test_parse_claude_text_delta() {
1118 let line =
1119 r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
1120 let event = parse_claude_event(line);
1121 assert!(matches!(
1122 event,
1123 Some(StreamEvent {
1124 kind: StreamEventKind::TextDelta { ref text },
1125 ..
1126 }) if text == "Hello"
1127 ));
1128 }
1129
1130 #[test]
1131 fn test_parse_claude_tool_use() {
1132 let line =
1133 r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
1134 let event = parse_claude_event(line);
1135 match event {
1136 Some(StreamEvent {
1137 kind: StreamEventKind::ToolStart {
1138 ref tool_name,
1139 ref tool_id,
1140 ref input_summary,
1141 },
1142 ..
1143 }) => {
1144 assert_eq!(tool_name, "Read");
1145 assert_eq!(tool_id, "tool_1");
1146 assert!(input_summary.contains("path"));
1147 }
1148 _ => panic!("Expected ToolStart"),
1149 }
1150 }
1151
1152 #[test]
1153 fn test_parse_claude_error() {
1154 let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
1155 let event = parse_claude_event(line);
1156 match event {
1157 Some(StreamEvent {
1158 kind: StreamEventKind::Error { ref message },
1159 ..
1160 }) => {
1161 assert_eq!(message, "Rate limit exceeded");
1162 }
1163 _ => panic!("Expected Error event"),
1164 }
1165 }
1166
1167 #[test]
1168 fn test_parse_claude_system_init_session() {
1169 let line = r#"{"type":"system","subtype":"init","session_id":"sess-init-123"}"#;
1170 let event = parse_claude_event(line);
1171 match event {
1172 Some(StreamEvent {
1173 kind: StreamEventKind::SessionAssigned { ref session_id },
1174 ..
1175 }) => {
1176 assert_eq!(session_id, "sess-init-123");
1177 }
1178 _ => panic!("Expected SessionAssigned from system init event"),
1179 }
1180 }
1181
1182 #[test]
1183 fn test_parse_claude_result_with_session() {
1184 let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
1185 let event = parse_claude_event(line);
1186 match event {
1187 Some(StreamEvent {
1188 kind: StreamEventKind::SessionAssigned { ref session_id },
1189 ..
1190 }) => {
1191 assert_eq!(session_id, "sess-abc123");
1192 }
1193 _ => panic!("Expected SessionAssigned"),
1194 }
1195 }
1196
1197 #[test]
1198 fn test_parse_claude_result_completion() {
1199 let line = r#"{"type":"result"}"#;
1200 let event = parse_claude_event(line);
1201 assert!(matches!(
1202 event,
1203 Some(StreamEvent {
1204 kind: StreamEventKind::Complete { success: true },
1205 ..
1206 })
1207 ));
1208 }
1209
1210 #[test]
1211 fn test_parse_claude_tool_result() {
1212 let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
1213 let event = parse_claude_event(line);
1214 match event {
1215 Some(StreamEvent {
1216 kind: StreamEventKind::ToolResult {
1217 ref tool_id,
1218 success,
1219 ..
1220 },
1221 ..
1222 }) => {
1223 assert_eq!(tool_id, "tool_1");
1224 assert!(success);
1225 }
1226 _ => panic!("Expected ToolResult"),
1227 }
1228 }
1229
1230 #[test]
1231 fn test_parse_claude_tool_result_error() {
1232 let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
1233 let event = parse_claude_event(line);
1234 match event {
1235 Some(StreamEvent {
1236 kind: StreamEventKind::ToolResult { success, .. },
1237 ..
1238 }) => {
1239 assert!(!success);
1240 }
1241 _ => panic!("Expected ToolResult with failure"),
1242 }
1243 }
1244
1245 #[test]
1246 fn test_parse_claude_unknown_type_returns_none() {
1247 let line = r#"{"type":"unknown_event","data":"test"}"#;
1248 let event = parse_claude_event(line);
1249 assert!(event.is_none());
1250 }
1251
1252 #[test]
1253 fn test_claude_interactive_command() {
1254 let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
1255 let cmd = runner.interactive_command("sess_123");
1256 assert_eq!(cmd[0], "/usr/local/bin/claude");
1257 assert_eq!(cmd[1], "--resume");
1258 assert_eq!(cmd[2], "sess_123");
1259 }
1260
1261 #[test]
1266 fn test_parse_assistant_text_with_message_content() {
1267 let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
1268 let event = parse_opencode_event(line);
1269 assert!(matches!(
1270 event,
1271 Some(StreamEvent {
1272 kind: StreamEventKind::TextDelta { ref text },
1273 ..
1274 }) if text == "Hello world"
1275 ));
1276 }
1277
1278 #[test]
1279 fn test_parse_content_type_with_text() {
1280 let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
1281 let event = parse_opencode_event(line);
1282 assert!(matches!(
1283 event,
1284 Some(StreamEvent {
1285 kind: StreamEventKind::TextDelta { ref text },
1286 ..
1287 }) if text == "Response text"
1288 ));
1289 }
1290
1291 #[test]
1292 fn test_parse_message_type_with_direct_text() {
1293 let line = r#"{"type": "message", "text": "Direct text"}"#;
1294 let event = parse_opencode_event(line);
1295 assert!(matches!(
1296 event,
1297 Some(StreamEvent {
1298 kind: StreamEventKind::TextDelta { ref text },
1299 ..
1300 }) if text == "Direct text"
1301 ));
1302 }
1303
1304 #[test]
1305 fn test_parse_assistant_with_delta_field() {
1306 let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
1307 let event = parse_opencode_event(line);
1308 assert!(matches!(
1309 event,
1310 Some(StreamEvent {
1311 kind: StreamEventKind::TextDelta { ref text },
1312 ..
1313 }) if text == "Streaming chunk"
1314 ));
1315 }
1316
1317 #[test]
1322 fn test_parse_tool_call_started() {
1323 let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
1324 let event = parse_opencode_event(line);
1325 match event {
1326 Some(StreamEvent {
1327 kind:
1328 StreamEventKind::ToolStart {
1329 ref tool_name,
1330 ref tool_id,
1331 ref input_summary,
1332 },
1333 ..
1334 }) => {
1335 assert_eq!(tool_name, "read_file");
1336 assert_eq!(tool_id, "tool_1");
1337 assert!(input_summary.contains("path"));
1338 }
1339 _ => panic!("Expected ToolStart, got {:?}", event),
1340 }
1341 }
1342
1343 #[test]
1344 fn test_parse_tool_use_start() {
1345 let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
1346 let event = parse_opencode_event(line);
1347 match event {
1348 Some(StreamEvent {
1349 kind:
1350 StreamEventKind::ToolStart {
1351 ref tool_name,
1352 ref tool_id,
1353 ..
1354 },
1355 ..
1356 }) => {
1357 assert_eq!(tool_name, "bash");
1358 assert_eq!(tool_id, "t123");
1359 }
1360 _ => panic!("Expected ToolStart"),
1361 }
1362 }
1363
1364 #[test]
1365 fn test_parse_tool_call_completed() {
1366 let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
1367 let event = parse_opencode_event(line);
1368 match event {
1369 Some(StreamEvent {
1370 kind:
1371 StreamEventKind::ToolResult {
1372 ref tool_name,
1373 ref tool_id,
1374 success,
1375 },
1376 ..
1377 }) => {
1378 assert_eq!(tool_name, "write_file");
1379 assert_eq!(tool_id, "t2");
1380 assert!(success);
1381 }
1382 _ => panic!("Expected ToolResult"),
1383 }
1384 }
1385
1386 #[test]
1387 fn test_parse_tool_call_with_error() {
1388 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
1389 let event = parse_opencode_event(line);
1390 match event {
1391 Some(StreamEvent {
1392 kind:
1393 StreamEventKind::ToolResult {
1394 success, ..
1395 },
1396 ..
1397 }) => {
1398 assert!(!success);
1399 }
1400 _ => panic!("Expected ToolResult with failure"),
1401 }
1402 }
1403
1404 #[test]
1405 fn test_parse_tool_call_failed_subtype() {
1406 let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
1407 let event = parse_opencode_event(line);
1408 match event {
1409 Some(StreamEvent {
1410 kind:
1411 StreamEventKind::ToolResult {
1412 success, ..
1413 },
1414 ..
1415 }) => {
1416 assert!(!success);
1417 }
1418 _ => panic!("Expected failed ToolResult"),
1419 }
1420 }
1421
1422 #[test]
1427 fn test_parse_result_success() {
1428 let line = r#"{"type": "result", "success": true}"#;
1429 let event = parse_opencode_event(line);
1430 assert!(matches!(
1431 event,
1432 Some(StreamEvent {
1433 kind: StreamEventKind::Complete { success: true },
1434 ..
1435 })
1436 ));
1437 }
1438
1439 #[test]
1440 fn test_parse_result_failure() {
1441 let line = r#"{"type": "result", "success": false}"#;
1442 let event = parse_opencode_event(line);
1443 assert!(matches!(
1444 event,
1445 Some(StreamEvent {
1446 kind: StreamEventKind::Complete { success: false },
1447 ..
1448 })
1449 ));
1450 }
1451
1452 #[test]
1453 fn test_parse_done_type() {
1454 let line = r#"{"type": "done"}"#;
1455 let event = parse_opencode_event(line);
1456 assert!(matches!(
1457 event,
1458 Some(StreamEvent {
1459 kind: StreamEventKind::Complete { success: true },
1460 ..
1461 })
1462 ));
1463 }
1464
1465 #[test]
1470 fn test_parse_error_with_message() {
1471 let line = r#"{"type": "error", "message": "Connection failed"}"#;
1472 let event = parse_opencode_event(line);
1473 match event {
1474 Some(StreamEvent {
1475 kind: StreamEventKind::Error { ref message },
1476 ..
1477 }) => {
1478 assert_eq!(message, "Connection failed");
1479 }
1480 _ => panic!("Expected Error event"),
1481 }
1482 }
1483
1484 #[test]
1485 fn test_parse_error_with_error_field() {
1486 let line = r#"{"type": "error", "error": "Rate limited"}"#;
1487 let event = parse_opencode_event(line);
1488 match event {
1489 Some(StreamEvent {
1490 kind: StreamEventKind::Error { ref message },
1491 ..
1492 }) => {
1493 assert_eq!(message, "Rate limited");
1494 }
1495 _ => panic!("Expected Error event"),
1496 }
1497 }
1498
1499 #[test]
1504 fn test_parse_session_assignment() {
1505 let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1506 let event = parse_opencode_event(line);
1507 match event {
1508 Some(StreamEvent {
1509 kind: StreamEventKind::SessionAssigned { ref session_id },
1510 ..
1511 }) => {
1512 assert_eq!(session_id, "sess_abc123");
1513 }
1514 _ => panic!("Expected SessionAssigned"),
1515 }
1516 }
1517
1518 #[test]
1519 fn test_parse_session_with_id_field() {
1520 let line = r#"{"type": "init", "id": "session_xyz"}"#;
1521 let event = parse_opencode_event(line);
1522 match event {
1523 Some(StreamEvent {
1524 kind: StreamEventKind::SessionAssigned { ref session_id },
1525 ..
1526 }) => {
1527 assert_eq!(session_id, "session_xyz");
1528 }
1529 _ => panic!("Expected SessionAssigned"),
1530 }
1531 }
1532
1533 #[test]
1538 fn test_parse_unknown_event_returns_none() {
1539 let line = r#"{"type": "custom_event", "data": "something"}"#;
1540 let event = parse_opencode_event(line);
1541 assert!(event.is_none());
1542 }
1543
1544 #[test]
1545 fn test_parse_invalid_json_returns_none() {
1546 let line = "not json at all";
1547 let event = parse_opencode_event(line);
1548 assert!(event.is_none());
1549 }
1550
1551 #[test]
1552 fn test_parse_missing_type_returns_none() {
1553 let line = r#"{"message": "no type field"}"#;
1554 let event = parse_opencode_event(line);
1555 assert!(event.is_none());
1556 }
1557
1558 #[test]
1559 fn test_parse_empty_json_returns_none() {
1560 let line = "{}";
1561 let event = parse_opencode_event(line);
1562 assert!(event.is_none());
1563 }
1564
1565 #[test]
1570 fn test_summarize_json_object() {
1571 let value = serde_json::json!({"path": "/foo", "content": "bar"});
1572 let summary = summarize_json(&value);
1573 assert!(summary.contains("path"));
1574 assert!(summary.contains("content"));
1575 }
1576
1577 #[test]
1578 fn test_summarize_json_object_truncated() {
1579 let value = serde_json::json!({
1580 "key1": "v1",
1581 "key2": "v2",
1582 "key3": "v3",
1583 "key4": "v4"
1584 });
1585 let summary = summarize_json(&value);
1586 assert!(summary.contains("..."));
1587 }
1588
1589 #[test]
1590 fn test_summarize_json_empty_object() {
1591 let value = serde_json::json!({});
1592 let summary = summarize_json(&value);
1593 assert_eq!(summary, "{}");
1594 }
1595
1596 #[test]
1597 fn test_summarize_json_string() {
1598 let value = serde_json::json!("short string");
1599 let summary = summarize_json(&value);
1600 assert_eq!(summary, "\"short string\"");
1601 }
1602
1603 #[test]
1604 fn test_summarize_json_long_string() {
1605 let long = "a".repeat(100);
1606 let value = serde_json::json!(long);
1607 let summary = summarize_json(&value);
1608 assert!(summary.len() < 60);
1609 assert!(summary.ends_with("...\""));
1610 }
1611
1612 #[test]
1613 fn test_summarize_json_null() {
1614 let value = serde_json::Value::Null;
1615 let summary = summarize_json(&value);
1616 assert_eq!(summary, "");
1617 }
1618
1619 #[test]
1620 fn test_summarize_json_array() {
1621 let value = serde_json::json!([1, 2, 3, 4, 5]);
1622 let summary = summarize_json(&value);
1623 assert_eq!(summary, "[5 items]");
1624 }
1625
1626 #[test]
1627 fn test_summarize_json_number() {
1628 let value = serde_json::json!(42);
1629 let summary = summarize_json(&value);
1630 assert_eq!(summary, "42");
1631 }
1632
1633 #[test]
1638 fn test_interactive_command_format() {
1639 let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1640 let cmd = runner.interactive_command("session_123");
1641 assert_eq!(cmd[0], "/usr/local/bin/opencode");
1642 assert_eq!(cmd[1], "attach");
1643 assert!(cmd.contains(&"--session".to_string()));
1644 assert!(cmd.contains(&"session_123".to_string()));
1645 }
1646
1647 #[test]
1652 fn test_opencode_headless_with_binary_path() {
1653 let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1654 assert!(matches!(runner.harness(), Harness::OpenCode));
1656 }
1657
1658 #[test]
1659 fn test_opencode_interactive_command_structure() {
1660 let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1661 let cmd = runner.interactive_command("sess-xyz-789");
1662
1663 assert_eq!(cmd.len(), 5);
1665 assert_eq!(cmd[0], "/bin/opencode");
1666 assert_eq!(cmd[1], "attach");
1667 assert_eq!(cmd[2], "http://localhost:4096");
1668 assert_eq!(cmd[3], "--session");
1669 assert_eq!(cmd[4], "sess-xyz-789");
1670 }
1671
1672 #[test]
1673 fn test_opencode_harness_type() {
1674 let runner = OpenCodeHeadless::with_binary_path("opencode");
1675 assert_eq!(runner.harness(), Harness::OpenCode);
1676 }
1677
1678 #[test]
1683 fn test_claude_headless_with_binary_path() {
1684 let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1685 assert_eq!(runner.binary_path(), "/custom/claude");
1686 assert!(matches!(runner.harness(), Harness::Claude));
1687 }
1688
1689 #[test]
1690 fn test_claude_headless_with_allowed_tools() {
1691 let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1692 .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1693 assert_eq!(runner.binary_path(), "/bin/claude");
1695 }
1696
1697 #[test]
1698 fn test_claude_interactive_command_structure() {
1699 let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1700 let cmd = runner.interactive_command("sess-abc-123");
1701
1702 assert_eq!(cmd.len(), 3);
1704 assert_eq!(cmd[0], "/usr/bin/claude");
1705 assert_eq!(cmd[1], "--resume");
1706 assert_eq!(cmd[2], "sess-abc-123");
1707 }
1708
1709 #[test]
1710 fn test_claude_harness_type() {
1711 let runner = ClaudeHeadless::with_binary_path("claude");
1712 assert_eq!(runner.harness(), Harness::Claude);
1713 }
1714
1715 #[test]
1720 fn test_any_runner_claude_variant() {
1721 let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1722 assert_eq!(runner.harness(), Harness::Claude);
1723
1724 let cmd = runner.interactive_command("session-1");
1725 assert_eq!(cmd[0], "/bin/claude");
1726 assert_eq!(cmd[1], "--resume");
1727 }
1728
1729 #[test]
1730 fn test_any_runner_opencode_variant() {
1731 let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1732 assert_eq!(runner.harness(), Harness::OpenCode);
1733
1734 let cmd = runner.interactive_command("session-2");
1735 assert_eq!(cmd[0], "/bin/opencode");
1736 assert_eq!(cmd[1], "attach");
1737 }
1738
1739 #[test]
1740 fn test_any_runner_harness_matches() {
1741 let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1742 let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1743
1744 assert!(matches!(claude.harness(), Harness::Claude));
1746 assert!(matches!(opencode.harness(), Harness::OpenCode));
1747 }
1748
1749 #[test]
1754 fn test_parse_opencode_tool_with_pending_status() {
1755 let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1756 let event = parse_opencode_event(line);
1757 match event {
1758 Some(StreamEvent {
1759 kind:
1760 StreamEventKind::ToolStart {
1761 ref tool_name,
1762 ref tool_id,
1763 ..
1764 },
1765 ..
1766 }) => {
1767 assert_eq!(tool_name, "write_file");
1768 assert_eq!(tool_id, "t99");
1769 }
1770 _ => panic!("Expected ToolStart for pending status"),
1771 }
1772 }
1773
1774 #[test]
1775 fn test_parse_opencode_tool_done_status() {
1776 let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1777 let event = parse_opencode_event(line);
1778 match event {
1779 Some(StreamEvent {
1780 kind:
1781 StreamEventKind::ToolResult {
1782 ref tool_name,
1783 success,
1784 ..
1785 },
1786 ..
1787 }) => {
1788 assert_eq!(tool_name, "exec");
1789 assert!(success);
1790 }
1791 _ => panic!("Expected ToolResult for done subtype"),
1792 }
1793 }
1794
1795 #[test]
1796 fn test_parse_opencode_tool_success_status() {
1797 let line =
1798 r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1799 let event = parse_opencode_event(line);
1800 match event {
1801 Some(StreamEvent {
1802 kind: StreamEventKind::ToolResult { success, .. },
1803 ..
1804 }) => {
1805 assert!(success);
1806 }
1807 _ => panic!("Expected ToolResult for success subtype"),
1808 }
1809 }
1810
1811 #[test]
1812 fn test_parse_opencode_complete_type() {
1813 let line = r#"{"type": "complete", "success": true}"#;
1814 let event = parse_opencode_event(line);
1815 assert!(matches!(
1816 event,
1817 Some(StreamEvent {
1818 kind: StreamEventKind::Complete { success: true },
1819 ..
1820 })
1821 ));
1822 }
1823
1824 #[test]
1825 fn test_parse_opencode_session_start_type() {
1826 let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1827 let event = parse_opencode_event(line);
1828 match event {
1829 Some(StreamEvent {
1830 kind: StreamEventKind::SessionAssigned { ref session_id },
1831 ..
1832 }) => {
1833 assert_eq!(session_id, "sess-start-001");
1834 }
1835 _ => panic!("Expected SessionAssigned for session_start type"),
1836 }
1837 }
1838
1839 #[test]
1840 fn test_parse_opencode_assistant_with_message_text() {
1841 let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1842 let event = parse_opencode_event(line);
1843 assert!(matches!(
1844 event,
1845 Some(StreamEvent {
1846 kind: StreamEventKind::TextDelta { ref text },
1847 ..
1848 }) if text == "Thinking about this..."
1849 ));
1850 }
1851
1852 #[test]
1853 fn test_parse_opencode_tool_call_error_subtype() {
1854 let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1855 let event = parse_opencode_event(line);
1856 match event {
1857 Some(StreamEvent {
1858 kind:
1859 StreamEventKind::ToolResult {
1860 ref tool_name,
1861 success,
1862 ..
1863 },
1864 ..
1865 }) => {
1866 assert_eq!(tool_name, "git");
1867 assert!(!success);
1868 }
1869 _ => panic!("Expected failed ToolResult for error subtype"),
1870 }
1871 }
1872
1873 #[test]
1874 fn test_parse_opencode_tool_with_nested_input() {
1875 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"}}}"#;
1876 let event = parse_opencode_event(line);
1877 match event {
1878 Some(StreamEvent {
1879 kind:
1880 StreamEventKind::ToolStart {
1881 ref tool_name,
1882 ref input_summary,
1883 ..
1884 },
1885 ..
1886 }) => {
1887 assert_eq!(tool_name, "write_file");
1888 assert!(input_summary.contains("path"));
1890 }
1891 _ => panic!("Expected ToolStart with input summary"),
1892 }
1893 }
1894
1895 #[test]
1896 fn test_parse_opencode_tool_result_with_error_string() {
1897 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1898 let event = parse_opencode_event(line);
1899 match event {
1900 Some(StreamEvent {
1901 kind: StreamEventKind::ToolResult { success, .. },
1902 ..
1903 }) => {
1904 assert!(!success);
1906 }
1907 _ => panic!("Expected failed ToolResult"),
1908 }
1909 }
1910
1911 #[test]
1912 fn test_parse_opencode_unknown_subtype_returns_none() {
1913 let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1914 let event = parse_opencode_event(line);
1915 assert!(event.is_none());
1916 }
1917
1918 #[test]
1923 fn test_parse_cursor_system_init() {
1924 let line = r#"{"type":"system","subtype":"init","session_id":"013608ef-dda7-4b38-9741-54fb0323ce1c","model":"Claude 4.5 Opus"}"#;
1925 let event = parse_cursor_event(line);
1926 match event {
1927 Some(StreamEvent {
1928 kind: StreamEventKind::SessionAssigned { ref session_id },
1929 ..
1930 }) => {
1931 assert_eq!(session_id, "013608ef-dda7-4b38-9741-54fb0323ce1c");
1932 }
1933 _ => panic!("Expected SessionAssigned from system init"),
1934 }
1935 }
1936
1937 #[test]
1938 fn test_parse_cursor_tool_call_started() {
1939 let line = r#"{"type":"tool_call","subtype":"started","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py","streamContent":"print(\"Hello\")\n"}}}}"#;
1940 let event = parse_cursor_event(line);
1941 match event {
1942 Some(StreamEvent {
1943 kind:
1944 StreamEventKind::ToolStart {
1945 ref tool_name,
1946 ref tool_id,
1947 ref input_summary,
1948 },
1949 ..
1950 }) => {
1951 assert_eq!(tool_name, "Edit");
1952 assert_eq!(tool_id, "toolu_123");
1953 assert!(input_summary.contains("path"));
1954 }
1955 _ => panic!("Expected ToolStart, got {:?}", event),
1956 }
1957 }
1958
1959 #[test]
1960 fn test_parse_cursor_tool_call_completed() {
1961 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}}}}}"#;
1962 let event = parse_cursor_event(line);
1963 match event {
1964 Some(StreamEvent {
1965 kind:
1966 StreamEventKind::ToolResult {
1967 ref tool_name,
1968 ref tool_id,
1969 success,
1970 },
1971 ..
1972 }) => {
1973 assert_eq!(tool_name, "Edit");
1974 assert_eq!(tool_id, "toolu_123");
1975 assert!(success);
1976 }
1977 _ => panic!("Expected ToolResult, got {:?}", event),
1978 }
1979 }
1980
1981 #[test]
1982 fn test_parse_cursor_assistant_message() {
1983 let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.py"}]}}"#;
1984 let event = parse_cursor_event(line);
1985 assert!(matches!(
1986 event,
1987 Some(StreamEvent {
1988 kind: StreamEventKind::TextDelta { ref text },
1989 ..
1990 }) if text == "Created hello.py"
1991 ));
1992 }
1993
1994 #[test]
1995 fn test_parse_cursor_result_success() {
1996 let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"Done","session_id":"sess-123"}"#;
1997 let event = parse_cursor_event(line);
1998 assert!(matches!(
1999 event,
2000 Some(StreamEvent {
2001 kind: StreamEventKind::Complete { success: true },
2002 ..
2003 })
2004 ));
2005 }
2006
2007 #[test]
2008 fn test_parse_cursor_result_error() {
2009 let line = r#"{"type":"result","subtype":"error","is_error":true,"result":"Failed"}"#;
2010 let event = parse_cursor_event(line);
2011 assert!(matches!(
2012 event,
2013 Some(StreamEvent {
2014 kind: StreamEventKind::Complete { success: false },
2015 ..
2016 })
2017 ));
2018 }
2019
2020 #[test]
2021 fn test_parse_cursor_user_message_ignored() {
2022 let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}}"#;
2023 let event = parse_cursor_event(line);
2024 assert!(event.is_none());
2025 }
2026
2027 #[test]
2028 fn test_parse_cursor_invalid_json() {
2029 let event = parse_cursor_event("not json");
2030 assert!(event.is_none());
2031 }
2032}