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