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