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
743pub fn find_claude_result(ndjson: &str) -> Option<super::AgentResult> {
760 let trimmed = ndjson.trim();
761 if trimmed.is_empty() {
762 return None;
763 }
764
765 if let Some((result_line, _tokens)) = find_ndjson_result_line(trimmed) {
767 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(result_line) {
768 if let Some(envelope) = parsed.as_object() {
769 let is_error = envelope
770 .get("is_error")
771 .and_then(|v| v.as_bool())
772 .unwrap_or(false);
773
774 let result_text = if is_error {
775 envelope_error_text(envelope)
778 } else {
779 envelope
780 .get("result")
781 .and_then(|v| v.as_str())
782 .unwrap_or("")
783 .to_string()
784 };
785
786 let (input_tokens, output_tokens) = extract_usage(envelope);
787 let duration_ms = envelope.get("duration_ms").and_then(|v| v.as_u64());
788
789 let cost_usd = envelope
790 .get("costUSD")
791 .or_else(|| envelope.get("cost_usd"))
792 .and_then(|v| v.as_f64());
793
794 return Some(super::AgentResult {
795 is_error,
796 result_text,
797 input_tokens,
798 output_tokens,
799 cost_usd,
800 duration_ms,
801 truncated_by_length: false,
802 });
803 }
804 }
805 }
806
807 let mut last_text = None;
809 let mut input_tokens = None;
810 let mut output_tokens = None;
811
812 for line in trimmed.lines() {
813 let line = line.trim();
814 if line.is_empty() {
815 continue;
816 }
817 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
818 continue;
819 };
820 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
821 continue;
822 }
823 if let Some(content) = val
825 .get("message")
826 .and_then(|m| m.get("content"))
827 .and_then(|c| c.as_array())
828 {
829 for item in content {
830 if item.get("type").and_then(|v| v.as_str()) == Some("text") {
831 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
832 let t = text.trim();
833 if !t.is_empty() {
834 last_text = Some(t.to_string());
835 }
836 }
837 }
838 }
839 }
840 if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
842 if let Some(inp) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
843 input_tokens = Some(input_tokens.unwrap_or(0u64).max(inp));
844 }
845 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
846 output_tokens = Some(output_tokens.unwrap_or(0u64).max(out));
847 }
848 }
849 }
850
851 last_text.map(|text| super::AgentResult {
852 is_error: false,
853 result_text: text,
854 input_tokens,
855 output_tokens,
856 cost_usd: None,
857 duration_ms: None,
858 truncated_by_length: false,
859 })
860}
861
862pub fn collect_assistant_messages_text(ndjson: &str) -> String {
867 let mut parts: Vec<String> = Vec::new();
868 for line in ndjson.trim().lines() {
869 let line = line.trim();
870 if line.is_empty() {
871 continue;
872 }
873 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
874 continue;
875 };
876 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
877 continue;
878 }
879 if let Some(content) = val
880 .get("message")
881 .and_then(|m| m.get("content"))
882 .and_then(|c| c.as_array())
883 {
884 for item in content {
885 if item.get("type").and_then(|v| v.as_str()) == Some("text") {
886 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
887 let t = text.trim();
888 if !t.is_empty() {
889 parts.push(t.to_string());
890 }
891 }
892 }
893 }
894 }
895 }
896 parts.join("\n")
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902
903 fn runner() -> ClaudeRunner {
904 ClaudeRunner::new("claude")
905 }
906
907 #[test]
908 fn error_envelope_uses_errors_array_when_result_missing() {
909 let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["API error (status 402 Payment Required): usage balance exhausted"]}"#;
910 match runner().parse_response(raw) {
911 Err(AgentError::AgentFailed { message } | AgentError::Auth { message }) => {
912 assert!(crate::engine::cooldown::detect_credit_exhaustion(&message).is_some());
913 }
914 other => panic!("unexpected: {other:?}"),
915 }
916 }
917
918 #[test]
919 fn error_envelope_falls_back_to_subtype() {
920 let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true}"#;
921 match runner().parse_response(raw) {
922 Err(AgentError::AgentFailed { message }) => {
923 assert_eq!(message, "error_during_execution")
924 }
925 other => panic!("unexpected: {other:?}"),
926 }
927 }
928
929 #[test]
930 fn parse_claude_envelope_success() {
931 let raw = r#"{
932 "type": "result",
933 "subtype": "success",
934 "is_error": false,
935 "duration_ms": 2006,
936 "result": "{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
937 "usage": {"input_tokens": 10, "output_tokens": 78},
938 "permission_denials": []
939 }"#;
940
941 let parsed = runner().parse_response(raw).unwrap();
942 assert_eq!(parsed.response.status, "done");
943 assert_eq!(parsed.response.summary, "hello");
944 assert_eq!(parsed.input_tokens, Some(10));
945 assert_eq!(parsed.output_tokens, Some(78));
946 assert_eq!(parsed.duration_ms, Some(2006));
947 }
948
949 #[test]
950 fn parse_claude_envelope_with_markdown_result() {
951 let raw = r#"{
952 "type": "result",
953 "subtype": "success",
954 "is_error": false,
955 "duration_ms": 5000,
956 "result": "```json\n{\"status\": \"done\", \"summary\": \"fixed bug\"}\n```",
957 "usage": {"input_tokens": 100, "output_tokens": 50},
958 "permission_denials": ["Bash(rm *)"]
959 }"#;
960
961 let parsed = runner().parse_response(raw).unwrap();
962 assert_eq!(parsed.response.status, "done");
963 assert_eq!(parsed.response.summary, "fixed bug");
964 }
965
966 #[test]
967 fn parse_claude_envelope_error() {
968 let raw = r#"{
969 "type": "result",
970 "subtype": "error",
971 "is_error": true,
972 "result": "credit balance too low",
973 "usage": {},
974 "permission_denials": []
975 }"#;
976
977 let err = runner().parse_response(raw).unwrap_err();
978 assert!(matches!(err, AgentError::Auth { .. }));
979 }
980
981 #[test]
982 fn parse_claude_envelope_rate_limit_error() {
983 let raw = r#"{
984 "type": "result",
985 "subtype": "error",
986 "is_error": true,
987 "result": "rate limit exceeded, please try again later",
988 "usage": {},
989 "permission_denials": []
990 }"#;
991
992 let err = runner().parse_response(raw).unwrap_err();
993 assert!(matches!(err, AgentError::RateLimit { .. }));
994 }
995
996 #[test]
997 fn parse_envelope_no_false_positive_from_work_product_result() {
998 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();
1005 let parsed = runner()
1008 .parse_response(&raw)
1009 .expect("plain-text work product must synthesize successfully");
1010 assert_eq!(
1012 parsed.input_tokens,
1013 Some(10),
1014 "input_tokens must be preserved"
1015 );
1016 assert_eq!(
1017 parsed.output_tokens,
1018 Some(5),
1019 "output_tokens must be preserved"
1020 );
1021 }
1022
1023 #[test]
1024 fn parse_direct_json_no_envelope() {
1025 let raw =
1026 r#"{"status":"done","summary":"hello","accomplished":[],"remaining":[],"files":[]}"#;
1027
1028 let parsed = runner().parse_response(raw).unwrap();
1029 assert_eq!(parsed.response.status, "done");
1030 assert!(parsed.input_tokens.is_none());
1031 }
1032
1033 #[test]
1034 fn parse_empty_response() {
1035 let err = runner().parse_response("").unwrap_err();
1036 assert!(matches!(err, AgentError::InvalidResponse { .. }));
1037 }
1038
1039 #[test]
1040 fn classify_error_rate_limit() {
1041 let err = runner().classify_error(1, "", "429 Too Many Requests");
1042 assert!(matches!(err, AgentError::RateLimit { .. }));
1043 }
1044
1045 #[test]
1046 fn classify_error_detects_session_limit_from_mid_stream_system_event() {
1047 let header_events = "{\"type\":\"conversation\",\"subtype\":\"init\"}\n\
1054 {\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"I'll work on this task.\"}]}}\n"
1055 .repeat(80);
1056 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)"}}"#;
1057 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Here is the implementation...\"}]}}\n"
1058 .repeat(80);
1059 let stdout = format!("{header_events}{system_event}\n{work_product}");
1060 assert!(
1062 stdout.len() > 6000,
1063 "test stdout must exceed combined head+tail windows, got {} bytes",
1064 stdout.len()
1065 );
1066 let err = runner().classify_error(1, &stdout, "");
1067 assert!(
1068 matches!(err, AgentError::RateLimit { .. }),
1069 "session limit system event mid-stream must be detected, got: {err:?}"
1070 );
1071 let msg = err.to_string();
1072 assert!(
1073 msg.contains("session limit"),
1074 "error message should contain the rate limit reason, got: {msg}"
1075 );
1076 }
1077
1078 #[test]
1079 fn classify_error_prefers_clean_result_text_over_byte_truncated_tail() {
1080 let padding = "{\"type\":\"system\",\"subtype\":\"init\"}\n".repeat(50);
1091 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"}"#;
1092 let stdout = format!("{padding}{result_line}\n");
1093 assert!(
1097 stdout.len() > 300,
1098 "test stdout must exceed the tail-truncation window"
1099 );
1100
1101 let err = runner().classify_error(1, &stdout, "");
1102 assert!(
1103 matches!(err, AgentError::AgentFailed { .. }),
1104 "expected AgentFailed with the clean message, got: {err:?}"
1105 );
1106 let msg = err.to_string();
1107 assert_eq!(
1108 msg, "agent failed: Failed to authenticate: OAuth session expired and could not be refreshed",
1109 "stored error must be the clean result text, not a byte-truncated JSON fragment"
1110 );
1111 assert!(
1112 !msg.contains("i_error"),
1113 "stored error must not contain the byte-truncated JSON fragment, got: {msg}"
1114 );
1115 }
1116
1117 #[test]
1118 fn classify_error_timeout() {
1119 let err = runner().classify_error(124, "", "");
1120 assert!(matches!(err, AgentError::Timeout { .. }));
1121 }
1122
1123 #[test]
1124 fn extract_usage_from_model_usage() {
1125 let response_json: serde_json::Value = serde_json::json!({
1126 "modelUsage": {
1127 "claude-haiku-4-5-20251001": {
1128 "inputTokens": 1000,
1129 "outputTokens": 500
1130 }
1131 }
1132 });
1133 let envelope = response_json.as_object().unwrap();
1134 let (input, output) = extract_usage(envelope);
1135 assert_eq!(input, Some(1000));
1136 assert_eq!(output, Some(500));
1137 }
1138
1139 #[test]
1140 fn build_command_with_model() {
1141 let r = runner();
1142 let perms = PermissionRules {
1143 autonomous: true,
1144 sandbox: SandboxLevel::WorkspaceWrite,
1145 disallowed_tools: vec!["Bash(rm *)".to_string()],
1146 allowed_tools: vec![],
1147 allowed_edit_paths: vec![],
1148 deny_read_only: false,
1149 extra_writable_dirs: vec![],
1150 };
1151 let cmd = r.build_command(
1152 Some("opus"),
1153 "timeout 1800",
1154 "/tmp/sys.txt",
1155 "/tmp/msg.txt",
1156 &perms,
1157 );
1158 assert!(cmd.contains("--model 'opus'"));
1159 assert!(cmd.contains("claude -p"));
1160 assert!(cmd.contains("--output-format stream-json"));
1161 assert!(cmd.contains("--permission-mode bypassPermissions"));
1162 assert!(cmd.contains("--disallowedTools 'Bash(rm *)'"));
1163 }
1164
1165 #[test]
1166 fn build_command_supervised_mode() {
1167 let r = runner();
1168 let perms = PermissionRules {
1169 autonomous: false,
1170 sandbox: SandboxLevel::WorkspaceWrite,
1171 disallowed_tools: vec![],
1172 allowed_tools: vec![],
1173 allowed_edit_paths: vec![],
1174 deny_read_only: false,
1175 extra_writable_dirs: vec![],
1176 };
1177 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1178 assert!(cmd.contains("--permission-mode acceptEdits"));
1179 }
1180
1181 #[test]
1182 fn build_command_allowed_edit_paths_scopes_edit_write() {
1183 let r = runner();
1184 let perms = PermissionRules {
1185 autonomous: true,
1186 sandbox: SandboxLevel::WorkspaceWrite,
1187 disallowed_tools: vec![],
1188 allowed_tools: vec![
1189 "Edit".to_string(),
1190 "Write".to_string(),
1191 "Read".to_string(),
1192 "Bash".to_string(),
1193 ],
1194 allowed_edit_paths: vec![std::path::PathBuf::from("/home/user/worktree")],
1195 deny_read_only: false,
1196 extra_writable_dirs: vec![],
1197 };
1198 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1199
1200 assert!(
1202 cmd.contains("Edit(/home/user/worktree/*)"),
1203 "expected scoped Edit, got: {cmd}"
1204 );
1205 assert!(
1206 cmd.contains("Write(/home/user/worktree/*)"),
1207 "expected scoped Write, got: {cmd}"
1208 );
1209
1210 assert!(
1212 cmd.contains(",Read,") || cmd.contains("'Read,") || cmd.contains(",Read'"),
1213 "expected unrestricted Read, got: {cmd}"
1214 );
1215 assert!(
1216 cmd.contains(",Bash,") || cmd.contains("'Bash,") || cmd.contains(",Bash'"),
1217 "expected unrestricted Bash, got: {cmd}"
1218 );
1219 }
1220
1221 #[test]
1222 fn kimi_runner_name() {
1223 let r = ClaudeRunner::new("kimi");
1224 assert_eq!(r.name(), "kimi");
1225 }
1226
1227 #[test]
1230 fn fixture_claude_success() {
1231 let raw = include_str!("../../../../tests/fixtures/claude_success.json");
1232 let parsed = runner().parse_response(raw).unwrap();
1233 assert_eq!(parsed.response.status, "done");
1234 assert!(parsed.response.summary.contains("Implemented"));
1235 assert_eq!(parsed.response.accomplished.len(), 2);
1236 assert_eq!(parsed.input_tokens, Some(15000));
1237 assert_eq!(parsed.output_tokens, Some(3500));
1238 assert_eq!(parsed.duration_ms, Some(45000));
1239 }
1240
1241 #[test]
1242 fn fixture_claude_rate_limit() {
1243 let raw = include_str!("../../../../tests/fixtures/claude_error_rate_limit.json");
1244 let err = runner().parse_response(raw).unwrap_err();
1245 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1246 }
1247
1248 #[test]
1251 fn fixture_claude_stream_success() {
1252 let raw = include_str!("../../../../tests/fixtures/claude_stream_success.jsonl");
1253 let parsed = runner().parse_response(raw).unwrap();
1254 assert_eq!(parsed.response.status, "done");
1255 assert!(parsed.response.summary.contains("Implemented"));
1256 assert_eq!(parsed.response.accomplished.len(), 2);
1257 assert_eq!(parsed.input_tokens, Some(15000));
1258 assert_eq!(parsed.output_tokens, Some(3500));
1259 assert_eq!(parsed.duration_ms, Some(45000));
1260 }
1261
1262 #[test]
1263 fn fixture_claude_stream_error() {
1264 let raw = include_str!("../../../../tests/fixtures/claude_stream_error.jsonl");
1265 let err = runner().parse_response(raw).unwrap_err();
1266 assert!(matches!(err, AgentError::Auth { .. }), "got: {err:?}");
1267 }
1268
1269 #[test]
1270 fn fixture_claude_stream_rate_limit() {
1271 let raw = include_str!("../../../../tests/fixtures/claude_stream_rate_limit.jsonl");
1272 let err = runner().parse_response(raw).unwrap_err();
1273 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1274 }
1275
1276 #[test]
1277 fn fixture_claude_stream_thinking_block_conflict() {
1278 let raw =
1279 include_str!("../../../../tests/fixtures/claude_stream_thinking_block_conflict.jsonl");
1280 let err = runner().parse_response(raw).unwrap_err();
1281 assert!(
1282 matches!(err, AgentError::ThinkingBlockConflict { .. }),
1283 "expected ThinkingBlockConflict, got: {err:?}"
1284 );
1285 }
1286
1287 #[test]
1288 fn classify_error_thinking_block_conflict_from_stderr() {
1289 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.";
1292 let err = runner().classify_error(1, "", stderr);
1293 assert!(
1294 matches!(err, AgentError::ThinkingBlockConflict { .. }),
1295 "expected ThinkingBlockConflict, got: {err:?}"
1296 );
1297 }
1298
1299 #[test]
1300 fn thinking_block_conflict_does_not_match_unrelated_text() {
1301 let unrelated = "I was thinking about how to handle blocks of code in the response.";
1304 let err = runner().classify_error(1, "", unrelated);
1305 assert!(
1306 !matches!(err, AgentError::ThinkingBlockConflict { .. }),
1307 "should not classify unrelated text as ThinkingBlockConflict, got: {err:?}"
1308 );
1309 }
1310
1311 #[test]
1312 fn parse_ndjson_with_intermediate_events() {
1313 let raw = concat!(
1315 r#"{"type":"system","subtype":"init"}"#,
1316 "\n",
1317 r#"{"type":"assistant","message":{"role":"assistant","content":[]}}"#,
1318 "\n",
1319 r#"{"type":"tool_use","tool":{"name":"Read","input":{}}}"#,
1320 "\n",
1321 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}}"#,
1322 "\n"
1323 );
1324 let parsed = runner().parse_response(raw).unwrap();
1325 assert_eq!(parsed.response.status, "done");
1326 assert_eq!(parsed.response.summary, "streamed");
1327 assert_eq!(parsed.input_tokens, Some(5));
1328 assert_eq!(parsed.output_tokens, Some(10));
1329 }
1330
1331 #[test]
1332 fn parse_ndjson_skips_malformed_lines() {
1333 let raw = concat!(
1335 "not valid json\n",
1336 r#"{"type":"partial_update","data":"something"}"#,
1337 "\n",
1338 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}}"#,
1339 "\n"
1340 );
1341 let parsed = runner().parse_response(raw).unwrap();
1342 assert_eq!(parsed.response.status, "done");
1343 assert_eq!(parsed.response.summary, "ok");
1344 }
1345
1346 #[test]
1347 fn parse_single_line_json_backward_compat() {
1348 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}}"#;
1350 let parsed = runner().parse_response(raw).unwrap();
1351 assert_eq!(parsed.response.status, "done");
1352 assert_eq!(parsed.response.summary, "backward compat");
1353 assert_eq!(parsed.input_tokens, Some(50));
1354 assert_eq!(parsed.output_tokens, Some(25));
1355 }
1356
1357 #[test]
1360 fn kimi_parses_claude_envelope() {
1361 let r = ClaudeRunner::new("kimi");
1362 let raw = r#"{
1363 "type": "result",
1364 "subtype": "success",
1365 "is_error": false,
1366 "duration_ms": 3000,
1367 "result": "{\"status\":\"done\",\"summary\":\"kimi did it\",\"accomplished\":[\"task\"],\"remaining\":[],\"files\":[\"a.rs\"]}",
1368 "usage": {"input_tokens": 200, "output_tokens": 100}
1369 }"#;
1370
1371 let parsed = r.parse_response(raw).unwrap();
1372 assert_eq!(parsed.response.status, "done");
1373 assert_eq!(parsed.response.summary, "kimi did it");
1374 }
1375
1376 #[test]
1377 fn minimax_parses_claude_envelope() {
1378 let r = ClaudeRunner::new("minimax");
1379 assert_eq!(r.name(), "minimax");
1380 let raw = r#"{
1381 "type": "result",
1382 "subtype": "success",
1383 "is_error": false,
1384 "result": "{\"status\":\"done\",\"summary\":\"minimax did it\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1385 "usage": {}
1386 }"#;
1387
1388 let parsed = r.parse_response(raw).unwrap();
1389 assert_eq!(parsed.response.status, "done");
1390 }
1391
1392 #[test]
1393 fn kimi_build_command_uses_kimi_binary() {
1394 let r = ClaudeRunner::new("kimi");
1395 let perms = PermissionRules {
1396 autonomous: true,
1397 sandbox: SandboxLevel::WorkspaceWrite,
1398 disallowed_tools: vec![],
1399 allowed_tools: vec![],
1400 allowed_edit_paths: vec![],
1401 deny_read_only: false,
1402 extra_writable_dirs: vec![],
1403 };
1404 let cmd = r.build_command(
1405 Some("sonnet"),
1406 "timeout 1800",
1407 "/tmp/sys.txt",
1408 "/tmp/msg.txt",
1409 &perms,
1410 );
1411 assert!(cmd.contains("kimi -p"), "expected kimi binary, got: {cmd}");
1412 assert!(cmd.contains("--model 'sonnet'"));
1413 }
1414
1415 #[test]
1416 fn build_command_with_allowed_tools() {
1417 let r = runner();
1418 let perms = PermissionRules {
1419 autonomous: true,
1420 sandbox: SandboxLevel::WorkspaceWrite,
1421 disallowed_tools: vec!["Bash(rm *)".to_string()], allowed_tools: vec![
1423 "Edit".to_string(),
1424 "Write".to_string(),
1425 "Read".to_string(),
1426 "Bash".to_string(),
1427 "Grep".to_string(),
1428 "git".to_string(),
1429 "npm".to_string(),
1430 ],
1431 allowed_edit_paths: vec![],
1432 deny_read_only: false,
1433 extra_writable_dirs: vec![],
1434 };
1435 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1436
1437 assert!(
1439 cmd.contains("--allowedTools"),
1440 "expected --allowedTools flag, got: {cmd}"
1441 );
1442 assert!(
1443 !cmd.contains("--disallowedTools"),
1444 "should not have --disallowedTools when allowed_tools is set, got: {cmd}"
1445 );
1446
1447 assert!(cmd.contains("Edit"), "missing Edit tool");
1449 assert!(cmd.contains("Write"), "missing Write tool");
1450 assert!(cmd.contains("Bash"), "missing Bash tool");
1451
1452 assert!(
1454 cmd.contains("Bash(git *)"),
1455 "missing Bash(git *) pattern, got: {cmd}"
1456 );
1457 assert!(
1458 cmd.contains("Bash(npm *)"),
1459 "missing Bash(npm *) pattern, got: {cmd}"
1460 );
1461 }
1462
1463 #[test]
1464 fn build_command_allowed_tools_empty_falls_back_to_disallowed() {
1465 let r = runner();
1466 let perms = PermissionRules {
1467 autonomous: true,
1468 sandbox: SandboxLevel::WorkspaceWrite,
1469 disallowed_tools: vec!["Bash(rm *)".to_string()],
1470 allowed_tools: vec![], allowed_edit_paths: vec![],
1472 deny_read_only: false,
1473 extra_writable_dirs: vec![],
1474 };
1475 let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1476
1477 assert!(
1478 cmd.contains("--disallowedTools"),
1479 "should fall back to --disallowedTools when allowed_tools is empty, got: {cmd}"
1480 );
1481 assert!(
1482 !cmd.contains("--allowedTools"),
1483 "should not have --allowedTools when empty, got: {cmd}"
1484 );
1485 }
1486
1487 #[test]
1488 fn translate_allowed_tools_categorizes_correctly() {
1489 let allowed = vec![
1490 "Edit".to_string(),
1491 "Bash".to_string(),
1492 "Grep".to_string(),
1493 "git".to_string(),
1494 "cargo".to_string(),
1495 "WebFetch".to_string(),
1496 ];
1497 let result = translate_allowed_tools(&allowed, &[]);
1499 assert_eq!(
1500 result,
1501 vec![
1502 "Edit",
1503 "Bash",
1504 "Grep",
1505 "Bash(git *)",
1506 "Bash(cargo *)",
1507 "WebFetch"
1508 ]
1509 );
1510 }
1511
1512 #[test]
1513 fn translate_allowed_tools_scopes_edit_write() {
1514 let allowed = vec!["Edit".to_string(), "Write".to_string(), "Read".to_string()];
1515 let paths = vec![std::path::PathBuf::from("/tmp/worktree")];
1516 let result = translate_allowed_tools(&allowed, &paths);
1517 assert_eq!(
1518 result,
1519 vec!["Edit(/tmp/worktree/*)", "Write(/tmp/worktree/*)", "Read"]
1520 );
1521 }
1522
1523 #[test]
1524 fn classify_error_overloaded_529() {
1525 let err = runner().classify_error(1, "", "overloaded_error: 529");
1527 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1528 }
1529
1530 #[test]
1531 fn classify_error_context_length() {
1532 let err = runner().classify_error(1, "", "context_length_exceeded: max 200000 tokens");
1533 assert!(
1534 matches!(err, AgentError::ContextOverflow { .. }),
1535 "got: {err:?}"
1536 );
1537 }
1538
1539 #[test]
1543 fn extract_text_stream_json_returns_result_field() {
1544 let raw = concat!(
1545 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1546 "\n",
1547 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1548 "\n",
1549 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1550 );
1551 let text = runner().extract_text(raw).unwrap();
1552 assert_eq!(
1553 text,
1554 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1555 );
1556 }
1557
1558 #[test]
1560 fn extract_text_stream_json_markdown_result() {
1561 let review_json =
1562 r#"```json\n{"decision":"request_changes","notes":"Fix it","issues":[]}\n```"#;
1563 let result_line = format!(
1564 r#"{{"type":"result","subtype":"success","is_error":false,"result":"{review_json}","usage":{{"input_tokens":10,"output_tokens":5}}}}"#
1565 );
1566 let raw = format!(
1567 "{}\n{}",
1568 r#"{"type":"system","subtype":"init","session_id":"s1"}"#, result_line
1569 );
1570 let text = runner().extract_text(&raw).unwrap();
1571 assert!(
1572 text.contains("request_changes"),
1573 "expected review JSON in result, got: {text}"
1574 );
1575 }
1576
1577 #[test]
1579 fn extract_text_stream_json_rate_limit_propagates() {
1580 let raw = concat!(
1581 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1582 "\n",
1583 r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded: 429 Too Many Requests","usage":{"input_tokens":0,"output_tokens":0}}"#,
1584 );
1585 let err = runner().extract_text(raw).unwrap_err();
1586 assert!(
1587 matches!(err, AgentError::RateLimit { .. }),
1588 "expected RateLimit, got: {err:?}"
1589 );
1590 }
1591
1592 #[test]
1593 fn extract_stream_json_fallbacks_to_opencode_extractor_on_leading_init() {
1594 let raw = concat!(
1599 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1600 "\n",
1601 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"status\":\"done\",\"summary\":\"fallback\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}]}}"#,
1603 );
1604
1605 let text = extract_stream_json_result_text(raw).expect("should fallback and extract JSON");
1606 let parsed: serde_json::Value =
1607 serde_json::from_str(&text).expect("extracted text must be JSON");
1608 assert_eq!(parsed["status"], "done");
1609 assert_eq!(parsed["summary"], "fallback");
1610 }
1611
1612 #[test]
1614 fn extract_text_stream_json_auth_error_propagates() {
1615 let raw = concat!(
1616 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1617 "\n",
1618 r#"{"type":"result","subtype":"error","is_error":true,"result":"401 Unauthorized: invalid api key","usage":{"input_tokens":0,"output_tokens":0}}"#,
1619 );
1620 let err = runner().extract_text(raw).unwrap_err();
1621 assert!(
1622 matches!(err, AgentError::Auth { .. }),
1623 "expected Auth, got: {err:?}"
1624 );
1625 }
1626
1627 #[test]
1629 fn extract_text_empty_input_returns_empty_ok() {
1630 let text = runner().extract_text("").unwrap();
1631 assert!(text.is_empty());
1632 }
1633
1634 #[test]
1637 fn find_claude_result_success_with_tokens() {
1638 let ndjson = concat!(
1639 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1640 "\n",
1641 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working..."}]}}"#,
1642 "\n",
1643 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}","usage":{"input_tokens":1500,"output_tokens":200},"duration_ms":5000}"#,
1644 );
1645 let result = find_claude_result(ndjson).expect("should find result");
1646 assert!(!result.is_error);
1647 assert!(result.result_text.contains("approve"));
1648 assert_eq!(result.input_tokens, Some(1500));
1649 assert_eq!(result.output_tokens, Some(200));
1650 assert_eq!(result.duration_ms, Some(5000));
1651 }
1652
1653 #[test]
1654 fn find_claude_result_error_flag() {
1655 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1656 let result = find_claude_result(ndjson).expect("should find result");
1657 assert!(result.is_error);
1658 assert!(result.result_text.contains("rate limit"));
1659 }
1660
1661 #[test]
1662 fn find_claude_result_error_envelope_with_empty_result_uses_errors() {
1663 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}"]}"#;
1666 let result = find_claude_result(ndjson).expect("should find result");
1667 assert!(result.is_error);
1668 assert!(result.result_text.contains("usage balance exhausted"));
1669 assert!(crate::engine::cooldown::detect_credit_exhaustion(&result.result_text).is_some());
1670 }
1671
1672 #[test]
1673 fn find_claude_result_empty_returns_none() {
1674 assert!(find_claude_result("").is_none());
1675 assert!(find_claude_result(" ").is_none());
1676 }
1677
1678 #[test]
1679 fn find_claude_result_no_result_line_falls_back_to_assistant() {
1680 let ndjson = concat!(
1682 r#"{"type":"system","subtype":"init","model":"MiniMax-M2.7"}"#,
1683 "\n",
1684 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analysis complete"}],"usage":{"input_tokens":800,"output_tokens":150}}}"#,
1685 );
1686 let result = find_claude_result(ndjson).expect("should fall back to assistant text");
1687 assert!(!result.is_error);
1688 assert_eq!(result.result_text, "analysis complete");
1689 assert_eq!(result.input_tokens, Some(800));
1690 assert_eq!(result.output_tokens, Some(150));
1691 }
1692
1693 #[test]
1694 fn find_claude_result_plain_text_returns_none() {
1695 assert!(find_claude_result("just some plain text output").is_none());
1697 }
1698
1699 #[test]
1700 fn find_claude_result_model_usage() {
1701 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":5000,"outputTokens":1000}},"duration_ms":3000}"#;
1702 let result = find_claude_result(ndjson).expect("should extract modelUsage");
1703 assert_eq!(result.input_tokens, Some(5000));
1704 assert_eq!(result.output_tokens, Some(1000));
1705 }
1706
1707 #[test]
1714 fn find_ndjson_result_line_skips_plain_text_result() {
1715 let inner_result = serde_json::json!({"status": "done", "summary": "fix applied"});
1717 let json_result_line = serde_json::json!({
1718 "type": "result",
1719 "subtype": "success",
1720 "is_error": false,
1721 "duration_ms": 273044,
1722 "result": inner_result.to_string(),
1723 "usage": {"input_tokens": 12, "output_tokens": 1577}
1724 })
1725 .to_string();
1726 let plain_result_line = serde_json::json!({
1727 "type": "result",
1728 "subtype": "success",
1729 "is_error": false,
1730 "duration_ms": 2352,
1731 "result": "All 2352 tests passed.",
1732 "usage": {"input_tokens": 3, "output_tokens": 16}
1733 })
1734 .to_string();
1735 let mut ndjson = json_result_line.clone();
1737 ndjson.push('\n');
1738 ndjson.push_str(r#"{"type":"system","subtype":"task_notification"}"#);
1739 ndjson.push('\n');
1740 ndjson.push_str(r#"{"type":"system","subtype":"init"}"#);
1741 ndjson.push('\n');
1742 ndjson.push_str(
1743 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"summary here"}]}}"#,
1744 );
1745 ndjson.push('\n');
1746 ndjson.push_str(&plain_result_line);
1747
1748 let line = find_ndjson_result_line(&ndjson)
1749 .expect("should find a result line")
1750 .0;
1751 let parsed: serde_json::Value =
1753 serde_json::from_str(line).expect("returned line must be valid JSON");
1754 let result_val = parsed.get("result").expect("result field must exist");
1755 assert!(
1756 result_val.is_string(),
1757 "result field must be a JSON string (not plain text), got: {result_val}"
1758 );
1759 let result_str = result_val.as_str().unwrap();
1760 assert!(
1761 result_str.starts_with('{'),
1762 "result must be JSON (object), got: {result_str}"
1763 );
1764 assert!(
1765 !result_str.contains("All 2352 tests passed"),
1766 "must NOT return outer plain-text result: {result_str}"
1767 );
1768 }
1769
1770 #[test]
1775 fn find_ndjson_result_line_prefers_more_tokens_on_plain_text() {
1776 let inner_result_line = serde_json::json!({
1778 "type": "result",
1779 "subtype": "success",
1780 "is_error": false,
1781 "duration_ms": 273044,
1782 "result": "All 2352 tests pass. The fix is clean and correct.",
1783 "usage": {"input_tokens": 12, "output_tokens": 1577}
1784 })
1785 .to_string();
1786 let outer_result_line = serde_json::json!({
1787 "type": "result",
1788 "subtype": "success",
1789 "is_error": false,
1790 "duration_ms": 2352,
1791 "result": "Already retrieved the result — all 2352 tests passed.",
1792 "usage": {"input_tokens": 3, "output_tokens": 16}
1793 })
1794 .to_string();
1795
1796 let ndjson = format!(
1798 "{0}\n{1}\n{2}",
1799 inner_result_line, r#"{"type":"system","subtype":"init"}"#, outer_result_line
1800 );
1801
1802 let (line, tokens) = find_ndjson_result_line(&ndjson).expect("should find a result line");
1803
1804 assert!(
1806 line.contains("The fix is clean"),
1807 "expected inner result (more tokens), got: {line}"
1808 );
1809 assert_eq!(
1811 tokens,
1812 Some((12, 1577)),
1813 "tokens must be from the inner result"
1814 );
1815 }
1816
1817 #[test]
1820 fn parse_response_preserves_tokens_on_synthesis_fallback() {
1821 let ndjson = concat!(
1824 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}}"#,
1825 "\n",
1826 r#"{"type":"system","subtype":"init"}"#,
1827 "\n",
1828 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}}"#,
1829 );
1830
1831 let parsed = runner()
1832 .parse_response(ndjson)
1833 .expect("should parse successfully");
1834
1835 assert_eq!(
1837 parsed.input_tokens,
1838 Some(12),
1839 "input_tokens must be preserved (not lost on synthesis fallback)"
1840 );
1841 assert_eq!(
1842 parsed.output_tokens,
1843 Some(1577),
1844 "output_tokens must be preserved (not lost on synthesis fallback)"
1845 );
1846 }
1847
1848 #[test]
1849 fn parse_response_uses_inner_json_result_preserving_tokens() {
1850 let ndjson = concat!(
1853 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}}"#,
1854 "\n",
1855 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1856 "\n",
1857 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"tests passed"}]}}"#,
1858 "\n",
1859 r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"All 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
1860 );
1861
1862 let parsed = runner()
1863 .parse_response(ndjson)
1864 .expect("should parse successfully");
1865
1866 assert_eq!(
1868 parsed.response.status, "done",
1869 "expected inner JSON result status, got: {:?}",
1870 parsed.response.status
1871 );
1872 assert_eq!(
1873 parsed.response.summary.as_str(),
1874 "fix applied",
1875 "expected inner JSON result summary, got: {:?}",
1876 parsed.response.summary
1877 );
1878 assert_eq!(
1880 parsed.input_tokens,
1881 Some(12),
1882 "input_tokens must be from inner result (not outer plain-text result)"
1883 );
1884 assert_eq!(
1885 parsed.output_tokens,
1886 Some(1577),
1887 "output_tokens must be from inner result (not outer plain-text result)"
1888 );
1889 }
1890
1891 #[test]
1895 fn extract_text_falls_back_to_opencode_style_when_no_json_result() {
1896 let ndjson = concat!(
1898 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1899 "\n",
1900 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All tasks complete"}]}}"#,
1901 );
1902
1903 let text = runner().extract_text(ndjson).expect("should extract text");
1904 assert!(
1906 text.contains("All tasks complete"),
1907 "expected extracted assistant text, got: {text}"
1908 );
1909 assert!(!text.contains("type"), "must not return raw NDJSON: {text}");
1910 }
1911}
1912
1913pub(crate) fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
1915 let blocks = || v["message"]["content"].as_array().into_iter().flatten();
1916 match json_str(v, "/type") {
1917 "system" if json_str(v, "/subtype") == "init" => vec![StreamEvent::Started {
1918 model: json_str(v, "/model").to_string(),
1919 }],
1920 "assistant" => blocks()
1921 .filter_map(|b| match json_str(b, "/type") {
1922 "text" => Some(StreamEvent::Text(json_str(b, "/text").to_string())),
1923 "thinking" => Some(StreamEvent::Thinking(json_str(b, "/thinking").to_string())),
1924 "tool_use" => Some(StreamEvent::ToolCall {
1925 id: json_str(b, "/id").to_string(),
1926 name: json_str(b, "/name").to_string(),
1927 input: b["input"].clone(),
1928 }),
1929 _ => None,
1930 })
1931 .collect(),
1932 "user" => blocks()
1933 .filter(|b| json_str(b, "/type") == "tool_result")
1934 .map(|b| StreamEvent::ToolResult {
1935 id: json_str(b, "/tool_use_id").to_string(),
1936 name: String::new(),
1937 output: match &b["content"] {
1938 serde_json::Value::String(s) => s.clone(),
1939 serde_json::Value::Array(parts) => parts
1940 .iter()
1941 .filter_map(|p| p["text"].as_str())
1942 .collect::<Vec<_>>()
1943 .join("\n"),
1944 _ => String::new(),
1945 },
1946 is_error: b["is_error"].as_bool() == Some(true),
1947 })
1948 .collect(),
1949 "error" => vec![StreamEvent::Error(json_error_message(v))],
1950 "result" => {
1951 let failed = v["is_error"].as_bool() == Some(true)
1952 || json_str(v, "/subtype").starts_with("error");
1953 if failed {
1954 return vec![StreamEvent::Error(json_str(v, "/result").to_string())];
1955 }
1956
1957 let turns = v["num_turns"].as_u64().unwrap_or(0);
1958 let secs = v["duration_ms"].as_u64().unwrap_or(0) / 1000;
1959 let time = if secs >= 60 {
1960 format!("{}m {}s", secs / 60, secs % 60)
1961 } else {
1962 format!("{secs}s")
1963 };
1964 vec![StreamEvent::Done(format!("{turns} turns · {time}"))]
1965 }
1966 _ => vec![],
1967 }
1968}
1969
1970#[cfg(test)]
1971mod stream_tests {
1972 use super::*;
1973 use serde_json::json;
1974
1975 fn events(line: &str) -> Vec<StreamEvent> {
1976 ClaudeRunner::new("claude").stream_events(&serde_json::from_str(line).unwrap())
1977 }
1978
1979 #[test]
1980 fn init_reports_model() {
1981 let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-5","cwd":"/w"}"#;
1982 assert_eq!(
1983 events(line),
1984 vec![StreamEvent::Started {
1985 model: "claude-sonnet-5".into()
1986 }]
1987 );
1988 }
1989
1990 #[test]
1991 fn assistant_blocks_in_order() {
1992 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"}}]}}"#;
1993 assert_eq!(
1994 events(line),
1995 vec![
1996 StreamEvent::Thinking("plan".into()),
1997 StreamEvent::Text("Reading.".into()),
1998 StreamEvent::ToolCall {
1999 id: "t1".into(),
2000 name: "Read".into(),
2001 input: json!({"file_path": "a.rs"}),
2002 },
2003 ]
2004 );
2005 }
2006
2007 #[test]
2008 fn tool_result_string_and_block_content() {
2009 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}]}}"#;
2010 assert_eq!(
2011 events(line),
2012 vec![
2013 StreamEvent::ToolResult {
2014 id: "t1".into(),
2015 name: String::new(),
2016 output: "ok".into(),
2017 is_error: false,
2018 },
2019 StreamEvent::ToolResult {
2020 id: "t2".into(),
2021 name: String::new(),
2022 output: "a\nb".into(),
2023 is_error: true,
2024 },
2025 ]
2026 );
2027 }
2028
2029 #[test]
2030 fn result_success_and_error() {
2031 let ok = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":66,"duration_ms":580214}"#;
2032 assert_eq!(
2033 events(ok),
2034 vec![StreamEvent::Done("66 turns · 9m 40s".into())]
2035 );
2036 let err = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"too many turns"}"#;
2037 assert_eq!(
2038 events(err),
2039 vec![StreamEvent::Error("too many turns".into())]
2040 );
2041 }
2042
2043 #[test]
2044 fn noise_events_are_dropped() {
2045 for line in [
2046 r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
2047 r#"{"type":"system","subtype":"task_started"}"#,
2048 r#"{"type":"rate_limit_event","rate_limit_info":{}}"#,
2049 r#"{"type":"tool_progress"}"#,
2050 ] {
2051 assert!(events(line).is_empty(), "{line}");
2052 }
2053 }
2054}