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 Harness::Cursor => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
428 }
429 }
430
431 pub async fn start(
433 &self,
434 task_id: &str,
435 prompt: &str,
436 working_dir: &Path,
437 model: Option<&str>,
438 ) -> Result<SessionHandle> {
439 match self {
440 AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
441 AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
442 }
443 }
444
445 pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
447 match self {
448 AnyRunner::Claude(runner) => runner.interactive_command(session_id),
449 AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
450 }
451 }
452
453 pub fn harness(&self) -> Harness {
455 match self {
456 AnyRunner::Claude(runner) => runner.harness(),
457 AnyRunner::OpenCode(runner) => runner.harness(),
458 }
459 }
460}
461
462pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
467 AnyRunner::new(harness)
468}
469
470pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
484 let json: serde_json::Value = serde_json::from_str(line).ok()?;
485
486 let event_type = json.get("type")?.as_str()?;
487
488 match event_type {
489 "assistant" | "message" | "content" => {
491 let text = json
493 .pointer("/message/content/0/text")
494 .or_else(|| json.pointer("/content/0/text"))
495 .or_else(|| json.pointer("/message/text"))
496 .or_else(|| json.get("text"))
497 .or_else(|| json.get("delta"))
498 .and_then(|v| v.as_str())?;
499 Some(StreamEvent::text_delta(text))
500 }
501
502 "tool_call" | "tool_use" => {
504 let subtype = json
505 .get("subtype")
506 .or_else(|| json.get("status"))
507 .and_then(|v| v.as_str())
508 .unwrap_or("started");
509
510 match subtype {
511 "started" | "start" | "pending" => {
512 let tool_name = json
514 .pointer("/tool_call/name")
515 .or_else(|| json.pointer("/tool_call/tool"))
516 .or_else(|| json.get("name"))
517 .or_else(|| json.get("tool"))
518 .and_then(|v| v.as_str())
519 .unwrap_or("unknown");
520
521 let tool_id = json
523 .pointer("/tool_call/id")
524 .or_else(|| json.get("id"))
525 .or_else(|| json.get("tool_id"))
526 .and_then(|v| v.as_str())
527 .unwrap_or("");
528
529 let input = json
531 .pointer("/tool_call/input")
532 .or_else(|| json.get("input"))
533 .cloned()
534 .unwrap_or(serde_json::Value::Null);
535 let input_summary = summarize_json(&input);
536
537 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
538 }
539 "completed" | "complete" | "done" | "success" => {
540 let tool_name = json
541 .pointer("/tool_call/name")
542 .or_else(|| json.get("name"))
543 .or_else(|| json.get("tool"))
544 .and_then(|v| v.as_str())
545 .unwrap_or("");
546
547 let tool_id = json
548 .pointer("/tool_call/id")
549 .or_else(|| json.get("id"))
550 .or_else(|| json.get("tool_id"))
551 .and_then(|v| v.as_str())
552 .unwrap_or("");
553
554 let success = !json
556 .pointer("/result/is_error")
557 .or_else(|| json.get("is_error"))
558 .or_else(|| json.get("error"))
559 .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
560 .unwrap_or(false);
561
562 Some(StreamEvent::new(StreamEventKind::ToolResult {
563 tool_name: tool_name.to_string(),
564 tool_id: tool_id.to_string(),
565 success,
566 }))
567 }
568 "failed" | "error" => {
569 let tool_name = json
570 .pointer("/tool_call/name")
571 .or_else(|| json.get("name"))
572 .and_then(|v| v.as_str())
573 .unwrap_or("");
574
575 let tool_id = json
576 .pointer("/tool_call/id")
577 .or_else(|| json.get("id"))
578 .and_then(|v| v.as_str())
579 .unwrap_or("");
580
581 Some(StreamEvent::new(StreamEventKind::ToolResult {
582 tool_name: tool_name.to_string(),
583 tool_id: tool_id.to_string(),
584 success: false,
585 }))
586 }
587 _ => None,
588 }
589 }
590
591 "result" | "done" | "complete" => {
593 let success = json
594 .get("success")
595 .and_then(|v| v.as_bool())
596 .unwrap_or(true);
597 Some(StreamEvent::complete(success))
598 }
599
600 "error" => {
602 let message = json
603 .get("message")
604 .or_else(|| json.get("error"))
605 .and_then(|v| v.as_str())
606 .unwrap_or("Unknown error");
607 Some(StreamEvent::error(message))
608 }
609
610 "session" | "session_start" | "init" => {
612 let session_id = json
613 .get("session_id")
614 .or_else(|| json.get("id"))
615 .and_then(|v| v.as_str())?;
616 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
617 session_id: session_id.to_string(),
618 }))
619 }
620
621 _ => None,
623 }
624}
625
626fn summarize_json(value: &serde_json::Value) -> String {
633 match value {
634 serde_json::Value::Object(obj) => {
635 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
636 if keys.is_empty() {
637 "{}".to_string()
638 } else if keys.len() < obj.len() {
639 format!("{{{},...}}", keys.join(", "))
640 } else {
641 format!("{{{}}}", keys.join(", "))
642 }
643 }
644 serde_json::Value::String(s) => {
645 if s.len() > 50 {
646 format!("\"{}...\"", &s[..47])
647 } else {
648 format!("\"{}\"", s)
649 }
650 }
651 serde_json::Value::Null => String::new(),
652 serde_json::Value::Array(arr) => {
653 format!("[{} items]", arr.len())
654 }
655 other => {
656 let s = other.to_string();
657 if s.len() > 50 {
658 format!("{}...", &s[..47])
659 } else {
660 s
661 }
662 }
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 #[test]
675 fn test_parse_claude_text_delta() {
676 let line =
677 r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
678 let event = parse_claude_event(line);
679 assert!(matches!(
680 event,
681 Some(StreamEvent {
682 kind: StreamEventKind::TextDelta { ref text },
683 ..
684 }) if text == "Hello"
685 ));
686 }
687
688 #[test]
689 fn test_parse_claude_tool_use() {
690 let line =
691 r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
692 let event = parse_claude_event(line);
693 match event {
694 Some(StreamEvent {
695 kind: StreamEventKind::ToolStart {
696 ref tool_name,
697 ref tool_id,
698 ref input_summary,
699 },
700 ..
701 }) => {
702 assert_eq!(tool_name, "Read");
703 assert_eq!(tool_id, "tool_1");
704 assert!(input_summary.contains("path"));
705 }
706 _ => panic!("Expected ToolStart"),
707 }
708 }
709
710 #[test]
711 fn test_parse_claude_error() {
712 let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
713 let event = parse_claude_event(line);
714 match event {
715 Some(StreamEvent {
716 kind: StreamEventKind::Error { ref message },
717 ..
718 }) => {
719 assert_eq!(message, "Rate limit exceeded");
720 }
721 _ => panic!("Expected Error event"),
722 }
723 }
724
725 #[test]
726 fn test_parse_claude_result_with_session() {
727 let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
728 let event = parse_claude_event(line);
729 match event {
730 Some(StreamEvent {
731 kind: StreamEventKind::SessionAssigned { ref session_id },
732 ..
733 }) => {
734 assert_eq!(session_id, "sess-abc123");
735 }
736 _ => panic!("Expected SessionAssigned"),
737 }
738 }
739
740 #[test]
741 fn test_parse_claude_result_completion() {
742 let line = r#"{"type":"result"}"#;
743 let event = parse_claude_event(line);
744 assert!(matches!(
745 event,
746 Some(StreamEvent {
747 kind: StreamEventKind::Complete { success: true },
748 ..
749 })
750 ));
751 }
752
753 #[test]
754 fn test_parse_claude_tool_result() {
755 let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
756 let event = parse_claude_event(line);
757 match event {
758 Some(StreamEvent {
759 kind: StreamEventKind::ToolResult {
760 ref tool_id,
761 success,
762 ..
763 },
764 ..
765 }) => {
766 assert_eq!(tool_id, "tool_1");
767 assert!(success);
768 }
769 _ => panic!("Expected ToolResult"),
770 }
771 }
772
773 #[test]
774 fn test_parse_claude_tool_result_error() {
775 let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
776 let event = parse_claude_event(line);
777 match event {
778 Some(StreamEvent {
779 kind: StreamEventKind::ToolResult { success, .. },
780 ..
781 }) => {
782 assert!(!success);
783 }
784 _ => panic!("Expected ToolResult with failure"),
785 }
786 }
787
788 #[test]
789 fn test_parse_claude_unknown_type_returns_none() {
790 let line = r#"{"type":"unknown_event","data":"test"}"#;
791 let event = parse_claude_event(line);
792 assert!(event.is_none());
793 }
794
795 #[test]
796 fn test_claude_interactive_command() {
797 let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
798 let cmd = runner.interactive_command("sess_123");
799 assert_eq!(cmd[0], "/usr/local/bin/claude");
800 assert_eq!(cmd[1], "--resume");
801 assert_eq!(cmd[2], "sess_123");
802 }
803
804 #[test]
809 fn test_parse_assistant_text_with_message_content() {
810 let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
811 let event = parse_opencode_event(line);
812 assert!(matches!(
813 event,
814 Some(StreamEvent {
815 kind: StreamEventKind::TextDelta { ref text },
816 ..
817 }) if text == "Hello world"
818 ));
819 }
820
821 #[test]
822 fn test_parse_content_type_with_text() {
823 let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
824 let event = parse_opencode_event(line);
825 assert!(matches!(
826 event,
827 Some(StreamEvent {
828 kind: StreamEventKind::TextDelta { ref text },
829 ..
830 }) if text == "Response text"
831 ));
832 }
833
834 #[test]
835 fn test_parse_message_type_with_direct_text() {
836 let line = r#"{"type": "message", "text": "Direct text"}"#;
837 let event = parse_opencode_event(line);
838 assert!(matches!(
839 event,
840 Some(StreamEvent {
841 kind: StreamEventKind::TextDelta { ref text },
842 ..
843 }) if text == "Direct text"
844 ));
845 }
846
847 #[test]
848 fn test_parse_assistant_with_delta_field() {
849 let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
850 let event = parse_opencode_event(line);
851 assert!(matches!(
852 event,
853 Some(StreamEvent {
854 kind: StreamEventKind::TextDelta { ref text },
855 ..
856 }) if text == "Streaming chunk"
857 ));
858 }
859
860 #[test]
865 fn test_parse_tool_call_started() {
866 let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
867 let event = parse_opencode_event(line);
868 match event {
869 Some(StreamEvent {
870 kind:
871 StreamEventKind::ToolStart {
872 ref tool_name,
873 ref tool_id,
874 ref input_summary,
875 },
876 ..
877 }) => {
878 assert_eq!(tool_name, "read_file");
879 assert_eq!(tool_id, "tool_1");
880 assert!(input_summary.contains("path"));
881 }
882 _ => panic!("Expected ToolStart, got {:?}", event),
883 }
884 }
885
886 #[test]
887 fn test_parse_tool_use_start() {
888 let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
889 let event = parse_opencode_event(line);
890 match event {
891 Some(StreamEvent {
892 kind:
893 StreamEventKind::ToolStart {
894 ref tool_name,
895 ref tool_id,
896 ..
897 },
898 ..
899 }) => {
900 assert_eq!(tool_name, "bash");
901 assert_eq!(tool_id, "t123");
902 }
903 _ => panic!("Expected ToolStart"),
904 }
905 }
906
907 #[test]
908 fn test_parse_tool_call_completed() {
909 let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
910 let event = parse_opencode_event(line);
911 match event {
912 Some(StreamEvent {
913 kind:
914 StreamEventKind::ToolResult {
915 ref tool_name,
916 ref tool_id,
917 success,
918 },
919 ..
920 }) => {
921 assert_eq!(tool_name, "write_file");
922 assert_eq!(tool_id, "t2");
923 assert!(success);
924 }
925 _ => panic!("Expected ToolResult"),
926 }
927 }
928
929 #[test]
930 fn test_parse_tool_call_with_error() {
931 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
932 let event = parse_opencode_event(line);
933 match event {
934 Some(StreamEvent {
935 kind:
936 StreamEventKind::ToolResult {
937 success, ..
938 },
939 ..
940 }) => {
941 assert!(!success);
942 }
943 _ => panic!("Expected ToolResult with failure"),
944 }
945 }
946
947 #[test]
948 fn test_parse_tool_call_failed_subtype() {
949 let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
950 let event = parse_opencode_event(line);
951 match event {
952 Some(StreamEvent {
953 kind:
954 StreamEventKind::ToolResult {
955 success, ..
956 },
957 ..
958 }) => {
959 assert!(!success);
960 }
961 _ => panic!("Expected failed ToolResult"),
962 }
963 }
964
965 #[test]
970 fn test_parse_result_success() {
971 let line = r#"{"type": "result", "success": true}"#;
972 let event = parse_opencode_event(line);
973 assert!(matches!(
974 event,
975 Some(StreamEvent {
976 kind: StreamEventKind::Complete { success: true },
977 ..
978 })
979 ));
980 }
981
982 #[test]
983 fn test_parse_result_failure() {
984 let line = r#"{"type": "result", "success": false}"#;
985 let event = parse_opencode_event(line);
986 assert!(matches!(
987 event,
988 Some(StreamEvent {
989 kind: StreamEventKind::Complete { success: false },
990 ..
991 })
992 ));
993 }
994
995 #[test]
996 fn test_parse_done_type() {
997 let line = r#"{"type": "done"}"#;
998 let event = parse_opencode_event(line);
999 assert!(matches!(
1000 event,
1001 Some(StreamEvent {
1002 kind: StreamEventKind::Complete { success: true },
1003 ..
1004 })
1005 ));
1006 }
1007
1008 #[test]
1013 fn test_parse_error_with_message() {
1014 let line = r#"{"type": "error", "message": "Connection failed"}"#;
1015 let event = parse_opencode_event(line);
1016 match event {
1017 Some(StreamEvent {
1018 kind: StreamEventKind::Error { ref message },
1019 ..
1020 }) => {
1021 assert_eq!(message, "Connection failed");
1022 }
1023 _ => panic!("Expected Error event"),
1024 }
1025 }
1026
1027 #[test]
1028 fn test_parse_error_with_error_field() {
1029 let line = r#"{"type": "error", "error": "Rate limited"}"#;
1030 let event = parse_opencode_event(line);
1031 match event {
1032 Some(StreamEvent {
1033 kind: StreamEventKind::Error { ref message },
1034 ..
1035 }) => {
1036 assert_eq!(message, "Rate limited");
1037 }
1038 _ => panic!("Expected Error event"),
1039 }
1040 }
1041
1042 #[test]
1047 fn test_parse_session_assignment() {
1048 let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1049 let event = parse_opencode_event(line);
1050 match event {
1051 Some(StreamEvent {
1052 kind: StreamEventKind::SessionAssigned { ref session_id },
1053 ..
1054 }) => {
1055 assert_eq!(session_id, "sess_abc123");
1056 }
1057 _ => panic!("Expected SessionAssigned"),
1058 }
1059 }
1060
1061 #[test]
1062 fn test_parse_session_with_id_field() {
1063 let line = r#"{"type": "init", "id": "session_xyz"}"#;
1064 let event = parse_opencode_event(line);
1065 match event {
1066 Some(StreamEvent {
1067 kind: StreamEventKind::SessionAssigned { ref session_id },
1068 ..
1069 }) => {
1070 assert_eq!(session_id, "session_xyz");
1071 }
1072 _ => panic!("Expected SessionAssigned"),
1073 }
1074 }
1075
1076 #[test]
1081 fn test_parse_unknown_event_returns_none() {
1082 let line = r#"{"type": "custom_event", "data": "something"}"#;
1083 let event = parse_opencode_event(line);
1084 assert!(event.is_none());
1085 }
1086
1087 #[test]
1088 fn test_parse_invalid_json_returns_none() {
1089 let line = "not json at all";
1090 let event = parse_opencode_event(line);
1091 assert!(event.is_none());
1092 }
1093
1094 #[test]
1095 fn test_parse_missing_type_returns_none() {
1096 let line = r#"{"message": "no type field"}"#;
1097 let event = parse_opencode_event(line);
1098 assert!(event.is_none());
1099 }
1100
1101 #[test]
1102 fn test_parse_empty_json_returns_none() {
1103 let line = "{}";
1104 let event = parse_opencode_event(line);
1105 assert!(event.is_none());
1106 }
1107
1108 #[test]
1113 fn test_summarize_json_object() {
1114 let value = serde_json::json!({"path": "/foo", "content": "bar"});
1115 let summary = summarize_json(&value);
1116 assert!(summary.contains("path"));
1117 assert!(summary.contains("content"));
1118 }
1119
1120 #[test]
1121 fn test_summarize_json_object_truncated() {
1122 let value = serde_json::json!({
1123 "key1": "v1",
1124 "key2": "v2",
1125 "key3": "v3",
1126 "key4": "v4"
1127 });
1128 let summary = summarize_json(&value);
1129 assert!(summary.contains("..."));
1130 }
1131
1132 #[test]
1133 fn test_summarize_json_empty_object() {
1134 let value = serde_json::json!({});
1135 let summary = summarize_json(&value);
1136 assert_eq!(summary, "{}");
1137 }
1138
1139 #[test]
1140 fn test_summarize_json_string() {
1141 let value = serde_json::json!("short string");
1142 let summary = summarize_json(&value);
1143 assert_eq!(summary, "\"short string\"");
1144 }
1145
1146 #[test]
1147 fn test_summarize_json_long_string() {
1148 let long = "a".repeat(100);
1149 let value = serde_json::json!(long);
1150 let summary = summarize_json(&value);
1151 assert!(summary.len() < 60);
1152 assert!(summary.ends_with("...\""));
1153 }
1154
1155 #[test]
1156 fn test_summarize_json_null() {
1157 let value = serde_json::Value::Null;
1158 let summary = summarize_json(&value);
1159 assert_eq!(summary, "");
1160 }
1161
1162 #[test]
1163 fn test_summarize_json_array() {
1164 let value = serde_json::json!([1, 2, 3, 4, 5]);
1165 let summary = summarize_json(&value);
1166 assert_eq!(summary, "[5 items]");
1167 }
1168
1169 #[test]
1170 fn test_summarize_json_number() {
1171 let value = serde_json::json!(42);
1172 let summary = summarize_json(&value);
1173 assert_eq!(summary, "42");
1174 }
1175
1176 #[test]
1181 fn test_interactive_command_format() {
1182 let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1183 let cmd = runner.interactive_command("session_123");
1184 assert_eq!(cmd[0], "/usr/local/bin/opencode");
1185 assert_eq!(cmd[1], "attach");
1186 assert!(cmd.contains(&"--session".to_string()));
1187 assert!(cmd.contains(&"session_123".to_string()));
1188 }
1189
1190 #[test]
1195 fn test_opencode_headless_with_binary_path() {
1196 let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1197 assert!(matches!(runner.harness(), Harness::OpenCode));
1199 }
1200
1201 #[test]
1202 fn test_opencode_interactive_command_structure() {
1203 let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1204 let cmd = runner.interactive_command("sess-xyz-789");
1205
1206 assert_eq!(cmd.len(), 5);
1208 assert_eq!(cmd[0], "/bin/opencode");
1209 assert_eq!(cmd[1], "attach");
1210 assert_eq!(cmd[2], "http://localhost:4096");
1211 assert_eq!(cmd[3], "--session");
1212 assert_eq!(cmd[4], "sess-xyz-789");
1213 }
1214
1215 #[test]
1216 fn test_opencode_harness_type() {
1217 let runner = OpenCodeHeadless::with_binary_path("opencode");
1218 assert_eq!(runner.harness(), Harness::OpenCode);
1219 }
1220
1221 #[test]
1226 fn test_claude_headless_with_binary_path() {
1227 let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1228 assert_eq!(runner.binary_path(), "/custom/claude");
1229 assert!(matches!(runner.harness(), Harness::Claude));
1230 }
1231
1232 #[test]
1233 fn test_claude_headless_with_allowed_tools() {
1234 let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1235 .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1236 assert_eq!(runner.binary_path(), "/bin/claude");
1238 }
1239
1240 #[test]
1241 fn test_claude_interactive_command_structure() {
1242 let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1243 let cmd = runner.interactive_command("sess-abc-123");
1244
1245 assert_eq!(cmd.len(), 3);
1247 assert_eq!(cmd[0], "/usr/bin/claude");
1248 assert_eq!(cmd[1], "--resume");
1249 assert_eq!(cmd[2], "sess-abc-123");
1250 }
1251
1252 #[test]
1253 fn test_claude_harness_type() {
1254 let runner = ClaudeHeadless::with_binary_path("claude");
1255 assert_eq!(runner.harness(), Harness::Claude);
1256 }
1257
1258 #[test]
1263 fn test_any_runner_claude_variant() {
1264 let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1265 assert_eq!(runner.harness(), Harness::Claude);
1266
1267 let cmd = runner.interactive_command("session-1");
1268 assert_eq!(cmd[0], "/bin/claude");
1269 assert_eq!(cmd[1], "--resume");
1270 }
1271
1272 #[test]
1273 fn test_any_runner_opencode_variant() {
1274 let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1275 assert_eq!(runner.harness(), Harness::OpenCode);
1276
1277 let cmd = runner.interactive_command("session-2");
1278 assert_eq!(cmd[0], "/bin/opencode");
1279 assert_eq!(cmd[1], "attach");
1280 }
1281
1282 #[test]
1283 fn test_any_runner_harness_matches() {
1284 let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1285 let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1286
1287 assert!(matches!(claude.harness(), Harness::Claude));
1289 assert!(matches!(opencode.harness(), Harness::OpenCode));
1290 }
1291
1292 #[test]
1297 fn test_parse_opencode_tool_with_pending_status() {
1298 let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1299 let event = parse_opencode_event(line);
1300 match event {
1301 Some(StreamEvent {
1302 kind:
1303 StreamEventKind::ToolStart {
1304 ref tool_name,
1305 ref tool_id,
1306 ..
1307 },
1308 ..
1309 }) => {
1310 assert_eq!(tool_name, "write_file");
1311 assert_eq!(tool_id, "t99");
1312 }
1313 _ => panic!("Expected ToolStart for pending status"),
1314 }
1315 }
1316
1317 #[test]
1318 fn test_parse_opencode_tool_done_status() {
1319 let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1320 let event = parse_opencode_event(line);
1321 match event {
1322 Some(StreamEvent {
1323 kind:
1324 StreamEventKind::ToolResult {
1325 ref tool_name,
1326 success,
1327 ..
1328 },
1329 ..
1330 }) => {
1331 assert_eq!(tool_name, "exec");
1332 assert!(success);
1333 }
1334 _ => panic!("Expected ToolResult for done subtype"),
1335 }
1336 }
1337
1338 #[test]
1339 fn test_parse_opencode_tool_success_status() {
1340 let line =
1341 r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1342 let event = parse_opencode_event(line);
1343 match event {
1344 Some(StreamEvent {
1345 kind: StreamEventKind::ToolResult { success, .. },
1346 ..
1347 }) => {
1348 assert!(success);
1349 }
1350 _ => panic!("Expected ToolResult for success subtype"),
1351 }
1352 }
1353
1354 #[test]
1355 fn test_parse_opencode_complete_type() {
1356 let line = r#"{"type": "complete", "success": true}"#;
1357 let event = parse_opencode_event(line);
1358 assert!(matches!(
1359 event,
1360 Some(StreamEvent {
1361 kind: StreamEventKind::Complete { success: true },
1362 ..
1363 })
1364 ));
1365 }
1366
1367 #[test]
1368 fn test_parse_opencode_session_start_type() {
1369 let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1370 let event = parse_opencode_event(line);
1371 match event {
1372 Some(StreamEvent {
1373 kind: StreamEventKind::SessionAssigned { ref session_id },
1374 ..
1375 }) => {
1376 assert_eq!(session_id, "sess-start-001");
1377 }
1378 _ => panic!("Expected SessionAssigned for session_start type"),
1379 }
1380 }
1381
1382 #[test]
1383 fn test_parse_opencode_assistant_with_message_text() {
1384 let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1385 let event = parse_opencode_event(line);
1386 assert!(matches!(
1387 event,
1388 Some(StreamEvent {
1389 kind: StreamEventKind::TextDelta { ref text },
1390 ..
1391 }) if text == "Thinking about this..."
1392 ));
1393 }
1394
1395 #[test]
1396 fn test_parse_opencode_tool_call_error_subtype() {
1397 let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1398 let event = parse_opencode_event(line);
1399 match event {
1400 Some(StreamEvent {
1401 kind:
1402 StreamEventKind::ToolResult {
1403 ref tool_name,
1404 success,
1405 ..
1406 },
1407 ..
1408 }) => {
1409 assert_eq!(tool_name, "git");
1410 assert!(!success);
1411 }
1412 _ => panic!("Expected failed ToolResult for error subtype"),
1413 }
1414 }
1415
1416 #[test]
1417 fn test_parse_opencode_tool_with_nested_input() {
1418 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"}}}"#;
1419 let event = parse_opencode_event(line);
1420 match event {
1421 Some(StreamEvent {
1422 kind:
1423 StreamEventKind::ToolStart {
1424 ref tool_name,
1425 ref input_summary,
1426 ..
1427 },
1428 ..
1429 }) => {
1430 assert_eq!(tool_name, "write_file");
1431 assert!(input_summary.contains("path"));
1433 }
1434 _ => panic!("Expected ToolStart with input summary"),
1435 }
1436 }
1437
1438 #[test]
1439 fn test_parse_opencode_tool_result_with_error_string() {
1440 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1441 let event = parse_opencode_event(line);
1442 match event {
1443 Some(StreamEvent {
1444 kind: StreamEventKind::ToolResult { success, .. },
1445 ..
1446 }) => {
1447 assert!(!success);
1449 }
1450 _ => panic!("Expected failed ToolResult"),
1451 }
1452 }
1453
1454 #[test]
1455 fn test_parse_opencode_unknown_subtype_returns_none() {
1456 let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1457 let event = parse_opencode_event(line);
1458 assert!(event.is_none());
1459 }
1460}