1#[cfg(test)]
48use super::SandboxLevel;
49use super::{
50 AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent, json_error_message,
51 json_str,
52};
53use crate::parser;
54
55#[derive(Debug)]
57pub struct ClaudeRunner {
58 binary: String,
60}
61
62impl ClaudeRunner {
63 pub fn new(agent_name: &str) -> Self {
64 Self {
65 binary: agent_name.to_string(),
66 }
67 }
68
69 fn parse_envelope(raw: &str) -> Result<ParsedResponse, AgentError> {
73 let parsed_json: serde_json::Value = serde_json::from_str(raw).map_err(|_| {
74 AgentError::InvalidResponse {
76 raw: raw.to_string(),
77 }
78 })?;
79
80 let envelope = parsed_json
81 .as_object()
82 .ok_or_else(|| AgentError::InvalidResponse {
83 raw: raw.to_string(),
84 })?;
85
86 let is_envelope = envelope.get("type").and_then(|v| v.as_str()) == Some("result");
88
89 if !is_envelope {
90 return Self::parse_direct(raw);
92 }
93
94 let is_error = envelope
96 .get("is_error")
97 .and_then(serde_json::Value::as_bool)
98 .unwrap_or(false);
99
100 let error_text = if is_error {
102 envelope_error_text(envelope)
103 } else {
104 String::new()
105 };
106 let result_text = if is_error {
107 error_text.as_str()
108 } else {
109 envelope
110 .get("result")
111 .and_then(|v| v.as_str())
112 .unwrap_or("")
113 };
114
115 if is_error {
116 if let Some(e) = super::patterns::detect_stale_session(result_text) {
120 return Err(e);
121 }
122 if let Some(e) = super::patterns::detect_thinking_block_conflict(result_text) {
125 return Err(e);
126 }
127 if let Some(e) = super::patterns::detect_auth_error(result_text) {
133 return Err(e);
134 }
135 if let Some(e) = super::patterns::detect_rate_limit(result_text) {
136 return Err(e);
137 }
138 if let Some(e) = super::patterns::detect_context_overflow(result_text) {
139 return Err(e);
140 }
141 return Err(AgentError::AgentFailed {
142 message: result_text.to_string(),
143 });
144 }
145
146 let (input_tokens, output_tokens) = extract_usage(envelope);
148
149 let duration_ms = envelope
151 .get("duration_ms")
152 .and_then(serde_json::Value::as_u64);
153
154 let response = match parser::parse(result_text) {
159 Ok(r) => r,
160 Err(_) => {
161 super::synthesize_response_from_text(result_text).ok_or_else(|| {
164 AgentError::InvalidResponse {
165 raw: result_text.to_string(),
166 }
167 })?
168 }
169 };
170
171 Ok(ParsedResponse {
172 response,
173 input_tokens,
174 output_tokens,
175 duration_ms,
176 })
177 }
178
179 fn parse_direct(raw: &str) -> Result<ParsedResponse, AgentError> {
181 let response = parser::parse(raw).map_err(|_| AgentError::InvalidResponse {
182 raw: raw.to_string(),
183 })?;
184
185 Ok(ParsedResponse {
186 response,
187 input_tokens: None,
188 output_tokens: None,
189 duration_ms: None,
190 })
191 }
192}
193
194fn envelope_error_text(obj: &serde_json::Map<String, serde_json::Value>) -> String {
197 let result = obj.get("result").and_then(|v| v.as_str()).unwrap_or("");
198 if !result.is_empty() {
199 return result.to_string();
200 }
201 let errors = obj
202 .get("errors")
203 .and_then(|v| v.as_array())
204 .map(|a| {
205 a.iter()
206 .filter_map(|e| e.as_str())
207 .collect::<Vec<_>>()
208 .join("\n")
209 })
210 .unwrap_or_default();
211 if !errors.is_empty() {
212 return errors;
213 }
214 obj.get("subtype")
215 .and_then(|v| v.as_str())
216 .unwrap_or("")
217 .to_string()
218}
219
220fn extract_error_from_envelope(
224 obj: &serde_json::Map<String, serde_json::Value>,
225 _raw: &str,
226) -> Option<AgentError> {
227 if obj.get("type").and_then(|v| v.as_str()) == Some("result")
228 && obj
229 .get("is_error")
230 .and_then(serde_json::Value::as_bool)
231 .unwrap_or(false)
232 {
233 let result_text = envelope_error_text(obj);
234 let result_text = result_text.as_str();
235 if let Some(e) = super::patterns::detect_thinking_block_conflict(result_text) {
239 return Some(e);
240 }
241 if let Some(e) = super::patterns::detect_auth_error(result_text) {
242 return Some(e);
243 }
244 if let Some(e) = super::patterns::detect_rate_limit(result_text) {
245 return Some(e);
246 }
247 if let Some(e) = super::patterns::detect_context_overflow(result_text) {
248 return Some(e);
249 }
250 return Some(AgentError::AgentFailed {
251 message: result_text.to_string(),
252 });
253 }
254 None
255}
256
257fn detect_error_envelope(text: &str) -> Option<AgentError> {
261 for line in text.lines().filter(|l| !l.trim().is_empty()) {
262 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line)
263 && let Some(obj) = val.as_object()
264 && let Some(err) = extract_error_from_envelope(obj, line)
265 {
266 return Some(err);
267 }
268 }
269 None
270}
271
272pub(crate) fn extract_stream_json_result_text(raw: &str) -> Result<String, AgentError> {
273 let trimmed = raw.trim();
274 if trimmed.is_empty() {
275 return Err(AgentError::InvalidResponse { raw: String::new() });
276 }
277
278 let envelope_line_opt = find_ndjson_result_line(trimmed).map(|(line, _)| line);
281
282 if let Some(line) = envelope_line_opt {
283 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line)
285 && let Some(obj) = parsed.as_object()
286 {
287 if let Some(err) = extract_error_from_envelope(obj, line) {
289 return Err(err);
290 }
291 if let Some(result) = obj.get("result") {
293 if let Some(text) = result.as_str() {
294 return Ok(text.to_string());
295 }
296 if result.is_object() || result.is_array() {
297 return Ok(result.to_string());
298 }
299 }
300 }
301 }
303
304 if let Some(fallback) = crate::engine::runner::agents::opencode::extract_router_text(trimmed) {
307 return Ok(fallback);
308 }
309
310 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed)
313 && let Some(obj) = parsed.as_object()
314 {
315 if let Some(err) = extract_error_from_envelope(obj, trimmed) {
317 return Err(err);
318 }
319 if let Some(result) = obj.get("result") {
321 if let Some(text) = result.as_str() {
322 return Ok(text.to_string());
323 }
324 if result.is_object() || result.is_array() {
325 return Ok(result.to_string());
326 }
327 }
328 return Ok(trimmed.to_string());
329 }
330
331 if let Some(err) = detect_error_envelope(trimmed) {
334 return Err(err);
335 }
336
337 Ok(trimmed.to_string())
338}
339
340impl AgentRunner for ClaudeRunner {
341 #[cfg(test)]
342 fn name(&self) -> &str {
343 &self.binary
344 }
345
346 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
347 stream_events(event)
348 }
349
350 fn router_transcript(&self) -> super::RouterTranscript {
351 super::RouterTranscript::Messages
352 }
353
354 fn extract_text(&self, raw: &str) -> Result<String, super::AgentError> {
355 if raw.trim().is_empty() {
357 return Ok(String::new());
358 }
359 extract_stream_json_result_text(raw)
360 }
361
362 fn build_command(
363 &self,
364 model: Option<&str>,
365 timeout_cmd: &str,
366 sys_file: &str,
367 msg_file: &str,
368 permissions: &PermissionRules,
369 ) -> String {
370 let model_flag = model
371 .map(str::trim)
372 .filter(|m| !m.is_empty())
373 .map(|m| format!("--model {}", super::shell_single_quote(m)))
374 .unwrap_or_default();
375
376 let permission_mode = if permissions.autonomous {
378 "bypassPermissions"
379 } else {
380 "acceptEdits"
381 };
382
383 let tool_flag = if !permissions.allowed_tools.is_empty() {
387 let tools = translate_allowed_tools(
388 &permissions.allowed_tools,
389 &permissions.allowed_edit_paths,
390 );
391 format!("--allowedTools '{}'", tools.join(","))
392 } else if !permissions.disallowed_tools.is_empty() {
393 format!(
394 "--disallowedTools '{}'",
395 permissions.disallowed_tools.join(",")
396 )
397 } else {
398 String::new()
399 };
400
401 format!(
402 r#"{timeout_cmd} {binary} -p --verbose {model_flag} \
403 --permission-mode {permission_mode} \
404 --output-format stream-json \
405 {tool_flag} \
406 --append-system-prompt "{sys_file}" \
407 < "{msg_file}""#,
408 timeout_cmd = timeout_cmd,
409 binary = self.binary,
410 model_flag = model_flag,
411 permission_mode = permission_mode,
412 tool_flag = tool_flag,
413 sys_file = sys_file,
414 msg_file = msg_file,
415 )
416 }
417
418 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
419 let trimmed = raw.trim();
420 if trimmed.is_empty() {
421 return Err(AgentError::InvalidResponse { raw: String::new() });
422 }
423
424 if let Some((result_line, tokens)) = find_ndjson_result_line(trimmed) {
427 let result = Self::parse_envelope(result_line)?;
428 let input_tokens = result.input_tokens.or(tokens.map(|(i, _)| i));
432 let output_tokens = result.output_tokens.or(tokens.map(|(_, o)| o));
433 if input_tokens != result.input_tokens || output_tokens != result.output_tokens {
434 return Ok(ParsedResponse {
435 input_tokens,
436 output_tokens,
437 ..result
438 });
439 }
440 return Ok(result);
441 }
442
443 Self::parse_envelope(trimmed)
445 }
446
447 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
448 let error_result: Option<String> = (|| {
452 let trimmed = stdout.trim();
453 let (line, _) = find_ndjson_result_line(trimmed)?;
454 let val: serde_json::Value = serde_json::from_str(line).ok()?;
455 let obj = val.as_object()?;
456 if obj.get("is_error").and_then(serde_json::Value::as_bool) != Some(true) {
457 return None;
458 }
459 Some(envelope_error_text(obj)).filter(|t| !t.is_empty())
460 })();
461
462 let system_error: Option<String> = (|| {
470 let trimmed = stdout.trim();
471 for line in trimmed.lines() {
472 let line = line.trim();
473 if line.is_empty() {
474 continue;
475 }
476 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line)
477 && let Some(obj) = val.as_object()
478 && obj.get("type").and_then(|t| t.as_str()) == Some("system")
479 && let Some(error) = obj.get("error")
480 && let Some(msg) = error.get("message").and_then(|m| m.as_str())
481 {
482 return Some(msg.to_string());
483 }
484 }
485 None
486 })();
487
488 let has_result_line = find_ndjson_result_line(stdout.trim()).is_some();
491 if matches!(exit_code, 143 | 137)
492 && !has_result_line
493 && error_result.is_none()
494 && system_error.is_none()
495 {
496 return AgentError::Interrupted {
497 message: format!("agent killed by signal (exit {exit_code})"),
498 };
499 }
500
501 let combined = match (&error_result, &system_error) {
502 (Some(r), Some(s)) => format!("{s}\n{r}\n{stdout}\n{stderr}"),
503 (Some(r), None) => format!("{r}\n{stdout}\n{stderr}"),
504 (None, Some(s)) => format!("{s}\n{stdout}\n{stderr}"),
505 (None, None) => format!("{stdout}\n{stderr}"),
506 };
507 if let Some(message) = &error_result
510 && let Some(e) = super::patterns::detect_auth_error(message)
511 {
512 return e;
513 }
514 let classified = super::patterns::classify_from_text(exit_code, &combined);
515
516 if let (AgentError::Unknown { .. }, Some(message)) = (&classified, &error_result)
524 && !message.is_empty()
525 {
526 return AgentError::AgentFailed {
527 message: message.clone(),
528 };
529 }
530 classified
531 }
532
533 fn router_command(
534 &self,
535 prompt: &str,
536 model: Option<&str>,
537 ) -> anyhow::Result<tokio::process::Command> {
538 let mut cmd = tokio::process::Command::new(&self.binary);
539 cmd.env_remove("CLAUDECODE"); cmd.arg("--output-format")
541 .arg("stream-json")
542 .arg("--verbose")
543 .arg("--print");
544 if let Some(m) = model.map(str::trim).filter(|m| !m.is_empty()) {
545 cmd.arg("--model").arg(m);
546 }
547 cmd.arg(prompt);
548 Ok(cmd)
549 }
550}
551
552const CLAUDE_NATIVE_TOOLS: &[&str] = &[
555 "Edit",
556 "Write",
557 "Read",
558 "Bash",
559 "Grep",
560 "Glob",
561 "WebFetch",
562 "WebSearch",
563 "Task",
564 "Skill",
565 "NotebookEdit",
566 "EnterPlanMode",
567 "ExitPlanMode",
568 "EnterWorktree",
569 "AskUserQuestion",
570 "TodoWrite",
571 "TodoRead",
572];
573
574fn translate_allowed_tools(
580 allowed: &[String],
581 allowed_edit_paths: &[std::path::PathBuf],
582) -> Vec<String> {
583 let mut tools = Vec::new();
584 for item in allowed {
585 if (item == "Edit" || item == "Write") && !allowed_edit_paths.is_empty() {
586 for path in allowed_edit_paths {
588 tools.push(format!("{}({}/*)", item, path.display()));
589 }
590 } else if CLAUDE_NATIVE_TOOLS.contains(&item.as_str()) {
591 tools.push(item.clone());
592 } else {
593 tools.push(format!("Bash({item} *)"));
595 }
596 }
597 tools
598}
599
600pub(crate) fn find_ndjson_result_line(text: &str) -> Option<(&str, Option<(u64, u64)>)> {
615 let lines: Vec<_> = text.lines().filter(|l| !l.trim().is_empty()).collect();
616
617 for line in lines.iter().rev() {
620 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line)
621 && let Some(obj) = val.as_object()
622 && obj.get("type").and_then(|t| t.as_str()) == Some("result")
623 && let Some(result) = obj.get("result")
624 {
625 let is_json_result = match result {
626 serde_json::Value::Object(_) | serde_json::Value::Array(_) => true,
627 serde_json::Value::String(s) => {
628 serde_json::from_str::<serde_json::Value>(s).is_ok()
629 }
630 _ => false,
631 };
632 if is_json_result {
633 let tokens = extract_result_tokens(obj);
634 return Some((line, tokens));
635 }
636 }
637 }
638
639 let mut best_line: Option<&str> = None;
645 let mut best_total = 0u64;
646
647 for line in &lines {
648 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line)
649 && let Some(obj) = val.as_object()
650 && obj.get("type").and_then(|t| t.as_str()) == Some("result")
651 {
652 let tokens = extract_result_tokens(obj);
653 let total = tokens.map_or(0, |(i, o)| i.saturating_add(o));
654 if total >= best_total {
655 best_total = total;
656 best_line = Some(*line);
657 }
658 }
659 }
660
661 best_line.map(|line| (line, extract_result_tokens_from_line(line)))
662}
663
664fn extract_result_tokens(obj: &serde_json::Map<String, serde_json::Value>) -> Option<(u64, u64)> {
666 if let Some(usage) = obj.get("usage").and_then(|v| v.as_object()) {
668 let input = usage
669 .get("input_tokens")
670 .and_then(serde_json::Value::as_u64);
671 let output = usage
672 .get("output_tokens")
673 .and_then(serde_json::Value::as_u64);
674 if let (Some(i), Some(o)) = (input, output) {
675 return Some((i, o));
676 }
677 }
678 if let Some(model_usage) = obj.get("modelUsage").and_then(|v| v.as_object()) {
680 let mut total_input = 0u64;
681 let mut total_output = 0u64;
682 let mut found = false;
683 for (_model_name, model_stats) in model_usage {
684 if let Some(stats) = model_stats.as_object() {
685 if let Some(i) = stats
686 .get("inputTokens")
687 .or_else(|| stats.get("input_tokens"))
688 .and_then(serde_json::Value::as_u64)
689 {
690 total_input += i;
691 found = true;
692 }
693 if let Some(o) = stats
694 .get("outputTokens")
695 .or_else(|| stats.get("output_tokens"))
696 .and_then(serde_json::Value::as_u64)
697 {
698 total_output += o;
699 found = true;
700 }
701 }
702 }
703 if found {
704 return Some((total_input, total_output));
705 }
706 }
707 None
708}
709
710fn extract_result_tokens_from_line(line: &str) -> Option<(u64, u64)> {
712 let val = serde_json::from_str::<serde_json::Value>(line).ok()?;
713 let obj = val.as_object()?;
714 extract_result_tokens(obj)
715}
716
717fn extract_usage(
719 envelope: &serde_json::Map<String, serde_json::Value>,
720) -> (Option<u64>, Option<u64>) {
721 if let Some(usage) = envelope.get("usage").and_then(|v| v.as_object()) {
723 let input = usage
724 .get("input_tokens")
725 .and_then(serde_json::Value::as_u64);
726 let output = usage
727 .get("output_tokens")
728 .and_then(serde_json::Value::as_u64);
729 if input.is_some() || output.is_some() {
730 return (input, output);
731 }
732 }
733
734 if let Some(model_usage) = envelope.get("modelUsage").and_then(|v| v.as_object()) {
736 let mut total_input = 0u64;
737 let mut total_output = 0u64;
738 let mut found = false;
739
740 for (_model_name, model_stats) in model_usage {
741 if let Some(stats) = model_stats.as_object() {
742 if let Some(i) = stats
743 .get("inputTokens")
744 .or_else(|| stats.get("input_tokens"))
745 .and_then(serde_json::Value::as_u64)
746 {
747 total_input += i;
748 found = true;
749 }
750 if let Some(o) = stats
751 .get("outputTokens")
752 .or_else(|| stats.get("output_tokens"))
753 .and_then(serde_json::Value::as_u64)
754 {
755 total_output += o;
756 found = true;
757 }
758 }
759 }
760
761 if found {
762 return (Some(total_input), Some(total_output));
763 }
764 }
765
766 (None, None)
767}
768
769const SHOW_CURSOR: &str = "\x1b[?25h";
770const HIDE_CURSOR: &str = "\x1b[?25l";
771
772pub fn strip_tty_control_lines(raw: &str) -> String {
774 raw.lines()
775 .filter(|line| !is_tty_control_line(line))
776 .map(strip_trailing_cursor)
777 .collect::<Vec<_>>()
778 .join("\n")
779}
780
781fn strip_trailing_cursor(line: &str) -> &str {
782 let mut s = line.trim_end();
783 loop {
784 if let Some(next) = s
785 .strip_suffix(SHOW_CURSOR)
786 .or_else(|| s.strip_suffix(HIDE_CURSOR))
787 {
788 s = next.trim_end();
789 continue;
790 }
791 return s;
792 }
793}
794
795fn is_tty_control_line(line: &str) -> bool {
796 let trimmed = strip_trailing_cursor(line).trim();
797 if trimmed.is_empty() {
798 return !line.trim().is_empty();
799 }
800 trimmed.chars().all(|c| matches!(c, '^' | 'D' | '\u{0008}'))
801}
802
803fn last_assistant_content_type(ndjson: &str) -> Option<String> {
805 let mut last = None;
806 for line in ndjson.lines() {
807 let line = line.trim();
808 if line.is_empty() {
809 continue;
810 }
811 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
812 continue;
813 };
814 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
815 continue;
816 }
817 let Some(content) = val
818 .get("message")
819 .and_then(|m| m.get("content"))
820 .and_then(|c| c.as_array())
821 else {
822 continue;
823 };
824 if let Some(item) = content.last()
825 && let Some(t) = item.get("type").and_then(|v| v.as_str())
826 {
827 last = Some(t.to_string());
828 }
829 }
830 last
831}
832
833pub fn review_session_exited_mid_thought(ndjson: &str) -> bool {
836 if find_ndjson_result_line(ndjson).is_some() {
837 return false;
838 }
839 matches!(
840 last_assistant_content_type(ndjson).as_deref(),
841 Some("thinking" | "redacted_thinking" | "tool_use")
842 )
843}
844
845pub fn find_claude_result(ndjson: &str) -> Option<super::AgentResult> {
862 let stripped = strip_tty_control_lines(ndjson);
863 let trimmed = stripped.trim();
864 if trimmed.is_empty() {
865 return None;
866 }
867
868 if let Some((result_line, _tokens)) = find_ndjson_result_line(trimmed)
870 && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(result_line)
871 && let Some(envelope) = parsed.as_object()
872 {
873 let is_error = envelope
874 .get("is_error")
875 .and_then(serde_json::Value::as_bool)
876 .unwrap_or(false);
877
878 let result_text = if is_error {
879 envelope_error_text(envelope)
882 } else {
883 envelope
884 .get("result")
885 .and_then(|v| v.as_str())
886 .unwrap_or("")
887 .to_string()
888 };
889
890 let (input_tokens, output_tokens) = extract_usage(envelope);
891 let duration_ms = envelope
892 .get("duration_ms")
893 .and_then(serde_json::Value::as_u64);
894
895 let cost_usd = envelope
896 .get("costUSD")
897 .or_else(|| envelope.get("cost_usd"))
898 .and_then(serde_json::Value::as_f64);
899
900 return Some(super::AgentResult {
901 is_error,
902 result_text,
903 input_tokens,
904 output_tokens,
905 cost_usd,
906 duration_ms,
907 truncated_by_length: false,
908 });
909 }
910
911 let mut last_text = None;
913 let mut input_tokens = None;
914 let mut output_tokens = None;
915
916 for line in trimmed.lines() {
917 let line = line.trim();
918 if line.is_empty() {
919 continue;
920 }
921 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
922 continue;
923 };
924 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
925 continue;
926 }
927 if let Some(content) = val
929 .get("message")
930 .and_then(|m| m.get("content"))
931 .and_then(|c| c.as_array())
932 {
933 for item in content {
934 if item.get("type").and_then(|v| v.as_str()) == Some("text")
935 && let Some(text) = item.get("text").and_then(|v| v.as_str())
936 {
937 let t = text.trim();
938 if !t.is_empty() {
939 last_text = Some(t.to_string());
940 }
941 }
942 }
943 }
944 if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
946 if let Some(inp) = usage
947 .get("input_tokens")
948 .and_then(serde_json::Value::as_u64)
949 {
950 input_tokens = Some(input_tokens.unwrap_or(0u64).max(inp));
951 }
952 if let Some(out) = usage
953 .get("output_tokens")
954 .and_then(serde_json::Value::as_u64)
955 {
956 output_tokens = Some(output_tokens.unwrap_or(0u64).max(out));
957 }
958 }
959 }
960
961 last_text.map(|text| super::AgentResult {
962 is_error: false,
963 result_text: text,
964 input_tokens,
965 output_tokens,
966 cost_usd: None,
967 duration_ms: None,
968 truncated_by_length: false,
969 })
970}
971
972pub fn collect_assistant_messages_text(ndjson: &str) -> String {
977 let mut parts: Vec<String> = Vec::new();
978 for line in ndjson.trim().lines() {
979 let line = line.trim();
980 if line.is_empty() {
981 continue;
982 }
983 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
984 continue;
985 };
986 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
987 continue;
988 }
989 if let Some(content) = val
990 .get("message")
991 .and_then(|m| m.get("content"))
992 .and_then(|c| c.as_array())
993 {
994 for item in content {
995 if item.get("type").and_then(|v| v.as_str()) == Some("text")
996 && let Some(text) = item.get("text").and_then(|v| v.as_str())
997 {
998 let t = text.trim();
999 if !t.is_empty() {
1000 parts.push(t.to_string());
1001 }
1002 }
1003 }
1004 }
1005 }
1006 parts.join("\n")
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 fn runner() -> ClaudeRunner {
1014 ClaudeRunner::new("claude")
1015 }
1016
1017 #[test]
1018 fn error_envelope_uses_errors_array_when_result_missing() {
1019 let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["API error (status 402 Payment Required): usage balance exhausted"]}"#;
1020 match runner().parse_response(raw) {
1021 Err(AgentError::AgentFailed { message } | AgentError::Auth { message }) => {
1022 assert!(crate::engine::cooldown::detect_credit_exhaustion(&message).is_some());
1023 }
1024 other => panic!("unexpected: {other:?}"),
1025 }
1026 }
1027
1028 #[test]
1029 fn error_envelope_falls_back_to_subtype() {
1030 let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true}"#;
1031 match runner().parse_response(raw) {
1032 Err(AgentError::AgentFailed { message }) => {
1033 assert_eq!(message, "error_during_execution");
1034 }
1035 other => panic!("unexpected: {other:?}"),
1036 }
1037 }
1038
1039 #[test]
1040 fn parse_claude_envelope_success() {
1041 let raw = r#"{
1042 "type": "result",
1043 "subtype": "success",
1044 "is_error": false,
1045 "duration_ms": 2006,
1046 "result": "{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1047 "usage": {"input_tokens": 10, "output_tokens": 78},
1048 "permission_denials": []
1049 }"#;
1050
1051 let parsed = runner().parse_response(raw).unwrap();
1052 assert_eq!(parsed.response.status, "done");
1053 assert_eq!(parsed.response.summary, "hello");
1054 assert_eq!(parsed.input_tokens, Some(10));
1055 assert_eq!(parsed.output_tokens, Some(78));
1056 assert_eq!(parsed.duration_ms, Some(2006));
1057 }
1058
1059 #[test]
1060 fn parse_claude_envelope_with_markdown_result() {
1061 let raw = r#"{
1062 "type": "result",
1063 "subtype": "success",
1064 "is_error": false,
1065 "duration_ms": 5000,
1066 "result": "```json\n{\"status\": \"done\", \"summary\": \"fixed bug\"}\n```",
1067 "usage": {"input_tokens": 100, "output_tokens": 50},
1068 "permission_denials": ["Bash(rm *)"]
1069 }"#;
1070
1071 let parsed = runner().parse_response(raw).unwrap();
1072 assert_eq!(parsed.response.status, "done");
1073 assert_eq!(parsed.response.summary, "fixed bug");
1074 }
1075
1076 #[test]
1077 fn parse_claude_envelope_error() {
1078 let raw = r#"{
1079 "type": "result",
1080 "subtype": "error",
1081 "is_error": true,
1082 "result": "credit balance too low",
1083 "usage": {},
1084 "permission_denials": []
1085 }"#;
1086
1087 let err = runner().parse_response(raw).unwrap_err();
1088 assert!(matches!(err, AgentError::Auth { .. }));
1089 }
1090
1091 #[test]
1092 fn parse_claude_envelope_rate_limit_error() {
1093 let raw = r#"{
1094 "type": "result",
1095 "subtype": "error",
1096 "is_error": true,
1097 "result": "rate limit exceeded, please try again later",
1098 "usage": {},
1099 "permission_denials": []
1100 }"#;
1101
1102 let err = runner().parse_response(raw).unwrap_err();
1103 assert!(matches!(err, AgentError::RateLimit { .. }));
1104 }
1105
1106 #[test]
1107 fn parse_envelope_no_false_positive_from_work_product_result() {
1108 let raw = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1000,"result":"Added detect_rate_limit and quota exceeded patterns","usage":{"input_tokens":10,"output_tokens":5}}"#.to_string();
1115 let parsed = runner()
1118 .parse_response(&raw)
1119 .expect("plain-text work product must synthesize successfully");
1120 assert_eq!(
1122 parsed.input_tokens,
1123 Some(10),
1124 "input_tokens must be preserved"
1125 );
1126 assert_eq!(
1127 parsed.output_tokens,
1128 Some(5),
1129 "output_tokens must be preserved"
1130 );
1131 }
1132
1133 #[test]
1134 fn parse_direct_json_no_envelope() {
1135 let raw =
1136 r#"{"status":"done","summary":"hello","accomplished":[],"remaining":[],"files":[]}"#;
1137
1138 let parsed = runner().parse_response(raw).unwrap();
1139 assert_eq!(parsed.response.status, "done");
1140 assert!(parsed.input_tokens.is_none());
1141 }
1142
1143 #[test]
1144 fn parse_empty_response() {
1145 let err = runner().parse_response("").unwrap_err();
1146 assert!(matches!(err, AgentError::InvalidResponse { .. }));
1147 }
1148
1149 #[test]
1150 fn classify_error_rate_limit() {
1151 let err = runner().classify_error(1, "", "429 Too Many Requests");
1152 assert!(matches!(err, AgentError::RateLimit { .. }));
1153 }
1154
1155 #[test]
1156 fn classify_error_detects_session_limit_from_mid_stream_system_event() {
1157 let header_events = "{\"type\":\"conversation\",\"subtype\":\"init\"}\n\
1164 {\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"I'll work on this task.\"}]}}\n"
1165 .repeat(80);
1166 let system_event = r#"{"type":"system","subtype":"error","error":{"type":"session_limit","message":"You've hit your session limit · resets 4:10am (America/Sao_Paulo)"}}"#;
1167 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Here is the implementation...\"}]}}\n"
1168 .repeat(80);
1169 let stdout = format!("{header_events}{system_event}\n{work_product}");
1170 assert!(
1172 stdout.len() > 6000,
1173 "test stdout must exceed combined head+tail windows, got {} bytes",
1174 stdout.len()
1175 );
1176 let err = runner().classify_error(1, &stdout, "");
1177 assert!(
1178 matches!(err, AgentError::RateLimit { .. }),
1179 "session limit system event mid-stream must be detected, got: {err:?}"
1180 );
1181 let msg = err.to_string();
1182 assert!(
1183 msg.contains("session limit"),
1184 "error message should contain the rate limit reason, got: {msg}"
1185 );
1186 }
1187
1188 #[test]
1189 fn classify_error_killed_run_with_429_in_uuid_hex_group_is_not_rate_limit() {
1190 let hook = r#"{"type":"system","subtype":"hook_response","hook_id":"18db61b6-7b72-429c-8279-fae3089bf565"}"#;
1192 let prose = "the first lazy solution that works is the right one.\n".repeat(80);
1193 let stdout = format!("{hook}\n{prose}");
1194 assert!(stdout.len() > 3000);
1195 for code in [143, 137, 1] {
1196 let err = runner().classify_error(code, &stdout, "");
1197 assert!(
1198 !matches!(err, AgentError::RateLimit { .. }),
1199 "exit {code} must not classify as RateLimit, got: {err:?}"
1200 );
1201 }
1202 let err = runner().classify_error(143, &stdout, "");
1203 assert!(
1204 matches!(err, AgentError::Interrupted { .. }),
1205 "got: {err:?}"
1206 );
1207 }
1208
1209 #[test]
1210 fn classify_error_402_envelope_not_masked_by_429_in_hook_uuid() {
1211 let hook = r#"{"type":"system","subtype":"init","hook":"SessionStart","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b","outputTokens":429}"#;
1214 let hook_prose = "on. The first lazy solution that works is the\nright one once you actually know what the change has to touch.\n\n**Build the smallest thing that can fail.**\n";
1215 let padding = hook_prose.repeat(40);
1216 let result_line = r#"{"type":"result","is_error":true,"terminal_reason":"api_error","api_error_status":402,"subtype":"success","result":"API Error: 402 insufficient balance (1008)"}"#;
1217 let stdout = format!("{hook}\n{padding}\n{result_line}\n");
1218 assert!(
1219 stdout.len() > 3000,
1220 "fixture must put the 4295 UUID inside the 3000-byte head, got {} bytes",
1221 stdout.len()
1222 );
1223 let err = runner().classify_error(1, &stdout, "");
1224 assert!(
1225 matches!(err, AgentError::Auth { .. }),
1226 "402 envelope must classify as Auth, got: {err:?}"
1227 );
1228 let msg = err.to_string();
1229 assert!(
1230 msg.contains("insufficient balance"),
1231 "stored error must keep the 402 text, got: {msg}"
1232 );
1233 }
1234
1235 #[test]
1236 fn classify_error_prefers_clean_result_text_over_byte_truncated_tail() {
1237 let padding = "{\"type\":\"system\",\"subtype\":\"init\"}\n".repeat(50);
1248 let result_line = r#"{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"32a7c67c","total_cost_usd":0,"usage":{},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":null,"result":"Failed to authenticate: OAuth session expired and could not be refreshed","type":"result","duration_ms":141,"uuid":"fe0ccff2-54d3-47cd-b5ba-a82f99a72b45"}"#;
1249 let stdout = format!("{padding}{result_line}\n");
1250 assert!(
1254 stdout.len() > 300,
1255 "test stdout must exceed the tail-truncation window"
1256 );
1257
1258 let err = runner().classify_error(1, &stdout, "");
1259 assert!(
1260 matches!(err, AgentError::AgentFailed { .. }),
1261 "expected AgentFailed with the clean message, got: {err:?}"
1262 );
1263 let msg = err.to_string();
1264 assert_eq!(
1265 msg,
1266 "agent failed: Failed to authenticate: OAuth session expired and could not be refreshed",
1267 "stored error must be the clean result text, not a byte-truncated JSON fragment"
1268 );
1269 assert!(
1270 !msg.contains("i_error"),
1271 "stored error must not contain the byte-truncated JSON fragment, got: {msg}"
1272 );
1273 }
1274
1275 #[test]
1276 fn classify_error_timeout() {
1277 let err = runner().classify_error(124, "", "");
1278 assert!(matches!(err, AgentError::Timeout { .. }));
1279 }
1280
1281 #[test]
1282 fn extract_usage_from_model_usage() {
1283 let response_json: serde_json::Value = serde_json::json!({
1284 "modelUsage": {
1285 "claude-haiku-4-5-20251001": {
1286 "inputTokens": 1000,
1287 "outputTokens": 500
1288 }
1289 }
1290 });
1291 let envelope = response_json.as_object().unwrap();
1292 let (input, output) = extract_usage(envelope);
1293 assert_eq!(input, Some(1000));
1294 assert_eq!(output, Some(500));
1295 }
1296
1297 #[test]
1298 fn build_command_skips_an_empty_model() {
1299 let r = runner();
1300 let perms = PermissionRules {
1301 autonomous: true,
1302 sandbox: SandboxLevel::WorkspaceWrite,
1303 disallowed_tools: vec![],
1304 allowed_tools: vec![],
1305 allowed_edit_paths: vec![],
1306 deny_read_only: false,
1307 extra_writable_dirs: vec![],
1308 };
1309 for m in ["", " "] {
1310 let cmd = r.build_command(Some(m), "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1311 assert!(!cmd.contains("--model"), "{cmd}");
1312 }
1313 }
1314
1315 #[test]
1316 fn build_command_with_model() {
1317 let r = runner();
1318 let perms = PermissionRules {
1319 autonomous: true,
1320 sandbox: SandboxLevel::WorkspaceWrite,
1321 disallowed_tools: vec!["Bash(rm *)".to_string()],
1322 allowed_tools: vec![],
1323 allowed_edit_paths: vec![],
1324 deny_read_only: false,
1325 extra_writable_dirs: vec![],
1326 };
1327 let cmd = r.build_command(
1328 Some("opus"),
1329 "timeout 1800",
1330 "/tmp/sys.txt",
1331 "/tmp/msg.txt",
1332 &perms,
1333 );
1334 assert!(cmd.contains("--model 'opus'"));
1335 assert!(cmd.contains("claude -p"));
1336 assert!(cmd.contains("--output-format stream-json"));
1337 assert!(cmd.contains("--permission-mode bypassPermissions"));
1338 assert!(cmd.contains("--disallowedTools 'Bash(rm *)'"));
1339 }
1340
1341 #[test]
1342 fn build_command_supervised_mode() {
1343 let r = runner();
1344 let perms = PermissionRules {
1345 autonomous: false,
1346 sandbox: SandboxLevel::WorkspaceWrite,
1347 disallowed_tools: vec![],
1348 allowed_tools: vec![],
1349 allowed_edit_paths: vec![],
1350 deny_read_only: false,
1351 extra_writable_dirs: vec![],
1352 };
1353 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1354 assert!(cmd.contains("--permission-mode acceptEdits"));
1355 }
1356
1357 #[test]
1358 fn build_command_allowed_edit_paths_scopes_edit_write() {
1359 let r = runner();
1360 let perms = PermissionRules {
1361 autonomous: true,
1362 sandbox: SandboxLevel::WorkspaceWrite,
1363 disallowed_tools: vec![],
1364 allowed_tools: vec![
1365 "Edit".to_string(),
1366 "Write".to_string(),
1367 "Read".to_string(),
1368 "Bash".to_string(),
1369 ],
1370 allowed_edit_paths: vec![std::path::PathBuf::from("/home/user/worktree")],
1371 deny_read_only: false,
1372 extra_writable_dirs: vec![],
1373 };
1374 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1375
1376 assert!(
1378 cmd.contains("Edit(/home/user/worktree/*)"),
1379 "expected scoped Edit, got: {cmd}"
1380 );
1381 assert!(
1382 cmd.contains("Write(/home/user/worktree/*)"),
1383 "expected scoped Write, got: {cmd}"
1384 );
1385
1386 assert!(
1388 cmd.contains(",Read,") || cmd.contains("'Read,") || cmd.contains(",Read'"),
1389 "expected unrestricted Read, got: {cmd}"
1390 );
1391 assert!(
1392 cmd.contains(",Bash,") || cmd.contains("'Bash,") || cmd.contains(",Bash'"),
1393 "expected unrestricted Bash, got: {cmd}"
1394 );
1395 }
1396
1397 #[test]
1398 fn kimi_runner_name() {
1399 let r = ClaudeRunner::new("kimi");
1400 assert_eq!(r.name(), "kimi");
1401 }
1402
1403 #[test]
1406 fn fixture_claude_success() {
1407 let raw = include_str!("../../../../tests/fixtures/claude_success.json");
1408 let parsed = runner().parse_response(raw).unwrap();
1409 assert_eq!(parsed.response.status, "done");
1410 assert!(parsed.response.summary.contains("Implemented"));
1411 assert_eq!(parsed.response.accomplished.len(), 2);
1412 assert_eq!(parsed.input_tokens, Some(15000));
1413 assert_eq!(parsed.output_tokens, Some(3500));
1414 assert_eq!(parsed.duration_ms, Some(45000));
1415 }
1416
1417 #[test]
1418 fn fixture_claude_rate_limit() {
1419 let raw = include_str!("../../../../tests/fixtures/claude_error_rate_limit.json");
1420 let err = runner().parse_response(raw).unwrap_err();
1421 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1422 }
1423
1424 #[test]
1427 fn fixture_claude_stream_success() {
1428 let raw = include_str!("../../../../tests/fixtures/claude_stream_success.jsonl");
1429 let parsed = runner().parse_response(raw).unwrap();
1430 assert_eq!(parsed.response.status, "done");
1431 assert!(parsed.response.summary.contains("Implemented"));
1432 assert_eq!(parsed.response.accomplished.len(), 2);
1433 assert_eq!(parsed.input_tokens, Some(15000));
1434 assert_eq!(parsed.output_tokens, Some(3500));
1435 assert_eq!(parsed.duration_ms, Some(45000));
1436 }
1437
1438 #[test]
1439 fn fixture_claude_stream_error() {
1440 let raw = include_str!("../../../../tests/fixtures/claude_stream_error.jsonl");
1441 let err = runner().parse_response(raw).unwrap_err();
1442 assert!(matches!(err, AgentError::Auth { .. }), "got: {err:?}");
1443 }
1444
1445 #[test]
1446 fn fixture_claude_stream_rate_limit() {
1447 let raw = include_str!("../../../../tests/fixtures/claude_stream_rate_limit.jsonl");
1448 let err = runner().parse_response(raw).unwrap_err();
1449 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1450 }
1451
1452 #[test]
1453 fn fixture_claude_stream_thinking_block_conflict() {
1454 let raw =
1455 include_str!("../../../../tests/fixtures/claude_stream_thinking_block_conflict.jsonl");
1456 let err = runner().parse_response(raw).unwrap_err();
1457 assert!(
1458 matches!(err, AgentError::ThinkingBlockConflict { .. }),
1459 "expected ThinkingBlockConflict, got: {err:?}"
1460 );
1461 }
1462
1463 #[test]
1464 fn classify_error_thinking_block_conflict_from_stderr() {
1465 let stderr = "claude failed: API Error: 400 messages.1.content.3: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.";
1468 let err = runner().classify_error(1, "", stderr);
1469 assert!(
1470 matches!(err, AgentError::ThinkingBlockConflict { .. }),
1471 "expected ThinkingBlockConflict, got: {err:?}"
1472 );
1473 }
1474
1475 #[test]
1476 fn thinking_block_conflict_does_not_match_unrelated_text() {
1477 let unrelated = "I was thinking about how to handle blocks of code in the response.";
1480 let err = runner().classify_error(1, "", unrelated);
1481 assert!(
1482 !matches!(err, AgentError::ThinkingBlockConflict { .. }),
1483 "should not classify unrelated text as ThinkingBlockConflict, got: {err:?}"
1484 );
1485 }
1486
1487 #[test]
1488 fn parse_ndjson_with_intermediate_events() {
1489 let raw = concat!(
1491 r#"{"type":"system","subtype":"init"}"#,
1492 "\n",
1493 r#"{"type":"assistant","message":{"role":"assistant","content":[]}}"#,
1494 "\n",
1495 r#"{"type":"tool_use","tool":{"name":"Read","input":{}}}"#,
1496 "\n",
1497 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1000,"result":"{\"status\":\"done\",\"summary\":\"streamed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":5,"output_tokens":10}}"#,
1498 "\n"
1499 );
1500 let parsed = runner().parse_response(raw).unwrap();
1501 assert_eq!(parsed.response.status, "done");
1502 assert_eq!(parsed.response.summary, "streamed");
1503 assert_eq!(parsed.input_tokens, Some(5));
1504 assert_eq!(parsed.output_tokens, Some(10));
1505 }
1506
1507 #[test]
1508 fn parse_ndjson_skips_malformed_lines() {
1509 let raw = concat!(
1511 "not valid json\n",
1512 r#"{"type":"partial_update","data":"something"}"#,
1513 "\n",
1514 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":500,"result":"{\"status\":\"done\",\"summary\":\"ok\",\"accomplished\":[],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":1,"output_tokens":2}}"#,
1515 "\n"
1516 );
1517 let parsed = runner().parse_response(raw).unwrap();
1518 assert_eq!(parsed.response.status, "done");
1519 assert_eq!(parsed.response.summary, "ok");
1520 }
1521
1522 #[test]
1523 fn parse_single_line_json_backward_compat() {
1524 let raw = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"result":"{\"status\":\"done\",\"summary\":\"backward compat\",\"accomplished\":[],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":50,"output_tokens":25}}"#;
1526 let parsed = runner().parse_response(raw).unwrap();
1527 assert_eq!(parsed.response.status, "done");
1528 assert_eq!(parsed.response.summary, "backward compat");
1529 assert_eq!(parsed.input_tokens, Some(50));
1530 assert_eq!(parsed.output_tokens, Some(25));
1531 }
1532
1533 #[test]
1536 fn kimi_parses_claude_envelope() {
1537 let r = ClaudeRunner::new("kimi");
1538 let raw = r#"{
1539 "type": "result",
1540 "subtype": "success",
1541 "is_error": false,
1542 "duration_ms": 3000,
1543 "result": "{\"status\":\"done\",\"summary\":\"kimi did it\",\"accomplished\":[\"task\"],\"remaining\":[],\"files\":[\"a.rs\"]}",
1544 "usage": {"input_tokens": 200, "output_tokens": 100}
1545 }"#;
1546
1547 let parsed = r.parse_response(raw).unwrap();
1548 assert_eq!(parsed.response.status, "done");
1549 assert_eq!(parsed.response.summary, "kimi did it");
1550 }
1551
1552 #[test]
1553 fn minimax_parses_claude_envelope() {
1554 let r = ClaudeRunner::new("minimax");
1555 assert_eq!(r.name(), "minimax");
1556 let raw = r#"{
1557 "type": "result",
1558 "subtype": "success",
1559 "is_error": false,
1560 "result": "{\"status\":\"done\",\"summary\":\"minimax did it\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1561 "usage": {}
1562 }"#;
1563
1564 let parsed = r.parse_response(raw).unwrap();
1565 assert_eq!(parsed.response.status, "done");
1566 }
1567
1568 #[test]
1569 fn kimi_build_command_uses_kimi_binary() {
1570 let r = ClaudeRunner::new("kimi");
1571 let perms = PermissionRules {
1572 autonomous: true,
1573 sandbox: SandboxLevel::WorkspaceWrite,
1574 disallowed_tools: vec![],
1575 allowed_tools: vec![],
1576 allowed_edit_paths: vec![],
1577 deny_read_only: false,
1578 extra_writable_dirs: vec![],
1579 };
1580 let cmd = r.build_command(
1581 Some("sonnet"),
1582 "timeout 1800",
1583 "/tmp/sys.txt",
1584 "/tmp/msg.txt",
1585 &perms,
1586 );
1587 assert!(cmd.contains("kimi -p"), "expected kimi binary, got: {cmd}");
1588 assert!(cmd.contains("--model 'sonnet'"));
1589 }
1590
1591 #[test]
1592 fn build_command_with_allowed_tools() {
1593 let r = runner();
1594 let perms = PermissionRules {
1595 autonomous: true,
1596 sandbox: SandboxLevel::WorkspaceWrite,
1597 disallowed_tools: vec!["Bash(rm *)".to_string()], allowed_tools: vec![
1599 "Edit".to_string(),
1600 "Write".to_string(),
1601 "Read".to_string(),
1602 "Bash".to_string(),
1603 "Grep".to_string(),
1604 "git".to_string(),
1605 "npm".to_string(),
1606 ],
1607 allowed_edit_paths: vec![],
1608 deny_read_only: false,
1609 extra_writable_dirs: vec![],
1610 };
1611 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1612
1613 assert!(
1615 cmd.contains("--allowedTools"),
1616 "expected --allowedTools flag, got: {cmd}"
1617 );
1618 assert!(
1619 !cmd.contains("--disallowedTools"),
1620 "should not have --disallowedTools when allowed_tools is set, got: {cmd}"
1621 );
1622
1623 assert!(cmd.contains("Edit"), "missing Edit tool");
1625 assert!(cmd.contains("Write"), "missing Write tool");
1626 assert!(cmd.contains("Bash"), "missing Bash tool");
1627
1628 assert!(
1630 cmd.contains("Bash(git *)"),
1631 "missing Bash(git *) pattern, got: {cmd}"
1632 );
1633 assert!(
1634 cmd.contains("Bash(npm *)"),
1635 "missing Bash(npm *) pattern, got: {cmd}"
1636 );
1637 }
1638
1639 #[test]
1640 fn build_command_allowed_tools_empty_falls_back_to_disallowed() {
1641 let r = runner();
1642 let perms = PermissionRules {
1643 autonomous: true,
1644 sandbox: SandboxLevel::WorkspaceWrite,
1645 disallowed_tools: vec!["Bash(rm *)".to_string()],
1646 allowed_tools: vec![], allowed_edit_paths: vec![],
1648 deny_read_only: false,
1649 extra_writable_dirs: vec![],
1650 };
1651 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1652
1653 assert!(
1654 cmd.contains("--disallowedTools"),
1655 "should fall back to --disallowedTools when allowed_tools is empty, got: {cmd}"
1656 );
1657 assert!(
1658 !cmd.contains("--allowedTools"),
1659 "should not have --allowedTools when empty, got: {cmd}"
1660 );
1661 }
1662
1663 #[test]
1664 fn translate_allowed_tools_categorizes_correctly() {
1665 let allowed = vec![
1666 "Edit".to_string(),
1667 "Bash".to_string(),
1668 "Grep".to_string(),
1669 "git".to_string(),
1670 "cargo".to_string(),
1671 "WebFetch".to_string(),
1672 ];
1673 let result = translate_allowed_tools(&allowed, &[]);
1675 assert_eq!(
1676 result,
1677 vec![
1678 "Edit",
1679 "Bash",
1680 "Grep",
1681 "Bash(git *)",
1682 "Bash(cargo *)",
1683 "WebFetch"
1684 ]
1685 );
1686 }
1687
1688 #[test]
1689 fn translate_allowed_tools_scopes_edit_write() {
1690 let allowed = vec!["Edit".to_string(), "Write".to_string(), "Read".to_string()];
1691 let paths = vec![std::path::PathBuf::from("/tmp/worktree")];
1692 let result = translate_allowed_tools(&allowed, &paths);
1693 assert_eq!(
1694 result,
1695 vec!["Edit(/tmp/worktree/*)", "Write(/tmp/worktree/*)", "Read"]
1696 );
1697 }
1698
1699 #[test]
1700 fn classify_error_overloaded_529() {
1701 let err = runner().classify_error(1, "", "overloaded_error: 529");
1703 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1704 }
1705
1706 #[test]
1707 fn classify_error_context_length() {
1708 let err = runner().classify_error(1, "", "context_length_exceeded: max 200000 tokens");
1709 assert!(
1710 matches!(err, AgentError::ContextOverflow { .. }),
1711 "got: {err:?}"
1712 );
1713 }
1714
1715 #[test]
1719 fn extract_text_stream_json_returns_result_field() {
1720 let raw = concat!(
1721 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1722 "\n",
1723 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1724 "\n",
1725 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1726 );
1727 let text = runner().extract_text(raw).unwrap();
1728 assert_eq!(
1729 text,
1730 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1731 );
1732 }
1733
1734 #[test]
1736 fn extract_text_stream_json_markdown_result() {
1737 let review_json =
1738 r#"```json\n{"decision":"request_changes","notes":"Fix it","issues":[]}\n```"#;
1739 let result_line = format!(
1740 r#"{{"type":"result","subtype":"success","is_error":false,"result":"{review_json}","usage":{{"input_tokens":10,"output_tokens":5}}}}"#
1741 );
1742 let raw = format!(
1743 "{}\n{}",
1744 r#"{"type":"system","subtype":"init","session_id":"s1"}"#, result_line
1745 );
1746 let text = runner().extract_text(&raw).unwrap();
1747 assert!(
1748 text.contains("request_changes"),
1749 "expected review JSON in result, got: {text}"
1750 );
1751 }
1752
1753 #[test]
1755 fn extract_text_stream_json_rate_limit_propagates() {
1756 let raw = concat!(
1757 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1758 "\n",
1759 r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded: 429 Too Many Requests","usage":{"input_tokens":0,"output_tokens":0}}"#,
1760 );
1761 let err = runner().extract_text(raw).unwrap_err();
1762 assert!(
1763 matches!(err, AgentError::RateLimit { .. }),
1764 "expected RateLimit, got: {err:?}"
1765 );
1766 }
1767
1768 #[test]
1769 fn extract_stream_json_fallbacks_to_opencode_extractor_on_leading_init() {
1770 let raw = concat!(
1775 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1776 "\n",
1777 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"status\":\"done\",\"summary\":\"fallback\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}]}}"#,
1779 );
1780
1781 let text = extract_stream_json_result_text(raw).expect("should fallback and extract JSON");
1782 let parsed: serde_json::Value =
1783 serde_json::from_str(&text).expect("extracted text must be JSON");
1784 assert_eq!(parsed["status"], "done");
1785 assert_eq!(parsed["summary"], "fallback");
1786 }
1787
1788 #[test]
1790 fn extract_text_stream_json_auth_error_propagates() {
1791 let raw = concat!(
1792 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1793 "\n",
1794 r#"{"type":"result","subtype":"error","is_error":true,"result":"401 Unauthorized: invalid api key","usage":{"input_tokens":0,"output_tokens":0}}"#,
1795 );
1796 let err = runner().extract_text(raw).unwrap_err();
1797 assert!(
1798 matches!(err, AgentError::Auth { .. }),
1799 "expected Auth, got: {err:?}"
1800 );
1801 }
1802
1803 #[test]
1805 fn extract_text_empty_input_returns_empty_ok() {
1806 let text = runner().extract_text("").unwrap();
1807 assert_eq!(text, "");
1808 }
1809
1810 #[test]
1813 fn find_claude_result_success_with_tokens() {
1814 let ndjson = concat!(
1815 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1816 "\n",
1817 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working..."}]}}"#,
1818 "\n",
1819 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}","usage":{"input_tokens":1500,"output_tokens":200},"duration_ms":5000}"#,
1820 );
1821 let result = find_claude_result(ndjson).expect("should find result");
1822 assert!(!result.is_error);
1823 assert!(result.result_text.contains("approve"));
1824 assert_eq!(result.input_tokens, Some(1500));
1825 assert_eq!(result.output_tokens, Some(200));
1826 assert_eq!(result.duration_ms, Some(5000));
1827 }
1828
1829 #[test]
1830 fn find_claude_result_error_flag() {
1831 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1832 let result = find_claude_result(ndjson).expect("should find result");
1833 assert!(result.is_error);
1834 assert!(result.result_text.contains("rate limit"));
1835 }
1836
1837 #[test]
1838 fn find_claude_result_error_envelope_with_empty_result_uses_errors() {
1839 let ndjson = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"num_turns":0,"errors":["Internal error: {\"message\":\"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\"http_status\":402}"]}"#;
1842 let result = find_claude_result(ndjson).expect("should find result");
1843 assert!(result.is_error);
1844 assert!(result.result_text.contains("usage balance exhausted"));
1845 assert!(crate::engine::cooldown::detect_credit_exhaustion(&result.result_text).is_some());
1846 }
1847
1848 #[test]
1849 fn find_claude_result_empty_returns_none() {
1850 assert!(find_claude_result("").is_none());
1851 assert!(find_claude_result(" ").is_none());
1852 }
1853
1854 #[test]
1855 fn find_claude_result_no_result_line_falls_back_to_assistant() {
1856 let ndjson = concat!(
1858 r#"{"type":"system","subtype":"init","model":"MiniMax-M2.7"}"#,
1859 "\n",
1860 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analysis complete"}],"usage":{"input_tokens":800,"output_tokens":150}}}"#,
1861 );
1862 let result = find_claude_result(ndjson).expect("should fall back to assistant text");
1863 assert!(!result.is_error);
1864 assert_eq!(result.result_text, "analysis complete");
1865 assert_eq!(result.input_tokens, Some(800));
1866 assert_eq!(result.output_tokens, Some(150));
1867 }
1868
1869 #[test]
1870 fn find_claude_result_plain_text_returns_none() {
1871 assert!(find_claude_result("just some plain text output").is_none());
1873 }
1874
1875 #[test]
1876 fn strip_tty_control_lines_drops_show_cursor_and_ctrl_d_redraw() {
1877 let redraw = "^D\u{0008}".repeat(20);
1878 let raw = format!(
1879 "{}\n{}\n{}\n{}\n{}",
1880 r#"{"type":"system","subtype":"init"}"#,
1881 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"#,
1882 SHOW_CURSOR,
1883 redraw,
1884 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1885 );
1886 let cleaned = strip_tty_control_lines(&raw);
1887 assert!(
1888 !cleaned.contains(SHOW_CURSOR),
1889 "show-cursor sequence must be stripped"
1890 );
1891 assert!(
1892 !cleaned.contains('\u{0008}'),
1893 "backspace redraw line must be stripped"
1894 );
1895 assert!(cleaned.contains("\"type\":\"result\""));
1896 assert!(cleaned.contains("hello"));
1897 }
1898
1899 #[test]
1900 fn strip_tty_control_lines_strips_trailing_cursor_on_json_line() {
1901 let line = format!(
1902 r#"{{"type":"result","subtype":"success","is_error":false,"result":"ok"}}{SHOW_CURSOR}"#
1903 );
1904 let cleaned = strip_tty_control_lines(&line);
1905 assert_eq!(
1906 cleaned,
1907 r#"{"type":"result","subtype":"success","is_error":false,"result":"ok"}"#
1908 );
1909 }
1910
1911 #[test]
1912 fn find_claude_result_ignores_trailing_tty_junk() {
1913 let ndjson = format!(
1914 "{}\n{}\n{}",
1915 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working"}]}}"#,
1916 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}","usage":{"input_tokens":10,"output_tokens":4}}"#,
1917 SHOW_CURSOR,
1918 );
1919 let result = find_claude_result(&ndjson).expect("result envelope should still parse");
1920 assert!(result.result_text.contains("approve"));
1921 assert_eq!(result.input_tokens, Some(10));
1922 }
1923
1924 #[test]
1925 fn review_session_exited_mid_thought_detects_thinking_without_envelope() {
1926 let ndjson = concat!(
1927 r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
1928 "\n",
1929 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers:"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1930 "\n",
1931 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1932 );
1933 assert!(review_session_exited_mid_thought(ndjson));
1934 assert!(find_ndjson_result_line(ndjson).is_none());
1935 }
1936
1937 #[test]
1938 fn review_session_exited_mid_thought_detects_tool_use_without_envelope() {
1939 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
1940 assert!(review_session_exited_mid_thought(ndjson));
1941 }
1942
1943 #[test]
1944 fn review_session_exited_mid_thought_false_when_envelope_present() {
1945 let ndjson = concat!(
1946 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"done"}]}}"#,
1947 "\n",
1948 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1949 );
1950 assert!(!review_session_exited_mid_thought(ndjson));
1951 }
1952
1953 #[test]
1954 fn review_session_exited_mid_thought_false_for_finished_text() {
1955 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#;
1956 assert!(!review_session_exited_mid_thought(ndjson));
1957 }
1958
1959 #[test]
1960 fn find_claude_result_model_usage() {
1961 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":5000,"outputTokens":1000}},"duration_ms":3000}"#;
1962 let result = find_claude_result(ndjson).expect("should extract modelUsage");
1963 assert_eq!(result.input_tokens, Some(5000));
1964 assert_eq!(result.output_tokens, Some(1000));
1965 }
1966
1967 #[test]
1974 fn find_ndjson_result_line_skips_plain_text_result() {
1975 let inner_result = serde_json::json!({"status": "done", "summary": "fix applied"});
1977 let json_result_line = serde_json::json!({
1978 "type": "result",
1979 "subtype": "success",
1980 "is_error": false,
1981 "duration_ms": 273_044,
1982 "result": inner_result.to_string(),
1983 "usage": {"input_tokens": 12, "output_tokens": 1577}
1984 })
1985 .to_string();
1986 let plain_result_line = serde_json::json!({
1987 "type": "result",
1988 "subtype": "success",
1989 "is_error": false,
1990 "duration_ms": 2352,
1991 "result": "All 2352 tests passed.",
1992 "usage": {"input_tokens": 3, "output_tokens": 16}
1993 })
1994 .to_string();
1995 let mut ndjson = json_result_line;
1997 ndjson.push('\n');
1998 ndjson.push_str(r#"{"type":"system","subtype":"task_notification"}"#);
1999 ndjson.push('\n');
2000 ndjson.push_str(r#"{"type":"system","subtype":"init"}"#);
2001 ndjson.push('\n');
2002 ndjson.push_str(
2003 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"summary here"}]}}"#,
2004 );
2005 ndjson.push('\n');
2006 ndjson.push_str(&plain_result_line);
2007
2008 let line = find_ndjson_result_line(&ndjson)
2009 .expect("should find a result line")
2010 .0;
2011 let parsed: serde_json::Value =
2013 serde_json::from_str(line).expect("returned line must be valid JSON");
2014 let result_val = parsed.get("result").expect("result field must exist");
2015 assert!(
2016 result_val.is_string(),
2017 "result field must be a JSON string (not plain text), got: {result_val}"
2018 );
2019 let result_str = result_val.as_str().unwrap();
2020 assert!(
2021 result_str.starts_with('{'),
2022 "result must be JSON (object), got: {result_str}"
2023 );
2024 assert!(
2025 !result_str.contains("All 2352 tests passed"),
2026 "must NOT return outer plain-text result: {result_str}"
2027 );
2028 }
2029
2030 #[test]
2035 fn find_ndjson_result_line_prefers_more_tokens_on_plain_text() {
2036 let inner_result_line = serde_json::json!({
2038 "type": "result",
2039 "subtype": "success",
2040 "is_error": false,
2041 "duration_ms": 273_044,
2042 "result": "All 2352 tests pass. The fix is clean and correct.",
2043 "usage": {"input_tokens": 12, "output_tokens": 1577}
2044 })
2045 .to_string();
2046 let outer_result_line = serde_json::json!({
2047 "type": "result",
2048 "subtype": "success",
2049 "is_error": false,
2050 "duration_ms": 2352,
2051 "result": "Already retrieved the result — all 2352 tests passed.",
2052 "usage": {"input_tokens": 3, "output_tokens": 16}
2053 })
2054 .to_string();
2055
2056 let ndjson = format!(
2058 "{0}\n{1}\n{2}",
2059 inner_result_line, r#"{"type":"system","subtype":"init"}"#, outer_result_line
2060 );
2061
2062 let (line, tokens) = find_ndjson_result_line(&ndjson).expect("should find a result line");
2063
2064 assert!(
2066 line.contains("The fix is clean"),
2067 "expected inner result (more tokens), got: {line}"
2068 );
2069 assert_eq!(
2071 tokens,
2072 Some((12, 1577)),
2073 "tokens must be from the inner result"
2074 );
2075 }
2076
2077 #[test]
2080 fn parse_response_preserves_tokens_on_synthesis_fallback() {
2081 let ndjson = concat!(
2084 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":273044,"result":"All 2352 tests pass. The fix is clean and correct.","usage":{"input_tokens":12,"output_tokens":1577}}"#,
2085 "\n",
2086 r#"{"type":"system","subtype":"init"}"#,
2087 "\n",
2088 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"Already retrieved the result — all 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
2089 );
2090
2091 let parsed = runner()
2092 .parse_response(ndjson)
2093 .expect("should parse successfully");
2094
2095 assert_eq!(
2097 parsed.input_tokens,
2098 Some(12),
2099 "input_tokens must be preserved (not lost on synthesis fallback)"
2100 );
2101 assert_eq!(
2102 parsed.output_tokens,
2103 Some(1577),
2104 "output_tokens must be preserved (not lost on synthesis fallback)"
2105 );
2106 }
2107
2108 #[test]
2109 fn parse_response_uses_inner_json_result_preserving_tokens() {
2110 let ndjson = concat!(
2113 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":273044,"result":"{\"status\":\"done\",\"summary\":\"fix applied\",\"accomplished\":[\"fix\"],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":12,"output_tokens":1577}}"#,
2114 "\n",
2115 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2116 "\n",
2117 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"tests passed"}]}}"#,
2118 "\n",
2119 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"All 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
2120 );
2121
2122 let parsed = runner()
2123 .parse_response(ndjson)
2124 .expect("should parse successfully");
2125
2126 assert_eq!(
2128 parsed.response.status, "done",
2129 "expected inner JSON result status, got: {:?}",
2130 parsed.response.status
2131 );
2132 assert_eq!(
2133 parsed.response.summary.as_str(),
2134 "fix applied",
2135 "expected inner JSON result summary, got: {:?}",
2136 parsed.response.summary
2137 );
2138 assert_eq!(
2140 parsed.input_tokens,
2141 Some(12),
2142 "input_tokens must be from inner result (not outer plain-text result)"
2143 );
2144 assert_eq!(
2145 parsed.output_tokens,
2146 Some(1577),
2147 "output_tokens must be from inner result (not outer plain-text result)"
2148 );
2149 }
2150
2151 #[test]
2155 fn extract_text_falls_back_to_opencode_style_when_no_json_result() {
2156 let ndjson = concat!(
2158 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2159 "\n",
2160 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All tasks complete"}]}}"#,
2161 );
2162
2163 let text = runner().extract_text(ndjson).expect("should extract text");
2164 assert!(
2166 text.contains("All tasks complete"),
2167 "expected extracted assistant text, got: {text}"
2168 );
2169 assert!(!text.contains("type"), "must not return raw NDJSON: {text}");
2170 }
2171}
2172
2173pub(crate) fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2175 let blocks = || v["message"]["content"].as_array().into_iter().flatten();
2176 match json_str(v, "/type") {
2177 "system" if json_str(v, "/subtype") == "init" => vec![StreamEvent::Started {
2178 model: json_str(v, "/model").to_string(),
2179 }],
2180 "assistant" => blocks()
2181 .filter_map(|b| match json_str(b, "/type") {
2182 "text" => Some(StreamEvent::Text(json_str(b, "/text").to_string())),
2183 "thinking" => Some(StreamEvent::Thinking(json_str(b, "/thinking").to_string())),
2184 "tool_use" => Some(StreamEvent::ToolCall {
2185 id: json_str(b, "/id").to_string(),
2186 name: json_str(b, "/name").to_string(),
2187 input: b["input"].clone(),
2188 }),
2189 _ => None,
2190 })
2191 .collect(),
2192 "user" => blocks()
2193 .filter(|b| json_str(b, "/type") == "tool_result")
2194 .map(|b| StreamEvent::ToolResult {
2195 id: json_str(b, "/tool_use_id").to_string(),
2196 name: String::new(),
2197 output: match &b["content"] {
2198 serde_json::Value::String(s) => s.clone(),
2199 serde_json::Value::Array(parts) => parts
2200 .iter()
2201 .filter_map(|p| p["text"].as_str())
2202 .collect::<Vec<_>>()
2203 .join("\n"),
2204 _ => String::new(),
2205 },
2206 is_error: b["is_error"].as_bool() == Some(true),
2207 })
2208 .collect(),
2209 "error" => vec![StreamEvent::Error(json_error_message(v))],
2210 "result" => {
2211 let failed = v["is_error"].as_bool() == Some(true)
2212 || json_str(v, "/subtype").starts_with("error");
2213 if failed {
2214 return vec![StreamEvent::Error(json_str(v, "/result").to_string())];
2215 }
2216
2217 let turns = v["num_turns"].as_u64().unwrap_or(0);
2218 let secs = v["duration_ms"].as_u64().unwrap_or(0) / 1000;
2219 let time = if secs >= 60 {
2220 format!("{}m {}s", secs / 60, secs % 60)
2221 } else {
2222 format!("{secs}s")
2223 };
2224 vec![StreamEvent::Done(format!("{turns} turns · {time}"))]
2225 }
2226 _ => vec![],
2227 }
2228}
2229
2230#[cfg(test)]
2231mod stream_tests {
2232 use super::*;
2233 use serde_json::json;
2234
2235 fn events(line: &str) -> Vec<StreamEvent> {
2236 ClaudeRunner::new("claude").stream_events(&serde_json::from_str(line).unwrap())
2237 }
2238
2239 #[test]
2240 fn init_reports_model() {
2241 let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-5","cwd":"/w"}"#;
2242 assert_eq!(
2243 events(line),
2244 vec![StreamEvent::Started {
2245 model: "claude-sonnet-5".into()
2246 }]
2247 );
2248 }
2249
2250 #[test]
2251 fn assistant_blocks_in_order() {
2252 let line = r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"plan"},{"type":"text","text":"Reading."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"a.rs"}}]}}"#;
2253 assert_eq!(
2254 events(line),
2255 vec![
2256 StreamEvent::Thinking("plan".into()),
2257 StreamEvent::Text("Reading.".into()),
2258 StreamEvent::ToolCall {
2259 id: "t1".into(),
2260 name: "Read".into(),
2261 input: json!({"file_path": "a.rs"}),
2262 },
2263 ]
2264 );
2265 }
2266
2267 #[test]
2268 fn tool_result_string_and_block_content() {
2269 let line = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"},{"type":"tool_result","tool_use_id":"t2","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}],"is_error":true}]}}"#;
2270 assert_eq!(
2271 events(line),
2272 vec![
2273 StreamEvent::ToolResult {
2274 id: "t1".into(),
2275 name: String::new(),
2276 output: "ok".into(),
2277 is_error: false,
2278 },
2279 StreamEvent::ToolResult {
2280 id: "t2".into(),
2281 name: String::new(),
2282 output: "a\nb".into(),
2283 is_error: true,
2284 },
2285 ]
2286 );
2287 }
2288
2289 #[test]
2290 fn result_success_and_error() {
2291 let ok = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":66,"duration_ms":580214}"#;
2292 assert_eq!(
2293 events(ok),
2294 vec![StreamEvent::Done("66 turns · 9m 40s".into())]
2295 );
2296 let err = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"too many turns"}"#;
2297 assert_eq!(
2298 events(err),
2299 vec![StreamEvent::Error("too many turns".into())]
2300 );
2301 }
2302
2303 #[test]
2304 fn noise_events_are_dropped() {
2305 for line in [
2306 r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
2307 r#"{"type":"system","subtype":"task_started"}"#,
2308 r#"{"type":"rate_limit_event","rate_limit_info":{}}"#,
2309 r#"{"type":"tool_progress"}"#,
2310 ] {
2311 assert!(events(line).is_empty(), "{line}");
2312 }
2313 }
2314}