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 model: Option<String>,
727}
728
729impl RhoHeadless {
730 pub fn new(model: Option<String>) -> Result<Self> {
732 let binary_path = find_harness_binary(Harness::Rho)?.to_string();
733 Ok(Self {
734 binary_path,
735 model,
736 })
737 }
738
739 #[cfg(test)]
740 pub fn with_binary_path(path: impl Into<String>) -> Self {
741 Self {
742 binary_path: path.into(),
743 model: None,
744 }
745 }
746
747 #[cfg(test)]
748 pub fn binary_path(&self) -> &str {
749 &self.binary_path
750 }
751}
752
753impl HeadlessRunner for RhoHeadless {
754 fn start<'a>(
755 &'a self,
756 task_id: &'a str,
757 prompt: &'a str,
758 working_dir: &'a Path,
759 model: Option<&'a str>,
760 ) -> BoxFuture<'a, Result<SessionHandle>> {
761 Box::pin(async move {
762 let mut cmd = Command::new(&self.binary_path);
763
764 cmd.arg("--output-format").arg("stream-json");
766 cmd.arg("-p").arg(prompt);
767 cmd.arg("-C").arg(working_dir);
768
769 let effective_model = model.or(self.model.as_deref());
771 if let Some(m) = effective_model {
772 cmd.arg("--model").arg(m);
773 }
774
775 cmd.current_dir(working_dir);
776 cmd.env("SCUD_TASK_ID", task_id);
777 cmd.stdout(Stdio::piped());
778 cmd.stderr(Stdio::piped());
779
780 let mut child = cmd.spawn()?;
781 let (tx, rx) = mpsc::channel(1000);
782
783 let stdout = child.stdout.take().expect("stdout was piped");
784 let task_id_for_events = task_id.to_string();
785
786 tokio::spawn(async move {
788 let reader = BufReader::new(stdout);
789 let mut lines = reader.lines();
790
791 while let Ok(Some(line)) = lines.next_line().await {
792 if let Some(event) = parse_rho_event(&line) {
793 trace!(task_id = %task_id_for_events, "rho event: {:?}", event.kind);
794 if tx.send(event).await.is_err() {
795 break;
796 }
797 } else if !line.trim().is_empty() {
798 debug!(task_id = %task_id_for_events, "rho: unparsed line: {}", if line.len() > 200 { &line[..200] } else { &line });
799 }
800 }
801
802 let _ = tx.send(StreamEvent::complete(true)).await;
804 });
805
806 Ok(SessionHandle::from_child(task_id.to_string(), child, rx))
807 })
808 }
809
810 fn interactive_command(&self, session_id: &str) -> Vec<String> {
811 vec![
812 self.binary_path.clone(),
813 "--resume".to_string(),
814 session_id.to_string(),
815 ]
816 }
817
818 fn harness(&self) -> Harness {
819 Harness::Rho
820 }
821}
822
823fn parse_rho_event(line: &str) -> Option<StreamEvent> {
835 let json: serde_json::Value = serde_json::from_str(line).ok()?;
836
837 let event_type = json.get("type")?.as_str()?;
838
839 match event_type {
840 "session" => {
841 let session_id = json.get("session_id").and_then(|v| v.as_str())?;
842 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
843 session_id: session_id.to_string(),
844 }))
845 }
846 "text_delta" => {
847 let text = json.get("text").and_then(|v| v.as_str())?;
848 Some(StreamEvent::text_delta(text))
849 }
850 "tool_start" => {
851 let tool_name = json
852 .get("tool_name")
853 .and_then(|v| v.as_str())
854 .unwrap_or("unknown");
855 let tool_id = json
856 .get("tool_id")
857 .and_then(|v| v.as_str())
858 .unwrap_or("");
859 let input_summary = json
860 .get("input_summary")
861 .and_then(|v| v.as_str())
862 .unwrap_or("");
863 Some(StreamEvent::tool_start(tool_name, tool_id, input_summary))
864 }
865 "tool_result" => {
866 let tool_name = json
867 .get("tool_name")
868 .and_then(|v| v.as_str())
869 .unwrap_or("")
870 .to_string();
871 let tool_id = json
872 .get("tool_id")
873 .and_then(|v| v.as_str())
874 .unwrap_or("")
875 .to_string();
876 let success = json
877 .get("success")
878 .and_then(|v| v.as_bool())
879 .unwrap_or(true);
880 Some(StreamEvent::new(StreamEventKind::ToolResult {
881 tool_name,
882 tool_id,
883 success,
884 }))
885 }
886 "complete" => {
887 let success = json
888 .get("success")
889 .and_then(|v| v.as_bool())
890 .unwrap_or(true);
891 Some(StreamEvent::complete(success))
892 }
893 "error" => {
894 let message = json
895 .get("message")
896 .and_then(|v| v.as_str())
897 .unwrap_or("Unknown error");
898 Some(StreamEvent::error(message))
899 }
900 _ => None,
901 }
902}
903
904pub enum AnyRunner {
910 Claude(ClaudeHeadless),
911 OpenCode(OpenCodeHeadless),
912 Cursor(CursorHeadless),
913 Rho(RhoHeadless),
914 #[cfg(feature = "direct-api")]
915 DirectApi(super::direct_api::DirectApiRunner),
916}
917
918impl AnyRunner {
919 #[cfg(feature = "direct-api")]
921 pub fn new_direct_api(provider: crate::llm::provider::AgentProvider) -> Self {
922 AnyRunner::DirectApi(super::direct_api::DirectApiRunner::new().with_provider(provider))
923 }
924
925 pub fn new(harness: Harness) -> Result<Self> {
927 match harness {
928 Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
929 Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
930 Harness::Cursor => Ok(AnyRunner::Cursor(CursorHeadless::new()?)),
931 Harness::Rho => Ok(AnyRunner::Rho(RhoHeadless::new(None)?)),
932 #[cfg(feature = "direct-api")]
933 Harness::DirectApi => Ok(AnyRunner::DirectApi(
934 super::direct_api::DirectApiRunner::new(),
935 )),
936 }
937 }
938
939 pub async fn start(
941 &self,
942 task_id: &str,
943 prompt: &str,
944 working_dir: &Path,
945 model: Option<&str>,
946 ) -> Result<SessionHandle> {
947 match self {
948 AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
949 AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
950 AnyRunner::Cursor(runner) => runner.start(task_id, prompt, working_dir, model).await,
951 AnyRunner::Rho(runner) => runner.start(task_id, prompt, working_dir, model).await,
952 #[cfg(feature = "direct-api")]
953 AnyRunner::DirectApi(runner) => runner.start(task_id, prompt, working_dir, model).await,
954 }
955 }
956
957 pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
959 match self {
960 AnyRunner::Claude(runner) => runner.interactive_command(session_id),
961 AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
962 AnyRunner::Cursor(runner) => runner.interactive_command(session_id),
963 AnyRunner::Rho(runner) => runner.interactive_command(session_id),
964 #[cfg(feature = "direct-api")]
965 AnyRunner::DirectApi(runner) => runner.interactive_command(session_id),
966 }
967 }
968
969 pub fn harness(&self) -> Harness {
971 match self {
972 AnyRunner::Claude(runner) => runner.harness(),
973 AnyRunner::OpenCode(runner) => runner.harness(),
974 AnyRunner::Cursor(runner) => runner.harness(),
975 AnyRunner::Rho(runner) => runner.harness(),
976 #[cfg(feature = "direct-api")]
977 AnyRunner::DirectApi(runner) => runner.harness(),
978 }
979 }
980}
981
982pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
987 AnyRunner::new(harness)
988}
989
990pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
1004 let json: serde_json::Value = serde_json::from_str(line).ok()?;
1005
1006 let event_type = json.get("type")?.as_str()?;
1007
1008 match event_type {
1009 "assistant" | "message" | "content" => {
1011 let text = json
1013 .pointer("/message/content/0/text")
1014 .or_else(|| json.pointer("/content/0/text"))
1015 .or_else(|| json.pointer("/message/text"))
1016 .or_else(|| json.get("text"))
1017 .or_else(|| json.get("delta"))
1018 .and_then(|v| v.as_str())?;
1019 Some(StreamEvent::text_delta(text))
1020 }
1021
1022 "tool_call" | "tool_use" => {
1024 let subtype = json
1025 .get("subtype")
1026 .or_else(|| json.get("status"))
1027 .and_then(|v| v.as_str())
1028 .unwrap_or("started");
1029
1030 match subtype {
1031 "started" | "start" | "pending" => {
1032 let tool_name = json
1034 .pointer("/tool_call/name")
1035 .or_else(|| json.pointer("/tool_call/tool"))
1036 .or_else(|| json.get("name"))
1037 .or_else(|| json.get("tool"))
1038 .and_then(|v| v.as_str())
1039 .unwrap_or("unknown");
1040
1041 let tool_id = json
1043 .pointer("/tool_call/id")
1044 .or_else(|| json.get("id"))
1045 .or_else(|| json.get("tool_id"))
1046 .and_then(|v| v.as_str())
1047 .unwrap_or("");
1048
1049 let input = json
1051 .pointer("/tool_call/input")
1052 .or_else(|| json.get("input"))
1053 .cloned()
1054 .unwrap_or(serde_json::Value::Null);
1055 let input_summary = summarize_json(&input);
1056
1057 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
1058 }
1059 "completed" | "complete" | "done" | "success" => {
1060 let tool_name = json
1061 .pointer("/tool_call/name")
1062 .or_else(|| json.get("name"))
1063 .or_else(|| json.get("tool"))
1064 .and_then(|v| v.as_str())
1065 .unwrap_or("");
1066
1067 let tool_id = json
1068 .pointer("/tool_call/id")
1069 .or_else(|| json.get("id"))
1070 .or_else(|| json.get("tool_id"))
1071 .and_then(|v| v.as_str())
1072 .unwrap_or("");
1073
1074 let success = !json
1076 .pointer("/result/is_error")
1077 .or_else(|| json.get("is_error"))
1078 .or_else(|| json.get("error"))
1079 .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
1080 .unwrap_or(false);
1081
1082 Some(StreamEvent::new(StreamEventKind::ToolResult {
1083 tool_name: tool_name.to_string(),
1084 tool_id: tool_id.to_string(),
1085 success,
1086 }))
1087 }
1088 "failed" | "error" => {
1089 let tool_name = json
1090 .pointer("/tool_call/name")
1091 .or_else(|| json.get("name"))
1092 .and_then(|v| v.as_str())
1093 .unwrap_or("");
1094
1095 let tool_id = json
1096 .pointer("/tool_call/id")
1097 .or_else(|| json.get("id"))
1098 .and_then(|v| v.as_str())
1099 .unwrap_or("");
1100
1101 Some(StreamEvent::new(StreamEventKind::ToolResult {
1102 tool_name: tool_name.to_string(),
1103 tool_id: tool_id.to_string(),
1104 success: false,
1105 }))
1106 }
1107 _ => None,
1108 }
1109 }
1110
1111 "result" | "done" | "complete" => {
1113 let success = json
1114 .get("success")
1115 .and_then(|v| v.as_bool())
1116 .unwrap_or(true);
1117 Some(StreamEvent::complete(success))
1118 }
1119
1120 "error" => {
1122 let message = json
1123 .get("message")
1124 .or_else(|| json.get("error"))
1125 .and_then(|v| v.as_str())
1126 .unwrap_or("Unknown error");
1127 Some(StreamEvent::error(message))
1128 }
1129
1130 "session" | "session_start" | "init" => {
1132 let session_id = json
1133 .get("session_id")
1134 .or_else(|| json.get("id"))
1135 .and_then(|v| v.as_str())?;
1136 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
1137 session_id: session_id.to_string(),
1138 }))
1139 }
1140
1141 _ => None,
1143 }
1144}
1145
1146fn summarize_json(value: &serde_json::Value) -> String {
1153 match value {
1154 serde_json::Value::Object(obj) => {
1155 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
1156 if keys.is_empty() {
1157 "{}".to_string()
1158 } else if keys.len() < obj.len() {
1159 format!("{{{},...}}", keys.join(", "))
1160 } else {
1161 format!("{{{}}}", keys.join(", "))
1162 }
1163 }
1164 serde_json::Value::String(s) => {
1165 if s.len() > 50 {
1166 format!("\"{}...\"", &s[..47])
1167 } else {
1168 format!("\"{}\"", s)
1169 }
1170 }
1171 serde_json::Value::Null => String::new(),
1172 serde_json::Value::Array(arr) => {
1173 format!("[{} items]", arr.len())
1174 }
1175 other => {
1176 let s = other.to_string();
1177 if s.len() > 50 {
1178 format!("{}...", &s[..47])
1179 } else {
1180 s
1181 }
1182 }
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189
1190 #[test]
1195 fn test_parse_claude_text_delta() {
1196 let line =
1197 r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
1198 let event = parse_claude_event(line);
1199 assert!(matches!(
1200 event,
1201 Some(StreamEvent {
1202 kind: StreamEventKind::TextDelta { ref text },
1203 ..
1204 }) if text == "Hello"
1205 ));
1206 }
1207
1208 #[test]
1209 fn test_parse_claude_tool_use() {
1210 let line =
1211 r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
1212 let event = parse_claude_event(line);
1213 match event {
1214 Some(StreamEvent {
1215 kind:
1216 StreamEventKind::ToolStart {
1217 ref tool_name,
1218 ref tool_id,
1219 ref input_summary,
1220 },
1221 ..
1222 }) => {
1223 assert_eq!(tool_name, "Read");
1224 assert_eq!(tool_id, "tool_1");
1225 assert!(input_summary.contains("path"));
1226 }
1227 _ => panic!("Expected ToolStart"),
1228 }
1229 }
1230
1231 #[test]
1232 fn test_parse_claude_error() {
1233 let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
1234 let event = parse_claude_event(line);
1235 match event {
1236 Some(StreamEvent {
1237 kind: StreamEventKind::Error { ref message },
1238 ..
1239 }) => {
1240 assert_eq!(message, "Rate limit exceeded");
1241 }
1242 _ => panic!("Expected Error event"),
1243 }
1244 }
1245
1246 #[test]
1247 fn test_parse_claude_system_init_session() {
1248 let line = r#"{"type":"system","subtype":"init","session_id":"sess-init-123"}"#;
1249 let event = parse_claude_event(line);
1250 match event {
1251 Some(StreamEvent {
1252 kind: StreamEventKind::SessionAssigned { ref session_id },
1253 ..
1254 }) => {
1255 assert_eq!(session_id, "sess-init-123");
1256 }
1257 _ => panic!("Expected SessionAssigned from system init event"),
1258 }
1259 }
1260
1261 #[test]
1262 fn test_parse_claude_result_with_session() {
1263 let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
1264 let event = parse_claude_event(line);
1265 match event {
1266 Some(StreamEvent {
1267 kind: StreamEventKind::SessionAssigned { ref session_id },
1268 ..
1269 }) => {
1270 assert_eq!(session_id, "sess-abc123");
1271 }
1272 _ => panic!("Expected SessionAssigned"),
1273 }
1274 }
1275
1276 #[test]
1277 fn test_parse_claude_result_completion() {
1278 let line = r#"{"type":"result"}"#;
1279 let event = parse_claude_event(line);
1280 assert!(matches!(
1281 event,
1282 Some(StreamEvent {
1283 kind: StreamEventKind::Complete { success: true },
1284 ..
1285 })
1286 ));
1287 }
1288
1289 #[test]
1290 fn test_parse_claude_tool_result() {
1291 let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
1292 let event = parse_claude_event(line);
1293 match event {
1294 Some(StreamEvent {
1295 kind:
1296 StreamEventKind::ToolResult {
1297 ref tool_id,
1298 success,
1299 ..
1300 },
1301 ..
1302 }) => {
1303 assert_eq!(tool_id, "tool_1");
1304 assert!(success);
1305 }
1306 _ => panic!("Expected ToolResult"),
1307 }
1308 }
1309
1310 #[test]
1311 fn test_parse_claude_tool_result_error() {
1312 let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
1313 let event = parse_claude_event(line);
1314 match event {
1315 Some(StreamEvent {
1316 kind: StreamEventKind::ToolResult { success, .. },
1317 ..
1318 }) => {
1319 assert!(!success);
1320 }
1321 _ => panic!("Expected ToolResult with failure"),
1322 }
1323 }
1324
1325 #[test]
1326 fn test_parse_claude_unknown_type_returns_none() {
1327 let line = r#"{"type":"unknown_event","data":"test"}"#;
1328 let event = parse_claude_event(line);
1329 assert!(event.is_none());
1330 }
1331
1332 #[test]
1333 fn test_claude_interactive_command() {
1334 let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
1335 let cmd = runner.interactive_command("sess_123");
1336 assert_eq!(cmd[0], "/usr/local/bin/claude");
1337 assert_eq!(cmd[1], "--resume");
1338 assert_eq!(cmd[2], "sess_123");
1339 }
1340
1341 #[test]
1346 fn test_parse_assistant_text_with_message_content() {
1347 let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
1348 let event = parse_opencode_event(line);
1349 assert!(matches!(
1350 event,
1351 Some(StreamEvent {
1352 kind: StreamEventKind::TextDelta { ref text },
1353 ..
1354 }) if text == "Hello world"
1355 ));
1356 }
1357
1358 #[test]
1359 fn test_parse_content_type_with_text() {
1360 let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
1361 let event = parse_opencode_event(line);
1362 assert!(matches!(
1363 event,
1364 Some(StreamEvent {
1365 kind: StreamEventKind::TextDelta { ref text },
1366 ..
1367 }) if text == "Response text"
1368 ));
1369 }
1370
1371 #[test]
1372 fn test_parse_message_type_with_direct_text() {
1373 let line = r#"{"type": "message", "text": "Direct text"}"#;
1374 let event = parse_opencode_event(line);
1375 assert!(matches!(
1376 event,
1377 Some(StreamEvent {
1378 kind: StreamEventKind::TextDelta { ref text },
1379 ..
1380 }) if text == "Direct text"
1381 ));
1382 }
1383
1384 #[test]
1385 fn test_parse_assistant_with_delta_field() {
1386 let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
1387 let event = parse_opencode_event(line);
1388 assert!(matches!(
1389 event,
1390 Some(StreamEvent {
1391 kind: StreamEventKind::TextDelta { ref text },
1392 ..
1393 }) if text == "Streaming chunk"
1394 ));
1395 }
1396
1397 #[test]
1402 fn test_parse_tool_call_started() {
1403 let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
1404 let event = parse_opencode_event(line);
1405 match event {
1406 Some(StreamEvent {
1407 kind:
1408 StreamEventKind::ToolStart {
1409 ref tool_name,
1410 ref tool_id,
1411 ref input_summary,
1412 },
1413 ..
1414 }) => {
1415 assert_eq!(tool_name, "read_file");
1416 assert_eq!(tool_id, "tool_1");
1417 assert!(input_summary.contains("path"));
1418 }
1419 _ => panic!("Expected ToolStart, got {:?}", event),
1420 }
1421 }
1422
1423 #[test]
1424 fn test_parse_tool_use_start() {
1425 let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
1426 let event = parse_opencode_event(line);
1427 match event {
1428 Some(StreamEvent {
1429 kind:
1430 StreamEventKind::ToolStart {
1431 ref tool_name,
1432 ref tool_id,
1433 ..
1434 },
1435 ..
1436 }) => {
1437 assert_eq!(tool_name, "bash");
1438 assert_eq!(tool_id, "t123");
1439 }
1440 _ => panic!("Expected ToolStart"),
1441 }
1442 }
1443
1444 #[test]
1445 fn test_parse_tool_call_completed() {
1446 let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
1447 let event = parse_opencode_event(line);
1448 match event {
1449 Some(StreamEvent {
1450 kind:
1451 StreamEventKind::ToolResult {
1452 ref tool_name,
1453 ref tool_id,
1454 success,
1455 },
1456 ..
1457 }) => {
1458 assert_eq!(tool_name, "write_file");
1459 assert_eq!(tool_id, "t2");
1460 assert!(success);
1461 }
1462 _ => panic!("Expected ToolResult"),
1463 }
1464 }
1465
1466 #[test]
1467 fn test_parse_tool_call_with_error() {
1468 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
1469 let event = parse_opencode_event(line);
1470 match event {
1471 Some(StreamEvent {
1472 kind: StreamEventKind::ToolResult { success, .. },
1473 ..
1474 }) => {
1475 assert!(!success);
1476 }
1477 _ => panic!("Expected ToolResult with failure"),
1478 }
1479 }
1480
1481 #[test]
1482 fn test_parse_tool_call_failed_subtype() {
1483 let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
1484 let event = parse_opencode_event(line);
1485 match event {
1486 Some(StreamEvent {
1487 kind: StreamEventKind::ToolResult { success, .. },
1488 ..
1489 }) => {
1490 assert!(!success);
1491 }
1492 _ => panic!("Expected failed ToolResult"),
1493 }
1494 }
1495
1496 #[test]
1501 fn test_parse_result_success() {
1502 let line = r#"{"type": "result", "success": true}"#;
1503 let event = parse_opencode_event(line);
1504 assert!(matches!(
1505 event,
1506 Some(StreamEvent {
1507 kind: StreamEventKind::Complete { success: true },
1508 ..
1509 })
1510 ));
1511 }
1512
1513 #[test]
1514 fn test_parse_result_failure() {
1515 let line = r#"{"type": "result", "success": false}"#;
1516 let event = parse_opencode_event(line);
1517 assert!(matches!(
1518 event,
1519 Some(StreamEvent {
1520 kind: StreamEventKind::Complete { success: false },
1521 ..
1522 })
1523 ));
1524 }
1525
1526 #[test]
1527 fn test_parse_done_type() {
1528 let line = r#"{"type": "done"}"#;
1529 let event = parse_opencode_event(line);
1530 assert!(matches!(
1531 event,
1532 Some(StreamEvent {
1533 kind: StreamEventKind::Complete { success: true },
1534 ..
1535 })
1536 ));
1537 }
1538
1539 #[test]
1544 fn test_parse_error_with_message() {
1545 let line = r#"{"type": "error", "message": "Connection failed"}"#;
1546 let event = parse_opencode_event(line);
1547 match event {
1548 Some(StreamEvent {
1549 kind: StreamEventKind::Error { ref message },
1550 ..
1551 }) => {
1552 assert_eq!(message, "Connection failed");
1553 }
1554 _ => panic!("Expected Error event"),
1555 }
1556 }
1557
1558 #[test]
1559 fn test_parse_error_with_error_field() {
1560 let line = r#"{"type": "error", "error": "Rate limited"}"#;
1561 let event = parse_opencode_event(line);
1562 match event {
1563 Some(StreamEvent {
1564 kind: StreamEventKind::Error { ref message },
1565 ..
1566 }) => {
1567 assert_eq!(message, "Rate limited");
1568 }
1569 _ => panic!("Expected Error event"),
1570 }
1571 }
1572
1573 #[test]
1578 fn test_parse_session_assignment() {
1579 let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1580 let event = parse_opencode_event(line);
1581 match event {
1582 Some(StreamEvent {
1583 kind: StreamEventKind::SessionAssigned { ref session_id },
1584 ..
1585 }) => {
1586 assert_eq!(session_id, "sess_abc123");
1587 }
1588 _ => panic!("Expected SessionAssigned"),
1589 }
1590 }
1591
1592 #[test]
1593 fn test_parse_session_with_id_field() {
1594 let line = r#"{"type": "init", "id": "session_xyz"}"#;
1595 let event = parse_opencode_event(line);
1596 match event {
1597 Some(StreamEvent {
1598 kind: StreamEventKind::SessionAssigned { ref session_id },
1599 ..
1600 }) => {
1601 assert_eq!(session_id, "session_xyz");
1602 }
1603 _ => panic!("Expected SessionAssigned"),
1604 }
1605 }
1606
1607 #[test]
1612 fn test_parse_unknown_event_returns_none() {
1613 let line = r#"{"type": "custom_event", "data": "something"}"#;
1614 let event = parse_opencode_event(line);
1615 assert!(event.is_none());
1616 }
1617
1618 #[test]
1619 fn test_parse_invalid_json_returns_none() {
1620 let line = "not json at all";
1621 let event = parse_opencode_event(line);
1622 assert!(event.is_none());
1623 }
1624
1625 #[test]
1626 fn test_parse_missing_type_returns_none() {
1627 let line = r#"{"message": "no type field"}"#;
1628 let event = parse_opencode_event(line);
1629 assert!(event.is_none());
1630 }
1631
1632 #[test]
1633 fn test_parse_empty_json_returns_none() {
1634 let line = "{}";
1635 let event = parse_opencode_event(line);
1636 assert!(event.is_none());
1637 }
1638
1639 #[test]
1644 fn test_summarize_json_object() {
1645 let value = serde_json::json!({"path": "/foo", "content": "bar"});
1646 let summary = summarize_json(&value);
1647 assert!(summary.contains("path"));
1648 assert!(summary.contains("content"));
1649 }
1650
1651 #[test]
1652 fn test_summarize_json_object_truncated() {
1653 let value = serde_json::json!({
1654 "key1": "v1",
1655 "key2": "v2",
1656 "key3": "v3",
1657 "key4": "v4"
1658 });
1659 let summary = summarize_json(&value);
1660 assert!(summary.contains("..."));
1661 }
1662
1663 #[test]
1664 fn test_summarize_json_empty_object() {
1665 let value = serde_json::json!({});
1666 let summary = summarize_json(&value);
1667 assert_eq!(summary, "{}");
1668 }
1669
1670 #[test]
1671 fn test_summarize_json_string() {
1672 let value = serde_json::json!("short string");
1673 let summary = summarize_json(&value);
1674 assert_eq!(summary, "\"short string\"");
1675 }
1676
1677 #[test]
1678 fn test_summarize_json_long_string() {
1679 let long = "a".repeat(100);
1680 let value = serde_json::json!(long);
1681 let summary = summarize_json(&value);
1682 assert!(summary.len() < 60);
1683 assert!(summary.ends_with("...\""));
1684 }
1685
1686 #[test]
1687 fn test_summarize_json_null() {
1688 let value = serde_json::Value::Null;
1689 let summary = summarize_json(&value);
1690 assert_eq!(summary, "");
1691 }
1692
1693 #[test]
1694 fn test_summarize_json_array() {
1695 let value = serde_json::json!([1, 2, 3, 4, 5]);
1696 let summary = summarize_json(&value);
1697 assert_eq!(summary, "[5 items]");
1698 }
1699
1700 #[test]
1701 fn test_summarize_json_number() {
1702 let value = serde_json::json!(42);
1703 let summary = summarize_json(&value);
1704 assert_eq!(summary, "42");
1705 }
1706
1707 #[test]
1712 fn test_interactive_command_format() {
1713 let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1714 let cmd = runner.interactive_command("session_123");
1715 assert_eq!(cmd[0], "/usr/local/bin/opencode");
1716 assert_eq!(cmd[1], "attach");
1717 assert!(cmd.contains(&"--session".to_string()));
1718 assert!(cmd.contains(&"session_123".to_string()));
1719 }
1720
1721 #[test]
1726 fn test_opencode_headless_with_binary_path() {
1727 let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1728 assert!(matches!(runner.harness(), Harness::OpenCode));
1730 }
1731
1732 #[test]
1733 fn test_opencode_interactive_command_structure() {
1734 let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1735 let cmd = runner.interactive_command("sess-xyz-789");
1736
1737 assert_eq!(cmd.len(), 5);
1739 assert_eq!(cmd[0], "/bin/opencode");
1740 assert_eq!(cmd[1], "attach");
1741 assert_eq!(cmd[2], "http://localhost:4096");
1742 assert_eq!(cmd[3], "--session");
1743 assert_eq!(cmd[4], "sess-xyz-789");
1744 }
1745
1746 #[test]
1747 fn test_opencode_harness_type() {
1748 let runner = OpenCodeHeadless::with_binary_path("opencode");
1749 assert_eq!(runner.harness(), Harness::OpenCode);
1750 }
1751
1752 #[test]
1757 fn test_claude_headless_with_binary_path() {
1758 let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1759 assert_eq!(runner.binary_path(), "/custom/claude");
1760 assert!(matches!(runner.harness(), Harness::Claude));
1761 }
1762
1763 #[test]
1764 fn test_claude_headless_with_allowed_tools() {
1765 let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1766 .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1767 assert_eq!(runner.binary_path(), "/bin/claude");
1769 }
1770
1771 #[test]
1772 fn test_claude_interactive_command_structure() {
1773 let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1774 let cmd = runner.interactive_command("sess-abc-123");
1775
1776 assert_eq!(cmd.len(), 3);
1778 assert_eq!(cmd[0], "/usr/bin/claude");
1779 assert_eq!(cmd[1], "--resume");
1780 assert_eq!(cmd[2], "sess-abc-123");
1781 }
1782
1783 #[test]
1784 fn test_claude_harness_type() {
1785 let runner = ClaudeHeadless::with_binary_path("claude");
1786 assert_eq!(runner.harness(), Harness::Claude);
1787 }
1788
1789 #[test]
1794 fn test_any_runner_claude_variant() {
1795 let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1796 assert_eq!(runner.harness(), Harness::Claude);
1797
1798 let cmd = runner.interactive_command("session-1");
1799 assert_eq!(cmd[0], "/bin/claude");
1800 assert_eq!(cmd[1], "--resume");
1801 }
1802
1803 #[test]
1804 fn test_any_runner_opencode_variant() {
1805 let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1806 assert_eq!(runner.harness(), Harness::OpenCode);
1807
1808 let cmd = runner.interactive_command("session-2");
1809 assert_eq!(cmd[0], "/bin/opencode");
1810 assert_eq!(cmd[1], "attach");
1811 }
1812
1813 #[test]
1814 fn test_any_runner_rho_variant_resume_command() {
1815 let runner = AnyRunner::Rho(RhoHeadless::with_binary_path("/bin/rho-cli"));
1816 assert_eq!(runner.harness(), Harness::Rho);
1817
1818 let cmd = runner.interactive_command("session-rho-1");
1819 assert_eq!(cmd.len(), 3);
1820 assert_eq!(cmd[0], "/bin/rho-cli");
1821 assert_eq!(cmd[1], "--resume");
1822 assert_eq!(cmd[2], "session-rho-1");
1823 }
1824
1825 #[test]
1826 fn test_any_runner_harness_matches() {
1827 let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1828 let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1829
1830 assert!(matches!(claude.harness(), Harness::Claude));
1832 assert!(matches!(opencode.harness(), Harness::OpenCode));
1833 }
1834
1835 #[test]
1840 fn test_parse_opencode_tool_with_pending_status() {
1841 let line =
1842 r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1843 let event = parse_opencode_event(line);
1844 match event {
1845 Some(StreamEvent {
1846 kind:
1847 StreamEventKind::ToolStart {
1848 ref tool_name,
1849 ref tool_id,
1850 ..
1851 },
1852 ..
1853 }) => {
1854 assert_eq!(tool_name, "write_file");
1855 assert_eq!(tool_id, "t99");
1856 }
1857 _ => panic!("Expected ToolStart for pending status"),
1858 }
1859 }
1860
1861 #[test]
1862 fn test_parse_opencode_tool_done_status() {
1863 let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1864 let event = parse_opencode_event(line);
1865 match event {
1866 Some(StreamEvent {
1867 kind:
1868 StreamEventKind::ToolResult {
1869 ref tool_name,
1870 success,
1871 ..
1872 },
1873 ..
1874 }) => {
1875 assert_eq!(tool_name, "exec");
1876 assert!(success);
1877 }
1878 _ => panic!("Expected ToolResult for done subtype"),
1879 }
1880 }
1881
1882 #[test]
1883 fn test_parse_opencode_tool_success_status() {
1884 let line = r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1885 let event = parse_opencode_event(line);
1886 match event {
1887 Some(StreamEvent {
1888 kind: StreamEventKind::ToolResult { success, .. },
1889 ..
1890 }) => {
1891 assert!(success);
1892 }
1893 _ => panic!("Expected ToolResult for success subtype"),
1894 }
1895 }
1896
1897 #[test]
1898 fn test_parse_opencode_complete_type() {
1899 let line = r#"{"type": "complete", "success": true}"#;
1900 let event = parse_opencode_event(line);
1901 assert!(matches!(
1902 event,
1903 Some(StreamEvent {
1904 kind: StreamEventKind::Complete { success: true },
1905 ..
1906 })
1907 ));
1908 }
1909
1910 #[test]
1911 fn test_parse_opencode_session_start_type() {
1912 let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1913 let event = parse_opencode_event(line);
1914 match event {
1915 Some(StreamEvent {
1916 kind: StreamEventKind::SessionAssigned { ref session_id },
1917 ..
1918 }) => {
1919 assert_eq!(session_id, "sess-start-001");
1920 }
1921 _ => panic!("Expected SessionAssigned for session_start type"),
1922 }
1923 }
1924
1925 #[test]
1926 fn test_parse_opencode_assistant_with_message_text() {
1927 let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1928 let event = parse_opencode_event(line);
1929 assert!(matches!(
1930 event,
1931 Some(StreamEvent {
1932 kind: StreamEventKind::TextDelta { ref text },
1933 ..
1934 }) if text == "Thinking about this..."
1935 ));
1936 }
1937
1938 #[test]
1939 fn test_parse_opencode_tool_call_error_subtype() {
1940 let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1941 let event = parse_opencode_event(line);
1942 match event {
1943 Some(StreamEvent {
1944 kind:
1945 StreamEventKind::ToolResult {
1946 ref tool_name,
1947 success,
1948 ..
1949 },
1950 ..
1951 }) => {
1952 assert_eq!(tool_name, "git");
1953 assert!(!success);
1954 }
1955 _ => panic!("Expected failed ToolResult for error subtype"),
1956 }
1957 }
1958
1959 #[test]
1960 fn test_parse_opencode_tool_with_nested_input() {
1961 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"}}}"#;
1962 let event = parse_opencode_event(line);
1963 match event {
1964 Some(StreamEvent {
1965 kind:
1966 StreamEventKind::ToolStart {
1967 ref tool_name,
1968 ref input_summary,
1969 ..
1970 },
1971 ..
1972 }) => {
1973 assert_eq!(tool_name, "write_file");
1974 assert!(input_summary.contains("path"));
1976 }
1977 _ => panic!("Expected ToolStart with input summary"),
1978 }
1979 }
1980
1981 #[test]
1982 fn test_parse_opencode_tool_result_with_error_string() {
1983 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1984 let event = parse_opencode_event(line);
1985 match event {
1986 Some(StreamEvent {
1987 kind: StreamEventKind::ToolResult { success, .. },
1988 ..
1989 }) => {
1990 assert!(!success);
1992 }
1993 _ => panic!("Expected failed ToolResult"),
1994 }
1995 }
1996
1997 #[test]
1998 fn test_parse_opencode_unknown_subtype_returns_none() {
1999 let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
2000 let event = parse_opencode_event(line);
2001 assert!(event.is_none());
2002 }
2003
2004 #[test]
2009 fn test_parse_cursor_system_init() {
2010 let line = r#"{"type":"system","subtype":"init","session_id":"013608ef-dda7-4b38-9741-54fb0323ce1c","model":"Claude 4.5 Opus"}"#;
2011 let event = parse_cursor_event(line);
2012 match event {
2013 Some(StreamEvent {
2014 kind: StreamEventKind::SessionAssigned { ref session_id },
2015 ..
2016 }) => {
2017 assert_eq!(session_id, "013608ef-dda7-4b38-9741-54fb0323ce1c");
2018 }
2019 _ => panic!("Expected SessionAssigned from system init"),
2020 }
2021 }
2022
2023 #[test]
2024 fn test_parse_cursor_tool_call_started() {
2025 let line = r#"{"type":"tool_call","subtype":"started","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py","streamContent":"print(\"Hello\")\n"}}}}"#;
2026 let event = parse_cursor_event(line);
2027 match event {
2028 Some(StreamEvent {
2029 kind:
2030 StreamEventKind::ToolStart {
2031 ref tool_name,
2032 ref tool_id,
2033 ref input_summary,
2034 },
2035 ..
2036 }) => {
2037 assert_eq!(tool_name, "Edit");
2038 assert_eq!(tool_id, "toolu_123");
2039 assert!(input_summary.contains("path"));
2040 }
2041 _ => panic!("Expected ToolStart, got {:?}", event),
2042 }
2043 }
2044
2045 #[test]
2046 fn test_parse_cursor_tool_call_completed() {
2047 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}}}}}"#;
2048 let event = parse_cursor_event(line);
2049 match event {
2050 Some(StreamEvent {
2051 kind:
2052 StreamEventKind::ToolResult {
2053 ref tool_name,
2054 ref tool_id,
2055 success,
2056 },
2057 ..
2058 }) => {
2059 assert_eq!(tool_name, "Edit");
2060 assert_eq!(tool_id, "toolu_123");
2061 assert!(success);
2062 }
2063 _ => panic!("Expected ToolResult, got {:?}", event),
2064 }
2065 }
2066
2067 #[test]
2068 fn test_parse_cursor_assistant_message() {
2069 let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.py"}]}}"#;
2070 let event = parse_cursor_event(line);
2071 assert!(matches!(
2072 event,
2073 Some(StreamEvent {
2074 kind: StreamEventKind::TextDelta { ref text },
2075 ..
2076 }) if text == "Created hello.py"
2077 ));
2078 }
2079
2080 #[test]
2081 fn test_parse_cursor_result_success() {
2082 let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"Done","session_id":"sess-123"}"#;
2083 let event = parse_cursor_event(line);
2084 assert!(matches!(
2085 event,
2086 Some(StreamEvent {
2087 kind: StreamEventKind::Complete { success: true },
2088 ..
2089 })
2090 ));
2091 }
2092
2093 #[test]
2094 fn test_parse_cursor_result_error() {
2095 let line = r#"{"type":"result","subtype":"error","is_error":true,"result":"Failed"}"#;
2096 let event = parse_cursor_event(line);
2097 assert!(matches!(
2098 event,
2099 Some(StreamEvent {
2100 kind: StreamEventKind::Complete { success: false },
2101 ..
2102 })
2103 ));
2104 }
2105
2106 #[test]
2107 fn test_parse_cursor_user_message_ignored() {
2108 let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}}"#;
2109 let event = parse_cursor_event(line);
2110 assert!(event.is_none());
2111 }
2112
2113 #[test]
2114 fn test_parse_cursor_invalid_json() {
2115 let event = parse_cursor_event("not json");
2116 assert!(event.is_none());
2117 }
2118
2119 #[test]
2124 fn test_parse_rho_session() {
2125 let line = r#"{"type":"session","session_id":"abc-123-def"}"#;
2126 let event = parse_rho_event(line);
2127 match event {
2128 Some(StreamEvent {
2129 kind: StreamEventKind::SessionAssigned { ref session_id },
2130 ..
2131 }) => {
2132 assert_eq!(session_id, "abc-123-def");
2133 }
2134 _ => panic!("Expected SessionAssigned"),
2135 }
2136 }
2137
2138 #[test]
2139 fn test_parse_rho_text_delta() {
2140 let line = r#"{"type":"text_delta","text":"Hello world"}"#;
2141 let event = parse_rho_event(line);
2142 assert!(matches!(
2143 event,
2144 Some(StreamEvent {
2145 kind: StreamEventKind::TextDelta { ref text },
2146 ..
2147 }) if text == "Hello world"
2148 ));
2149 }
2150
2151 #[test]
2152 fn test_parse_rho_tool_start() {
2153 let line =
2154 r#"{"type":"tool_start","tool_name":"read","tool_id":"tc_1","input_summary":"src/main.rs"}"#;
2155 let event = parse_rho_event(line);
2156 match event {
2157 Some(StreamEvent {
2158 kind:
2159 StreamEventKind::ToolStart {
2160 ref tool_name,
2161 ref tool_id,
2162 ref input_summary,
2163 },
2164 ..
2165 }) => {
2166 assert_eq!(tool_name, "read");
2167 assert_eq!(tool_id, "tc_1");
2168 assert_eq!(input_summary, "src/main.rs");
2169 }
2170 _ => panic!("Expected ToolStart"),
2171 }
2172 }
2173
2174 #[test]
2175 fn test_parse_rho_tool_result() {
2176 let line = r#"{"type":"tool_result","tool_name":"read","tool_id":"tc_1","success":true}"#;
2177 let event = parse_rho_event(line);
2178 match event {
2179 Some(StreamEvent {
2180 kind:
2181 StreamEventKind::ToolResult {
2182 ref tool_name,
2183 ref tool_id,
2184 success,
2185 },
2186 ..
2187 }) => {
2188 assert_eq!(tool_name, "read");
2189 assert_eq!(tool_id, "tc_1");
2190 assert!(success);
2191 }
2192 _ => panic!("Expected ToolResult"),
2193 }
2194 }
2195
2196 #[test]
2197 fn test_parse_rho_tool_result_failure() {
2198 let line =
2199 r#"{"type":"tool_result","tool_name":"bash","tool_id":"tc_2","success":false}"#;
2200 let event = parse_rho_event(line);
2201 match event {
2202 Some(StreamEvent {
2203 kind: StreamEventKind::ToolResult { success, .. },
2204 ..
2205 }) => {
2206 assert!(!success);
2207 }
2208 _ => panic!("Expected ToolResult"),
2209 }
2210 }
2211
2212 #[test]
2213 fn test_parse_rho_complete() {
2214 let line = r#"{"type":"complete","success":true,"session_id":"abc-123"}"#;
2215 let event = parse_rho_event(line);
2216 assert!(matches!(
2217 event,
2218 Some(StreamEvent {
2219 kind: StreamEventKind::Complete { success: true },
2220 ..
2221 })
2222 ));
2223 }
2224
2225 #[test]
2226 fn test_parse_rho_complete_failure() {
2227 let line = r#"{"type":"complete","success":false}"#;
2228 let event = parse_rho_event(line);
2229 assert!(matches!(
2230 event,
2231 Some(StreamEvent {
2232 kind: StreamEventKind::Complete { success: false },
2233 ..
2234 })
2235 ));
2236 }
2237
2238 #[test]
2239 fn test_parse_rho_error() {
2240 let line = r#"{"type":"error","message":"Rate limit exceeded"}"#;
2241 let event = parse_rho_event(line);
2242 match event {
2243 Some(StreamEvent {
2244 kind: StreamEventKind::Error { ref message },
2245 ..
2246 }) => {
2247 assert_eq!(message, "Rate limit exceeded");
2248 }
2249 _ => panic!("Expected Error event"),
2250 }
2251 }
2252
2253 #[test]
2254 fn test_parse_rho_unknown_type() {
2255 let line = r#"{"type":"unknown_event","data":"something"}"#;
2256 let event = parse_rho_event(line);
2257 assert!(event.is_none());
2258 }
2259
2260 #[test]
2261 fn test_parse_rho_invalid_json() {
2262 let event = parse_rho_event("not json at all");
2263 assert!(event.is_none());
2264 }
2265}