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