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