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