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(|m| format!("--model {}", super::shell_single_quote(m)))
370 .unwrap_or_default();
371
372 let permission_mode = if permissions.autonomous {
374 "bypassPermissions"
375 } else {
376 "acceptEdits"
377 };
378
379 let tool_flag = if !permissions.allowed_tools.is_empty() {
383 let tools = translate_allowed_tools(
384 &permissions.allowed_tools,
385 &permissions.allowed_edit_paths,
386 );
387 format!("--allowedTools '{}'", tools.join(","))
388 } else if !permissions.disallowed_tools.is_empty() {
389 format!(
390 "--disallowedTools '{}'",
391 permissions.disallowed_tools.join(",")
392 )
393 } else {
394 String::new()
395 };
396
397 format!(
398 r#"{timeout_cmd} {binary} -p --verbose {model_flag} \
399 --permission-mode {permission_mode} \
400 --output-format stream-json \
401 {tool_flag} \
402 --append-system-prompt "{sys_file}" \
403 < "{msg_file}""#,
404 timeout_cmd = timeout_cmd,
405 binary = self.binary,
406 model_flag = model_flag,
407 permission_mode = permission_mode,
408 tool_flag = tool_flag,
409 sys_file = sys_file,
410 msg_file = msg_file,
411 )
412 }
413
414 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
415 let trimmed = raw.trim();
416 if trimmed.is_empty() {
417 return Err(AgentError::InvalidResponse { raw: String::new() });
418 }
419
420 if let Some((result_line, tokens)) = find_ndjson_result_line(trimmed) {
423 let result = self.parse_envelope(result_line)?;
424 let input_tokens = result.input_tokens.or(tokens.map(|(i, _)| i));
428 let output_tokens = result.output_tokens.or(tokens.map(|(_, o)| o));
429 if input_tokens != result.input_tokens || output_tokens != result.output_tokens {
430 return Ok(ParsedResponse {
431 input_tokens,
432 output_tokens,
433 ..result
434 });
435 }
436 return Ok(result);
437 }
438
439 self.parse_envelope(trimmed)
441 }
442
443 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
444 let error_result: Option<String> = (|| {
448 let trimmed = stdout.trim();
449 let (line, _) = find_ndjson_result_line(trimmed)?;
450 let val: serde_json::Value = serde_json::from_str(line).ok()?;
451 let obj = val.as_object()?;
452 if obj.get("is_error").and_then(|v| v.as_bool()) != Some(true) {
453 return None;
454 }
455 Some(envelope_error_text(obj)).filter(|t| !t.is_empty())
456 })();
457
458 let system_error: Option<String> = (|| {
466 let trimmed = stdout.trim();
467 for line in trimmed.lines() {
468 let line = line.trim();
469 if line.is_empty() {
470 continue;
471 }
472 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
473 if let Some(obj) = val.as_object() {
474 if obj.get("type").and_then(|t| t.as_str()) == Some("system") {
475 if let Some(error) = obj.get("error") {
476 if let Some(msg) = error.get("message").and_then(|m| m.as_str()) {
477 return Some(msg.to_string());
478 }
479 }
480 }
481 }
482 }
483 }
484 None
485 })();
486
487 let combined = match (&error_result, &system_error) {
488 (Some(r), Some(s)) => format!("{s}\n{r}\n{stdout}\n{stderr}"),
489 (Some(r), None) => format!("{r}\n{stdout}\n{stderr}"),
490 (None, Some(s)) => format!("{s}\n{stdout}\n{stderr}"),
491 (None, None) => format!("{stdout}\n{stderr}"),
492 };
493 let classified = super::patterns::classify_from_text(exit_code, &combined);
494
495 if let (AgentError::Unknown { .. }, Some(message)) = (&classified, &error_result) {
503 if !message.is_empty() {
504 return AgentError::AgentFailed {
505 message: message.clone(),
506 };
507 }
508 }
509 classified
510 }
511
512 fn router_command(
513 &self,
514 prompt: &str,
515 model: Option<&str>,
516 ) -> anyhow::Result<tokio::process::Command> {
517 let mut cmd = tokio::process::Command::new(&self.binary);
518 cmd.env_remove("CLAUDECODE"); cmd.arg("--output-format")
520 .arg("stream-json")
521 .arg("--verbose")
522 .arg("--print");
523 if let Some(m) = model {
524 cmd.arg("--model").arg(m);
525 }
526 cmd.arg(prompt);
527 Ok(cmd)
528 }
529}
530
531const CLAUDE_NATIVE_TOOLS: &[&str] = &[
534 "Edit",
535 "Write",
536 "Read",
537 "Bash",
538 "Grep",
539 "Glob",
540 "WebFetch",
541 "WebSearch",
542 "Task",
543 "Skill",
544 "NotebookEdit",
545 "EnterPlanMode",
546 "ExitPlanMode",
547 "EnterWorktree",
548 "AskUserQuestion",
549 "TodoWrite",
550 "TodoRead",
551];
552
553fn translate_allowed_tools(
559 allowed: &[String],
560 allowed_edit_paths: &[std::path::PathBuf],
561) -> Vec<String> {
562 let mut tools = Vec::new();
563 for item in allowed {
564 if (item == "Edit" || item == "Write") && !allowed_edit_paths.is_empty() {
565 for path in allowed_edit_paths {
567 tools.push(format!("{}({}/*)", item, path.display()));
568 }
569 } else if CLAUDE_NATIVE_TOOLS.contains(&item.as_str()) {
570 tools.push(item.clone());
571 } else {
572 tools.push(format!("Bash({item} *)"));
574 }
575 }
576 tools
577}
578
579pub(crate) fn find_ndjson_result_line(text: &str) -> Option<(&str, Option<(u64, u64)>)> {
594 let lines: Vec<_> = text.lines().filter(|l| !l.trim().is_empty()).collect();
595
596 for line in lines.iter().rev() {
599 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
600 if let Some(obj) = val.as_object() {
601 if obj.get("type").and_then(|t| t.as_str()) == Some("result") {
602 if let Some(result) = obj.get("result") {
603 let is_json_result = match result {
604 serde_json::Value::Object(_) | serde_json::Value::Array(_) => true,
605 serde_json::Value::String(s) => {
606 serde_json::from_str::<serde_json::Value>(s).is_ok()
607 }
608 _ => false,
609 };
610 if is_json_result {
611 let tokens = extract_result_tokens(obj);
612 return Some((line, tokens));
613 }
614 }
615 }
616 }
617 }
618 }
619
620 let mut best_line: Option<&str> = None;
626 let mut best_total = 0u64;
627
628 for line in &lines {
629 if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
630 if let Some(obj) = val.as_object() {
631 if obj.get("type").and_then(|t| t.as_str()) == Some("result") {
632 let tokens = extract_result_tokens(obj);
633 let total = tokens.map(|(i, o)| i.saturating_add(o)).unwrap_or(0);
634 if total >= best_total {
635 best_total = total;
636 best_line = Some(*line);
637 }
638 }
639 }
640 }
641 }
642
643 best_line.map(|line| (line, extract_result_tokens_from_line(line)))
644}
645
646fn extract_result_tokens(obj: &serde_json::Map<String, serde_json::Value>) -> Option<(u64, u64)> {
648 if let Some(usage) = obj.get("usage").and_then(|v| v.as_object()) {
650 let input = usage.get("input_tokens").and_then(|v| v.as_u64());
651 let output = usage.get("output_tokens").and_then(|v| v.as_u64());
652 if let (Some(i), Some(o)) = (input, output) {
653 return Some((i, o));
654 }
655 }
656 if let Some(model_usage) = obj.get("modelUsage").and_then(|v| v.as_object()) {
658 let mut total_input = 0u64;
659 let mut total_output = 0u64;
660 let mut found = false;
661 for (_model_name, model_stats) in model_usage {
662 if let Some(stats) = model_stats.as_object() {
663 if let Some(i) = stats
664 .get("inputTokens")
665 .or_else(|| stats.get("input_tokens"))
666 .and_then(|v| v.as_u64())
667 {
668 total_input += i;
669 found = true;
670 }
671 if let Some(o) = stats
672 .get("outputTokens")
673 .or_else(|| stats.get("output_tokens"))
674 .and_then(|v| v.as_u64())
675 {
676 total_output += o;
677 found = true;
678 }
679 }
680 }
681 if found {
682 return Some((total_input, total_output));
683 }
684 }
685 None
686}
687
688fn extract_result_tokens_from_line(line: &str) -> Option<(u64, u64)> {
690 let val = serde_json::from_str::<serde_json::Value>(line).ok()?;
691 let obj = val.as_object()?;
692 extract_result_tokens(obj)
693}
694
695fn extract_usage(
697 envelope: &serde_json::Map<String, serde_json::Value>,
698) -> (Option<u64>, Option<u64>) {
699 if let Some(usage) = envelope.get("usage").and_then(|v| v.as_object()) {
701 let input = usage.get("input_tokens").and_then(|v| v.as_u64());
702 let output = usage.get("output_tokens").and_then(|v| v.as_u64());
703 if input.is_some() || output.is_some() {
704 return (input, output);
705 }
706 }
707
708 if let Some(model_usage) = envelope.get("modelUsage").and_then(|v| v.as_object()) {
710 let mut total_input = 0u64;
711 let mut total_output = 0u64;
712 let mut found = false;
713
714 for (_model_name, model_stats) in model_usage {
715 if let Some(stats) = model_stats.as_object() {
716 if let Some(i) = stats
717 .get("inputTokens")
718 .or_else(|| stats.get("input_tokens"))
719 .and_then(|v| v.as_u64())
720 {
721 total_input += i;
722 found = true;
723 }
724 if let Some(o) = stats
725 .get("outputTokens")
726 .or_else(|| stats.get("output_tokens"))
727 .and_then(|v| v.as_u64())
728 {
729 total_output += o;
730 found = true;
731 }
732 }
733 }
734
735 if found {
736 return (Some(total_input), Some(total_output));
737 }
738 }
739
740 (None, None)
741}
742
743const SHOW_CURSOR: &str = "\x1b[?25h";
744const HIDE_CURSOR: &str = "\x1b[?25l";
745
746pub fn strip_tty_control_lines(raw: &str) -> String {
748 raw.lines()
749 .filter(|line| !is_tty_control_line(line))
750 .map(strip_trailing_cursor)
751 .collect::<Vec<_>>()
752 .join("\n")
753}
754
755fn strip_trailing_cursor(line: &str) -> &str {
756 let mut s = line.trim_end();
757 loop {
758 if let Some(next) = s
759 .strip_suffix(SHOW_CURSOR)
760 .or_else(|| s.strip_suffix(HIDE_CURSOR))
761 {
762 s = next.trim_end();
763 continue;
764 }
765 return s;
766 }
767}
768
769fn is_tty_control_line(line: &str) -> bool {
770 let trimmed = strip_trailing_cursor(line).trim();
771 if trimmed.is_empty() {
772 return !line.trim().is_empty();
773 }
774 trimmed.chars().all(|c| matches!(c, '^' | 'D' | '\u{0008}'))
775}
776
777fn last_assistant_content_type(ndjson: &str) -> Option<String> {
779 let mut last = None;
780 for line in ndjson.lines() {
781 let line = line.trim();
782 if line.is_empty() {
783 continue;
784 }
785 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
786 continue;
787 };
788 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
789 continue;
790 }
791 let Some(content) = val
792 .get("message")
793 .and_then(|m| m.get("content"))
794 .and_then(|c| c.as_array())
795 else {
796 continue;
797 };
798 if let Some(item) = content.last() {
799 if let Some(t) = item.get("type").and_then(|v| v.as_str()) {
800 last = Some(t.to_string());
801 }
802 }
803 }
804 last
805}
806
807pub fn review_session_exited_mid_thought(ndjson: &str) -> bool {
810 if find_ndjson_result_line(ndjson).is_some() {
811 return false;
812 }
813 matches!(
814 last_assistant_content_type(ndjson).as_deref(),
815 Some("thinking" | "redacted_thinking" | "tool_use")
816 )
817}
818
819pub fn find_claude_result(ndjson: &str) -> Option<super::AgentResult> {
836 let stripped = strip_tty_control_lines(ndjson);
837 let trimmed = stripped.trim();
838 if trimmed.is_empty() {
839 return None;
840 }
841
842 if let Some((result_line, _tokens)) = find_ndjson_result_line(trimmed) {
844 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(result_line) {
845 if let Some(envelope) = parsed.as_object() {
846 let is_error = envelope
847 .get("is_error")
848 .and_then(|v| v.as_bool())
849 .unwrap_or(false);
850
851 let result_text = if is_error {
852 envelope_error_text(envelope)
855 } else {
856 envelope
857 .get("result")
858 .and_then(|v| v.as_str())
859 .unwrap_or("")
860 .to_string()
861 };
862
863 let (input_tokens, output_tokens) = extract_usage(envelope);
864 let duration_ms = envelope.get("duration_ms").and_then(|v| v.as_u64());
865
866 let cost_usd = envelope
867 .get("costUSD")
868 .or_else(|| envelope.get("cost_usd"))
869 .and_then(|v| v.as_f64());
870
871 return Some(super::AgentResult {
872 is_error,
873 result_text,
874 input_tokens,
875 output_tokens,
876 cost_usd,
877 duration_ms,
878 truncated_by_length: false,
879 });
880 }
881 }
882 }
883
884 let mut last_text = None;
886 let mut input_tokens = None;
887 let mut output_tokens = None;
888
889 for line in trimmed.lines() {
890 let line = line.trim();
891 if line.is_empty() {
892 continue;
893 }
894 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
895 continue;
896 };
897 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
898 continue;
899 }
900 if let Some(content) = val
902 .get("message")
903 .and_then(|m| m.get("content"))
904 .and_then(|c| c.as_array())
905 {
906 for item in content {
907 if item.get("type").and_then(|v| v.as_str()) == Some("text") {
908 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
909 let t = text.trim();
910 if !t.is_empty() {
911 last_text = Some(t.to_string());
912 }
913 }
914 }
915 }
916 }
917 if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
919 if let Some(inp) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
920 input_tokens = Some(input_tokens.unwrap_or(0u64).max(inp));
921 }
922 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
923 output_tokens = Some(output_tokens.unwrap_or(0u64).max(out));
924 }
925 }
926 }
927
928 last_text.map(|text| super::AgentResult {
929 is_error: false,
930 result_text: text,
931 input_tokens,
932 output_tokens,
933 cost_usd: None,
934 duration_ms: None,
935 truncated_by_length: false,
936 })
937}
938
939pub fn collect_assistant_messages_text(ndjson: &str) -> String {
944 let mut parts: Vec<String> = Vec::new();
945 for line in ndjson.trim().lines() {
946 let line = line.trim();
947 if line.is_empty() {
948 continue;
949 }
950 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
951 continue;
952 };
953 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
954 continue;
955 }
956 if let Some(content) = val
957 .get("message")
958 .and_then(|m| m.get("content"))
959 .and_then(|c| c.as_array())
960 {
961 for item in content {
962 if item.get("type").and_then(|v| v.as_str()) == Some("text") {
963 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
964 let t = text.trim();
965 if !t.is_empty() {
966 parts.push(t.to_string());
967 }
968 }
969 }
970 }
971 }
972 }
973 parts.join("\n")
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979
980 fn runner() -> ClaudeRunner {
981 ClaudeRunner::new("claude")
982 }
983
984 #[test]
985 fn error_envelope_uses_errors_array_when_result_missing() {
986 let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["API error (status 402 Payment Required): usage balance exhausted"]}"#;
987 match runner().parse_response(raw) {
988 Err(AgentError::AgentFailed { message } | AgentError::Auth { message }) => {
989 assert!(crate::engine::cooldown::detect_credit_exhaustion(&message).is_some());
990 }
991 other => panic!("unexpected: {other:?}"),
992 }
993 }
994
995 #[test]
996 fn error_envelope_falls_back_to_subtype() {
997 let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true}"#;
998 match runner().parse_response(raw) {
999 Err(AgentError::AgentFailed { message }) => {
1000 assert_eq!(message, "error_during_execution")
1001 }
1002 other => panic!("unexpected: {other:?}"),
1003 }
1004 }
1005
1006 #[test]
1007 fn parse_claude_envelope_success() {
1008 let raw = r#"{
1009 "type": "result",
1010 "subtype": "success",
1011 "is_error": false,
1012 "duration_ms": 2006,
1013 "result": "{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1014 "usage": {"input_tokens": 10, "output_tokens": 78},
1015 "permission_denials": []
1016 }"#;
1017
1018 let parsed = runner().parse_response(raw).unwrap();
1019 assert_eq!(parsed.response.status, "done");
1020 assert_eq!(parsed.response.summary, "hello");
1021 assert_eq!(parsed.input_tokens, Some(10));
1022 assert_eq!(parsed.output_tokens, Some(78));
1023 assert_eq!(parsed.duration_ms, Some(2006));
1024 }
1025
1026 #[test]
1027 fn parse_claude_envelope_with_markdown_result() {
1028 let raw = r#"{
1029 "type": "result",
1030 "subtype": "success",
1031 "is_error": false,
1032 "duration_ms": 5000,
1033 "result": "```json\n{\"status\": \"done\", \"summary\": \"fixed bug\"}\n```",
1034 "usage": {"input_tokens": 100, "output_tokens": 50},
1035 "permission_denials": ["Bash(rm *)"]
1036 }"#;
1037
1038 let parsed = runner().parse_response(raw).unwrap();
1039 assert_eq!(parsed.response.status, "done");
1040 assert_eq!(parsed.response.summary, "fixed bug");
1041 }
1042
1043 #[test]
1044 fn parse_claude_envelope_error() {
1045 let raw = r#"{
1046 "type": "result",
1047 "subtype": "error",
1048 "is_error": true,
1049 "result": "credit balance too low",
1050 "usage": {},
1051 "permission_denials": []
1052 }"#;
1053
1054 let err = runner().parse_response(raw).unwrap_err();
1055 assert!(matches!(err, AgentError::Auth { .. }));
1056 }
1057
1058 #[test]
1059 fn parse_claude_envelope_rate_limit_error() {
1060 let raw = r#"{
1061 "type": "result",
1062 "subtype": "error",
1063 "is_error": true,
1064 "result": "rate limit exceeded, please try again later",
1065 "usage": {},
1066 "permission_denials": []
1067 }"#;
1068
1069 let err = runner().parse_response(raw).unwrap_err();
1070 assert!(matches!(err, AgentError::RateLimit { .. }));
1071 }
1072
1073 #[test]
1074 fn parse_envelope_no_false_positive_from_work_product_result() {
1075 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();
1082 let parsed = runner()
1085 .parse_response(&raw)
1086 .expect("plain-text work product must synthesize successfully");
1087 assert_eq!(
1089 parsed.input_tokens,
1090 Some(10),
1091 "input_tokens must be preserved"
1092 );
1093 assert_eq!(
1094 parsed.output_tokens,
1095 Some(5),
1096 "output_tokens must be preserved"
1097 );
1098 }
1099
1100 #[test]
1101 fn parse_direct_json_no_envelope() {
1102 let raw =
1103 r#"{"status":"done","summary":"hello","accomplished":[],"remaining":[],"files":[]}"#;
1104
1105 let parsed = runner().parse_response(raw).unwrap();
1106 assert_eq!(parsed.response.status, "done");
1107 assert!(parsed.input_tokens.is_none());
1108 }
1109
1110 #[test]
1111 fn parse_empty_response() {
1112 let err = runner().parse_response("").unwrap_err();
1113 assert!(matches!(err, AgentError::InvalidResponse { .. }));
1114 }
1115
1116 #[test]
1117 fn classify_error_rate_limit() {
1118 let err = runner().classify_error(1, "", "429 Too Many Requests");
1119 assert!(matches!(err, AgentError::RateLimit { .. }));
1120 }
1121
1122 #[test]
1123 fn classify_error_detects_session_limit_from_mid_stream_system_event() {
1124 let header_events = "{\"type\":\"conversation\",\"subtype\":\"init\"}\n\
1131 {\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"I'll work on this task.\"}]}}\n"
1132 .repeat(80);
1133 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)"}}"#;
1134 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Here is the implementation...\"}]}}\n"
1135 .repeat(80);
1136 let stdout = format!("{header_events}{system_event}\n{work_product}");
1137 assert!(
1139 stdout.len() > 6000,
1140 "test stdout must exceed combined head+tail windows, got {} bytes",
1141 stdout.len()
1142 );
1143 let err = runner().classify_error(1, &stdout, "");
1144 assert!(
1145 matches!(err, AgentError::RateLimit { .. }),
1146 "session limit system event mid-stream must be detected, got: {err:?}"
1147 );
1148 let msg = err.to_string();
1149 assert!(
1150 msg.contains("session limit"),
1151 "error message should contain the rate limit reason, got: {msg}"
1152 );
1153 }
1154
1155 #[test]
1156 fn classify_error_prefers_clean_result_text_over_byte_truncated_tail() {
1157 let padding = "{\"type\":\"system\",\"subtype\":\"init\"}\n".repeat(50);
1168 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"}"#;
1169 let stdout = format!("{padding}{result_line}\n");
1170 assert!(
1174 stdout.len() > 300,
1175 "test stdout must exceed the tail-truncation window"
1176 );
1177
1178 let err = runner().classify_error(1, &stdout, "");
1179 assert!(
1180 matches!(err, AgentError::AgentFailed { .. }),
1181 "expected AgentFailed with the clean message, got: {err:?}"
1182 );
1183 let msg = err.to_string();
1184 assert_eq!(
1185 msg, "agent failed: Failed to authenticate: OAuth session expired and could not be refreshed",
1186 "stored error must be the clean result text, not a byte-truncated JSON fragment"
1187 );
1188 assert!(
1189 !msg.contains("i_error"),
1190 "stored error must not contain the byte-truncated JSON fragment, got: {msg}"
1191 );
1192 }
1193
1194 #[test]
1195 fn classify_error_timeout() {
1196 let err = runner().classify_error(124, "", "");
1197 assert!(matches!(err, AgentError::Timeout { .. }));
1198 }
1199
1200 #[test]
1201 fn extract_usage_from_model_usage() {
1202 let response_json: serde_json::Value = serde_json::json!({
1203 "modelUsage": {
1204 "claude-haiku-4-5-20251001": {
1205 "inputTokens": 1000,
1206 "outputTokens": 500
1207 }
1208 }
1209 });
1210 let envelope = response_json.as_object().unwrap();
1211 let (input, output) = extract_usage(envelope);
1212 assert_eq!(input, Some(1000));
1213 assert_eq!(output, Some(500));
1214 }
1215
1216 #[test]
1217 fn build_command_with_model() {
1218 let r = runner();
1219 let perms = PermissionRules {
1220 autonomous: true,
1221 sandbox: SandboxLevel::WorkspaceWrite,
1222 disallowed_tools: vec!["Bash(rm *)".to_string()],
1223 allowed_tools: vec![],
1224 allowed_edit_paths: vec![],
1225 deny_read_only: false,
1226 extra_writable_dirs: vec![],
1227 };
1228 let cmd = r.build_command(
1229 Some("opus"),
1230 "timeout 1800",
1231 "/tmp/sys.txt",
1232 "/tmp/msg.txt",
1233 &perms,
1234 );
1235 assert!(cmd.contains("--model 'opus'"));
1236 assert!(cmd.contains("claude -p"));
1237 assert!(cmd.contains("--output-format stream-json"));
1238 assert!(cmd.contains("--permission-mode bypassPermissions"));
1239 assert!(cmd.contains("--disallowedTools 'Bash(rm *)'"));
1240 }
1241
1242 #[test]
1243 fn build_command_supervised_mode() {
1244 let r = runner();
1245 let perms = PermissionRules {
1246 autonomous: false,
1247 sandbox: SandboxLevel::WorkspaceWrite,
1248 disallowed_tools: vec![],
1249 allowed_tools: vec![],
1250 allowed_edit_paths: vec![],
1251 deny_read_only: false,
1252 extra_writable_dirs: vec![],
1253 };
1254 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1255 assert!(cmd.contains("--permission-mode acceptEdits"));
1256 }
1257
1258 #[test]
1259 fn build_command_allowed_edit_paths_scopes_edit_write() {
1260 let r = runner();
1261 let perms = PermissionRules {
1262 autonomous: true,
1263 sandbox: SandboxLevel::WorkspaceWrite,
1264 disallowed_tools: vec![],
1265 allowed_tools: vec![
1266 "Edit".to_string(),
1267 "Write".to_string(),
1268 "Read".to_string(),
1269 "Bash".to_string(),
1270 ],
1271 allowed_edit_paths: vec![std::path::PathBuf::from("/home/user/worktree")],
1272 deny_read_only: false,
1273 extra_writable_dirs: vec![],
1274 };
1275 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1276
1277 assert!(
1279 cmd.contains("Edit(/home/user/worktree/*)"),
1280 "expected scoped Edit, got: {cmd}"
1281 );
1282 assert!(
1283 cmd.contains("Write(/home/user/worktree/*)"),
1284 "expected scoped Write, got: {cmd}"
1285 );
1286
1287 assert!(
1289 cmd.contains(",Read,") || cmd.contains("'Read,") || cmd.contains(",Read'"),
1290 "expected unrestricted Read, got: {cmd}"
1291 );
1292 assert!(
1293 cmd.contains(",Bash,") || cmd.contains("'Bash,") || cmd.contains(",Bash'"),
1294 "expected unrestricted Bash, got: {cmd}"
1295 );
1296 }
1297
1298 #[test]
1299 fn kimi_runner_name() {
1300 let r = ClaudeRunner::new("kimi");
1301 assert_eq!(r.name(), "kimi");
1302 }
1303
1304 #[test]
1307 fn fixture_claude_success() {
1308 let raw = include_str!("../../../../tests/fixtures/claude_success.json");
1309 let parsed = runner().parse_response(raw).unwrap();
1310 assert_eq!(parsed.response.status, "done");
1311 assert!(parsed.response.summary.contains("Implemented"));
1312 assert_eq!(parsed.response.accomplished.len(), 2);
1313 assert_eq!(parsed.input_tokens, Some(15000));
1314 assert_eq!(parsed.output_tokens, Some(3500));
1315 assert_eq!(parsed.duration_ms, Some(45000));
1316 }
1317
1318 #[test]
1319 fn fixture_claude_rate_limit() {
1320 let raw = include_str!("../../../../tests/fixtures/claude_error_rate_limit.json");
1321 let err = runner().parse_response(raw).unwrap_err();
1322 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1323 }
1324
1325 #[test]
1328 fn fixture_claude_stream_success() {
1329 let raw = include_str!("../../../../tests/fixtures/claude_stream_success.jsonl");
1330 let parsed = runner().parse_response(raw).unwrap();
1331 assert_eq!(parsed.response.status, "done");
1332 assert!(parsed.response.summary.contains("Implemented"));
1333 assert_eq!(parsed.response.accomplished.len(), 2);
1334 assert_eq!(parsed.input_tokens, Some(15000));
1335 assert_eq!(parsed.output_tokens, Some(3500));
1336 assert_eq!(parsed.duration_ms, Some(45000));
1337 }
1338
1339 #[test]
1340 fn fixture_claude_stream_error() {
1341 let raw = include_str!("../../../../tests/fixtures/claude_stream_error.jsonl");
1342 let err = runner().parse_response(raw).unwrap_err();
1343 assert!(matches!(err, AgentError::Auth { .. }), "got: {err:?}");
1344 }
1345
1346 #[test]
1347 fn fixture_claude_stream_rate_limit() {
1348 let raw = include_str!("../../../../tests/fixtures/claude_stream_rate_limit.jsonl");
1349 let err = runner().parse_response(raw).unwrap_err();
1350 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1351 }
1352
1353 #[test]
1354 fn fixture_claude_stream_thinking_block_conflict() {
1355 let raw =
1356 include_str!("../../../../tests/fixtures/claude_stream_thinking_block_conflict.jsonl");
1357 let err = runner().parse_response(raw).unwrap_err();
1358 assert!(
1359 matches!(err, AgentError::ThinkingBlockConflict { .. }),
1360 "expected ThinkingBlockConflict, got: {err:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn classify_error_thinking_block_conflict_from_stderr() {
1366 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.";
1369 let err = runner().classify_error(1, "", stderr);
1370 assert!(
1371 matches!(err, AgentError::ThinkingBlockConflict { .. }),
1372 "expected ThinkingBlockConflict, got: {err:?}"
1373 );
1374 }
1375
1376 #[test]
1377 fn thinking_block_conflict_does_not_match_unrelated_text() {
1378 let unrelated = "I was thinking about how to handle blocks of code in the response.";
1381 let err = runner().classify_error(1, "", unrelated);
1382 assert!(
1383 !matches!(err, AgentError::ThinkingBlockConflict { .. }),
1384 "should not classify unrelated text as ThinkingBlockConflict, got: {err:?}"
1385 );
1386 }
1387
1388 #[test]
1389 fn parse_ndjson_with_intermediate_events() {
1390 let raw = concat!(
1392 r#"{"type":"system","subtype":"init"}"#,
1393 "\n",
1394 r#"{"type":"assistant","message":{"role":"assistant","content":[]}}"#,
1395 "\n",
1396 r#"{"type":"tool_use","tool":{"name":"Read","input":{}}}"#,
1397 "\n",
1398 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}}"#,
1399 "\n"
1400 );
1401 let parsed = runner().parse_response(raw).unwrap();
1402 assert_eq!(parsed.response.status, "done");
1403 assert_eq!(parsed.response.summary, "streamed");
1404 assert_eq!(parsed.input_tokens, Some(5));
1405 assert_eq!(parsed.output_tokens, Some(10));
1406 }
1407
1408 #[test]
1409 fn parse_ndjson_skips_malformed_lines() {
1410 let raw = concat!(
1412 "not valid json\n",
1413 r#"{"type":"partial_update","data":"something"}"#,
1414 "\n",
1415 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}}"#,
1416 "\n"
1417 );
1418 let parsed = runner().parse_response(raw).unwrap();
1419 assert_eq!(parsed.response.status, "done");
1420 assert_eq!(parsed.response.summary, "ok");
1421 }
1422
1423 #[test]
1424 fn parse_single_line_json_backward_compat() {
1425 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}}"#;
1427 let parsed = runner().parse_response(raw).unwrap();
1428 assert_eq!(parsed.response.status, "done");
1429 assert_eq!(parsed.response.summary, "backward compat");
1430 assert_eq!(parsed.input_tokens, Some(50));
1431 assert_eq!(parsed.output_tokens, Some(25));
1432 }
1433
1434 #[test]
1437 fn kimi_parses_claude_envelope() {
1438 let r = ClaudeRunner::new("kimi");
1439 let raw = r#"{
1440 "type": "result",
1441 "subtype": "success",
1442 "is_error": false,
1443 "duration_ms": 3000,
1444 "result": "{\"status\":\"done\",\"summary\":\"kimi did it\",\"accomplished\":[\"task\"],\"remaining\":[],\"files\":[\"a.rs\"]}",
1445 "usage": {"input_tokens": 200, "output_tokens": 100}
1446 }"#;
1447
1448 let parsed = r.parse_response(raw).unwrap();
1449 assert_eq!(parsed.response.status, "done");
1450 assert_eq!(parsed.response.summary, "kimi did it");
1451 }
1452
1453 #[test]
1454 fn minimax_parses_claude_envelope() {
1455 let r = ClaudeRunner::new("minimax");
1456 assert_eq!(r.name(), "minimax");
1457 let raw = r#"{
1458 "type": "result",
1459 "subtype": "success",
1460 "is_error": false,
1461 "result": "{\"status\":\"done\",\"summary\":\"minimax did it\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1462 "usage": {}
1463 }"#;
1464
1465 let parsed = r.parse_response(raw).unwrap();
1466 assert_eq!(parsed.response.status, "done");
1467 }
1468
1469 #[test]
1470 fn kimi_build_command_uses_kimi_binary() {
1471 let r = ClaudeRunner::new("kimi");
1472 let perms = PermissionRules {
1473 autonomous: true,
1474 sandbox: SandboxLevel::WorkspaceWrite,
1475 disallowed_tools: vec![],
1476 allowed_tools: vec![],
1477 allowed_edit_paths: vec![],
1478 deny_read_only: false,
1479 extra_writable_dirs: vec![],
1480 };
1481 let cmd = r.build_command(
1482 Some("sonnet"),
1483 "timeout 1800",
1484 "/tmp/sys.txt",
1485 "/tmp/msg.txt",
1486 &perms,
1487 );
1488 assert!(cmd.contains("kimi -p"), "expected kimi binary, got: {cmd}");
1489 assert!(cmd.contains("--model 'sonnet'"));
1490 }
1491
1492 #[test]
1493 fn build_command_with_allowed_tools() {
1494 let r = runner();
1495 let perms = PermissionRules {
1496 autonomous: true,
1497 sandbox: SandboxLevel::WorkspaceWrite,
1498 disallowed_tools: vec!["Bash(rm *)".to_string()], allowed_tools: vec![
1500 "Edit".to_string(),
1501 "Write".to_string(),
1502 "Read".to_string(),
1503 "Bash".to_string(),
1504 "Grep".to_string(),
1505 "git".to_string(),
1506 "npm".to_string(),
1507 ],
1508 allowed_edit_paths: vec![],
1509 deny_read_only: false,
1510 extra_writable_dirs: vec![],
1511 };
1512 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1513
1514 assert!(
1516 cmd.contains("--allowedTools"),
1517 "expected --allowedTools flag, got: {cmd}"
1518 );
1519 assert!(
1520 !cmd.contains("--disallowedTools"),
1521 "should not have --disallowedTools when allowed_tools is set, got: {cmd}"
1522 );
1523
1524 assert!(cmd.contains("Edit"), "missing Edit tool");
1526 assert!(cmd.contains("Write"), "missing Write tool");
1527 assert!(cmd.contains("Bash"), "missing Bash tool");
1528
1529 assert!(
1531 cmd.contains("Bash(git *)"),
1532 "missing Bash(git *) pattern, got: {cmd}"
1533 );
1534 assert!(
1535 cmd.contains("Bash(npm *)"),
1536 "missing Bash(npm *) pattern, got: {cmd}"
1537 );
1538 }
1539
1540 #[test]
1541 fn build_command_allowed_tools_empty_falls_back_to_disallowed() {
1542 let r = runner();
1543 let perms = PermissionRules {
1544 autonomous: true,
1545 sandbox: SandboxLevel::WorkspaceWrite,
1546 disallowed_tools: vec!["Bash(rm *)".to_string()],
1547 allowed_tools: vec![], allowed_edit_paths: vec![],
1549 deny_read_only: false,
1550 extra_writable_dirs: vec![],
1551 };
1552 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1553
1554 assert!(
1555 cmd.contains("--disallowedTools"),
1556 "should fall back to --disallowedTools when allowed_tools is empty, got: {cmd}"
1557 );
1558 assert!(
1559 !cmd.contains("--allowedTools"),
1560 "should not have --allowedTools when empty, got: {cmd}"
1561 );
1562 }
1563
1564 #[test]
1565 fn translate_allowed_tools_categorizes_correctly() {
1566 let allowed = vec![
1567 "Edit".to_string(),
1568 "Bash".to_string(),
1569 "Grep".to_string(),
1570 "git".to_string(),
1571 "cargo".to_string(),
1572 "WebFetch".to_string(),
1573 ];
1574 let result = translate_allowed_tools(&allowed, &[]);
1576 assert_eq!(
1577 result,
1578 vec![
1579 "Edit",
1580 "Bash",
1581 "Grep",
1582 "Bash(git *)",
1583 "Bash(cargo *)",
1584 "WebFetch"
1585 ]
1586 );
1587 }
1588
1589 #[test]
1590 fn translate_allowed_tools_scopes_edit_write() {
1591 let allowed = vec!["Edit".to_string(), "Write".to_string(), "Read".to_string()];
1592 let paths = vec![std::path::PathBuf::from("/tmp/worktree")];
1593 let result = translate_allowed_tools(&allowed, &paths);
1594 assert_eq!(
1595 result,
1596 vec!["Edit(/tmp/worktree/*)", "Write(/tmp/worktree/*)", "Read"]
1597 );
1598 }
1599
1600 #[test]
1601 fn classify_error_overloaded_529() {
1602 let err = runner().classify_error(1, "", "overloaded_error: 529");
1604 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1605 }
1606
1607 #[test]
1608 fn classify_error_context_length() {
1609 let err = runner().classify_error(1, "", "context_length_exceeded: max 200000 tokens");
1610 assert!(
1611 matches!(err, AgentError::ContextOverflow { .. }),
1612 "got: {err:?}"
1613 );
1614 }
1615
1616 #[test]
1620 fn extract_text_stream_json_returns_result_field() {
1621 let raw = concat!(
1622 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1623 "\n",
1624 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1625 "\n",
1626 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1627 );
1628 let text = runner().extract_text(raw).unwrap();
1629 assert_eq!(
1630 text,
1631 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1632 );
1633 }
1634
1635 #[test]
1637 fn extract_text_stream_json_markdown_result() {
1638 let review_json =
1639 r#"```json\n{"decision":"request_changes","notes":"Fix it","issues":[]}\n```"#;
1640 let result_line = format!(
1641 r#"{{"type":"result","subtype":"success","is_error":false,"result":"{review_json}","usage":{{"input_tokens":10,"output_tokens":5}}}}"#
1642 );
1643 let raw = format!(
1644 "{}\n{}",
1645 r#"{"type":"system","subtype":"init","session_id":"s1"}"#, result_line
1646 );
1647 let text = runner().extract_text(&raw).unwrap();
1648 assert!(
1649 text.contains("request_changes"),
1650 "expected review JSON in result, got: {text}"
1651 );
1652 }
1653
1654 #[test]
1656 fn extract_text_stream_json_rate_limit_propagates() {
1657 let raw = concat!(
1658 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1659 "\n",
1660 r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded: 429 Too Many Requests","usage":{"input_tokens":0,"output_tokens":0}}"#,
1661 );
1662 let err = runner().extract_text(raw).unwrap_err();
1663 assert!(
1664 matches!(err, AgentError::RateLimit { .. }),
1665 "expected RateLimit, got: {err:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn extract_stream_json_fallbacks_to_opencode_extractor_on_leading_init() {
1671 let raw = concat!(
1676 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1677 "\n",
1678 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"status\":\"done\",\"summary\":\"fallback\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}]}}"#,
1680 );
1681
1682 let text = extract_stream_json_result_text(raw).expect("should fallback and extract JSON");
1683 let parsed: serde_json::Value =
1684 serde_json::from_str(&text).expect("extracted text must be JSON");
1685 assert_eq!(parsed["status"], "done");
1686 assert_eq!(parsed["summary"], "fallback");
1687 }
1688
1689 #[test]
1691 fn extract_text_stream_json_auth_error_propagates() {
1692 let raw = concat!(
1693 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1694 "\n",
1695 r#"{"type":"result","subtype":"error","is_error":true,"result":"401 Unauthorized: invalid api key","usage":{"input_tokens":0,"output_tokens":0}}"#,
1696 );
1697 let err = runner().extract_text(raw).unwrap_err();
1698 assert!(
1699 matches!(err, AgentError::Auth { .. }),
1700 "expected Auth, got: {err:?}"
1701 );
1702 }
1703
1704 #[test]
1706 fn extract_text_empty_input_returns_empty_ok() {
1707 let text = runner().extract_text("").unwrap();
1708 assert!(text.is_empty());
1709 }
1710
1711 #[test]
1714 fn find_claude_result_success_with_tokens() {
1715 let ndjson = concat!(
1716 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1717 "\n",
1718 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working..."}]}}"#,
1719 "\n",
1720 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}","usage":{"input_tokens":1500,"output_tokens":200},"duration_ms":5000}"#,
1721 );
1722 let result = find_claude_result(ndjson).expect("should find result");
1723 assert!(!result.is_error);
1724 assert!(result.result_text.contains("approve"));
1725 assert_eq!(result.input_tokens, Some(1500));
1726 assert_eq!(result.output_tokens, Some(200));
1727 assert_eq!(result.duration_ms, Some(5000));
1728 }
1729
1730 #[test]
1731 fn find_claude_result_error_flag() {
1732 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1733 let result = find_claude_result(ndjson).expect("should find result");
1734 assert!(result.is_error);
1735 assert!(result.result_text.contains("rate limit"));
1736 }
1737
1738 #[test]
1739 fn find_claude_result_error_envelope_with_empty_result_uses_errors() {
1740 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}"]}"#;
1743 let result = find_claude_result(ndjson).expect("should find result");
1744 assert!(result.is_error);
1745 assert!(result.result_text.contains("usage balance exhausted"));
1746 assert!(crate::engine::cooldown::detect_credit_exhaustion(&result.result_text).is_some());
1747 }
1748
1749 #[test]
1750 fn find_claude_result_empty_returns_none() {
1751 assert!(find_claude_result("").is_none());
1752 assert!(find_claude_result(" ").is_none());
1753 }
1754
1755 #[test]
1756 fn find_claude_result_no_result_line_falls_back_to_assistant() {
1757 let ndjson = concat!(
1759 r#"{"type":"system","subtype":"init","model":"MiniMax-M2.7"}"#,
1760 "\n",
1761 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analysis complete"}],"usage":{"input_tokens":800,"output_tokens":150}}}"#,
1762 );
1763 let result = find_claude_result(ndjson).expect("should fall back to assistant text");
1764 assert!(!result.is_error);
1765 assert_eq!(result.result_text, "analysis complete");
1766 assert_eq!(result.input_tokens, Some(800));
1767 assert_eq!(result.output_tokens, Some(150));
1768 }
1769
1770 #[test]
1771 fn find_claude_result_plain_text_returns_none() {
1772 assert!(find_claude_result("just some plain text output").is_none());
1774 }
1775
1776 #[test]
1777 fn strip_tty_control_lines_drops_show_cursor_and_ctrl_d_redraw() {
1778 let redraw = "^D\u{0008}".repeat(20);
1779 let raw = format!(
1780 "{}\n{}\n{}\n{}\n{}",
1781 r#"{"type":"system","subtype":"init"}"#,
1782 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"#,
1783 SHOW_CURSOR,
1784 redraw,
1785 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1786 );
1787 let cleaned = strip_tty_control_lines(&raw);
1788 assert!(
1789 !cleaned.contains(SHOW_CURSOR),
1790 "show-cursor sequence must be stripped"
1791 );
1792 assert!(
1793 !cleaned.contains('\u{0008}'),
1794 "backspace redraw line must be stripped"
1795 );
1796 assert!(cleaned.contains("\"type\":\"result\""));
1797 assert!(cleaned.contains("hello"));
1798 }
1799
1800 #[test]
1801 fn strip_tty_control_lines_strips_trailing_cursor_on_json_line() {
1802 let line = format!(
1803 r#"{{"type":"result","subtype":"success","is_error":false,"result":"ok"}}{SHOW_CURSOR}"#
1804 );
1805 let cleaned = strip_tty_control_lines(&line);
1806 assert_eq!(
1807 cleaned,
1808 r#"{"type":"result","subtype":"success","is_error":false,"result":"ok"}"#
1809 );
1810 }
1811
1812 #[test]
1813 fn find_claude_result_ignores_trailing_tty_junk() {
1814 let ndjson = format!(
1815 "{}\n{}\n{}",
1816 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working"}]}}"#,
1817 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}","usage":{"input_tokens":10,"output_tokens":4}}"#,
1818 SHOW_CURSOR,
1819 );
1820 let result = find_claude_result(&ndjson).expect("result envelope should still parse");
1821 assert!(result.result_text.contains("approve"));
1822 assert_eq!(result.input_tokens, Some(10));
1823 }
1824
1825 #[test]
1826 fn review_session_exited_mid_thought_detects_thinking_without_envelope() {
1827 let ndjson = concat!(
1828 r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
1829 "\n",
1830 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers:"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1831 "\n",
1832 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1833 );
1834 assert!(review_session_exited_mid_thought(ndjson));
1835 assert!(find_ndjson_result_line(ndjson).is_none());
1836 }
1837
1838 #[test]
1839 fn review_session_exited_mid_thought_detects_tool_use_without_envelope() {
1840 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
1841 assert!(review_session_exited_mid_thought(ndjson));
1842 }
1843
1844 #[test]
1845 fn review_session_exited_mid_thought_false_when_envelope_present() {
1846 let ndjson = concat!(
1847 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"done"}]}}"#,
1848 "\n",
1849 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1850 );
1851 assert!(!review_session_exited_mid_thought(ndjson));
1852 }
1853
1854 #[test]
1855 fn review_session_exited_mid_thought_false_for_finished_text() {
1856 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#;
1857 assert!(!review_session_exited_mid_thought(ndjson));
1858 }
1859
1860 #[test]
1861 fn find_claude_result_model_usage() {
1862 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":5000,"outputTokens":1000}},"duration_ms":3000}"#;
1863 let result = find_claude_result(ndjson).expect("should extract modelUsage");
1864 assert_eq!(result.input_tokens, Some(5000));
1865 assert_eq!(result.output_tokens, Some(1000));
1866 }
1867
1868 #[test]
1875 fn find_ndjson_result_line_skips_plain_text_result() {
1876 let inner_result = serde_json::json!({"status": "done", "summary": "fix applied"});
1878 let json_result_line = serde_json::json!({
1879 "type": "result",
1880 "subtype": "success",
1881 "is_error": false,
1882 "duration_ms": 273044,
1883 "result": inner_result.to_string(),
1884 "usage": {"input_tokens": 12, "output_tokens": 1577}
1885 })
1886 .to_string();
1887 let plain_result_line = serde_json::json!({
1888 "type": "result",
1889 "subtype": "success",
1890 "is_error": false,
1891 "duration_ms": 2352,
1892 "result": "All 2352 tests passed.",
1893 "usage": {"input_tokens": 3, "output_tokens": 16}
1894 })
1895 .to_string();
1896 let mut ndjson = json_result_line.clone();
1898 ndjson.push('\n');
1899 ndjson.push_str(r#"{"type":"system","subtype":"task_notification"}"#);
1900 ndjson.push('\n');
1901 ndjson.push_str(r#"{"type":"system","subtype":"init"}"#);
1902 ndjson.push('\n');
1903 ndjson.push_str(
1904 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"summary here"}]}}"#,
1905 );
1906 ndjson.push('\n');
1907 ndjson.push_str(&plain_result_line);
1908
1909 let line = find_ndjson_result_line(&ndjson)
1910 .expect("should find a result line")
1911 .0;
1912 let parsed: serde_json::Value =
1914 serde_json::from_str(line).expect("returned line must be valid JSON");
1915 let result_val = parsed.get("result").expect("result field must exist");
1916 assert!(
1917 result_val.is_string(),
1918 "result field must be a JSON string (not plain text), got: {result_val}"
1919 );
1920 let result_str = result_val.as_str().unwrap();
1921 assert!(
1922 result_str.starts_with('{'),
1923 "result must be JSON (object), got: {result_str}"
1924 );
1925 assert!(
1926 !result_str.contains("All 2352 tests passed"),
1927 "must NOT return outer plain-text result: {result_str}"
1928 );
1929 }
1930
1931 #[test]
1936 fn find_ndjson_result_line_prefers_more_tokens_on_plain_text() {
1937 let inner_result_line = serde_json::json!({
1939 "type": "result",
1940 "subtype": "success",
1941 "is_error": false,
1942 "duration_ms": 273044,
1943 "result": "All 2352 tests pass. The fix is clean and correct.",
1944 "usage": {"input_tokens": 12, "output_tokens": 1577}
1945 })
1946 .to_string();
1947 let outer_result_line = serde_json::json!({
1948 "type": "result",
1949 "subtype": "success",
1950 "is_error": false,
1951 "duration_ms": 2352,
1952 "result": "Already retrieved the result — all 2352 tests passed.",
1953 "usage": {"input_tokens": 3, "output_tokens": 16}
1954 })
1955 .to_string();
1956
1957 let ndjson = format!(
1959 "{0}\n{1}\n{2}",
1960 inner_result_line, r#"{"type":"system","subtype":"init"}"#, outer_result_line
1961 );
1962
1963 let (line, tokens) = find_ndjson_result_line(&ndjson).expect("should find a result line");
1964
1965 assert!(
1967 line.contains("The fix is clean"),
1968 "expected inner result (more tokens), got: {line}"
1969 );
1970 assert_eq!(
1972 tokens,
1973 Some((12, 1577)),
1974 "tokens must be from the inner result"
1975 );
1976 }
1977
1978 #[test]
1981 fn parse_response_preserves_tokens_on_synthesis_fallback() {
1982 let ndjson = concat!(
1985 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}}"#,
1986 "\n",
1987 r#"{"type":"system","subtype":"init"}"#,
1988 "\n",
1989 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}}"#,
1990 );
1991
1992 let parsed = runner()
1993 .parse_response(ndjson)
1994 .expect("should parse successfully");
1995
1996 assert_eq!(
1998 parsed.input_tokens,
1999 Some(12),
2000 "input_tokens must be preserved (not lost on synthesis fallback)"
2001 );
2002 assert_eq!(
2003 parsed.output_tokens,
2004 Some(1577),
2005 "output_tokens must be preserved (not lost on synthesis fallback)"
2006 );
2007 }
2008
2009 #[test]
2010 fn parse_response_uses_inner_json_result_preserving_tokens() {
2011 let ndjson = concat!(
2014 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}}"#,
2015 "\n",
2016 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2017 "\n",
2018 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"tests passed"}]}}"#,
2019 "\n",
2020 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"All 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
2021 );
2022
2023 let parsed = runner()
2024 .parse_response(ndjson)
2025 .expect("should parse successfully");
2026
2027 assert_eq!(
2029 parsed.response.status, "done",
2030 "expected inner JSON result status, got: {:?}",
2031 parsed.response.status
2032 );
2033 assert_eq!(
2034 parsed.response.summary.as_str(),
2035 "fix applied",
2036 "expected inner JSON result summary, got: {:?}",
2037 parsed.response.summary
2038 );
2039 assert_eq!(
2041 parsed.input_tokens,
2042 Some(12),
2043 "input_tokens must be from inner result (not outer plain-text result)"
2044 );
2045 assert_eq!(
2046 parsed.output_tokens,
2047 Some(1577),
2048 "output_tokens must be from inner result (not outer plain-text result)"
2049 );
2050 }
2051
2052 #[test]
2056 fn extract_text_falls_back_to_opencode_style_when_no_json_result() {
2057 let ndjson = concat!(
2059 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2060 "\n",
2061 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All tasks complete"}]}}"#,
2062 );
2063
2064 let text = runner().extract_text(ndjson).expect("should extract text");
2065 assert!(
2067 text.contains("All tasks complete"),
2068 "expected extracted assistant text, got: {text}"
2069 );
2070 assert!(!text.contains("type"), "must not return raw NDJSON: {text}");
2071 }
2072}
2073
2074pub(crate) fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2076 let blocks = || v["message"]["content"].as_array().into_iter().flatten();
2077 match json_str(v, "/type") {
2078 "system" if json_str(v, "/subtype") == "init" => vec![StreamEvent::Started {
2079 model: json_str(v, "/model").to_string(),
2080 }],
2081 "assistant" => blocks()
2082 .filter_map(|b| match json_str(b, "/type") {
2083 "text" => Some(StreamEvent::Text(json_str(b, "/text").to_string())),
2084 "thinking" => Some(StreamEvent::Thinking(json_str(b, "/thinking").to_string())),
2085 "tool_use" => Some(StreamEvent::ToolCall {
2086 id: json_str(b, "/id").to_string(),
2087 name: json_str(b, "/name").to_string(),
2088 input: b["input"].clone(),
2089 }),
2090 _ => None,
2091 })
2092 .collect(),
2093 "user" => blocks()
2094 .filter(|b| json_str(b, "/type") == "tool_result")
2095 .map(|b| StreamEvent::ToolResult {
2096 id: json_str(b, "/tool_use_id").to_string(),
2097 name: String::new(),
2098 output: match &b["content"] {
2099 serde_json::Value::String(s) => s.clone(),
2100 serde_json::Value::Array(parts) => parts
2101 .iter()
2102 .filter_map(|p| p["text"].as_str())
2103 .collect::<Vec<_>>()
2104 .join("\n"),
2105 _ => String::new(),
2106 },
2107 is_error: b["is_error"].as_bool() == Some(true),
2108 })
2109 .collect(),
2110 "error" => vec![StreamEvent::Error(json_error_message(v))],
2111 "result" => {
2112 let failed = v["is_error"].as_bool() == Some(true)
2113 || json_str(v, "/subtype").starts_with("error");
2114 if failed {
2115 return vec![StreamEvent::Error(json_str(v, "/result").to_string())];
2116 }
2117
2118 let turns = v["num_turns"].as_u64().unwrap_or(0);
2119 let secs = v["duration_ms"].as_u64().unwrap_or(0) / 1000;
2120 let time = if secs >= 60 {
2121 format!("{}m {}s", secs / 60, secs % 60)
2122 } else {
2123 format!("{secs}s")
2124 };
2125 vec![StreamEvent::Done(format!("{turns} turns · {time}"))]
2126 }
2127 _ => vec![],
2128 }
2129}
2130
2131#[cfg(test)]
2132mod stream_tests {
2133 use super::*;
2134 use serde_json::json;
2135
2136 fn events(line: &str) -> Vec<StreamEvent> {
2137 ClaudeRunner::new("claude").stream_events(&serde_json::from_str(line).unwrap())
2138 }
2139
2140 #[test]
2141 fn init_reports_model() {
2142 let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-5","cwd":"/w"}"#;
2143 assert_eq!(
2144 events(line),
2145 vec![StreamEvent::Started {
2146 model: "claude-sonnet-5".into()
2147 }]
2148 );
2149 }
2150
2151 #[test]
2152 fn assistant_blocks_in_order() {
2153 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"}}]}}"#;
2154 assert_eq!(
2155 events(line),
2156 vec![
2157 StreamEvent::Thinking("plan".into()),
2158 StreamEvent::Text("Reading.".into()),
2159 StreamEvent::ToolCall {
2160 id: "t1".into(),
2161 name: "Read".into(),
2162 input: json!({"file_path": "a.rs"}),
2163 },
2164 ]
2165 );
2166 }
2167
2168 #[test]
2169 fn tool_result_string_and_block_content() {
2170 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}]}}"#;
2171 assert_eq!(
2172 events(line),
2173 vec![
2174 StreamEvent::ToolResult {
2175 id: "t1".into(),
2176 name: String::new(),
2177 output: "ok".into(),
2178 is_error: false,
2179 },
2180 StreamEvent::ToolResult {
2181 id: "t2".into(),
2182 name: String::new(),
2183 output: "a\nb".into(),
2184 is_error: true,
2185 },
2186 ]
2187 );
2188 }
2189
2190 #[test]
2191 fn result_success_and_error() {
2192 let ok = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":66,"duration_ms":580214}"#;
2193 assert_eq!(
2194 events(ok),
2195 vec![StreamEvent::Done("66 turns · 9m 40s".into())]
2196 );
2197 let err = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"too many turns"}"#;
2198 assert_eq!(
2199 events(err),
2200 vec![StreamEvent::Error("too many turns".into())]
2201 );
2202 }
2203
2204 #[test]
2205 fn noise_events_are_dropped() {
2206 for line in [
2207 r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
2208 r#"{"type":"system","subtype":"task_started"}"#,
2209 r#"{"type":"rate_limit_event","rate_limit_info":{}}"#,
2210 r#"{"type":"tool_progress"}"#,
2211 ] {
2212 assert!(events(line).is_empty(), "{line}");
2213 }
2214 }
2215}