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 has_429 = contains_standalone_digits(&lower, "429");
1274 let has_529 = lower.contains("http 529")
1278 || lower.contains("529 service")
1279 || (lower.find(": 529").is_some_and(|pos| {
1280 !lower[pos + 5..]
1281 .chars()
1282 .next()
1283 .is_some_and(|c| c.is_ascii_digit())
1284 }));
1285
1286 if match_info.is_some() || has_429 || has_529 {
1287 let message = if let Some((_, pos)) = match_info {
1288 extract_context_around(text, pos, 300)
1289 } else {
1290 safe_tail(text, 300).to_string()
1291 };
1292 return Some(AgentError::RateLimit { message });
1293 }
1294 None
1295 }
1296
1297 fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1303 let half = window / 2;
1304 let start = byte_pos.saturating_sub(half);
1305 let end = (byte_pos + window).min(text.len());
1306 let mut s = start;
1308 while s < text.len() && !text.is_char_boundary(s) {
1309 s += 1;
1310 }
1311 let mut e = end;
1312 while e < text.len() && !text.is_char_boundary(e) {
1313 e += 1;
1314 }
1315 text[s..e].to_string()
1316 }
1317
1318 fn contains_standalone_digits(lower: &str, digits: &str) -> bool {
1320 let mut start = 0;
1321 while let Some(rel) = lower[start..].find(digits) {
1322 let pos = start + rel;
1323 let before_ok = pos == 0
1324 || !lower[..pos]
1325 .chars()
1326 .next_back()
1327 .is_some_and(|c| c.is_ascii_digit());
1328 let after = pos + digits.len();
1329 let after_ok = after >= lower.len()
1330 || !lower[after..]
1331 .chars()
1332 .next()
1333 .is_some_and(|c| c.is_ascii_digit());
1334 if before_ok && after_ok {
1335 return true;
1336 }
1337 start = pos + 1;
1338 }
1339 false
1340 }
1341
1342 pub fn contains_http_status(lower: &str, code: &str) -> bool {
1350 if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1352 return true;
1353 }
1354 let needle = format!(": {code}");
1356 let mut start = 0;
1357 while let Some(rel) = lower[start..].find(needle.as_str()) {
1358 let after = start + rel + needle.len();
1359 match lower[after..].chars().next() {
1360 Some(c) if c.is_ascii_digit() => {
1361 start = after;
1363 }
1364 _ => return true,
1365 }
1366 }
1367 false
1368 }
1369
1370 pub fn detect_auth_error(text: &str) -> Option<AgentError> {
1376 let lower = text.to_lowercase();
1377 let patterns = [
1378 "unauthorized",
1379 "authentication required",
1380 "invalid api",
1381 "invalid key",
1382 "invalid token",
1383 "auth fail",
1384 "no api key",
1385 "no token",
1386 "expired key",
1387 "expired token",
1388 "expired plan",
1389 "billing plan",
1393 "billing error",
1394 "billing suspended",
1395 "billing expired",
1396 "billing limit",
1397 "billing failed",
1398 "billing cycle exhausted",
1399 "insufficient credit",
1400 "insufficient balance",
1401 "credit balance too low",
1402 "payment required",
1403 ];
1404 let http_401 = contains_http_status(&lower, "401");
1407 let http_403 = contains_http_status(&lower, "403");
1408 let http_407 = contains_http_status(&lower, "407");
1409 let proxy_auth_required = lower.contains("proxy authentication required");
1410 if !patterns.iter().any(|p| lower.contains(p))
1411 && !http_401
1412 && !http_403
1413 && !http_407
1414 && !proxy_auth_required
1415 {
1416 return None;
1417 }
1418 let first_match_pos =
1420 patterns
1421 .iter()
1422 .filter_map(|p| lower.find(p))
1423 .min()
1424 .map(|lower_pos| {
1425 let char_idx = lower[..lower_pos].chars().count();
1426 text.char_indices()
1427 .nth(char_idx)
1428 .map_or(text.len(), |(i, _)| i)
1429 });
1430 let http_401_match_pos = if http_401 {
1433 lower.find("401 ").or_else(|| lower.find(": 401"))
1434 } else {
1435 None
1436 };
1437 let http_403_match_pos = if http_403 {
1438 lower.find("403 ").or_else(|| lower.find(": 403"))
1439 } else {
1440 None
1441 };
1442 let http_407_match_pos = if http_407 {
1443 lower.find("407 ").or_else(|| lower.find(": 407"))
1444 } else {
1445 None
1446 };
1447 let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1448 .into_iter()
1449 .flatten()
1450 .min()
1451 .and_then(|rel| {
1452 let char_idx = lower[..rel].chars().count();
1453 text.char_indices().nth(char_idx).map(|(i, _)| i)
1454 });
1455 let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1456 let message = if let Some(pos) = match_pos {
1457 find_line_containing(text, pos)
1458 } else {
1459 find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1462 };
1463 Some(AgentError::Auth { message })
1464 }
1465
1466 fn find_line_containing(text: &str, byte_pos: usize) -> String {
1468 let start = text[..byte_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
1469 let end = text[byte_pos..]
1470 .find('\n')
1471 .map(|i| byte_pos + i)
1472 .unwrap_or(text.len());
1473 text[start..end].trim().to_string()
1474 }
1475
1476 fn find_auth_line_scan(text: &str) -> Option<String> {
1480 for line in text.lines().rev().take(50) {
1481 let line_lower = line.to_lowercase();
1482 if contains_http_status(&line_lower, "401")
1483 || contains_http_status(&line_lower, "403")
1484 || contains_http_status(&line_lower, "407")
1485 || line_lower.contains("unauthorized")
1486 || line_lower.contains("authentication required")
1487 || line_lower.contains("invalid api")
1488 || line_lower.contains("invalid key")
1489 || line_lower.contains("invalid token")
1490 || line_lower.contains("auth fail")
1491 || line_lower.contains("no api key")
1492 || line_lower.contains("no token")
1493 || line_lower.contains("expired key")
1494 || line_lower.contains("expired token")
1495 || line_lower.contains("expired plan")
1496 || line_lower.contains("billing plan")
1497 || line_lower.contains("billing error")
1498 || line_lower.contains("billing suspended")
1499 || line_lower.contains("billing expired")
1500 || line_lower.contains("billing limit")
1501 || line_lower.contains("billing failed")
1502 || line_lower.contains("billing cycle exhausted")
1503 || line_lower.contains("insufficient credit")
1504 || line_lower.contains("credit balance too low")
1505 || line_lower.contains("payment required")
1506 || line_lower.contains("proxy authentication required")
1507 {
1508 return Some(line.trim().to_string());
1509 }
1510 }
1511 None
1512 }
1513
1514 pub fn detect_network_error(text: &str) -> Option<AgentError> {
1516 let lower = text.to_lowercase();
1517 let patterns = [
1518 "connectionrefused",
1519 "connection refused",
1520 "unable to connect",
1521 "econnrefused",
1522 "network unreachable",
1523 "socket connection was closed",
1525 "socket hang up",
1526 "econnreset",
1527 "connection reset",
1528 "etimedout",
1529 "connect etimedout",
1530 "aborterror",
1531 "the operation was aborted",
1532 "network request failed",
1533 "fetch failed",
1534 "upstream idle timeout",
1536 "streaming response failed",
1537 "upstream request failed",
1538 "stalled mid-stream",
1540 "connection closed mid-response",
1541 ];
1542 let match_pos = patterns
1545 .iter()
1546 .filter_map(|p| lower.find(p))
1547 .min()
1548 .map(|lower_pos| {
1549 let char_idx = lower[..lower_pos].chars().count();
1550 text.char_indices()
1551 .nth(char_idx)
1552 .map_or(text.len(), |(i, _)| i)
1553 });
1554 if let Some(pos) = match_pos {
1555 return Some(AgentError::NetworkError {
1556 message: extract_context_around(text, pos, 300),
1557 });
1558 }
1559 None
1560 }
1561
1562 pub fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1572 let lower = text.to_lowercase();
1573 let is_model_unavailable = lower.contains("model metadata")
1574 || lower.contains("no endpoints found")
1575 || (lower.contains("model")
1576 && (lower.contains("not found")
1577 || lower.contains("not supported")
1578 || lower.contains("does not exist")
1579 || lower.contains("deprecated")
1580 || lower.contains("unavailable for free")
1581 || lower.contains("not available in your country")
1582 || lower.contains("not available in your region")));
1583 if !is_model_unavailable {
1584 return None;
1585 }
1586 let model = extract_quoted(text, '`')
1587 .or_else(|| extract_quoted(text, '\''))
1588 .unwrap_or_default();
1589 Some(AgentError::ModelUnavailable {
1590 message: safe_tail(text, 300).to_string(),
1591 model,
1592 })
1593 }
1594
1595 fn extract_quoted(text: &str, quote: char) -> Option<String> {
1597 let start = text.find(quote)?;
1598 let rest = &text[start + quote.len_utf8()..];
1599 let end = rest.find(quote)?;
1600 Some(rest[..end].to_string())
1601 }
1602
1603 pub fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1605 let lower = text.to_lowercase();
1606 let patterns = ["worktree directory does not exist"];
1607 if patterns.iter().any(|p| lower.contains(p)) {
1608 return Some(AgentError::AgentFailed {
1609 message: safe_tail(text, 300).to_string(),
1610 });
1611 }
1612 None
1613 }
1614
1615 pub fn detect_context_overflow(text: &str) -> Option<AgentError> {
1617 let lower = text.to_lowercase();
1618 let patterns = [
1619 "context_length_exceeded",
1620 "context length exceeded",
1621 "maximum context length",
1622 "too many tokens",
1623 "token limit",
1624 "context overflow",
1625 ];
1626 if patterns.iter().any(|p| lower.contains(p)) {
1627 return Some(AgentError::ContextOverflow {
1628 message: safe_tail(text, 300).to_string(),
1629 });
1630 }
1631 None
1632 }
1633
1634 pub fn detect_missing_tool(text: &str) -> Option<AgentError> {
1636 let known_tools = [
1637 "bun",
1638 "node",
1639 "npm",
1640 "pnpm",
1641 "yarn",
1642 "deno",
1643 "tsc",
1644 "eslint",
1645 "prettier",
1646 "jest",
1647 "vitest",
1648 "cargo",
1649 "rustc",
1650 "go",
1651 "python",
1652 "python3",
1653 "pip",
1654 "pip3",
1655 "uv",
1656 "poetry",
1657 "pytest",
1658 "ruff",
1659 "black",
1660 "mypy",
1661 "make",
1662 "cmake",
1663 "ninja",
1664 "just",
1665 "bats",
1666 "docker",
1667 "docker-compose",
1668 "podman",
1669 "kubectl",
1670 "helm",
1671 "terraform",
1672 "anchor",
1673 "avm",
1674 "solana",
1675 "solana-test-validator",
1676 ];
1677
1678 let lower = text.to_lowercase();
1679 for tool in &known_tools {
1680 if lower.contains(&format!("{tool}: command not found"))
1681 || lower.contains(&format!("command not found: {tool}"))
1682 || lower.contains(&format!("{tool}: not found"))
1683 || lower.contains(&format!("env: {tool}: no such file"))
1684 || lower.contains(&format!("spawn {tool} enoent"))
1685 {
1686 return Some(AgentError::MissingTool {
1687 tool: tool.to_string(),
1688 });
1689 }
1690 }
1691 None
1692 }
1693
1694 pub fn detect_permission_denied(text: &str) -> Option<AgentError> {
1696 let lower = text.to_lowercase();
1697 let patterns = [
1698 "permission denied",
1699 "rejected permission",
1700 "permissionerror",
1701 "operation not permitted",
1702 "sandbox violation",
1703 "access denied",
1704 "eacces",
1705 "eperm",
1706 "read-only file system",
1707 "not writable",
1708 "disallowed tool",
1709 ];
1710 if patterns.iter().any(|p| lower.contains(p)) {
1711 return Some(AgentError::PermissionDenied {
1712 message: safe_tail(text, 300).to_string(),
1713 });
1714 }
1715 None
1716 }
1717
1718 pub fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1720 let lower = text.to_lowercase();
1721 let patterns = [
1722 "enter passphrase",
1723 "1password",
1724 "op signin",
1725 "ssh passphrase",
1726 "password:",
1727 ];
1728 let auth_required_with_interactive_context = lower.contains("authentication required")
1729 && (lower.contains("ssh")
1730 || lower.contains("passphrase")
1731 || lower.contains("1password")
1732 || lower.contains("op signin"));
1733 if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1734 return Some(AgentError::WaitingForInput {
1735 message: safe_tail(text, 300).to_string(),
1736 });
1737 }
1738 None
1739 }
1740
1741 pub fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1747 if text.contains(
1750 "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1751 ) {
1752 let message = text
1753 .lines()
1754 .find(|l| l.contains("cannot be modified"))
1755 .map(|l| l.trim().to_string())
1756 .unwrap_or_else(|| safe_tail(text, 300).to_string());
1757 return Some(AgentError::ThinkingBlockConflict { message });
1758 }
1759 None
1760 }
1761
1762 pub fn detect_stale_session(text: &str) -> Option<AgentError> {
1768 let needle = "no conversation found with session id";
1774 let lower = text.to_lowercase();
1775 if let Some(lower_pos) = lower.find(needle) {
1776 let char_idx = lower[..lower_pos].chars().count();
1781 let pos = text
1782 .char_indices()
1783 .nth(char_idx)
1784 .map(|(i, _)| i)
1785 .unwrap_or(text.len());
1786 let after = &text[pos..];
1788 let session_id = after
1789 .split_once(':')
1790 .map(|(_, rest)| rest.trim())
1791 .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
1793 .unwrap_or_default();
1794 return Some(AgentError::StaleSession { session_id });
1795 }
1796 None
1797 }
1798
1799 const TIMEOUT_EXIT_CODE: i32 = 124;
1801
1802 const DEFAULT_TIMEOUT_SECS: u64 = 1800;
1804
1805 pub(crate) const RATE_LIMIT_SCAN_TAIL_BYTES: usize = 3000;
1816
1817 pub(crate) const RATE_LIMIT_SCAN_HEAD_BYTES: usize = 3000;
1825
1826 pub fn classify_from_text(exit_code: i32, text: &str) -> AgentError {
1829 if exit_code == TIMEOUT_EXIT_CODE {
1830 return AgentError::Timeout {
1831 elapsed: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1832 };
1833 }
1834
1835 if exit_code == 0 && text.trim().is_empty() {
1840 return AgentError::Unknown {
1841 exit_code,
1842 message: "empty-output-exit0".to_string(),
1843 };
1844 }
1845
1846 if let Some(e) = detect_missing_tool(text) {
1847 return e;
1848 }
1849 if let Some(e) = detect_worktree_missing(text) {
1850 return e;
1851 }
1852 if let Some(e) = detect_thinking_block_conflict(text) {
1853 return e;
1854 }
1855 let scan_head = safe_head(text, RATE_LIMIT_SCAN_HEAD_BYTES);
1861 if let Some(e) = detect_rate_limit(scan_head) {
1862 return e;
1863 }
1864 let scan_tail = safe_tail(text, RATE_LIMIT_SCAN_TAIL_BYTES);
1871 if let Some(e) = detect_context_overflow(scan_tail) {
1872 return e;
1873 }
1874 if let Some(e) = detect_rate_limit(scan_tail) {
1875 return e;
1876 }
1877 if let Some(e) = detect_model_unavailable(scan_tail) {
1878 return e;
1879 }
1880 if let Some(e) = detect_network_error(scan_tail) {
1881 return e;
1882 }
1883 if let Some(e) = detect_auth_error(scan_tail) {
1884 return e;
1885 }
1886 if let Some(e) = detect_waiting_for_input(scan_tail) {
1887 return e;
1888 }
1889 if let Some(e) = detect_permission_denied(scan_tail) {
1890 return e;
1891 }
1892 if let Some(e) = detect_stale_session(text) {
1893 return e;
1894 }
1895
1896 AgentError::Unknown {
1897 exit_code,
1898 message: safe_tail(text, 300).to_string(),
1899 }
1900 }
1901
1902 pub fn safe_tail(text: &str, max_bytes: usize) -> &str {
1904 if text.len() <= max_bytes {
1905 return text;
1906 }
1907 let start = text.len() - max_bytes;
1908 let mut idx = start;
1910 while idx < text.len() && !text.is_char_boundary(idx) {
1911 idx += 1;
1912 }
1913 &text[idx..]
1914 }
1915
1916 pub fn safe_head(text: &str, max_bytes: usize) -> &str {
1918 if text.len() <= max_bytes {
1919 return text;
1920 }
1921 let mut idx = max_bytes;
1922 while idx > 0 && !text.is_char_boundary(idx) {
1924 idx -= 1;
1925 }
1926 &text[..idx]
1927 }
1928
1929 pub fn detect_ndjson_error(
1936 events: &[serde_json::Value],
1937 extract_message: impl Fn(&serde_json::Value) -> Option<String>,
1938 classify: impl Fn(&str) -> AgentError,
1939 ) -> Option<AgentError> {
1940 let mut best: Option<AgentError> = None;
1941
1942 for event in events {
1943 if let Some(message) = extract_message(event) {
1944 let err = classify(&message);
1945 let upgrade = match &best {
1948 None => true,
1949 Some(AgentError::AgentFailed { .. }) => true,
1950 Some(AgentError::NetworkError { .. }) => !matches!(
1951 err,
1952 AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1953 ),
1954 Some(_) => false,
1955 };
1956 if upgrade {
1957 best = Some(err);
1958 }
1959 }
1960 }
1961
1962 best
1963 }
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968 use super::*;
1969
1970 #[test]
1971 fn get_runner_returns_correct_types() {
1972 assert_eq!(get_runner("claude").name(), "claude");
1973 assert_eq!(get_runner("kimi").name(), "kimi");
1974 assert_eq!(get_runner("minimax").name(), "minimax");
1975 assert_eq!(get_runner("codex").name(), "codex");
1976 assert_eq!(get_runner("opencode").name(), "opencode");
1977 assert_eq!(get_runner("grok").name(), "grok");
1978 assert_eq!(
1979 get_runner("claude").router_transcript(),
1980 RouterTranscript::Messages
1981 );
1982 assert_eq!(
1983 get_runner("kimi").router_transcript(),
1984 RouterTranscript::Messages
1985 );
1986 assert_eq!(
1987 get_runner("minimax").router_transcript(),
1988 RouterTranscript::Messages
1989 );
1990 assert_eq!(
1991 get_runner("grok").router_transcript(),
1992 RouterTranscript::Messages
1993 );
1994 assert_eq!(
1995 get_runner("glm").router_transcript(),
1996 RouterTranscript::Messages
1997 );
1998 assert_eq!(
1999 get_runner("olm").router_transcript(),
2000 RouterTranscript::Messages
2001 );
2002 assert_eq!(
2003 get_runner("opencode").router_transcript(),
2004 RouterTranscript::OpenCode
2005 );
2006 assert_eq!(
2007 get_runner("codex").router_transcript(),
2008 RouterTranscript::Raw
2009 );
2010 assert!(supports_session("grok"));
2011 assert!(!supports_session("codex"));
2012 assert_eq!(get_runner("olm").name(), "olm");
2014 assert_eq!(get_runner("glm").name(), "glm");
2015 assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
2017 }
2018
2019 #[test]
2022 fn get_runner_no_warning_for_claude_compatible_agents() {
2023 use std::sync::{Arc, Mutex};
2024 use tracing::Level;
2025 use tracing_subscriber::util::SubscriberInitExt;
2026 use tracing_subscriber::EnvFilter;
2027
2028 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
2029 let output_clone = output.clone();
2030
2031 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2032 impl std::io::Write for CaptureWriter {
2033 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2034 self.0.lock().unwrap().extend_from_slice(buf);
2035 Ok(buf.len())
2036 }
2037 fn flush(&mut self) -> std::io::Result<()> {
2038 Ok(())
2039 }
2040 }
2041 impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2042 type Writer = CaptureWriter;
2043 fn make_writer(&'a self) -> Self::Writer {
2044 CaptureWriter(self.0.clone())
2045 }
2046 }
2047
2048 tracing_subscriber::fmt()
2049 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2050 .with_writer(CaptureWriter(output_clone))
2051 .with_ansi(false)
2052 .finish()
2053 .init();
2054
2055 for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2056 get_runner(agent);
2057 }
2058
2059 let captured = {
2060 let guard = output.lock().unwrap();
2061 String::from_utf8_lossy(&guard).to_string()
2062 };
2063 assert!(
2064 !captured.contains("unknown agent"),
2065 "known claude-compatible agents must not emit 'unknown agent' warnings; got: {}",
2066 captured
2067 );
2068 }
2069
2070 #[test]
2071 fn agent_error_display() {
2072 let e = AgentError::RateLimit {
2073 message: "429 Too Many Requests".to_string(),
2074 };
2075 assert!(e.to_string().contains("rate limit"));
2076
2077 let e = AgentError::Timeout {
2078 elapsed: Duration::from_secs(1800),
2079 };
2080 assert!(e.to_string().contains("1800"));
2081 }
2082
2083 #[test]
2084 fn pattern_detect_rate_limit() {
2085 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2086 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2087 assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2088 assert!(patterns::detect_rate_limit(
2089 "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2090 )
2091 .is_some());
2092 assert!(patterns::detect_rate_limit(
2093 "Upstream error from Alibaba: Request rate increased too quickly. \
2094 To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
2095 ).is_some());
2096 assert!(patterns::detect_rate_limit("all good").is_none());
2097 }
2098
2099 #[test]
2100 fn classify_from_text_detects_weekly_limit_as_rate_limit() {
2101 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)"}}"#;
2105 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2106 .repeat(200);
2107 let text = format!("{system_event}\n{work_product}");
2108 assert!(
2109 text.len() > 3000,
2110 "test output must exceed tail window to be meaningful"
2111 );
2112 let err = patterns::classify_from_text(1, &text);
2113 assert!(
2114 matches!(err, AgentError::RateLimit { .. }),
2115 "weekly limit as early system event must be detected via head scan, got: {err:?}"
2116 );
2117 let msg = err.to_string();
2118 assert!(
2119 msg.contains("weekly limit"),
2120 "error message should reference weekly limit, got: {msg}"
2121 );
2122 }
2123
2124 #[test]
2125 fn detect_rate_limit_github_copilot_monthly_quota() {
2126 let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
2130 assert!(
2131 matches!(err, Some(AgentError::RateLimit { .. })),
2132 "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
2133 );
2134 }
2135
2136 #[test]
2137 fn detect_rate_limit_nvidia_resourceexhausted() {
2138 let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
2142 let err = patterns::detect_rate_limit(opencode_error);
2143 assert!(
2144 matches!(err, Some(AgentError::RateLimit { .. })),
2145 "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
2146 );
2147
2148 assert!(
2150 patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
2151 "bare ResourceExhausted must match"
2152 );
2153 assert!(
2154 patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
2155 .is_some(),
2156 "worker local total request limit reached must match"
2157 );
2158
2159 let err = patterns::classify_from_text(1, opencode_error);
2161 assert!(
2162 matches!(err, AgentError::RateLimit { .. }),
2163 "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
2164 );
2165 }
2166
2167 #[test]
2168 fn detect_rate_limit_message_contains_context_not_tail() {
2169 let padding = "x".repeat(5000);
2172 let text = format!(
2173 "{padding}you've reached your usage limit for this billing cycle{padding}\
2174 {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
2175 );
2176 let err = patterns::detect_rate_limit(&text).expect("should detect");
2177 let msg = err.to_string();
2178 assert!(
2179 msg.contains("usage limit"),
2180 "message should contain the actual error, got: {msg}"
2181 );
2182 assert!(
2183 !msg.contains("web_fetch_requests"),
2184 "message should not be the tail JSON, got: {msg}"
2185 );
2186 }
2187
2188 #[test]
2189 fn pattern_detect_rate_limit_529_not_bare() {
2190 assert!(
2193 patterns::detect_rate_limit("error on line 529").is_none(),
2194 "bare line number 529 should not match"
2195 );
2196 assert!(
2197 patterns::detect_rate_limit("file size 529 bytes").is_none(),
2198 "bare file size 529 should not match"
2199 );
2200 assert!(
2201 patterns::detect_rate_limit("port :5290 in config").is_none(),
2202 "port number containing 529 should not match"
2203 );
2204 assert!(
2205 patterns::detect_rate_limit("timeout after 5290ms").is_none(),
2206 "duration containing 529 should not match"
2207 );
2208 assert!(
2210 patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
2211 "HTTP 529 status should match"
2212 );
2213 assert!(
2214 patterns::detect_rate_limit("http 529").is_some(),
2215 "http 529 should match"
2216 );
2217 assert!(
2218 patterns::detect_rate_limit("529 service unavailable").is_some(),
2219 "529 service unavailable should match"
2220 );
2221 assert!(
2222 patterns::detect_rate_limit("status: 529").is_some(),
2223 "status: 529 should match"
2224 );
2225 }
2226
2227 #[test]
2228 fn pattern_detect_auth() {
2229 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
2230 assert!(patterns::detect_auth_error("HTTP 401").is_some());
2231 assert!(patterns::detect_auth_error("error: 401").is_some());
2232 assert!(patterns::detect_auth_error("403 Forbidden").is_some());
2233 assert!(patterns::detect_auth_error("HTTP 403").is_some());
2234 assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
2235 assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
2236 assert!(patterns::detect_auth_error("invalid api key").is_some());
2237 assert!(patterns::detect_auth_error("billing expired").is_some());
2238 assert!(patterns::detect_auth_error("task done").is_none());
2239 assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
2241 assert!(patterns::detect_auth_error(
2242 "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2243 )
2244 .is_none());
2245 assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2247 assert!(patterns::detect_auth_error(
2248 r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2249 )
2250 .is_none());
2251 assert!(patterns::detect_auth_error("status: 401").is_some());
2253 assert!(patterns::detect_auth_error("error: 403").is_some());
2254 }
2255
2256 #[test]
2257 fn pattern_detect_auth_insufficient_balance() {
2258 assert!(
2263 patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2264 "insufficient balance must be detected as auth error"
2265 );
2266 assert!(
2267 patterns::detect_auth_error("insufficient balance").is_some(),
2268 "bare insufficient balance must be detected as auth error"
2269 );
2270 let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2272 .expect("should detect auth error");
2273 let AgentError::Auth { message } = err else {
2274 panic!("expected Auth, got {err:?}");
2275 };
2276 assert!(
2277 message.to_lowercase().contains("insufficient balance"),
2278 "stored message should contain the matched pattern, got: {message}"
2279 );
2280 assert!(
2282 message.lines().count() <= 2,
2283 "message should be the auth line, not the full tail: {}",
2284 message.len()
2285 );
2286 }
2287
2288 #[test]
2289 fn auth_error_message_contains_match_not_tail() {
2290 let text = "some output\nunauthorized: your api key has expired\nx".repeat(400)
2294 + r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#;
2295 let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2296 let AgentError::Auth { message } = err else {
2297 panic!("expected Auth, got {err:?}");
2298 };
2299 assert!(
2300 message.to_lowercase().contains("unauthorized"),
2301 "stored message should contain the matched pattern, got: {message}"
2302 );
2303 assert!(
2304 !message.contains("outputTokens"),
2305 "stored message must not be the trailing NDJSON, got: {message}"
2306 );
2307 assert!(
2309 message.lines().count() <= 2,
2310 "message should be the auth line, not the full tail: {}",
2311 message.len()
2312 );
2313 }
2314
2315 #[test]
2316 fn pattern_detect_network_error() {
2317 assert!(patterns::detect_network_error("connection refused").is_some());
2318 assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2319 assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2320 assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2321 assert!(patterns::detect_network_error("network unreachable").is_some());
2322 assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2323 assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2324 assert!(patterns::detect_network_error("Streaming response failed").is_some());
2325 assert!(patterns::detect_network_error("Upstream request failed").is_some());
2326 assert!(patterns::detect_network_error("all systems operational").is_none());
2327 }
2328
2329 #[test]
2330 fn network_error_message_contains_match_not_tail() {
2331 let prefix = "connection refused connecting to api.anthropic.com:443 ";
2333 let suffix = "x".repeat(2000)
2334 + r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#;
2335 let text = format!("{prefix}{suffix}");
2336
2337 let err = patterns::detect_network_error(&text).expect("should detect network error");
2338 let AgentError::NetworkError { message } = err else {
2339 panic!("expected NetworkError, got {err:?}");
2340 };
2341 assert!(
2342 message.to_lowercase().contains("connection refused"),
2343 "stored message should contain the matched pattern, got: {message}"
2344 );
2345 assert!(
2346 !message.contains("web_fetch_requests"),
2347 "stored message must not be the unrelated stats tail, got: {message}"
2348 );
2349 }
2350
2351 #[test]
2352 fn pattern_detect_context_overflow() {
2353 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2354 assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2355 assert!(patterns::detect_context_overflow("success").is_none());
2356 }
2357
2358 #[test]
2359 fn pattern_detect_worktree_missing() {
2360 assert!(
2361 patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2362 .is_some()
2363 );
2364 assert!(patterns::detect_worktree_missing("all good").is_none());
2365 }
2366
2367 #[test]
2368 fn pattern_detect_missing_tool() {
2369 assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2370 assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2371 assert!(patterns::detect_missing_tool("everything works").is_none());
2372 }
2373
2374 #[test]
2375 fn pattern_detect_permission_denied() {
2376 assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2377 assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2378 assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2379 assert!(patterns::detect_permission_denied("task completed").is_none());
2380 }
2381
2382 #[test]
2383 fn pattern_detect_waiting_for_input() {
2384 assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2385 assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2386 assert!(
2387 patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2388 .is_some()
2389 );
2390 assert!(
2391 patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2392 );
2393 assert!(patterns::detect_waiting_for_input("done").is_none());
2394 }
2395
2396 #[test]
2397 fn pattern_detect_stale_session() {
2398 let err = patterns::detect_stale_session(
2400 "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2401 );
2402 assert!(err.is_some(), "should detect stale session");
2403 if let Some(AgentError::StaleSession { session_id }) = err {
2404 assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2405 }
2406
2407 let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2409 assert!(err.is_some(), "should detect lowercase variant");
2410
2411 assert!(patterns::detect_stale_session("all good").is_none());
2413 assert!(patterns::detect_stale_session("conversation started").is_none());
2414
2415 let multibyte = "Straße: No conversation found with session ID: dead-beef";
2419 let err = patterns::detect_stale_session(multibyte);
2420 assert!(
2421 err.is_some(),
2422 "should detect stale session after multi-byte chars"
2423 );
2424 if let Some(AgentError::StaleSession { session_id }) = err {
2425 assert_eq!(session_id, "dead-beef");
2426 }
2427 }
2428
2429 #[test]
2430 fn stale_session_display() {
2431 let e = AgentError::StaleSession {
2432 session_id: "abc-123".to_string(),
2433 };
2434 assert!(e.to_string().contains("stale session"));
2435 assert!(e.to_string().contains("abc-123"));
2436 }
2437
2438 #[test]
2439 fn classify_from_text_timeout() {
2440 let err = patterns::classify_from_text(124, "");
2441 assert!(matches!(err, AgentError::Timeout { .. }));
2442 }
2443
2444 #[test]
2445 fn classify_from_text_missing_tool_before_rate_limit() {
2446 let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2448 assert!(matches!(err, AgentError::MissingTool { .. }));
2449 }
2450
2451 #[test]
2452 fn classify_from_text_worktree_missing() {
2453 let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2454 assert!(matches!(err, AgentError::AgentFailed { .. }));
2455 }
2456
2457 #[test]
2458 fn classify_from_text_model_not_found_backtick() {
2459 let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2463 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2464 or you do not have access to it.)";
2465 let err = patterns::classify_from_text(1, text);
2466 match err {
2467 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2468 other => panic!("expected ModelUnavailable, got: {other:?}"),
2469 }
2470 }
2471
2472 #[test]
2473 fn classify_from_text_model_metadata_not_found() {
2474 let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2475 metadata; this can degrade performance and cause issues.";
2476 let err = patterns::classify_from_text(1, text);
2477 match err {
2478 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2479 other => panic!("expected ModelUnavailable, got: {other:?}"),
2480 }
2481 }
2482
2483 #[test]
2484 fn classify_from_text_no_false_positive_from_work_product() {
2485 let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2488 - Added copilot quota patterns to `parse_retry_at`\n\
2489 fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2490 quota exceeded handling for billing cycle\n"
2491 .repeat(50); let actual_error = "\nerror: command not found: cargo\n";
2493 let text = format!("{work_product}{actual_error}");
2494 let err = patterns::classify_from_text(1, &text);
2495 assert!(
2496 !matches!(err, AgentError::RateLimit { .. }),
2497 "work product keywords must not trigger rate limit, got: {err:?}"
2498 );
2499 }
2500
2501 #[test]
2502 fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2503 let padding = "normal agent work output ".repeat(100);
2506 let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2507 let err = patterns::classify_from_text(1, &text);
2508 assert!(
2509 matches!(err, AgentError::RateLimit { .. }),
2510 "real rate limit at tail must be detected, got: {err:?}"
2511 );
2512 }
2513
2514 #[test]
2515 fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2516 let padding = "normal agent work output ".repeat(250);
2517 let text = format!(
2518 "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2519 );
2520 let err = patterns::classify_from_text(1, &text);
2521 assert!(
2522 !matches!(err, AgentError::RateLimit { .. }),
2523 "markdown table cell must not trigger rate limit, got: {err:?}"
2524 );
2525 }
2526
2527 #[test]
2528 fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2529 let text = "Aflαβrate_limit error: too many requests";
2533 let err = patterns::classify_from_text(1, text);
2534 assert!(
2535 matches!(err, AgentError::RateLimit { .. }),
2536 "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2537 );
2538 }
2539
2540 #[test]
2541 fn classify_from_text_detects_session_limit_as_early_system_event() {
2542 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)"}}"#;
2548 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2551 .repeat(200);
2552 let text = format!("{system_event}\n{work_product}");
2553 assert!(
2555 text.len() > 3000,
2556 "test output must exceed tail window to be meaningful"
2557 );
2558 let err = patterns::classify_from_text(1, &text);
2559 assert!(
2560 matches!(err, AgentError::RateLimit { .. }),
2561 "session limit as early system event must be detected via head scan, got: {err:?}"
2562 );
2563 let msg = err.to_string();
2565 assert!(
2566 msg.contains("session limit"),
2567 "error message should contain the rate limit reason, got: {msg}"
2568 );
2569 }
2570
2571 #[test]
2572 fn classify_from_text_no_false_positive_auth_from_work_product() {
2573 let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2577 // handle 403 forbidden errors\n\
2578 // billing checks happen elsewhere\n"
2579 .repeat(80);
2580 let actual_error = "\nerror: command not found: cargo\n";
2581 let text = format!("{work_product}{actual_error}");
2582 let err = patterns::classify_from_text(1, &text);
2583 assert!(
2584 !matches!(err, AgentError::Auth { .. }),
2585 "work product auth keywords must not trigger auth classification, got: {err:?}"
2586 );
2587 assert!(
2588 matches!(err, AgentError::MissingTool { .. }),
2589 "the real tail error should still win, got: {err:?}"
2590 );
2591 }
2592
2593 #[test]
2594 fn classify_from_text_still_detects_real_auth_at_tail() {
2595 let padding = "normal agent work output ".repeat(100);
2596 let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2597 let err = patterns::classify_from_text(1, &text);
2598 assert!(
2599 matches!(err, AgentError::Auth { .. }),
2600 "real auth error at tail must be detected, got: {err:?}"
2601 );
2602 }
2603
2604 #[test]
2605 fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2606 let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2610 // Ensure we don't exceed the token limit per request\n\
2611 fn check_token_limit(tokens: usize) { ... }\n\
2612 if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2613 // Note: too many tokens in the prompt causes context_length_exceeded\n"
2614 .repeat(80);
2615 let actual_error = "\nerror: command not found: cargo\n";
2616 let text = format!("{work_product}{actual_error}");
2617 let err = patterns::classify_from_text(1, &text);
2618 assert!(
2619 !matches!(err, AgentError::ContextOverflow { .. }),
2620 "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2621 );
2622 assert!(
2623 matches!(err, AgentError::MissingTool { .. }),
2624 "the real tail error should still win, got: {err:?}"
2625 );
2626 }
2627
2628 #[test]
2629 fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2630 let padding = "normal agent work output ".repeat(100);
2631 let text = format!("{padding}\nError: context_length_exceeded\n");
2632 let err = patterns::classify_from_text(1, &text);
2633 assert!(
2634 matches!(err, AgentError::ContextOverflow { .. }),
2635 "real context overflow at tail must be detected, got: {err:?}"
2636 );
2637 }
2638
2639 #[test]
2640 fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2641 let work_product = "match fs::metadata(path) {\n\
2645 Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2646 bail!(\"access denied: {path}\");\n\
2647 }\n\
2648 // Check if directory is writable\n\
2649 if !path.is_writable() { ... }\n\
2650 }\n"
2651 .repeat(80);
2652 let actual_error = "\nerror: command not found: cargo\n";
2653 let text = format!("{work_product}{actual_error}");
2654 let err = patterns::classify_from_text(1, &text);
2655 assert!(
2656 !matches!(err, AgentError::PermissionDenied { .. }),
2657 "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2658 );
2659 assert!(
2660 matches!(err, AgentError::MissingTool { .. }),
2661 "the real tail error should still win, got: {err:?}"
2662 );
2663 }
2664
2665 #[test]
2666 fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2667 let padding = "normal agent work output ".repeat(100);
2668 let text = format!("{padding}\npermission denied: /etc/hosts\n");
2669 let err = patterns::classify_from_text(1, &text);
2670 assert!(
2671 matches!(err, AgentError::PermissionDenied { .. }),
2672 "real permission denied at tail must be detected, got: {err:?}"
2673 );
2674 }
2675
2676 #[test]
2677 fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2678 let work_product = "// Handle password prompts gracefully\n\
2682 if prompt.contains(\"password:\") {\n\
2683 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2684 }\n\
2685 // SSH agent will ask for the deploy key passphrase\n\
2686 fn handle_ssh_passphrase() { ... }\n\
2687 )\n"
2688 .repeat(80);
2689 let actual_error = "\nerror: command not found: cargo\n";
2690 let text = format!("{work_product}{actual_error}");
2691 let err = patterns::classify_from_text(1, &text);
2692 assert!(
2693 !matches!(err, AgentError::WaitingForInput { .. }),
2694 "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2695 );
2696 assert!(
2697 matches!(err, AgentError::MissingTool { .. }),
2698 "the real tail error should still win, got: {err:?}"
2699 );
2700 }
2701
2702 #[test]
2703 fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2704 let padding = "normal agent work output ".repeat(100);
2705 let text = format!("{padding}\nEnter passphrase for key:\n");
2706 let err = patterns::classify_from_text(1, &text);
2707 assert!(
2708 matches!(err, AgentError::WaitingForInput { .. }),
2709 "real waiting for input at tail must be detected, got: {err:?}"
2710 );
2711 }
2712
2713 #[test]
2714 fn classify_from_text_prefers_auth_over_permission_denied() {
2715 let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2716 assert!(
2717 matches!(err, AgentError::Auth { .. }),
2718 "auth errors must outrank permission denied, got: {err:?}"
2719 );
2720 }
2721
2722 #[test]
2723 fn classify_from_text_prefers_auth_over_waiting_for_input() {
2724 let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2725 assert!(
2726 matches!(err, AgentError::Auth { .. }),
2727 "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2728 );
2729 }
2730
2731 #[test]
2732 fn classify_from_text_detects_stale_session() {
2733 let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2738 let err = patterns::classify_from_text(1, text);
2739 assert!(
2740 matches!(err, AgentError::StaleSession { .. }),
2741 "stale session must be detected via classify_from_text, got: {err:?}"
2742 );
2743 assert!(
2745 err.to_string().contains("stale session"),
2746 "error display must contain 'stale session' for recovery code to trigger"
2747 );
2748 }
2749
2750 #[test]
2751 fn classify_from_text_detects_socket_closed() {
2752 let text = "API Error: The socket connection was closed unexpectedly. \
2755 For more information, pass `verbose: true` in the second argument to fetch()";
2756 let err = patterns::classify_from_text(1, text);
2757 assert!(
2758 matches!(err, AgentError::NetworkError { .. }),
2759 "socket closed must be NetworkError, got: {err:?}"
2760 );
2761 }
2762
2763 #[test]
2764 fn classify_from_text_detects_socket_hang_up() {
2765 let err = patterns::classify_from_text(1, "Error: socket hang up");
2766 assert!(
2767 matches!(err, AgentError::NetworkError { .. }),
2768 "socket hang up must be NetworkError, got: {err:?}"
2769 );
2770 }
2771
2772 #[test]
2773 fn classify_from_text_detects_fetch_failed() {
2774 let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2775 assert!(
2776 matches!(err, AgentError::NetworkError { .. }),
2777 "fetch failed must be NetworkError, got: {err:?}"
2778 );
2779 }
2780
2781 #[test]
2782 fn classify_from_text_detects_econnreset() {
2783 let err = patterns::classify_from_text(1, "read ECONNRESET");
2784 assert!(
2785 matches!(err, AgentError::NetworkError { .. }),
2786 "ECONNRESET must be NetworkError, got: {err:?}"
2787 );
2788 }
2789
2790 #[test]
2791 fn classify_from_text_detects_stalled_mid_stream() {
2792 let err = patterns::classify_from_text(
2795 1,
2796 "API Error: Response stalled mid-stream. The response above may be incomplete.",
2797 );
2798 assert!(
2799 matches!(err, AgentError::NetworkError { .. }),
2800 "stalled mid-stream must be NetworkError, got: {err:?}"
2801 );
2802 }
2803
2804 #[test]
2805 fn classify_from_text_detects_connection_closed_mid_response() {
2806 let err = patterns::classify_from_text(
2809 1,
2810 "API Error: Connection closed mid-response. The response above may be incomplete.",
2811 );
2812 assert!(
2813 matches!(err, AgentError::NetworkError { .. }),
2814 "connection closed mid-response must be NetworkError, got: {err:?}"
2815 );
2816 }
2817
2818 #[test]
2819 fn synthesize_response_marks_done_for_plain_text_no_op() {
2820 let response = synthesize_response_from_text(
2821 "No open positions, no trade executions, no conditions change. Full cash, holding.",
2822 )
2823 .unwrap();
2824
2825 assert_eq!(response.status, "done");
2826 assert_eq!(
2827 response.summary,
2828 "No open positions, no trade executions, no conditions change. Full cash, holding."
2829 );
2830 assert!(response.error.is_none());
2831 }
2832
2833 #[test]
2834 fn synthesize_response_marks_needs_review_for_plain_text_error() {
2835 let response =
2836 synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2837
2838 assert_eq!(response.status, "needs_review");
2839 assert_eq!(
2840 response.error.as_deref(),
2841 Some("agent returned plain text instead of JSON")
2842 );
2843 }
2844
2845 #[test]
2846 fn synthesize_response_rejects_empty_text() {
2847 assert!(synthesize_response_from_text(" \n\t ").is_none());
2848 }
2849
2850 #[test]
2856 fn synthesize_response_rejects_ndjson_event_stream() {
2857 let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2858{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2859{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2860 assert!(
2861 synthesize_response_from_text(ndjson).is_none(),
2862 "raw NDJSON must not be interpreted as agent prose"
2863 );
2864
2865 let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2866 assert!(
2867 synthesize_response_from_text(single).is_none(),
2868 "single-line NDJSON event must also be rejected"
2869 );
2870 }
2871
2872 #[test]
2873 fn synthesize_response_accepts_json_without_event_type() {
2874 let text = "The task is complete. Filed issue #1234 successfully.";
2878 let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2879 assert_eq!(response.status, "done");
2880 }
2881
2882 #[test]
2889 fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2890 assert!(
2892 synthesize_response_from_text("Let's go!").is_none(),
2893 "startup greeting must not synthesize as needs_review"
2894 );
2895 assert!(
2897 synthesize_response_from_text(
2898 "I need to find where the stream command is implemented. \
2899 Looking at the CLI structure, it seems like the stream functionality \
2900 might be in the task module or another file. Let me check the CLI \
2901 command structure. First, let me look at the task module since stream \
2902 is related to tasks."
2903 )
2904 .is_none(),
2905 "exploratory prose must not synthesize as needs_review"
2906 );
2907 }
2908
2909 #[test]
2910 fn synthesize_response_does_not_mark_done_for_incomplete() {
2911 let response = synthesize_response_from_text("The task is incomplete").unwrap();
2912 assert_eq!(
2913 response.status, "needs_review",
2914 "\"incomplete\" should not match \"complete\""
2915 );
2916 }
2917
2918 #[test]
2919 fn synthesize_response_does_not_mark_done_for_not_done() {
2920 let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2921 assert_eq!(
2922 response.status, "needs_review",
2923 "\"not done\" should not match bare \"done\""
2924 );
2925 }
2926
2927 #[test]
2928 fn synthesize_response_does_not_mark_done_for_not_complete() {
2929 let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2930 assert_eq!(
2931 response.status, "needs_review",
2932 "\"not complete\" should not match \"complete\""
2933 );
2934 }
2935
2936 #[test]
2937 fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2938 let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2939 assert_eq!(
2940 response.status, "needs_review",
2941 "\"not yet completed\" should not match \"completed\""
2942 );
2943 }
2944
2945 #[test]
2946 fn synthesize_response_marks_done_for_completed() {
2947 let response =
2948 synthesize_response_from_text("Task has been completed successfully").unwrap();
2949 assert_eq!(response.status, "done");
2950 }
2951
2952 #[test]
2953 fn synthesize_response_marks_done_for_word_boundary_done() {
2954 let response = synthesize_response_from_text("All changes are done.").unwrap();
2955 assert_eq!(response.status, "done");
2956 }
2957
2958 #[test]
2959 fn synthesize_response_marks_done_for_issue_creation_text() {
2960 let response =
2961 synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2962 .unwrap();
2963
2964 assert_eq!(response.status, "done");
2965 assert!(response.error.is_none());
2966 assert!(response
2967 .summary
2968 .contains("Filed 3 high-value GitHub issues"));
2969 }
2970
2971 #[test]
2972 fn synthesize_response_marks_done_for_committed() {
2973 let response =
2974 synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2975 assert_eq!(response.status, "done");
2976 }
2977
2978 #[test]
2979 fn synthesize_response_rejects_exploratory_prose() {
2980 let exploratory = "I explored the codebase and found several .expect()/.unwrap() uses.\n\nLet me check each occurrence directly and run the tests to be sure. I may commit fixes afterwards.";
2986 match synthesize_response_from_text(exploratory) {
2987 None => {
2988 }
2990 Some(resp) => {
2991 assert_ne!(
2992 resp.status, "done",
2993 "Exploratory prose must not be auto-marked done"
2994 );
2995 }
2996 }
2997 }
2998
2999 #[test]
3000 fn synthesize_response_marks_done_for_commit_created() {
3001 let response = synthesize_response_from_text(
3002 "Commit created locally. The push is being blocked by permissions.",
3003 )
3004 .unwrap();
3005 assert_eq!(response.status, "done");
3006 }
3007
3008 #[test]
3009 fn synthesize_response_does_not_mark_done_for_not_committed() {
3010 let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
3011 assert_eq!(
3012 response.status, "needs_review",
3013 "\"not committed\" should not match \"committed\""
3014 );
3015 }
3016
3017 #[test]
3020 fn synthesize_response_marks_done_for_fix_is_complete() {
3021 let response = synthesize_response_from_text(
3025 "All 73 cooldown tests pass. The fix is complete.\n\n\
3026 **Summary of changes:**\n\n\
3027 1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
3028 `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
3029 )
3030 .unwrap();
3031 assert_eq!(
3032 response.status, "done",
3033 "\"The fix is complete\" must be classified as done, not needs_review"
3034 );
3035 }
3036
3037 #[test]
3038 fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3039 let response = synthesize_response_from_text(
3043 "All tests pass. The fix is clean and working.\n\n\
3044 **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3045 - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3046 - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3047 )
3048 .unwrap();
3049 assert_eq!(
3050 response.status, "done",
3051 "\"All tests pass. The fix is clean and working.\" must be classified as done"
3052 );
3053 }
3054
3055 #[test]
3058 fn parse_ndjson_handles_standard_lines() {
3059 let raw = r#"{"type":"text","text":"hello"}
3060{"type":"text","text":"world"}"#;
3061 let events = parse_ndjson(raw);
3062 assert_eq!(events.len(), 2);
3063 assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3064 assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3065 }
3066
3067 #[test]
3068 fn parse_ndjson_skips_empty_lines() {
3069 let raw = r#"{"type":"text"}
3070
3071{"type":"text"}
3072"#;
3073 let events = parse_ndjson(raw);
3074 assert_eq!(events.len(), 2);
3075 }
3076
3077 #[test]
3078 fn parse_ndjson_skips_malformed_lines() {
3079 let raw = r#"{"type":"valid"}
3080not json at all
3081{"type":"also_valid"}"#;
3082 let events = parse_ndjson(raw);
3083 assert_eq!(events.len(), 2);
3084 }
3085
3086 #[test]
3087 fn parse_ndjson_fallback_extracts_embedded_json() {
3088 let raw =
3090 r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3091 let events = parse_ndjson(raw);
3092 assert!(
3094 !events.is_empty(),
3095 "should extract JSON objects via fallback"
3096 );
3097 }
3098
3099 #[test]
3100 fn parse_ndjson_fallback_handles_fragmented_objects() {
3101 let raw = r#"{"type": "text
3103", "content": "hello
3104world"}"#;
3105 let events = parse_ndjson(raw);
3106 assert!(!events.is_empty(), "should handle fragmented JSON");
3108 }
3109
3110 #[test]
3113 fn permission_rules_default_is_autonomous() {
3114 let rules = PermissionRules::default();
3115 assert!(rules.autonomous);
3116 }
3117
3118 #[test]
3119 fn permission_rules_default_sandbox_is_workspace_write() {
3120 let rules = PermissionRules::default();
3121 assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3122 }
3123
3124 #[test]
3125 fn permission_rules_default_disallows_rm() {
3126 let rules = PermissionRules::default();
3127 assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3128 assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3129 }
3130
3131 #[test]
3132 fn permission_rules_default_no_allowed_edit_paths() {
3133 let rules = PermissionRules::default();
3134 assert!(rules.allowed_edit_paths.is_empty());
3135 }
3136
3137 #[test]
3141 fn all_agents_handle_default_permissions() {
3142 let perms = PermissionRules::default();
3143 let sys = "/tmp/sys.md";
3144 let msg = "/tmp/msg.md";
3145
3146 let claude = get_runner("claude");
3148 let cmd = claude.build_command(None, "", sys, msg, &perms);
3149 assert!(
3150 cmd.contains("--permission-mode bypassPermissions"),
3151 "claude default: expected bypassPermissions, got: {cmd}"
3152 );
3153 assert!(
3154 cmd.contains("Bash(rm *)"),
3155 "claude default: expected rm disallowed, got: {cmd}"
3156 );
3157
3158 let codex = get_runner("codex");
3160 let cmd = codex.build_command(None, "", sys, msg, &perms);
3161 assert!(
3162 cmd.contains("--sandbox workspace-write"),
3163 "codex default: expected --sandbox workspace-write, got: {cmd}"
3164 );
3165 assert!(
3166 cmd.contains(r#"-c 'approval_policy="never"'"#),
3167 "codex default: expected -c approval_policy=never, got: {cmd}"
3168 );
3169 assert!(
3170 !cmd.contains("--ask-for-approval"),
3171 "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3172 );
3173 assert!(
3174 !cmd.contains("--full-auto"),
3175 "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3176 );
3177
3178 let opencode = get_runner("opencode");
3181 let cmd = opencode.build_command(None, "", sys, msg, &perms);
3182 assert!(
3183 cmd.contains("--dangerously-skip-permissions"),
3184 "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3185 );
3186 assert!(
3187 !cmd.contains("opencode.json"),
3188 "opencode default: should not write a config file, got: {cmd}"
3189 );
3190 assert!(
3191 !cmd.contains("XDG_CONFIG_HOME"),
3192 "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3193 );
3194 assert!(
3195 !cmd.contains(".orch-opencode"),
3196 "opencode default: should not reference .orch-opencode, got: {cmd}"
3197 );
3198 }
3199
3200 #[test]
3202 fn all_agents_handle_supervised_mode() {
3203 let perms = PermissionRules {
3204 autonomous: false,
3205 sandbox: SandboxLevel::WorkspaceWrite,
3206 disallowed_tools: vec![],
3207 allowed_tools: vec![],
3208 allowed_edit_paths: vec![],
3209 deny_read_only: false,
3210 extra_writable_dirs: vec![],
3211 };
3212 let sys = "/tmp/sys.md";
3213 let msg = "/tmp/msg.md";
3214
3215 let claude = get_runner("claude");
3217 let cmd = claude.build_command(None, "", sys, msg, &perms);
3218 assert!(
3219 cmd.contains("--permission-mode acceptEdits"),
3220 "claude supervised: expected acceptEdits, got: {cmd}"
3221 );
3222
3223 let codex = get_runner("codex");
3225 let cmd = codex.build_command(None, "", sys, msg, &perms);
3226 assert!(
3227 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3228 "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3229 );
3230 assert!(
3231 !cmd.contains("--ask-for-approval"),
3232 "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3233 );
3234 }
3235
3236 #[test]
3238 fn all_agents_handle_full_access_sandbox() {
3239 let perms = PermissionRules {
3240 autonomous: true,
3241 sandbox: SandboxLevel::FullAccess,
3242 disallowed_tools: vec![],
3243 allowed_tools: vec![],
3244 allowed_edit_paths: vec![],
3245 deny_read_only: false,
3246 extra_writable_dirs: vec![],
3247 };
3248 let sys = "/tmp/sys.md";
3249 let msg = "/tmp/msg.md";
3250
3251 let claude = get_runner("claude");
3253 let cmd = claude.build_command(None, "", sys, msg, &perms);
3254 assert!(
3255 !cmd.contains("--sandbox"),
3256 "claude full_access: should have no sandbox flag, got: {cmd}"
3257 );
3258
3259 let codex = get_runner("codex");
3261 let cmd = codex.build_command(None, "", sys, msg, &perms);
3262 assert!(
3263 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3264 "codex full_access: expected dangerously-bypass, got: {cmd}"
3265 );
3266 }
3267
3268 #[test]
3270 fn codex_sandbox_none_uses_workspace_write() {
3271 let perms = PermissionRules {
3272 autonomous: true,
3273 sandbox: SandboxLevel::None,
3274 disallowed_tools: vec![],
3275 allowed_tools: vec![],
3276 allowed_edit_paths: vec![],
3277 deny_read_only: false,
3278 extra_writable_dirs: vec![],
3279 };
3280 let codex = get_runner("codex");
3281 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3282 assert!(
3283 cmd.contains("--sandbox workspace-write"),
3284 "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3285 );
3286 assert!(
3287 cmd.contains(r#"-c 'approval_policy="never"'"#),
3288 "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3289 );
3290 assert!(
3291 !cmd.contains("--ask-for-approval"),
3292 "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3293 );
3294 assert!(
3295 !cmd.contains("--full-auto"),
3296 "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3297 );
3298 }
3299
3300 #[test]
3302 fn claude_allowed_edit_paths_scope_edit_write() {
3303 let perms = PermissionRules {
3304 autonomous: true,
3305 sandbox: SandboxLevel::WorkspaceWrite,
3306 disallowed_tools: vec![],
3307 allowed_tools: vec![
3308 "Edit".to_string(),
3309 "Write".to_string(),
3310 "Read".to_string(),
3311 "Bash".to_string(),
3312 ],
3313 allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3314 deny_read_only: false,
3315 extra_writable_dirs: vec![],
3316 };
3317 let claude = get_runner("claude");
3318 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3319
3320 assert!(
3322 cmd.contains("Edit(/home/user/worktree/*)"),
3323 "missing scoped Edit, got: {cmd}"
3324 );
3325 assert!(
3326 cmd.contains("Write(/home/user/worktree/*)"),
3327 "missing scoped Write, got: {cmd}"
3328 );
3329 assert!(cmd.contains("Read"), "missing Read");
3331 }
3332
3333 #[test]
3335 fn codex_ignores_allowed_edit_paths() {
3336 let perms = PermissionRules {
3337 autonomous: true,
3338 sandbox: SandboxLevel::WorkspaceWrite,
3339 disallowed_tools: vec![],
3340 allowed_tools: vec![],
3341 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3342 deny_read_only: false,
3343 extra_writable_dirs: vec![],
3344 };
3345 let codex = get_runner("codex");
3346 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3347 assert!(
3348 !cmd.contains("/home/user/project"),
3349 "codex: should not reference blocked paths, got: {cmd}"
3350 );
3351 }
3352
3353 #[test]
3355 fn opencode_ignores_allowed_edit_paths() {
3356 let perms = PermissionRules {
3357 autonomous: true,
3358 sandbox: SandboxLevel::WorkspaceWrite,
3359 disallowed_tools: vec![],
3360 allowed_tools: vec![],
3361 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3362 deny_read_only: false,
3363 extra_writable_dirs: vec![],
3364 };
3365 let opencode = get_runner("opencode");
3366 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3367 assert!(
3368 !cmd.contains("/home/user/project"),
3369 "opencode: should not reference blocked paths, got: {cmd}"
3370 );
3371 }
3372
3373 #[test]
3375 fn claude_merges_disallowed_tools() {
3376 let perms = PermissionRules {
3377 autonomous: true,
3378 sandbox: SandboxLevel::WorkspaceWrite,
3379 disallowed_tools: vec![
3380 "Bash(rm *)".to_string(),
3381 "Bash(sudo *)".to_string(),
3382 "WebFetch".to_string(),
3383 ],
3384 allowed_tools: vec![],
3385 allowed_edit_paths: vec![],
3386 deny_read_only: false,
3387 extra_writable_dirs: vec![],
3388 };
3389 let claude = get_runner("claude");
3390 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3391 assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3392 assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3393 assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3394 }
3395
3396 #[test]
3398 fn codex_ignores_disallowed_tools() {
3399 let perms = PermissionRules {
3400 autonomous: true,
3401 sandbox: SandboxLevel::WorkspaceWrite,
3402 disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3403 allowed_tools: vec![],
3404 allowed_edit_paths: vec![],
3405 deny_read_only: false,
3406 extra_writable_dirs: vec![],
3407 };
3408 let codex = get_runner("codex");
3409 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3410 assert!(
3411 !cmd.contains("disallow"),
3412 "codex: should not have disallow flags, got: {cmd}"
3413 );
3414 }
3415
3416 #[test]
3418 fn claude_no_disallowed_flag_when_empty() {
3419 let perms = PermissionRules {
3420 autonomous: true,
3421 sandbox: SandboxLevel::WorkspaceWrite,
3422 disallowed_tools: vec![],
3423 allowed_tools: vec![],
3424 allowed_edit_paths: vec![],
3425 deny_read_only: false,
3426 extra_writable_dirs: vec![],
3427 };
3428 let claude = get_runner("claude");
3429 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3430 assert!(
3431 !cmd.contains("--disallowedTools"),
3432 "claude: should have no disallowed flag when empty, got: {cmd}"
3433 );
3434 }
3435
3436 #[test]
3438 fn kimi_minimax_use_claude_permissions() {
3439 let perms = PermissionRules {
3440 autonomous: false,
3441 sandbox: SandboxLevel::WorkspaceWrite,
3442 disallowed_tools: vec!["Bash(rm *)".to_string()],
3443 allowed_tools: vec![],
3444 allowed_edit_paths: vec![],
3445 deny_read_only: false,
3446 extra_writable_dirs: vec![],
3447 };
3448 let sys = "/tmp/sys.md";
3449 let msg = "/tmp/msg.md";
3450
3451 for agent in &["kimi", "minimax"] {
3452 let runner = get_runner(agent);
3453 let cmd = runner.build_command(None, "", sys, msg, &perms);
3454 assert!(
3455 cmd.contains("--permission-mode acceptEdits"),
3456 "{agent}: expected acceptEdits"
3457 );
3458 assert!(
3459 cmd.contains("Bash(rm *)"),
3460 "{agent}: expected rm disallowed"
3461 );
3462 }
3463 }
3464
3465 #[test]
3467 fn kimi_minimax_use_allowed_tools() {
3468 let perms = PermissionRules {
3469 autonomous: true,
3470 sandbox: SandboxLevel::WorkspaceWrite,
3471 disallowed_tools: vec![],
3472 allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3473 allowed_edit_paths: vec![],
3474 deny_read_only: false,
3475 extra_writable_dirs: vec![],
3476 };
3477
3478 for agent in &["kimi", "minimax"] {
3479 let runner = get_runner(agent);
3480 let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3481 assert!(
3482 cmd.contains("--allowedTools"),
3483 "{agent}: expected --allowedTools, got: {cmd}"
3484 );
3485 assert!(
3486 cmd.contains("Bash(git *)"),
3487 "{agent}: expected Bash(git *) pattern, got: {cmd}"
3488 );
3489 }
3490 }
3491
3492 #[test]
3496 fn claude_allowed_tools_generates_allowedtools_flag() {
3497 let perms = PermissionRules {
3498 autonomous: true,
3499 sandbox: SandboxLevel::WorkspaceWrite,
3500 disallowed_tools: vec!["Bash(rm *)".to_string()],
3501 allowed_tools: vec![
3502 "Edit".to_string(),
3503 "Write".to_string(),
3504 "Read".to_string(),
3505 "Bash".to_string(),
3506 "git".to_string(),
3507 ],
3508 allowed_edit_paths: vec![],
3509 deny_read_only: false,
3510 extra_writable_dirs: vec![],
3511 };
3512
3513 let claude = get_runner("claude");
3514 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3515 assert!(
3516 cmd.contains("--allowedTools"),
3517 "expected --allowedTools, got: {cmd}"
3518 );
3519 assert!(
3520 !cmd.contains("--disallowedTools"),
3521 "should not have --disallowedTools when allowed_tools is set"
3522 );
3523 }
3524
3525 #[test]
3527 fn codex_opencode_ignore_allowed_tools() {
3528 let perms = PermissionRules {
3529 autonomous: true,
3530 sandbox: SandboxLevel::WorkspaceWrite,
3531 disallowed_tools: vec![],
3532 allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3533 allowed_edit_paths: vec![],
3534 deny_read_only: false,
3535 extra_writable_dirs: vec![],
3536 };
3537
3538 let codex = get_runner("codex");
3539 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3540 assert!(
3541 !cmd.contains("--allowedTools"),
3542 "codex should not have --allowedTools, got: {cmd}"
3543 );
3544
3545 let opencode = get_runner("opencode");
3546 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3547 assert!(
3548 !cmd.contains("--allowedTools"),
3549 "opencode should not have --allowedTools, got: {cmd}"
3550 );
3551 }
3552
3553 #[test]
3558 #[ignore]
3559 fn integration_from_config_to_agent_commands() {
3560 let perms = PermissionRules::from_config();
3561
3562 assert!(
3564 perms.autonomous,
3565 "from_config: expected autonomous=true by default"
3566 );
3567
3568 for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3570 let runner = get_runner(agent_name);
3571 let cmd =
3572 runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3573 assert!(
3574 !cmd.is_empty(),
3575 "{agent_name}: build_command returned empty string"
3576 );
3577 assert!(
3578 cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3579 "{agent_name}: command should reference agent binary, got: {cmd}"
3580 );
3581 }
3582 }
3583
3584 #[test]
3587 #[ignore]
3588 fn integration_supervised_config_translates_correctly() {
3589 let perms = PermissionRules::from_config();
3594
3595 let claude = get_runner("claude");
3596 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3597
3598 if perms.autonomous {
3599 assert!(
3600 cmd.contains("bypassPermissions"),
3601 "autonomous config → bypassPermissions"
3602 );
3603 } else {
3604 assert!(
3605 cmd.contains("acceptEdits"),
3606 "supervised config → acceptEdits"
3607 );
3608 }
3609
3610 let codex = get_runner("codex");
3611 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3612
3613 if perms.autonomous {
3614 assert!(
3616 cmd.contains("--sandbox workspace-write"),
3617 "autonomous config → --sandbox workspace-write, got: {cmd}"
3618 );
3619 assert!(
3620 cmd.contains(r#"-c 'approval_policy="never"'"#),
3621 "autonomous config → -c approval_policy=never, got: {cmd}"
3622 );
3623 } else {
3624 assert!(
3625 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3626 "supervised config → -c approval_policy=on-request, got: {cmd}"
3627 );
3628 }
3629 assert!(
3630 !cmd.contains("--ask-for-approval"),
3631 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3632 );
3633 }
3634
3635 #[test]
3639 fn deny_read_only_blocks_reading_tools() {
3640 let perms = PermissionRules::deny_read_only();
3641
3642 let read_tools = [
3643 "Bash(ls *)",
3644 "Bash(ls)",
3645 "Bash(find *)",
3646 "Bash(cat *)",
3647 "Bash(head *)",
3648 "Bash(tail *)",
3649 "Bash(fzf *)",
3650 "Bash(less *)",
3651 "Bash(more *)",
3652 "Read",
3653 "Glob",
3654 "Grep",
3655 ];
3656
3657 for tool in read_tools {
3658 assert!(
3659 perms.disallowed_tools.contains(&tool.to_string()),
3660 "deny_read_only should block {tool}, got: {:?}",
3661 perms.disallowed_tools
3662 );
3663 }
3664 }
3665
3666 #[test]
3668 fn deny_read_only_claude_command() {
3669 let perms = PermissionRules::deny_read_only();
3670 let claude = get_runner("claude");
3671 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3672
3673 assert!(
3675 cmd.contains("Read"),
3676 "should block Read for control session"
3677 );
3678 assert!(
3679 cmd.contains("Glob"),
3680 "should block Glob for control session"
3681 );
3682 assert!(
3683 cmd.contains("Grep"),
3684 "should block Grep for control session"
3685 );
3686 assert!(
3688 cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3689 "should block ls"
3690 );
3691 assert!(
3693 cmd.contains("--permission-mode bypassPermissions"),
3694 "should be autonomous (bypassPermissions)"
3695 );
3696 }
3697
3698 #[test]
3701 fn synthesize_extracts_file_paths() {
3702 let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3703 let resp = synthesize_response_from_text(text).unwrap();
3704 assert!(
3705 resp.files
3706 .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3707 "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3708 resp.files
3709 );
3710 assert!(
3711 resp.files.contains(&"tests/integration.rs".to_string()),
3712 "expected tests/integration.rs in files, got: {:?}",
3713 resp.files
3714 );
3715 }
3716
3717 #[test]
3718 fn synthesize_extracts_accomplished_bullets() {
3719 let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3720 let resp = synthesize_response_from_text(text).unwrap();
3721 assert_eq!(resp.status, "done");
3722 assert!(
3723 resp.accomplished
3724 .iter()
3725 .any(|a| a.contains("Added logging")),
3726 "expected accomplished bullet, got: {:?}",
3727 resp.accomplished
3728 );
3729 assert!(
3730 resp.accomplished
3731 .iter()
3732 .any(|a| a.contains("Updated migration")),
3733 "expected accomplished bullet, got: {:?}",
3734 resp.accomplished
3735 );
3736 }
3737
3738 #[test]
3739 fn synthesize_extracts_remaining_section() {
3740 let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3741 let resp = synthesize_response_from_text(text).unwrap();
3742 assert!(
3743 resp.remaining
3744 .iter()
3745 .any(|r| r.contains("Write unit tests")),
3746 "expected remaining item, got: {:?}",
3747 resp.remaining
3748 );
3749 assert!(
3750 resp.remaining.iter().any(|r| r.contains("Update docs")),
3751 "expected remaining item, got: {:?}",
3752 resp.remaining
3753 );
3754 assert!(
3755 resp.accomplished.iter().any(|a| a.contains("Patched")),
3756 "expected accomplished item, got: {:?}",
3757 resp.accomplished
3758 );
3759 }
3760
3761 #[test]
3762 fn synthesize_fixed_phrase_is_done() {
3763 let text = "Fixed. The bug no longer reproduces.";
3764 let resp = synthesize_response_from_text(text).unwrap();
3765 assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3766 }
3767
3768 #[test]
3775 fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3776 let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3780 assert!(
3781 patterns::detect_rate_limit(payload).is_some(),
3782 "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3783 );
3784 }
3785
3786 #[test]
3787 fn detect_rate_limit_ignores_429_inside_longer_number() {
3788 let uuid = r#"{"type":"hook","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b"}"#;
3789 assert!(
3790 patterns::detect_rate_limit(uuid).is_none(),
3791 "429 inside a UUID digit run must not match"
3792 );
3793 }
3794
3795 #[test]
3799 fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3800 let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3801 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3802 && !raw_output.contains("\"is_error\":true");
3803 let detected = (!ndjson_completed)
3805 .then(|| patterns::detect_rate_limit(raw_output))
3806 .flatten();
3807 assert!(
3808 detected.is_none(),
3809 "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3810 );
3811 }
3812
3813 #[test]
3815 fn terminal_reason_completed_guard_passes_real_rate_limit() {
3816 let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3818 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3819 && !raw_output.contains("\"is_error\":true");
3820 let detected = (!ndjson_completed)
3821 .then(|| patterns::detect_rate_limit(raw_output))
3822 .flatten();
3823 assert!(
3824 detected.is_some(),
3825 "guard must NOT suppress a real rate-limit error"
3826 );
3827 }
3828
3829 #[test]
3830 fn synthesize_no_false_positive_file_paths() {
3831 let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3835 match synthesize_response_from_text(text) {
3836 None => {} Some(resp) => {
3838 assert!(
3839 resp.files.is_empty(),
3840 "expected no file paths extracted from URL text, got: {:?}",
3841 resp.files
3842 );
3843 }
3844 }
3845 }
3846
3847 #[test]
3852 fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3853 let text = "error: unexpected argument '--ask-for-approval' found\n \
3855 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3856 Usage: codex exec [OPTIONS] [PROMPT]";
3857 assert!(
3858 synthesize_response_from_text(text).is_none(),
3859 "CLI parser diagnostic must not synthesize as agent response"
3860 );
3861 }
3862
3863 #[test]
3864 fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3865 let text = "Usage: mycommand [OPTIONS] <arg>";
3866 assert!(
3867 synthesize_response_from_text(text).is_none(),
3868 "Usage line must not synthesize as agent response"
3869 );
3870 }
3871
3872 #[test]
3873 fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3874 let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3875 assert!(
3876 synthesize_response_from_text(text).is_none(),
3877 "Help hint must not synthesize as agent response"
3878 );
3879 }
3880
3881 #[test]
3882 fn synthesize_rejects_cli_parser_diagnostics_tip() {
3883 let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3884 assert!(
3885 synthesize_response_from_text(text).is_none(),
3886 "Clap tip must not synthesize as agent response"
3887 );
3888 }
3889
3890 #[test]
3891 fn synthesize_accepts_agent_error_messages_that_mention_error() {
3892 let text = "I tried to run the command but got an error: permission denied on that file.";
3895 let response =
3896 synthesize_response_from_text(text).expect("agent error message should synthesize");
3897 assert_eq!(response.status, "needs_review");
3898 assert!(response.error.is_some());
3899 }
3900
3901 #[test]
3906 fn synthesize_rejects_exact_codex_cli_error() {
3907 let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n \
3908 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3909 Usage: codex exec [OPTIONS] [PROMPT]";
3910 assert!(
3911 synthesize_response_from_text(stderr_from_codex).is_none(),
3912 "exact codex CLI error from #3197 must not synthesize as agent response"
3913 );
3914 }
3915}