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 pub deny_read_only: bool,
46 pub extra_writable_dirs: Vec<PathBuf>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum SandboxLevel {
56 WorkspaceWrite,
58 FullAccess,
60 None,
62 Os,
64}
65
66impl Default for PermissionRules {
67 fn default() -> Self {
68 Self {
69 autonomous: true,
70 sandbox: SandboxLevel::WorkspaceWrite,
71 disallowed_tools: vec![
72 "Bash(rm *)".to_string(),
73 "Bash(rm -*)".to_string(),
74 "Bash(git push*)".to_string(),
75 ],
76 allowed_tools: vec![],
77 allowed_edit_paths: vec![],
78 deny_read_only: false,
79 extra_writable_dirs: vec![],
80 }
81 }
82}
83
84impl PermissionRules {
85 pub fn from_config() -> Self {
87 let mut rules = Self::default();
88
89 if let Ok(mode) = crate::config::get("workflow.permissions.mode") {
90 rules.autonomous = mode != "supervised";
91 }
92
93 if let Ok(sandbox) = crate::config::get("workflow.permissions.sandbox") {
94 rules.sandbox = match sandbox.as_str() {
95 "full-access" | "danger-full-access" => SandboxLevel::FullAccess,
96 "none" => SandboxLevel::None,
97 _ => SandboxLevel::WorkspaceWrite,
98 };
99 }
100
101 if let Ok(tools) = crate::config::get("workflow.disallowed_tools") {
102 let parsed: Vec<String> = tools
103 .split(',')
104 .map(|s| s.trim().to_string())
105 .filter(|s| !s.is_empty())
106 .collect();
107 if !parsed.is_empty() {
108 rules.disallowed_tools = parsed;
109 }
110 }
111
112 if let Ok(tools) = crate::config::get_list("workflow.allowed_tools") {
113 rules.allowed_tools = tools;
114 }
115
116 rules
117 }
118
119 pub fn deny_read_only() -> Self {
126 Self {
127 autonomous: true,
128 sandbox: SandboxLevel::WorkspaceWrite,
129 disallowed_tools: vec![
130 "Bash(rm *)".to_string(),
131 "Bash(rm -*)".to_string(),
132 "Bash(git push*)".to_string(),
133 "Bash(ls *)".to_string(),
134 "Bash(ls)".to_string(),
135 "Bash(find *)".to_string(),
136 "Bash(cat *)".to_string(),
137 "Bash(head *)".to_string(),
138 "Bash(tail *)".to_string(),
139 "Bash(fzf *)".to_string(),
140 "Bash(less *)".to_string(),
141 "Bash(more *)".to_string(),
142 "Read".to_string(),
143 "Glob".to_string(),
144 "Grep".to_string(),
145 ],
146 allowed_tools: vec![],
147 allowed_edit_paths: vec![],
148 deny_read_only: true,
149 extra_writable_dirs: vec![],
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
160pub 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 "grok" => grok::find_grok_result(ndjson),
196 "opencode" => opencode::find_opencode_result(ndjson),
197 "codex" => codex::find_codex_result(ndjson),
198 _ => 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 Interrupted { message: String },
232 MissingTool { tool: String },
234 PermissionDenied { message: String },
236 WaitingForInput { message: String },
238 StaleSession { session_id: String },
243 InvalidResponse { raw: String },
245 AgentFailed { message: String },
247 NetworkError { message: String },
249 ThinkingBlockConflict { message: String },
256 Unknown { exit_code: i32, message: String },
258}
259
260impl std::fmt::Display for AgentError {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 match self {
263 Self::RateLimit { message, .. } => write!(f, "rate limit: {message}"),
264 Self::Auth { message } => write!(f, "auth error: {message}"),
265 Self::ModelUnavailable { message, model } => {
266 write!(f, "model unavailable ({model}): {message}")
267 }
268 Self::ContextOverflow { message, .. } => write!(f, "context overflow: {message}"),
269 Self::Timeout { elapsed } => write!(f, "timeout after {}s", elapsed.as_secs()),
270 Self::Interrupted { message } => write!(f, "interrupted: {message}"),
271 Self::MissingTool { tool } => write!(f, "missing tool: {tool}"),
272 Self::PermissionDenied { message } => write!(f, "permission denied: {message}"),
273 Self::WaitingForInput { message } => write!(f, "waiting for input: {message}"),
274 Self::StaleSession { session_id } => {
275 write!(f, "stale session (session expired): {session_id}")
276 }
277 Self::InvalidResponse { raw } => {
278 let end = truncate_at_char_boundary(raw, 200);
279 write!(f, "invalid response: {}", &raw[..end])
280 }
281 Self::AgentFailed { message } => write!(f, "agent failed: {message}"),
282 Self::NetworkError { message } => write!(f, "network error: {message}"),
283 Self::ThinkingBlockConflict { message } => {
284 write!(f, "thinking block conflict: {message}")
285 }
286 Self::Unknown { exit_code, message } => {
287 write!(f, "unknown error (exit {exit_code}): {message}")
288 }
289 }
290 }
291}
292
293impl std::error::Error for AgentError {}
294
295pub fn error_class_name(err: &AgentError) -> &'static str {
298 match err {
299 AgentError::RateLimit { .. } => "RateLimit",
300 AgentError::Auth { .. } => "Auth",
301 AgentError::ModelUnavailable { .. } => "ModelUnavailable",
302 AgentError::ContextOverflow { .. } => "ContextOverflow",
303 AgentError::Timeout { .. } => "Timeout",
304 AgentError::Interrupted { .. } => "Interrupted",
305 AgentError::MissingTool { .. } => "MissingTool",
306 AgentError::PermissionDenied { .. } => "PermissionDenied",
307 AgentError::WaitingForInput { .. } => "WaitingForInput",
308 AgentError::StaleSession { .. } => "StaleSession",
309 AgentError::InvalidResponse { .. } => "InvalidResponse",
310 AgentError::AgentFailed { .. } => "AgentFailed",
311 AgentError::NetworkError { .. } => "NetworkError",
312 AgentError::ThinkingBlockConflict { .. } => "ThinkingBlockConflict",
313 AgentError::Unknown { .. } => "Unknown",
314 }
315}
316
317fn extract_file_paths(text: &str) -> Vec<String> {
322 let extensions = [
325 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh",
326 "md", "toml", "yaml", "yml", "json", "sql", "html", "css", "swift", "kt",
327 ];
328 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
329 for token in text.split_whitespace() {
330 let token = token.trim_matches(|c: char| {
333 matches!(
334 c,
335 '`' | '\'' | '"' | '(' | ')' | '[' | ']' | ',' | ':' | ';'
336 )
337 });
338 let token = if let Some(without) = token.strip_suffix('.') {
342 let still_has_ext = extensions
343 .iter()
344 .any(|ext| without.ends_with(&format!(".{ext}")));
345 if still_has_ext { without } else { token }
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 fn contains_word(text: &str, word: &str) -> bool {
516 let mut start = 0;
517 while let Some(pos) = text[start..].find(word) {
518 let abs = start + pos;
519 let before_ok = abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
520 let after_ok = abs + word.len() >= text.len()
521 || !text.as_bytes()[abs + word.len()].is_ascii_alphanumeric();
522 if before_ok && after_ok {
523 return true;
524 }
525 start = abs + 1;
526 if start >= text.len() {
527 break;
528 }
529 }
530 false
531 }
532
533 let trimmed = text.trim();
534 if trimmed.is_empty() {
535 return None;
536 }
537
538 if is_cli_parser_diagnostic(trimmed) {
546 return None;
547 }
548
549 if looks_like_ndjson_event_stream(trimmed) {
555 return None;
556 }
557
558 let lower = trimmed.to_lowercase();
559 let looks_negative = [
566 "not done",
567 "not yet done",
568 "not complete",
569 "not yet complete",
570 "not completed",
571 "not yet completed",
572 "not committed",
573 "incomplete",
574 "undone",
575 ]
576 .iter()
577 .any(|needle| lower.contains(needle));
578
579 let explicit_done = [
582 "nothing to",
583 "nothing to do",
584 "nothing to execute",
585 "no positions",
586 "no open positions",
587 "no trades",
588 "no trade",
589 "no action needed",
590 "no changes to commit",
591 "completed",
592 "filed",
594 "filed issue",
595 "filed issues",
596 "created issue",
597 "created issues",
598 "opened issue",
599 "opened issues",
600 "issue created",
601 "issues created",
602 "issues filed",
603 "task created",
604 "posted comment",
605 "comment posted",
606 "changes committed",
607 "commit created",
608 "fixed.",
609 "has been fixed",
610 "the fix has been applied",
611 "the fix has already been applied",
612 "the fix is complete",
614 "fix is working",
615 "all tests pass",
616 "tests pass.",
617 ]
618 .iter()
619 .any(|needle| lower.contains(needle));
620
621 let mut done_confident = false;
627 for sentence in trimmed
628 .split(['.', '!', '?', '\n'])
629 .map(str::trim)
630 .filter(|s| !s.is_empty())
631 {
632 let s_lower = sentence.to_lowercase();
633 let word_count = sentence.split_whitespace().count();
635 let hedging = [
636 "let",
637 "let me",
638 "check",
639 "checking",
640 "will",
641 "i'll",
642 "i will",
643 "trying",
644 "try",
645 "investigat",
646 "maybe",
647 "could",
648 "should",
649 "might",
650 "if ",
651 "plan",
652 "planning",
653 ];
654 let contains_hedge = hedging.iter().any(|h| s_lower.contains(h));
655
656 if contains_word(&s_lower, "done") && word_count <= 8 && !contains_hedge {
657 done_confident = true;
658 break;
659 }
660
661 if s_lower.contains("committed") || s_lower.contains("commit created") {
665 if s_lower.contains("changes")
667 || s_lower.contains("i've")
668 || s_lower.contains("i have")
669 || s_lower.contains("the commit")
670 {
671 done_confident = true;
672 break;
673 }
674 }
675
676 let past_tense_actions = [
681 "added ",
682 "fixed ",
683 "updated ",
684 "implemented ",
685 "created ",
686 "removed ",
687 "changed ",
688 "deleted ",
689 "applied ",
690 "resolved ",
691 "patched ",
692 "refactored ",
693 "merged ",
694 "pushed ",
695 "deployed ",
696 ];
697 if !contains_hedge
698 && word_count <= 12
699 && past_tense_actions.iter().any(|v| s_lower.starts_with(v))
700 {
701 done_confident = true;
702 break;
703 }
704 }
705
706 let looks_done = !looks_negative && (explicit_done || done_confident);
707
708 let looks_error = [
709 "error:",
710 "failed to",
711 "cannot",
712 "permission denied",
713 "unable to",
714 ]
715 .iter()
716 .any(|needle| lower.contains(needle));
717
718 let status = if looks_error {
719 "needs_review"
720 } else if looks_done {
721 "done"
722 } else if looks_negative {
723 "needs_review"
725 } else {
726 return None;
732 };
733
734 let summary_end = truncate_at_char_boundary(trimmed, 500);
735 let summary = trimmed[..summary_end].to_string();
736 let error =
737 (status == "needs_review").then(|| "agent returned plain text instead of JSON".to_string());
738
739 let files = extract_file_paths(trimmed);
741 let (accomplished, remaining) = extract_bullet_sections(trimmed);
742
743 Some(AgentResponse {
744 status: status.to_string(),
745 summary,
746 accomplished,
747 remaining,
748 files,
749 error,
750 input_tokens: None,
751 output_tokens: None,
752 learnings: vec![],
753 delegations: vec![],
754 })
755}
756
757pub(crate) fn shell_single_quote(value: &str) -> String {
759 format!("'{}'", value.replace('\'', "'\\''"))
760}
761
762pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
765 if max_bytes >= s.len() {
766 return s.len();
767 }
768 let mut end = max_bytes;
770 while end > 0 && !s.is_char_boundary(end) {
771 end -= 1;
772 }
773 end
774}
775
776#[derive(Debug, Clone, PartialEq)]
778pub enum StreamEvent {
779 Started {
780 model: String,
781 },
782 Text(String),
783 Thinking(String),
784 ToolCall {
786 id: String,
787 name: String,
788 input: serde_json::Value,
789 },
790 ToolResult {
792 id: String,
793 name: String,
794 output: String,
795 is_error: bool,
796 },
797 Done(String),
798 Error(String),
799}
800
801pub(crate) fn json_str<'a>(v: &'a serde_json::Value, pointer: &str) -> &'a str {
803 v.pointer(pointer)
804 .and_then(serde_json::Value::as_str)
805 .unwrap_or("")
806}
807
808pub(crate) fn json_error_message(v: &serde_json::Value) -> String {
810 [
811 "/message",
812 "/error",
813 "/error/message",
814 "/error/data/message",
815 ]
816 .iter()
817 .find_map(|p| v.pointer(p).and_then(serde_json::Value::as_str))
818 .unwrap_or("unknown error")
819 .to_string()
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824pub enum RouterTranscript {
825 Messages,
827 OpenCode,
829 Raw,
831}
832
833pub trait AgentRunner: Send + Sync {
838 #[cfg(test)]
840 fn name(&self) -> &str;
841
842 fn build_command(
848 &self,
849 model: Option<&str>,
850 timeout_cmd: &str,
851 sys_file: &str,
852 msg_file: &str,
853 permissions: &PermissionRules,
854 ) -> String;
855
856 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError>;
861
862 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 && let Some(actual_elapsed) = elapsed_secs
900 {
901 *elapsed = Duration::from_secs(actual_elapsed);
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(crate) 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 pos_429 = find_standalone_digits(&lower, "429");
1268 let has_429 = pos_429.is_some();
1269 let has_529 = lower.contains("http 529")
1273 || lower.contains("529 service")
1274 || (lower.find(": 529").is_some_and(|pos| {
1275 !lower[pos + 5..]
1276 .chars()
1277 .next()
1278 .is_some_and(|c| c.is_ascii_digit())
1279 }));
1280
1281 if match_info.is_some() || has_429 || has_529 {
1282 let message = if let Some((_, pos)) = match_info {
1283 extract_context_around(text, pos, 300)
1284 } else if let Some(pos) = pos_429 {
1285 extract_context_around(text, lower_offset_to_original_byte(text, &lower, pos), 300)
1286 } else {
1287 safe_tail(text, 300).to_string()
1288 };
1289 return Some(AgentError::RateLimit { message });
1290 }
1291 None
1292 }
1293
1294 fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1300 let half = window / 2;
1301 let start = byte_pos.saturating_sub(half);
1302 let end = (byte_pos + window).min(text.len());
1303 let mut s = start;
1305 while s < text.len() && !text.is_char_boundary(s) {
1306 s += 1;
1307 }
1308 let mut e = end;
1309 while e < text.len() && !text.is_char_boundary(e) {
1310 e += 1;
1311 }
1312 text[s..e].to_string()
1313 }
1314
1315 fn find_standalone_digits(lower: &str, digits: &str) -> Option<usize> {
1318 let mut start = 0;
1319 while let Some(rel) = lower[start..].find(digits) {
1320 let pos = start + rel;
1321 let before_ok = pos == 0
1322 || !lower[..pos]
1323 .chars()
1324 .next_back()
1325 .is_some_and(|c| c.is_ascii_alphanumeric());
1326 let after = pos + digits.len();
1327 let after_ok = after >= lower.len()
1328 || !lower[after..]
1329 .chars()
1330 .next()
1331 .is_some_and(|c| c.is_ascii_alphanumeric());
1332 if before_ok && after_ok {
1333 return Some(pos);
1334 }
1335 start = pos + 1;
1336 }
1337 None
1338 }
1339
1340 pub(crate) fn contains_http_status(lower: &str, code: &str) -> bool {
1348 if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1350 return true;
1351 }
1352 let needle = format!(": {code}");
1354 let mut start = 0;
1355 while let Some(rel) = lower[start..].find(needle.as_str()) {
1356 let after = start + rel + needle.len();
1357 match lower[after..].chars().next() {
1358 Some(c) if c.is_ascii_digit() => {
1359 start = after;
1361 }
1362 _ => return true,
1363 }
1364 }
1365 false
1366 }
1367
1368 pub(crate) fn detect_auth_error(text: &str) -> Option<AgentError> {
1374 let lower = text.to_lowercase();
1375 let patterns = [
1376 "unauthorized",
1377 "authentication required",
1378 "invalid api",
1379 "invalid key",
1380 "invalid token",
1381 "auth fail",
1382 "no api key",
1383 "no token",
1384 "expired key",
1385 "expired token",
1386 "expired plan",
1387 "billing plan",
1391 "billing error",
1392 "billing suspended",
1393 "billing expired",
1394 "billing limit",
1395 "billing failed",
1396 "billing cycle exhausted",
1397 "insufficient credit",
1398 "insufficient balance",
1399 "credit balance too low",
1400 "payment required",
1401 ];
1402 let http_401 = contains_http_status(&lower, "401");
1405 let http_403 = contains_http_status(&lower, "403");
1406 let http_407 = contains_http_status(&lower, "407");
1407 let proxy_auth_required = lower.contains("proxy authentication required");
1408 if !patterns.iter().any(|p| lower.contains(p))
1409 && !http_401
1410 && !http_403
1411 && !http_407
1412 && !proxy_auth_required
1413 {
1414 return None;
1415 }
1416 let first_match_pos =
1418 patterns
1419 .iter()
1420 .filter_map(|p| lower.find(p))
1421 .min()
1422 .map(|lower_pos| {
1423 let char_idx = lower[..lower_pos].chars().count();
1424 text.char_indices()
1425 .nth(char_idx)
1426 .map_or(text.len(), |(i, _)| i)
1427 });
1428 let http_401_match_pos = if http_401 {
1431 lower.find("401 ").or_else(|| lower.find(": 401"))
1432 } else {
1433 None
1434 };
1435 let http_403_match_pos = if http_403 {
1436 lower.find("403 ").or_else(|| lower.find(": 403"))
1437 } else {
1438 None
1439 };
1440 let http_407_match_pos = if http_407 {
1441 lower.find("407 ").or_else(|| lower.find(": 407"))
1442 } else {
1443 None
1444 };
1445 let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1446 .into_iter()
1447 .flatten()
1448 .min()
1449 .and_then(|rel| {
1450 let char_idx = lower[..rel].chars().count();
1451 text.char_indices().nth(char_idx).map(|(i, _)| i)
1452 });
1453 let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1454 let message = if let Some(pos) = match_pos {
1455 find_line_containing(text, pos)
1456 } else {
1457 find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1460 };
1461 Some(AgentError::Auth { message })
1462 }
1463
1464 fn find_line_containing(text: &str, byte_pos: usize) -> String {
1466 let start = text[..byte_pos].rfind('\n').map_or(0, |i| i + 1);
1467 let end = text[byte_pos..]
1468 .find('\n')
1469 .map_or(text.len(), |i| byte_pos + i);
1470 text[start..end].trim().to_string()
1471 }
1472
1473 fn find_auth_line_scan(text: &str) -> Option<String> {
1477 for line in text.lines().rev().take(50) {
1478 let line_lower = line.to_lowercase();
1479 if contains_http_status(&line_lower, "401")
1480 || contains_http_status(&line_lower, "403")
1481 || contains_http_status(&line_lower, "407")
1482 || line_lower.contains("unauthorized")
1483 || line_lower.contains("authentication required")
1484 || line_lower.contains("invalid api")
1485 || line_lower.contains("invalid key")
1486 || line_lower.contains("invalid token")
1487 || line_lower.contains("auth fail")
1488 || line_lower.contains("no api key")
1489 || line_lower.contains("no token")
1490 || line_lower.contains("expired key")
1491 || line_lower.contains("expired token")
1492 || line_lower.contains("expired plan")
1493 || line_lower.contains("billing plan")
1494 || line_lower.contains("billing error")
1495 || line_lower.contains("billing suspended")
1496 || line_lower.contains("billing expired")
1497 || line_lower.contains("billing limit")
1498 || line_lower.contains("billing failed")
1499 || line_lower.contains("billing cycle exhausted")
1500 || line_lower.contains("insufficient credit")
1501 || line_lower.contains("credit balance too low")
1502 || line_lower.contains("payment required")
1503 || line_lower.contains("proxy authentication required")
1504 {
1505 return Some(line.trim().to_string());
1506 }
1507 }
1508 None
1509 }
1510
1511 pub(crate) fn detect_network_error(text: &str) -> Option<AgentError> {
1513 let lower = text.to_lowercase();
1514 let patterns = [
1515 "connectionrefused",
1516 "connection refused",
1517 "unable to connect",
1518 "econnrefused",
1519 "network unreachable",
1520 "socket connection was closed",
1522 "socket hang up",
1523 "econnreset",
1524 "connection reset",
1525 "etimedout",
1526 "connect etimedout",
1527 "aborterror",
1528 "the operation was aborted",
1529 "network request failed",
1530 "fetch failed",
1531 "upstream idle timeout",
1533 "streaming response failed",
1534 "upstream request failed",
1535 "stalled mid-stream",
1537 "connection closed mid-response",
1538 ];
1539 let match_pos = patterns
1542 .iter()
1543 .filter_map(|p| lower.find(p))
1544 .min()
1545 .map(|lower_pos| {
1546 let char_idx = lower[..lower_pos].chars().count();
1547 text.char_indices()
1548 .nth(char_idx)
1549 .map_or(text.len(), |(i, _)| i)
1550 });
1551 if let Some(pos) = match_pos {
1552 return Some(AgentError::NetworkError {
1553 message: extract_context_around(text, pos, 300),
1554 });
1555 }
1556 None
1557 }
1558
1559 pub(crate) fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1569 let lower = text.to_lowercase();
1570 let is_model_unavailable = lower.contains("model metadata")
1571 || lower.contains("no endpoints found")
1572 || (lower.contains("model")
1573 && (lower.contains("not found")
1574 || lower.contains("not supported")
1575 || lower.contains("does not exist")
1576 || lower.contains("deprecated")
1577 || lower.contains("unavailable for free")
1578 || lower.contains("not available in your country")
1579 || lower.contains("not available in your region")));
1580 if !is_model_unavailable {
1581 return None;
1582 }
1583 let model = extract_quoted(text, '`')
1584 .or_else(|| extract_quoted(text, '\''))
1585 .unwrap_or_default();
1586 Some(AgentError::ModelUnavailable {
1587 message: safe_tail(text, 300).to_string(),
1588 model,
1589 })
1590 }
1591
1592 fn extract_quoted(text: &str, quote: char) -> Option<String> {
1594 let start = text.find(quote)?;
1595 let rest = &text[start + quote.len_utf8()..];
1596 let end = rest.find(quote)?;
1597 Some(rest[..end].to_string())
1598 }
1599
1600 pub(crate) fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1602 let lower = text.to_lowercase();
1603 let patterns = ["worktree directory does not exist"];
1604 if patterns.iter().any(|p| lower.contains(p)) {
1605 return Some(AgentError::AgentFailed {
1606 message: safe_tail(text, 300).to_string(),
1607 });
1608 }
1609 None
1610 }
1611
1612 pub(crate) fn detect_context_overflow(text: &str) -> Option<AgentError> {
1614 let lower = text.to_lowercase();
1615 let patterns = [
1616 "context_length_exceeded",
1617 "context length exceeded",
1618 "maximum context length",
1619 "too many tokens",
1620 "token limit",
1621 "context overflow",
1622 ];
1623 if patterns.iter().any(|p| lower.contains(p)) {
1624 return Some(AgentError::ContextOverflow {
1625 message: safe_tail(text, 300).to_string(),
1626 });
1627 }
1628 None
1629 }
1630
1631 pub(crate) fn detect_missing_tool(text: &str) -> Option<AgentError> {
1633 let known_tools = [
1634 "bun",
1635 "node",
1636 "npm",
1637 "pnpm",
1638 "yarn",
1639 "deno",
1640 "tsc",
1641 "eslint",
1642 "prettier",
1643 "jest",
1644 "vitest",
1645 "cargo",
1646 "rustc",
1647 "go",
1648 "python",
1649 "python3",
1650 "pip",
1651 "pip3",
1652 "uv",
1653 "poetry",
1654 "pytest",
1655 "ruff",
1656 "black",
1657 "mypy",
1658 "make",
1659 "cmake",
1660 "ninja",
1661 "just",
1662 "bats",
1663 "docker",
1664 "docker-compose",
1665 "podman",
1666 "kubectl",
1667 "helm",
1668 "terraform",
1669 "anchor",
1670 "avm",
1671 "solana",
1672 "solana-test-validator",
1673 ];
1674
1675 let lower = text.to_lowercase();
1676 for tool in &known_tools {
1677 if lower.contains(&format!("{tool}: command not found"))
1678 || lower.contains(&format!("command not found: {tool}"))
1679 || lower.contains(&format!("{tool}: not found"))
1680 || lower.contains(&format!("env: {tool}: no such file"))
1681 || lower.contains(&format!("spawn {tool} enoent"))
1682 {
1683 return Some(AgentError::MissingTool {
1684 tool: tool.to_string(),
1685 });
1686 }
1687 }
1688 None
1689 }
1690
1691 pub(crate) fn detect_permission_denied(text: &str) -> Option<AgentError> {
1693 let lower = text.to_lowercase();
1694 let patterns = [
1695 "permission denied",
1696 "rejected permission",
1697 "permissionerror",
1698 "operation not permitted",
1699 "sandbox violation",
1700 "access denied",
1701 "eacces",
1702 "eperm",
1703 "read-only file system",
1704 "not writable",
1705 "disallowed tool",
1706 ];
1707 if patterns.iter().any(|p| lower.contains(p)) {
1708 return Some(AgentError::PermissionDenied {
1709 message: safe_tail(text, 300).to_string(),
1710 });
1711 }
1712 None
1713 }
1714
1715 pub(crate) fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1717 let lower = text.to_lowercase();
1718 let patterns = [
1719 "enter passphrase",
1720 "1password",
1721 "op signin",
1722 "ssh passphrase",
1723 "password:",
1724 ];
1725 let auth_required_with_interactive_context = lower.contains("authentication required")
1726 && (lower.contains("ssh")
1727 || lower.contains("passphrase")
1728 || lower.contains("1password")
1729 || lower.contains("op signin"));
1730 if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1731 return Some(AgentError::WaitingForInput {
1732 message: safe_tail(text, 300).to_string(),
1733 });
1734 }
1735 None
1736 }
1737
1738 pub(crate) fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1744 if text.contains(
1747 "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1748 ) {
1749 let message = text
1750 .lines()
1751 .find(|l| l.contains("cannot be modified")).map_or_else(|| safe_tail(text, 300).to_string(), |l| l.trim().to_string());
1752 return Some(AgentError::ThinkingBlockConflict { message });
1753 }
1754 None
1755 }
1756
1757 pub(crate) fn detect_stale_session(text: &str) -> Option<AgentError> {
1763 let needle = "no conversation found with session id";
1769 let lower = text.to_lowercase();
1770 if let Some(lower_pos) = lower.find(needle) {
1771 let char_idx = lower[..lower_pos].chars().count();
1776 let pos = text
1777 .char_indices()
1778 .nth(char_idx)
1779 .map_or(text.len(), |(i, _)| i);
1780 let after = &text[pos..];
1782 let session_id = after
1783 .split_once(':')
1784 .map(|(_, rest)| rest.trim())
1785 .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
1787 .unwrap_or_default();
1788 return Some(AgentError::StaleSession { session_id });
1789 }
1790 None
1791 }
1792
1793 const TIMEOUT_EXIT_CODE: i32 = 124;
1795
1796 const DEFAULT_TIMEOUT_SECS: u64 = 1800;
1798
1799 pub(crate) const RATE_LIMIT_SCAN_TAIL_BYTES: usize = 3000;
1810
1811 pub(crate) const RATE_LIMIT_SCAN_HEAD_BYTES: usize = 3000;
1819
1820 pub(crate) fn classify_from_text(exit_code: i32, text: &str) -> AgentError {
1823 if exit_code == TIMEOUT_EXIT_CODE {
1824 return AgentError::Timeout {
1825 elapsed: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1826 };
1827 }
1828
1829 if exit_code == 0 && text.trim().is_empty() {
1834 return AgentError::Unknown {
1835 exit_code,
1836 message: "empty-output-exit0".to_string(),
1837 };
1838 }
1839
1840 if let Some(e) = detect_missing_tool(text) {
1841 return e;
1842 }
1843 if let Some(e) = detect_worktree_missing(text) {
1844 return e;
1845 }
1846 if let Some(e) = detect_thinking_block_conflict(text) {
1847 return e;
1848 }
1849 let scan_head = safe_head(text, RATE_LIMIT_SCAN_HEAD_BYTES);
1855 if let Some(e) = detect_rate_limit(scan_head) {
1856 return e;
1857 }
1858 let scan_tail = safe_tail(text, RATE_LIMIT_SCAN_TAIL_BYTES);
1865 if let Some(e) = detect_context_overflow(scan_tail) {
1866 return e;
1867 }
1868 if let Some(e) = detect_rate_limit(scan_tail) {
1869 return e;
1870 }
1871 if let Some(e) = detect_model_unavailable(scan_tail) {
1872 return e;
1873 }
1874 if let Some(e) = detect_network_error(scan_tail) {
1875 return e;
1876 }
1877 if let Some(e) = detect_auth_error(scan_tail) {
1878 return e;
1879 }
1880 if let Some(e) = detect_waiting_for_input(scan_tail) {
1881 return e;
1882 }
1883 if let Some(e) = detect_permission_denied(scan_tail) {
1884 return e;
1885 }
1886 if let Some(e) = detect_stale_session(text) {
1887 return e;
1888 }
1889
1890 AgentError::Unknown {
1891 exit_code,
1892 message: safe_tail(text, 300).to_string(),
1893 }
1894 }
1895
1896 pub(crate) fn safe_tail(text: &str, max_bytes: usize) -> &str {
1898 if text.len() <= max_bytes {
1899 return text;
1900 }
1901 let start = text.len() - max_bytes;
1902 let mut idx = start;
1904 while idx < text.len() && !text.is_char_boundary(idx) {
1905 idx += 1;
1906 }
1907 &text[idx..]
1908 }
1909
1910 pub(crate) fn safe_head(text: &str, max_bytes: usize) -> &str {
1912 if text.len() <= max_bytes {
1913 return text;
1914 }
1915 let mut idx = max_bytes;
1916 while idx > 0 && !text.is_char_boundary(idx) {
1918 idx -= 1;
1919 }
1920 &text[..idx]
1921 }
1922
1923 pub(crate) fn detect_ndjson_error(
1930 events: &[serde_json::Value],
1931 extract_message: impl Fn(&serde_json::Value) -> Option<String>,
1932 classify: impl Fn(&str) -> AgentError,
1933 ) -> Option<AgentError> {
1934 let mut best: Option<AgentError> = None;
1935
1936 for event in events {
1937 if let Some(message) = extract_message(event) {
1938 let err = classify(&message);
1939 let upgrade = match &best {
1942 None | Some(AgentError::AgentFailed { .. }) => true,
1943 Some(AgentError::NetworkError { .. }) => !matches!(
1944 err,
1945 AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1946 ),
1947 Some(_) => false,
1948 };
1949 if upgrade {
1950 best = Some(err);
1951 }
1952 }
1953 }
1954
1955 best
1956 }
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961 use super::*;
1962
1963 #[test]
1964 fn get_runner_returns_correct_types() {
1965 assert_eq!(get_runner("claude").name(), "claude");
1966 assert_eq!(get_runner("kimi").name(), "kimi");
1967 assert_eq!(get_runner("minimax").name(), "minimax");
1968 assert_eq!(get_runner("codex").name(), "codex");
1969 assert_eq!(get_runner("opencode").name(), "opencode");
1970 assert_eq!(get_runner("grok").name(), "grok");
1971 assert_eq!(
1972 get_runner("claude").router_transcript(),
1973 RouterTranscript::Messages
1974 );
1975 assert_eq!(
1976 get_runner("kimi").router_transcript(),
1977 RouterTranscript::Messages
1978 );
1979 assert_eq!(
1980 get_runner("minimax").router_transcript(),
1981 RouterTranscript::Messages
1982 );
1983 assert_eq!(
1984 get_runner("grok").router_transcript(),
1985 RouterTranscript::Messages
1986 );
1987 assert_eq!(
1988 get_runner("glm").router_transcript(),
1989 RouterTranscript::Messages
1990 );
1991 assert_eq!(
1992 get_runner("olm").router_transcript(),
1993 RouterTranscript::Messages
1994 );
1995 assert_eq!(
1996 get_runner("opencode").router_transcript(),
1997 RouterTranscript::OpenCode
1998 );
1999 assert_eq!(
2000 get_runner("codex").router_transcript(),
2001 RouterTranscript::Raw
2002 );
2003 assert!(supports_session("grok"));
2004 assert!(!supports_session("codex"));
2005 assert_eq!(get_runner("olm").name(), "olm");
2007 assert_eq!(get_runner("glm").name(), "glm");
2008 assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
2010 }
2011
2012 #[test]
2015 fn get_runner_no_warning_for_claude_compatible_agents() {
2016 use std::sync::{Arc, Mutex};
2017 use tracing::Level;
2018 use tracing_subscriber::EnvFilter;
2019 use tracing_subscriber::util::SubscriberInitExt;
2020
2021 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2022 impl std::io::Write for CaptureWriter {
2023 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2024 self.0.lock().unwrap().extend_from_slice(buf);
2025 Ok(buf.len())
2026 }
2027 fn flush(&mut self) -> std::io::Result<()> {
2028 Ok(())
2029 }
2030 }
2031 impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2032 type Writer = CaptureWriter;
2033 fn make_writer(&'a self) -> Self::Writer {
2034 CaptureWriter(std::sync::Arc::clone(&self.0))
2035 }
2036 }
2037
2038 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
2039 let output_clone = std::sync::Arc::clone(&output);
2040
2041 tracing_subscriber::fmt()
2042 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2043 .with_writer(CaptureWriter(output_clone))
2044 .with_ansi(false)
2045 .finish()
2046 .init();
2047
2048 for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2049 get_runner(agent);
2050 }
2051
2052 let captured = {
2053 let guard = output.lock().unwrap();
2054 String::from_utf8_lossy(&guard).to_string()
2055 };
2056 assert!(
2057 !captured.contains("unknown agent"),
2058 "known claude-compatible agents must not emit 'unknown agent' warnings; got: {captured}"
2059 );
2060 }
2061
2062 #[test]
2063 fn agent_error_display() {
2064 let e = AgentError::RateLimit {
2065 message: "429 Too Many Requests".to_string(),
2066 };
2067 assert!(e.to_string().contains("rate limit"));
2068
2069 let e = AgentError::Timeout {
2070 elapsed: Duration::from_mins(30),
2071 };
2072 assert!(e.to_string().contains("1800"));
2073 }
2074
2075 #[test]
2076 fn pattern_detect_rate_limit() {
2077 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2078 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2079 assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2080 assert!(
2081 patterns::detect_rate_limit(
2082 "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2083 )
2084 .is_some()
2085 );
2086 assert!(patterns::detect_rate_limit(
2087 "Upstream error from Alibaba: Request rate increased too quickly. \
2088 To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
2089 ).is_some());
2090 assert!(patterns::detect_rate_limit("all good").is_none());
2091 }
2092
2093 #[test]
2094 fn classify_from_text_detects_weekly_limit_as_rate_limit() {
2095 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)"}}"#;
2099 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2100 .repeat(200);
2101 let text = format!("{system_event}\n{work_product}");
2102 assert!(
2103 text.len() > 3000,
2104 "test output must exceed tail window to be meaningful"
2105 );
2106 let err = patterns::classify_from_text(1, &text);
2107 assert!(
2108 matches!(err, AgentError::RateLimit { .. }),
2109 "weekly limit as early system event must be detected via head scan, got: {err:?}"
2110 );
2111 let msg = err.to_string();
2112 assert!(
2113 msg.contains("weekly limit"),
2114 "error message should reference weekly limit, got: {msg}"
2115 );
2116 }
2117
2118 #[test]
2119 fn detect_rate_limit_github_copilot_monthly_quota() {
2120 let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
2124 assert!(
2125 matches!(err, Some(AgentError::RateLimit { .. })),
2126 "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
2127 );
2128 }
2129
2130 #[test]
2131 fn detect_rate_limit_nvidia_resourceexhausted() {
2132 let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
2136 let err = patterns::detect_rate_limit(opencode_error);
2137 assert!(
2138 matches!(err, Some(AgentError::RateLimit { .. })),
2139 "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
2140 );
2141
2142 assert!(
2144 patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
2145 "bare ResourceExhausted must match"
2146 );
2147 assert!(
2148 patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
2149 .is_some(),
2150 "worker local total request limit reached must match"
2151 );
2152
2153 let err = patterns::classify_from_text(1, opencode_error);
2155 assert!(
2156 matches!(err, AgentError::RateLimit { .. }),
2157 "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
2158 );
2159 }
2160
2161 #[test]
2162 fn detect_rate_limit_message_contains_context_not_tail() {
2163 let padding = "x".repeat(5000);
2166 let text = format!(
2167 "{padding}you've reached your usage limit for this billing cycle{padding}\
2168 {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
2169 );
2170 let err = patterns::detect_rate_limit(&text).expect("should detect");
2171 let msg = err.to_string();
2172 assert!(
2173 msg.contains("usage limit"),
2174 "message should contain the actual error, got: {msg}"
2175 );
2176 assert!(
2177 !msg.contains("web_fetch_requests"),
2178 "message should not be the tail JSON, got: {msg}"
2179 );
2180 }
2181
2182 #[test]
2183 fn pattern_detect_rate_limit_529_not_bare() {
2184 assert!(
2187 patterns::detect_rate_limit("error on line 529").is_none(),
2188 "bare line number 529 should not match"
2189 );
2190 assert!(
2191 patterns::detect_rate_limit("file size 529 bytes").is_none(),
2192 "bare file size 529 should not match"
2193 );
2194 assert!(
2195 patterns::detect_rate_limit("port :5290 in config").is_none(),
2196 "port number containing 529 should not match"
2197 );
2198 assert!(
2199 patterns::detect_rate_limit("timeout after 5290ms").is_none(),
2200 "duration containing 529 should not match"
2201 );
2202 assert!(
2204 patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
2205 "HTTP 529 status should match"
2206 );
2207 assert!(
2208 patterns::detect_rate_limit("http 529").is_some(),
2209 "http 529 should match"
2210 );
2211 assert!(
2212 patterns::detect_rate_limit("529 service unavailable").is_some(),
2213 "529 service unavailable should match"
2214 );
2215 assert!(
2216 patterns::detect_rate_limit("status: 529").is_some(),
2217 "status: 529 should match"
2218 );
2219 }
2220
2221 #[test]
2222 fn pattern_detect_auth() {
2223 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
2224 assert!(patterns::detect_auth_error("HTTP 401").is_some());
2225 assert!(patterns::detect_auth_error("error: 401").is_some());
2226 assert!(patterns::detect_auth_error("403 Forbidden").is_some());
2227 assert!(patterns::detect_auth_error("HTTP 403").is_some());
2228 assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
2229 assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
2230 assert!(patterns::detect_auth_error("invalid api key").is_some());
2231 assert!(patterns::detect_auth_error("billing expired").is_some());
2232 assert!(patterns::detect_auth_error("task done").is_none());
2233 assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
2235 assert!(
2236 patterns::detect_auth_error(
2237 "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2238 )
2239 .is_none()
2240 );
2241 assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2243 assert!(
2244 patterns::detect_auth_error(
2245 r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2246 )
2247 .is_none()
2248 );
2249 assert!(patterns::detect_auth_error("status: 401").is_some());
2251 assert!(patterns::detect_auth_error("error: 403").is_some());
2252 }
2253
2254 #[test]
2255 fn pattern_detect_auth_insufficient_balance() {
2256 assert!(
2261 patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2262 "insufficient balance must be detected as auth error"
2263 );
2264 assert!(
2265 patterns::detect_auth_error("insufficient balance").is_some(),
2266 "bare insufficient balance must be detected as auth error"
2267 );
2268 let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2270 .expect("should detect auth error");
2271 let AgentError::Auth { message } = err else {
2272 panic!("expected Auth, got {err:?}");
2273 };
2274 assert!(
2275 message.to_lowercase().contains("insufficient balance"),
2276 "stored message should contain the matched pattern, got: {message}"
2277 );
2278 assert!(
2280 message.lines().count() <= 2,
2281 "message should be the auth line, not the full tail: {}",
2282 message.len()
2283 );
2284 }
2285
2286 #[test]
2287 fn auth_error_message_contains_match_not_tail() {
2288 let mut text = "some output\nunauthorized: your api key has expired\nx".repeat(400);
2292 text.push_str(r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#);
2293 let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2294 let AgentError::Auth { message } = err else {
2295 panic!("expected Auth, got {err:?}");
2296 };
2297 assert!(
2298 message.to_lowercase().contains("unauthorized"),
2299 "stored message should contain the matched pattern, got: {message}"
2300 );
2301 assert!(
2302 !message.contains("outputTokens"),
2303 "stored message must not be the trailing NDJSON, got: {message}"
2304 );
2305 assert!(
2307 message.lines().count() <= 2,
2308 "message should be the auth line, not the full tail: {}",
2309 message.len()
2310 );
2311 }
2312
2313 #[test]
2314 fn pattern_detect_network_error() {
2315 assert!(patterns::detect_network_error("connection refused").is_some());
2316 assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2317 assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2318 assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2319 assert!(patterns::detect_network_error("network unreachable").is_some());
2320 assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2321 assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2322 assert!(patterns::detect_network_error("Streaming response failed").is_some());
2323 assert!(patterns::detect_network_error("Upstream request failed").is_some());
2324 assert!(patterns::detect_network_error("all systems operational").is_none());
2325 }
2326
2327 #[test]
2328 fn network_error_message_contains_match_not_tail() {
2329 let prefix = "connection refused connecting to api.anthropic.com:443 ";
2331 let mut suffix = "x".repeat(2000);
2332 suffix.push_str(r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#);
2333 let text = format!("{prefix}{suffix}");
2334
2335 let err = patterns::detect_network_error(&text).expect("should detect network error");
2336 let AgentError::NetworkError { message } = err else {
2337 panic!("expected NetworkError, got {err:?}");
2338 };
2339 assert!(
2340 message.to_lowercase().contains("connection refused"),
2341 "stored message should contain the matched pattern, got: {message}"
2342 );
2343 assert!(
2344 !message.contains("web_fetch_requests"),
2345 "stored message must not be the unrelated stats tail, got: {message}"
2346 );
2347 }
2348
2349 #[test]
2350 fn pattern_detect_context_overflow() {
2351 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2352 assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2353 assert!(patterns::detect_context_overflow("success").is_none());
2354 }
2355
2356 #[test]
2357 fn pattern_detect_worktree_missing() {
2358 assert!(
2359 patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2360 .is_some()
2361 );
2362 assert!(patterns::detect_worktree_missing("all good").is_none());
2363 }
2364
2365 #[test]
2366 fn pattern_detect_missing_tool() {
2367 assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2368 assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2369 assert!(patterns::detect_missing_tool("everything works").is_none());
2370 }
2371
2372 #[test]
2373 fn pattern_detect_permission_denied() {
2374 assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2375 assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2376 assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2377 assert!(patterns::detect_permission_denied("task completed").is_none());
2378 }
2379
2380 #[test]
2381 fn pattern_detect_waiting_for_input() {
2382 assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2383 assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2384 assert!(
2385 patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2386 .is_some()
2387 );
2388 assert!(
2389 patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2390 );
2391 assert!(patterns::detect_waiting_for_input("done").is_none());
2392 }
2393
2394 #[test]
2395 fn pattern_detect_stale_session() {
2396 let err = patterns::detect_stale_session(
2398 "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2399 );
2400 assert!(err.is_some(), "should detect stale session");
2401 if let Some(AgentError::StaleSession { session_id }) = err {
2402 assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2403 }
2404
2405 let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2407 assert!(err.is_some(), "should detect lowercase variant");
2408
2409 assert!(patterns::detect_stale_session("all good").is_none());
2411 assert!(patterns::detect_stale_session("conversation started").is_none());
2412
2413 let multibyte = "Straße: No conversation found with session ID: dead-beef";
2417 let err = patterns::detect_stale_session(multibyte);
2418 assert!(
2419 err.is_some(),
2420 "should detect stale session after multi-byte chars"
2421 );
2422 if let Some(AgentError::StaleSession { session_id }) = err {
2423 assert_eq!(session_id, "dead-beef");
2424 }
2425 }
2426
2427 #[test]
2428 fn stale_session_display() {
2429 let e = AgentError::StaleSession {
2430 session_id: "abc-123".to_string(),
2431 };
2432 assert!(e.to_string().contains("stale session"));
2433 assert!(e.to_string().contains("abc-123"));
2434 }
2435
2436 #[test]
2437 fn classify_from_text_timeout() {
2438 let err = patterns::classify_from_text(124, "");
2439 assert!(matches!(err, AgentError::Timeout { .. }));
2440 }
2441
2442 #[test]
2443 fn classify_from_text_missing_tool_before_rate_limit() {
2444 let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2446 assert!(matches!(err, AgentError::MissingTool { .. }));
2447 }
2448
2449 #[test]
2450 fn classify_from_text_worktree_missing() {
2451 let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2452 assert!(matches!(err, AgentError::AgentFailed { .. }));
2453 }
2454
2455 #[test]
2456 fn classify_from_text_model_not_found_backtick() {
2457 let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2461 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2462 or you do not have access to it.)";
2463 let err = patterns::classify_from_text(1, text);
2464 match err {
2465 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2466 other => panic!("expected ModelUnavailable, got: {other:?}"),
2467 }
2468 }
2469
2470 #[test]
2471 fn classify_from_text_model_metadata_not_found() {
2472 let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2473 metadata; this can degrade performance and cause issues.";
2474 let err = patterns::classify_from_text(1, text);
2475 match err {
2476 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2477 other => panic!("expected ModelUnavailable, got: {other:?}"),
2478 }
2479 }
2480
2481 #[test]
2482 fn classify_from_text_no_false_positive_from_work_product() {
2483 let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2486 - Added copilot quota patterns to `parse_retry_at`\n\
2487 fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2488 quota exceeded handling for billing cycle\n"
2489 .repeat(50); let actual_error = "\nerror: command not found: cargo\n";
2491 let text = format!("{work_product}{actual_error}");
2492 let err = patterns::classify_from_text(1, &text);
2493 assert!(
2494 !matches!(err, AgentError::RateLimit { .. }),
2495 "work product keywords must not trigger rate limit, got: {err:?}"
2496 );
2497 }
2498
2499 #[test]
2500 fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2501 let padding = "normal agent work output ".repeat(100);
2504 let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2505 let err = patterns::classify_from_text(1, &text);
2506 assert!(
2507 matches!(err, AgentError::RateLimit { .. }),
2508 "real rate limit at tail must be detected, got: {err:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2514 let padding = "normal agent work output ".repeat(250);
2515 let text = format!(
2516 "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2517 );
2518 let err = patterns::classify_from_text(1, &text);
2519 assert!(
2520 !matches!(err, AgentError::RateLimit { .. }),
2521 "markdown table cell must not trigger rate limit, got: {err:?}"
2522 );
2523 }
2524
2525 #[test]
2526 fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2527 let text = "Aflαβrate_limit error: too many requests";
2531 let err = patterns::classify_from_text(1, text);
2532 assert!(
2533 matches!(err, AgentError::RateLimit { .. }),
2534 "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2535 );
2536 }
2537
2538 #[test]
2539 fn classify_from_text_detects_session_limit_as_early_system_event() {
2540 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)"}}"#;
2546 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2549 .repeat(200);
2550 let text = format!("{system_event}\n{work_product}");
2551 assert!(
2553 text.len() > 3000,
2554 "test output must exceed tail window to be meaningful"
2555 );
2556 let err = patterns::classify_from_text(1, &text);
2557 assert!(
2558 matches!(err, AgentError::RateLimit { .. }),
2559 "session limit as early system event must be detected via head scan, got: {err:?}"
2560 );
2561 let msg = err.to_string();
2563 assert!(
2564 msg.contains("session limit"),
2565 "error message should contain the rate limit reason, got: {msg}"
2566 );
2567 }
2568
2569 #[test]
2570 fn classify_from_text_no_false_positive_auth_from_work_product() {
2571 let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2575 // handle 403 forbidden errors\n\
2576 // billing checks happen elsewhere\n"
2577 .repeat(80);
2578 let actual_error = "\nerror: command not found: cargo\n";
2579 let text = format!("{work_product}{actual_error}");
2580 let err = patterns::classify_from_text(1, &text);
2581 assert!(
2582 !matches!(err, AgentError::Auth { .. }),
2583 "work product auth keywords must not trigger auth classification, got: {err:?}"
2584 );
2585 assert!(
2586 matches!(err, AgentError::MissingTool { .. }),
2587 "the real tail error should still win, got: {err:?}"
2588 );
2589 }
2590
2591 #[test]
2592 fn classify_from_text_still_detects_real_auth_at_tail() {
2593 let padding = "normal agent work output ".repeat(100);
2594 let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2595 let err = patterns::classify_from_text(1, &text);
2596 assert!(
2597 matches!(err, AgentError::Auth { .. }),
2598 "real auth error at tail must be detected, got: {err:?}"
2599 );
2600 }
2601
2602 #[test]
2603 fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2604 let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2608 // Ensure we don't exceed the token limit per request\n\
2609 fn check_token_limit(tokens: usize) { ... }\n\
2610 if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2611 // Note: too many tokens in the prompt causes context_length_exceeded\n"
2612 .repeat(80);
2613 let actual_error = "\nerror: command not found: cargo\n";
2614 let text = format!("{work_product}{actual_error}");
2615 let err = patterns::classify_from_text(1, &text);
2616 assert!(
2617 !matches!(err, AgentError::ContextOverflow { .. }),
2618 "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2619 );
2620 assert!(
2621 matches!(err, AgentError::MissingTool { .. }),
2622 "the real tail error should still win, got: {err:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2628 let padding = "normal agent work output ".repeat(100);
2629 let text = format!("{padding}\nError: context_length_exceeded\n");
2630 let err = patterns::classify_from_text(1, &text);
2631 assert!(
2632 matches!(err, AgentError::ContextOverflow { .. }),
2633 "real context overflow at tail must be detected, got: {err:?}"
2634 );
2635 }
2636
2637 #[test]
2638 fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2639 let work_product = "match fs::metadata(path) {\n\
2643 Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2644 bail!(\"access denied: {path}\");\n\
2645 }\n\
2646 // Check if directory is writable\n\
2647 if !path.is_writable() { ... }\n\
2648 }\n"
2649 .repeat(80);
2650 let actual_error = "\nerror: command not found: cargo\n";
2651 let text = format!("{work_product}{actual_error}");
2652 let err = patterns::classify_from_text(1, &text);
2653 assert!(
2654 !matches!(err, AgentError::PermissionDenied { .. }),
2655 "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2656 );
2657 assert!(
2658 matches!(err, AgentError::MissingTool { .. }),
2659 "the real tail error should still win, got: {err:?}"
2660 );
2661 }
2662
2663 #[test]
2664 fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2665 let padding = "normal agent work output ".repeat(100);
2666 let text = format!("{padding}\npermission denied: /etc/hosts\n");
2667 let err = patterns::classify_from_text(1, &text);
2668 assert!(
2669 matches!(err, AgentError::PermissionDenied { .. }),
2670 "real permission denied at tail must be detected, got: {err:?}"
2671 );
2672 }
2673
2674 #[test]
2675 fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2676 let work_product = "// Handle password prompts gracefully\n\
2680 if prompt.contains(\"password:\") {\n\
2681 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2682 }\n\
2683 // SSH agent will ask for the deploy key passphrase\n\
2684 fn handle_ssh_passphrase() { ... }\n\
2685 )\n"
2686 .repeat(80);
2687 let actual_error = "\nerror: command not found: cargo\n";
2688 let text = format!("{work_product}{actual_error}");
2689 let err = patterns::classify_from_text(1, &text);
2690 assert!(
2691 !matches!(err, AgentError::WaitingForInput { .. }),
2692 "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2693 );
2694 assert!(
2695 matches!(err, AgentError::MissingTool { .. }),
2696 "the real tail error should still win, got: {err:?}"
2697 );
2698 }
2699
2700 #[test]
2701 fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2702 let padding = "normal agent work output ".repeat(100);
2703 let text = format!("{padding}\nEnter passphrase for key:\n");
2704 let err = patterns::classify_from_text(1, &text);
2705 assert!(
2706 matches!(err, AgentError::WaitingForInput { .. }),
2707 "real waiting for input at tail must be detected, got: {err:?}"
2708 );
2709 }
2710
2711 #[test]
2712 fn classify_from_text_prefers_auth_over_permission_denied() {
2713 let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2714 assert!(
2715 matches!(err, AgentError::Auth { .. }),
2716 "auth errors must outrank permission denied, got: {err:?}"
2717 );
2718 }
2719
2720 #[test]
2721 fn classify_from_text_prefers_auth_over_waiting_for_input() {
2722 let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2723 assert!(
2724 matches!(err, AgentError::Auth { .. }),
2725 "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2726 );
2727 }
2728
2729 #[test]
2730 fn classify_from_text_detects_stale_session() {
2731 let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2736 let err = patterns::classify_from_text(1, text);
2737 assert!(
2738 matches!(err, AgentError::StaleSession { .. }),
2739 "stale session must be detected via classify_from_text, got: {err:?}"
2740 );
2741 assert!(
2743 err.to_string().contains("stale session"),
2744 "error display must contain 'stale session' for recovery code to trigger"
2745 );
2746 }
2747
2748 #[test]
2749 fn classify_from_text_detects_socket_closed() {
2750 let text = "API Error: The socket connection was closed unexpectedly. \
2753 For more information, pass `verbose: true` in the second argument to fetch()";
2754 let err = patterns::classify_from_text(1, text);
2755 assert!(
2756 matches!(err, AgentError::NetworkError { .. }),
2757 "socket closed must be NetworkError, got: {err:?}"
2758 );
2759 }
2760
2761 #[test]
2762 fn classify_from_text_detects_socket_hang_up() {
2763 let err = patterns::classify_from_text(1, "Error: socket hang up");
2764 assert!(
2765 matches!(err, AgentError::NetworkError { .. }),
2766 "socket hang up must be NetworkError, got: {err:?}"
2767 );
2768 }
2769
2770 #[test]
2771 fn classify_from_text_detects_fetch_failed() {
2772 let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2773 assert!(
2774 matches!(err, AgentError::NetworkError { .. }),
2775 "fetch failed must be NetworkError, got: {err:?}"
2776 );
2777 }
2778
2779 #[test]
2780 fn classify_from_text_detects_econnreset() {
2781 let err = patterns::classify_from_text(1, "read ECONNRESET");
2782 assert!(
2783 matches!(err, AgentError::NetworkError { .. }),
2784 "ECONNRESET must be NetworkError, got: {err:?}"
2785 );
2786 }
2787
2788 #[test]
2789 fn classify_from_text_detects_stalled_mid_stream() {
2790 let err = patterns::classify_from_text(
2793 1,
2794 "API Error: Response stalled mid-stream. The response above may be incomplete.",
2795 );
2796 assert!(
2797 matches!(err, AgentError::NetworkError { .. }),
2798 "stalled mid-stream must be NetworkError, got: {err:?}"
2799 );
2800 }
2801
2802 #[test]
2803 fn classify_from_text_detects_connection_closed_mid_response() {
2804 let err = patterns::classify_from_text(
2807 1,
2808 "API Error: Connection closed mid-response. The response above may be incomplete.",
2809 );
2810 assert!(
2811 matches!(err, AgentError::NetworkError { .. }),
2812 "connection closed mid-response must be NetworkError, got: {err:?}"
2813 );
2814 }
2815
2816 #[test]
2817 fn synthesize_response_marks_done_for_plain_text_no_op() {
2818 let response = synthesize_response_from_text(
2819 "No open positions, no trade executions, no conditions change. Full cash, holding.",
2820 )
2821 .unwrap();
2822
2823 assert_eq!(response.status, "done");
2824 assert_eq!(
2825 response.summary,
2826 "No open positions, no trade executions, no conditions change. Full cash, holding."
2827 );
2828 assert!(response.error.is_none());
2829 }
2830
2831 #[test]
2832 fn synthesize_response_marks_needs_review_for_plain_text_error() {
2833 let response =
2834 synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2835
2836 assert_eq!(response.status, "needs_review");
2837 assert_eq!(
2838 response.error.as_deref(),
2839 Some("agent returned plain text instead of JSON")
2840 );
2841 }
2842
2843 #[test]
2844 fn synthesize_response_rejects_empty_text() {
2845 assert!(synthesize_response_from_text(" \n\t ").is_none());
2846 }
2847
2848 #[test]
2854 fn synthesize_response_rejects_ndjson_event_stream() {
2855 let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2856{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2857{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2858 assert!(
2859 synthesize_response_from_text(ndjson).is_none(),
2860 "raw NDJSON must not be interpreted as agent prose"
2861 );
2862
2863 let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2864 assert!(
2865 synthesize_response_from_text(single).is_none(),
2866 "single-line NDJSON event must also be rejected"
2867 );
2868 }
2869
2870 #[test]
2871 fn synthesize_response_accepts_json_without_event_type() {
2872 let text = "The task is complete. Filed issue #1234 successfully.";
2876 let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2877 assert_eq!(response.status, "done");
2878 }
2879
2880 #[test]
2887 fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2888 assert!(
2890 synthesize_response_from_text("Let's go!").is_none(),
2891 "startup greeting must not synthesize as needs_review"
2892 );
2893 assert!(
2895 synthesize_response_from_text(
2896 "I need to find where the stream command is implemented. \
2897 Looking at the CLI structure, it seems like the stream functionality \
2898 might be in the task module or another file. Let me check the CLI \
2899 command structure. First, let me look at the task module since stream \
2900 is related to tasks."
2901 )
2902 .is_none(),
2903 "exploratory prose must not synthesize as needs_review"
2904 );
2905 }
2906
2907 #[test]
2908 fn synthesize_response_does_not_mark_done_for_incomplete() {
2909 let response = synthesize_response_from_text("The task is incomplete").unwrap();
2910 assert_eq!(
2911 response.status, "needs_review",
2912 "\"incomplete\" should not match \"complete\""
2913 );
2914 }
2915
2916 #[test]
2917 fn synthesize_response_does_not_mark_done_for_not_done() {
2918 let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2919 assert_eq!(
2920 response.status, "needs_review",
2921 "\"not done\" should not match bare \"done\""
2922 );
2923 }
2924
2925 #[test]
2926 fn synthesize_response_does_not_mark_done_for_not_complete() {
2927 let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2928 assert_eq!(
2929 response.status, "needs_review",
2930 "\"not complete\" should not match \"complete\""
2931 );
2932 }
2933
2934 #[test]
2935 fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2936 let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2937 assert_eq!(
2938 response.status, "needs_review",
2939 "\"not yet completed\" should not match \"completed\""
2940 );
2941 }
2942
2943 #[test]
2944 fn synthesize_response_marks_done_for_completed() {
2945 let response =
2946 synthesize_response_from_text("Task has been completed successfully").unwrap();
2947 assert_eq!(response.status, "done");
2948 }
2949
2950 #[test]
2951 fn synthesize_response_marks_done_for_word_boundary_done() {
2952 let response = synthesize_response_from_text("All changes are done.").unwrap();
2953 assert_eq!(response.status, "done");
2954 }
2955
2956 #[test]
2957 fn synthesize_response_marks_done_for_issue_creation_text() {
2958 let response =
2959 synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2960 .unwrap();
2961
2962 assert_eq!(response.status, "done");
2963 assert!(response.error.is_none());
2964 assert!(
2965 response
2966 .summary
2967 .contains("Filed 3 high-value GitHub issues")
2968 );
2969 }
2970
2971 #[test]
2972 fn synthesize_response_marks_done_for_committed() {
2973 let response =
2974 synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2975 assert_eq!(response.status, "done");
2976 }
2977
2978 #[test]
2979 fn synthesize_response_rejects_exploratory_prose() {
2980 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.";
2986 match synthesize_response_from_text(exploratory) {
2987 None => {
2988 }
2990 Some(resp) => {
2991 assert_ne!(
2992 resp.status, "done",
2993 "Exploratory prose must not be auto-marked done"
2994 );
2995 }
2996 }
2997 }
2998
2999 #[test]
3000 fn synthesize_response_marks_done_for_commit_created() {
3001 let response = synthesize_response_from_text(
3002 "Commit created locally. The push is being blocked by permissions.",
3003 )
3004 .unwrap();
3005 assert_eq!(response.status, "done");
3006 }
3007
3008 #[test]
3009 fn synthesize_response_does_not_mark_done_for_not_committed() {
3010 let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
3011 assert_eq!(
3012 response.status, "needs_review",
3013 "\"not committed\" should not match \"committed\""
3014 );
3015 }
3016
3017 #[test]
3020 fn synthesize_response_marks_done_for_fix_is_complete() {
3021 let response = synthesize_response_from_text(
3025 "All 73 cooldown tests pass. The fix is complete.\n\n\
3026 **Summary of changes:**\n\n\
3027 1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
3028 `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
3029 )
3030 .unwrap();
3031 assert_eq!(
3032 response.status, "done",
3033 "\"The fix is complete\" must be classified as done, not needs_review"
3034 );
3035 }
3036
3037 #[test]
3038 fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3039 let response = synthesize_response_from_text(
3043 "All tests pass. The fix is clean and working.\n\n\
3044 **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3045 - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3046 - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3047 )
3048 .unwrap();
3049 assert_eq!(
3050 response.status, "done",
3051 "\"All tests pass. The fix is clean and working.\" must be classified as done"
3052 );
3053 }
3054
3055 #[test]
3058 fn parse_ndjson_handles_standard_lines() {
3059 let raw = r#"{"type":"text","text":"hello"}
3060{"type":"text","text":"world"}"#;
3061 let events = parse_ndjson(raw);
3062 assert_eq!(events.len(), 2);
3063 assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3064 assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3065 }
3066
3067 #[test]
3068 fn parse_ndjson_skips_empty_lines() {
3069 let raw = r#"{"type":"text"}
3070
3071{"type":"text"}
3072"#;
3073 let events = parse_ndjson(raw);
3074 assert_eq!(events.len(), 2);
3075 }
3076
3077 #[test]
3078 fn parse_ndjson_skips_malformed_lines() {
3079 let raw = r#"{"type":"valid"}
3080not json at all
3081{"type":"also_valid"}"#;
3082 let events = parse_ndjson(raw);
3083 assert_eq!(events.len(), 2);
3084 }
3085
3086 #[test]
3087 fn parse_ndjson_fallback_extracts_embedded_json() {
3088 let raw =
3090 r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3091 let events = parse_ndjson(raw);
3092 assert!(
3094 !events.is_empty(),
3095 "should extract JSON objects via fallback"
3096 );
3097 }
3098
3099 #[test]
3100 fn parse_ndjson_fallback_handles_fragmented_objects() {
3101 let raw = r#"{"type": "text
3103", "content": "hello
3104world"}"#;
3105 let events = parse_ndjson(raw);
3106 assert!(!events.is_empty(), "should handle fragmented JSON");
3108 }
3109
3110 #[test]
3113 fn permission_rules_default_is_autonomous() {
3114 let rules = PermissionRules::default();
3115 assert!(rules.autonomous);
3116 }
3117
3118 #[test]
3119 fn permission_rules_default_sandbox_is_workspace_write() {
3120 let rules = PermissionRules::default();
3121 assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3122 }
3123
3124 #[test]
3125 fn permission_rules_default_disallows_rm() {
3126 let rules = PermissionRules::default();
3127 assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3128 assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3129 }
3130
3131 #[test]
3132 fn permission_rules_default_no_allowed_edit_paths() {
3133 let rules = PermissionRules::default();
3134 assert!(
3135 rules.allowed_edit_paths.is_empty(),
3136 "{:?}",
3137 rules.allowed_edit_paths
3138 );
3139 }
3140
3141 #[test]
3145 fn all_agents_handle_default_permissions() {
3146 let perms = PermissionRules::default();
3147 let sys = "/tmp/sys.md";
3148 let msg = "/tmp/msg.md";
3149
3150 let claude = get_runner("claude");
3152 let cmd = claude.build_command(None, "", sys, msg, &perms);
3153 assert!(
3154 cmd.contains("--permission-mode bypassPermissions"),
3155 "claude default: expected bypassPermissions, got: {cmd}"
3156 );
3157 assert!(
3158 cmd.contains("Bash(rm *)"),
3159 "claude default: expected rm disallowed, got: {cmd}"
3160 );
3161
3162 let codex = get_runner("codex");
3164 let cmd = codex.build_command(None, "", sys, msg, &perms);
3165 assert!(
3166 cmd.contains("--sandbox workspace-write"),
3167 "codex default: expected --sandbox workspace-write, got: {cmd}"
3168 );
3169 assert!(
3170 cmd.contains(r#"-c 'approval_policy="never"'"#),
3171 "codex default: expected -c approval_policy=never, got: {cmd}"
3172 );
3173 assert!(
3174 !cmd.contains("--ask-for-approval"),
3175 "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3176 );
3177 assert!(
3178 !cmd.contains("--full-auto"),
3179 "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3180 );
3181
3182 let opencode = get_runner("opencode");
3185 let cmd = opencode.build_command(None, "", sys, msg, &perms);
3186 assert!(
3187 cmd.contains("--dangerously-skip-permissions"),
3188 "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3189 );
3190 assert!(
3191 !cmd.contains("opencode.json"),
3192 "opencode default: should not write a config file, got: {cmd}"
3193 );
3194 assert!(
3195 !cmd.contains("XDG_CONFIG_HOME"),
3196 "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3197 );
3198 assert!(
3199 !cmd.contains(".orch-opencode"),
3200 "opencode default: should not reference .orch-opencode, got: {cmd}"
3201 );
3202 }
3203
3204 #[test]
3206 fn all_agents_handle_supervised_mode() {
3207 let perms = PermissionRules {
3208 autonomous: false,
3209 sandbox: SandboxLevel::WorkspaceWrite,
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("--permission-mode acceptEdits"),
3224 "claude supervised: expected acceptEdits, 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(r#"-c 'approval_policy="on-request"'"#),
3232 "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3233 );
3234 assert!(
3235 !cmd.contains("--ask-for-approval"),
3236 "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3237 );
3238 }
3239
3240 #[test]
3242 fn all_agents_handle_full_access_sandbox() {
3243 let perms = PermissionRules {
3244 autonomous: true,
3245 sandbox: SandboxLevel::FullAccess,
3246 disallowed_tools: vec![],
3247 allowed_tools: vec![],
3248 allowed_edit_paths: vec![],
3249 deny_read_only: false,
3250 extra_writable_dirs: vec![],
3251 };
3252 let sys = "/tmp/sys.md";
3253 let msg = "/tmp/msg.md";
3254
3255 let claude = get_runner("claude");
3257 let cmd = claude.build_command(None, "", sys, msg, &perms);
3258 assert!(
3259 !cmd.contains("--sandbox"),
3260 "claude full_access: should have no sandbox flag, got: {cmd}"
3261 );
3262
3263 let codex = get_runner("codex");
3265 let cmd = codex.build_command(None, "", sys, msg, &perms);
3266 assert!(
3267 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3268 "codex full_access: expected dangerously-bypass, got: {cmd}"
3269 );
3270 }
3271
3272 #[test]
3274 fn codex_sandbox_none_uses_workspace_write() {
3275 let perms = PermissionRules {
3276 autonomous: true,
3277 sandbox: SandboxLevel::None,
3278 disallowed_tools: vec![],
3279 allowed_tools: vec![],
3280 allowed_edit_paths: vec![],
3281 deny_read_only: false,
3282 extra_writable_dirs: vec![],
3283 };
3284 let codex = get_runner("codex");
3285 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3286 assert!(
3287 cmd.contains("--sandbox workspace-write"),
3288 "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3289 );
3290 assert!(
3291 cmd.contains(r#"-c 'approval_policy="never"'"#),
3292 "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3293 );
3294 assert!(
3295 !cmd.contains("--ask-for-approval"),
3296 "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3297 );
3298 assert!(
3299 !cmd.contains("--full-auto"),
3300 "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3301 );
3302 }
3303
3304 #[test]
3306 fn claude_allowed_edit_paths_scope_edit_write() {
3307 let perms = PermissionRules {
3308 autonomous: true,
3309 sandbox: SandboxLevel::WorkspaceWrite,
3310 disallowed_tools: vec![],
3311 allowed_tools: vec![
3312 "Edit".to_string(),
3313 "Write".to_string(),
3314 "Read".to_string(),
3315 "Bash".to_string(),
3316 ],
3317 allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3318 deny_read_only: false,
3319 extra_writable_dirs: vec![],
3320 };
3321 let claude = get_runner("claude");
3322 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3323
3324 assert!(
3326 cmd.contains("Edit(/home/user/worktree/*)"),
3327 "missing scoped Edit, got: {cmd}"
3328 );
3329 assert!(
3330 cmd.contains("Write(/home/user/worktree/*)"),
3331 "missing scoped Write, got: {cmd}"
3332 );
3333 assert!(cmd.contains("Read"), "missing Read");
3335 }
3336
3337 #[test]
3339 fn codex_ignores_allowed_edit_paths() {
3340 let perms = PermissionRules {
3341 autonomous: true,
3342 sandbox: SandboxLevel::WorkspaceWrite,
3343 disallowed_tools: vec![],
3344 allowed_tools: vec![],
3345 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3346 deny_read_only: false,
3347 extra_writable_dirs: vec![],
3348 };
3349 let codex = get_runner("codex");
3350 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3351 assert!(
3352 !cmd.contains("/home/user/project"),
3353 "codex: should not reference blocked paths, got: {cmd}"
3354 );
3355 }
3356
3357 #[test]
3359 fn opencode_ignores_allowed_edit_paths() {
3360 let perms = PermissionRules {
3361 autonomous: true,
3362 sandbox: SandboxLevel::WorkspaceWrite,
3363 disallowed_tools: vec![],
3364 allowed_tools: vec![],
3365 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3366 deny_read_only: false,
3367 extra_writable_dirs: vec![],
3368 };
3369 let opencode = get_runner("opencode");
3370 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3371 assert!(
3372 !cmd.contains("/home/user/project"),
3373 "opencode: should not reference blocked paths, got: {cmd}"
3374 );
3375 }
3376
3377 #[test]
3379 fn claude_merges_disallowed_tools() {
3380 let perms = PermissionRules {
3381 autonomous: true,
3382 sandbox: SandboxLevel::WorkspaceWrite,
3383 disallowed_tools: vec![
3384 "Bash(rm *)".to_string(),
3385 "Bash(sudo *)".to_string(),
3386 "WebFetch".to_string(),
3387 ],
3388 allowed_tools: vec![],
3389 allowed_edit_paths: vec![],
3390 deny_read_only: false,
3391 extra_writable_dirs: vec![],
3392 };
3393 let claude = get_runner("claude");
3394 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3395 assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3396 assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3397 assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3398 }
3399
3400 #[test]
3402 fn codex_ignores_disallowed_tools() {
3403 let perms = PermissionRules {
3404 autonomous: true,
3405 sandbox: SandboxLevel::WorkspaceWrite,
3406 disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3407 allowed_tools: vec![],
3408 allowed_edit_paths: vec![],
3409 deny_read_only: false,
3410 extra_writable_dirs: vec![],
3411 };
3412 let codex = get_runner("codex");
3413 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3414 assert!(
3415 !cmd.contains("disallow"),
3416 "codex: should not have disallow flags, got: {cmd}"
3417 );
3418 }
3419
3420 #[test]
3422 fn claude_no_disallowed_flag_when_empty() {
3423 let perms = PermissionRules {
3424 autonomous: true,
3425 sandbox: SandboxLevel::WorkspaceWrite,
3426 disallowed_tools: vec![],
3427 allowed_tools: vec![],
3428 allowed_edit_paths: vec![],
3429 deny_read_only: false,
3430 extra_writable_dirs: vec![],
3431 };
3432 let claude = get_runner("claude");
3433 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3434 assert!(
3435 !cmd.contains("--disallowedTools"),
3436 "claude: should have no disallowed flag when empty, got: {cmd}"
3437 );
3438 }
3439
3440 #[test]
3442 fn kimi_minimax_use_claude_permissions() {
3443 let perms = PermissionRules {
3444 autonomous: false,
3445 sandbox: SandboxLevel::WorkspaceWrite,
3446 disallowed_tools: vec!["Bash(rm *)".to_string()],
3447 allowed_tools: vec![],
3448 allowed_edit_paths: vec![],
3449 deny_read_only: false,
3450 extra_writable_dirs: vec![],
3451 };
3452 let sys = "/tmp/sys.md";
3453 let msg = "/tmp/msg.md";
3454
3455 for agent in &["kimi", "minimax"] {
3456 let runner = get_runner(agent);
3457 let cmd = runner.build_command(None, "", sys, msg, &perms);
3458 assert!(
3459 cmd.contains("--permission-mode acceptEdits"),
3460 "{agent}: expected acceptEdits"
3461 );
3462 assert!(
3463 cmd.contains("Bash(rm *)"),
3464 "{agent}: expected rm disallowed"
3465 );
3466 }
3467 }
3468
3469 #[test]
3471 fn kimi_minimax_use_allowed_tools() {
3472 let perms = PermissionRules {
3473 autonomous: true,
3474 sandbox: SandboxLevel::WorkspaceWrite,
3475 disallowed_tools: vec![],
3476 allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3477 allowed_edit_paths: vec![],
3478 deny_read_only: false,
3479 extra_writable_dirs: vec![],
3480 };
3481
3482 for agent in &["kimi", "minimax"] {
3483 let runner = get_runner(agent);
3484 let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3485 assert!(
3486 cmd.contains("--allowedTools"),
3487 "{agent}: expected --allowedTools, got: {cmd}"
3488 );
3489 assert!(
3490 cmd.contains("Bash(git *)"),
3491 "{agent}: expected Bash(git *) pattern, got: {cmd}"
3492 );
3493 }
3494 }
3495
3496 #[test]
3500 fn claude_allowed_tools_generates_allowedtools_flag() {
3501 let perms = PermissionRules {
3502 autonomous: true,
3503 sandbox: SandboxLevel::WorkspaceWrite,
3504 disallowed_tools: vec!["Bash(rm *)".to_string()],
3505 allowed_tools: vec![
3506 "Edit".to_string(),
3507 "Write".to_string(),
3508 "Read".to_string(),
3509 "Bash".to_string(),
3510 "git".to_string(),
3511 ],
3512 allowed_edit_paths: vec![],
3513 deny_read_only: false,
3514 extra_writable_dirs: vec![],
3515 };
3516
3517 let claude = get_runner("claude");
3518 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3519 assert!(
3520 cmd.contains("--allowedTools"),
3521 "expected --allowedTools, got: {cmd}"
3522 );
3523 assert!(
3524 !cmd.contains("--disallowedTools"),
3525 "should not have --disallowedTools when allowed_tools is set"
3526 );
3527 }
3528
3529 #[test]
3531 fn codex_opencode_ignore_allowed_tools() {
3532 let perms = PermissionRules {
3533 autonomous: true,
3534 sandbox: SandboxLevel::WorkspaceWrite,
3535 disallowed_tools: vec![],
3536 allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3537 allowed_edit_paths: vec![],
3538 deny_read_only: false,
3539 extra_writable_dirs: vec![],
3540 };
3541
3542 let codex = get_runner("codex");
3543 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3544 assert!(
3545 !cmd.contains("--allowedTools"),
3546 "codex should not have --allowedTools, got: {cmd}"
3547 );
3548
3549 let opencode = get_runner("opencode");
3550 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3551 assert!(
3552 !cmd.contains("--allowedTools"),
3553 "opencode should not have --allowedTools, got: {cmd}"
3554 );
3555 }
3556
3557 #[test]
3562 #[ignore = "reads the operator config"]
3563 fn integration_from_config_to_agent_commands() {
3564 let perms = PermissionRules::from_config();
3565
3566 assert!(
3568 perms.autonomous,
3569 "from_config: expected autonomous=true by default"
3570 );
3571
3572 for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3574 let runner = get_runner(agent_name);
3575 let cmd =
3576 runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3577 assert!(
3578 !cmd.is_empty(),
3579 "{agent_name}: build_command returned empty string"
3580 );
3581 assert!(
3582 cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3583 "{agent_name}: command should reference agent binary, got: {cmd}"
3584 );
3585 }
3586 }
3587
3588 #[test]
3591 #[ignore = "reads the operator config"]
3592 fn integration_supervised_config_translates_correctly() {
3593 let perms = PermissionRules::from_config();
3598
3599 let claude = get_runner("claude");
3600 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3601
3602 if perms.autonomous {
3603 assert!(
3604 cmd.contains("bypassPermissions"),
3605 "autonomous config → bypassPermissions"
3606 );
3607 } else {
3608 assert!(
3609 cmd.contains("acceptEdits"),
3610 "supervised config → acceptEdits"
3611 );
3612 }
3613
3614 let codex = get_runner("codex");
3615 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3616
3617 if perms.autonomous {
3618 assert!(
3620 cmd.contains("--sandbox workspace-write"),
3621 "autonomous config → --sandbox workspace-write, got: {cmd}"
3622 );
3623 assert!(
3624 cmd.contains(r#"-c 'approval_policy="never"'"#),
3625 "autonomous config → -c approval_policy=never, got: {cmd}"
3626 );
3627 } else {
3628 assert!(
3629 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3630 "supervised config → -c approval_policy=on-request, got: {cmd}"
3631 );
3632 }
3633 assert!(
3634 !cmd.contains("--ask-for-approval"),
3635 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3636 );
3637 }
3638
3639 #[test]
3643 fn deny_read_only_blocks_reading_tools() {
3644 let perms = PermissionRules::deny_read_only();
3645
3646 let read_tools = [
3647 "Bash(ls *)",
3648 "Bash(ls)",
3649 "Bash(find *)",
3650 "Bash(cat *)",
3651 "Bash(head *)",
3652 "Bash(tail *)",
3653 "Bash(fzf *)",
3654 "Bash(less *)",
3655 "Bash(more *)",
3656 "Read",
3657 "Glob",
3658 "Grep",
3659 ];
3660
3661 for tool in read_tools {
3662 assert!(
3663 perms.disallowed_tools.contains(&tool.to_string()),
3664 "deny_read_only should block {tool}, got: {:?}",
3665 perms.disallowed_tools
3666 );
3667 }
3668 }
3669
3670 #[test]
3672 fn deny_read_only_claude_command() {
3673 let perms = PermissionRules::deny_read_only();
3674 let claude = get_runner("claude");
3675 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3676
3677 assert!(
3679 cmd.contains("Read"),
3680 "should block Read for control session"
3681 );
3682 assert!(
3683 cmd.contains("Glob"),
3684 "should block Glob for control session"
3685 );
3686 assert!(
3687 cmd.contains("Grep"),
3688 "should block Grep for control session"
3689 );
3690 assert!(
3692 cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3693 "should block ls"
3694 );
3695 assert!(
3697 cmd.contains("--permission-mode bypassPermissions"),
3698 "should be autonomous (bypassPermissions)"
3699 );
3700 }
3701
3702 #[test]
3705 fn synthesize_extracts_file_paths() {
3706 let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3707 let resp = synthesize_response_from_text(text).unwrap();
3708 assert!(
3709 resp.files
3710 .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3711 "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3712 resp.files
3713 );
3714 assert!(
3715 resp.files.contains(&"tests/integration.rs".to_string()),
3716 "expected tests/integration.rs in files, got: {:?}",
3717 resp.files
3718 );
3719 }
3720
3721 #[test]
3722 fn synthesize_extracts_accomplished_bullets() {
3723 let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3724 let resp = synthesize_response_from_text(text).unwrap();
3725 assert_eq!(resp.status, "done");
3726 assert!(
3727 resp.accomplished
3728 .iter()
3729 .any(|a| a.contains("Added logging")),
3730 "expected accomplished bullet, got: {:?}",
3731 resp.accomplished
3732 );
3733 assert!(
3734 resp.accomplished
3735 .iter()
3736 .any(|a| a.contains("Updated migration")),
3737 "expected accomplished bullet, got: {:?}",
3738 resp.accomplished
3739 );
3740 }
3741
3742 #[test]
3743 fn synthesize_extracts_remaining_section() {
3744 let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3745 let resp = synthesize_response_from_text(text).unwrap();
3746 assert!(
3747 resp.remaining
3748 .iter()
3749 .any(|r| r.contains("Write unit tests")),
3750 "expected remaining item, got: {:?}",
3751 resp.remaining
3752 );
3753 assert!(
3754 resp.remaining.iter().any(|r| r.contains("Update docs")),
3755 "expected remaining item, got: {:?}",
3756 resp.remaining
3757 );
3758 assert!(
3759 resp.accomplished.iter().any(|a| a.contains("Patched")),
3760 "expected accomplished item, got: {:?}",
3761 resp.accomplished
3762 );
3763 }
3764
3765 #[test]
3766 fn synthesize_fixed_phrase_is_done() {
3767 let text = "Fixed. The bug no longer reproduces.";
3768 let resp = synthesize_response_from_text(text).unwrap();
3769 assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3770 }
3771
3772 #[test]
3779 fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3780 let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3784 assert!(
3785 patterns::detect_rate_limit(payload).is_some(),
3786 "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3787 );
3788 }
3789
3790 #[test]
3791 fn detect_rate_limit_ignores_429_inside_longer_number() {
3792 let uuid = r#"{"type":"hook","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b"}"#;
3793 assert!(
3794 patterns::detect_rate_limit(uuid).is_none(),
3795 "429 inside a UUID digit run must not match"
3796 );
3797 }
3798
3799 #[test]
3800 fn detect_rate_limit_ignores_429_in_uuid_hex_group() {
3801 let hook = r#"{"type":"system","subtype":"hook_response","hook_id":"18db61b6-7b72-429c-8279-fae3089bf565"}"#;
3802 assert!(patterns::detect_rate_limit(hook).is_none());
3803 }
3804
3805 #[test]
3806 fn detect_rate_limit_429_message_comes_from_matched_line() {
3807 let filler = "unrelated agent prose that is long enough to fill the window. ".repeat(20);
3808 let text = format!("HTTP 429 Too Many Requests\n{filler}");
3809 match patterns::detect_rate_limit(&text) {
3810 Some(AgentError::RateLimit { message }) => assert!(message.contains("429")),
3811 other => panic!("expected RateLimit, got {other:?}"),
3812 }
3813 }
3814
3815 #[test]
3819 fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3820 let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3821 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3822 && !raw_output.contains("\"is_error\":true");
3823 let detected = (!ndjson_completed)
3825 .then(|| patterns::detect_rate_limit(raw_output))
3826 .flatten();
3827 assert!(
3828 detected.is_none(),
3829 "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3830 );
3831 }
3832
3833 #[test]
3835 fn terminal_reason_completed_guard_passes_real_rate_limit() {
3836 let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3838 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3839 && !raw_output.contains("\"is_error\":true");
3840 let detected = (!ndjson_completed)
3841 .then(|| patterns::detect_rate_limit(raw_output))
3842 .flatten();
3843 assert!(
3844 detected.is_some(),
3845 "guard must NOT suppress a real rate-limit error"
3846 );
3847 }
3848
3849 #[test]
3850 fn synthesize_no_false_positive_file_paths() {
3851 let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3855 match synthesize_response_from_text(text) {
3856 None => {} Some(resp) => {
3858 assert!(
3859 resp.files.is_empty(),
3860 "expected no file paths extracted from URL text, got: {:?}",
3861 resp.files
3862 );
3863 }
3864 }
3865 }
3866
3867 #[test]
3872 fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3873 let text = "error: unexpected argument '--ask-for-approval' found\n \
3875 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3876 Usage: codex exec [OPTIONS] [PROMPT]";
3877 assert!(
3878 synthesize_response_from_text(text).is_none(),
3879 "CLI parser diagnostic must not synthesize as agent response"
3880 );
3881 }
3882
3883 #[test]
3884 fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3885 let text = "Usage: mycommand [OPTIONS] <arg>";
3886 assert!(
3887 synthesize_response_from_text(text).is_none(),
3888 "Usage line must not synthesize as agent response"
3889 );
3890 }
3891
3892 #[test]
3893 fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3894 let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3895 assert!(
3896 synthesize_response_from_text(text).is_none(),
3897 "Help hint must not synthesize as agent response"
3898 );
3899 }
3900
3901 #[test]
3902 fn synthesize_rejects_cli_parser_diagnostics_tip() {
3903 let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3904 assert!(
3905 synthesize_response_from_text(text).is_none(),
3906 "Clap tip must not synthesize as agent response"
3907 );
3908 }
3909
3910 #[test]
3911 fn synthesize_accepts_agent_error_messages_that_mention_error() {
3912 let text = "I tried to run the command but got an error: permission denied on that file.";
3915 let response =
3916 synthesize_response_from_text(text).expect("agent error message should synthesize");
3917 assert_eq!(response.status, "needs_review");
3918 assert!(response.error.is_some());
3919 }
3920
3921 #[test]
3926 fn synthesize_rejects_exact_codex_cli_error() {
3927 let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n \
3928 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3929 Usage: codex exec [OPTIONS] [PROMPT]";
3930 assert!(
3931 synthesize_response_from_text(stderr_from_codex).is_none(),
3932 "exact codex CLI error from #3197 must not synthesize as agent response"
3933 );
3934 }
3935}