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