1pub mod claude;
9pub mod codex;
10pub mod grok;
11pub mod kimi;
12pub mod minimax;
13pub mod opencode;
14
15use crate::parser::AgentResponse;
16use std::path::PathBuf;
17use std::time::Duration;
18
19#[derive(Debug, Clone)]
25pub struct PermissionRules {
26 pub autonomous: bool,
28 pub sandbox: SandboxLevel,
30 pub disallowed_tools: Vec<String>,
32 pub allowed_tools: Vec<String>,
38 pub allowed_edit_paths: Vec<PathBuf>,
42 #[allow(dead_code)]
46 pub deny_read_only: bool,
47 pub extra_writable_dirs: Vec<PathBuf>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum SandboxLevel {
57 WorkspaceWrite,
59 FullAccess,
61 None,
63}
64
65impl Default for PermissionRules {
66 fn default() -> Self {
67 Self {
68 autonomous: true,
69 sandbox: SandboxLevel::WorkspaceWrite,
70 disallowed_tools: vec![
71 "Bash(rm *)".to_string(),
72 "Bash(rm -*)".to_string(),
73 "Bash(git push*)".to_string(),
74 ],
75 allowed_tools: vec![],
76 allowed_edit_paths: vec![],
77 deny_read_only: false,
78 extra_writable_dirs: vec![],
79 }
80 }
81}
82
83impl PermissionRules {
84 pub fn from_config() -> Self {
86 let mut rules = Self::default();
87
88 if let Ok(mode) = crate::config::get("workflow.permissions.mode") {
89 rules.autonomous = mode != "supervised";
90 }
91
92 if let Ok(sandbox) = crate::config::get("workflow.permissions.sandbox") {
93 rules.sandbox = match sandbox.as_str() {
94 "full-access" | "danger-full-access" => SandboxLevel::FullAccess,
95 "none" => SandboxLevel::None,
96 _ => SandboxLevel::WorkspaceWrite,
97 };
98 }
99
100 if let Ok(tools) = crate::config::get("workflow.disallowed_tools") {
101 let parsed: Vec<String> = tools
102 .split(',')
103 .map(|s| s.trim().to_string())
104 .filter(|s| !s.is_empty())
105 .collect();
106 if !parsed.is_empty() {
107 rules.disallowed_tools = parsed;
108 }
109 }
110
111 if let Ok(tools) = crate::config::get_list("workflow.allowed_tools") {
112 rules.allowed_tools = tools;
113 }
114
115 rules
116 }
117
118 pub fn deny_read_only() -> Self {
125 Self {
126 autonomous: true,
127 sandbox: SandboxLevel::WorkspaceWrite,
128 disallowed_tools: vec![
129 "Bash(rm *)".to_string(),
130 "Bash(rm -*)".to_string(),
131 "Bash(git push*)".to_string(),
132 "Bash(ls *)".to_string(),
133 "Bash(ls)".to_string(),
134 "Bash(find *)".to_string(),
135 "Bash(cat *)".to_string(),
136 "Bash(head *)".to_string(),
137 "Bash(tail *)".to_string(),
138 "Bash(fzf *)".to_string(),
139 "Bash(less *)".to_string(),
140 "Bash(more *)".to_string(),
141 "Read".to_string(),
142 "Glob".to_string(),
143 "Grep".to_string(),
144 ],
145 allowed_tools: vec![],
146 allowed_edit_paths: vec![],
147 deny_read_only: true,
148 extra_writable_dirs: vec![],
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
159#[allow(dead_code)] pub struct AgentResult {
161 pub is_error: bool,
163 pub result_text: String,
165 pub input_tokens: Option<u64>,
167 pub output_tokens: Option<u64>,
169 pub cost_usd: Option<f64>,
171 pub duration_ms: Option<u64>,
173 pub truncated_by_length: bool,
179}
180
181pub fn collect_assistant_messages_text(_agent: &str, ndjson: &str) -> String {
186 claude::collect_assistant_messages_text(ndjson)
187}
188
189pub fn find_agent_result(agent: &str, ndjson: &str) -> Option<AgentResult> {
194 match agent {
195 "claude" | "kimi" | "minimax" => claude::find_claude_result(ndjson),
196 "grok" => grok::find_grok_result(ndjson),
197 "opencode" => opencode::find_opencode_result(ndjson),
198 "codex" => codex::find_codex_result(ndjson),
199 _ => claude::find_claude_result(ndjson),
200 }
201}
202
203#[derive(Debug, Clone)]
206pub struct ParsedResponse {
207 pub response: AgentResponse,
209 pub input_tokens: Option<u64>,
211 pub output_tokens: Option<u64>,
213 pub duration_ms: Option<u64>,
215}
216
217#[derive(Debug, Clone)]
219pub enum AgentError {
220 RateLimit { message: String },
222 Auth { message: String },
224 ModelUnavailable { message: String, model: String },
226 ContextOverflow { message: String },
228 Timeout { elapsed: Duration },
230 MissingTool { tool: String },
232 PermissionDenied { message: String },
234 WaitingForInput { message: String },
236 StaleSession { session_id: String },
241 InvalidResponse { raw: String },
243 AgentFailed { message: String },
245 NetworkError { message: String },
247 ThinkingBlockConflict { message: String },
254 Unknown { exit_code: i32, message: String },
256}
257
258impl std::fmt::Display for AgentError {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 match self {
261 Self::RateLimit { message, .. } => write!(f, "rate limit: {message}"),
262 Self::Auth { message } => write!(f, "auth error: {message}"),
263 Self::ModelUnavailable { message, model } => {
264 write!(f, "model unavailable ({model}): {message}")
265 }
266 Self::ContextOverflow { message, .. } => write!(f, "context overflow: {message}"),
267 Self::Timeout { elapsed } => write!(f, "timeout after {}s", elapsed.as_secs()),
268 Self::MissingTool { tool } => write!(f, "missing tool: {tool}"),
269 Self::PermissionDenied { message } => write!(f, "permission denied: {message}"),
270 Self::WaitingForInput { message } => write!(f, "waiting for input: {message}"),
271 Self::StaleSession { session_id } => {
272 write!(f, "stale session (session expired): {session_id}")
273 }
274 Self::InvalidResponse { raw } => {
275 let end = truncate_at_char_boundary(raw, 200);
276 write!(f, "invalid response: {}", &raw[..end])
277 }
278 Self::AgentFailed { message } => write!(f, "agent failed: {message}"),
279 Self::NetworkError { message } => write!(f, "network error: {message}"),
280 Self::ThinkingBlockConflict { message } => {
281 write!(f, "thinking block conflict: {message}")
282 }
283 Self::Unknown { exit_code, message } => {
284 write!(f, "unknown error (exit {exit_code}): {message}")
285 }
286 }
287 }
288}
289
290impl std::error::Error for AgentError {}
291
292pub fn error_class_name(err: &AgentError) -> &'static str {
295 match err {
296 AgentError::RateLimit { .. } => "RateLimit",
297 AgentError::Auth { .. } => "Auth",
298 AgentError::ModelUnavailable { .. } => "ModelUnavailable",
299 AgentError::ContextOverflow { .. } => "ContextOverflow",
300 AgentError::Timeout { .. } => "Timeout",
301 AgentError::MissingTool { .. } => "MissingTool",
302 AgentError::PermissionDenied { .. } => "PermissionDenied",
303 AgentError::WaitingForInput { .. } => "WaitingForInput",
304 AgentError::StaleSession { .. } => "StaleSession",
305 AgentError::InvalidResponse { .. } => "InvalidResponse",
306 AgentError::AgentFailed { .. } => "AgentFailed",
307 AgentError::NetworkError { .. } => "NetworkError",
308 AgentError::ThinkingBlockConflict { .. } => "ThinkingBlockConflict",
309 AgentError::Unknown { .. } => "Unknown",
310 }
311}
312
313fn extract_file_paths(text: &str) -> Vec<String> {
318 let extensions = [
321 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh",
322 "md", "toml", "yaml", "yml", "json", "sql", "html", "css", "swift", "kt",
323 ];
324 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
325 for token in text.split_whitespace() {
326 let token = token.trim_matches(|c: char| {
329 matches!(
330 c,
331 '`' | '\'' | '"' | '(' | ')' | '[' | ']' | ',' | ':' | ';'
332 )
333 });
334 let token = if let Some(without) = token.strip_suffix('.') {
338 let still_has_ext = extensions
339 .iter()
340 .any(|ext| without.ends_with(&format!(".{ext}")));
341 if still_has_ext {
342 without
343 } else {
344 token
345 }
346 } else {
347 token
348 };
349 let has_ext = extensions
352 .iter()
353 .any(|ext| token.ends_with(&format!(".{ext}")));
354 if !has_ext {
355 continue;
356 }
357 let path_like = token.contains('/')
360 || token.starts_with("src")
361 || token.starts_with("tests")
362 || token.starts_with("lib")
363 || token.starts_with("bin")
364 || token.starts_with("prompts")
365 || token.starts_with("migrations");
366 if path_like && token.len() >= 4 && !token.contains("://") {
367 found.insert(token.to_string());
368 }
369 }
370 found.into_iter().collect()
371}
372
373fn extract_bullet_sections(text: &str) -> (Vec<String>, Vec<String>) {
378 let remaining_headers = [
379 "remaining",
380 "todo",
381 "to do",
382 "next steps",
383 "still needed",
384 "left to do",
385 "not done",
386 "outstanding",
387 ];
388
389 let mut accomplished: Vec<String> = vec![];
390 let mut remaining: Vec<String> = vec![];
391 let mut in_remaining_section = false;
392
393 for line in text.lines() {
394 let trimmed = line.trim();
395 if trimmed.is_empty() {
396 continue;
397 }
398 let lower = trimmed.to_lowercase();
399
400 let is_heading = trimmed.ends_with(':') || trimmed.starts_with('#');
405
406 if is_heading {
407 in_remaining_section = remaining_headers.iter().any(|h| lower.contains(h));
408 continue;
409 }
410
411 let is_bullet = trimmed.starts_with('-')
413 || trimmed.starts_with('*')
414 || trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains('.');
415
416 if !is_bullet {
417 continue;
418 }
419
420 let content = trimmed
422 .trim_start_matches(|c: char| c == '-' || c == '*' || c.is_ascii_digit() || c == '.')
423 .trim()
424 .to_string();
425
426 if content.is_empty() {
427 continue;
428 }
429
430 if in_remaining_section {
431 remaining.push(content);
432 } else {
433 accomplished.push(content);
434 }
435 }
436
437 (accomplished, remaining)
438}
439
440fn is_cli_parser_diagnostic(text: &str) -> bool {
443 let lower = text.to_lowercase();
444
445 if lower.contains("error: unexpected argument") {
447 return true;
448 }
449
450 if text.lines().any(|line| {
452 let trimmed = line.trim();
453 trimmed.starts_with("Usage:") || trimmed.starts_with("usage:")
454 }) {
455 return true;
456 }
457
458 if lower.contains("for more information, try '--help'") {
460 return true;
461 }
462
463 if lower.contains("tip: to pass '") {
465 return true;
466 }
467
468 false
469}
470
471fn looks_like_ndjson_event_stream(text: &str) -> bool {
479 let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
480 return false;
481 };
482
483 if !first_line.starts_with('{') {
484 return false;
485 }
486
487 let Ok(value) = serde_json::from_str::<serde_json::Value>(first_line) else {
488 return false;
489 };
490
491 let Some(event_type) = value.get("type").and_then(|v| v.as_str()) else {
492 return false;
493 };
494
495 matches!(
496 event_type,
497 "step_start"
498 | "step_finish"
499 | "tool_use"
500 | "tool_result"
501 | "assistant"
502 | "system"
503 | "message"
504 | "text"
505 )
506}
507
508pub fn synthesize_response_from_text(text: &str) -> Option<AgentResponse> {
513 let trimmed = text.trim();
514 if trimmed.is_empty() {
515 return None;
516 }
517
518 if is_cli_parser_diagnostic(trimmed) {
526 return None;
527 }
528
529 if looks_like_ndjson_event_stream(trimmed) {
535 return None;
536 }
537
538 let lower = trimmed.to_lowercase();
539 fn contains_word(text: &str, word: &str) -> bool {
546 let mut start = 0;
547 while let Some(pos) = text[start..].find(word) {
548 let abs = start + pos;
549 let before_ok = abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
550 let after_ok = abs + word.len() >= text.len()
551 || !text.as_bytes()[abs + word.len()].is_ascii_alphanumeric();
552 if before_ok && after_ok {
553 return true;
554 }
555 start = abs + 1;
556 if start >= text.len() {
557 break;
558 }
559 }
560 false
561 }
562
563 let looks_negative = [
565 "not done",
566 "not yet done",
567 "not complete",
568 "not yet complete",
569 "not completed",
570 "not yet completed",
571 "not committed",
572 "incomplete",
573 "undone",
574 ]
575 .iter()
576 .any(|needle| lower.contains(needle));
577
578 let explicit_done = [
581 "nothing to",
582 "nothing to do",
583 "nothing to execute",
584 "no positions",
585 "no open positions",
586 "no trades",
587 "no trade",
588 "no action needed",
589 "no changes to commit",
590 "completed",
591 "filed",
593 "filed issue",
594 "filed issues",
595 "created issue",
596 "created issues",
597 "opened issue",
598 "opened issues",
599 "issue created",
600 "issues created",
601 "issues filed",
602 "task created",
603 "posted comment",
604 "comment posted",
605 "changes committed",
606 "commit created",
607 "fixed.",
608 "has been fixed",
609 "the fix has been applied",
610 "the fix has already been applied",
611 "the fix is complete",
613 "fix is working",
614 "all tests pass",
615 "tests pass.",
616 ]
617 .iter()
618 .any(|needle| lower.contains(needle));
619
620 let mut done_confident = false;
626 for sentence in trimmed
627 .split(['.', '!', '?', '\n'])
628 .map(|s| s.trim())
629 .filter(|s| !s.is_empty())
630 {
631 let s_lower = sentence.to_lowercase();
632 let word_count = sentence.split_whitespace().count();
634 let hedging = [
635 "let",
636 "let me",
637 "check",
638 "checking",
639 "will",
640 "i'll",
641 "i will",
642 "trying",
643 "try",
644 "investigat",
645 "maybe",
646 "could",
647 "should",
648 "might",
649 "if ",
650 "plan",
651 "planning",
652 ];
653 let contains_hedge = hedging.iter().any(|h| s_lower.contains(h));
654
655 if contains_word(&s_lower, "done") && word_count <= 8 && !contains_hedge {
656 done_confident = true;
657 break;
658 }
659
660 if s_lower.contains("committed") || s_lower.contains("commit created") {
664 if s_lower.contains("changes")
666 || s_lower.contains("i've")
667 || s_lower.contains("i have")
668 || s_lower.contains("the commit")
669 {
670 done_confident = true;
671 break;
672 }
673 }
674
675 let past_tense_actions = [
680 "added ",
681 "fixed ",
682 "updated ",
683 "implemented ",
684 "created ",
685 "removed ",
686 "changed ",
687 "deleted ",
688 "applied ",
689 "resolved ",
690 "patched ",
691 "refactored ",
692 "merged ",
693 "pushed ",
694 "deployed ",
695 ];
696 if !contains_hedge
697 && word_count <= 12
698 && past_tense_actions.iter().any(|v| s_lower.starts_with(v))
699 {
700 done_confident = true;
701 break;
702 }
703 }
704
705 let looks_done = !looks_negative && (explicit_done || done_confident);
706
707 let looks_error = [
708 "error:",
709 "failed to",
710 "cannot",
711 "permission denied",
712 "unable to",
713 ]
714 .iter()
715 .any(|needle| lower.contains(needle));
716
717 let status = if looks_error {
718 "needs_review"
719 } else if looks_done {
720 "done"
721 } else if looks_negative {
722 "needs_review"
724 } else {
725 return None;
731 };
732
733 let summary_end = truncate_at_char_boundary(trimmed, 500);
734 let summary = trimmed[..summary_end].to_string();
735 let error =
736 (status == "needs_review").then(|| "agent returned plain text instead of JSON".to_string());
737
738 let files = extract_file_paths(trimmed);
740 let (accomplished, remaining) = extract_bullet_sections(trimmed);
741
742 Some(AgentResponse {
743 status: status.to_string(),
744 summary,
745 accomplished,
746 remaining,
747 files,
748 error,
749 input_tokens: None,
750 output_tokens: None,
751 learnings: vec![],
752 delegations: vec![],
753 })
754}
755
756pub(crate) fn shell_single_quote(value: &str) -> String {
758 format!("'{}'", value.replace('\'', "'\\''"))
759}
760
761pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
764 if max_bytes >= s.len() {
765 return s.len();
766 }
767 let mut end = max_bytes;
769 while end > 0 && !s.is_char_boundary(end) {
770 end -= 1;
771 }
772 end
773}
774
775#[derive(Debug, Clone, PartialEq)]
777pub enum StreamEvent {
778 Started {
779 model: String,
780 },
781 Text(String),
782 Thinking(String),
783 ToolCall {
785 id: String,
786 name: String,
787 input: serde_json::Value,
788 },
789 ToolResult {
791 id: String,
792 name: String,
793 output: String,
794 is_error: bool,
795 },
796 Done(String),
797 Error(String),
798}
799
800pub(crate) fn json_str<'a>(v: &'a serde_json::Value, pointer: &str) -> &'a str {
802 v.pointer(pointer)
803 .and_then(serde_json::Value::as_str)
804 .unwrap_or("")
805}
806
807pub(crate) fn json_error_message(v: &serde_json::Value) -> String {
809 [
810 "/message",
811 "/error",
812 "/error/message",
813 "/error/data/message",
814 ]
815 .iter()
816 .find_map(|p| v.pointer(p).and_then(serde_json::Value::as_str))
817 .unwrap_or("unknown error")
818 .to_string()
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823pub enum RouterTranscript {
824 Messages,
826 OpenCode,
828 Raw,
830}
831
832pub trait AgentRunner: Send + Sync {
837 #[cfg(test)]
839 fn name(&self) -> &str;
840
841 fn build_command(
847 &self,
848 model: Option<&str>,
849 timeout_cmd: &str,
850 sys_file: &str,
851 msg_file: &str,
852 permissions: &PermissionRules,
853 ) -> String;
854
855 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError>;
860
861 #[allow(dead_code)] fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
877 Ok(raw.to_string())
878 }
879
880 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent>;
882
883 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError;
888
889 fn classify_error_with_elapsed(
891 &self,
892 exit_code: i32,
893 stdout: &str,
894 stderr: &str,
895 elapsed_secs: Option<u64>,
896 ) -> AgentError {
897 let mut err = self.classify_error(exit_code, stdout, stderr);
898 if let AgentError::Timeout { elapsed } = &mut err {
899 if let Some(actual_elapsed) = elapsed_secs {
900 *elapsed = Duration::from_secs(actual_elapsed);
901 }
902 }
903 err
904 }
905
906 fn available_models(&self) -> Vec<String> {
908 vec![]
909 }
910
911 fn free_models(&self) -> Vec<String> {
913 vec![]
914 }
915
916 fn router_transcript(&self) -> RouterTranscript {
918 RouterTranscript::Raw
919 }
920
921 fn router_command(
927 &self,
928 prompt: &str,
929 model: Option<&str>,
930 ) -> anyhow::Result<tokio::process::Command>;
931}
932
933pub fn supports_session(agent: &str) -> bool {
935 matches!(agent, "claude" | "kimi" | "minimax" | "grok")
936}
937
938pub fn get_runner(agent_name: &str) -> Box<dyn AgentRunner> {
940 const CLAUDE_COMPATIBLE_AGENTS: &[&str] = &["claude", "kimi", "minimax", "olm", "glm"];
942
943 match agent_name {
944 "claude" => Box::new(claude::ClaudeRunner::new(agent_name)),
945 "kimi" => Box::new(kimi::KimiClaudeRunner::new()),
946 "minimax" => Box::new(minimax::MiniMaxClaudeRunner::new(agent_name)),
947 "codex" => Box::new(codex::CodexRunner),
948 "opencode" => Box::new(opencode::OpenCodeRunner::new()),
949 "grok" => Box::new(grok::GrokRunner),
950 other => {
952 if !CLAUDE_COMPATIBLE_AGENTS.contains(&other) {
954 tracing::warn!(
955 agent = other,
956 "unknown agent, using claude-compatible runner"
957 );
958 }
959 Box::new(claude::ClaudeRunner::new(other))
960 }
961 }
962}
963
964pub(crate) fn parse_ndjson(raw: &str) -> Vec<serde_json::Value> {
973 let events: Vec<serde_json::Value> = raw
975 .lines()
976 .filter(|line| !line.trim().is_empty())
977 .filter_map(|line| match serde_json::from_str(line) {
978 Ok(val) => Some(val),
979 Err(e) => {
980 tracing::debug!(line, error = %e, "skipping unparseable NDJSON line");
981 None
982 }
983 })
984 .collect();
985
986 if !events.is_empty() {
990 return events;
991 }
992
993 let acc: String = raw
999 .lines()
1000 .filter(|line| {
1001 let t = line.trim();
1002 !t.is_empty() && !t.starts_with("//") && !t.starts_with('#')
1003 })
1004 .collect::<Vec<_>>()
1005 .join(" ");
1006
1007 if acc.is_empty() {
1008 return Vec::new();
1009 }
1010
1011 if let Ok(val) = serde_json::from_str::<serde_json::Value>(acc.trim()) {
1014 let stripped: String = acc.chars().filter(|c| !c.is_ascii_whitespace()).collect();
1019 if let Ok(stripped_val) = serde_json::from_str::<serde_json::Value>(&stripped) {
1020 if stripped_val == val {
1024 return Vec::new();
1025 }
1026 }
1027 return vec![val];
1028 }
1029
1030 extract_json_objects(&acc)
1034}
1035
1036fn extract_json_objects(text: &str) -> Vec<serde_json::Value> {
1041 let mut results = Vec::new();
1042 let mut start: Option<usize> = None;
1043 let mut depth: i32 = 0;
1044 let mut in_string = false;
1045 let mut escape = false;
1046
1047 for (idx, ch) in text.char_indices() {
1048 if let Some(start_idx) = start {
1049 if in_string {
1050 if escape {
1051 escape = false;
1052 } else if ch == '\\' {
1053 escape = true;
1054 } else if ch == '"' {
1055 in_string = false;
1056 }
1057 continue;
1058 }
1059
1060 match ch {
1061 '"' => in_string = true,
1062 '{' => depth += 1,
1063 '}' => {
1064 depth -= 1;
1065 if depth == 0 {
1066 let candidate = &text[start_idx..=idx];
1067 if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
1068 results.push(val);
1069 }
1070 start = None;
1071 }
1072 }
1073 _ => {}
1074 }
1075 continue;
1076 }
1077
1078 if ch == '{' {
1079 start = Some(idx);
1080 depth = 1;
1081 in_string = false;
1082 escape = false;
1083 }
1084 }
1085
1086 results
1087}
1088
1089macro_rules! delegate_agent_runner {
1098 ($runner:ty, $inner:ident) => {
1099 impl AgentRunner for $runner {
1100 #[cfg(test)]
1101 fn name(&self) -> &str {
1102 self.$inner.name()
1103 }
1104
1105 fn build_command(
1106 &self,
1107 model: Option<&str>,
1108 timeout_cmd: &str,
1109 sys_file: &str,
1110 msg_file: &str,
1111 permissions: &PermissionRules,
1112 ) -> String {
1113 self.$inner
1114 .build_command(model, timeout_cmd, sys_file, msg_file, permissions)
1115 }
1116
1117 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
1118 self.$inner.parse_response(raw)
1119 }
1120
1121 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
1122 self.$inner.extract_text(raw)
1123 }
1124
1125 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
1126 self.$inner.classify_error(exit_code, stdout, stderr)
1127 }
1128
1129 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
1130 self.$inner.stream_events(event)
1131 }
1132
1133 fn router_transcript(&self) -> crate::engine::runner::agents::RouterTranscript {
1134 self.$inner.router_transcript()
1135 }
1136
1137 fn router_command(
1138 &self,
1139 prompt: &str,
1140 model: Option<&str>,
1141 ) -> anyhow::Result<tokio::process::Command> {
1142 self.$inner.router_command(prompt, model)
1143 }
1144 }
1145 };
1146}
1147
1148pub(crate) use delegate_agent_runner;
1149
1150pub(crate) mod patterns {
1152 use super::AgentError;
1153 use std::time::Duration;
1154
1155 fn is_word_boundary(text: &str, lower_pos: usize, pattern: &str) -> bool {
1159 let after_match = lower_pos + pattern.len();
1160 let prev_is_boundary = lower_pos == 0
1161 || !text[..lower_pos]
1162 .chars()
1163 .last()
1164 .is_some_and(|c| c.is_alphanumeric() || c == '_');
1165 let next_is_boundary = after_match >= text.len()
1166 || !text[after_match..]
1167 .chars()
1168 .next()
1169 .is_some_and(|c| c.is_alphanumeric() || c == '_');
1170 prev_is_boundary && next_is_boundary
1171 }
1172
1173 fn lower_offset_to_original_byte(text: &str, lower: &str, lower_pos: usize) -> usize {
1174 let char_idx = lower[..lower_pos].chars().count();
1175 text.char_indices()
1176 .nth(char_idx)
1177 .map_or(text.len(), |(i, _)| i)
1178 }
1179
1180 fn line_bounds(text: &str, byte_pos: usize) -> (usize, usize) {
1181 let start = text[..byte_pos].rfind('\n').map_or(0, |idx| idx + 1);
1182 let end = text[byte_pos..]
1183 .find('\n')
1184 .map_or(text.len(), |idx| byte_pos + idx);
1185 (start, end)
1186 }
1187
1188 fn is_markdown_table_cell(text: &str, byte_pos: usize, pattern: &str) -> bool {
1189 let (line_start, line_end) = line_bounds(text, byte_pos);
1190 let line = &text[line_start..line_end];
1191 let trimmed = line.trim_start();
1192 if !(trimmed.starts_with('|')
1193 || trimmed.starts_with("+|")
1194 || trimmed.starts_with("-|")
1195 || trimmed.starts_with(">|"))
1196 {
1197 return false;
1198 }
1199 if line.matches('|').count() < 2 {
1200 return false;
1201 }
1202
1203 let rel_pos = byte_pos.saturating_sub(line_start);
1204 let after_match = rel_pos + pattern.len();
1205 let prev_is_pipe = line[..rel_pos]
1206 .chars()
1207 .rev()
1208 .find(|c| !c.is_whitespace())
1209 .is_some_and(|c| c == '|');
1210 let next_is_pipe = line[after_match..]
1211 .chars()
1212 .find(|c| !c.is_whitespace())
1213 .is_some_and(|c| c == '|');
1214 prev_is_pipe && next_is_pipe
1215 }
1216
1217 pub fn detect_rate_limit(text: &str) -> Option<AgentError> {
1219 let lower = text.to_lowercase();
1220 let patterns = [
1221 "rate limit",
1222 "rate_limit",
1223 "ratelimit",
1224 "rate increased too quickly", "too many requests",
1226 "usage limit",
1227 "session limit", "weekly limit", "quota exceeded",
1230 "monthly quota", "overloaded",
1232 "capacity",
1233 "throttled",
1234 "insufficient_quota",
1235 "tokens_exceeded",
1236 "you've hit your usage limit",
1237 "resourceexhausted", "worker local total request limit reached", ];
1240 let match_info = patterns
1247 .iter()
1248 .filter_map(|pattern| lower.find(pattern).map(|lower_pos| (*pattern, lower_pos)))
1249 .filter_map(|(pattern, lower_pos)| {
1250 let byte_pos = lower_offset_to_original_byte(text, &lower, lower_pos);
1251 if matches!(pattern, "rate_limit" | "ratelimit")
1252 && !is_word_boundary(text, byte_pos, pattern)
1253 {
1254 return None;
1255 }
1256
1257 if matches!(pattern, "rate_limit" | "ratelimit")
1258 && is_markdown_table_cell(text, byte_pos, pattern)
1259 {
1260 return None;
1261 }
1262 Some((pattern, byte_pos))
1263 })
1264 .min_by_key(|(_, byte_pos)| *byte_pos);
1265 let has_429 = lower.contains("429");
1266 let has_529 = lower.contains("http 529")
1270 || lower.contains("529 service")
1271 || (lower.find(": 529").is_some_and(|pos| {
1272 !lower[pos + 5..]
1273 .chars()
1274 .next()
1275 .is_some_and(|c| c.is_ascii_digit())
1276 }));
1277
1278 if match_info.is_some() || has_429 || has_529 {
1279 let message = if let Some((_, pos)) = match_info {
1280 extract_context_around(text, pos, 300)
1281 } else {
1282 safe_tail(text, 300).to_string()
1283 };
1284 return Some(AgentError::RateLimit { message });
1285 }
1286 None
1287 }
1288
1289 fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1295 let half = window / 2;
1296 let start = byte_pos.saturating_sub(half);
1297 let end = (byte_pos + window).min(text.len());
1298 let mut s = start;
1300 while s < text.len() && !text.is_char_boundary(s) {
1301 s += 1;
1302 }
1303 let mut e = end;
1304 while e < text.len() && !text.is_char_boundary(e) {
1305 e += 1;
1306 }
1307 text[s..e].to_string()
1308 }
1309
1310 pub fn contains_http_status(lower: &str, code: &str) -> bool {
1318 if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1320 return true;
1321 }
1322 let needle = format!(": {code}");
1324 let mut start = 0;
1325 while let Some(rel) = lower[start..].find(needle.as_str()) {
1326 let after = start + rel + needle.len();
1327 match lower[after..].chars().next() {
1328 Some(c) if c.is_ascii_digit() => {
1329 start = after;
1331 }
1332 _ => return true,
1333 }
1334 }
1335 false
1336 }
1337
1338 pub fn detect_auth_error(text: &str) -> Option<AgentError> {
1344 let lower = text.to_lowercase();
1345 let patterns = [
1346 "unauthorized",
1347 "authentication required",
1348 "invalid api",
1349 "invalid key",
1350 "invalid token",
1351 "auth fail",
1352 "no api key",
1353 "no token",
1354 "expired key",
1355 "expired token",
1356 "expired plan",
1357 "billing plan",
1361 "billing error",
1362 "billing suspended",
1363 "billing expired",
1364 "billing limit",
1365 "billing failed",
1366 "billing cycle exhausted",
1367 "insufficient credit",
1368 "insufficient balance",
1369 "credit balance too low",
1370 "payment required",
1371 ];
1372 let http_401 = contains_http_status(&lower, "401");
1375 let http_403 = contains_http_status(&lower, "403");
1376 let http_407 = contains_http_status(&lower, "407");
1377 let proxy_auth_required = lower.contains("proxy authentication required");
1378 if !patterns.iter().any(|p| lower.contains(p))
1379 && !http_401
1380 && !http_403
1381 && !http_407
1382 && !proxy_auth_required
1383 {
1384 return None;
1385 }
1386 let first_match_pos =
1388 patterns
1389 .iter()
1390 .filter_map(|p| lower.find(p))
1391 .min()
1392 .map(|lower_pos| {
1393 let char_idx = lower[..lower_pos].chars().count();
1394 text.char_indices()
1395 .nth(char_idx)
1396 .map_or(text.len(), |(i, _)| i)
1397 });
1398 let http_401_match_pos = if http_401 {
1401 lower.find("401 ").or_else(|| lower.find(": 401"))
1402 } else {
1403 None
1404 };
1405 let http_403_match_pos = if http_403 {
1406 lower.find("403 ").or_else(|| lower.find(": 403"))
1407 } else {
1408 None
1409 };
1410 let http_407_match_pos = if http_407 {
1411 lower.find("407 ").or_else(|| lower.find(": 407"))
1412 } else {
1413 None
1414 };
1415 let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1416 .into_iter()
1417 .flatten()
1418 .min()
1419 .and_then(|rel| {
1420 let char_idx = lower[..rel].chars().count();
1421 text.char_indices().nth(char_idx).map(|(i, _)| i)
1422 });
1423 let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1424 let message = if let Some(pos) = match_pos {
1425 find_line_containing(text, pos)
1426 } else {
1427 find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1430 };
1431 Some(AgentError::Auth { message })
1432 }
1433
1434 fn find_line_containing(text: &str, byte_pos: usize) -> String {
1436 let start = text[..byte_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
1437 let end = text[byte_pos..]
1438 .find('\n')
1439 .map(|i| byte_pos + i)
1440 .unwrap_or(text.len());
1441 text[start..end].trim().to_string()
1442 }
1443
1444 fn find_auth_line_scan(text: &str) -> Option<String> {
1448 for line in text.lines().rev().take(50) {
1449 let line_lower = line.to_lowercase();
1450 if contains_http_status(&line_lower, "401")
1451 || contains_http_status(&line_lower, "403")
1452 || contains_http_status(&line_lower, "407")
1453 || line_lower.contains("unauthorized")
1454 || line_lower.contains("authentication required")
1455 || line_lower.contains("invalid api")
1456 || line_lower.contains("invalid key")
1457 || line_lower.contains("invalid token")
1458 || line_lower.contains("auth fail")
1459 || line_lower.contains("no api key")
1460 || line_lower.contains("no token")
1461 || line_lower.contains("expired key")
1462 || line_lower.contains("expired token")
1463 || line_lower.contains("expired plan")
1464 || line_lower.contains("billing plan")
1465 || line_lower.contains("billing error")
1466 || line_lower.contains("billing suspended")
1467 || line_lower.contains("billing expired")
1468 || line_lower.contains("billing limit")
1469 || line_lower.contains("billing failed")
1470 || line_lower.contains("billing cycle exhausted")
1471 || line_lower.contains("insufficient credit")
1472 || line_lower.contains("credit balance too low")
1473 || line_lower.contains("payment required")
1474 || line_lower.contains("proxy authentication required")
1475 {
1476 return Some(line.trim().to_string());
1477 }
1478 }
1479 None
1480 }
1481
1482 pub fn detect_network_error(text: &str) -> Option<AgentError> {
1484 let lower = text.to_lowercase();
1485 let patterns = [
1486 "connectionrefused",
1487 "connection refused",
1488 "unable to connect",
1489 "econnrefused",
1490 "network unreachable",
1491 "socket connection was closed",
1493 "socket hang up",
1494 "econnreset",
1495 "connection reset",
1496 "etimedout",
1497 "connect etimedout",
1498 "aborterror",
1499 "the operation was aborted",
1500 "network request failed",
1501 "fetch failed",
1502 "upstream idle timeout",
1504 "streaming response failed",
1505 "upstream request failed",
1506 "stalled mid-stream",
1508 "connection closed mid-response",
1509 ];
1510 let match_pos = patterns
1513 .iter()
1514 .filter_map(|p| lower.find(p))
1515 .min()
1516 .map(|lower_pos| {
1517 let char_idx = lower[..lower_pos].chars().count();
1518 text.char_indices()
1519 .nth(char_idx)
1520 .map_or(text.len(), |(i, _)| i)
1521 });
1522 if let Some(pos) = match_pos {
1523 return Some(AgentError::NetworkError {
1524 message: extract_context_around(text, pos, 300),
1525 });
1526 }
1527 None
1528 }
1529
1530 pub fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1540 let lower = text.to_lowercase();
1541 let is_model_unavailable = lower.contains("model metadata")
1542 || lower.contains("no endpoints found")
1543 || (lower.contains("model")
1544 && (lower.contains("not found")
1545 || lower.contains("not supported")
1546 || lower.contains("does not exist")
1547 || lower.contains("deprecated")
1548 || lower.contains("unavailable for free")
1549 || lower.contains("not available in your country")
1550 || lower.contains("not available in your region")));
1551 if !is_model_unavailable {
1552 return None;
1553 }
1554 let model = extract_quoted(text, '`')
1555 .or_else(|| extract_quoted(text, '\''))
1556 .unwrap_or_default();
1557 Some(AgentError::ModelUnavailable {
1558 message: safe_tail(text, 300).to_string(),
1559 model,
1560 })
1561 }
1562
1563 fn extract_quoted(text: &str, quote: char) -> Option<String> {
1565 let start = text.find(quote)?;
1566 let rest = &text[start + quote.len_utf8()..];
1567 let end = rest.find(quote)?;
1568 Some(rest[..end].to_string())
1569 }
1570
1571 pub fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1573 let lower = text.to_lowercase();
1574 let patterns = ["worktree directory does not exist"];
1575 if patterns.iter().any(|p| lower.contains(p)) {
1576 return Some(AgentError::AgentFailed {
1577 message: safe_tail(text, 300).to_string(),
1578 });
1579 }
1580 None
1581 }
1582
1583 pub fn detect_context_overflow(text: &str) -> Option<AgentError> {
1585 let lower = text.to_lowercase();
1586 let patterns = [
1587 "context_length_exceeded",
1588 "context length exceeded",
1589 "maximum context length",
1590 "too many tokens",
1591 "token limit",
1592 "context overflow",
1593 ];
1594 if patterns.iter().any(|p| lower.contains(p)) {
1595 return Some(AgentError::ContextOverflow {
1596 message: safe_tail(text, 300).to_string(),
1597 });
1598 }
1599 None
1600 }
1601
1602 pub fn detect_missing_tool(text: &str) -> Option<AgentError> {
1604 let known_tools = [
1605 "bun",
1606 "node",
1607 "npm",
1608 "pnpm",
1609 "yarn",
1610 "deno",
1611 "tsc",
1612 "eslint",
1613 "prettier",
1614 "jest",
1615 "vitest",
1616 "cargo",
1617 "rustc",
1618 "go",
1619 "python",
1620 "python3",
1621 "pip",
1622 "pip3",
1623 "uv",
1624 "poetry",
1625 "pytest",
1626 "ruff",
1627 "black",
1628 "mypy",
1629 "make",
1630 "cmake",
1631 "ninja",
1632 "just",
1633 "bats",
1634 "docker",
1635 "docker-compose",
1636 "podman",
1637 "kubectl",
1638 "helm",
1639 "terraform",
1640 "anchor",
1641 "avm",
1642 "solana",
1643 "solana-test-validator",
1644 ];
1645
1646 let lower = text.to_lowercase();
1647 for tool in &known_tools {
1648 if lower.contains(&format!("{tool}: command not found"))
1649 || lower.contains(&format!("command not found: {tool}"))
1650 || lower.contains(&format!("{tool}: not found"))
1651 || lower.contains(&format!("env: {tool}: no such file"))
1652 || lower.contains(&format!("spawn {tool} enoent"))
1653 {
1654 return Some(AgentError::MissingTool {
1655 tool: tool.to_string(),
1656 });
1657 }
1658 }
1659 None
1660 }
1661
1662 pub fn detect_permission_denied(text: &str) -> Option<AgentError> {
1664 let lower = text.to_lowercase();
1665 let patterns = [
1666 "permission denied",
1667 "rejected permission",
1668 "permissionerror",
1669 "operation not permitted",
1670 "sandbox violation",
1671 "access denied",
1672 "eacces",
1673 "eperm",
1674 "read-only file system",
1675 "not writable",
1676 "disallowed tool",
1677 ];
1678 if patterns.iter().any(|p| lower.contains(p)) {
1679 return Some(AgentError::PermissionDenied {
1680 message: safe_tail(text, 300).to_string(),
1681 });
1682 }
1683 None
1684 }
1685
1686 pub fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1688 let lower = text.to_lowercase();
1689 let patterns = [
1690 "enter passphrase",
1691 "1password",
1692 "op signin",
1693 "ssh passphrase",
1694 "password:",
1695 ];
1696 let auth_required_with_interactive_context = lower.contains("authentication required")
1697 && (lower.contains("ssh")
1698 || lower.contains("passphrase")
1699 || lower.contains("1password")
1700 || lower.contains("op signin"));
1701 if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1702 return Some(AgentError::WaitingForInput {
1703 message: safe_tail(text, 300).to_string(),
1704 });
1705 }
1706 None
1707 }
1708
1709 pub fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1715 if text.contains(
1718 "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1719 ) {
1720 let message = text
1721 .lines()
1722 .find(|l| l.contains("cannot be modified"))
1723 .map(|l| l.trim().to_string())
1724 .unwrap_or_else(|| safe_tail(text, 300).to_string());
1725 return Some(AgentError::ThinkingBlockConflict { message });
1726 }
1727 None
1728 }
1729
1730 pub fn detect_stale_session(text: &str) -> Option<AgentError> {
1736 let needle = "no conversation found with session id";
1742 let lower = text.to_lowercase();
1743 if let Some(lower_pos) = lower.find(needle) {
1744 let char_idx = lower[..lower_pos].chars().count();
1749 let pos = text
1750 .char_indices()
1751 .nth(char_idx)
1752 .map(|(i, _)| i)
1753 .unwrap_or(text.len());
1754 let after = &text[pos..];
1756 let session_id = after
1757 .split_once(':')
1758 .map(|(_, rest)| rest.trim())
1759 .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
1761 .unwrap_or_default();
1762 return Some(AgentError::StaleSession { session_id });
1763 }
1764 None
1765 }
1766
1767 const TIMEOUT_EXIT_CODE: i32 = 124;
1769
1770 const DEFAULT_TIMEOUT_SECS: u64 = 1800;
1772
1773 pub(crate) const RATE_LIMIT_SCAN_TAIL_BYTES: usize = 3000;
1784
1785 pub(crate) const RATE_LIMIT_SCAN_HEAD_BYTES: usize = 3000;
1793
1794 pub fn classify_from_text(exit_code: i32, text: &str) -> AgentError {
1797 if exit_code == TIMEOUT_EXIT_CODE {
1798 return AgentError::Timeout {
1799 elapsed: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1800 };
1801 }
1802
1803 if exit_code == 0 && text.trim().is_empty() {
1808 return AgentError::Unknown {
1809 exit_code,
1810 message: "empty-output-exit0".to_string(),
1811 };
1812 }
1813
1814 if let Some(e) = detect_missing_tool(text) {
1815 return e;
1816 }
1817 if let Some(e) = detect_worktree_missing(text) {
1818 return e;
1819 }
1820 if let Some(e) = detect_thinking_block_conflict(text) {
1821 return e;
1822 }
1823 let scan_head = safe_head(text, RATE_LIMIT_SCAN_HEAD_BYTES);
1829 if let Some(e) = detect_rate_limit(scan_head) {
1830 return e;
1831 }
1832 let scan_tail = safe_tail(text, RATE_LIMIT_SCAN_TAIL_BYTES);
1839 if let Some(e) = detect_context_overflow(scan_tail) {
1840 return e;
1841 }
1842 if let Some(e) = detect_rate_limit(scan_tail) {
1843 return e;
1844 }
1845 if let Some(e) = detect_model_unavailable(scan_tail) {
1846 return e;
1847 }
1848 if let Some(e) = detect_network_error(scan_tail) {
1849 return e;
1850 }
1851 if let Some(e) = detect_auth_error(scan_tail) {
1852 return e;
1853 }
1854 if let Some(e) = detect_waiting_for_input(scan_tail) {
1855 return e;
1856 }
1857 if let Some(e) = detect_permission_denied(scan_tail) {
1858 return e;
1859 }
1860 if let Some(e) = detect_stale_session(text) {
1861 return e;
1862 }
1863
1864 AgentError::Unknown {
1865 exit_code,
1866 message: safe_tail(text, 300).to_string(),
1867 }
1868 }
1869
1870 pub fn safe_tail(text: &str, max_bytes: usize) -> &str {
1872 if text.len() <= max_bytes {
1873 return text;
1874 }
1875 let start = text.len() - max_bytes;
1876 let mut idx = start;
1878 while idx < text.len() && !text.is_char_boundary(idx) {
1879 idx += 1;
1880 }
1881 &text[idx..]
1882 }
1883
1884 pub fn safe_head(text: &str, max_bytes: usize) -> &str {
1886 if text.len() <= max_bytes {
1887 return text;
1888 }
1889 let mut idx = max_bytes;
1890 while idx > 0 && !text.is_char_boundary(idx) {
1892 idx -= 1;
1893 }
1894 &text[..idx]
1895 }
1896
1897 pub fn detect_ndjson_error(
1904 events: &[serde_json::Value],
1905 extract_message: impl Fn(&serde_json::Value) -> Option<String>,
1906 classify: impl Fn(&str) -> AgentError,
1907 ) -> Option<AgentError> {
1908 let mut best: Option<AgentError> = None;
1909
1910 for event in events {
1911 if let Some(message) = extract_message(event) {
1912 let err = classify(&message);
1913 let upgrade = match &best {
1916 None => true,
1917 Some(AgentError::AgentFailed { .. }) => true,
1918 Some(AgentError::NetworkError { .. }) => !matches!(
1919 err,
1920 AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1921 ),
1922 Some(_) => false,
1923 };
1924 if upgrade {
1925 best = Some(err);
1926 }
1927 }
1928 }
1929
1930 best
1931 }
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936 use super::*;
1937
1938 #[test]
1939 fn get_runner_returns_correct_types() {
1940 assert_eq!(get_runner("claude").name(), "claude");
1941 assert_eq!(get_runner("kimi").name(), "kimi");
1942 assert_eq!(get_runner("minimax").name(), "minimax");
1943 assert_eq!(get_runner("codex").name(), "codex");
1944 assert_eq!(get_runner("opencode").name(), "opencode");
1945 assert_eq!(get_runner("grok").name(), "grok");
1946 assert_eq!(
1947 get_runner("claude").router_transcript(),
1948 RouterTranscript::Messages
1949 );
1950 assert_eq!(
1951 get_runner("kimi").router_transcript(),
1952 RouterTranscript::Messages
1953 );
1954 assert_eq!(
1955 get_runner("minimax").router_transcript(),
1956 RouterTranscript::Messages
1957 );
1958 assert_eq!(
1959 get_runner("grok").router_transcript(),
1960 RouterTranscript::Messages
1961 );
1962 assert_eq!(
1963 get_runner("glm").router_transcript(),
1964 RouterTranscript::Messages
1965 );
1966 assert_eq!(
1967 get_runner("olm").router_transcript(),
1968 RouterTranscript::Messages
1969 );
1970 assert_eq!(
1971 get_runner("opencode").router_transcript(),
1972 RouterTranscript::OpenCode
1973 );
1974 assert_eq!(
1975 get_runner("codex").router_transcript(),
1976 RouterTranscript::Raw
1977 );
1978 assert!(supports_session("grok"));
1979 assert!(!supports_session("codex"));
1980 assert_eq!(get_runner("olm").name(), "olm");
1982 assert_eq!(get_runner("glm").name(), "glm");
1983 assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
1985 }
1986
1987 #[test]
1990 fn get_runner_no_warning_for_claude_compatible_agents() {
1991 use std::sync::{Arc, Mutex};
1992 use tracing::Level;
1993 use tracing_subscriber::util::SubscriberInitExt;
1994 use tracing_subscriber::EnvFilter;
1995
1996 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1997 let output_clone = output.clone();
1998
1999 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2000 impl std::io::Write for CaptureWriter {
2001 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2002 self.0.lock().unwrap().extend_from_slice(buf);
2003 Ok(buf.len())
2004 }
2005 fn flush(&mut self) -> std::io::Result<()> {
2006 Ok(())
2007 }
2008 }
2009 impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2010 type Writer = CaptureWriter;
2011 fn make_writer(&'a self) -> Self::Writer {
2012 CaptureWriter(self.0.clone())
2013 }
2014 }
2015
2016 tracing_subscriber::fmt()
2017 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2018 .with_writer(CaptureWriter(output_clone))
2019 .with_ansi(false)
2020 .finish()
2021 .init();
2022
2023 for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2024 get_runner(agent);
2025 }
2026
2027 let captured = {
2028 let guard = output.lock().unwrap();
2029 String::from_utf8_lossy(&guard).to_string()
2030 };
2031 assert!(
2032 !captured.contains("unknown agent"),
2033 "known claude-compatible agents must not emit 'unknown agent' warnings; got: {}",
2034 captured
2035 );
2036 }
2037
2038 #[test]
2039 fn agent_error_display() {
2040 let e = AgentError::RateLimit {
2041 message: "429 Too Many Requests".to_string(),
2042 };
2043 assert!(e.to_string().contains("rate limit"));
2044
2045 let e = AgentError::Timeout {
2046 elapsed: Duration::from_secs(1800),
2047 };
2048 assert!(e.to_string().contains("1800"));
2049 }
2050
2051 #[test]
2052 fn pattern_detect_rate_limit() {
2053 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2054 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2055 assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2056 assert!(patterns::detect_rate_limit(
2057 "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2058 )
2059 .is_some());
2060 assert!(patterns::detect_rate_limit(
2061 "Upstream error from Alibaba: Request rate increased too quickly. \
2062 To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
2063 ).is_some());
2064 assert!(patterns::detect_rate_limit("all good").is_none());
2065 }
2066
2067 #[test]
2068 fn classify_from_text_detects_weekly_limit_as_rate_limit() {
2069 let system_event = r#"{"type":"system","subtype":"error","error":{"type":"weekly_limit","message":"You've hit your weekly limit · resets Jun 9 at 1am (America/Sao_Paulo)"}}"#;
2073 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2074 .repeat(200);
2075 let text = format!("{system_event}\n{work_product}");
2076 assert!(
2077 text.len() > 3000,
2078 "test output must exceed tail window to be meaningful"
2079 );
2080 let err = patterns::classify_from_text(1, &text);
2081 assert!(
2082 matches!(err, AgentError::RateLimit { .. }),
2083 "weekly limit as early system event must be detected via head scan, got: {err:?}"
2084 );
2085 let msg = err.to_string();
2086 assert!(
2087 msg.contains("weekly limit"),
2088 "error message should reference weekly limit, got: {msg}"
2089 );
2090 }
2091
2092 #[test]
2093 fn detect_rate_limit_github_copilot_monthly_quota() {
2094 let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
2098 assert!(
2099 matches!(err, Some(AgentError::RateLimit { .. })),
2100 "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
2101 );
2102 }
2103
2104 #[test]
2105 fn detect_rate_limit_nvidia_resourceexhausted() {
2106 let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
2110 let err = patterns::detect_rate_limit(opencode_error);
2111 assert!(
2112 matches!(err, Some(AgentError::RateLimit { .. })),
2113 "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
2114 );
2115
2116 assert!(
2118 patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
2119 "bare ResourceExhausted must match"
2120 );
2121 assert!(
2122 patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
2123 .is_some(),
2124 "worker local total request limit reached must match"
2125 );
2126
2127 let err = patterns::classify_from_text(1, opencode_error);
2129 assert!(
2130 matches!(err, AgentError::RateLimit { .. }),
2131 "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
2132 );
2133 }
2134
2135 #[test]
2136 fn detect_rate_limit_message_contains_context_not_tail() {
2137 let padding = "x".repeat(5000);
2140 let text = format!(
2141 "{padding}you've reached your usage limit for this billing cycle{padding}\
2142 {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
2143 );
2144 let err = patterns::detect_rate_limit(&text).expect("should detect");
2145 let msg = err.to_string();
2146 assert!(
2147 msg.contains("usage limit"),
2148 "message should contain the actual error, got: {msg}"
2149 );
2150 assert!(
2151 !msg.contains("web_fetch_requests"),
2152 "message should not be the tail JSON, got: {msg}"
2153 );
2154 }
2155
2156 #[test]
2157 fn pattern_detect_rate_limit_529_not_bare() {
2158 assert!(
2161 patterns::detect_rate_limit("error on line 529").is_none(),
2162 "bare line number 529 should not match"
2163 );
2164 assert!(
2165 patterns::detect_rate_limit("file size 529 bytes").is_none(),
2166 "bare file size 529 should not match"
2167 );
2168 assert!(
2169 patterns::detect_rate_limit("port :5290 in config").is_none(),
2170 "port number containing 529 should not match"
2171 );
2172 assert!(
2173 patterns::detect_rate_limit("timeout after 5290ms").is_none(),
2174 "duration containing 529 should not match"
2175 );
2176 assert!(
2178 patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
2179 "HTTP 529 status should match"
2180 );
2181 assert!(
2182 patterns::detect_rate_limit("http 529").is_some(),
2183 "http 529 should match"
2184 );
2185 assert!(
2186 patterns::detect_rate_limit("529 service unavailable").is_some(),
2187 "529 service unavailable should match"
2188 );
2189 assert!(
2190 patterns::detect_rate_limit("status: 529").is_some(),
2191 "status: 529 should match"
2192 );
2193 }
2194
2195 #[test]
2196 fn pattern_detect_auth() {
2197 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
2198 assert!(patterns::detect_auth_error("HTTP 401").is_some());
2199 assert!(patterns::detect_auth_error("error: 401").is_some());
2200 assert!(patterns::detect_auth_error("403 Forbidden").is_some());
2201 assert!(patterns::detect_auth_error("HTTP 403").is_some());
2202 assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
2203 assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
2204 assert!(patterns::detect_auth_error("invalid api key").is_some());
2205 assert!(patterns::detect_auth_error("billing expired").is_some());
2206 assert!(patterns::detect_auth_error("task done").is_none());
2207 assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
2209 assert!(patterns::detect_auth_error(
2210 "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2211 )
2212 .is_none());
2213 assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2215 assert!(patterns::detect_auth_error(
2216 r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2217 )
2218 .is_none());
2219 assert!(patterns::detect_auth_error("status: 401").is_some());
2221 assert!(patterns::detect_auth_error("error: 403").is_some());
2222 }
2223
2224 #[test]
2225 fn pattern_detect_auth_insufficient_balance() {
2226 assert!(
2231 patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2232 "insufficient balance must be detected as auth error"
2233 );
2234 assert!(
2235 patterns::detect_auth_error("insufficient balance").is_some(),
2236 "bare insufficient balance must be detected as auth error"
2237 );
2238 let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2240 .expect("should detect auth error");
2241 let AgentError::Auth { message } = err else {
2242 panic!("expected Auth, got {err:?}");
2243 };
2244 assert!(
2245 message.to_lowercase().contains("insufficient balance"),
2246 "stored message should contain the matched pattern, got: {message}"
2247 );
2248 assert!(
2250 message.lines().count() <= 2,
2251 "message should be the auth line, not the full tail: {}",
2252 message.len()
2253 );
2254 }
2255
2256 #[test]
2257 fn auth_error_message_contains_match_not_tail() {
2258 let text = "some output\nunauthorized: your api key has expired\nx".repeat(400)
2262 + r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#;
2263 let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2264 let AgentError::Auth { message } = err else {
2265 panic!("expected Auth, got {err:?}");
2266 };
2267 assert!(
2268 message.to_lowercase().contains("unauthorized"),
2269 "stored message should contain the matched pattern, got: {message}"
2270 );
2271 assert!(
2272 !message.contains("outputTokens"),
2273 "stored message must not be the trailing NDJSON, got: {message}"
2274 );
2275 assert!(
2277 message.lines().count() <= 2,
2278 "message should be the auth line, not the full tail: {}",
2279 message.len()
2280 );
2281 }
2282
2283 #[test]
2284 fn pattern_detect_network_error() {
2285 assert!(patterns::detect_network_error("connection refused").is_some());
2286 assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2287 assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2288 assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2289 assert!(patterns::detect_network_error("network unreachable").is_some());
2290 assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2291 assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2292 assert!(patterns::detect_network_error("Streaming response failed").is_some());
2293 assert!(patterns::detect_network_error("Upstream request failed").is_some());
2294 assert!(patterns::detect_network_error("all systems operational").is_none());
2295 }
2296
2297 #[test]
2298 fn network_error_message_contains_match_not_tail() {
2299 let prefix = "connection refused connecting to api.anthropic.com:443 ";
2301 let suffix = "x".repeat(2000)
2302 + r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#;
2303 let text = format!("{prefix}{suffix}");
2304
2305 let err = patterns::detect_network_error(&text).expect("should detect network error");
2306 let AgentError::NetworkError { message } = err else {
2307 panic!("expected NetworkError, got {err:?}");
2308 };
2309 assert!(
2310 message.to_lowercase().contains("connection refused"),
2311 "stored message should contain the matched pattern, got: {message}"
2312 );
2313 assert!(
2314 !message.contains("web_fetch_requests"),
2315 "stored message must not be the unrelated stats tail, got: {message}"
2316 );
2317 }
2318
2319 #[test]
2320 fn pattern_detect_context_overflow() {
2321 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2322 assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2323 assert!(patterns::detect_context_overflow("success").is_none());
2324 }
2325
2326 #[test]
2327 fn pattern_detect_worktree_missing() {
2328 assert!(
2329 patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2330 .is_some()
2331 );
2332 assert!(patterns::detect_worktree_missing("all good").is_none());
2333 }
2334
2335 #[test]
2336 fn pattern_detect_missing_tool() {
2337 assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2338 assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2339 assert!(patterns::detect_missing_tool("everything works").is_none());
2340 }
2341
2342 #[test]
2343 fn pattern_detect_permission_denied() {
2344 assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2345 assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2346 assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2347 assert!(patterns::detect_permission_denied("task completed").is_none());
2348 }
2349
2350 #[test]
2351 fn pattern_detect_waiting_for_input() {
2352 assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2353 assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2354 assert!(
2355 patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2356 .is_some()
2357 );
2358 assert!(
2359 patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2360 );
2361 assert!(patterns::detect_waiting_for_input("done").is_none());
2362 }
2363
2364 #[test]
2365 fn pattern_detect_stale_session() {
2366 let err = patterns::detect_stale_session(
2368 "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2369 );
2370 assert!(err.is_some(), "should detect stale session");
2371 if let Some(AgentError::StaleSession { session_id }) = err {
2372 assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2373 }
2374
2375 let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2377 assert!(err.is_some(), "should detect lowercase variant");
2378
2379 assert!(patterns::detect_stale_session("all good").is_none());
2381 assert!(patterns::detect_stale_session("conversation started").is_none());
2382
2383 let multibyte = "Straße: No conversation found with session ID: dead-beef";
2387 let err = patterns::detect_stale_session(multibyte);
2388 assert!(
2389 err.is_some(),
2390 "should detect stale session after multi-byte chars"
2391 );
2392 if let Some(AgentError::StaleSession { session_id }) = err {
2393 assert_eq!(session_id, "dead-beef");
2394 }
2395 }
2396
2397 #[test]
2398 fn stale_session_display() {
2399 let e = AgentError::StaleSession {
2400 session_id: "abc-123".to_string(),
2401 };
2402 assert!(e.to_string().contains("stale session"));
2403 assert!(e.to_string().contains("abc-123"));
2404 }
2405
2406 #[test]
2407 fn classify_from_text_timeout() {
2408 let err = patterns::classify_from_text(124, "");
2409 assert!(matches!(err, AgentError::Timeout { .. }));
2410 }
2411
2412 #[test]
2413 fn classify_from_text_missing_tool_before_rate_limit() {
2414 let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2416 assert!(matches!(err, AgentError::MissingTool { .. }));
2417 }
2418
2419 #[test]
2420 fn classify_from_text_worktree_missing() {
2421 let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2422 assert!(matches!(err, AgentError::AgentFailed { .. }));
2423 }
2424
2425 #[test]
2426 fn classify_from_text_model_not_found_backtick() {
2427 let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2431 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2432 or you do not have access to it.)";
2433 let err = patterns::classify_from_text(1, text);
2434 match err {
2435 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2436 other => panic!("expected ModelUnavailable, got: {other:?}"),
2437 }
2438 }
2439
2440 #[test]
2441 fn classify_from_text_model_metadata_not_found() {
2442 let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2443 metadata; this can degrade performance and cause issues.";
2444 let err = patterns::classify_from_text(1, text);
2445 match err {
2446 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2447 other => panic!("expected ModelUnavailable, got: {other:?}"),
2448 }
2449 }
2450
2451 #[test]
2452 fn classify_from_text_no_false_positive_from_work_product() {
2453 let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2456 - Added copilot quota patterns to `parse_retry_at`\n\
2457 fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2458 quota exceeded handling for billing cycle\n"
2459 .repeat(50); let actual_error = "\nerror: command not found: cargo\n";
2461 let text = format!("{work_product}{actual_error}");
2462 let err = patterns::classify_from_text(1, &text);
2463 assert!(
2464 !matches!(err, AgentError::RateLimit { .. }),
2465 "work product keywords must not trigger rate limit, got: {err:?}"
2466 );
2467 }
2468
2469 #[test]
2470 fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2471 let padding = "normal agent work output ".repeat(100);
2474 let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2475 let err = patterns::classify_from_text(1, &text);
2476 assert!(
2477 matches!(err, AgentError::RateLimit { .. }),
2478 "real rate limit at tail must be detected, got: {err:?}"
2479 );
2480 }
2481
2482 #[test]
2483 fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2484 let padding = "normal agent work output ".repeat(250);
2485 let text = format!(
2486 "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2487 );
2488 let err = patterns::classify_from_text(1, &text);
2489 assert!(
2490 !matches!(err, AgentError::RateLimit { .. }),
2491 "markdown table cell must not trigger rate limit, got: {err:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2497 let text = "Aflαβrate_limit error: too many requests";
2501 let err = patterns::classify_from_text(1, text);
2502 assert!(
2503 matches!(err, AgentError::RateLimit { .. }),
2504 "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2505 );
2506 }
2507
2508 #[test]
2509 fn classify_from_text_detects_session_limit_as_early_system_event() {
2510 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)"}}"#;
2516 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2519 .repeat(200);
2520 let text = format!("{system_event}\n{work_product}");
2521 assert!(
2523 text.len() > 3000,
2524 "test output must exceed tail window to be meaningful"
2525 );
2526 let err = patterns::classify_from_text(1, &text);
2527 assert!(
2528 matches!(err, AgentError::RateLimit { .. }),
2529 "session limit as early system event must be detected via head scan, got: {err:?}"
2530 );
2531 let msg = err.to_string();
2533 assert!(
2534 msg.contains("session limit"),
2535 "error message should contain the rate limit reason, got: {msg}"
2536 );
2537 }
2538
2539 #[test]
2540 fn classify_from_text_no_false_positive_auth_from_work_product() {
2541 let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2545 // handle 403 forbidden errors\n\
2546 // billing checks happen elsewhere\n"
2547 .repeat(80);
2548 let actual_error = "\nerror: command not found: cargo\n";
2549 let text = format!("{work_product}{actual_error}");
2550 let err = patterns::classify_from_text(1, &text);
2551 assert!(
2552 !matches!(err, AgentError::Auth { .. }),
2553 "work product auth keywords must not trigger auth classification, got: {err:?}"
2554 );
2555 assert!(
2556 matches!(err, AgentError::MissingTool { .. }),
2557 "the real tail error should still win, got: {err:?}"
2558 );
2559 }
2560
2561 #[test]
2562 fn classify_from_text_still_detects_real_auth_at_tail() {
2563 let padding = "normal agent work output ".repeat(100);
2564 let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2565 let err = patterns::classify_from_text(1, &text);
2566 assert!(
2567 matches!(err, AgentError::Auth { .. }),
2568 "real auth error at tail must be detected, got: {err:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2574 let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2578 // Ensure we don't exceed the token limit per request\n\
2579 fn check_token_limit(tokens: usize) { ... }\n\
2580 if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2581 // Note: too many tokens in the prompt causes context_length_exceeded\n"
2582 .repeat(80);
2583 let actual_error = "\nerror: command not found: cargo\n";
2584 let text = format!("{work_product}{actual_error}");
2585 let err = patterns::classify_from_text(1, &text);
2586 assert!(
2587 !matches!(err, AgentError::ContextOverflow { .. }),
2588 "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2589 );
2590 assert!(
2591 matches!(err, AgentError::MissingTool { .. }),
2592 "the real tail error should still win, got: {err:?}"
2593 );
2594 }
2595
2596 #[test]
2597 fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2598 let padding = "normal agent work output ".repeat(100);
2599 let text = format!("{padding}\nError: context_length_exceeded\n");
2600 let err = patterns::classify_from_text(1, &text);
2601 assert!(
2602 matches!(err, AgentError::ContextOverflow { .. }),
2603 "real context overflow at tail must be detected, got: {err:?}"
2604 );
2605 }
2606
2607 #[test]
2608 fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2609 let work_product = "match fs::metadata(path) {\n\
2613 Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2614 bail!(\"access denied: {path}\");\n\
2615 }\n\
2616 // Check if directory is writable\n\
2617 if !path.is_writable() { ... }\n\
2618 }\n"
2619 .repeat(80);
2620 let actual_error = "\nerror: command not found: cargo\n";
2621 let text = format!("{work_product}{actual_error}");
2622 let err = patterns::classify_from_text(1, &text);
2623 assert!(
2624 !matches!(err, AgentError::PermissionDenied { .. }),
2625 "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2626 );
2627 assert!(
2628 matches!(err, AgentError::MissingTool { .. }),
2629 "the real tail error should still win, got: {err:?}"
2630 );
2631 }
2632
2633 #[test]
2634 fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2635 let padding = "normal agent work output ".repeat(100);
2636 let text = format!("{padding}\npermission denied: /etc/hosts\n");
2637 let err = patterns::classify_from_text(1, &text);
2638 assert!(
2639 matches!(err, AgentError::PermissionDenied { .. }),
2640 "real permission denied at tail must be detected, got: {err:?}"
2641 );
2642 }
2643
2644 #[test]
2645 fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2646 let work_product = "// Handle password prompts gracefully\n\
2650 if prompt.contains(\"password:\") {\n\
2651 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2652 }\n\
2653 // SSH agent will ask for the deploy key passphrase\n\
2654 fn handle_ssh_passphrase() { ... }\n\
2655 )\n"
2656 .repeat(80);
2657 let actual_error = "\nerror: command not found: cargo\n";
2658 let text = format!("{work_product}{actual_error}");
2659 let err = patterns::classify_from_text(1, &text);
2660 assert!(
2661 !matches!(err, AgentError::WaitingForInput { .. }),
2662 "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2663 );
2664 assert!(
2665 matches!(err, AgentError::MissingTool { .. }),
2666 "the real tail error should still win, got: {err:?}"
2667 );
2668 }
2669
2670 #[test]
2671 fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2672 let padding = "normal agent work output ".repeat(100);
2673 let text = format!("{padding}\nEnter passphrase for key:\n");
2674 let err = patterns::classify_from_text(1, &text);
2675 assert!(
2676 matches!(err, AgentError::WaitingForInput { .. }),
2677 "real waiting for input at tail must be detected, got: {err:?}"
2678 );
2679 }
2680
2681 #[test]
2682 fn classify_from_text_prefers_auth_over_permission_denied() {
2683 let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2684 assert!(
2685 matches!(err, AgentError::Auth { .. }),
2686 "auth errors must outrank permission denied, got: {err:?}"
2687 );
2688 }
2689
2690 #[test]
2691 fn classify_from_text_prefers_auth_over_waiting_for_input() {
2692 let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2693 assert!(
2694 matches!(err, AgentError::Auth { .. }),
2695 "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn classify_from_text_detects_stale_session() {
2701 let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2706 let err = patterns::classify_from_text(1, text);
2707 assert!(
2708 matches!(err, AgentError::StaleSession { .. }),
2709 "stale session must be detected via classify_from_text, got: {err:?}"
2710 );
2711 assert!(
2713 err.to_string().contains("stale session"),
2714 "error display must contain 'stale session' for recovery code to trigger"
2715 );
2716 }
2717
2718 #[test]
2719 fn classify_from_text_detects_socket_closed() {
2720 let text = "API Error: The socket connection was closed unexpectedly. \
2723 For more information, pass `verbose: true` in the second argument to fetch()";
2724 let err = patterns::classify_from_text(1, text);
2725 assert!(
2726 matches!(err, AgentError::NetworkError { .. }),
2727 "socket closed must be NetworkError, got: {err:?}"
2728 );
2729 }
2730
2731 #[test]
2732 fn classify_from_text_detects_socket_hang_up() {
2733 let err = patterns::classify_from_text(1, "Error: socket hang up");
2734 assert!(
2735 matches!(err, AgentError::NetworkError { .. }),
2736 "socket hang up must be NetworkError, got: {err:?}"
2737 );
2738 }
2739
2740 #[test]
2741 fn classify_from_text_detects_fetch_failed() {
2742 let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2743 assert!(
2744 matches!(err, AgentError::NetworkError { .. }),
2745 "fetch failed must be NetworkError, got: {err:?}"
2746 );
2747 }
2748
2749 #[test]
2750 fn classify_from_text_detects_econnreset() {
2751 let err = patterns::classify_from_text(1, "read ECONNRESET");
2752 assert!(
2753 matches!(err, AgentError::NetworkError { .. }),
2754 "ECONNRESET must be NetworkError, got: {err:?}"
2755 );
2756 }
2757
2758 #[test]
2759 fn classify_from_text_detects_stalled_mid_stream() {
2760 let err = patterns::classify_from_text(
2763 1,
2764 "API Error: Response stalled mid-stream. The response above may be incomplete.",
2765 );
2766 assert!(
2767 matches!(err, AgentError::NetworkError { .. }),
2768 "stalled mid-stream must be NetworkError, got: {err:?}"
2769 );
2770 }
2771
2772 #[test]
2773 fn classify_from_text_detects_connection_closed_mid_response() {
2774 let err = patterns::classify_from_text(
2777 1,
2778 "API Error: Connection closed mid-response. The response above may be incomplete.",
2779 );
2780 assert!(
2781 matches!(err, AgentError::NetworkError { .. }),
2782 "connection closed mid-response must be NetworkError, got: {err:?}"
2783 );
2784 }
2785
2786 #[test]
2787 fn synthesize_response_marks_done_for_plain_text_no_op() {
2788 let response = synthesize_response_from_text(
2789 "No open positions, no trade executions, no conditions change. Full cash, holding.",
2790 )
2791 .unwrap();
2792
2793 assert_eq!(response.status, "done");
2794 assert_eq!(
2795 response.summary,
2796 "No open positions, no trade executions, no conditions change. Full cash, holding."
2797 );
2798 assert!(response.error.is_none());
2799 }
2800
2801 #[test]
2802 fn synthesize_response_marks_needs_review_for_plain_text_error() {
2803 let response =
2804 synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2805
2806 assert_eq!(response.status, "needs_review");
2807 assert_eq!(
2808 response.error.as_deref(),
2809 Some("agent returned plain text instead of JSON")
2810 );
2811 }
2812
2813 #[test]
2814 fn synthesize_response_rejects_empty_text() {
2815 assert!(synthesize_response_from_text(" \n\t ").is_none());
2816 }
2817
2818 #[test]
2824 fn synthesize_response_rejects_ndjson_event_stream() {
2825 let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2826{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2827{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2828 assert!(
2829 synthesize_response_from_text(ndjson).is_none(),
2830 "raw NDJSON must not be interpreted as agent prose"
2831 );
2832
2833 let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2834 assert!(
2835 synthesize_response_from_text(single).is_none(),
2836 "single-line NDJSON event must also be rejected"
2837 );
2838 }
2839
2840 #[test]
2841 fn synthesize_response_accepts_json_without_event_type() {
2842 let text = "The task is complete. Filed issue #1234 successfully.";
2846 let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2847 assert_eq!(response.status, "done");
2848 }
2849
2850 #[test]
2857 fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2858 assert!(
2860 synthesize_response_from_text("Let's go!").is_none(),
2861 "startup greeting must not synthesize as needs_review"
2862 );
2863 assert!(
2865 synthesize_response_from_text(
2866 "I need to find where the stream command is implemented. \
2867 Looking at the CLI structure, it seems like the stream functionality \
2868 might be in the task module or another file. Let me check the CLI \
2869 command structure. First, let me look at the task module since stream \
2870 is related to tasks."
2871 )
2872 .is_none(),
2873 "exploratory prose must not synthesize as needs_review"
2874 );
2875 }
2876
2877 #[test]
2878 fn synthesize_response_does_not_mark_done_for_incomplete() {
2879 let response = synthesize_response_from_text("The task is incomplete").unwrap();
2880 assert_eq!(
2881 response.status, "needs_review",
2882 "\"incomplete\" should not match \"complete\""
2883 );
2884 }
2885
2886 #[test]
2887 fn synthesize_response_does_not_mark_done_for_not_done() {
2888 let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2889 assert_eq!(
2890 response.status, "needs_review",
2891 "\"not done\" should not match bare \"done\""
2892 );
2893 }
2894
2895 #[test]
2896 fn synthesize_response_does_not_mark_done_for_not_complete() {
2897 let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2898 assert_eq!(
2899 response.status, "needs_review",
2900 "\"not complete\" should not match \"complete\""
2901 );
2902 }
2903
2904 #[test]
2905 fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2906 let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2907 assert_eq!(
2908 response.status, "needs_review",
2909 "\"not yet completed\" should not match \"completed\""
2910 );
2911 }
2912
2913 #[test]
2914 fn synthesize_response_marks_done_for_completed() {
2915 let response =
2916 synthesize_response_from_text("Task has been completed successfully").unwrap();
2917 assert_eq!(response.status, "done");
2918 }
2919
2920 #[test]
2921 fn synthesize_response_marks_done_for_word_boundary_done() {
2922 let response = synthesize_response_from_text("All changes are done.").unwrap();
2923 assert_eq!(response.status, "done");
2924 }
2925
2926 #[test]
2927 fn synthesize_response_marks_done_for_issue_creation_text() {
2928 let response =
2929 synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2930 .unwrap();
2931
2932 assert_eq!(response.status, "done");
2933 assert!(response.error.is_none());
2934 assert!(response
2935 .summary
2936 .contains("Filed 3 high-value GitHub issues"));
2937 }
2938
2939 #[test]
2940 fn synthesize_response_marks_done_for_committed() {
2941 let response =
2942 synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2943 assert_eq!(response.status, "done");
2944 }
2945
2946 #[test]
2947 fn synthesize_response_rejects_exploratory_prose() {
2948 let exploratory = "I explored the codebase and found several .expect()/.unwrap() uses.\n\nLet me check each occurrence directly and run the tests to be sure. I may commit fixes afterwards.";
2954 match synthesize_response_from_text(exploratory) {
2955 None => {
2956 }
2958 Some(resp) => {
2959 assert_ne!(
2960 resp.status, "done",
2961 "Exploratory prose must not be auto-marked done"
2962 );
2963 }
2964 }
2965 }
2966
2967 #[test]
2968 fn synthesize_response_marks_done_for_commit_created() {
2969 let response = synthesize_response_from_text(
2970 "Commit created locally. The push is being blocked by permissions.",
2971 )
2972 .unwrap();
2973 assert_eq!(response.status, "done");
2974 }
2975
2976 #[test]
2977 fn synthesize_response_does_not_mark_done_for_not_committed() {
2978 let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
2979 assert_eq!(
2980 response.status, "needs_review",
2981 "\"not committed\" should not match \"committed\""
2982 );
2983 }
2984
2985 #[test]
2988 fn synthesize_response_marks_done_for_fix_is_complete() {
2989 let response = synthesize_response_from_text(
2993 "All 73 cooldown tests pass. The fix is complete.\n\n\
2994 **Summary of changes:**\n\n\
2995 1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
2996 `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
2997 )
2998 .unwrap();
2999 assert_eq!(
3000 response.status, "done",
3001 "\"The fix is complete\" must be classified as done, not needs_review"
3002 );
3003 }
3004
3005 #[test]
3006 fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3007 let response = synthesize_response_from_text(
3011 "All tests pass. The fix is clean and working.\n\n\
3012 **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3013 - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3014 - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3015 )
3016 .unwrap();
3017 assert_eq!(
3018 response.status, "done",
3019 "\"All tests pass. The fix is clean and working.\" must be classified as done"
3020 );
3021 }
3022
3023 #[test]
3026 fn parse_ndjson_handles_standard_lines() {
3027 let raw = r#"{"type":"text","text":"hello"}
3028{"type":"text","text":"world"}"#;
3029 let events = parse_ndjson(raw);
3030 assert_eq!(events.len(), 2);
3031 assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3032 assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3033 }
3034
3035 #[test]
3036 fn parse_ndjson_skips_empty_lines() {
3037 let raw = r#"{"type":"text"}
3038
3039{"type":"text"}
3040"#;
3041 let events = parse_ndjson(raw);
3042 assert_eq!(events.len(), 2);
3043 }
3044
3045 #[test]
3046 fn parse_ndjson_skips_malformed_lines() {
3047 let raw = r#"{"type":"valid"}
3048not json at all
3049{"type":"also_valid"}"#;
3050 let events = parse_ndjson(raw);
3051 assert_eq!(events.len(), 2);
3052 }
3053
3054 #[test]
3055 fn parse_ndjson_fallback_extracts_embedded_json() {
3056 let raw =
3058 r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3059 let events = parse_ndjson(raw);
3060 assert!(
3062 !events.is_empty(),
3063 "should extract JSON objects via fallback"
3064 );
3065 }
3066
3067 #[test]
3068 fn parse_ndjson_fallback_handles_fragmented_objects() {
3069 let raw = r#"{"type": "text
3071", "content": "hello
3072world"}"#;
3073 let events = parse_ndjson(raw);
3074 assert!(!events.is_empty(), "should handle fragmented JSON");
3076 }
3077
3078 #[test]
3081 fn permission_rules_default_is_autonomous() {
3082 let rules = PermissionRules::default();
3083 assert!(rules.autonomous);
3084 }
3085
3086 #[test]
3087 fn permission_rules_default_sandbox_is_workspace_write() {
3088 let rules = PermissionRules::default();
3089 assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3090 }
3091
3092 #[test]
3093 fn permission_rules_default_disallows_rm() {
3094 let rules = PermissionRules::default();
3095 assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3096 assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3097 }
3098
3099 #[test]
3100 fn permission_rules_default_no_allowed_edit_paths() {
3101 let rules = PermissionRules::default();
3102 assert!(rules.allowed_edit_paths.is_empty());
3103 }
3104
3105 #[test]
3109 fn all_agents_handle_default_permissions() {
3110 let perms = PermissionRules::default();
3111 let sys = "/tmp/sys.md";
3112 let msg = "/tmp/msg.md";
3113
3114 let claude = get_runner("claude");
3116 let cmd = claude.build_command(None, "", sys, msg, &perms);
3117 assert!(
3118 cmd.contains("--permission-mode bypassPermissions"),
3119 "claude default: expected bypassPermissions, got: {cmd}"
3120 );
3121 assert!(
3122 cmd.contains("Bash(rm *)"),
3123 "claude default: expected rm disallowed, got: {cmd}"
3124 );
3125
3126 let codex = get_runner("codex");
3128 let cmd = codex.build_command(None, "", sys, msg, &perms);
3129 assert!(
3130 cmd.contains("--sandbox workspace-write"),
3131 "codex default: expected --sandbox workspace-write, got: {cmd}"
3132 );
3133 assert!(
3134 cmd.contains(r#"-c 'approval_policy="never"'"#),
3135 "codex default: expected -c approval_policy=never, got: {cmd}"
3136 );
3137 assert!(
3138 !cmd.contains("--ask-for-approval"),
3139 "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3140 );
3141 assert!(
3142 !cmd.contains("--full-auto"),
3143 "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3144 );
3145
3146 let opencode = get_runner("opencode");
3149 let cmd = opencode.build_command(None, "", sys, msg, &perms);
3150 assert!(
3151 cmd.contains("--dangerously-skip-permissions"),
3152 "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3153 );
3154 assert!(
3155 !cmd.contains("opencode.json"),
3156 "opencode default: should not write a config file, got: {cmd}"
3157 );
3158 assert!(
3159 !cmd.contains("XDG_CONFIG_HOME"),
3160 "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3161 );
3162 assert!(
3163 !cmd.contains(".orch-opencode"),
3164 "opencode default: should not reference .orch-opencode, got: {cmd}"
3165 );
3166 }
3167
3168 #[test]
3170 fn all_agents_handle_supervised_mode() {
3171 let perms = PermissionRules {
3172 autonomous: false,
3173 sandbox: SandboxLevel::WorkspaceWrite,
3174 disallowed_tools: vec![],
3175 allowed_tools: vec![],
3176 allowed_edit_paths: vec![],
3177 deny_read_only: false,
3178 extra_writable_dirs: vec![],
3179 };
3180 let sys = "/tmp/sys.md";
3181 let msg = "/tmp/msg.md";
3182
3183 let claude = get_runner("claude");
3185 let cmd = claude.build_command(None, "", sys, msg, &perms);
3186 assert!(
3187 cmd.contains("--permission-mode acceptEdits"),
3188 "claude supervised: expected acceptEdits, got: {cmd}"
3189 );
3190
3191 let codex = get_runner("codex");
3193 let cmd = codex.build_command(None, "", sys, msg, &perms);
3194 assert!(
3195 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3196 "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3197 );
3198 assert!(
3199 !cmd.contains("--ask-for-approval"),
3200 "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3201 );
3202 }
3203
3204 #[test]
3206 fn all_agents_handle_full_access_sandbox() {
3207 let perms = PermissionRules {
3208 autonomous: true,
3209 sandbox: SandboxLevel::FullAccess,
3210 disallowed_tools: vec![],
3211 allowed_tools: vec![],
3212 allowed_edit_paths: vec![],
3213 deny_read_only: false,
3214 extra_writable_dirs: vec![],
3215 };
3216 let sys = "/tmp/sys.md";
3217 let msg = "/tmp/msg.md";
3218
3219 let claude = get_runner("claude");
3221 let cmd = claude.build_command(None, "", sys, msg, &perms);
3222 assert!(
3223 !cmd.contains("--sandbox"),
3224 "claude full_access: should have no sandbox flag, got: {cmd}"
3225 );
3226
3227 let codex = get_runner("codex");
3229 let cmd = codex.build_command(None, "", sys, msg, &perms);
3230 assert!(
3231 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3232 "codex full_access: expected dangerously-bypass, got: {cmd}"
3233 );
3234 }
3235
3236 #[test]
3238 fn codex_sandbox_none_uses_workspace_write() {
3239 let perms = PermissionRules {
3240 autonomous: true,
3241 sandbox: SandboxLevel::None,
3242 disallowed_tools: vec![],
3243 allowed_tools: vec![],
3244 allowed_edit_paths: vec![],
3245 deny_read_only: false,
3246 extra_writable_dirs: vec![],
3247 };
3248 let codex = get_runner("codex");
3249 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3250 assert!(
3251 cmd.contains("--sandbox workspace-write"),
3252 "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3253 );
3254 assert!(
3255 cmd.contains(r#"-c 'approval_policy="never"'"#),
3256 "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3257 );
3258 assert!(
3259 !cmd.contains("--ask-for-approval"),
3260 "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3261 );
3262 assert!(
3263 !cmd.contains("--full-auto"),
3264 "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3265 );
3266 }
3267
3268 #[test]
3270 fn claude_allowed_edit_paths_scope_edit_write() {
3271 let perms = PermissionRules {
3272 autonomous: true,
3273 sandbox: SandboxLevel::WorkspaceWrite,
3274 disallowed_tools: vec![],
3275 allowed_tools: vec![
3276 "Edit".to_string(),
3277 "Write".to_string(),
3278 "Read".to_string(),
3279 "Bash".to_string(),
3280 ],
3281 allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3282 deny_read_only: false,
3283 extra_writable_dirs: vec![],
3284 };
3285 let claude = get_runner("claude");
3286 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3287
3288 assert!(
3290 cmd.contains("Edit(/home/user/worktree/*)"),
3291 "missing scoped Edit, got: {cmd}"
3292 );
3293 assert!(
3294 cmd.contains("Write(/home/user/worktree/*)"),
3295 "missing scoped Write, got: {cmd}"
3296 );
3297 assert!(cmd.contains("Read"), "missing Read");
3299 }
3300
3301 #[test]
3303 fn codex_ignores_allowed_edit_paths() {
3304 let perms = PermissionRules {
3305 autonomous: true,
3306 sandbox: SandboxLevel::WorkspaceWrite,
3307 disallowed_tools: vec![],
3308 allowed_tools: vec![],
3309 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3310 deny_read_only: false,
3311 extra_writable_dirs: vec![],
3312 };
3313 let codex = get_runner("codex");
3314 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3315 assert!(
3316 !cmd.contains("/home/user/project"),
3317 "codex: should not reference blocked paths, got: {cmd}"
3318 );
3319 }
3320
3321 #[test]
3323 fn opencode_ignores_allowed_edit_paths() {
3324 let perms = PermissionRules {
3325 autonomous: true,
3326 sandbox: SandboxLevel::WorkspaceWrite,
3327 disallowed_tools: vec![],
3328 allowed_tools: vec![],
3329 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3330 deny_read_only: false,
3331 extra_writable_dirs: vec![],
3332 };
3333 let opencode = get_runner("opencode");
3334 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3335 assert!(
3336 !cmd.contains("/home/user/project"),
3337 "opencode: should not reference blocked paths, got: {cmd}"
3338 );
3339 }
3340
3341 #[test]
3343 fn claude_merges_disallowed_tools() {
3344 let perms = PermissionRules {
3345 autonomous: true,
3346 sandbox: SandboxLevel::WorkspaceWrite,
3347 disallowed_tools: vec![
3348 "Bash(rm *)".to_string(),
3349 "Bash(sudo *)".to_string(),
3350 "WebFetch".to_string(),
3351 ],
3352 allowed_tools: vec![],
3353 allowed_edit_paths: vec![],
3354 deny_read_only: false,
3355 extra_writable_dirs: vec![],
3356 };
3357 let claude = get_runner("claude");
3358 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3359 assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3360 assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3361 assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3362 }
3363
3364 #[test]
3366 fn codex_ignores_disallowed_tools() {
3367 let perms = PermissionRules {
3368 autonomous: true,
3369 sandbox: SandboxLevel::WorkspaceWrite,
3370 disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3371 allowed_tools: vec![],
3372 allowed_edit_paths: vec![],
3373 deny_read_only: false,
3374 extra_writable_dirs: vec![],
3375 };
3376 let codex = get_runner("codex");
3377 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3378 assert!(
3379 !cmd.contains("disallow"),
3380 "codex: should not have disallow flags, got: {cmd}"
3381 );
3382 }
3383
3384 #[test]
3386 fn claude_no_disallowed_flag_when_empty() {
3387 let perms = PermissionRules {
3388 autonomous: true,
3389 sandbox: SandboxLevel::WorkspaceWrite,
3390 disallowed_tools: vec![],
3391 allowed_tools: vec![],
3392 allowed_edit_paths: vec![],
3393 deny_read_only: false,
3394 extra_writable_dirs: vec![],
3395 };
3396 let claude = get_runner("claude");
3397 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3398 assert!(
3399 !cmd.contains("--disallowedTools"),
3400 "claude: should have no disallowed flag when empty, got: {cmd}"
3401 );
3402 }
3403
3404 #[test]
3406 fn kimi_minimax_use_claude_permissions() {
3407 let perms = PermissionRules {
3408 autonomous: false,
3409 sandbox: SandboxLevel::WorkspaceWrite,
3410 disallowed_tools: vec!["Bash(rm *)".to_string()],
3411 allowed_tools: vec![],
3412 allowed_edit_paths: vec![],
3413 deny_read_only: false,
3414 extra_writable_dirs: vec![],
3415 };
3416 let sys = "/tmp/sys.md";
3417 let msg = "/tmp/msg.md";
3418
3419 for agent in &["kimi", "minimax"] {
3420 let runner = get_runner(agent);
3421 let cmd = runner.build_command(None, "", sys, msg, &perms);
3422 assert!(
3423 cmd.contains("--permission-mode acceptEdits"),
3424 "{agent}: expected acceptEdits"
3425 );
3426 assert!(
3427 cmd.contains("Bash(rm *)"),
3428 "{agent}: expected rm disallowed"
3429 );
3430 }
3431 }
3432
3433 #[test]
3435 fn kimi_minimax_use_allowed_tools() {
3436 let perms = PermissionRules {
3437 autonomous: true,
3438 sandbox: SandboxLevel::WorkspaceWrite,
3439 disallowed_tools: vec![],
3440 allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3441 allowed_edit_paths: vec![],
3442 deny_read_only: false,
3443 extra_writable_dirs: vec![],
3444 };
3445
3446 for agent in &["kimi", "minimax"] {
3447 let runner = get_runner(agent);
3448 let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3449 assert!(
3450 cmd.contains("--allowedTools"),
3451 "{agent}: expected --allowedTools, got: {cmd}"
3452 );
3453 assert!(
3454 cmd.contains("Bash(git *)"),
3455 "{agent}: expected Bash(git *) pattern, got: {cmd}"
3456 );
3457 }
3458 }
3459
3460 #[test]
3464 fn claude_allowed_tools_generates_allowedtools_flag() {
3465 let perms = PermissionRules {
3466 autonomous: true,
3467 sandbox: SandboxLevel::WorkspaceWrite,
3468 disallowed_tools: vec!["Bash(rm *)".to_string()],
3469 allowed_tools: vec![
3470 "Edit".to_string(),
3471 "Write".to_string(),
3472 "Read".to_string(),
3473 "Bash".to_string(),
3474 "git".to_string(),
3475 ],
3476 allowed_edit_paths: vec![],
3477 deny_read_only: false,
3478 extra_writable_dirs: vec![],
3479 };
3480
3481 let claude = get_runner("claude");
3482 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3483 assert!(
3484 cmd.contains("--allowedTools"),
3485 "expected --allowedTools, got: {cmd}"
3486 );
3487 assert!(
3488 !cmd.contains("--disallowedTools"),
3489 "should not have --disallowedTools when allowed_tools is set"
3490 );
3491 }
3492
3493 #[test]
3495 fn codex_opencode_ignore_allowed_tools() {
3496 let perms = PermissionRules {
3497 autonomous: true,
3498 sandbox: SandboxLevel::WorkspaceWrite,
3499 disallowed_tools: vec![],
3500 allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3501 allowed_edit_paths: vec![],
3502 deny_read_only: false,
3503 extra_writable_dirs: vec![],
3504 };
3505
3506 let codex = get_runner("codex");
3507 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3508 assert!(
3509 !cmd.contains("--allowedTools"),
3510 "codex should not have --allowedTools, got: {cmd}"
3511 );
3512
3513 let opencode = get_runner("opencode");
3514 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3515 assert!(
3516 !cmd.contains("--allowedTools"),
3517 "opencode should not have --allowedTools, got: {cmd}"
3518 );
3519 }
3520
3521 #[test]
3526 #[ignore]
3527 fn integration_from_config_to_agent_commands() {
3528 let perms = PermissionRules::from_config();
3529
3530 assert!(
3532 perms.autonomous,
3533 "from_config: expected autonomous=true by default"
3534 );
3535
3536 for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3538 let runner = get_runner(agent_name);
3539 let cmd =
3540 runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3541 assert!(
3542 !cmd.is_empty(),
3543 "{agent_name}: build_command returned empty string"
3544 );
3545 assert!(
3546 cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3547 "{agent_name}: command should reference agent binary, got: {cmd}"
3548 );
3549 }
3550 }
3551
3552 #[test]
3555 #[ignore]
3556 fn integration_supervised_config_translates_correctly() {
3557 let perms = PermissionRules::from_config();
3562
3563 let claude = get_runner("claude");
3564 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3565
3566 if perms.autonomous {
3567 assert!(
3568 cmd.contains("bypassPermissions"),
3569 "autonomous config → bypassPermissions"
3570 );
3571 } else {
3572 assert!(
3573 cmd.contains("acceptEdits"),
3574 "supervised config → acceptEdits"
3575 );
3576 }
3577
3578 let codex = get_runner("codex");
3579 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3580
3581 if perms.autonomous {
3582 assert!(
3584 cmd.contains("--sandbox workspace-write"),
3585 "autonomous config → --sandbox workspace-write, got: {cmd}"
3586 );
3587 assert!(
3588 cmd.contains(r#"-c 'approval_policy="never"'"#),
3589 "autonomous config → -c approval_policy=never, got: {cmd}"
3590 );
3591 } else {
3592 assert!(
3593 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3594 "supervised config → -c approval_policy=on-request, got: {cmd}"
3595 );
3596 }
3597 assert!(
3598 !cmd.contains("--ask-for-approval"),
3599 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3600 );
3601 }
3602
3603 #[test]
3607 fn deny_read_only_blocks_reading_tools() {
3608 let perms = PermissionRules::deny_read_only();
3609
3610 let read_tools = [
3611 "Bash(ls *)",
3612 "Bash(ls)",
3613 "Bash(find *)",
3614 "Bash(cat *)",
3615 "Bash(head *)",
3616 "Bash(tail *)",
3617 "Bash(fzf *)",
3618 "Bash(less *)",
3619 "Bash(more *)",
3620 "Read",
3621 "Glob",
3622 "Grep",
3623 ];
3624
3625 for tool in read_tools {
3626 assert!(
3627 perms.disallowed_tools.contains(&tool.to_string()),
3628 "deny_read_only should block {tool}, got: {:?}",
3629 perms.disallowed_tools
3630 );
3631 }
3632 }
3633
3634 #[test]
3636 fn deny_read_only_claude_command() {
3637 let perms = PermissionRules::deny_read_only();
3638 let claude = get_runner("claude");
3639 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3640
3641 assert!(
3643 cmd.contains("Read"),
3644 "should block Read for control session"
3645 );
3646 assert!(
3647 cmd.contains("Glob"),
3648 "should block Glob for control session"
3649 );
3650 assert!(
3651 cmd.contains("Grep"),
3652 "should block Grep for control session"
3653 );
3654 assert!(
3656 cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3657 "should block ls"
3658 );
3659 assert!(
3661 cmd.contains("--permission-mode bypassPermissions"),
3662 "should be autonomous (bypassPermissions)"
3663 );
3664 }
3665
3666 #[test]
3669 fn synthesize_extracts_file_paths() {
3670 let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3671 let resp = synthesize_response_from_text(text).unwrap();
3672 assert!(
3673 resp.files
3674 .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3675 "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3676 resp.files
3677 );
3678 assert!(
3679 resp.files.contains(&"tests/integration.rs".to_string()),
3680 "expected tests/integration.rs in files, got: {:?}",
3681 resp.files
3682 );
3683 }
3684
3685 #[test]
3686 fn synthesize_extracts_accomplished_bullets() {
3687 let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3688 let resp = synthesize_response_from_text(text).unwrap();
3689 assert_eq!(resp.status, "done");
3690 assert!(
3691 resp.accomplished
3692 .iter()
3693 .any(|a| a.contains("Added logging")),
3694 "expected accomplished bullet, got: {:?}",
3695 resp.accomplished
3696 );
3697 assert!(
3698 resp.accomplished
3699 .iter()
3700 .any(|a| a.contains("Updated migration")),
3701 "expected accomplished bullet, got: {:?}",
3702 resp.accomplished
3703 );
3704 }
3705
3706 #[test]
3707 fn synthesize_extracts_remaining_section() {
3708 let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3709 let resp = synthesize_response_from_text(text).unwrap();
3710 assert!(
3711 resp.remaining
3712 .iter()
3713 .any(|r| r.contains("Write unit tests")),
3714 "expected remaining item, got: {:?}",
3715 resp.remaining
3716 );
3717 assert!(
3718 resp.remaining.iter().any(|r| r.contains("Update docs")),
3719 "expected remaining item, got: {:?}",
3720 resp.remaining
3721 );
3722 assert!(
3723 resp.accomplished.iter().any(|a| a.contains("Patched")),
3724 "expected accomplished item, got: {:?}",
3725 resp.accomplished
3726 );
3727 }
3728
3729 #[test]
3730 fn synthesize_fixed_phrase_is_done() {
3731 let text = "Fixed. The bug no longer reproduces.";
3732 let resp = synthesize_response_from_text(text).unwrap();
3733 assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3734 }
3735
3736 #[test]
3743 fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3744 let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3748 assert!(
3749 patterns::detect_rate_limit(payload).is_some(),
3750 "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3751 );
3752 }
3753
3754 #[test]
3758 fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3759 let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3760 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3761 && !raw_output.contains("\"is_error\":true");
3762 let detected = (!ndjson_completed)
3764 .then(|| patterns::detect_rate_limit(raw_output))
3765 .flatten();
3766 assert!(
3767 detected.is_none(),
3768 "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3769 );
3770 }
3771
3772 #[test]
3774 fn terminal_reason_completed_guard_passes_real_rate_limit() {
3775 let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3777 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3778 && !raw_output.contains("\"is_error\":true");
3779 let detected = (!ndjson_completed)
3780 .then(|| patterns::detect_rate_limit(raw_output))
3781 .flatten();
3782 assert!(
3783 detected.is_some(),
3784 "guard must NOT suppress a real rate-limit error"
3785 );
3786 }
3787
3788 #[test]
3789 fn synthesize_no_false_positive_file_paths() {
3790 let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3794 match synthesize_response_from_text(text) {
3795 None => {} Some(resp) => {
3797 assert!(
3798 resp.files.is_empty(),
3799 "expected no file paths extracted from URL text, got: {:?}",
3800 resp.files
3801 );
3802 }
3803 }
3804 }
3805
3806 #[test]
3811 fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3812 let text = "error: unexpected argument '--ask-for-approval' found\n \
3814 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3815 Usage: codex exec [OPTIONS] [PROMPT]";
3816 assert!(
3817 synthesize_response_from_text(text).is_none(),
3818 "CLI parser diagnostic must not synthesize as agent response"
3819 );
3820 }
3821
3822 #[test]
3823 fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3824 let text = "Usage: mycommand [OPTIONS] <arg>";
3825 assert!(
3826 synthesize_response_from_text(text).is_none(),
3827 "Usage line must not synthesize as agent response"
3828 );
3829 }
3830
3831 #[test]
3832 fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3833 let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3834 assert!(
3835 synthesize_response_from_text(text).is_none(),
3836 "Help hint must not synthesize as agent response"
3837 );
3838 }
3839
3840 #[test]
3841 fn synthesize_rejects_cli_parser_diagnostics_tip() {
3842 let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3843 assert!(
3844 synthesize_response_from_text(text).is_none(),
3845 "Clap tip must not synthesize as agent response"
3846 );
3847 }
3848
3849 #[test]
3850 fn synthesize_accepts_agent_error_messages_that_mention_error() {
3851 let text = "I tried to run the command but got an error: permission denied on that file.";
3854 let response =
3855 synthesize_response_from_text(text).expect("agent error message should synthesize");
3856 assert_eq!(response.status, "needs_review");
3857 assert!(response.error.is_some());
3858 }
3859
3860 #[test]
3865 fn synthesize_rejects_exact_codex_cli_error() {
3866 let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n \
3867 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3868 Usage: codex exec [OPTIONS] [PROMPT]";
3869 assert!(
3870 synthesize_response_from_text(stderr_from_codex).is_none(),
3871 "exact codex CLI error from #3197 must not synthesize as agent response"
3872 );
3873 }
3874}