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
312fn parse_cursor_event(line: &str) -> Option<StreamEvent> {
324 let json: serde_json::Value = serde_json::from_str(line).ok()?;
325 let event_type = json.get("type")?.as_str()?;
326
327 match event_type {
328 "system" => {
329 let session_id = json.get("session_id").and_then(|v| v.as_str())?;
331 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
332 session_id: session_id.to_string(),
333 }))
334 }
335 "tool_call" => {
336 let subtype = json.get("subtype").and_then(|v| v.as_str()).unwrap_or("started");
337 let call_id = json
338 .get("call_id")
339 .and_then(|v| v.as_str())
340 .unwrap_or("");
341
342 let tool_name = json
344 .get("tool_call")
345 .and_then(|tc| tc.as_object())
346 .and_then(|obj| obj.keys().next())
347 .map(|k| {
348 k.trim_end_matches("ToolCall")
350 .chars()
351 .next()
352 .map(|c| {
353 let mut s = c.to_uppercase().to_string();
354 s.push_str(&k.trim_end_matches("ToolCall")[c.len_utf8()..]);
355 s
356 })
357 .unwrap_or_else(|| k.to_string())
358 })
359 .unwrap_or_else(|| "tool".to_string());
360
361 match subtype {
362 "started" => {
363 let input_summary = json
365 .get("tool_call")
366 .and_then(|tc| tc.as_object())
367 .and_then(|obj| obj.values().next())
368 .and_then(|v| v.get("args"))
369 .map(|args| summarize_json(args))
370 .unwrap_or_default();
371 Some(StreamEvent::tool_start(&tool_name, call_id, &input_summary))
372 }
373 "completed" => {
374 let success = json
375 .get("tool_call")
376 .and_then(|tc| tc.as_object())
377 .and_then(|obj| obj.values().next())
378 .and_then(|v| v.get("result"))
379 .map(|r| r.get("success").is_some())
380 .unwrap_or(true);
381 Some(StreamEvent::new(StreamEventKind::ToolResult {
382 tool_name,
383 tool_id: call_id.to_string(),
384 success,
385 }))
386 }
387 _ => None,
388 }
389 }
390 "assistant" => {
391 let text = json
392 .pointer("/message/content/0/text")
393 .and_then(|v| v.as_str())?;
394 Some(StreamEvent::text_delta(text))
395 }
396 "result" => {
397 let is_error = json
398 .get("is_error")
399 .and_then(|v| v.as_bool())
400 .unwrap_or(false);
401 Some(StreamEvent::complete(!is_error))
402 }
403 _ => None,
405 }
406}
407
408pub struct OpenCodeHeadless {
413 binary_path: String,
414}
415
416impl OpenCodeHeadless {
417 pub fn new() -> Result<Self> {
421 let binary_path = find_harness_binary(Harness::OpenCode)?.to_string();
422 Ok(Self { binary_path })
423 }
424
425 #[cfg(test)]
427 pub fn with_binary_path(path: impl Into<String>) -> Self {
428 Self {
429 binary_path: path.into(),
430 }
431 }
432}
433
434impl HeadlessRunner for OpenCodeHeadless {
435 fn start<'a>(
436 &'a self,
437 task_id: &'a str,
438 prompt: &'a str,
439 working_dir: &'a Path,
440 model: Option<&'a str>,
441 ) -> BoxFuture<'a, Result<SessionHandle>> {
442 Box::pin(async move {
443 let mut cmd = Command::new(&self.binary_path);
445
446 cmd.arg("run");
447 cmd.arg("--format").arg("json");
448 cmd.arg("--variant").arg("minimal");
449
450 if let Some(m) = model {
451 cmd.arg("--model").arg(m);
452 }
453
454 cmd.arg(prompt);
455 cmd.current_dir(working_dir);
456 cmd.env("SCUD_TASK_ID", task_id);
457 cmd.stdout(Stdio::piped());
458 cmd.stderr(Stdio::piped());
459
460 let mut child = cmd.spawn()?;
461 let (tx, rx) = mpsc::channel(1000);
462
463 let stdout = child.stdout.take().expect("stdout was piped");
464
465 tokio::spawn(async move {
466 let reader = BufReader::new(stdout);
467 let mut lines = reader.lines();
468
469 while let Ok(Some(line)) = lines.next_line().await {
470 if let Some(event) = parse_opencode_event(&line) {
471 if tx.send(event).await.is_err() {
472 break;
473 }
474 }
475 }
476
477 let _ = tx.send(StreamEvent::complete(true)).await;
478 });
479
480 Ok(SessionHandle {
481 task_id: task_id.to_string(),
482 session_id: None,
483 child,
484 events: rx,
485 })
486 })
487 }
488
489 fn interactive_command(&self, session_id: &str) -> Vec<String> {
490 vec![
492 self.binary_path.clone(),
493 "attach".to_string(),
494 "http://localhost:4096".to_string(),
495 "--session".to_string(),
496 session_id.to_string(),
497 ]
498 }
499
500 fn harness(&self) -> Harness {
501 Harness::OpenCode
502 }
503}
504
505pub struct CursorHeadless {
510 binary_path: String,
511}
512
513impl CursorHeadless {
514 pub fn new() -> Result<Self> {
516 let binary_path = find_harness_binary(Harness::Cursor)?.to_string();
517 Ok(Self { binary_path })
518 }
519}
520
521impl HeadlessRunner for CursorHeadless {
522 fn start<'a>(
523 &'a self,
524 task_id: &'a str,
525 prompt: &'a str,
526 working_dir: &'a Path,
527 model: Option<&'a str>,
528 ) -> BoxFuture<'a, Result<SessionHandle>> {
529 Box::pin(async move {
530 let mut cmd = Command::new(&self.binary_path);
531
532 cmd.arg("-p");
533
534 if let Some(m) = model {
535 cmd.arg("--model").arg(m);
536 }
537
538 cmd.arg("--output-format").arg("stream-json");
540 cmd.arg(prompt);
541 cmd.current_dir(working_dir);
542 cmd.env("SCUD_TASK_ID", task_id);
543 cmd.stdout(Stdio::piped());
544 cmd.stderr(Stdio::piped());
545
546 let mut child = cmd.spawn()?;
547 let (tx, rx) = mpsc::channel(1000);
548
549 let stdout = child.stdout.take().expect("stdout was piped");
550
551 tokio::spawn(async move {
552 let reader = BufReader::new(stdout);
553 let mut lines = reader.lines();
554
555 while let Ok(Some(line)) = lines.next_line().await {
556 if let Some(event) = parse_cursor_event(&line) {
558 if tx.send(event).await.is_err() {
559 break;
560 }
561 } else if !line.trim().is_empty()
562 && serde_json::from_str::<serde_json::Value>(&line).is_err()
563 {
564 let _ = tx.send(StreamEvent::text_delta(&format!("{}\n", line))).await;
567 }
568 }
569
570 let _ = tx.send(StreamEvent::complete(true)).await;
571 });
572
573 Ok(SessionHandle {
574 task_id: task_id.to_string(),
575 session_id: None,
576 child,
577 events: rx,
578 })
579 })
580 }
581
582 fn interactive_command(&self, session_id: &str) -> Vec<String> {
583 vec![
584 self.binary_path.clone(),
585 "--resume".to_string(),
586 session_id.to_string(),
587 ]
588 }
589
590 fn harness(&self) -> Harness {
591 Harness::Cursor
592 }
593}
594
595pub enum AnyRunner {
601 Claude(ClaudeHeadless),
602 OpenCode(OpenCodeHeadless),
603 Cursor(CursorHeadless),
604}
605
606impl AnyRunner {
607 pub fn new(harness: Harness) -> Result<Self> {
609 match harness {
610 Harness::Claude => Ok(AnyRunner::Claude(ClaudeHeadless::new()?)),
611 Harness::OpenCode => Ok(AnyRunner::OpenCode(OpenCodeHeadless::new()?)),
612 Harness::Cursor => Ok(AnyRunner::Cursor(CursorHeadless::new()?)),
613 }
614 }
615
616 pub async fn start(
618 &self,
619 task_id: &str,
620 prompt: &str,
621 working_dir: &Path,
622 model: Option<&str>,
623 ) -> Result<SessionHandle> {
624 match self {
625 AnyRunner::Claude(runner) => runner.start(task_id, prompt, working_dir, model).await,
626 AnyRunner::OpenCode(runner) => runner.start(task_id, prompt, working_dir, model).await,
627 AnyRunner::Cursor(runner) => runner.start(task_id, prompt, working_dir, model).await,
628 }
629 }
630
631 pub fn interactive_command(&self, session_id: &str) -> Vec<String> {
633 match self {
634 AnyRunner::Claude(runner) => runner.interactive_command(session_id),
635 AnyRunner::OpenCode(runner) => runner.interactive_command(session_id),
636 AnyRunner::Cursor(runner) => runner.interactive_command(session_id),
637 }
638 }
639
640 pub fn harness(&self) -> Harness {
642 match self {
643 AnyRunner::Claude(runner) => runner.harness(),
644 AnyRunner::OpenCode(runner) => runner.harness(),
645 AnyRunner::Cursor(runner) => runner.harness(),
646 }
647 }
648}
649
650pub fn create_runner(harness: Harness) -> Result<AnyRunner> {
655 AnyRunner::new(harness)
656}
657
658pub fn parse_opencode_event(line: &str) -> Option<StreamEvent> {
672 let json: serde_json::Value = serde_json::from_str(line).ok()?;
673
674 let event_type = json.get("type")?.as_str()?;
675
676 match event_type {
677 "assistant" | "message" | "content" => {
679 let text = json
681 .pointer("/message/content/0/text")
682 .or_else(|| json.pointer("/content/0/text"))
683 .or_else(|| json.pointer("/message/text"))
684 .or_else(|| json.get("text"))
685 .or_else(|| json.get("delta"))
686 .and_then(|v| v.as_str())?;
687 Some(StreamEvent::text_delta(text))
688 }
689
690 "tool_call" | "tool_use" => {
692 let subtype = json
693 .get("subtype")
694 .or_else(|| json.get("status"))
695 .and_then(|v| v.as_str())
696 .unwrap_or("started");
697
698 match subtype {
699 "started" | "start" | "pending" => {
700 let tool_name = json
702 .pointer("/tool_call/name")
703 .or_else(|| json.pointer("/tool_call/tool"))
704 .or_else(|| json.get("name"))
705 .or_else(|| json.get("tool"))
706 .and_then(|v| v.as_str())
707 .unwrap_or("unknown");
708
709 let tool_id = json
711 .pointer("/tool_call/id")
712 .or_else(|| json.get("id"))
713 .or_else(|| json.get("tool_id"))
714 .and_then(|v| v.as_str())
715 .unwrap_or("");
716
717 let input = json
719 .pointer("/tool_call/input")
720 .or_else(|| json.get("input"))
721 .cloned()
722 .unwrap_or(serde_json::Value::Null);
723 let input_summary = summarize_json(&input);
724
725 Some(StreamEvent::tool_start(tool_name, tool_id, &input_summary))
726 }
727 "completed" | "complete" | "done" | "success" => {
728 let tool_name = json
729 .pointer("/tool_call/name")
730 .or_else(|| json.get("name"))
731 .or_else(|| json.get("tool"))
732 .and_then(|v| v.as_str())
733 .unwrap_or("");
734
735 let tool_id = json
736 .pointer("/tool_call/id")
737 .or_else(|| json.get("id"))
738 .or_else(|| json.get("tool_id"))
739 .and_then(|v| v.as_str())
740 .unwrap_or("");
741
742 let success = !json
744 .pointer("/result/is_error")
745 .or_else(|| json.get("is_error"))
746 .or_else(|| json.get("error"))
747 .map(|v| v.as_bool().unwrap_or(false) || v.is_string())
748 .unwrap_or(false);
749
750 Some(StreamEvent::new(StreamEventKind::ToolResult {
751 tool_name: tool_name.to_string(),
752 tool_id: tool_id.to_string(),
753 success,
754 }))
755 }
756 "failed" | "error" => {
757 let tool_name = json
758 .pointer("/tool_call/name")
759 .or_else(|| json.get("name"))
760 .and_then(|v| v.as_str())
761 .unwrap_or("");
762
763 let tool_id = json
764 .pointer("/tool_call/id")
765 .or_else(|| json.get("id"))
766 .and_then(|v| v.as_str())
767 .unwrap_or("");
768
769 Some(StreamEvent::new(StreamEventKind::ToolResult {
770 tool_name: tool_name.to_string(),
771 tool_id: tool_id.to_string(),
772 success: false,
773 }))
774 }
775 _ => None,
776 }
777 }
778
779 "result" | "done" | "complete" => {
781 let success = json
782 .get("success")
783 .and_then(|v| v.as_bool())
784 .unwrap_or(true);
785 Some(StreamEvent::complete(success))
786 }
787
788 "error" => {
790 let message = json
791 .get("message")
792 .or_else(|| json.get("error"))
793 .and_then(|v| v.as_str())
794 .unwrap_or("Unknown error");
795 Some(StreamEvent::error(message))
796 }
797
798 "session" | "session_start" | "init" => {
800 let session_id = json
801 .get("session_id")
802 .or_else(|| json.get("id"))
803 .and_then(|v| v.as_str())?;
804 Some(StreamEvent::new(StreamEventKind::SessionAssigned {
805 session_id: session_id.to_string(),
806 }))
807 }
808
809 _ => None,
811 }
812}
813
814fn summarize_json(value: &serde_json::Value) -> String {
821 match value {
822 serde_json::Value::Object(obj) => {
823 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(3).collect();
824 if keys.is_empty() {
825 "{}".to_string()
826 } else if keys.len() < obj.len() {
827 format!("{{{},...}}", keys.join(", "))
828 } else {
829 format!("{{{}}}", keys.join(", "))
830 }
831 }
832 serde_json::Value::String(s) => {
833 if s.len() > 50 {
834 format!("\"{}...\"", &s[..47])
835 } else {
836 format!("\"{}\"", s)
837 }
838 }
839 serde_json::Value::Null => String::new(),
840 serde_json::Value::Array(arr) => {
841 format!("[{} items]", arr.len())
842 }
843 other => {
844 let s = other.to_string();
845 if s.len() > 50 {
846 format!("{}...", &s[..47])
847 } else {
848 s
849 }
850 }
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 #[test]
863 fn test_parse_claude_text_delta() {
864 let line =
865 r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"Hello"}}}"#;
866 let event = parse_claude_event(line);
867 assert!(matches!(
868 event,
869 Some(StreamEvent {
870 kind: StreamEventKind::TextDelta { ref text },
871 ..
872 }) if text == "Hello"
873 ));
874 }
875
876 #[test]
877 fn test_parse_claude_tool_use() {
878 let line =
879 r#"{"type":"tool_use","name":"Read","id":"tool_1","input":{"path":"src/main.rs"}}"#;
880 let event = parse_claude_event(line);
881 match event {
882 Some(StreamEvent {
883 kind: StreamEventKind::ToolStart {
884 ref tool_name,
885 ref tool_id,
886 ref input_summary,
887 },
888 ..
889 }) => {
890 assert_eq!(tool_name, "Read");
891 assert_eq!(tool_id, "tool_1");
892 assert!(input_summary.contains("path"));
893 }
894 _ => panic!("Expected ToolStart"),
895 }
896 }
897
898 #[test]
899 fn test_parse_claude_error() {
900 let line = r#"{"type":"error","error":"Rate limit exceeded"}"#;
901 let event = parse_claude_event(line);
902 match event {
903 Some(StreamEvent {
904 kind: StreamEventKind::Error { ref message },
905 ..
906 }) => {
907 assert_eq!(message, "Rate limit exceeded");
908 }
909 _ => panic!("Expected Error event"),
910 }
911 }
912
913 #[test]
914 fn test_parse_claude_result_with_session() {
915 let line = r#"{"type":"result","session_id":"sess-abc123"}"#;
916 let event = parse_claude_event(line);
917 match event {
918 Some(StreamEvent {
919 kind: StreamEventKind::SessionAssigned { ref session_id },
920 ..
921 }) => {
922 assert_eq!(session_id, "sess-abc123");
923 }
924 _ => panic!("Expected SessionAssigned"),
925 }
926 }
927
928 #[test]
929 fn test_parse_claude_result_completion() {
930 let line = r#"{"type":"result"}"#;
931 let event = parse_claude_event(line);
932 assert!(matches!(
933 event,
934 Some(StreamEvent {
935 kind: StreamEventKind::Complete { success: true },
936 ..
937 })
938 ));
939 }
940
941 #[test]
942 fn test_parse_claude_tool_result() {
943 let line = r#"{"type":"tool_result","tool_use_id":"tool_1","content":"success"}"#;
944 let event = parse_claude_event(line);
945 match event {
946 Some(StreamEvent {
947 kind: StreamEventKind::ToolResult {
948 ref tool_id,
949 success,
950 ..
951 },
952 ..
953 }) => {
954 assert_eq!(tool_id, "tool_1");
955 assert!(success);
956 }
957 _ => panic!("Expected ToolResult"),
958 }
959 }
960
961 #[test]
962 fn test_parse_claude_tool_result_error() {
963 let line = r#"{"type":"tool_result","tool_use_id":"tool_2","is_error":true}"#;
964 let event = parse_claude_event(line);
965 match event {
966 Some(StreamEvent {
967 kind: StreamEventKind::ToolResult { success, .. },
968 ..
969 }) => {
970 assert!(!success);
971 }
972 _ => panic!("Expected ToolResult with failure"),
973 }
974 }
975
976 #[test]
977 fn test_parse_claude_unknown_type_returns_none() {
978 let line = r#"{"type":"unknown_event","data":"test"}"#;
979 let event = parse_claude_event(line);
980 assert!(event.is_none());
981 }
982
983 #[test]
984 fn test_claude_interactive_command() {
985 let runner = ClaudeHeadless::with_binary_path("/usr/local/bin/claude");
986 let cmd = runner.interactive_command("sess_123");
987 assert_eq!(cmd[0], "/usr/local/bin/claude");
988 assert_eq!(cmd[1], "--resume");
989 assert_eq!(cmd[2], "sess_123");
990 }
991
992 #[test]
997 fn test_parse_assistant_text_with_message_content() {
998 let line = r#"{"type": "assistant", "message": {"content": [{"text": "Hello world"}]}}"#;
999 let event = parse_opencode_event(line);
1000 assert!(matches!(
1001 event,
1002 Some(StreamEvent {
1003 kind: StreamEventKind::TextDelta { ref text },
1004 ..
1005 }) if text == "Hello world"
1006 ));
1007 }
1008
1009 #[test]
1010 fn test_parse_content_type_with_text() {
1011 let line = r#"{"type": "content", "content": [{"text": "Response text"}]}"#;
1012 let event = parse_opencode_event(line);
1013 assert!(matches!(
1014 event,
1015 Some(StreamEvent {
1016 kind: StreamEventKind::TextDelta { ref text },
1017 ..
1018 }) if text == "Response text"
1019 ));
1020 }
1021
1022 #[test]
1023 fn test_parse_message_type_with_direct_text() {
1024 let line = r#"{"type": "message", "text": "Direct text"}"#;
1025 let event = parse_opencode_event(line);
1026 assert!(matches!(
1027 event,
1028 Some(StreamEvent {
1029 kind: StreamEventKind::TextDelta { ref text },
1030 ..
1031 }) if text == "Direct text"
1032 ));
1033 }
1034
1035 #[test]
1036 fn test_parse_assistant_with_delta_field() {
1037 let line = r#"{"type": "assistant", "delta": "Streaming chunk"}"#;
1038 let event = parse_opencode_event(line);
1039 assert!(matches!(
1040 event,
1041 Some(StreamEvent {
1042 kind: StreamEventKind::TextDelta { ref text },
1043 ..
1044 }) if text == "Streaming chunk"
1045 ));
1046 }
1047
1048 #[test]
1053 fn test_parse_tool_call_started() {
1054 let line = r#"{"type": "tool_call", "subtype": "started", "tool_call": {"name": "read_file", "id": "tool_1", "input": {"path": "src/main.rs"}}}"#;
1055 let event = parse_opencode_event(line);
1056 match event {
1057 Some(StreamEvent {
1058 kind:
1059 StreamEventKind::ToolStart {
1060 ref tool_name,
1061 ref tool_id,
1062 ref input_summary,
1063 },
1064 ..
1065 }) => {
1066 assert_eq!(tool_name, "read_file");
1067 assert_eq!(tool_id, "tool_1");
1068 assert!(input_summary.contains("path"));
1069 }
1070 _ => panic!("Expected ToolStart, got {:?}", event),
1071 }
1072 }
1073
1074 #[test]
1075 fn test_parse_tool_use_start() {
1076 let line = r#"{"type": "tool_use", "status": "start", "name": "bash", "id": "t123"}"#;
1077 let event = parse_opencode_event(line);
1078 match event {
1079 Some(StreamEvent {
1080 kind:
1081 StreamEventKind::ToolStart {
1082 ref tool_name,
1083 ref tool_id,
1084 ..
1085 },
1086 ..
1087 }) => {
1088 assert_eq!(tool_name, "bash");
1089 assert_eq!(tool_id, "t123");
1090 }
1091 _ => panic!("Expected ToolStart"),
1092 }
1093 }
1094
1095 #[test]
1096 fn test_parse_tool_call_completed() {
1097 let line = r#"{"type": "tool_call", "subtype": "completed", "tool_call": {"name": "write_file", "id": "t2"}, "result": {}}"#;
1098 let event = parse_opencode_event(line);
1099 match event {
1100 Some(StreamEvent {
1101 kind:
1102 StreamEventKind::ToolResult {
1103 ref tool_name,
1104 ref tool_id,
1105 success,
1106 },
1107 ..
1108 }) => {
1109 assert_eq!(tool_name, "write_file");
1110 assert_eq!(tool_id, "t2");
1111 assert!(success);
1112 }
1113 _ => panic!("Expected ToolResult"),
1114 }
1115 }
1116
1117 #[test]
1118 fn test_parse_tool_call_with_error() {
1119 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "result": {"is_error": true}}"#;
1120 let event = parse_opencode_event(line);
1121 match event {
1122 Some(StreamEvent {
1123 kind:
1124 StreamEventKind::ToolResult {
1125 success, ..
1126 },
1127 ..
1128 }) => {
1129 assert!(!success);
1130 }
1131 _ => panic!("Expected ToolResult with failure"),
1132 }
1133 }
1134
1135 #[test]
1136 fn test_parse_tool_call_failed_subtype() {
1137 let line = r#"{"type": "tool_call", "subtype": "failed", "name": "git", "id": "t3"}"#;
1138 let event = parse_opencode_event(line);
1139 match event {
1140 Some(StreamEvent {
1141 kind:
1142 StreamEventKind::ToolResult {
1143 success, ..
1144 },
1145 ..
1146 }) => {
1147 assert!(!success);
1148 }
1149 _ => panic!("Expected failed ToolResult"),
1150 }
1151 }
1152
1153 #[test]
1158 fn test_parse_result_success() {
1159 let line = r#"{"type": "result", "success": true}"#;
1160 let event = parse_opencode_event(line);
1161 assert!(matches!(
1162 event,
1163 Some(StreamEvent {
1164 kind: StreamEventKind::Complete { success: true },
1165 ..
1166 })
1167 ));
1168 }
1169
1170 #[test]
1171 fn test_parse_result_failure() {
1172 let line = r#"{"type": "result", "success": false}"#;
1173 let event = parse_opencode_event(line);
1174 assert!(matches!(
1175 event,
1176 Some(StreamEvent {
1177 kind: StreamEventKind::Complete { success: false },
1178 ..
1179 })
1180 ));
1181 }
1182
1183 #[test]
1184 fn test_parse_done_type() {
1185 let line = r#"{"type": "done"}"#;
1186 let event = parse_opencode_event(line);
1187 assert!(matches!(
1188 event,
1189 Some(StreamEvent {
1190 kind: StreamEventKind::Complete { success: true },
1191 ..
1192 })
1193 ));
1194 }
1195
1196 #[test]
1201 fn test_parse_error_with_message() {
1202 let line = r#"{"type": "error", "message": "Connection failed"}"#;
1203 let event = parse_opencode_event(line);
1204 match event {
1205 Some(StreamEvent {
1206 kind: StreamEventKind::Error { ref message },
1207 ..
1208 }) => {
1209 assert_eq!(message, "Connection failed");
1210 }
1211 _ => panic!("Expected Error event"),
1212 }
1213 }
1214
1215 #[test]
1216 fn test_parse_error_with_error_field() {
1217 let line = r#"{"type": "error", "error": "Rate limited"}"#;
1218 let event = parse_opencode_event(line);
1219 match event {
1220 Some(StreamEvent {
1221 kind: StreamEventKind::Error { ref message },
1222 ..
1223 }) => {
1224 assert_eq!(message, "Rate limited");
1225 }
1226 _ => panic!("Expected Error event"),
1227 }
1228 }
1229
1230 #[test]
1235 fn test_parse_session_assignment() {
1236 let line = r#"{"type": "session", "session_id": "sess_abc123"}"#;
1237 let event = parse_opencode_event(line);
1238 match event {
1239 Some(StreamEvent {
1240 kind: StreamEventKind::SessionAssigned { ref session_id },
1241 ..
1242 }) => {
1243 assert_eq!(session_id, "sess_abc123");
1244 }
1245 _ => panic!("Expected SessionAssigned"),
1246 }
1247 }
1248
1249 #[test]
1250 fn test_parse_session_with_id_field() {
1251 let line = r#"{"type": "init", "id": "session_xyz"}"#;
1252 let event = parse_opencode_event(line);
1253 match event {
1254 Some(StreamEvent {
1255 kind: StreamEventKind::SessionAssigned { ref session_id },
1256 ..
1257 }) => {
1258 assert_eq!(session_id, "session_xyz");
1259 }
1260 _ => panic!("Expected SessionAssigned"),
1261 }
1262 }
1263
1264 #[test]
1269 fn test_parse_unknown_event_returns_none() {
1270 let line = r#"{"type": "custom_event", "data": "something"}"#;
1271 let event = parse_opencode_event(line);
1272 assert!(event.is_none());
1273 }
1274
1275 #[test]
1276 fn test_parse_invalid_json_returns_none() {
1277 let line = "not json at all";
1278 let event = parse_opencode_event(line);
1279 assert!(event.is_none());
1280 }
1281
1282 #[test]
1283 fn test_parse_missing_type_returns_none() {
1284 let line = r#"{"message": "no type field"}"#;
1285 let event = parse_opencode_event(line);
1286 assert!(event.is_none());
1287 }
1288
1289 #[test]
1290 fn test_parse_empty_json_returns_none() {
1291 let line = "{}";
1292 let event = parse_opencode_event(line);
1293 assert!(event.is_none());
1294 }
1295
1296 #[test]
1301 fn test_summarize_json_object() {
1302 let value = serde_json::json!({"path": "/foo", "content": "bar"});
1303 let summary = summarize_json(&value);
1304 assert!(summary.contains("path"));
1305 assert!(summary.contains("content"));
1306 }
1307
1308 #[test]
1309 fn test_summarize_json_object_truncated() {
1310 let value = serde_json::json!({
1311 "key1": "v1",
1312 "key2": "v2",
1313 "key3": "v3",
1314 "key4": "v4"
1315 });
1316 let summary = summarize_json(&value);
1317 assert!(summary.contains("..."));
1318 }
1319
1320 #[test]
1321 fn test_summarize_json_empty_object() {
1322 let value = serde_json::json!({});
1323 let summary = summarize_json(&value);
1324 assert_eq!(summary, "{}");
1325 }
1326
1327 #[test]
1328 fn test_summarize_json_string() {
1329 let value = serde_json::json!("short string");
1330 let summary = summarize_json(&value);
1331 assert_eq!(summary, "\"short string\"");
1332 }
1333
1334 #[test]
1335 fn test_summarize_json_long_string() {
1336 let long = "a".repeat(100);
1337 let value = serde_json::json!(long);
1338 let summary = summarize_json(&value);
1339 assert!(summary.len() < 60);
1340 assert!(summary.ends_with("...\""));
1341 }
1342
1343 #[test]
1344 fn test_summarize_json_null() {
1345 let value = serde_json::Value::Null;
1346 let summary = summarize_json(&value);
1347 assert_eq!(summary, "");
1348 }
1349
1350 #[test]
1351 fn test_summarize_json_array() {
1352 let value = serde_json::json!([1, 2, 3, 4, 5]);
1353 let summary = summarize_json(&value);
1354 assert_eq!(summary, "[5 items]");
1355 }
1356
1357 #[test]
1358 fn test_summarize_json_number() {
1359 let value = serde_json::json!(42);
1360 let summary = summarize_json(&value);
1361 assert_eq!(summary, "42");
1362 }
1363
1364 #[test]
1369 fn test_interactive_command_format() {
1370 let runner = OpenCodeHeadless::with_binary_path("/usr/local/bin/opencode");
1371 let cmd = runner.interactive_command("session_123");
1372 assert_eq!(cmd[0], "/usr/local/bin/opencode");
1373 assert_eq!(cmd[1], "attach");
1374 assert!(cmd.contains(&"--session".to_string()));
1375 assert!(cmd.contains(&"session_123".to_string()));
1376 }
1377
1378 #[test]
1383 fn test_opencode_headless_with_binary_path() {
1384 let runner = OpenCodeHeadless::with_binary_path("/custom/path/opencode");
1385 assert!(matches!(runner.harness(), Harness::OpenCode));
1387 }
1388
1389 #[test]
1390 fn test_opencode_interactive_command_structure() {
1391 let runner = OpenCodeHeadless::with_binary_path("/bin/opencode");
1392 let cmd = runner.interactive_command("sess-xyz-789");
1393
1394 assert_eq!(cmd.len(), 5);
1396 assert_eq!(cmd[0], "/bin/opencode");
1397 assert_eq!(cmd[1], "attach");
1398 assert_eq!(cmd[2], "http://localhost:4096");
1399 assert_eq!(cmd[3], "--session");
1400 assert_eq!(cmd[4], "sess-xyz-789");
1401 }
1402
1403 #[test]
1404 fn test_opencode_harness_type() {
1405 let runner = OpenCodeHeadless::with_binary_path("opencode");
1406 assert_eq!(runner.harness(), Harness::OpenCode);
1407 }
1408
1409 #[test]
1414 fn test_claude_headless_with_binary_path() {
1415 let runner = ClaudeHeadless::with_binary_path("/custom/claude");
1416 assert_eq!(runner.binary_path(), "/custom/claude");
1417 assert!(matches!(runner.harness(), Harness::Claude));
1418 }
1419
1420 #[test]
1421 fn test_claude_headless_with_allowed_tools() {
1422 let runner = ClaudeHeadless::with_binary_path("/bin/claude")
1423 .with_allowed_tools(vec!["Read".to_string(), "Write".to_string()]);
1424 assert_eq!(runner.binary_path(), "/bin/claude");
1426 }
1427
1428 #[test]
1429 fn test_claude_interactive_command_structure() {
1430 let runner = ClaudeHeadless::with_binary_path("/usr/bin/claude");
1431 let cmd = runner.interactive_command("sess-abc-123");
1432
1433 assert_eq!(cmd.len(), 3);
1435 assert_eq!(cmd[0], "/usr/bin/claude");
1436 assert_eq!(cmd[1], "--resume");
1437 assert_eq!(cmd[2], "sess-abc-123");
1438 }
1439
1440 #[test]
1441 fn test_claude_harness_type() {
1442 let runner = ClaudeHeadless::with_binary_path("claude");
1443 assert_eq!(runner.harness(), Harness::Claude);
1444 }
1445
1446 #[test]
1451 fn test_any_runner_claude_variant() {
1452 let runner = AnyRunner::Claude(ClaudeHeadless::with_binary_path("/bin/claude"));
1453 assert_eq!(runner.harness(), Harness::Claude);
1454
1455 let cmd = runner.interactive_command("session-1");
1456 assert_eq!(cmd[0], "/bin/claude");
1457 assert_eq!(cmd[1], "--resume");
1458 }
1459
1460 #[test]
1461 fn test_any_runner_opencode_variant() {
1462 let runner = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("/bin/opencode"));
1463 assert_eq!(runner.harness(), Harness::OpenCode);
1464
1465 let cmd = runner.interactive_command("session-2");
1466 assert_eq!(cmd[0], "/bin/opencode");
1467 assert_eq!(cmd[1], "attach");
1468 }
1469
1470 #[test]
1471 fn test_any_runner_harness_matches() {
1472 let claude = AnyRunner::Claude(ClaudeHeadless::with_binary_path("claude"));
1473 let opencode = AnyRunner::OpenCode(OpenCodeHeadless::with_binary_path("opencode"));
1474
1475 assert!(matches!(claude.harness(), Harness::Claude));
1477 assert!(matches!(opencode.harness(), Harness::OpenCode));
1478 }
1479
1480 #[test]
1485 fn test_parse_opencode_tool_with_pending_status() {
1486 let line = r#"{"type": "tool_call", "status": "pending", "tool": "write_file", "id": "t99"}"#;
1487 let event = parse_opencode_event(line);
1488 match event {
1489 Some(StreamEvent {
1490 kind:
1491 StreamEventKind::ToolStart {
1492 ref tool_name,
1493 ref tool_id,
1494 ..
1495 },
1496 ..
1497 }) => {
1498 assert_eq!(tool_name, "write_file");
1499 assert_eq!(tool_id, "t99");
1500 }
1501 _ => panic!("Expected ToolStart for pending status"),
1502 }
1503 }
1504
1505 #[test]
1506 fn test_parse_opencode_tool_done_status() {
1507 let line = r#"{"type": "tool_call", "subtype": "done", "name": "exec", "id": "t50"}"#;
1508 let event = parse_opencode_event(line);
1509 match event {
1510 Some(StreamEvent {
1511 kind:
1512 StreamEventKind::ToolResult {
1513 ref tool_name,
1514 success,
1515 ..
1516 },
1517 ..
1518 }) => {
1519 assert_eq!(tool_name, "exec");
1520 assert!(success);
1521 }
1522 _ => panic!("Expected ToolResult for done subtype"),
1523 }
1524 }
1525
1526 #[test]
1527 fn test_parse_opencode_tool_success_status() {
1528 let line =
1529 r#"{"type": "tool_use", "subtype": "success", "tool_call": {"name": "bash", "id": "t77"}}"#;
1530 let event = parse_opencode_event(line);
1531 match event {
1532 Some(StreamEvent {
1533 kind: StreamEventKind::ToolResult { success, .. },
1534 ..
1535 }) => {
1536 assert!(success);
1537 }
1538 _ => panic!("Expected ToolResult for success subtype"),
1539 }
1540 }
1541
1542 #[test]
1543 fn test_parse_opencode_complete_type() {
1544 let line = r#"{"type": "complete", "success": true}"#;
1545 let event = parse_opencode_event(line);
1546 assert!(matches!(
1547 event,
1548 Some(StreamEvent {
1549 kind: StreamEventKind::Complete { success: true },
1550 ..
1551 })
1552 ));
1553 }
1554
1555 #[test]
1556 fn test_parse_opencode_session_start_type() {
1557 let line = r#"{"type": "session_start", "session_id": "sess-start-001"}"#;
1558 let event = parse_opencode_event(line);
1559 match event {
1560 Some(StreamEvent {
1561 kind: StreamEventKind::SessionAssigned { ref session_id },
1562 ..
1563 }) => {
1564 assert_eq!(session_id, "sess-start-001");
1565 }
1566 _ => panic!("Expected SessionAssigned for session_start type"),
1567 }
1568 }
1569
1570 #[test]
1571 fn test_parse_opencode_assistant_with_message_text() {
1572 let line = r#"{"type": "assistant", "message": {"text": "Thinking about this..."}}"#;
1573 let event = parse_opencode_event(line);
1574 assert!(matches!(
1575 event,
1576 Some(StreamEvent {
1577 kind: StreamEventKind::TextDelta { ref text },
1578 ..
1579 }) if text == "Thinking about this..."
1580 ));
1581 }
1582
1583 #[test]
1584 fn test_parse_opencode_tool_call_error_subtype() {
1585 let line = r#"{"type": "tool_call", "subtype": "error", "tool_call": {"name": "git", "id": "t88"}}"#;
1586 let event = parse_opencode_event(line);
1587 match event {
1588 Some(StreamEvent {
1589 kind:
1590 StreamEventKind::ToolResult {
1591 ref tool_name,
1592 success,
1593 ..
1594 },
1595 ..
1596 }) => {
1597 assert_eq!(tool_name, "git");
1598 assert!(!success);
1599 }
1600 _ => panic!("Expected failed ToolResult for error subtype"),
1601 }
1602 }
1603
1604 #[test]
1605 fn test_parse_opencode_tool_with_nested_input() {
1606 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"}}}"#;
1607 let event = parse_opencode_event(line);
1608 match event {
1609 Some(StreamEvent {
1610 kind:
1611 StreamEventKind::ToolStart {
1612 ref tool_name,
1613 ref input_summary,
1614 ..
1615 },
1616 ..
1617 }) => {
1618 assert_eq!(tool_name, "write_file");
1619 assert!(input_summary.contains("path"));
1621 }
1622 _ => panic!("Expected ToolStart with input summary"),
1623 }
1624 }
1625
1626 #[test]
1627 fn test_parse_opencode_tool_result_with_error_string() {
1628 let line = r#"{"type": "tool_call", "subtype": "completed", "name": "bash", "error": "Command not found"}"#;
1629 let event = parse_opencode_event(line);
1630 match event {
1631 Some(StreamEvent {
1632 kind: StreamEventKind::ToolResult { success, .. },
1633 ..
1634 }) => {
1635 assert!(!success);
1637 }
1638 _ => panic!("Expected failed ToolResult"),
1639 }
1640 }
1641
1642 #[test]
1643 fn test_parse_opencode_unknown_subtype_returns_none() {
1644 let line = r#"{"type": "tool_call", "subtype": "unknown_status", "name": "bash"}"#;
1645 let event = parse_opencode_event(line);
1646 assert!(event.is_none());
1647 }
1648
1649 #[test]
1654 fn test_parse_cursor_system_init() {
1655 let line = r#"{"type":"system","subtype":"init","session_id":"013608ef-dda7-4b38-9741-54fb0323ce1c","model":"Claude 4.5 Opus"}"#;
1656 let event = parse_cursor_event(line);
1657 match event {
1658 Some(StreamEvent {
1659 kind: StreamEventKind::SessionAssigned { ref session_id },
1660 ..
1661 }) => {
1662 assert_eq!(session_id, "013608ef-dda7-4b38-9741-54fb0323ce1c");
1663 }
1664 _ => panic!("Expected SessionAssigned from system init"),
1665 }
1666 }
1667
1668 #[test]
1669 fn test_parse_cursor_tool_call_started() {
1670 let line = r#"{"type":"tool_call","subtype":"started","call_id":"toolu_123","tool_call":{"editToolCall":{"args":{"path":"/tmp/hello.py","streamContent":"print(\"Hello\")\n"}}}}"#;
1671 let event = parse_cursor_event(line);
1672 match event {
1673 Some(StreamEvent {
1674 kind:
1675 StreamEventKind::ToolStart {
1676 ref tool_name,
1677 ref tool_id,
1678 ref input_summary,
1679 },
1680 ..
1681 }) => {
1682 assert_eq!(tool_name, "Edit");
1683 assert_eq!(tool_id, "toolu_123");
1684 assert!(input_summary.contains("path"));
1685 }
1686 _ => panic!("Expected ToolStart, got {:?}", event),
1687 }
1688 }
1689
1690 #[test]
1691 fn test_parse_cursor_tool_call_completed() {
1692 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}}}}}"#;
1693 let event = parse_cursor_event(line);
1694 match event {
1695 Some(StreamEvent {
1696 kind:
1697 StreamEventKind::ToolResult {
1698 ref tool_name,
1699 ref tool_id,
1700 success,
1701 },
1702 ..
1703 }) => {
1704 assert_eq!(tool_name, "Edit");
1705 assert_eq!(tool_id, "toolu_123");
1706 assert!(success);
1707 }
1708 _ => panic!("Expected ToolResult, got {:?}", event),
1709 }
1710 }
1711
1712 #[test]
1713 fn test_parse_cursor_assistant_message() {
1714 let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Created hello.py"}]}}"#;
1715 let event = parse_cursor_event(line);
1716 assert!(matches!(
1717 event,
1718 Some(StreamEvent {
1719 kind: StreamEventKind::TextDelta { ref text },
1720 ..
1721 }) if text == "Created hello.py"
1722 ));
1723 }
1724
1725 #[test]
1726 fn test_parse_cursor_result_success() {
1727 let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"Done","session_id":"sess-123"}"#;
1728 let event = parse_cursor_event(line);
1729 assert!(matches!(
1730 event,
1731 Some(StreamEvent {
1732 kind: StreamEventKind::Complete { success: true },
1733 ..
1734 })
1735 ));
1736 }
1737
1738 #[test]
1739 fn test_parse_cursor_result_error() {
1740 let line = r#"{"type":"result","subtype":"error","is_error":true,"result":"Failed"}"#;
1741 let event = parse_cursor_event(line);
1742 assert!(matches!(
1743 event,
1744 Some(StreamEvent {
1745 kind: StreamEventKind::Complete { success: false },
1746 ..
1747 })
1748 ));
1749 }
1750
1751 #[test]
1752 fn test_parse_cursor_user_message_ignored() {
1753 let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}}"#;
1754 let event = parse_cursor_event(line);
1755 assert!(event.is_none());
1756 }
1757
1758 #[test]
1759 fn test_parse_cursor_invalid_json() {
1760 let event = parse_cursor_event("not json");
1761 assert!(event.is_none());
1762 }
1763}