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