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