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