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