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 Interrupted { message: String },
234 MissingTool { tool: String },
236 PermissionDenied { message: String },
238 WaitingForInput { message: String },
240 StaleSession { session_id: String },
245 InvalidResponse { raw: String },
247 AgentFailed { message: String },
249 NetworkError { message: String },
251 ThinkingBlockConflict { message: String },
258 Unknown { exit_code: i32, message: String },
260}
261
262impl std::fmt::Display for AgentError {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 match self {
265 Self::RateLimit { message, .. } => write!(f, "rate limit: {message}"),
266 Self::Auth { message } => write!(f, "auth error: {message}"),
267 Self::ModelUnavailable { message, model } => {
268 write!(f, "model unavailable ({model}): {message}")
269 }
270 Self::ContextOverflow { message, .. } => write!(f, "context overflow: {message}"),
271 Self::Timeout { elapsed } => write!(f, "timeout after {}s", elapsed.as_secs()),
272 Self::Interrupted { message } => write!(f, "interrupted: {message}"),
273 Self::MissingTool { tool } => write!(f, "missing tool: {tool}"),
274 Self::PermissionDenied { message } => write!(f, "permission denied: {message}"),
275 Self::WaitingForInput { message } => write!(f, "waiting for input: {message}"),
276 Self::StaleSession { session_id } => {
277 write!(f, "stale session (session expired): {session_id}")
278 }
279 Self::InvalidResponse { raw } => {
280 let end = truncate_at_char_boundary(raw, 200);
281 write!(f, "invalid response: {}", &raw[..end])
282 }
283 Self::AgentFailed { message } => write!(f, "agent failed: {message}"),
284 Self::NetworkError { message } => write!(f, "network error: {message}"),
285 Self::ThinkingBlockConflict { message } => {
286 write!(f, "thinking block conflict: {message}")
287 }
288 Self::Unknown { exit_code, message } => {
289 write!(f, "unknown error (exit {exit_code}): {message}")
290 }
291 }
292 }
293}
294
295impl std::error::Error for AgentError {}
296
297pub fn error_class_name(err: &AgentError) -> &'static str {
300 match err {
301 AgentError::RateLimit { .. } => "RateLimit",
302 AgentError::Auth { .. } => "Auth",
303 AgentError::ModelUnavailable { .. } => "ModelUnavailable",
304 AgentError::ContextOverflow { .. } => "ContextOverflow",
305 AgentError::Timeout { .. } => "Timeout",
306 AgentError::Interrupted { .. } => "Interrupted",
307 AgentError::MissingTool { .. } => "MissingTool",
308 AgentError::PermissionDenied { .. } => "PermissionDenied",
309 AgentError::WaitingForInput { .. } => "WaitingForInput",
310 AgentError::StaleSession { .. } => "StaleSession",
311 AgentError::InvalidResponse { .. } => "InvalidResponse",
312 AgentError::AgentFailed { .. } => "AgentFailed",
313 AgentError::NetworkError { .. } => "NetworkError",
314 AgentError::ThinkingBlockConflict { .. } => "ThinkingBlockConflict",
315 AgentError::Unknown { .. } => "Unknown",
316 }
317}
318
319fn extract_file_paths(text: &str) -> Vec<String> {
324 let extensions = [
327 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh",
328 "md", "toml", "yaml", "yml", "json", "sql", "html", "css", "swift", "kt",
329 ];
330 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
331 for token in text.split_whitespace() {
332 let token = token.trim_matches(|c: char| {
335 matches!(
336 c,
337 '`' | '\'' | '"' | '(' | ')' | '[' | ']' | ',' | ':' | ';'
338 )
339 });
340 let token = if let Some(without) = token.strip_suffix('.') {
344 let still_has_ext = extensions
345 .iter()
346 .any(|ext| without.ends_with(&format!(".{ext}")));
347 if still_has_ext {
348 without
349 } else {
350 token
351 }
352 } else {
353 token
354 };
355 let has_ext = extensions
358 .iter()
359 .any(|ext| token.ends_with(&format!(".{ext}")));
360 if !has_ext {
361 continue;
362 }
363 let path_like = token.contains('/')
366 || token.starts_with("src")
367 || token.starts_with("tests")
368 || token.starts_with("lib")
369 || token.starts_with("bin")
370 || token.starts_with("prompts")
371 || token.starts_with("migrations");
372 if path_like && token.len() >= 4 && !token.contains("://") {
373 found.insert(token.to_string());
374 }
375 }
376 found.into_iter().collect()
377}
378
379fn extract_bullet_sections(text: &str) -> (Vec<String>, Vec<String>) {
384 let remaining_headers = [
385 "remaining",
386 "todo",
387 "to do",
388 "next steps",
389 "still needed",
390 "left to do",
391 "not done",
392 "outstanding",
393 ];
394
395 let mut accomplished: Vec<String> = vec![];
396 let mut remaining: Vec<String> = vec![];
397 let mut in_remaining_section = false;
398
399 for line in text.lines() {
400 let trimmed = line.trim();
401 if trimmed.is_empty() {
402 continue;
403 }
404 let lower = trimmed.to_lowercase();
405
406 let is_heading = trimmed.ends_with(':') || trimmed.starts_with('#');
411
412 if is_heading {
413 in_remaining_section = remaining_headers.iter().any(|h| lower.contains(h));
414 continue;
415 }
416
417 let is_bullet = trimmed.starts_with('-')
419 || trimmed.starts_with('*')
420 || trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains('.');
421
422 if !is_bullet {
423 continue;
424 }
425
426 let content = trimmed
428 .trim_start_matches(|c: char| c == '-' || c == '*' || c.is_ascii_digit() || c == '.')
429 .trim()
430 .to_string();
431
432 if content.is_empty() {
433 continue;
434 }
435
436 if in_remaining_section {
437 remaining.push(content);
438 } else {
439 accomplished.push(content);
440 }
441 }
442
443 (accomplished, remaining)
444}
445
446fn is_cli_parser_diagnostic(text: &str) -> bool {
449 let lower = text.to_lowercase();
450
451 if lower.contains("error: unexpected argument") {
453 return true;
454 }
455
456 if text.lines().any(|line| {
458 let trimmed = line.trim();
459 trimmed.starts_with("Usage:") || trimmed.starts_with("usage:")
460 }) {
461 return true;
462 }
463
464 if lower.contains("for more information, try '--help'") {
466 return true;
467 }
468
469 if lower.contains("tip: to pass '") {
471 return true;
472 }
473
474 false
475}
476
477fn looks_like_ndjson_event_stream(text: &str) -> bool {
485 let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
486 return false;
487 };
488
489 if !first_line.starts_with('{') {
490 return false;
491 }
492
493 let Ok(value) = serde_json::from_str::<serde_json::Value>(first_line) else {
494 return false;
495 };
496
497 let Some(event_type) = value.get("type").and_then(|v| v.as_str()) else {
498 return false;
499 };
500
501 matches!(
502 event_type,
503 "step_start"
504 | "step_finish"
505 | "tool_use"
506 | "tool_result"
507 | "assistant"
508 | "system"
509 | "message"
510 | "text"
511 )
512}
513
514pub fn synthesize_response_from_text(text: &str) -> Option<AgentResponse> {
519 let trimmed = text.trim();
520 if trimmed.is_empty() {
521 return None;
522 }
523
524 if is_cli_parser_diagnostic(trimmed) {
532 return None;
533 }
534
535 if looks_like_ndjson_event_stream(trimmed) {
541 return None;
542 }
543
544 let lower = trimmed.to_lowercase();
545 fn contains_word(text: &str, word: &str) -> bool {
552 let mut start = 0;
553 while let Some(pos) = text[start..].find(word) {
554 let abs = start + pos;
555 let before_ok = abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
556 let after_ok = abs + word.len() >= text.len()
557 || !text.as_bytes()[abs + word.len()].is_ascii_alphanumeric();
558 if before_ok && after_ok {
559 return true;
560 }
561 start = abs + 1;
562 if start >= text.len() {
563 break;
564 }
565 }
566 false
567 }
568
569 let looks_negative = [
571 "not done",
572 "not yet done",
573 "not complete",
574 "not yet complete",
575 "not completed",
576 "not yet completed",
577 "not committed",
578 "incomplete",
579 "undone",
580 ]
581 .iter()
582 .any(|needle| lower.contains(needle));
583
584 let explicit_done = [
587 "nothing to",
588 "nothing to do",
589 "nothing to execute",
590 "no positions",
591 "no open positions",
592 "no trades",
593 "no trade",
594 "no action needed",
595 "no changes to commit",
596 "completed",
597 "filed",
599 "filed issue",
600 "filed issues",
601 "created issue",
602 "created issues",
603 "opened issue",
604 "opened issues",
605 "issue created",
606 "issues created",
607 "issues filed",
608 "task created",
609 "posted comment",
610 "comment posted",
611 "changes committed",
612 "commit created",
613 "fixed.",
614 "has been fixed",
615 "the fix has been applied",
616 "the fix has already been applied",
617 "the fix is complete",
619 "fix is working",
620 "all tests pass",
621 "tests pass.",
622 ]
623 .iter()
624 .any(|needle| lower.contains(needle));
625
626 let mut done_confident = false;
632 for sentence in trimmed
633 .split(['.', '!', '?', '\n'])
634 .map(|s| s.trim())
635 .filter(|s| !s.is_empty())
636 {
637 let s_lower = sentence.to_lowercase();
638 let word_count = sentence.split_whitespace().count();
640 let hedging = [
641 "let",
642 "let me",
643 "check",
644 "checking",
645 "will",
646 "i'll",
647 "i will",
648 "trying",
649 "try",
650 "investigat",
651 "maybe",
652 "could",
653 "should",
654 "might",
655 "if ",
656 "plan",
657 "planning",
658 ];
659 let contains_hedge = hedging.iter().any(|h| s_lower.contains(h));
660
661 if contains_word(&s_lower, "done") && word_count <= 8 && !contains_hedge {
662 done_confident = true;
663 break;
664 }
665
666 if s_lower.contains("committed") || s_lower.contains("commit created") {
670 if s_lower.contains("changes")
672 || s_lower.contains("i've")
673 || s_lower.contains("i have")
674 || s_lower.contains("the commit")
675 {
676 done_confident = true;
677 break;
678 }
679 }
680
681 let past_tense_actions = [
686 "added ",
687 "fixed ",
688 "updated ",
689 "implemented ",
690 "created ",
691 "removed ",
692 "changed ",
693 "deleted ",
694 "applied ",
695 "resolved ",
696 "patched ",
697 "refactored ",
698 "merged ",
699 "pushed ",
700 "deployed ",
701 ];
702 if !contains_hedge
703 && word_count <= 12
704 && past_tense_actions.iter().any(|v| s_lower.starts_with(v))
705 {
706 done_confident = true;
707 break;
708 }
709 }
710
711 let looks_done = !looks_negative && (explicit_done || done_confident);
712
713 let looks_error = [
714 "error:",
715 "failed to",
716 "cannot",
717 "permission denied",
718 "unable to",
719 ]
720 .iter()
721 .any(|needle| lower.contains(needle));
722
723 let status = if looks_error {
724 "needs_review"
725 } else if looks_done {
726 "done"
727 } else if looks_negative {
728 "needs_review"
730 } else {
731 return None;
737 };
738
739 let summary_end = truncate_at_char_boundary(trimmed, 500);
740 let summary = trimmed[..summary_end].to_string();
741 let error =
742 (status == "needs_review").then(|| "agent returned plain text instead of JSON".to_string());
743
744 let files = extract_file_paths(trimmed);
746 let (accomplished, remaining) = extract_bullet_sections(trimmed);
747
748 Some(AgentResponse {
749 status: status.to_string(),
750 summary,
751 accomplished,
752 remaining,
753 files,
754 error,
755 input_tokens: None,
756 output_tokens: None,
757 learnings: vec![],
758 delegations: vec![],
759 })
760}
761
762pub(crate) fn shell_single_quote(value: &str) -> String {
764 format!("'{}'", value.replace('\'', "'\\''"))
765}
766
767pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
770 if max_bytes >= s.len() {
771 return s.len();
772 }
773 let mut end = max_bytes;
775 while end > 0 && !s.is_char_boundary(end) {
776 end -= 1;
777 }
778 end
779}
780
781#[derive(Debug, Clone, PartialEq)]
783pub enum StreamEvent {
784 Started {
785 model: String,
786 },
787 Text(String),
788 Thinking(String),
789 ToolCall {
791 id: String,
792 name: String,
793 input: serde_json::Value,
794 },
795 ToolResult {
797 id: String,
798 name: String,
799 output: String,
800 is_error: bool,
801 },
802 Done(String),
803 Error(String),
804}
805
806pub(crate) fn json_str<'a>(v: &'a serde_json::Value, pointer: &str) -> &'a str {
808 v.pointer(pointer)
809 .and_then(serde_json::Value::as_str)
810 .unwrap_or("")
811}
812
813pub(crate) fn json_error_message(v: &serde_json::Value) -> String {
815 [
816 "/message",
817 "/error",
818 "/error/message",
819 "/error/data/message",
820 ]
821 .iter()
822 .find_map(|p| v.pointer(p).and_then(serde_json::Value::as_str))
823 .unwrap_or("unknown error")
824 .to_string()
825}
826
827#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829pub enum RouterTranscript {
830 Messages,
832 OpenCode,
834 Raw,
836}
837
838pub trait AgentRunner: Send + Sync {
843 #[cfg(test)]
845 fn name(&self) -> &str;
846
847 fn build_command(
853 &self,
854 model: Option<&str>,
855 timeout_cmd: &str,
856 sys_file: &str,
857 msg_file: &str,
858 permissions: &PermissionRules,
859 ) -> String;
860
861 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError>;
866
867 #[allow(dead_code)] fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
883 Ok(raw.to_string())
884 }
885
886 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent>;
888
889 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError;
894
895 fn classify_error_with_elapsed(
897 &self,
898 exit_code: i32,
899 stdout: &str,
900 stderr: &str,
901 elapsed_secs: Option<u64>,
902 ) -> AgentError {
903 let mut err = self.classify_error(exit_code, stdout, stderr);
904 if let AgentError::Timeout { elapsed } = &mut err {
905 if let Some(actual_elapsed) = elapsed_secs {
906 *elapsed = Duration::from_secs(actual_elapsed);
907 }
908 }
909 err
910 }
911
912 fn available_models(&self) -> Vec<String> {
914 vec![]
915 }
916
917 fn free_models(&self) -> Vec<String> {
919 vec![]
920 }
921
922 fn router_transcript(&self) -> RouterTranscript {
924 RouterTranscript::Raw
925 }
926
927 fn router_command(
933 &self,
934 prompt: &str,
935 model: Option<&str>,
936 ) -> anyhow::Result<tokio::process::Command>;
937}
938
939pub fn supports_session(agent: &str) -> bool {
941 matches!(agent, "claude" | "kimi" | "minimax" | "grok")
942}
943
944pub fn get_runner(agent_name: &str) -> Box<dyn AgentRunner> {
946 const CLAUDE_COMPATIBLE_AGENTS: &[&str] = &["claude", "kimi", "minimax", "olm", "glm"];
948
949 match agent_name {
950 "claude" => Box::new(claude::ClaudeRunner::new(agent_name)),
951 "kimi" => Box::new(kimi::KimiClaudeRunner::new()),
952 "minimax" => Box::new(minimax::MiniMaxClaudeRunner::new(agent_name)),
953 "codex" => Box::new(codex::CodexRunner),
954 "opencode" => Box::new(opencode::OpenCodeRunner::new()),
955 "grok" => Box::new(grok::GrokRunner),
956 other => {
958 if !CLAUDE_COMPATIBLE_AGENTS.contains(&other) {
960 tracing::warn!(
961 agent = other,
962 "unknown agent, using claude-compatible runner"
963 );
964 }
965 Box::new(claude::ClaudeRunner::new(other))
966 }
967 }
968}
969
970pub(crate) fn parse_ndjson(raw: &str) -> Vec<serde_json::Value> {
979 let events: Vec<serde_json::Value> = raw
981 .lines()
982 .filter(|line| !line.trim().is_empty())
983 .filter_map(|line| match serde_json::from_str(line) {
984 Ok(val) => Some(val),
985 Err(e) => {
986 tracing::debug!(line, error = %e, "skipping unparseable NDJSON line");
987 None
988 }
989 })
990 .collect();
991
992 if !events.is_empty() {
996 return events;
997 }
998
999 let acc: String = raw
1005 .lines()
1006 .filter(|line| {
1007 let t = line.trim();
1008 !t.is_empty() && !t.starts_with("//") && !t.starts_with('#')
1009 })
1010 .collect::<Vec<_>>()
1011 .join(" ");
1012
1013 if acc.is_empty() {
1014 return Vec::new();
1015 }
1016
1017 if let Ok(val) = serde_json::from_str::<serde_json::Value>(acc.trim()) {
1020 let stripped: String = acc.chars().filter(|c| !c.is_ascii_whitespace()).collect();
1025 if let Ok(stripped_val) = serde_json::from_str::<serde_json::Value>(&stripped) {
1026 if stripped_val == val {
1030 return Vec::new();
1031 }
1032 }
1033 return vec![val];
1034 }
1035
1036 extract_json_objects(&acc)
1040}
1041
1042fn extract_json_objects(text: &str) -> Vec<serde_json::Value> {
1047 let mut results = Vec::new();
1048 let mut start: Option<usize> = None;
1049 let mut depth: i32 = 0;
1050 let mut in_string = false;
1051 let mut escape = false;
1052
1053 for (idx, ch) in text.char_indices() {
1054 if let Some(start_idx) = start {
1055 if in_string {
1056 if escape {
1057 escape = false;
1058 } else if ch == '\\' {
1059 escape = true;
1060 } else if ch == '"' {
1061 in_string = false;
1062 }
1063 continue;
1064 }
1065
1066 match ch {
1067 '"' => in_string = true,
1068 '{' => depth += 1,
1069 '}' => {
1070 depth -= 1;
1071 if depth == 0 {
1072 let candidate = &text[start_idx..=idx];
1073 if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
1074 results.push(val);
1075 }
1076 start = None;
1077 }
1078 }
1079 _ => {}
1080 }
1081 continue;
1082 }
1083
1084 if ch == '{' {
1085 start = Some(idx);
1086 depth = 1;
1087 in_string = false;
1088 escape = false;
1089 }
1090 }
1091
1092 results
1093}
1094
1095macro_rules! delegate_agent_runner {
1104 ($runner:ty, $inner:ident) => {
1105 impl AgentRunner for $runner {
1106 #[cfg(test)]
1107 fn name(&self) -> &str {
1108 self.$inner.name()
1109 }
1110
1111 fn build_command(
1112 &self,
1113 model: Option<&str>,
1114 timeout_cmd: &str,
1115 sys_file: &str,
1116 msg_file: &str,
1117 permissions: &PermissionRules,
1118 ) -> String {
1119 self.$inner
1120 .build_command(model, timeout_cmd, sys_file, msg_file, permissions)
1121 }
1122
1123 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
1124 self.$inner.parse_response(raw)
1125 }
1126
1127 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
1128 self.$inner.extract_text(raw)
1129 }
1130
1131 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
1132 self.$inner.classify_error(exit_code, stdout, stderr)
1133 }
1134
1135 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
1136 self.$inner.stream_events(event)
1137 }
1138
1139 fn router_transcript(&self) -> crate::engine::runner::agents::RouterTranscript {
1140 self.$inner.router_transcript()
1141 }
1142
1143 fn router_command(
1144 &self,
1145 prompt: &str,
1146 model: Option<&str>,
1147 ) -> anyhow::Result<tokio::process::Command> {
1148 self.$inner.router_command(prompt, model)
1149 }
1150 }
1151 };
1152}
1153
1154pub(crate) use delegate_agent_runner;
1155
1156pub(crate) mod patterns {
1158 use super::AgentError;
1159 use std::time::Duration;
1160
1161 fn is_word_boundary(text: &str, lower_pos: usize, pattern: &str) -> bool {
1165 let after_match = lower_pos + pattern.len();
1166 let prev_is_boundary = lower_pos == 0
1167 || !text[..lower_pos]
1168 .chars()
1169 .last()
1170 .is_some_and(|c| c.is_alphanumeric() || c == '_');
1171 let next_is_boundary = after_match >= text.len()
1172 || !text[after_match..]
1173 .chars()
1174 .next()
1175 .is_some_and(|c| c.is_alphanumeric() || c == '_');
1176 prev_is_boundary && next_is_boundary
1177 }
1178
1179 fn lower_offset_to_original_byte(text: &str, lower: &str, lower_pos: usize) -> usize {
1180 let char_idx = lower[..lower_pos].chars().count();
1181 text.char_indices()
1182 .nth(char_idx)
1183 .map_or(text.len(), |(i, _)| i)
1184 }
1185
1186 fn line_bounds(text: &str, byte_pos: usize) -> (usize, usize) {
1187 let start = text[..byte_pos].rfind('\n').map_or(0, |idx| idx + 1);
1188 let end = text[byte_pos..]
1189 .find('\n')
1190 .map_or(text.len(), |idx| byte_pos + idx);
1191 (start, end)
1192 }
1193
1194 fn is_markdown_table_cell(text: &str, byte_pos: usize, pattern: &str) -> bool {
1195 let (line_start, line_end) = line_bounds(text, byte_pos);
1196 let line = &text[line_start..line_end];
1197 let trimmed = line.trim_start();
1198 if !(trimmed.starts_with('|')
1199 || trimmed.starts_with("+|")
1200 || trimmed.starts_with("-|")
1201 || trimmed.starts_with(">|"))
1202 {
1203 return false;
1204 }
1205 if line.matches('|').count() < 2 {
1206 return false;
1207 }
1208
1209 let rel_pos = byte_pos.saturating_sub(line_start);
1210 let after_match = rel_pos + pattern.len();
1211 let prev_is_pipe = line[..rel_pos]
1212 .chars()
1213 .rev()
1214 .find(|c| !c.is_whitespace())
1215 .is_some_and(|c| c == '|');
1216 let next_is_pipe = line[after_match..]
1217 .chars()
1218 .find(|c| !c.is_whitespace())
1219 .is_some_and(|c| c == '|');
1220 prev_is_pipe && next_is_pipe
1221 }
1222
1223 pub fn detect_rate_limit(text: &str) -> Option<AgentError> {
1225 let lower = text.to_lowercase();
1226 let patterns = [
1227 "rate limit",
1228 "rate_limit",
1229 "ratelimit",
1230 "rate increased too quickly", "too many requests",
1232 "usage limit",
1233 "session limit", "weekly limit", "quota exceeded",
1236 "monthly quota", "overloaded",
1238 "capacity",
1239 "throttled",
1240 "insufficient_quota",
1241 "tokens_exceeded",
1242 "you've hit your usage limit",
1243 "resourceexhausted", "worker local total request limit reached", ];
1246 let match_info = patterns
1253 .iter()
1254 .filter_map(|pattern| lower.find(pattern).map(|lower_pos| (*pattern, lower_pos)))
1255 .filter_map(|(pattern, lower_pos)| {
1256 let byte_pos = lower_offset_to_original_byte(text, &lower, lower_pos);
1257 if matches!(pattern, "rate_limit" | "ratelimit")
1258 && !is_word_boundary(text, byte_pos, pattern)
1259 {
1260 return None;
1261 }
1262
1263 if matches!(pattern, "rate_limit" | "ratelimit")
1264 && is_markdown_table_cell(text, byte_pos, pattern)
1265 {
1266 return None;
1267 }
1268 Some((pattern, byte_pos))
1269 })
1270 .min_by_key(|(_, byte_pos)| *byte_pos);
1271 let pos_429 = find_standalone_digits(&lower, "429");
1274 let has_429 = pos_429.is_some();
1275 let has_529 = lower.contains("http 529")
1279 || lower.contains("529 service")
1280 || (lower.find(": 529").is_some_and(|pos| {
1281 !lower[pos + 5..]
1282 .chars()
1283 .next()
1284 .is_some_and(|c| c.is_ascii_digit())
1285 }));
1286
1287 if match_info.is_some() || has_429 || has_529 {
1288 let message = if let Some((_, pos)) = match_info {
1289 extract_context_around(text, pos, 300)
1290 } else if let Some(pos) = pos_429 {
1291 extract_context_around(text, lower_offset_to_original_byte(text, &lower, pos), 300)
1292 } else {
1293 safe_tail(text, 300).to_string()
1294 };
1295 return Some(AgentError::RateLimit { message });
1296 }
1297 None
1298 }
1299
1300 fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1306 let half = window / 2;
1307 let start = byte_pos.saturating_sub(half);
1308 let end = (byte_pos + window).min(text.len());
1309 let mut s = start;
1311 while s < text.len() && !text.is_char_boundary(s) {
1312 s += 1;
1313 }
1314 let mut e = end;
1315 while e < text.len() && !text.is_char_boundary(e) {
1316 e += 1;
1317 }
1318 text[s..e].to_string()
1319 }
1320
1321 fn find_standalone_digits(lower: &str, digits: &str) -> Option<usize> {
1324 let mut start = 0;
1325 while let Some(rel) = lower[start..].find(digits) {
1326 let pos = start + rel;
1327 let before_ok = pos == 0
1328 || !lower[..pos]
1329 .chars()
1330 .next_back()
1331 .is_some_and(|c| c.is_ascii_alphanumeric());
1332 let after = pos + digits.len();
1333 let after_ok = after >= lower.len()
1334 || !lower[after..]
1335 .chars()
1336 .next()
1337 .is_some_and(|c| c.is_ascii_alphanumeric());
1338 if before_ok && after_ok {
1339 return Some(pos);
1340 }
1341 start = pos + 1;
1342 }
1343 None
1344 }
1345
1346 pub fn contains_http_status(lower: &str, code: &str) -> bool {
1354 if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1356 return true;
1357 }
1358 let needle = format!(": {code}");
1360 let mut start = 0;
1361 while let Some(rel) = lower[start..].find(needle.as_str()) {
1362 let after = start + rel + needle.len();
1363 match lower[after..].chars().next() {
1364 Some(c) if c.is_ascii_digit() => {
1365 start = after;
1367 }
1368 _ => return true,
1369 }
1370 }
1371 false
1372 }
1373
1374 pub fn detect_auth_error(text: &str) -> Option<AgentError> {
1380 let lower = text.to_lowercase();
1381 let patterns = [
1382 "unauthorized",
1383 "authentication required",
1384 "invalid api",
1385 "invalid key",
1386 "invalid token",
1387 "auth fail",
1388 "no api key",
1389 "no token",
1390 "expired key",
1391 "expired token",
1392 "expired plan",
1393 "billing plan",
1397 "billing error",
1398 "billing suspended",
1399 "billing expired",
1400 "billing limit",
1401 "billing failed",
1402 "billing cycle exhausted",
1403 "insufficient credit",
1404 "insufficient balance",
1405 "credit balance too low",
1406 "payment required",
1407 ];
1408 let http_401 = contains_http_status(&lower, "401");
1411 let http_403 = contains_http_status(&lower, "403");
1412 let http_407 = contains_http_status(&lower, "407");
1413 let proxy_auth_required = lower.contains("proxy authentication required");
1414 if !patterns.iter().any(|p| lower.contains(p))
1415 && !http_401
1416 && !http_403
1417 && !http_407
1418 && !proxy_auth_required
1419 {
1420 return None;
1421 }
1422 let first_match_pos =
1424 patterns
1425 .iter()
1426 .filter_map(|p| lower.find(p))
1427 .min()
1428 .map(|lower_pos| {
1429 let char_idx = lower[..lower_pos].chars().count();
1430 text.char_indices()
1431 .nth(char_idx)
1432 .map_or(text.len(), |(i, _)| i)
1433 });
1434 let http_401_match_pos = if http_401 {
1437 lower.find("401 ").or_else(|| lower.find(": 401"))
1438 } else {
1439 None
1440 };
1441 let http_403_match_pos = if http_403 {
1442 lower.find("403 ").or_else(|| lower.find(": 403"))
1443 } else {
1444 None
1445 };
1446 let http_407_match_pos = if http_407 {
1447 lower.find("407 ").or_else(|| lower.find(": 407"))
1448 } else {
1449 None
1450 };
1451 let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1452 .into_iter()
1453 .flatten()
1454 .min()
1455 .and_then(|rel| {
1456 let char_idx = lower[..rel].chars().count();
1457 text.char_indices().nth(char_idx).map(|(i, _)| i)
1458 });
1459 let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1460 let message = if let Some(pos) = match_pos {
1461 find_line_containing(text, pos)
1462 } else {
1463 find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1466 };
1467 Some(AgentError::Auth { message })
1468 }
1469
1470 fn find_line_containing(text: &str, byte_pos: usize) -> String {
1472 let start = text[..byte_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
1473 let end = text[byte_pos..]
1474 .find('\n')
1475 .map(|i| byte_pos + i)
1476 .unwrap_or(text.len());
1477 text[start..end].trim().to_string()
1478 }
1479
1480 fn find_auth_line_scan(text: &str) -> Option<String> {
1484 for line in text.lines().rev().take(50) {
1485 let line_lower = line.to_lowercase();
1486 if contains_http_status(&line_lower, "401")
1487 || contains_http_status(&line_lower, "403")
1488 || contains_http_status(&line_lower, "407")
1489 || line_lower.contains("unauthorized")
1490 || line_lower.contains("authentication required")
1491 || line_lower.contains("invalid api")
1492 || line_lower.contains("invalid key")
1493 || line_lower.contains("invalid token")
1494 || line_lower.contains("auth fail")
1495 || line_lower.contains("no api key")
1496 || line_lower.contains("no token")
1497 || line_lower.contains("expired key")
1498 || line_lower.contains("expired token")
1499 || line_lower.contains("expired plan")
1500 || line_lower.contains("billing plan")
1501 || line_lower.contains("billing error")
1502 || line_lower.contains("billing suspended")
1503 || line_lower.contains("billing expired")
1504 || line_lower.contains("billing limit")
1505 || line_lower.contains("billing failed")
1506 || line_lower.contains("billing cycle exhausted")
1507 || line_lower.contains("insufficient credit")
1508 || line_lower.contains("credit balance too low")
1509 || line_lower.contains("payment required")
1510 || line_lower.contains("proxy authentication required")
1511 {
1512 return Some(line.trim().to_string());
1513 }
1514 }
1515 None
1516 }
1517
1518 pub fn detect_network_error(text: &str) -> Option<AgentError> {
1520 let lower = text.to_lowercase();
1521 let patterns = [
1522 "connectionrefused",
1523 "connection refused",
1524 "unable to connect",
1525 "econnrefused",
1526 "network unreachable",
1527 "socket connection was closed",
1529 "socket hang up",
1530 "econnreset",
1531 "connection reset",
1532 "etimedout",
1533 "connect etimedout",
1534 "aborterror",
1535 "the operation was aborted",
1536 "network request failed",
1537 "fetch failed",
1538 "upstream idle timeout",
1540 "streaming response failed",
1541 "upstream request failed",
1542 "stalled mid-stream",
1544 "connection closed mid-response",
1545 ];
1546 let match_pos = patterns
1549 .iter()
1550 .filter_map(|p| lower.find(p))
1551 .min()
1552 .map(|lower_pos| {
1553 let char_idx = lower[..lower_pos].chars().count();
1554 text.char_indices()
1555 .nth(char_idx)
1556 .map_or(text.len(), |(i, _)| i)
1557 });
1558 if let Some(pos) = match_pos {
1559 return Some(AgentError::NetworkError {
1560 message: extract_context_around(text, pos, 300),
1561 });
1562 }
1563 None
1564 }
1565
1566 pub fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1576 let lower = text.to_lowercase();
1577 let is_model_unavailable = lower.contains("model metadata")
1578 || lower.contains("no endpoints found")
1579 || (lower.contains("model")
1580 && (lower.contains("not found")
1581 || lower.contains("not supported")
1582 || lower.contains("does not exist")
1583 || lower.contains("deprecated")
1584 || lower.contains("unavailable for free")
1585 || lower.contains("not available in your country")
1586 || lower.contains("not available in your region")));
1587 if !is_model_unavailable {
1588 return None;
1589 }
1590 let model = extract_quoted(text, '`')
1591 .or_else(|| extract_quoted(text, '\''))
1592 .unwrap_or_default();
1593 Some(AgentError::ModelUnavailable {
1594 message: safe_tail(text, 300).to_string(),
1595 model,
1596 })
1597 }
1598
1599 fn extract_quoted(text: &str, quote: char) -> Option<String> {
1601 let start = text.find(quote)?;
1602 let rest = &text[start + quote.len_utf8()..];
1603 let end = rest.find(quote)?;
1604 Some(rest[..end].to_string())
1605 }
1606
1607 pub fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1609 let lower = text.to_lowercase();
1610 let patterns = ["worktree directory does not exist"];
1611 if patterns.iter().any(|p| lower.contains(p)) {
1612 return Some(AgentError::AgentFailed {
1613 message: safe_tail(text, 300).to_string(),
1614 });
1615 }
1616 None
1617 }
1618
1619 pub fn detect_context_overflow(text: &str) -> Option<AgentError> {
1621 let lower = text.to_lowercase();
1622 let patterns = [
1623 "context_length_exceeded",
1624 "context length exceeded",
1625 "maximum context length",
1626 "too many tokens",
1627 "token limit",
1628 "context overflow",
1629 ];
1630 if patterns.iter().any(|p| lower.contains(p)) {
1631 return Some(AgentError::ContextOverflow {
1632 message: safe_tail(text, 300).to_string(),
1633 });
1634 }
1635 None
1636 }
1637
1638 pub fn detect_missing_tool(text: &str) -> Option<AgentError> {
1640 let known_tools = [
1641 "bun",
1642 "node",
1643 "npm",
1644 "pnpm",
1645 "yarn",
1646 "deno",
1647 "tsc",
1648 "eslint",
1649 "prettier",
1650 "jest",
1651 "vitest",
1652 "cargo",
1653 "rustc",
1654 "go",
1655 "python",
1656 "python3",
1657 "pip",
1658 "pip3",
1659 "uv",
1660 "poetry",
1661 "pytest",
1662 "ruff",
1663 "black",
1664 "mypy",
1665 "make",
1666 "cmake",
1667 "ninja",
1668 "just",
1669 "bats",
1670 "docker",
1671 "docker-compose",
1672 "podman",
1673 "kubectl",
1674 "helm",
1675 "terraform",
1676 "anchor",
1677 "avm",
1678 "solana",
1679 "solana-test-validator",
1680 ];
1681
1682 let lower = text.to_lowercase();
1683 for tool in &known_tools {
1684 if lower.contains(&format!("{tool}: command not found"))
1685 || lower.contains(&format!("command not found: {tool}"))
1686 || lower.contains(&format!("{tool}: not found"))
1687 || lower.contains(&format!("env: {tool}: no such file"))
1688 || lower.contains(&format!("spawn {tool} enoent"))
1689 {
1690 return Some(AgentError::MissingTool {
1691 tool: tool.to_string(),
1692 });
1693 }
1694 }
1695 None
1696 }
1697
1698 pub fn detect_permission_denied(text: &str) -> Option<AgentError> {
1700 let lower = text.to_lowercase();
1701 let patterns = [
1702 "permission denied",
1703 "rejected permission",
1704 "permissionerror",
1705 "operation not permitted",
1706 "sandbox violation",
1707 "access denied",
1708 "eacces",
1709 "eperm",
1710 "read-only file system",
1711 "not writable",
1712 "disallowed tool",
1713 ];
1714 if patterns.iter().any(|p| lower.contains(p)) {
1715 return Some(AgentError::PermissionDenied {
1716 message: safe_tail(text, 300).to_string(),
1717 });
1718 }
1719 None
1720 }
1721
1722 pub fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1724 let lower = text.to_lowercase();
1725 let patterns = [
1726 "enter passphrase",
1727 "1password",
1728 "op signin",
1729 "ssh passphrase",
1730 "password:",
1731 ];
1732 let auth_required_with_interactive_context = lower.contains("authentication required")
1733 && (lower.contains("ssh")
1734 || lower.contains("passphrase")
1735 || lower.contains("1password")
1736 || lower.contains("op signin"));
1737 if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1738 return Some(AgentError::WaitingForInput {
1739 message: safe_tail(text, 300).to_string(),
1740 });
1741 }
1742 None
1743 }
1744
1745 pub fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1751 if text.contains(
1754 "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1755 ) {
1756 let message = text
1757 .lines()
1758 .find(|l| l.contains("cannot be modified"))
1759 .map(|l| l.trim().to_string())
1760 .unwrap_or_else(|| safe_tail(text, 300).to_string());
1761 return Some(AgentError::ThinkingBlockConflict { message });
1762 }
1763 None
1764 }
1765
1766 pub fn detect_stale_session(text: &str) -> Option<AgentError> {
1772 let needle = "no conversation found with session id";
1778 let lower = text.to_lowercase();
1779 if let Some(lower_pos) = lower.find(needle) {
1780 let char_idx = lower[..lower_pos].chars().count();
1785 let pos = text
1786 .char_indices()
1787 .nth(char_idx)
1788 .map(|(i, _)| i)
1789 .unwrap_or(text.len());
1790 let after = &text[pos..];
1792 let session_id = after
1793 .split_once(':')
1794 .map(|(_, rest)| rest.trim())
1795 .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
1797 .unwrap_or_default();
1798 return Some(AgentError::StaleSession { session_id });
1799 }
1800 None
1801 }
1802
1803 const TIMEOUT_EXIT_CODE: i32 = 124;
1805
1806 const DEFAULT_TIMEOUT_SECS: u64 = 1800;
1808
1809 pub(crate) const RATE_LIMIT_SCAN_TAIL_BYTES: usize = 3000;
1820
1821 pub(crate) const RATE_LIMIT_SCAN_HEAD_BYTES: usize = 3000;
1829
1830 pub fn classify_from_text(exit_code: i32, text: &str) -> AgentError {
1833 if exit_code == TIMEOUT_EXIT_CODE {
1834 return AgentError::Timeout {
1835 elapsed: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1836 };
1837 }
1838
1839 if exit_code == 0 && text.trim().is_empty() {
1844 return AgentError::Unknown {
1845 exit_code,
1846 message: "empty-output-exit0".to_string(),
1847 };
1848 }
1849
1850 if let Some(e) = detect_missing_tool(text) {
1851 return e;
1852 }
1853 if let Some(e) = detect_worktree_missing(text) {
1854 return e;
1855 }
1856 if let Some(e) = detect_thinking_block_conflict(text) {
1857 return e;
1858 }
1859 let scan_head = safe_head(text, RATE_LIMIT_SCAN_HEAD_BYTES);
1865 if let Some(e) = detect_rate_limit(scan_head) {
1866 return e;
1867 }
1868 let scan_tail = safe_tail(text, RATE_LIMIT_SCAN_TAIL_BYTES);
1875 if let Some(e) = detect_context_overflow(scan_tail) {
1876 return e;
1877 }
1878 if let Some(e) = detect_rate_limit(scan_tail) {
1879 return e;
1880 }
1881 if let Some(e) = detect_model_unavailable(scan_tail) {
1882 return e;
1883 }
1884 if let Some(e) = detect_network_error(scan_tail) {
1885 return e;
1886 }
1887 if let Some(e) = detect_auth_error(scan_tail) {
1888 return e;
1889 }
1890 if let Some(e) = detect_waiting_for_input(scan_tail) {
1891 return e;
1892 }
1893 if let Some(e) = detect_permission_denied(scan_tail) {
1894 return e;
1895 }
1896 if let Some(e) = detect_stale_session(text) {
1897 return e;
1898 }
1899
1900 AgentError::Unknown {
1901 exit_code,
1902 message: safe_tail(text, 300).to_string(),
1903 }
1904 }
1905
1906 pub fn safe_tail(text: &str, max_bytes: usize) -> &str {
1908 if text.len() <= max_bytes {
1909 return text;
1910 }
1911 let start = text.len() - max_bytes;
1912 let mut idx = start;
1914 while idx < text.len() && !text.is_char_boundary(idx) {
1915 idx += 1;
1916 }
1917 &text[idx..]
1918 }
1919
1920 pub fn safe_head(text: &str, max_bytes: usize) -> &str {
1922 if text.len() <= max_bytes {
1923 return text;
1924 }
1925 let mut idx = max_bytes;
1926 while idx > 0 && !text.is_char_boundary(idx) {
1928 idx -= 1;
1929 }
1930 &text[..idx]
1931 }
1932
1933 pub fn detect_ndjson_error(
1940 events: &[serde_json::Value],
1941 extract_message: impl Fn(&serde_json::Value) -> Option<String>,
1942 classify: impl Fn(&str) -> AgentError,
1943 ) -> Option<AgentError> {
1944 let mut best: Option<AgentError> = None;
1945
1946 for event in events {
1947 if let Some(message) = extract_message(event) {
1948 let err = classify(&message);
1949 let upgrade = match &best {
1952 None => true,
1953 Some(AgentError::AgentFailed { .. }) => true,
1954 Some(AgentError::NetworkError { .. }) => !matches!(
1955 err,
1956 AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1957 ),
1958 Some(_) => false,
1959 };
1960 if upgrade {
1961 best = Some(err);
1962 }
1963 }
1964 }
1965
1966 best
1967 }
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972 use super::*;
1973
1974 #[test]
1975 fn get_runner_returns_correct_types() {
1976 assert_eq!(get_runner("claude").name(), "claude");
1977 assert_eq!(get_runner("kimi").name(), "kimi");
1978 assert_eq!(get_runner("minimax").name(), "minimax");
1979 assert_eq!(get_runner("codex").name(), "codex");
1980 assert_eq!(get_runner("opencode").name(), "opencode");
1981 assert_eq!(get_runner("grok").name(), "grok");
1982 assert_eq!(
1983 get_runner("claude").router_transcript(),
1984 RouterTranscript::Messages
1985 );
1986 assert_eq!(
1987 get_runner("kimi").router_transcript(),
1988 RouterTranscript::Messages
1989 );
1990 assert_eq!(
1991 get_runner("minimax").router_transcript(),
1992 RouterTranscript::Messages
1993 );
1994 assert_eq!(
1995 get_runner("grok").router_transcript(),
1996 RouterTranscript::Messages
1997 );
1998 assert_eq!(
1999 get_runner("glm").router_transcript(),
2000 RouterTranscript::Messages
2001 );
2002 assert_eq!(
2003 get_runner("olm").router_transcript(),
2004 RouterTranscript::Messages
2005 );
2006 assert_eq!(
2007 get_runner("opencode").router_transcript(),
2008 RouterTranscript::OpenCode
2009 );
2010 assert_eq!(
2011 get_runner("codex").router_transcript(),
2012 RouterTranscript::Raw
2013 );
2014 assert!(supports_session("grok"));
2015 assert!(!supports_session("codex"));
2016 assert_eq!(get_runner("olm").name(), "olm");
2018 assert_eq!(get_runner("glm").name(), "glm");
2019 assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
2021 }
2022
2023 #[test]
2026 fn get_runner_no_warning_for_claude_compatible_agents() {
2027 use std::sync::{Arc, Mutex};
2028 use tracing::Level;
2029 use tracing_subscriber::util::SubscriberInitExt;
2030 use tracing_subscriber::EnvFilter;
2031
2032 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
2033 let output_clone = output.clone();
2034
2035 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2036 impl std::io::Write for CaptureWriter {
2037 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2038 self.0.lock().unwrap().extend_from_slice(buf);
2039 Ok(buf.len())
2040 }
2041 fn flush(&mut self) -> std::io::Result<()> {
2042 Ok(())
2043 }
2044 }
2045 impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2046 type Writer = CaptureWriter;
2047 fn make_writer(&'a self) -> Self::Writer {
2048 CaptureWriter(self.0.clone())
2049 }
2050 }
2051
2052 tracing_subscriber::fmt()
2053 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2054 .with_writer(CaptureWriter(output_clone))
2055 .with_ansi(false)
2056 .finish()
2057 .init();
2058
2059 for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2060 get_runner(agent);
2061 }
2062
2063 let captured = {
2064 let guard = output.lock().unwrap();
2065 String::from_utf8_lossy(&guard).to_string()
2066 };
2067 assert!(
2068 !captured.contains("unknown agent"),
2069 "known claude-compatible agents must not emit 'unknown agent' warnings; got: {}",
2070 captured
2071 );
2072 }
2073
2074 #[test]
2075 fn agent_error_display() {
2076 let e = AgentError::RateLimit {
2077 message: "429 Too Many Requests".to_string(),
2078 };
2079 assert!(e.to_string().contains("rate limit"));
2080
2081 let e = AgentError::Timeout {
2082 elapsed: Duration::from_secs(1800),
2083 };
2084 assert!(e.to_string().contains("1800"));
2085 }
2086
2087 #[test]
2088 fn pattern_detect_rate_limit() {
2089 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2090 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2091 assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2092 assert!(patterns::detect_rate_limit(
2093 "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2094 )
2095 .is_some());
2096 assert!(patterns::detect_rate_limit(
2097 "Upstream error from Alibaba: Request rate increased too quickly. \
2098 To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
2099 ).is_some());
2100 assert!(patterns::detect_rate_limit("all good").is_none());
2101 }
2102
2103 #[test]
2104 fn classify_from_text_detects_weekly_limit_as_rate_limit() {
2105 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)"}}"#;
2109 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2110 .repeat(200);
2111 let text = format!("{system_event}\n{work_product}");
2112 assert!(
2113 text.len() > 3000,
2114 "test output must exceed tail window to be meaningful"
2115 );
2116 let err = patterns::classify_from_text(1, &text);
2117 assert!(
2118 matches!(err, AgentError::RateLimit { .. }),
2119 "weekly limit as early system event must be detected via head scan, got: {err:?}"
2120 );
2121 let msg = err.to_string();
2122 assert!(
2123 msg.contains("weekly limit"),
2124 "error message should reference weekly limit, got: {msg}"
2125 );
2126 }
2127
2128 #[test]
2129 fn detect_rate_limit_github_copilot_monthly_quota() {
2130 let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
2134 assert!(
2135 matches!(err, Some(AgentError::RateLimit { .. })),
2136 "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
2137 );
2138 }
2139
2140 #[test]
2141 fn detect_rate_limit_nvidia_resourceexhausted() {
2142 let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
2146 let err = patterns::detect_rate_limit(opencode_error);
2147 assert!(
2148 matches!(err, Some(AgentError::RateLimit { .. })),
2149 "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
2150 );
2151
2152 assert!(
2154 patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
2155 "bare ResourceExhausted must match"
2156 );
2157 assert!(
2158 patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
2159 .is_some(),
2160 "worker local total request limit reached must match"
2161 );
2162
2163 let err = patterns::classify_from_text(1, opencode_error);
2165 assert!(
2166 matches!(err, AgentError::RateLimit { .. }),
2167 "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
2168 );
2169 }
2170
2171 #[test]
2172 fn detect_rate_limit_message_contains_context_not_tail() {
2173 let padding = "x".repeat(5000);
2176 let text = format!(
2177 "{padding}you've reached your usage limit for this billing cycle{padding}\
2178 {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
2179 );
2180 let err = patterns::detect_rate_limit(&text).expect("should detect");
2181 let msg = err.to_string();
2182 assert!(
2183 msg.contains("usage limit"),
2184 "message should contain the actual error, got: {msg}"
2185 );
2186 assert!(
2187 !msg.contains("web_fetch_requests"),
2188 "message should not be the tail JSON, got: {msg}"
2189 );
2190 }
2191
2192 #[test]
2193 fn pattern_detect_rate_limit_529_not_bare() {
2194 assert!(
2197 patterns::detect_rate_limit("error on line 529").is_none(),
2198 "bare line number 529 should not match"
2199 );
2200 assert!(
2201 patterns::detect_rate_limit("file size 529 bytes").is_none(),
2202 "bare file size 529 should not match"
2203 );
2204 assert!(
2205 patterns::detect_rate_limit("port :5290 in config").is_none(),
2206 "port number containing 529 should not match"
2207 );
2208 assert!(
2209 patterns::detect_rate_limit("timeout after 5290ms").is_none(),
2210 "duration containing 529 should not match"
2211 );
2212 assert!(
2214 patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
2215 "HTTP 529 status should match"
2216 );
2217 assert!(
2218 patterns::detect_rate_limit("http 529").is_some(),
2219 "http 529 should match"
2220 );
2221 assert!(
2222 patterns::detect_rate_limit("529 service unavailable").is_some(),
2223 "529 service unavailable should match"
2224 );
2225 assert!(
2226 patterns::detect_rate_limit("status: 529").is_some(),
2227 "status: 529 should match"
2228 );
2229 }
2230
2231 #[test]
2232 fn pattern_detect_auth() {
2233 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
2234 assert!(patterns::detect_auth_error("HTTP 401").is_some());
2235 assert!(patterns::detect_auth_error("error: 401").is_some());
2236 assert!(patterns::detect_auth_error("403 Forbidden").is_some());
2237 assert!(patterns::detect_auth_error("HTTP 403").is_some());
2238 assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
2239 assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
2240 assert!(patterns::detect_auth_error("invalid api key").is_some());
2241 assert!(patterns::detect_auth_error("billing expired").is_some());
2242 assert!(patterns::detect_auth_error("task done").is_none());
2243 assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
2245 assert!(patterns::detect_auth_error(
2246 "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2247 )
2248 .is_none());
2249 assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2251 assert!(patterns::detect_auth_error(
2252 r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2253 )
2254 .is_none());
2255 assert!(patterns::detect_auth_error("status: 401").is_some());
2257 assert!(patterns::detect_auth_error("error: 403").is_some());
2258 }
2259
2260 #[test]
2261 fn pattern_detect_auth_insufficient_balance() {
2262 assert!(
2267 patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2268 "insufficient balance must be detected as auth error"
2269 );
2270 assert!(
2271 patterns::detect_auth_error("insufficient balance").is_some(),
2272 "bare insufficient balance must be detected as auth error"
2273 );
2274 let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2276 .expect("should detect auth error");
2277 let AgentError::Auth { message } = err else {
2278 panic!("expected Auth, got {err:?}");
2279 };
2280 assert!(
2281 message.to_lowercase().contains("insufficient balance"),
2282 "stored message should contain the matched pattern, got: {message}"
2283 );
2284 assert!(
2286 message.lines().count() <= 2,
2287 "message should be the auth line, not the full tail: {}",
2288 message.len()
2289 );
2290 }
2291
2292 #[test]
2293 fn auth_error_message_contains_match_not_tail() {
2294 let text = "some output\nunauthorized: your api key has expired\nx".repeat(400)
2298 + r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#;
2299 let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2300 let AgentError::Auth { message } = err else {
2301 panic!("expected Auth, got {err:?}");
2302 };
2303 assert!(
2304 message.to_lowercase().contains("unauthorized"),
2305 "stored message should contain the matched pattern, got: {message}"
2306 );
2307 assert!(
2308 !message.contains("outputTokens"),
2309 "stored message must not be the trailing NDJSON, got: {message}"
2310 );
2311 assert!(
2313 message.lines().count() <= 2,
2314 "message should be the auth line, not the full tail: {}",
2315 message.len()
2316 );
2317 }
2318
2319 #[test]
2320 fn pattern_detect_network_error() {
2321 assert!(patterns::detect_network_error("connection refused").is_some());
2322 assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2323 assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2324 assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2325 assert!(patterns::detect_network_error("network unreachable").is_some());
2326 assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2327 assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2328 assert!(patterns::detect_network_error("Streaming response failed").is_some());
2329 assert!(patterns::detect_network_error("Upstream request failed").is_some());
2330 assert!(patterns::detect_network_error("all systems operational").is_none());
2331 }
2332
2333 #[test]
2334 fn network_error_message_contains_match_not_tail() {
2335 let prefix = "connection refused connecting to api.anthropic.com:443 ";
2337 let suffix = "x".repeat(2000)
2338 + r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#;
2339 let text = format!("{prefix}{suffix}");
2340
2341 let err = patterns::detect_network_error(&text).expect("should detect network error");
2342 let AgentError::NetworkError { message } = err else {
2343 panic!("expected NetworkError, got {err:?}");
2344 };
2345 assert!(
2346 message.to_lowercase().contains("connection refused"),
2347 "stored message should contain the matched pattern, got: {message}"
2348 );
2349 assert!(
2350 !message.contains("web_fetch_requests"),
2351 "stored message must not be the unrelated stats tail, got: {message}"
2352 );
2353 }
2354
2355 #[test]
2356 fn pattern_detect_context_overflow() {
2357 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2358 assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2359 assert!(patterns::detect_context_overflow("success").is_none());
2360 }
2361
2362 #[test]
2363 fn pattern_detect_worktree_missing() {
2364 assert!(
2365 patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2366 .is_some()
2367 );
2368 assert!(patterns::detect_worktree_missing("all good").is_none());
2369 }
2370
2371 #[test]
2372 fn pattern_detect_missing_tool() {
2373 assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2374 assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2375 assert!(patterns::detect_missing_tool("everything works").is_none());
2376 }
2377
2378 #[test]
2379 fn pattern_detect_permission_denied() {
2380 assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2381 assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2382 assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2383 assert!(patterns::detect_permission_denied("task completed").is_none());
2384 }
2385
2386 #[test]
2387 fn pattern_detect_waiting_for_input() {
2388 assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2389 assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2390 assert!(
2391 patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2392 .is_some()
2393 );
2394 assert!(
2395 patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2396 );
2397 assert!(patterns::detect_waiting_for_input("done").is_none());
2398 }
2399
2400 #[test]
2401 fn pattern_detect_stale_session() {
2402 let err = patterns::detect_stale_session(
2404 "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2405 );
2406 assert!(err.is_some(), "should detect stale session");
2407 if let Some(AgentError::StaleSession { session_id }) = err {
2408 assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2409 }
2410
2411 let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2413 assert!(err.is_some(), "should detect lowercase variant");
2414
2415 assert!(patterns::detect_stale_session("all good").is_none());
2417 assert!(patterns::detect_stale_session("conversation started").is_none());
2418
2419 let multibyte = "Straße: No conversation found with session ID: dead-beef";
2423 let err = patterns::detect_stale_session(multibyte);
2424 assert!(
2425 err.is_some(),
2426 "should detect stale session after multi-byte chars"
2427 );
2428 if let Some(AgentError::StaleSession { session_id }) = err {
2429 assert_eq!(session_id, "dead-beef");
2430 }
2431 }
2432
2433 #[test]
2434 fn stale_session_display() {
2435 let e = AgentError::StaleSession {
2436 session_id: "abc-123".to_string(),
2437 };
2438 assert!(e.to_string().contains("stale session"));
2439 assert!(e.to_string().contains("abc-123"));
2440 }
2441
2442 #[test]
2443 fn classify_from_text_timeout() {
2444 let err = patterns::classify_from_text(124, "");
2445 assert!(matches!(err, AgentError::Timeout { .. }));
2446 }
2447
2448 #[test]
2449 fn classify_from_text_missing_tool_before_rate_limit() {
2450 let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2452 assert!(matches!(err, AgentError::MissingTool { .. }));
2453 }
2454
2455 #[test]
2456 fn classify_from_text_worktree_missing() {
2457 let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2458 assert!(matches!(err, AgentError::AgentFailed { .. }));
2459 }
2460
2461 #[test]
2462 fn classify_from_text_model_not_found_backtick() {
2463 let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2467 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2468 or you do not have access to it.)";
2469 let err = patterns::classify_from_text(1, text);
2470 match err {
2471 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2472 other => panic!("expected ModelUnavailable, got: {other:?}"),
2473 }
2474 }
2475
2476 #[test]
2477 fn classify_from_text_model_metadata_not_found() {
2478 let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2479 metadata; this can degrade performance and cause issues.";
2480 let err = patterns::classify_from_text(1, text);
2481 match err {
2482 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2483 other => panic!("expected ModelUnavailable, got: {other:?}"),
2484 }
2485 }
2486
2487 #[test]
2488 fn classify_from_text_no_false_positive_from_work_product() {
2489 let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2492 - Added copilot quota patterns to `parse_retry_at`\n\
2493 fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2494 quota exceeded handling for billing cycle\n"
2495 .repeat(50); let actual_error = "\nerror: command not found: cargo\n";
2497 let text = format!("{work_product}{actual_error}");
2498 let err = patterns::classify_from_text(1, &text);
2499 assert!(
2500 !matches!(err, AgentError::RateLimit { .. }),
2501 "work product keywords must not trigger rate limit, got: {err:?}"
2502 );
2503 }
2504
2505 #[test]
2506 fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2507 let padding = "normal agent work output ".repeat(100);
2510 let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2511 let err = patterns::classify_from_text(1, &text);
2512 assert!(
2513 matches!(err, AgentError::RateLimit { .. }),
2514 "real rate limit at tail must be detected, got: {err:?}"
2515 );
2516 }
2517
2518 #[test]
2519 fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2520 let padding = "normal agent work output ".repeat(250);
2521 let text = format!(
2522 "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2523 );
2524 let err = patterns::classify_from_text(1, &text);
2525 assert!(
2526 !matches!(err, AgentError::RateLimit { .. }),
2527 "markdown table cell must not trigger rate limit, got: {err:?}"
2528 );
2529 }
2530
2531 #[test]
2532 fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2533 let text = "Aflαβrate_limit error: too many requests";
2537 let err = patterns::classify_from_text(1, text);
2538 assert!(
2539 matches!(err, AgentError::RateLimit { .. }),
2540 "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2541 );
2542 }
2543
2544 #[test]
2545 fn classify_from_text_detects_session_limit_as_early_system_event() {
2546 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)"}}"#;
2552 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2555 .repeat(200);
2556 let text = format!("{system_event}\n{work_product}");
2557 assert!(
2559 text.len() > 3000,
2560 "test output must exceed tail window to be meaningful"
2561 );
2562 let err = patterns::classify_from_text(1, &text);
2563 assert!(
2564 matches!(err, AgentError::RateLimit { .. }),
2565 "session limit as early system event must be detected via head scan, got: {err:?}"
2566 );
2567 let msg = err.to_string();
2569 assert!(
2570 msg.contains("session limit"),
2571 "error message should contain the rate limit reason, got: {msg}"
2572 );
2573 }
2574
2575 #[test]
2576 fn classify_from_text_no_false_positive_auth_from_work_product() {
2577 let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2581 // handle 403 forbidden errors\n\
2582 // billing checks happen elsewhere\n"
2583 .repeat(80);
2584 let actual_error = "\nerror: command not found: cargo\n";
2585 let text = format!("{work_product}{actual_error}");
2586 let err = patterns::classify_from_text(1, &text);
2587 assert!(
2588 !matches!(err, AgentError::Auth { .. }),
2589 "work product auth keywords must not trigger auth classification, got: {err:?}"
2590 );
2591 assert!(
2592 matches!(err, AgentError::MissingTool { .. }),
2593 "the real tail error should still win, got: {err:?}"
2594 );
2595 }
2596
2597 #[test]
2598 fn classify_from_text_still_detects_real_auth_at_tail() {
2599 let padding = "normal agent work output ".repeat(100);
2600 let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2601 let err = patterns::classify_from_text(1, &text);
2602 assert!(
2603 matches!(err, AgentError::Auth { .. }),
2604 "real auth error at tail must be detected, got: {err:?}"
2605 );
2606 }
2607
2608 #[test]
2609 fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2610 let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2614 // Ensure we don't exceed the token limit per request\n\
2615 fn check_token_limit(tokens: usize) { ... }\n\
2616 if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2617 // Note: too many tokens in the prompt causes context_length_exceeded\n"
2618 .repeat(80);
2619 let actual_error = "\nerror: command not found: cargo\n";
2620 let text = format!("{work_product}{actual_error}");
2621 let err = patterns::classify_from_text(1, &text);
2622 assert!(
2623 !matches!(err, AgentError::ContextOverflow { .. }),
2624 "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2625 );
2626 assert!(
2627 matches!(err, AgentError::MissingTool { .. }),
2628 "the real tail error should still win, got: {err:?}"
2629 );
2630 }
2631
2632 #[test]
2633 fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2634 let padding = "normal agent work output ".repeat(100);
2635 let text = format!("{padding}\nError: context_length_exceeded\n");
2636 let err = patterns::classify_from_text(1, &text);
2637 assert!(
2638 matches!(err, AgentError::ContextOverflow { .. }),
2639 "real context overflow at tail must be detected, got: {err:?}"
2640 );
2641 }
2642
2643 #[test]
2644 fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2645 let work_product = "match fs::metadata(path) {\n\
2649 Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2650 bail!(\"access denied: {path}\");\n\
2651 }\n\
2652 // Check if directory is writable\n\
2653 if !path.is_writable() { ... }\n\
2654 }\n"
2655 .repeat(80);
2656 let actual_error = "\nerror: command not found: cargo\n";
2657 let text = format!("{work_product}{actual_error}");
2658 let err = patterns::classify_from_text(1, &text);
2659 assert!(
2660 !matches!(err, AgentError::PermissionDenied { .. }),
2661 "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2662 );
2663 assert!(
2664 matches!(err, AgentError::MissingTool { .. }),
2665 "the real tail error should still win, got: {err:?}"
2666 );
2667 }
2668
2669 #[test]
2670 fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2671 let padding = "normal agent work output ".repeat(100);
2672 let text = format!("{padding}\npermission denied: /etc/hosts\n");
2673 let err = patterns::classify_from_text(1, &text);
2674 assert!(
2675 matches!(err, AgentError::PermissionDenied { .. }),
2676 "real permission denied at tail must be detected, got: {err:?}"
2677 );
2678 }
2679
2680 #[test]
2681 fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2682 let work_product = "// Handle password prompts gracefully\n\
2686 if prompt.contains(\"password:\") {\n\
2687 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2688 }\n\
2689 // SSH agent will ask for the deploy key passphrase\n\
2690 fn handle_ssh_passphrase() { ... }\n\
2691 )\n"
2692 .repeat(80);
2693 let actual_error = "\nerror: command not found: cargo\n";
2694 let text = format!("{work_product}{actual_error}");
2695 let err = patterns::classify_from_text(1, &text);
2696 assert!(
2697 !matches!(err, AgentError::WaitingForInput { .. }),
2698 "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2699 );
2700 assert!(
2701 matches!(err, AgentError::MissingTool { .. }),
2702 "the real tail error should still win, got: {err:?}"
2703 );
2704 }
2705
2706 #[test]
2707 fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2708 let padding = "normal agent work output ".repeat(100);
2709 let text = format!("{padding}\nEnter passphrase for key:\n");
2710 let err = patterns::classify_from_text(1, &text);
2711 assert!(
2712 matches!(err, AgentError::WaitingForInput { .. }),
2713 "real waiting for input at tail must be detected, got: {err:?}"
2714 );
2715 }
2716
2717 #[test]
2718 fn classify_from_text_prefers_auth_over_permission_denied() {
2719 let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2720 assert!(
2721 matches!(err, AgentError::Auth { .. }),
2722 "auth errors must outrank permission denied, got: {err:?}"
2723 );
2724 }
2725
2726 #[test]
2727 fn classify_from_text_prefers_auth_over_waiting_for_input() {
2728 let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2729 assert!(
2730 matches!(err, AgentError::Auth { .. }),
2731 "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2732 );
2733 }
2734
2735 #[test]
2736 fn classify_from_text_detects_stale_session() {
2737 let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2742 let err = patterns::classify_from_text(1, text);
2743 assert!(
2744 matches!(err, AgentError::StaleSession { .. }),
2745 "stale session must be detected via classify_from_text, got: {err:?}"
2746 );
2747 assert!(
2749 err.to_string().contains("stale session"),
2750 "error display must contain 'stale session' for recovery code to trigger"
2751 );
2752 }
2753
2754 #[test]
2755 fn classify_from_text_detects_socket_closed() {
2756 let text = "API Error: The socket connection was closed unexpectedly. \
2759 For more information, pass `verbose: true` in the second argument to fetch()";
2760 let err = patterns::classify_from_text(1, text);
2761 assert!(
2762 matches!(err, AgentError::NetworkError { .. }),
2763 "socket closed must be NetworkError, got: {err:?}"
2764 );
2765 }
2766
2767 #[test]
2768 fn classify_from_text_detects_socket_hang_up() {
2769 let err = patterns::classify_from_text(1, "Error: socket hang up");
2770 assert!(
2771 matches!(err, AgentError::NetworkError { .. }),
2772 "socket hang up must be NetworkError, got: {err:?}"
2773 );
2774 }
2775
2776 #[test]
2777 fn classify_from_text_detects_fetch_failed() {
2778 let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2779 assert!(
2780 matches!(err, AgentError::NetworkError { .. }),
2781 "fetch failed must be NetworkError, got: {err:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn classify_from_text_detects_econnreset() {
2787 let err = patterns::classify_from_text(1, "read ECONNRESET");
2788 assert!(
2789 matches!(err, AgentError::NetworkError { .. }),
2790 "ECONNRESET must be NetworkError, got: {err:?}"
2791 );
2792 }
2793
2794 #[test]
2795 fn classify_from_text_detects_stalled_mid_stream() {
2796 let err = patterns::classify_from_text(
2799 1,
2800 "API Error: Response stalled mid-stream. The response above may be incomplete.",
2801 );
2802 assert!(
2803 matches!(err, AgentError::NetworkError { .. }),
2804 "stalled mid-stream must be NetworkError, got: {err:?}"
2805 );
2806 }
2807
2808 #[test]
2809 fn classify_from_text_detects_connection_closed_mid_response() {
2810 let err = patterns::classify_from_text(
2813 1,
2814 "API Error: Connection closed mid-response. The response above may be incomplete.",
2815 );
2816 assert!(
2817 matches!(err, AgentError::NetworkError { .. }),
2818 "connection closed mid-response must be NetworkError, got: {err:?}"
2819 );
2820 }
2821
2822 #[test]
2823 fn synthesize_response_marks_done_for_plain_text_no_op() {
2824 let response = synthesize_response_from_text(
2825 "No open positions, no trade executions, no conditions change. Full cash, holding.",
2826 )
2827 .unwrap();
2828
2829 assert_eq!(response.status, "done");
2830 assert_eq!(
2831 response.summary,
2832 "No open positions, no trade executions, no conditions change. Full cash, holding."
2833 );
2834 assert!(response.error.is_none());
2835 }
2836
2837 #[test]
2838 fn synthesize_response_marks_needs_review_for_plain_text_error() {
2839 let response =
2840 synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2841
2842 assert_eq!(response.status, "needs_review");
2843 assert_eq!(
2844 response.error.as_deref(),
2845 Some("agent returned plain text instead of JSON")
2846 );
2847 }
2848
2849 #[test]
2850 fn synthesize_response_rejects_empty_text() {
2851 assert!(synthesize_response_from_text(" \n\t ").is_none());
2852 }
2853
2854 #[test]
2860 fn synthesize_response_rejects_ndjson_event_stream() {
2861 let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2862{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2863{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2864 assert!(
2865 synthesize_response_from_text(ndjson).is_none(),
2866 "raw NDJSON must not be interpreted as agent prose"
2867 );
2868
2869 let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2870 assert!(
2871 synthesize_response_from_text(single).is_none(),
2872 "single-line NDJSON event must also be rejected"
2873 );
2874 }
2875
2876 #[test]
2877 fn synthesize_response_accepts_json_without_event_type() {
2878 let text = "The task is complete. Filed issue #1234 successfully.";
2882 let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2883 assert_eq!(response.status, "done");
2884 }
2885
2886 #[test]
2893 fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2894 assert!(
2896 synthesize_response_from_text("Let's go!").is_none(),
2897 "startup greeting must not synthesize as needs_review"
2898 );
2899 assert!(
2901 synthesize_response_from_text(
2902 "I need to find where the stream command is implemented. \
2903 Looking at the CLI structure, it seems like the stream functionality \
2904 might be in the task module or another file. Let me check the CLI \
2905 command structure. First, let me look at the task module since stream \
2906 is related to tasks."
2907 )
2908 .is_none(),
2909 "exploratory prose must not synthesize as needs_review"
2910 );
2911 }
2912
2913 #[test]
2914 fn synthesize_response_does_not_mark_done_for_incomplete() {
2915 let response = synthesize_response_from_text("The task is incomplete").unwrap();
2916 assert_eq!(
2917 response.status, "needs_review",
2918 "\"incomplete\" should not match \"complete\""
2919 );
2920 }
2921
2922 #[test]
2923 fn synthesize_response_does_not_mark_done_for_not_done() {
2924 let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2925 assert_eq!(
2926 response.status, "needs_review",
2927 "\"not done\" should not match bare \"done\""
2928 );
2929 }
2930
2931 #[test]
2932 fn synthesize_response_does_not_mark_done_for_not_complete() {
2933 let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2934 assert_eq!(
2935 response.status, "needs_review",
2936 "\"not complete\" should not match \"complete\""
2937 );
2938 }
2939
2940 #[test]
2941 fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2942 let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2943 assert_eq!(
2944 response.status, "needs_review",
2945 "\"not yet completed\" should not match \"completed\""
2946 );
2947 }
2948
2949 #[test]
2950 fn synthesize_response_marks_done_for_completed() {
2951 let response =
2952 synthesize_response_from_text("Task has been completed successfully").unwrap();
2953 assert_eq!(response.status, "done");
2954 }
2955
2956 #[test]
2957 fn synthesize_response_marks_done_for_word_boundary_done() {
2958 let response = synthesize_response_from_text("All changes are done.").unwrap();
2959 assert_eq!(response.status, "done");
2960 }
2961
2962 #[test]
2963 fn synthesize_response_marks_done_for_issue_creation_text() {
2964 let response =
2965 synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2966 .unwrap();
2967
2968 assert_eq!(response.status, "done");
2969 assert!(response.error.is_none());
2970 assert!(response
2971 .summary
2972 .contains("Filed 3 high-value GitHub issues"));
2973 }
2974
2975 #[test]
2976 fn synthesize_response_marks_done_for_committed() {
2977 let response =
2978 synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2979 assert_eq!(response.status, "done");
2980 }
2981
2982 #[test]
2983 fn synthesize_response_rejects_exploratory_prose() {
2984 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.";
2990 match synthesize_response_from_text(exploratory) {
2991 None => {
2992 }
2994 Some(resp) => {
2995 assert_ne!(
2996 resp.status, "done",
2997 "Exploratory prose must not be auto-marked done"
2998 );
2999 }
3000 }
3001 }
3002
3003 #[test]
3004 fn synthesize_response_marks_done_for_commit_created() {
3005 let response = synthesize_response_from_text(
3006 "Commit created locally. The push is being blocked by permissions.",
3007 )
3008 .unwrap();
3009 assert_eq!(response.status, "done");
3010 }
3011
3012 #[test]
3013 fn synthesize_response_does_not_mark_done_for_not_committed() {
3014 let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
3015 assert_eq!(
3016 response.status, "needs_review",
3017 "\"not committed\" should not match \"committed\""
3018 );
3019 }
3020
3021 #[test]
3024 fn synthesize_response_marks_done_for_fix_is_complete() {
3025 let response = synthesize_response_from_text(
3029 "All 73 cooldown tests pass. The fix is complete.\n\n\
3030 **Summary of changes:**\n\n\
3031 1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
3032 `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
3033 )
3034 .unwrap();
3035 assert_eq!(
3036 response.status, "done",
3037 "\"The fix is complete\" must be classified as done, not needs_review"
3038 );
3039 }
3040
3041 #[test]
3042 fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3043 let response = synthesize_response_from_text(
3047 "All tests pass. The fix is clean and working.\n\n\
3048 **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3049 - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3050 - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3051 )
3052 .unwrap();
3053 assert_eq!(
3054 response.status, "done",
3055 "\"All tests pass. The fix is clean and working.\" must be classified as done"
3056 );
3057 }
3058
3059 #[test]
3062 fn parse_ndjson_handles_standard_lines() {
3063 let raw = r#"{"type":"text","text":"hello"}
3064{"type":"text","text":"world"}"#;
3065 let events = parse_ndjson(raw);
3066 assert_eq!(events.len(), 2);
3067 assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3068 assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3069 }
3070
3071 #[test]
3072 fn parse_ndjson_skips_empty_lines() {
3073 let raw = r#"{"type":"text"}
3074
3075{"type":"text"}
3076"#;
3077 let events = parse_ndjson(raw);
3078 assert_eq!(events.len(), 2);
3079 }
3080
3081 #[test]
3082 fn parse_ndjson_skips_malformed_lines() {
3083 let raw = r#"{"type":"valid"}
3084not json at all
3085{"type":"also_valid"}"#;
3086 let events = parse_ndjson(raw);
3087 assert_eq!(events.len(), 2);
3088 }
3089
3090 #[test]
3091 fn parse_ndjson_fallback_extracts_embedded_json() {
3092 let raw =
3094 r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3095 let events = parse_ndjson(raw);
3096 assert!(
3098 !events.is_empty(),
3099 "should extract JSON objects via fallback"
3100 );
3101 }
3102
3103 #[test]
3104 fn parse_ndjson_fallback_handles_fragmented_objects() {
3105 let raw = r#"{"type": "text
3107", "content": "hello
3108world"}"#;
3109 let events = parse_ndjson(raw);
3110 assert!(!events.is_empty(), "should handle fragmented JSON");
3112 }
3113
3114 #[test]
3117 fn permission_rules_default_is_autonomous() {
3118 let rules = PermissionRules::default();
3119 assert!(rules.autonomous);
3120 }
3121
3122 #[test]
3123 fn permission_rules_default_sandbox_is_workspace_write() {
3124 let rules = PermissionRules::default();
3125 assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3126 }
3127
3128 #[test]
3129 fn permission_rules_default_disallows_rm() {
3130 let rules = PermissionRules::default();
3131 assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3132 assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3133 }
3134
3135 #[test]
3136 fn permission_rules_default_no_allowed_edit_paths() {
3137 let rules = PermissionRules::default();
3138 assert!(rules.allowed_edit_paths.is_empty());
3139 }
3140
3141 #[test]
3145 fn all_agents_handle_default_permissions() {
3146 let perms = PermissionRules::default();
3147 let sys = "/tmp/sys.md";
3148 let msg = "/tmp/msg.md";
3149
3150 let claude = get_runner("claude");
3152 let cmd = claude.build_command(None, "", sys, msg, &perms);
3153 assert!(
3154 cmd.contains("--permission-mode bypassPermissions"),
3155 "claude default: expected bypassPermissions, got: {cmd}"
3156 );
3157 assert!(
3158 cmd.contains("Bash(rm *)"),
3159 "claude default: expected rm disallowed, got: {cmd}"
3160 );
3161
3162 let codex = get_runner("codex");
3164 let cmd = codex.build_command(None, "", sys, msg, &perms);
3165 assert!(
3166 cmd.contains("--sandbox workspace-write"),
3167 "codex default: expected --sandbox workspace-write, got: {cmd}"
3168 );
3169 assert!(
3170 cmd.contains(r#"-c 'approval_policy="never"'"#),
3171 "codex default: expected -c approval_policy=never, got: {cmd}"
3172 );
3173 assert!(
3174 !cmd.contains("--ask-for-approval"),
3175 "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3176 );
3177 assert!(
3178 !cmd.contains("--full-auto"),
3179 "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3180 );
3181
3182 let opencode = get_runner("opencode");
3185 let cmd = opencode.build_command(None, "", sys, msg, &perms);
3186 assert!(
3187 cmd.contains("--dangerously-skip-permissions"),
3188 "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3189 );
3190 assert!(
3191 !cmd.contains("opencode.json"),
3192 "opencode default: should not write a config file, got: {cmd}"
3193 );
3194 assert!(
3195 !cmd.contains("XDG_CONFIG_HOME"),
3196 "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3197 );
3198 assert!(
3199 !cmd.contains(".orch-opencode"),
3200 "opencode default: should not reference .orch-opencode, got: {cmd}"
3201 );
3202 }
3203
3204 #[test]
3206 fn all_agents_handle_supervised_mode() {
3207 let perms = PermissionRules {
3208 autonomous: false,
3209 sandbox: SandboxLevel::WorkspaceWrite,
3210 disallowed_tools: vec![],
3211 allowed_tools: vec![],
3212 allowed_edit_paths: vec![],
3213 deny_read_only: false,
3214 extra_writable_dirs: vec![],
3215 };
3216 let sys = "/tmp/sys.md";
3217 let msg = "/tmp/msg.md";
3218
3219 let claude = get_runner("claude");
3221 let cmd = claude.build_command(None, "", sys, msg, &perms);
3222 assert!(
3223 cmd.contains("--permission-mode acceptEdits"),
3224 "claude supervised: expected acceptEdits, got: {cmd}"
3225 );
3226
3227 let codex = get_runner("codex");
3229 let cmd = codex.build_command(None, "", sys, msg, &perms);
3230 assert!(
3231 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3232 "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3233 );
3234 assert!(
3235 !cmd.contains("--ask-for-approval"),
3236 "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3237 );
3238 }
3239
3240 #[test]
3242 fn all_agents_handle_full_access_sandbox() {
3243 let perms = PermissionRules {
3244 autonomous: true,
3245 sandbox: SandboxLevel::FullAccess,
3246 disallowed_tools: vec![],
3247 allowed_tools: vec![],
3248 allowed_edit_paths: vec![],
3249 deny_read_only: false,
3250 extra_writable_dirs: vec![],
3251 };
3252 let sys = "/tmp/sys.md";
3253 let msg = "/tmp/msg.md";
3254
3255 let claude = get_runner("claude");
3257 let cmd = claude.build_command(None, "", sys, msg, &perms);
3258 assert!(
3259 !cmd.contains("--sandbox"),
3260 "claude full_access: should have no sandbox flag, got: {cmd}"
3261 );
3262
3263 let codex = get_runner("codex");
3265 let cmd = codex.build_command(None, "", sys, msg, &perms);
3266 assert!(
3267 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3268 "codex full_access: expected dangerously-bypass, got: {cmd}"
3269 );
3270 }
3271
3272 #[test]
3274 fn codex_sandbox_none_uses_workspace_write() {
3275 let perms = PermissionRules {
3276 autonomous: true,
3277 sandbox: SandboxLevel::None,
3278 disallowed_tools: vec![],
3279 allowed_tools: vec![],
3280 allowed_edit_paths: vec![],
3281 deny_read_only: false,
3282 extra_writable_dirs: vec![],
3283 };
3284 let codex = get_runner("codex");
3285 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3286 assert!(
3287 cmd.contains("--sandbox workspace-write"),
3288 "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3289 );
3290 assert!(
3291 cmd.contains(r#"-c 'approval_policy="never"'"#),
3292 "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3293 );
3294 assert!(
3295 !cmd.contains("--ask-for-approval"),
3296 "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3297 );
3298 assert!(
3299 !cmd.contains("--full-auto"),
3300 "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3301 );
3302 }
3303
3304 #[test]
3306 fn claude_allowed_edit_paths_scope_edit_write() {
3307 let perms = PermissionRules {
3308 autonomous: true,
3309 sandbox: SandboxLevel::WorkspaceWrite,
3310 disallowed_tools: vec![],
3311 allowed_tools: vec![
3312 "Edit".to_string(),
3313 "Write".to_string(),
3314 "Read".to_string(),
3315 "Bash".to_string(),
3316 ],
3317 allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3318 deny_read_only: false,
3319 extra_writable_dirs: vec![],
3320 };
3321 let claude = get_runner("claude");
3322 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3323
3324 assert!(
3326 cmd.contains("Edit(/home/user/worktree/*)"),
3327 "missing scoped Edit, got: {cmd}"
3328 );
3329 assert!(
3330 cmd.contains("Write(/home/user/worktree/*)"),
3331 "missing scoped Write, got: {cmd}"
3332 );
3333 assert!(cmd.contains("Read"), "missing Read");
3335 }
3336
3337 #[test]
3339 fn codex_ignores_allowed_edit_paths() {
3340 let perms = PermissionRules {
3341 autonomous: true,
3342 sandbox: SandboxLevel::WorkspaceWrite,
3343 disallowed_tools: vec![],
3344 allowed_tools: vec![],
3345 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3346 deny_read_only: false,
3347 extra_writable_dirs: vec![],
3348 };
3349 let codex = get_runner("codex");
3350 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3351 assert!(
3352 !cmd.contains("/home/user/project"),
3353 "codex: should not reference blocked paths, got: {cmd}"
3354 );
3355 }
3356
3357 #[test]
3359 fn opencode_ignores_allowed_edit_paths() {
3360 let perms = PermissionRules {
3361 autonomous: true,
3362 sandbox: SandboxLevel::WorkspaceWrite,
3363 disallowed_tools: vec![],
3364 allowed_tools: vec![],
3365 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3366 deny_read_only: false,
3367 extra_writable_dirs: vec![],
3368 };
3369 let opencode = get_runner("opencode");
3370 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3371 assert!(
3372 !cmd.contains("/home/user/project"),
3373 "opencode: should not reference blocked paths, got: {cmd}"
3374 );
3375 }
3376
3377 #[test]
3379 fn claude_merges_disallowed_tools() {
3380 let perms = PermissionRules {
3381 autonomous: true,
3382 sandbox: SandboxLevel::WorkspaceWrite,
3383 disallowed_tools: vec![
3384 "Bash(rm *)".to_string(),
3385 "Bash(sudo *)".to_string(),
3386 "WebFetch".to_string(),
3387 ],
3388 allowed_tools: vec![],
3389 allowed_edit_paths: vec![],
3390 deny_read_only: false,
3391 extra_writable_dirs: vec![],
3392 };
3393 let claude = get_runner("claude");
3394 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3395 assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3396 assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3397 assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3398 }
3399
3400 #[test]
3402 fn codex_ignores_disallowed_tools() {
3403 let perms = PermissionRules {
3404 autonomous: true,
3405 sandbox: SandboxLevel::WorkspaceWrite,
3406 disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3407 allowed_tools: vec![],
3408 allowed_edit_paths: vec![],
3409 deny_read_only: false,
3410 extra_writable_dirs: vec![],
3411 };
3412 let codex = get_runner("codex");
3413 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3414 assert!(
3415 !cmd.contains("disallow"),
3416 "codex: should not have disallow flags, got: {cmd}"
3417 );
3418 }
3419
3420 #[test]
3422 fn claude_no_disallowed_flag_when_empty() {
3423 let perms = PermissionRules {
3424 autonomous: true,
3425 sandbox: SandboxLevel::WorkspaceWrite,
3426 disallowed_tools: vec![],
3427 allowed_tools: vec![],
3428 allowed_edit_paths: vec![],
3429 deny_read_only: false,
3430 extra_writable_dirs: vec![],
3431 };
3432 let claude = get_runner("claude");
3433 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3434 assert!(
3435 !cmd.contains("--disallowedTools"),
3436 "claude: should have no disallowed flag when empty, got: {cmd}"
3437 );
3438 }
3439
3440 #[test]
3442 fn kimi_minimax_use_claude_permissions() {
3443 let perms = PermissionRules {
3444 autonomous: false,
3445 sandbox: SandboxLevel::WorkspaceWrite,
3446 disallowed_tools: vec!["Bash(rm *)".to_string()],
3447 allowed_tools: vec![],
3448 allowed_edit_paths: vec![],
3449 deny_read_only: false,
3450 extra_writable_dirs: vec![],
3451 };
3452 let sys = "/tmp/sys.md";
3453 let msg = "/tmp/msg.md";
3454
3455 for agent in &["kimi", "minimax"] {
3456 let runner = get_runner(agent);
3457 let cmd = runner.build_command(None, "", sys, msg, &perms);
3458 assert!(
3459 cmd.contains("--permission-mode acceptEdits"),
3460 "{agent}: expected acceptEdits"
3461 );
3462 assert!(
3463 cmd.contains("Bash(rm *)"),
3464 "{agent}: expected rm disallowed"
3465 );
3466 }
3467 }
3468
3469 #[test]
3471 fn kimi_minimax_use_allowed_tools() {
3472 let perms = PermissionRules {
3473 autonomous: true,
3474 sandbox: SandboxLevel::WorkspaceWrite,
3475 disallowed_tools: vec![],
3476 allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3477 allowed_edit_paths: vec![],
3478 deny_read_only: false,
3479 extra_writable_dirs: vec![],
3480 };
3481
3482 for agent in &["kimi", "minimax"] {
3483 let runner = get_runner(agent);
3484 let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3485 assert!(
3486 cmd.contains("--allowedTools"),
3487 "{agent}: expected --allowedTools, got: {cmd}"
3488 );
3489 assert!(
3490 cmd.contains("Bash(git *)"),
3491 "{agent}: expected Bash(git *) pattern, got: {cmd}"
3492 );
3493 }
3494 }
3495
3496 #[test]
3500 fn claude_allowed_tools_generates_allowedtools_flag() {
3501 let perms = PermissionRules {
3502 autonomous: true,
3503 sandbox: SandboxLevel::WorkspaceWrite,
3504 disallowed_tools: vec!["Bash(rm *)".to_string()],
3505 allowed_tools: vec![
3506 "Edit".to_string(),
3507 "Write".to_string(),
3508 "Read".to_string(),
3509 "Bash".to_string(),
3510 "git".to_string(),
3511 ],
3512 allowed_edit_paths: vec![],
3513 deny_read_only: false,
3514 extra_writable_dirs: vec![],
3515 };
3516
3517 let claude = get_runner("claude");
3518 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3519 assert!(
3520 cmd.contains("--allowedTools"),
3521 "expected --allowedTools, got: {cmd}"
3522 );
3523 assert!(
3524 !cmd.contains("--disallowedTools"),
3525 "should not have --disallowedTools when allowed_tools is set"
3526 );
3527 }
3528
3529 #[test]
3531 fn codex_opencode_ignore_allowed_tools() {
3532 let perms = PermissionRules {
3533 autonomous: true,
3534 sandbox: SandboxLevel::WorkspaceWrite,
3535 disallowed_tools: vec![],
3536 allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3537 allowed_edit_paths: vec![],
3538 deny_read_only: false,
3539 extra_writable_dirs: vec![],
3540 };
3541
3542 let codex = get_runner("codex");
3543 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3544 assert!(
3545 !cmd.contains("--allowedTools"),
3546 "codex should not have --allowedTools, got: {cmd}"
3547 );
3548
3549 let opencode = get_runner("opencode");
3550 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3551 assert!(
3552 !cmd.contains("--allowedTools"),
3553 "opencode should not have --allowedTools, got: {cmd}"
3554 );
3555 }
3556
3557 #[test]
3562 #[ignore]
3563 fn integration_from_config_to_agent_commands() {
3564 let perms = PermissionRules::from_config();
3565
3566 assert!(
3568 perms.autonomous,
3569 "from_config: expected autonomous=true by default"
3570 );
3571
3572 for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3574 let runner = get_runner(agent_name);
3575 let cmd =
3576 runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3577 assert!(
3578 !cmd.is_empty(),
3579 "{agent_name}: build_command returned empty string"
3580 );
3581 assert!(
3582 cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3583 "{agent_name}: command should reference agent binary, got: {cmd}"
3584 );
3585 }
3586 }
3587
3588 #[test]
3591 #[ignore]
3592 fn integration_supervised_config_translates_correctly() {
3593 let perms = PermissionRules::from_config();
3598
3599 let claude = get_runner("claude");
3600 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3601
3602 if perms.autonomous {
3603 assert!(
3604 cmd.contains("bypassPermissions"),
3605 "autonomous config → bypassPermissions"
3606 );
3607 } else {
3608 assert!(
3609 cmd.contains("acceptEdits"),
3610 "supervised config → acceptEdits"
3611 );
3612 }
3613
3614 let codex = get_runner("codex");
3615 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3616
3617 if perms.autonomous {
3618 assert!(
3620 cmd.contains("--sandbox workspace-write"),
3621 "autonomous config → --sandbox workspace-write, got: {cmd}"
3622 );
3623 assert!(
3624 cmd.contains(r#"-c 'approval_policy="never"'"#),
3625 "autonomous config → -c approval_policy=never, got: {cmd}"
3626 );
3627 } else {
3628 assert!(
3629 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3630 "supervised config → -c approval_policy=on-request, got: {cmd}"
3631 );
3632 }
3633 assert!(
3634 !cmd.contains("--ask-for-approval"),
3635 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3636 );
3637 }
3638
3639 #[test]
3643 fn deny_read_only_blocks_reading_tools() {
3644 let perms = PermissionRules::deny_read_only();
3645
3646 let read_tools = [
3647 "Bash(ls *)",
3648 "Bash(ls)",
3649 "Bash(find *)",
3650 "Bash(cat *)",
3651 "Bash(head *)",
3652 "Bash(tail *)",
3653 "Bash(fzf *)",
3654 "Bash(less *)",
3655 "Bash(more *)",
3656 "Read",
3657 "Glob",
3658 "Grep",
3659 ];
3660
3661 for tool in read_tools {
3662 assert!(
3663 perms.disallowed_tools.contains(&tool.to_string()),
3664 "deny_read_only should block {tool}, got: {:?}",
3665 perms.disallowed_tools
3666 );
3667 }
3668 }
3669
3670 #[test]
3672 fn deny_read_only_claude_command() {
3673 let perms = PermissionRules::deny_read_only();
3674 let claude = get_runner("claude");
3675 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3676
3677 assert!(
3679 cmd.contains("Read"),
3680 "should block Read for control session"
3681 );
3682 assert!(
3683 cmd.contains("Glob"),
3684 "should block Glob for control session"
3685 );
3686 assert!(
3687 cmd.contains("Grep"),
3688 "should block Grep for control session"
3689 );
3690 assert!(
3692 cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3693 "should block ls"
3694 );
3695 assert!(
3697 cmd.contains("--permission-mode bypassPermissions"),
3698 "should be autonomous (bypassPermissions)"
3699 );
3700 }
3701
3702 #[test]
3705 fn synthesize_extracts_file_paths() {
3706 let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3707 let resp = synthesize_response_from_text(text).unwrap();
3708 assert!(
3709 resp.files
3710 .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3711 "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3712 resp.files
3713 );
3714 assert!(
3715 resp.files.contains(&"tests/integration.rs".to_string()),
3716 "expected tests/integration.rs in files, got: {:?}",
3717 resp.files
3718 );
3719 }
3720
3721 #[test]
3722 fn synthesize_extracts_accomplished_bullets() {
3723 let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3724 let resp = synthesize_response_from_text(text).unwrap();
3725 assert_eq!(resp.status, "done");
3726 assert!(
3727 resp.accomplished
3728 .iter()
3729 .any(|a| a.contains("Added logging")),
3730 "expected accomplished bullet, got: {:?}",
3731 resp.accomplished
3732 );
3733 assert!(
3734 resp.accomplished
3735 .iter()
3736 .any(|a| a.contains("Updated migration")),
3737 "expected accomplished bullet, got: {:?}",
3738 resp.accomplished
3739 );
3740 }
3741
3742 #[test]
3743 fn synthesize_extracts_remaining_section() {
3744 let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3745 let resp = synthesize_response_from_text(text).unwrap();
3746 assert!(
3747 resp.remaining
3748 .iter()
3749 .any(|r| r.contains("Write unit tests")),
3750 "expected remaining item, got: {:?}",
3751 resp.remaining
3752 );
3753 assert!(
3754 resp.remaining.iter().any(|r| r.contains("Update docs")),
3755 "expected remaining item, got: {:?}",
3756 resp.remaining
3757 );
3758 assert!(
3759 resp.accomplished.iter().any(|a| a.contains("Patched")),
3760 "expected accomplished item, got: {:?}",
3761 resp.accomplished
3762 );
3763 }
3764
3765 #[test]
3766 fn synthesize_fixed_phrase_is_done() {
3767 let text = "Fixed. The bug no longer reproduces.";
3768 let resp = synthesize_response_from_text(text).unwrap();
3769 assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3770 }
3771
3772 #[test]
3779 fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3780 let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3784 assert!(
3785 patterns::detect_rate_limit(payload).is_some(),
3786 "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3787 );
3788 }
3789
3790 #[test]
3791 fn detect_rate_limit_ignores_429_inside_longer_number() {
3792 let uuid = r#"{"type":"hook","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b"}"#;
3793 assert!(
3794 patterns::detect_rate_limit(uuid).is_none(),
3795 "429 inside a UUID digit run must not match"
3796 );
3797 }
3798
3799 #[test]
3800 fn detect_rate_limit_ignores_429_in_uuid_hex_group() {
3801 let hook = r#"{"type":"system","subtype":"hook_response","hook_id":"18db61b6-7b72-429c-8279-fae3089bf565"}"#;
3802 assert!(patterns::detect_rate_limit(hook).is_none());
3803 }
3804
3805 #[test]
3806 fn detect_rate_limit_429_message_comes_from_matched_line() {
3807 let filler = "unrelated agent prose that is long enough to fill the window. ".repeat(20);
3808 let text = format!("HTTP 429 Too Many Requests\n{filler}");
3809 match patterns::detect_rate_limit(&text) {
3810 Some(AgentError::RateLimit { message }) => assert!(message.contains("429")),
3811 other => panic!("expected RateLimit, got {other:?}"),
3812 }
3813 }
3814
3815 #[test]
3819 fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3820 let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3821 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3822 && !raw_output.contains("\"is_error\":true");
3823 let detected = (!ndjson_completed)
3825 .then(|| patterns::detect_rate_limit(raw_output))
3826 .flatten();
3827 assert!(
3828 detected.is_none(),
3829 "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3830 );
3831 }
3832
3833 #[test]
3835 fn terminal_reason_completed_guard_passes_real_rate_limit() {
3836 let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3838 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3839 && !raw_output.contains("\"is_error\":true");
3840 let detected = (!ndjson_completed)
3841 .then(|| patterns::detect_rate_limit(raw_output))
3842 .flatten();
3843 assert!(
3844 detected.is_some(),
3845 "guard must NOT suppress a real rate-limit error"
3846 );
3847 }
3848
3849 #[test]
3850 fn synthesize_no_false_positive_file_paths() {
3851 let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3855 match synthesize_response_from_text(text) {
3856 None => {} Some(resp) => {
3858 assert!(
3859 resp.files.is_empty(),
3860 "expected no file paths extracted from URL text, got: {:?}",
3861 resp.files
3862 );
3863 }
3864 }
3865 }
3866
3867 #[test]
3872 fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3873 let text = "error: unexpected argument '--ask-for-approval' found\n \
3875 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3876 Usage: codex exec [OPTIONS] [PROMPT]";
3877 assert!(
3878 synthesize_response_from_text(text).is_none(),
3879 "CLI parser diagnostic must not synthesize as agent response"
3880 );
3881 }
3882
3883 #[test]
3884 fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3885 let text = "Usage: mycommand [OPTIONS] <arg>";
3886 assert!(
3887 synthesize_response_from_text(text).is_none(),
3888 "Usage line must not synthesize as agent response"
3889 );
3890 }
3891
3892 #[test]
3893 fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3894 let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3895 assert!(
3896 synthesize_response_from_text(text).is_none(),
3897 "Help hint must not synthesize as agent response"
3898 );
3899 }
3900
3901 #[test]
3902 fn synthesize_rejects_cli_parser_diagnostics_tip() {
3903 let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3904 assert!(
3905 synthesize_response_from_text(text).is_none(),
3906 "Clap tip must not synthesize as agent response"
3907 );
3908 }
3909
3910 #[test]
3911 fn synthesize_accepts_agent_error_messages_that_mention_error() {
3912 let text = "I tried to run the command but got an error: permission denied on that file.";
3915 let response =
3916 synthesize_response_from_text(text).expect("agent error message should synthesize");
3917 assert_eq!(response.status, "needs_review");
3918 assert!(response.error.is_some());
3919 }
3920
3921 #[test]
3926 fn synthesize_rejects_exact_codex_cli_error() {
3927 let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n \
3928 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3929 Usage: codex exec [OPTIONS] [PROMPT]";
3930 assert!(
3931 synthesize_response_from_text(stderr_from_codex).is_none(),
3932 "exact codex CLI error from #3197 must not synthesize as agent response"
3933 );
3934 }
3935}