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;
15
16use super::events::{StreamEvent, StreamEventKind};
17use crate::commands::spawn::terminal::{find_harness_binary, Harness};
18
19pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
21
22pub struct SessionHandle {
24 pub task_id: String,
26 pub session_id: Option<String>,
28 child: Child,
30 pub events: mpsc::Receiver<StreamEvent>,
32}
33
34impl SessionHandle {
35 pub async fn wait(mut self) -> Result<bool> {
37 let status = self.child.wait().await?;
38 Ok(status.success())
39 }
40
41 pub fn interrupt(&mut self) -> Result<()> {
43 #[cfg(unix)]
44 {
45 if let Some(pid) = self.child.id() {
46 let _ = std::process::Command::new("kill")
48 .arg("-INT")
49 .arg(pid.to_string())
50 .status();
51 }
52 }
53
54 #[cfg(not(unix))]
55 {
56 let _ = self.child.start_kill();
58 }
59
60 Ok(())
61 }
62
63 pub fn kill(&mut self) -> Result<()> {
65 self.child.start_kill()?;
66 Ok(())
67 }
68
69 pub fn pid(&self) -> Option<u32> {
71 self.child.id()
72 }
73}
74
75pub trait HeadlessRunner: Send + Sync {
84 fn start<'a>(
89 &'a self,
90 task_id: &'a str,
91 prompt: &'a str,
92 working_dir: &'a Path,
93 model: Option<&'a str>,
94 ) -> BoxFuture<'a, Result<SessionHandle>>;
95
96 fn interactive_command(&self, session_id: &str) -> Vec<String>;
101
102 fn harness(&self) -> Harness;
104}
105
106pub struct ClaudeHeadless {
111 binary_path: String,
112 allowed_tools: Vec<String>,
113}
114
115impl ClaudeHeadless {
116 pub fn new() -> Result<Self> {
120 let binary_path = find_harness_binary(Harness::Claude)?.to_string();
121 Ok(Self {
122 binary_path,
123 allowed_tools: vec![
124 "Read".to_string(),
125 "Write".to_string(),
126 "Edit".to_string(),
127 "Bash".to_string(),
128 "Glob".to_string(),
129 "Grep".to_string(),
130 ],
131 })
132 }
133
134 #[cfg(test)]
136 pub fn with_binary_path(path: impl Into<String>) -> Self {
137 Self {
138 binary_path: path.into(),
139 allowed_tools: vec![],
140 }
141 }
142
143 pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
145 self.allowed_tools = tools;
146 self
147 }
148
149 pub fn binary_path(&self) -> &str {
151 &self.binary_path
152 }
153}
154
155impl HeadlessRunner for ClaudeHeadless {
156 fn start<'a>(
157 &'a self,
158 task_id: &'a str,
159 prompt: &'a str,
160 working_dir: &'a Path,
161 model: Option<&'a str>,
162 ) -> BoxFuture<'a, Result<SessionHandle>> {
163 Box::pin(async move {
164 let mut cmd = Command::new(&self.binary_path);
165
166 cmd.arg("-p").arg(prompt);
168 cmd.arg("--output-format").arg("stream-json");
169 cmd.arg("--verbose");
170 cmd.arg("--dangerously-skip-permissions");
171
172 if let Some(m) = model {
174 cmd.arg("--model").arg(m);
175 }
176
177 if !self.allowed_tools.is_empty() {
179 cmd.arg("--allowedTools")
180 .arg(self.allowed_tools.join(","));
181 }
182
183 cmd.current_dir(working_dir);
185 cmd.env("SCUD_TASK_ID", task_id);
186
187 cmd.stdout(Stdio::piped());
189 cmd.stderr(Stdio::piped());
190
191 let mut child = cmd.spawn()?;
192
193 let (tx, rx) = mpsc::channel(1000);
195
196 let stdout = child.stdout.take().expect("stdout was piped");
198 let task_id_clone = task_id.to_string();
199
200 tokio::spawn(async move {
201 let reader = BufReader::new(stdout);
202 let mut lines = reader.lines();
203
204 while let Ok(Some(line)) = lines.next_line().await {
205 if let Some(event) = parse_claude_event(&line) {
206 if tx.send(event).await.is_err() {
207 break;
208 }
209 }
210 }
211
212 let _ = tx.send(StreamEvent::complete(true)).await;
214 });
215
216 Ok(SessionHandle {
217 task_id: task_id_clone,
218 session_id: None, child,
220 events: rx,
221 })
222 })
223 }
224
225 fn interactive_command(&self, session_id: &str) -> Vec<String> {
226 vec![
227 self.binary_path.clone(),
228 "--resume".to_string(),
229 session_id.to_string(),
230 ]
231 }
232
233 fn harness(&self) -> Harness {
234 Harness::Claude
235 }
236}
237
238fn parse_claude_event(line: &str) -> Option<StreamEvent> {
240 let json: serde_json::Value = serde_json::from_str(line).ok()?;
241
242 let event_type = json.get("type")?.as_str()?;
243
244 match event_type {
245 "stream_event" => {
246 if let Some(delta) = json.pointer("/event/delta") {
248 if delta.get("type")?.as_str()? == "text_delta" {
249 let text = delta.get("text")?.as_str()?;
250 return Some(StreamEvent::text_delta(text));
251 }
252 }
253 None
254 }
255 "assistant" | "content_block_delta" => {
256 if let Some(text) = json.pointer("/delta/text").and_then(|v| v.as_str()) {
258 return Some(StreamEvent::text_delta(text));
259 }
260 if let Some(text) = json.pointer("/content/0/text").and_then(|v| v.as_str()) {
261 return Some(StreamEvent::text_delta(text));
262 }
263 None
264 }
265 "tool_use" => {
266 let tool_name = json.get("name")?.as_str()?;
267 let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
268 let input = json
269 .get("input")
270 .cloned()
271 .unwrap_or(serde_json::Value::Null);
272 let input_summary = summarize_json(&input);
273 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
274 }
275 "tool_result" => {
276 let tool_id = json
277 .get("tool_use_id")
278 .and_then(|v| v.as_str())
279 .unwrap_or("unknown");
280 let success = !json
281 .get("is_error")
282 .and_then(|v| v.as_bool())
283 .unwrap_or(false);
284 Some(StreamEvent::new(StreamEventKind::ToolResult {
285 tool_name: String::new(), tool_id: tool_id.to_string(),
287 success,
288 }))
289 }
290 "result" => {
291 if let Some(session_id) = json.get("session_id").and_then(|v| v.as_str()) {
293 return Some(StreamEvent::new(StreamEventKind::SessionAssigned {
294 session_id: session_id.to_string(),
295 }));
296 }
297 Some(StreamEvent::complete(true))
299 }
300 "error" => {
301 let message = json
302 .get("error")
303 .and_then(|e| e.as_str())
304 .or_else(|| json.get("message").and_then(|e| e.as_str()))
305 .unwrap_or("Unknown error");
306 Some(StreamEvent::error(message))
307 }
308 _ => None,
309 }
310}
311
312pub struct OpenCodeHeadless {
317 binary_path: String,
318}
319
320impl OpenCodeHeadless {
321 pub fn new() -> Result<Self> {
325 let binary_path = find_harness_binary(Harness::OpenCode)?.to_string();
326 Ok(Self { binary_path })
327 }
328
329 #[cfg(test)]
331 pub fn with_binary_path(path: impl Into<String>) -> Self {
332 Self {
333 binary_path: path.into(),
334 }
335 }
336}
337
338impl HeadlessRunner for OpenCodeHeadless {
339 fn start<'a>(
340 &'a self,
341 task_id: &'a str,
342 prompt: &'a str,
343 working_dir: &'a Path,
344 model: Option<&'a str>,
345 ) -> BoxFuture<'a, Result<SessionHandle>> {
346 Box::pin(async move {
347 let mut cmd = Command::new(&self.binary_path);
349
350 cmd.arg("run");
351 cmd.arg("--format").arg("json");
352 cmd.arg("--variant").arg("minimal");
353
354 if let Some(m) = model {
355 cmd.arg("--model").arg(m);
356 }
357
358 cmd.arg(prompt);
359 cmd.current_dir(working_dir);
360 cmd.env("SCUD_TASK_ID", task_id);
361 cmd.stdout(Stdio::piped());
362 cmd.stderr(Stdio::piped());
363
364 let mut child = cmd.spawn()?;
365 let (tx, rx) = mpsc::channel(1000);
366
367 let stdout = child.stdout.take().expect("stdout was piped");
368
369 tokio::spawn(async move {
370 let reader = BufReader::new(stdout);
371 let mut lines = reader.lines();
372
373 while let Ok(Some(line)) = lines.next_line().await {
374 if let Some(event) = parse_opencode_event(&line) {
375 if tx.send(event).await.is_err() {
376 break;
377 }
378 }
379 }
380
381 let _ = tx.send(StreamEvent::complete(true)).await;
382 });
383
384 Ok(SessionHandle {
385 task_id: task_id.to_string(),
386 session_id: None,
387 child,
388 events: rx,
389 })
390 })
391 }
392
393 fn interactive_command(&self, session_id: &str) -> Vec<String> {
394 vec![
396 self.binary_path.clone(),
397 "attach".to_string(),
398 "http://localhost:4096".to_string(),
399 "--session".to_string(),
400 session_id.to_string(),
401 ]
402 }
403
404 fn harness(&self) -> Harness {
405 Harness::OpenCode
406 }
407}
408
409pub enum AnyRunner {
415 Claude(ClaudeHeadless),
416 OpenCode(OpenCodeHeadless),
417}
418
419impl AnyRunner {
420 pub fn new(harness: Harness) -> Result<Self> {
422 match harness {
423 Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
424 Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
425 }
426 }
427
428 pub async fn start(
430 &self,
431 task_id: &str,
432 prompt: &str,
433 working_dir: &Path,
434 model: Option<&str>,
435 ) -> Result<SessionHandle> {
436 match self {
437 AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
438 AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
439 }
440 }
441
442 pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
444 match self {
445 AnyRunner::Claude(runner) => runner.interactive_command(session_id),
446 AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
447 }
448 }
449
450 pub fn harness(&self) -> Harness {
452 match self {
453 AnyRunner::Claude(runner) => runner.harness(),
454 AnyRunner::OpenCode(runner) => runner.harness(),
455 }
456 }
457}
458
459pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
464 AnyRunner::new(harness)
465}
466
467pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
481 let json: serde_json::Value = serde_json::from_str(line).ok()?;
482
483 let event_type = json.get("type")?.as_str()?;
484
485 match event_type {
486 "assistant" | "message" | "content" => {
488 let text = json
490 .pointer("/message/content/0/text")
491 .or_else(|| json.pointer("/content/0/text"))
492 .or_else(|| json.pointer("/message/text"))
493 .or_else(|| json.get("text"))
494 .or_else(|| json.get("delta"))
495 .and_then(|v| v.as_str())?;
496 Some(StreamEvent::text_delta(text))
497 }
498
499 "tool_call" | "tool_use" => {
501 let subtype = json
502 .get("subtype")
503 .or_else(|| json.get("status"))
504 .and_then(|v| v.as_str())
505 .unwrap_or("started");
506
507 match subtype {
508 "started" | "start" | "pending" => {
509 let tool_name = json
511 .pointer("/tool_call/name")
512 .or_else(|| json.pointer("/tool_call/tool"))
513 .or_else(|| json.get("name"))
514 .or_else(|| json.get("tool"))
515 .and_then(|v| v.as_str())
516 .unwrap_or("unknown");
517
518 let tool_id = json
520 .pointer("/tool_call/id")
521 .or_else(|| json.get("id"))
522 .or_else(|| json.get("tool_id"))
523 .and_then(|v| v.as_str())
524 .unwrap_or("");
525
526 let input = json
528 .pointer("/tool_call/input")
529 .or_else(|| json.get("input"))
530 .cloned()
531 .unwrap_or(serde_json::Value::Null);
532 let input_summary = summarize_json(&input);
533
534 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
535 }
536 "completed" | "complete" | "done" | "success" => {
537 let tool_name = json
538 .pointer("/tool_call/name")
539 .or_else(|| json.get("name"))
540 .or_else(|| json.get("tool"))
541 .and_then(|v| v.as_str())
542 .unwrap_or("");
543
544 let tool_id = json
545 .pointer("/tool_call/id")
546 .or_else(|| json.get("id"))
547 .or_else(|| json.get("tool_id"))
548 .and_then(|v| v.as_str())
549 .unwrap_or("");
550
551 let success = !json
553 .pointer("/result/is_error")
554 .or_else(|| json.get("is_error"))
555 .or_else(|| json.get("error"))
556 .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
557 .unwrap_or(false);
558
559 Some(StreamEvent::new(StreamEventKind::ToolResult {
560 tool_name: tool_name.to_string(),
561 tool_id: tool_id.to_string(),
562 success,
563 }))
564 }
565 "failed" | "error" => {
566 let tool_name = json
567 .pointer("/tool_call/name")
568 .or_else(|| json.get("name"))
569 .and_then(|v| v.as_str())
570 .unwrap_or("");
571
572 let tool_id = json
573 .pointer("/tool_call/id")
574 .or_else(|| json.get("id"))
575 .and_then(|v| v.as_str())
576 .unwrap_or("");
577
578 Some(StreamEvent::new(StreamEventKind::ToolResult {
579 tool_name: tool_name.to_string(),
580 tool_id: tool_id.to_string(),
581 success: false,
582 }))
583 }
584 _ => None,
585 }
586 }
587
588 "result" | "done" | "complete" => {
590 let success = json
591 .get("success")
592 .and_then(|v| v.as_bool())
593 .unwrap_or(true);
594 Some(StreamEvent::complete(success))
595 }
596
597 "error" => {
599 let message = json
600 .get("message")
601 .or_else(|| json.get("error"))
602 .and_then(|v| v.as_str())
603 .unwrap_or("Unknown error");
604 Some(StreamEvent::error(message))
605 }
606
607 "session" | "session_start" | "init" => {
609 let session_id = json
610 .get("session_id")
611 .or_else(|| json.get("id"))
612 .and_then(|v| v.as_str())?;
613 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
614 session_id: session_id.to_string(),
615 }))
616 }
617
618 _ => None,
620 }
621}
622
623fn summarize_json(value: &serde_json::Value) -> String {
630 match value {
631 serde_json::Value::Object(obj) => {
632 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
633 if keys.is_empty() {
634 "{}".to_string()
635 } else if keys.len() < obj.len() {
636 format!("{{{},...}}", keys.join(", "))
637 } else {
638 format!("{{{}}}", keys.join(", "))
639 }
640 }
641 serde_json::Value::String(s) => {
642 if s.len() > 50 {
643 format!("\"{}...\"", &s[..47])
644 } else {
645 format!("\"{}\"", s)
646 }
647 }
648 serde_json::Value::Null => String::new(),
649 serde_json::Value::Array(arr) => {
650 format!("[{} items]", arr.len())
651 }
652 other => {
653 let s = other.to_string();
654 if s.len() > 50 {
655 format!("{}...", &s[..47])
656 } else {
657 s
658 }
659 }
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
672 fn test_parse_claude_text_delta() {
673 let line =
674 r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
675 let event = parse_claude_event(line);
676 assert!(matches!(
677 event,
678 Some(StreamEvent {
679 kind: StreamEventKind::TextDelta { ref text },
680 ..
681 }) if text == "Hello"
682 ));
683 }
684
685 #[test]
686 fn test_parse_claude_tool_use() {
687 let line =
688 r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
689 let event = parse_claude_event(line);
690 match event {
691 Some(StreamEvent {
692 kind: StreamEventKind::ToolStart {
693 ref tool_name,
694 ref tool_id,
695 ref input_summary,
696 },
697 ..
698 }) => {
699 assert_eq!(tool_name, "Read");
700 assert_eq!(tool_id, "tool_1");
701 assert!(input_summary.contains("path"));
702 }
703 _ => panic!("Expected ToolStart"),
704 }
705 }
706
707 #[test]
708 fn test_parse_claude_error() {
709 let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
710 let event = parse_claude_event(line);
711 match event {
712 Some(StreamEvent {
713 kind: StreamEventKind::Error { ref message },
714 ..
715 }) => {
716 assert_eq!(message, "Rate limit exceeded");
717 }
718 _ => panic!("Expected Error event"),
719 }
720 }
721
722 #[test]
723 fn test_parse_claude_result_with_session() {
724 let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
725 let event = parse_claude_event(line);
726 match event {
727 Some(StreamEvent {
728 kind: StreamEventKind::SessionAssigned { ref session_id },
729 ..
730 }) => {
731 assert_eq!(session_id, "sess-abc123");
732 }
733 _ => panic!("Expected SessionAssigned"),
734 }
735 }
736
737 #[test]
738 fn test_parse_claude_result_completion() {
739 let line = r#"{"type":"result"}"#;
740 let event = parse_claude_event(line);
741 assert!(matches!(
742 event,
743 Some(StreamEvent {
744 kind: StreamEventKind::Complete { success: true },
745 ..
746 })
747 ));
748 }
749
750 #[test]
751 fn test_parse_claude_tool_result() {
752 let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
753 let event = parse_claude_event(line);
754 match event {
755 Some(StreamEvent {
756 kind: StreamEventKind::ToolResult {
757 ref tool_id,
758 success,
759 ..
760 },
761 ..
762 }) => {
763 assert_eq!(tool_id, "tool_1");
764 assert!(success);
765 }
766 _ => panic!("Expected ToolResult"),
767 }
768 }
769
770 #[test]
771 fn test_parse_claude_tool_result_error() {
772 let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
773 let event = parse_claude_event(line);
774 match event {
775 Some(StreamEvent {
776 kind: StreamEventKind::ToolResult { success, .. },
777 ..
778 }) => {
779 assert!(!success);
780 }
781 _ => panic!("Expected ToolResult with failure"),
782 }
783 }
784
785 #[test]
786 fn test_parse_claude_unknown_type_returns_none() {
787 let line = r#"{"type":"unknown_event","data":"test"}"#;
788 let event = parse_claude_event(line);
789 assert!(event.is_none());
790 }
791
792 #[test]
793 fn test_claude_interactive_command() {
794 let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
795 let cmd = runner.interactive_command("sess_123");
796 assert_eq!(cmd[0], "/usr/local/bin/claude");
797 assert_eq!(cmd[1], "--resume");
798 assert_eq!(cmd[2], "sess_123");
799 }
800
801 #[test]
806 fn test_parse_assistant_text_with_message_content() {
807 let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
808 let event = parse_opencode_event(line);
809 assert!(matches!(
810 event,
811 Some(StreamEvent {
812 kind: StreamEventKind::TextDelta { ref text },
813 ..
814 }) if text == "Hello world"
815 ));
816 }
817
818 #[test]
819 fn test_parse_content_type_with_text() {
820 let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
821 let event = parse_opencode_event(line);
822 assert!(matches!(
823 event,
824 Some(StreamEvent {
825 kind: StreamEventKind::TextDelta { ref text },
826 ..
827 }) if text == "Response text"
828 ));
829 }
830
831 #[test]
832 fn test_parse_message_type_with_direct_text() {
833 let line = r#"{"type": "message", "text": "Direct text"}"#;
834 let event = parse_opencode_event(line);
835 assert!(matches!(
836 event,
837 Some(StreamEvent {
838 kind: StreamEventKind::TextDelta { ref text },
839 ..
840 }) if text == "Direct text"
841 ));
842 }
843
844 #[test]
845 fn test_parse_assistant_with_delta_field() {
846 let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
847 let event = parse_opencode_event(line);
848 assert!(matches!(
849 event,
850 Some(StreamEvent {
851 kind: StreamEventKind::TextDelta { ref text },
852 ..
853 }) if text == "Streaming chunk"
854 ));
855 }
856
857 #[test]
862 fn test_parse_tool_call_started() {
863 let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
864 let event = parse_opencode_event(line);
865 match event {
866 Some(StreamEvent {
867 kind:
868 StreamEventKind::ToolStart {
869 ref tool_name,
870 ref tool_id,
871 ref input_summary,
872 },
873 ..
874 }) => {
875 assert_eq!(tool_name, "read_file");
876 assert_eq!(tool_id, "tool_1");
877 assert!(input_summary.contains("path"));
878 }
879 _ => panic!("Expected ToolStart, got {:?}", event),
880 }
881 }
882
883 #[test]
884 fn test_parse_tool_use_start() {
885 let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
886 let event = parse_opencode_event(line);
887 match event {
888 Some(StreamEvent {
889 kind:
890 StreamEventKind::ToolStart {
891 ref tool_name,
892 ref tool_id,
893 ..
894 },
895 ..
896 }) => {
897 assert_eq!(tool_name, "bash");
898 assert_eq!(tool_id, "t123");
899 }
900 _ => panic!("Expected ToolStart"),
901 }
902 }
903
904 #[test]
905 fn test_parse_tool_call_completed() {
906 let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
907 let event = parse_opencode_event(line);
908 match event {
909 Some(StreamEvent {
910 kind:
911 StreamEventKind::ToolResult {
912 ref tool_name,
913 ref tool_id,
914 success,
915 },
916 ..
917 }) => {
918 assert_eq!(tool_name, "write_file");
919 assert_eq!(tool_id, "t2");
920 assert!(success);
921 }
922 _ => panic!("Expected ToolResult"),
923 }
924 }
925
926 #[test]
927 fn test_parse_tool_call_with_error() {
928 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
929 let event = parse_opencode_event(line);
930 match event {
931 Some(StreamEvent {
932 kind:
933 StreamEventKind::ToolResult {
934 success, ..
935 },
936 ..
937 }) => {
938 assert!(!success);
939 }
940 _ => panic!("Expected ToolResult with failure"),
941 }
942 }
943
944 #[test]
945 fn test_parse_tool_call_failed_subtype() {
946 let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
947 let event = parse_opencode_event(line);
948 match event {
949 Some(StreamEvent {
950 kind:
951 StreamEventKind::ToolResult {
952 success, ..
953 },
954 ..
955 }) => {
956 assert!(!success);
957 }
958 _ => panic!("Expected failed ToolResult"),
959 }
960 }
961
962 #[test]
967 fn test_parse_result_success() {
968 let line = r#"{"type": "result", "success": true}"#;
969 let event = parse_opencode_event(line);
970 assert!(matches!(
971 event,
972 Some(StreamEvent {
973 kind: StreamEventKind::Complete { success: true },
974 ..
975 })
976 ));
977 }
978
979 #[test]
980 fn test_parse_result_failure() {
981 let line = r#"{"type": "result", "success": false}"#;
982 let event = parse_opencode_event(line);
983 assert!(matches!(
984 event,
985 Some(StreamEvent {
986 kind: StreamEventKind::Complete { success: false },
987 ..
988 })
989 ));
990 }
991
992 #[test]
993 fn test_parse_done_type() {
994 let line = r#"{"type": "done"}"#;
995 let event = parse_opencode_event(line);
996 assert!(matches!(
997 event,
998 Some(StreamEvent {
999 kind: StreamEventKind::Complete { success: true },
1000 ..
1001 })
1002 ));
1003 }
1004
1005 #[test]
1010 fn test_parse_error_with_message() {
1011 let line = r#"{"type": "error", "message": "Connection failed"}"#;
1012 let event = parse_opencode_event(line);
1013 match event {
1014 Some(StreamEvent {
1015 kind: StreamEventKind::Error { ref message },
1016 ..
1017 }) => {
1018 assert_eq!(message, "Connection failed");
1019 }
1020 _ => panic!("Expected Error event"),
1021 }
1022 }
1023
1024 #[test]
1025 fn test_parse_error_with_error_field() {
1026 let line = r#"{"type": "error", "error": "Rate limited"}"#;
1027 let event = parse_opencode_event(line);
1028 match event {
1029 Some(StreamEvent {
1030 kind: StreamEventKind::Error { ref message },
1031 ..
1032 }) => {
1033 assert_eq!(message, "Rate limited");
1034 }
1035 _ => panic!("Expected Error event"),
1036 }
1037 }
1038
1039 #[test]
1044 fn test_parse_session_assignment() {
1045 let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1046 let event = parse_opencode_event(line);
1047 match event {
1048 Some(StreamEvent {
1049 kind: StreamEventKind::SessionAssigned { ref session_id },
1050 ..
1051 }) => {
1052 assert_eq!(session_id, "sess_abc123");
1053 }
1054 _ => panic!("Expected SessionAssigned"),
1055 }
1056 }
1057
1058 #[test]
1059 fn test_parse_session_with_id_field() {
1060 let line = r#"{"type": "init", "id": "session_xyz"}"#;
1061 let event = parse_opencode_event(line);
1062 match event {
1063 Some(StreamEvent {
1064 kind: StreamEventKind::SessionAssigned { ref session_id },
1065 ..
1066 }) => {
1067 assert_eq!(session_id, "session_xyz");
1068 }
1069 _ => panic!("Expected SessionAssigned"),
1070 }
1071 }
1072
1073 #[test]
1078 fn test_parse_unknown_event_returns_none() {
1079 let line = r#"{"type": "custom_event", "data": "something"}"#;
1080 let event = parse_opencode_event(line);
1081 assert!(event.is_none());
1082 }
1083
1084 #[test]
1085 fn test_parse_invalid_json_returns_none() {
1086 let line = "not json at all";
1087 let event = parse_opencode_event(line);
1088 assert!(event.is_none());
1089 }
1090
1091 #[test]
1092 fn test_parse_missing_type_returns_none() {
1093 let line = r#"{"message": "no type field"}"#;
1094 let event = parse_opencode_event(line);
1095 assert!(event.is_none());
1096 }
1097
1098 #[test]
1099 fn test_parse_empty_json_returns_none() {
1100 let line = "{}";
1101 let event = parse_opencode_event(line);
1102 assert!(event.is_none());
1103 }
1104
1105 #[test]
1110 fn test_summarize_json_object() {
1111 let value = serde_json::json!({"path": "/foo", "content": "bar"});
1112 let summary = summarize_json(&value);
1113 assert!(summary.contains("path"));
1114 assert!(summary.contains("content"));
1115 }
1116
1117 #[test]
1118 fn test_summarize_json_object_truncated() {
1119 let value = serde_json::json!({
1120 "key1": "v1",
1121 "key2": "v2",
1122 "key3": "v3",
1123 "key4": "v4"
1124 });
1125 let summary = summarize_json(&value);
1126 assert!(summary.contains("..."));
1127 }
1128
1129 #[test]
1130 fn test_summarize_json_empty_object() {
1131 let value = serde_json::json!({});
1132 let summary = summarize_json(&value);
1133 assert_eq!(summary, "{}");
1134 }
1135
1136 #[test]
1137 fn test_summarize_json_string() {
1138 let value = serde_json::json!("short string");
1139 let summary = summarize_json(&value);
1140 assert_eq!(summary, "\"short string\"");
1141 }
1142
1143 #[test]
1144 fn test_summarize_json_long_string() {
1145 let long = "a".repeat(100);
1146 let value = serde_json::json!(long);
1147 let summary = summarize_json(&value);
1148 assert!(summary.len() < 60);
1149 assert!(summary.ends_with("...\""));
1150 }
1151
1152 #[test]
1153 fn test_summarize_json_null() {
1154 let value = serde_json::Value::Null;
1155 let summary = summarize_json(&value);
1156 assert_eq!(summary, "");
1157 }
1158
1159 #[test]
1160 fn test_summarize_json_array() {
1161 let value = serde_json::json!([1, 2, 3, 4, 5]);
1162 let summary = summarize_json(&value);
1163 assert_eq!(summary, "[5 items]");
1164 }
1165
1166 #[test]
1167 fn test_summarize_json_number() {
1168 let value = serde_json::json!(42);
1169 let summary = summarize_json(&value);
1170 assert_eq!(summary, "42");
1171 }
1172
1173 #[test]
1178 fn test_interactive_command_format() {
1179 let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1180 let cmd = runner.interactive_command("session_123");
1181 assert_eq!(cmd[0], "/usr/local/bin/opencode");
1182 assert_eq!(cmd[1], "attach");
1183 assert!(cmd.contains(&"--session".to_string()));
1184 assert!(cmd.contains(&"session_123".to_string()));
1185 }
1186
1187 #[test]
1192 fn test_opencode_headless_with_binary_path() {
1193 let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1194 assert!(matches!(runner.harness(), Harness::OpenCode));
1196 }
1197
1198 #[test]
1199 fn test_opencode_interactive_command_structure() {
1200 let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1201 let cmd = runner.interactive_command("sess-xyz-789");
1202
1203 assert_eq!(cmd.len(), 5);
1205 assert_eq!(cmd[0], "/bin/opencode");
1206 assert_eq!(cmd[1], "attach");
1207 assert_eq!(cmd[2], "http://localhost:4096");
1208 assert_eq!(cmd[3], "--session");
1209 assert_eq!(cmd[4], "sess-xyz-789");
1210 }
1211
1212 #[test]
1213 fn test_opencode_harness_type() {
1214 let runner = OpenCodeHeadless::with_binary_path("opencode");
1215 assert_eq!(runner.harness(), Harness::OpenCode);
1216 }
1217
1218 #[test]
1223 fn test_claude_headless_with_binary_path() {
1224 let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1225 assert_eq!(runner.binary_path(), "/custom/claude");
1226 assert!(matches!(runner.harness(), Harness::Claude));
1227 }
1228
1229 #[test]
1230 fn test_claude_headless_with_allowed_tools() {
1231 let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1232 .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1233 assert_eq!(runner.binary_path(), "/bin/claude");
1235 }
1236
1237 #[test]
1238 fn test_claude_interactive_command_structure() {
1239 let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1240 let cmd = runner.interactive_command("sess-abc-123");
1241
1242 assert_eq!(cmd.len(), 3);
1244 assert_eq!(cmd[0], "/usr/bin/claude");
1245 assert_eq!(cmd[1], "--resume");
1246 assert_eq!(cmd[2], "sess-abc-123");
1247 }
1248
1249 #[test]
1250 fn test_claude_harness_type() {
1251 let runner = ClaudeHeadless::with_binary_path("claude");
1252 assert_eq!(runner.harness(), Harness::Claude);
1253 }
1254
1255 #[test]
1260 fn test_any_runner_claude_variant() {
1261 let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1262 assert_eq!(runner.harness(), Harness::Claude);
1263
1264 let cmd = runner.interactive_command("session-1");
1265 assert_eq!(cmd[0], "/bin/claude");
1266 assert_eq!(cmd[1], "--resume");
1267 }
1268
1269 #[test]
1270 fn test_any_runner_opencode_variant() {
1271 let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1272 assert_eq!(runner.harness(), Harness::OpenCode);
1273
1274 let cmd = runner.interactive_command("session-2");
1275 assert_eq!(cmd[0], "/bin/opencode");
1276 assert_eq!(cmd[1], "attach");
1277 }
1278
1279 #[test]
1280 fn test_any_runner_harness_matches() {
1281 let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1282 let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1283
1284 assert!(matches!(claude.harness(), Harness::Claude));
1286 assert!(matches!(opencode.harness(), Harness::OpenCode));
1287 }
1288
1289 #[test]
1294 fn test_parse_opencode_tool_with_pending_status() {
1295 let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1296 let event = parse_opencode_event(line);
1297 match event {
1298 Some(StreamEvent {
1299 kind:
1300 StreamEventKind::ToolStart {
1301 ref tool_name,
1302 ref tool_id,
1303 ..
1304 },
1305 ..
1306 }) => {
1307 assert_eq!(tool_name, "write_file");
1308 assert_eq!(tool_id, "t99");
1309 }
1310 _ => panic!("Expected ToolStart for pending status"),
1311 }
1312 }
1313
1314 #[test]
1315 fn test_parse_opencode_tool_done_status() {
1316 let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1317 let event = parse_opencode_event(line);
1318 match event {
1319 Some(StreamEvent {
1320 kind:
1321 StreamEventKind::ToolResult {
1322 ref tool_name,
1323 success,
1324 ..
1325 },
1326 ..
1327 }) => {
1328 assert_eq!(tool_name, "exec");
1329 assert!(success);
1330 }
1331 _ => panic!("Expected ToolResult for done subtype"),
1332 }
1333 }
1334
1335 #[test]
1336 fn test_parse_opencode_tool_success_status() {
1337 let line =
1338 r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1339 let event = parse_opencode_event(line);
1340 match event {
1341 Some(StreamEvent {
1342 kind: StreamEventKind::ToolResult { success, .. },
1343 ..
1344 }) => {
1345 assert!(success);
1346 }
1347 _ => panic!("Expected ToolResult for success subtype"),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_parse_opencode_complete_type() {
1353 let line = r#"{"type": "complete", "success": true}"#;
1354 let event = parse_opencode_event(line);
1355 assert!(matches!(
1356 event,
1357 Some(StreamEvent {
1358 kind: StreamEventKind::Complete { success: true },
1359 ..
1360 })
1361 ));
1362 }
1363
1364 #[test]
1365 fn test_parse_opencode_session_start_type() {
1366 let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1367 let event = parse_opencode_event(line);
1368 match event {
1369 Some(StreamEvent {
1370 kind: StreamEventKind::SessionAssigned { ref session_id },
1371 ..
1372 }) => {
1373 assert_eq!(session_id, "sess-start-001");
1374 }
1375 _ => panic!("Expected SessionAssigned for session_start type"),
1376 }
1377 }
1378
1379 #[test]
1380 fn test_parse_opencode_assistant_with_message_text() {
1381 let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1382 let event = parse_opencode_event(line);
1383 assert!(matches!(
1384 event,
1385 Some(StreamEvent {
1386 kind: StreamEventKind::TextDelta { ref text },
1387 ..
1388 }) if text == "Thinking about this..."
1389 ));
1390 }
1391
1392 #[test]
1393 fn test_parse_opencode_tool_call_error_subtype() {
1394 let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1395 let event = parse_opencode_event(line);
1396 match event {
1397 Some(StreamEvent {
1398 kind:
1399 StreamEventKind::ToolResult {
1400 ref tool_name,
1401 success,
1402 ..
1403 },
1404 ..
1405 }) => {
1406 assert_eq!(tool_name, "git");
1407 assert!(!success);
1408 }
1409 _ => panic!("Expected failed ToolResult for error subtype"),
1410 }
1411 }
1412
1413 #[test]
1414 fn test_parse_opencode_tool_with_nested_input() {
1415 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"}}}"#;
1416 let event = parse_opencode_event(line);
1417 match event {
1418 Some(StreamEvent {
1419 kind:
1420 StreamEventKind::ToolStart {
1421 ref tool_name,
1422 ref input_summary,
1423 ..
1424 },
1425 ..
1426 }) => {
1427 assert_eq!(tool_name, "write_file");
1428 assert!(input_summary.contains("path"));
1430 }
1431 _ => panic!("Expected ToolStart with input summary"),
1432 }
1433 }
1434
1435 #[test]
1436 fn test_parse_opencode_tool_result_with_error_string() {
1437 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1438 let event = parse_opencode_event(line);
1439 match event {
1440 Some(StreamEvent {
1441 kind: StreamEventKind::ToolResult { success, .. },
1442 ..
1443 }) => {
1444 assert!(!success);
1446 }
1447 _ => panic!("Expected failed ToolResult"),
1448 }
1449 }
1450
1451 #[test]
1452 fn test_parse_opencode_unknown_subtype_returns_none() {
1453 let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1454 let event = parse_opencode_event(line);
1455 assert!(event.is_none());
1456 }
1457}