1#[macro_use]
9mod delegate;
10pub(crate) mod patterns;
12
13pub mod claude;
14pub mod codex;
15pub mod grok;
16pub mod kimi;
17pub mod minimax;
18pub mod opencode;
19
20use crate::config;
21use crate::parser::AgentResponse;
22use std::collections::BTreeSet;
23use std::error;
24use std::fmt;
25use std::fmt::Display;
26use std::fmt::Formatter;
27use std::path::PathBuf;
28use std::time::Duration;
29use tokio::process::Command;
30
31#[derive(Debug, Clone)]
37pub struct PermissionRules {
38 pub autonomous: bool,
40 pub sandbox: SandboxLevel,
42 pub disallowed_tools: Vec<String>,
44 pub allowed_tools: Vec<String>,
50 pub allowed_edit_paths: Vec<PathBuf>,
54 pub deny_read_only: bool,
58 pub extra_writable_dirs: Vec<PathBuf>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum SandboxLevel {
68 WorkspaceWrite,
70 FullAccess,
72 None,
74 Os,
76}
77
78impl Default for PermissionRules {
79 fn default() -> Self {
80 Self {
81 autonomous: true,
82 sandbox: SandboxLevel::WorkspaceWrite,
83 disallowed_tools: vec![
84 "Bash(rm *)".to_string(),
85 "Bash(rm -*)".to_string(),
86 "Bash(git push*)".to_string(),
87 ],
88 allowed_tools: vec![],
89 allowed_edit_paths: vec![],
90 deny_read_only: false,
91 extra_writable_dirs: vec![],
92 }
93 }
94}
95
96impl PermissionRules {
97 pub fn from_config() -> Self {
99 let mut rules = Self::default();
100
101 if let Ok(mode) = config::get("workflow.permissions.mode") {
102 rules.autonomous = mode != "supervised";
103 }
104
105 if let Ok(sandbox) = config::get("workflow.permissions.sandbox") {
106 rules.sandbox = match sandbox.as_str() {
107 "full-access" | "danger-full-access" => SandboxLevel::FullAccess,
108 "none" => SandboxLevel::None,
109 _ => SandboxLevel::WorkspaceWrite,
110 };
111 }
112
113 if let Ok(tools) = config::get("workflow.disallowed_tools") {
114 let parsed: Vec<String> = tools
115 .split(',')
116 .map(|s| s.trim().to_string())
117 .filter(|s| !s.is_empty())
118 .collect();
119 if !parsed.is_empty() {
120 rules.disallowed_tools = parsed;
121 }
122 }
123
124 if let Ok(tools) = config::get_list("workflow.allowed_tools") {
125 rules.allowed_tools = tools;
126 }
127
128 rules
129 }
130
131 pub fn deny_read_only() -> Self {
138 Self {
139 autonomous: true,
140 sandbox: SandboxLevel::WorkspaceWrite,
141 disallowed_tools: vec![
142 "Bash(rm *)".to_string(),
143 "Bash(rm -*)".to_string(),
144 "Bash(git push*)".to_string(),
145 "Bash(ls *)".to_string(),
146 "Bash(ls)".to_string(),
147 "Bash(find *)".to_string(),
148 "Bash(cat *)".to_string(),
149 "Bash(head *)".to_string(),
150 "Bash(tail *)".to_string(),
151 "Bash(fzf *)".to_string(),
152 "Bash(less *)".to_string(),
153 "Bash(more *)".to_string(),
154 "Read".to_string(),
155 "Glob".to_string(),
156 "Grep".to_string(),
157 ],
158 allowed_tools: vec![],
159 allowed_edit_paths: vec![],
160 deny_read_only: true,
161 extra_writable_dirs: vec![],
162 }
163 }
164}
165
166#[derive(Debug, Clone)]
172pub struct AgentResult {
173 pub is_error: bool,
175 pub result_text: String,
177 pub input_tokens: Option<u64>,
179 pub output_tokens: Option<u64>,
181 pub cost_usd: Option<f64>,
183 pub duration_ms: Option<u64>,
185 pub truncated_by_length: bool,
191}
192
193pub fn collect_assistant_messages_text(_agent: &str, ndjson: &str) -> String {
198 claude::collect_assistant_messages_text(ndjson)
199}
200
201pub fn find_agent_result(agent: &str, ndjson: &str) -> Option<AgentResult> {
206 match agent {
207 "grok" => grok::find_grok_result(ndjson),
208 "opencode" => opencode::find_opencode_result(ndjson),
209 "codex" => codex::find_codex_result(ndjson),
210 _ => claude::find_claude_result(ndjson),
212 }
213}
214
215#[derive(Debug, Clone)]
218pub struct ParsedResponse {
219 pub response: AgentResponse,
221 pub input_tokens: Option<u64>,
223 pub output_tokens: Option<u64>,
225 pub duration_ms: Option<u64>,
227}
228
229#[derive(Debug, Clone)]
231pub enum AgentError {
232 RateLimit { message: String },
234 Auth { message: String },
236 ModelUnavailable { message: String, model: String },
238 ContextOverflow { message: String },
240 Timeout { elapsed: Duration },
242 Interrupted { message: String },
244 MissingTool { tool: String },
246 PermissionDenied { message: String },
248 WaitingForInput { message: String },
250 StaleSession { session_id: String },
255 InvalidResponse { raw: String },
257 AgentFailed { message: String },
259 NetworkError { message: String },
261 ThinkingBlockConflict { message: String },
268 Unknown { exit_code: i32, message: String },
270}
271
272impl Display for AgentError {
273 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
274 match self {
275 Self::RateLimit { message, .. } => write!(f, "rate limit: {message}"),
276 Self::Auth { message } => write!(f, "auth error: {message}"),
277 Self::ModelUnavailable { message, model } => {
278 write!(f, "model unavailable ({model}): {message}")
279 }
280 Self::ContextOverflow { message, .. } => write!(f, "context overflow: {message}"),
281 Self::Timeout { elapsed } => write!(f, "timeout after {}s", elapsed.as_secs()),
282 Self::Interrupted { message } => write!(f, "interrupted: {message}"),
283 Self::MissingTool { tool } => write!(f, "missing tool: {tool}"),
284 Self::PermissionDenied { message } => write!(f, "permission denied: {message}"),
285 Self::WaitingForInput { message } => write!(f, "waiting for input: {message}"),
286 Self::StaleSession { session_id } => {
287 write!(f, "stale session (session expired): {session_id}")
288 }
289 Self::InvalidResponse { raw } => {
290 let end = truncate_at_char_boundary(raw, 200);
291 write!(f, "invalid response: {}", &raw[..end])
292 }
293 Self::AgentFailed { message } => write!(f, "agent failed: {message}"),
294 Self::NetworkError { message } => write!(f, "network error: {message}"),
295 Self::ThinkingBlockConflict { message } => {
296 write!(f, "thinking block conflict: {message}")
297 }
298 Self::Unknown { exit_code, message } => {
299 write!(f, "unknown error (exit {exit_code}): {message}")
300 }
301 }
302 }
303}
304
305impl error::Error for AgentError {}
306
307pub fn error_class_name(err: &AgentError) -> &'static str {
310 match err {
311 AgentError::RateLimit { .. } => "RateLimit",
312 AgentError::Auth { .. } => "Auth",
313 AgentError::ModelUnavailable { .. } => "ModelUnavailable",
314 AgentError::ContextOverflow { .. } => "ContextOverflow",
315 AgentError::Timeout { .. } => "Timeout",
316 AgentError::Interrupted { .. } => "Interrupted",
317 AgentError::MissingTool { .. } => "MissingTool",
318 AgentError::PermissionDenied { .. } => "PermissionDenied",
319 AgentError::WaitingForInput { .. } => "WaitingForInput",
320 AgentError::StaleSession { .. } => "StaleSession",
321 AgentError::InvalidResponse { .. } => "InvalidResponse",
322 AgentError::AgentFailed { .. } => "AgentFailed",
323 AgentError::NetworkError { .. } => "NetworkError",
324 AgentError::ThinkingBlockConflict { .. } => "ThinkingBlockConflict",
325 AgentError::Unknown { .. } => "Unknown",
326 }
327}
328
329fn extract_file_paths(text: &str) -> Vec<String> {
334 let extensions = [
337 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh",
338 "md", "toml", "yaml", "yml", "json", "sql", "html", "css", "swift", "kt",
339 ];
340 let mut found: BTreeSet<String> = BTreeSet::new();
341 for token in text.split_whitespace() {
342 let token = token.trim_matches(|c: char| {
345 matches!(
346 c,
347 '`' | '\'' | '"' | '(' | ')' | '[' | ']' | ',' | ':' | ';'
348 )
349 });
350 let token = if let Some(without) = token.strip_suffix('.') {
354 let still_has_ext = extensions
355 .iter()
356 .any(|ext| without.ends_with(&format!(".{ext}")));
357 if still_has_ext { without } else { token }
358 } else {
359 token
360 };
361 let has_ext = extensions
364 .iter()
365 .any(|ext| token.ends_with(&format!(".{ext}")));
366 if !has_ext {
367 continue;
368 }
369 let path_like = token.contains('/')
372 || token.starts_with("src")
373 || token.starts_with("tests")
374 || token.starts_with("lib")
375 || token.starts_with("bin")
376 || token.starts_with("prompts")
377 || token.starts_with("migrations");
378 if path_like && token.len() >= 4 && !token.contains("://") {
379 found.insert(token.to_string());
380 }
381 }
382 found.into_iter().collect()
383}
384
385fn extract_bullet_sections(text: &str) -> (Vec<String>, Vec<String>) {
390 let remaining_headers = [
391 "remaining",
392 "todo",
393 "to do",
394 "next steps",
395 "still needed",
396 "left to do",
397 "not done",
398 "outstanding",
399 ];
400
401 let mut accomplished: Vec<String> = vec![];
402 let mut remaining: Vec<String> = vec![];
403 let mut in_remaining_section = false;
404
405 for line in text.lines() {
406 let trimmed = line.trim();
407 if trimmed.is_empty() {
408 continue;
409 }
410 let lower = trimmed.to_lowercase();
411
412 let is_heading = trimmed.ends_with(':') || trimmed.starts_with('#');
417
418 if is_heading {
419 in_remaining_section = remaining_headers.iter().any(|h| lower.contains(h));
420 continue;
421 }
422
423 let is_bullet = trimmed.starts_with('-')
425 || trimmed.starts_with('*')
426 || trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains('.');
427
428 if !is_bullet {
429 continue;
430 }
431
432 let content = trimmed
434 .trim_start_matches(|c: char| c == '-' || c == '*' || c.is_ascii_digit() || c == '.')
435 .trim()
436 .to_string();
437
438 if content.is_empty() {
439 continue;
440 }
441
442 if in_remaining_section {
443 remaining.push(content);
444 } else {
445 accomplished.push(content);
446 }
447 }
448
449 (accomplished, remaining)
450}
451
452fn is_cli_parser_diagnostic(text: &str) -> bool {
455 let lower = text.to_lowercase();
456
457 if lower.contains("error: unexpected argument") {
459 return true;
460 }
461
462 if text.lines().any(|line| {
464 let trimmed = line.trim();
465 trimmed.starts_with("Usage:") || trimmed.starts_with("usage:")
466 }) {
467 return true;
468 }
469
470 if lower.contains("for more information, try '--help'") {
472 return true;
473 }
474
475 if lower.contains("tip: to pass '") {
477 return true;
478 }
479
480 false
481}
482
483fn looks_like_ndjson_event_stream(text: &str) -> bool {
491 let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
492 return false;
493 };
494
495 if !first_line.starts_with('{') {
496 return false;
497 }
498
499 let Ok(value) = serde_json::from_str::<serde_json::Value>(first_line) else {
500 return false;
501 };
502
503 let Some(event_type) = value.get("type").and_then(|v| v.as_str()) else {
504 return false;
505 };
506
507 matches!(
508 event_type,
509 "step_start"
510 | "step_finish"
511 | "tool_use"
512 | "tool_result"
513 | "assistant"
514 | "system"
515 | "message"
516 | "text"
517 )
518}
519
520pub fn synthesize_response_from_text(text: &str) -> Option<AgentResponse> {
525 fn contains_word(text: &str, word: &str) -> bool {
528 let mut start = 0;
529 while let Some(pos) = text[start..].find(word) {
530 let abs = start + pos;
531 let before_ok = abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
532 let after_ok = abs + word.len() >= text.len()
533 || !text.as_bytes()[abs + word.len()].is_ascii_alphanumeric();
534 if before_ok && after_ok {
535 return true;
536 }
537 start = abs + 1;
538 if start >= text.len() {
539 break;
540 }
541 }
542 false
543 }
544
545 let trimmed = text.trim();
546 if trimmed.is_empty() {
547 return None;
548 }
549
550 if is_cli_parser_diagnostic(trimmed) {
558 return None;
559 }
560
561 if looks_like_ndjson_event_stream(trimmed) {
567 return None;
568 }
569
570 let lower = trimmed.to_lowercase();
571 let looks_negative = [
578 "not done",
579 "not yet done",
580 "not complete",
581 "not yet complete",
582 "not completed",
583 "not yet completed",
584 "not committed",
585 "incomplete",
586 "undone",
587 ]
588 .iter()
589 .any(|needle| lower.contains(needle));
590
591 let explicit_done = [
594 "nothing to",
595 "nothing to do",
596 "nothing to execute",
597 "no positions",
598 "no open positions",
599 "no trades",
600 "no trade",
601 "no action needed",
602 "no changes to commit",
603 "completed",
604 "filed",
606 "filed issue",
607 "filed issues",
608 "created issue",
609 "created issues",
610 "opened issue",
611 "opened issues",
612 "issue created",
613 "issues created",
614 "issues filed",
615 "task created",
616 "posted comment",
617 "comment posted",
618 "changes committed",
619 "commit created",
620 "fixed.",
621 "has been fixed",
622 "the fix has been applied",
623 "the fix has already been applied",
624 "the fix is complete",
626 "fix is working",
627 "all tests pass",
628 "tests pass.",
629 ]
630 .iter()
631 .any(|needle| lower.contains(needle));
632
633 let mut done_confident = false;
639 for sentence in trimmed
640 .split(['.', '!', '?', '\n'])
641 .map(str::trim)
642 .filter(|s| !s.is_empty())
643 {
644 let s_lower = sentence.to_lowercase();
645 let word_count = sentence.split_whitespace().count();
647 let hedging = [
648 "let",
649 "let me",
650 "check",
651 "checking",
652 "will",
653 "i'll",
654 "i will",
655 "trying",
656 "try",
657 "investigat",
658 "maybe",
659 "could",
660 "should",
661 "might",
662 "if ",
663 "plan",
664 "planning",
665 ];
666 let contains_hedge = hedging.iter().any(|h| s_lower.contains(h));
667
668 if contains_word(&s_lower, "done") && word_count <= 8 && !contains_hedge {
669 done_confident = true;
670 break;
671 }
672
673 if s_lower.contains("committed") || s_lower.contains("commit created") {
677 if s_lower.contains("changes")
679 || s_lower.contains("i've")
680 || s_lower.contains("i have")
681 || s_lower.contains("the commit")
682 {
683 done_confident = true;
684 break;
685 }
686 }
687
688 let past_tense_actions = [
693 "added ",
694 "fixed ",
695 "updated ",
696 "implemented ",
697 "created ",
698 "removed ",
699 "changed ",
700 "deleted ",
701 "applied ",
702 "resolved ",
703 "patched ",
704 "refactored ",
705 "merged ",
706 "pushed ",
707 "deployed ",
708 ];
709 if !contains_hedge
710 && word_count <= 12
711 && past_tense_actions.iter().any(|v| s_lower.starts_with(v))
712 {
713 done_confident = true;
714 break;
715 }
716 }
717
718 let looks_done = !looks_negative && (explicit_done || done_confident);
719
720 let looks_error = [
721 "error:",
722 "failed to",
723 "cannot",
724 "permission denied",
725 "unable to",
726 ]
727 .iter()
728 .any(|needle| lower.contains(needle));
729
730 let status = if looks_error {
731 "needs_review"
732 } else if looks_done {
733 "done"
734 } else if looks_negative {
735 "needs_review"
737 } else {
738 return None;
744 };
745
746 let summary_end = truncate_at_char_boundary(trimmed, 500);
747 let summary = trimmed[..summary_end].to_string();
748 let error =
749 (status == "needs_review").then(|| "agent returned plain text instead of JSON".to_string());
750
751 let files = extract_file_paths(trimmed);
753 let (accomplished, remaining) = extract_bullet_sections(trimmed);
754
755 Some(AgentResponse {
756 status: status.to_string(),
757 summary,
758 accomplished,
759 remaining,
760 files,
761 error,
762 input_tokens: None,
763 output_tokens: None,
764 learnings: vec![],
765 delegations: vec![],
766 })
767}
768
769pub(crate) fn shell_single_quote(value: &str) -> String {
771 format!("'{}'", value.replace('\'', "'\\''"))
772}
773
774pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
777 if max_bytes >= s.len() {
778 return s.len();
779 }
780 let mut end = max_bytes;
782 while end > 0 && !s.is_char_boundary(end) {
783 end -= 1;
784 }
785 end
786}
787
788#[derive(Debug, Clone, PartialEq)]
790pub enum StreamEvent {
791 Started {
792 model: String,
793 },
794 Text(String),
795 Thinking(String),
796 ToolCall {
798 id: String,
799 name: String,
800 input: serde_json::Value,
801 },
802 ToolResult {
804 id: String,
805 name: String,
806 output: String,
807 is_error: bool,
808 },
809 Done(String),
810 Error(String),
811}
812
813pub(crate) fn json_str<'a>(v: &'a serde_json::Value, pointer: &str) -> &'a str {
815 v.pointer(pointer)
816 .and_then(serde_json::Value::as_str)
817 .unwrap_or("")
818}
819
820pub(crate) fn json_error_message(v: &serde_json::Value) -> String {
822 [
823 "/message",
824 "/error",
825 "/error/message",
826 "/error/data/message",
827 ]
828 .iter()
829 .find_map(|p| v.pointer(p).and_then(serde_json::Value::as_str))
830 .unwrap_or("unknown error")
831 .to_string()
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq)]
836pub enum RouterTranscript {
837 Messages,
839 OpenCode,
841 Raw,
843}
844
845pub trait AgentRunner: Send + Sync {
850 #[cfg(test)]
852 fn name(&self) -> &str;
853
854 fn build_command(
860 &self,
861 model: Option<&str>,
862 timeout_cmd: &str,
863 sys_file: &str,
864 msg_file: &str,
865 permissions: &PermissionRules,
866 ) -> String;
867
868 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError>;
873
874 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
889 Ok(raw.to_string())
890 }
891
892 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent>;
894
895 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError;
900
901 fn classify_error_with_elapsed(
903 &self,
904 exit_code: i32,
905 stdout: &str,
906 stderr: &str,
907 elapsed_secs: Option<u64>,
908 ) -> AgentError {
909 let mut err = self.classify_error(exit_code, stdout, stderr);
910 if let AgentError::Timeout { elapsed } = &mut err
911 && let Some(actual_elapsed) = elapsed_secs
912 {
913 *elapsed = Duration::from_secs(actual_elapsed);
914 }
915 err
916 }
917
918 fn available_models(&self) -> Vec<String> {
920 vec![]
921 }
922
923 fn free_models(&self) -> Vec<String> {
925 vec![]
926 }
927
928 fn router_transcript(&self) -> RouterTranscript {
930 RouterTranscript::Raw
931 }
932
933 fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command>;
939}
940
941pub fn supports_session(agent: &str) -> bool {
943 matches!(agent, "claude" | "kimi" | "minimax" | "grok")
944}
945
946pub fn get_runner(agent_name: &str) -> Box<dyn AgentRunner> {
948 const CLAUDE_COMPATIBLE_AGENTS: &[&str] = &["claude", "kimi", "minimax", "olm", "glm"];
950
951 match agent_name {
952 "claude" => Box::new(claude::ClaudeRunner::new(agent_name)),
953 "kimi" => Box::new(kimi::KimiClaudeRunner::new()),
954 "minimax" => Box::new(minimax::MiniMaxClaudeRunner::new(agent_name)),
955 "codex" => Box::new(codex::CodexRunner),
956 "opencode" => Box::new(opencode::OpenCodeRunner::new()),
957 "grok" => Box::new(grok::GrokRunner),
958 other => {
960 if !CLAUDE_COMPATIBLE_AGENTS.contains(&other) {
962 tracing::warn!(
963 agent = other,
964 "unknown agent, using claude-compatible runner"
965 );
966 }
967 Box::new(claude::ClaudeRunner::new(other))
968 }
969 }
970}
971
972pub(crate) fn parse_ndjson(raw: &str) -> Vec<serde_json::Value> {
981 let events: Vec<serde_json::Value> = raw
983 .lines()
984 .filter(|line| !line.trim().is_empty())
985 .filter_map(|line| match serde_json::from_str(line) {
986 Ok(val) => Some(val),
987 Err(e) => {
988 tracing::debug!(line, error = %e, "skipping unparseable NDJSON line");
989 None
990 }
991 })
992 .collect();
993
994 if !events.is_empty() {
998 return events;
999 }
1000
1001 let acc: String = raw
1007 .lines()
1008 .filter(|line| {
1009 let t = line.trim();
1010 !t.is_empty() && !t.starts_with("//") && !t.starts_with('#')
1011 })
1012 .collect::<Vec<_>>()
1013 .join(" ");
1014
1015 if acc.is_empty() {
1016 return Vec::new();
1017 }
1018
1019 if let Ok(val) = serde_json::from_str::<serde_json::Value>(acc.trim()) {
1022 let stripped: String = acc.chars().filter(|c| !c.is_ascii_whitespace()).collect();
1027 if let Ok(stripped_val) = serde_json::from_str::<serde_json::Value>(&stripped) {
1028 if stripped_val == val {
1032 return Vec::new();
1033 }
1034 }
1035 return vec![val];
1036 }
1037
1038 extract_json_objects(&acc)
1042}
1043
1044fn extract_json_objects(text: &str) -> Vec<serde_json::Value> {
1049 let mut results = Vec::new();
1050 let mut start: Option<usize> = None;
1051 let mut depth: i32 = 0;
1052 let mut in_string = false;
1053 let mut escape = false;
1054
1055 for (idx, ch) in text.char_indices() {
1056 if let Some(start_idx) = start {
1057 if in_string {
1058 if escape {
1059 escape = false;
1060 } else if ch == '\\' {
1061 escape = true;
1062 } else if ch == '"' {
1063 in_string = false;
1064 }
1065 continue;
1066 }
1067
1068 match ch {
1069 '"' => in_string = true,
1070 '{' => depth += 1,
1071 '}' => {
1072 depth -= 1;
1073 if depth == 0 {
1074 let candidate = &text[start_idx..=idx];
1075 if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
1076 results.push(val);
1077 }
1078 start = None;
1079 }
1080 }
1081 _ => {}
1082 }
1083 continue;
1084 }
1085
1086 if ch == '{' {
1087 start = Some(idx);
1088 depth = 1;
1089 in_string = false;
1090 escape = false;
1091 }
1092 }
1093
1094 results
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use tracing::Level;
1100 use tracing_subscriber::EnvFilter;
1101 use tracing_subscriber::fmt::writer::MakeWriter;
1102 use tracing_subscriber::util::SubscriberInitExt;
1103
1104 use std::io;
1105 use std::io::Write;
1106 use std::sync::Arc;
1107 use std::sync::Mutex;
1108
1109 use super::*;
1110
1111 #[test]
1112 fn get_runner_returns_correct_types() {
1113 assert_eq!(get_runner("claude").name(), "claude");
1114 assert_eq!(get_runner("kimi").name(), "kimi");
1115 assert_eq!(get_runner("minimax").name(), "minimax");
1116 assert_eq!(get_runner("codex").name(), "codex");
1117 assert_eq!(get_runner("opencode").name(), "opencode");
1118 assert_eq!(get_runner("grok").name(), "grok");
1119 assert_eq!(
1120 get_runner("claude").router_transcript(),
1121 RouterTranscript::Messages
1122 );
1123 assert_eq!(
1124 get_runner("kimi").router_transcript(),
1125 RouterTranscript::Messages
1126 );
1127 assert_eq!(
1128 get_runner("minimax").router_transcript(),
1129 RouterTranscript::Messages
1130 );
1131 assert_eq!(
1132 get_runner("grok").router_transcript(),
1133 RouterTranscript::Messages
1134 );
1135 assert_eq!(
1136 get_runner("glm").router_transcript(),
1137 RouterTranscript::Messages
1138 );
1139 assert_eq!(
1140 get_runner("olm").router_transcript(),
1141 RouterTranscript::Messages
1142 );
1143 assert_eq!(
1144 get_runner("opencode").router_transcript(),
1145 RouterTranscript::OpenCode
1146 );
1147 assert_eq!(
1148 get_runner("codex").router_transcript(),
1149 RouterTranscript::Raw
1150 );
1151 assert!(supports_session("grok"));
1152 assert!(!supports_session("codex"));
1153 assert_eq!(get_runner("olm").name(), "olm");
1155 assert_eq!(get_runner("glm").name(), "glm");
1156 assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
1158 }
1159
1160 #[test]
1163 fn get_runner_no_warning_for_claude_compatible_agents() {
1164 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1165 impl Write for CaptureWriter {
1166 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1167 self.0.lock().unwrap().extend_from_slice(buf);
1168 Ok(buf.len())
1169 }
1170 fn flush(&mut self) -> io::Result<()> {
1171 Ok(())
1172 }
1173 }
1174 impl<'a> MakeWriter<'a> for CaptureWriter {
1175 type Writer = CaptureWriter;
1176 fn make_writer(&'a self) -> Self::Writer {
1177 CaptureWriter(Arc::clone(&self.0))
1178 }
1179 }
1180
1181 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1182 let output_clone = Arc::clone(&output);
1183
1184 tracing_subscriber::fmt()
1185 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
1186 .with_writer(CaptureWriter(output_clone))
1187 .with_ansi(false)
1188 .finish()
1189 .init();
1190
1191 for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
1192 get_runner(agent);
1193 }
1194
1195 let captured = {
1196 let guard = output.lock().unwrap();
1197 String::from_utf8_lossy(&guard).to_string()
1198 };
1199 assert!(
1200 !captured.contains("unknown agent"),
1201 "known claude-compatible agents must not emit 'unknown agent' warnings; got: {captured}"
1202 );
1203 }
1204
1205 #[test]
1206 fn agent_error_display() {
1207 let e = AgentError::RateLimit {
1208 message: "429 Too Many Requests".to_string(),
1209 };
1210 assert!(e.to_string().contains("rate limit"));
1211
1212 let e = AgentError::Timeout {
1213 elapsed: Duration::from_mins(30),
1214 };
1215 assert!(e.to_string().contains("1800"));
1216 }
1217
1218 #[test]
1219 fn pattern_detect_rate_limit() {
1220 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
1221 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
1222 assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
1223 assert!(
1224 patterns::detect_rate_limit(
1225 "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
1226 )
1227 .is_some()
1228 );
1229 assert!(patterns::detect_rate_limit(
1230 "Upstream error from Alibaba: Request rate increased too quickly. \
1231 To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
1232 ).is_some());
1233 assert!(patterns::detect_rate_limit("all good").is_none());
1234 }
1235
1236 #[test]
1237 fn classify_from_text_detects_weekly_limit_as_rate_limit() {
1238 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)"}}"#;
1242 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
1243 .repeat(200);
1244 let text = format!("{system_event}\n{work_product}");
1245 assert!(
1246 text.len() > 3000,
1247 "test output must exceed tail window to be meaningful"
1248 );
1249 let err = patterns::classify_from_text(1, &text);
1250 assert!(
1251 matches!(err, AgentError::RateLimit { .. }),
1252 "weekly limit as early system event must be detected via head scan, got: {err:?}"
1253 );
1254 let msg = err.to_string();
1255 assert!(
1256 msg.contains("weekly limit"),
1257 "error message should reference weekly limit, got: {msg}"
1258 );
1259 }
1260
1261 #[test]
1262 fn detect_rate_limit_github_copilot_monthly_quota() {
1263 let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
1267 assert!(
1268 matches!(err, Some(AgentError::RateLimit { .. })),
1269 "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
1270 );
1271 }
1272
1273 #[test]
1274 fn detect_rate_limit_nvidia_resourceexhausted() {
1275 let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
1279 let err = patterns::detect_rate_limit(opencode_error);
1280 assert!(
1281 matches!(err, Some(AgentError::RateLimit { .. })),
1282 "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
1283 );
1284
1285 assert!(
1287 patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
1288 "bare ResourceExhausted must match"
1289 );
1290 assert!(
1291 patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
1292 .is_some(),
1293 "worker local total request limit reached must match"
1294 );
1295
1296 let err = patterns::classify_from_text(1, opencode_error);
1298 assert!(
1299 matches!(err, AgentError::RateLimit { .. }),
1300 "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
1301 );
1302 }
1303
1304 #[test]
1305 fn detect_rate_limit_message_contains_context_not_tail() {
1306 let padding = "x".repeat(5000);
1309 let text = format!(
1310 "{padding}you've reached your usage limit for this billing cycle{padding}\
1311 {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
1312 );
1313 let err = patterns::detect_rate_limit(&text).expect("should detect");
1314 let msg = err.to_string();
1315 assert!(
1316 msg.contains("usage limit"),
1317 "message should contain the actual error, got: {msg}"
1318 );
1319 assert!(
1320 !msg.contains("web_fetch_requests"),
1321 "message should not be the tail JSON, got: {msg}"
1322 );
1323 }
1324
1325 #[test]
1326 fn pattern_detect_rate_limit_529_not_bare() {
1327 assert!(
1330 patterns::detect_rate_limit("error on line 529").is_none(),
1331 "bare line number 529 should not match"
1332 );
1333 assert!(
1334 patterns::detect_rate_limit("file size 529 bytes").is_none(),
1335 "bare file size 529 should not match"
1336 );
1337 assert!(
1338 patterns::detect_rate_limit("port :5290 in config").is_none(),
1339 "port number containing 529 should not match"
1340 );
1341 assert!(
1342 patterns::detect_rate_limit("timeout after 5290ms").is_none(),
1343 "duration containing 529 should not match"
1344 );
1345 assert!(
1347 patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
1348 "HTTP 529 status should match"
1349 );
1350 assert!(
1351 patterns::detect_rate_limit("http 529").is_some(),
1352 "http 529 should match"
1353 );
1354 assert!(
1355 patterns::detect_rate_limit("529 service unavailable").is_some(),
1356 "529 service unavailable should match"
1357 );
1358 assert!(
1359 patterns::detect_rate_limit("status: 529").is_some(),
1360 "status: 529 should match"
1361 );
1362 }
1363
1364 #[test]
1365 fn pattern_detect_auth() {
1366 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
1367 assert!(patterns::detect_auth_error("HTTP 401").is_some());
1368 assert!(patterns::detect_auth_error("error: 401").is_some());
1369 assert!(patterns::detect_auth_error("403 Forbidden").is_some());
1370 assert!(patterns::detect_auth_error("HTTP 403").is_some());
1371 assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
1372 assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
1373 assert!(patterns::detect_auth_error("invalid api key").is_some());
1374 assert!(patterns::detect_auth_error("billing expired").is_some());
1375 assert!(patterns::detect_auth_error("task done").is_none());
1376 assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
1378 assert!(
1379 patterns::detect_auth_error(
1380 "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
1381 )
1382 .is_none()
1383 );
1384 assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
1386 assert!(
1387 patterns::detect_auth_error(
1388 r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
1389 )
1390 .is_none()
1391 );
1392 assert!(patterns::detect_auth_error("status: 401").is_some());
1394 assert!(patterns::detect_auth_error("error: 403").is_some());
1395 }
1396
1397 #[test]
1398 fn pattern_detect_auth_insufficient_balance() {
1399 assert!(
1404 patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
1405 "insufficient balance must be detected as auth error"
1406 );
1407 assert!(
1408 patterns::detect_auth_error("insufficient balance").is_some(),
1409 "bare insufficient balance must be detected as auth error"
1410 );
1411 let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
1413 .expect("should detect auth error");
1414 let AgentError::Auth { message } = err else {
1415 panic!("expected Auth, got {err:?}");
1416 };
1417 assert!(
1418 message.to_lowercase().contains("insufficient balance"),
1419 "stored message should contain the matched pattern, got: {message}"
1420 );
1421 assert!(
1423 message.lines().count() <= 2,
1424 "message should be the auth line, not the full tail: {}",
1425 message.len()
1426 );
1427 }
1428
1429 #[test]
1430 fn auth_error_message_contains_match_not_tail() {
1431 let mut text = "some output\nunauthorized: your api key has expired\nx".repeat(400);
1435 text.push_str(r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#);
1436 let err = patterns::detect_auth_error(&text).expect("should detect auth error");
1437 let AgentError::Auth { message } = err else {
1438 panic!("expected Auth, got {err:?}");
1439 };
1440 assert!(
1441 message.to_lowercase().contains("unauthorized"),
1442 "stored message should contain the matched pattern, got: {message}"
1443 );
1444 assert!(
1445 !message.contains("outputTokens"),
1446 "stored message must not be the trailing NDJSON, got: {message}"
1447 );
1448 assert!(
1450 message.lines().count() <= 2,
1451 "message should be the auth line, not the full tail: {}",
1452 message.len()
1453 );
1454 }
1455
1456 #[test]
1457 fn pattern_detect_network_error() {
1458 assert!(patterns::detect_network_error("connection refused").is_some());
1459 assert!(patterns::detect_network_error("ConnectionRefused").is_some());
1460 assert!(patterns::detect_network_error("Unable to connect to API").is_some());
1461 assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
1462 assert!(patterns::detect_network_error("network unreachable").is_some());
1463 assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
1464 assert!(patterns::detect_network_error("upstream idle timeout").is_some());
1465 assert!(patterns::detect_network_error("Streaming response failed").is_some());
1466 assert!(patterns::detect_network_error("Upstream request failed").is_some());
1467 assert!(patterns::detect_network_error("all systems operational").is_none());
1468 }
1469
1470 #[test]
1471 fn network_error_message_contains_match_not_tail() {
1472 let prefix = "connection refused connecting to api.anthropic.com:443 ";
1474 let mut suffix = "x".repeat(2000);
1475 suffix.push_str(r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#);
1476 let text = format!("{prefix}{suffix}");
1477
1478 let err = patterns::detect_network_error(&text).expect("should detect network error");
1479 let AgentError::NetworkError { message } = err else {
1480 panic!("expected NetworkError, got {err:?}");
1481 };
1482 assert!(
1483 message.to_lowercase().contains("connection refused"),
1484 "stored message should contain the matched pattern, got: {message}"
1485 );
1486 assert!(
1487 !message.contains("web_fetch_requests"),
1488 "stored message must not be the unrelated stats tail, got: {message}"
1489 );
1490 }
1491
1492 #[test]
1493 fn pattern_detect_context_overflow() {
1494 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
1495 assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
1496 assert!(patterns::detect_context_overflow("success").is_none());
1497 }
1498
1499 #[test]
1500 fn pattern_detect_worktree_missing() {
1501 assert!(
1502 patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
1503 .is_some()
1504 );
1505 assert!(patterns::detect_worktree_missing("all good").is_none());
1506 }
1507
1508 #[test]
1509 fn pattern_detect_missing_tool() {
1510 assert!(patterns::detect_missing_tool("bun: command not found").is_some());
1511 assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
1512 assert!(patterns::detect_missing_tool("everything works").is_none());
1513 }
1514
1515 #[test]
1516 fn pattern_detect_permission_denied() {
1517 assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
1518 assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
1519 assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
1520 assert!(patterns::detect_permission_denied("task completed").is_none());
1521 }
1522
1523 #[test]
1524 fn pattern_detect_waiting_for_input() {
1525 assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
1526 assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
1527 assert!(
1528 patterns::detect_waiting_for_input("SSH authentication required for deploy key")
1529 .is_some()
1530 );
1531 assert!(
1532 patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
1533 );
1534 assert!(patterns::detect_waiting_for_input("done").is_none());
1535 }
1536
1537 #[test]
1538 fn pattern_detect_stale_session() {
1539 let err = patterns::detect_stale_session(
1541 "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
1542 );
1543 assert!(err.is_some(), "should detect stale session");
1544 if let Some(AgentError::StaleSession { session_id }) = err {
1545 assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
1546 }
1547
1548 let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
1550 assert!(err.is_some(), "should detect lowercase variant");
1551
1552 assert!(patterns::detect_stale_session("all good").is_none());
1554 assert!(patterns::detect_stale_session("conversation started").is_none());
1555
1556 let multibyte = "Straße: No conversation found with session ID: dead-beef";
1560 let err = patterns::detect_stale_session(multibyte);
1561 assert!(
1562 err.is_some(),
1563 "should detect stale session after multi-byte chars"
1564 );
1565 if let Some(AgentError::StaleSession { session_id }) = err {
1566 assert_eq!(session_id, "dead-beef");
1567 }
1568 }
1569
1570 #[test]
1571 fn stale_session_display() {
1572 let e = AgentError::StaleSession {
1573 session_id: "abc-123".to_string(),
1574 };
1575 assert!(e.to_string().contains("stale session"));
1576 assert!(e.to_string().contains("abc-123"));
1577 }
1578
1579 #[test]
1580 fn classify_from_text_timeout() {
1581 let err = patterns::classify_from_text(124, "");
1582 assert!(matches!(err, AgentError::Timeout { .. }));
1583 }
1584
1585 #[test]
1586 fn classify_from_text_missing_tool_before_rate_limit() {
1587 let err = patterns::classify_from_text(1, "bun: command not found rate limit");
1589 assert!(matches!(err, AgentError::MissingTool { .. }));
1590 }
1591
1592 #[test]
1593 fn classify_from_text_worktree_missing() {
1594 let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
1595 assert!(matches!(err, AgentError::AgentFailed { .. }));
1596 }
1597
1598 #[test]
1599 fn classify_from_text_model_not_found_backtick() {
1600 let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
1604 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
1605 or you do not have access to it.)";
1606 let err = patterns::classify_from_text(1, text);
1607 match err {
1608 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
1609 other => panic!("expected ModelUnavailable, got: {other:?}"),
1610 }
1611 }
1612
1613 #[test]
1614 fn classify_from_text_model_metadata_not_found() {
1615 let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
1616 metadata; this can degrade performance and cause issues.";
1617 let err = patterns::classify_from_text(1, text);
1618 match err {
1619 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
1620 other => panic!("expected ModelUnavailable, got: {other:?}"),
1621 }
1622 }
1623
1624 #[test]
1625 fn classify_from_text_no_false_positive_from_work_product() {
1626 let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
1629 - Added copilot quota patterns to `parse_retry_at`\n\
1630 fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
1631 quota exceeded handling for billing cycle\n"
1632 .repeat(50); let actual_error = "\nerror: command not found: cargo\n";
1634 let text = format!("{work_product}{actual_error}");
1635 let err = patterns::classify_from_text(1, &text);
1636 assert!(
1637 !matches!(err, AgentError::RateLimit { .. }),
1638 "work product keywords must not trigger rate limit, got: {err:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn classify_from_text_still_detects_real_rate_limit_at_tail() {
1644 let padding = "normal agent work output ".repeat(100);
1647 let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
1648 let err = patterns::classify_from_text(1, &text);
1649 assert!(
1650 matches!(err, AgentError::RateLimit { .. }),
1651 "real rate limit at tail must be detected, got: {err:?}"
1652 );
1653 }
1654
1655 #[test]
1656 fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
1657 let padding = "normal agent work output ".repeat(250);
1658 let text = format!(
1659 "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
1660 );
1661 let err = patterns::classify_from_text(1, &text);
1662 assert!(
1663 !matches!(err, AgentError::RateLimit { .. }),
1664 "markdown table cell must not trigger rate limit, got: {err:?}"
1665 );
1666 }
1667
1668 #[test]
1669 fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
1670 let text = "Aflαβrate_limit error: too many requests";
1674 let err = patterns::classify_from_text(1, text);
1675 assert!(
1676 matches!(err, AgentError::RateLimit { .. }),
1677 "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn classify_from_text_detects_session_limit_as_early_system_event() {
1683 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)"}}"#;
1689 let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
1692 .repeat(200);
1693 let text = format!("{system_event}\n{work_product}");
1694 assert!(
1696 text.len() > 3000,
1697 "test output must exceed tail window to be meaningful"
1698 );
1699 let err = patterns::classify_from_text(1, &text);
1700 assert!(
1701 matches!(err, AgentError::RateLimit { .. }),
1702 "session limit as early system event must be detected via head scan, got: {err:?}"
1703 );
1704 let msg = err.to_string();
1706 assert!(
1707 msg.contains("session limit"),
1708 "error message should contain the rate limit reason, got: {msg}"
1709 );
1710 }
1711
1712 #[test]
1713 fn classify_from_text_no_false_positive_auth_from_work_product() {
1714 let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
1718 // handle 403 forbidden errors\n\
1719 // billing checks happen elsewhere\n"
1720 .repeat(80);
1721 let actual_error = "\nerror: command not found: cargo\n";
1722 let text = format!("{work_product}{actual_error}");
1723 let err = patterns::classify_from_text(1, &text);
1724 assert!(
1725 !matches!(err, AgentError::Auth { .. }),
1726 "work product auth keywords must not trigger auth classification, got: {err:?}"
1727 );
1728 assert!(
1729 matches!(err, AgentError::MissingTool { .. }),
1730 "the real tail error should still win, got: {err:?}"
1731 );
1732 }
1733
1734 #[test]
1735 fn classify_from_text_still_detects_real_auth_at_tail() {
1736 let padding = "normal agent work output ".repeat(100);
1737 let text = format!("{padding}\nHTTP 401 Unauthorized\n");
1738 let err = patterns::classify_from_text(1, &text);
1739 assert!(
1740 matches!(err, AgentError::Auth { .. }),
1741 "real auth error at tail must be detected, got: {err:?}"
1742 );
1743 }
1744
1745 #[test]
1746 fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
1747 let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
1751 // Ensure we don't exceed the token limit per request\n\
1752 fn check_token_limit(tokens: usize) { ... }\n\
1753 if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
1754 // Note: too many tokens in the prompt causes context_length_exceeded\n"
1755 .repeat(80);
1756 let actual_error = "\nerror: command not found: cargo\n";
1757 let text = format!("{work_product}{actual_error}");
1758 let err = patterns::classify_from_text(1, &text);
1759 assert!(
1760 !matches!(err, AgentError::ContextOverflow { .. }),
1761 "work product context keywords must not trigger ContextOverflow, got: {err:?}"
1762 );
1763 assert!(
1764 matches!(err, AgentError::MissingTool { .. }),
1765 "the real tail error should still win, got: {err:?}"
1766 );
1767 }
1768
1769 #[test]
1770 fn classify_from_text_still_detects_real_context_overflow_at_tail() {
1771 let padding = "normal agent work output ".repeat(100);
1772 let text = format!("{padding}\nError: context_length_exceeded\n");
1773 let err = patterns::classify_from_text(1, &text);
1774 assert!(
1775 matches!(err, AgentError::ContextOverflow { .. }),
1776 "real context overflow at tail must be detected, got: {err:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
1782 let work_product = "match fs::metadata(path) {\n\
1786 Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
1787 bail!(\"access denied: {path}\");\n\
1788 }\n\
1789 // Check if directory is writable\n\
1790 if !path.is_writable() { ... }\n\
1791 }\n"
1792 .repeat(80);
1793 let actual_error = "\nerror: command not found: cargo\n";
1794 let text = format!("{work_product}{actual_error}");
1795 let err = patterns::classify_from_text(1, &text);
1796 assert!(
1797 !matches!(err, AgentError::PermissionDenied { .. }),
1798 "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
1799 );
1800 assert!(
1801 matches!(err, AgentError::MissingTool { .. }),
1802 "the real tail error should still win, got: {err:?}"
1803 );
1804 }
1805
1806 #[test]
1807 fn classify_from_text_still_detects_real_permission_denied_at_tail() {
1808 let padding = "normal agent work output ".repeat(100);
1809 let text = format!("{padding}\npermission denied: /etc/hosts\n");
1810 let err = patterns::classify_from_text(1, &text);
1811 assert!(
1812 matches!(err, AgentError::PermissionDenied { .. }),
1813 "real permission denied at tail must be detected, got: {err:?}"
1814 );
1815 }
1816
1817 #[test]
1818 fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
1819 let work_product = "// Handle password prompts gracefully\n\
1823 if prompt.contains(\"password:\") {\n\
1824 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
1825 }\n\
1826 // SSH agent will ask for the deploy key passphrase\n\
1827 fn handle_ssh_passphrase() { ... }\n\
1828 )\n"
1829 .repeat(80);
1830 let actual_error = "\nerror: command not found: cargo\n";
1831 let text = format!("{work_product}{actual_error}");
1832 let err = patterns::classify_from_text(1, &text);
1833 assert!(
1834 !matches!(err, AgentError::WaitingForInput { .. }),
1835 "work product input keywords must not trigger WaitingForInput, got: {err:?}"
1836 );
1837 assert!(
1838 matches!(err, AgentError::MissingTool { .. }),
1839 "the real tail error should still win, got: {err:?}"
1840 );
1841 }
1842
1843 #[test]
1844 fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
1845 let padding = "normal agent work output ".repeat(100);
1846 let text = format!("{padding}\nEnter passphrase for key:\n");
1847 let err = patterns::classify_from_text(1, &text);
1848 assert!(
1849 matches!(err, AgentError::WaitingForInput { .. }),
1850 "real waiting for input at tail must be detected, got: {err:?}"
1851 );
1852 }
1853
1854 #[test]
1855 fn classify_from_text_prefers_auth_over_permission_denied() {
1856 let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
1857 assert!(
1858 matches!(err, AgentError::Auth { .. }),
1859 "auth errors must outrank permission denied, got: {err:?}"
1860 );
1861 }
1862
1863 #[test]
1864 fn classify_from_text_prefers_auth_over_waiting_for_input() {
1865 let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
1866 assert!(
1867 matches!(err, AgentError::Auth { .. }),
1868 "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
1869 );
1870 }
1871
1872 #[test]
1873 fn classify_from_text_detects_stale_session() {
1874 let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
1879 let err = patterns::classify_from_text(1, text);
1880 assert!(
1881 matches!(err, AgentError::StaleSession { .. }),
1882 "stale session must be detected via classify_from_text, got: {err:?}"
1883 );
1884 assert!(
1886 err.to_string().contains("stale session"),
1887 "error display must contain 'stale session' for recovery code to trigger"
1888 );
1889 }
1890
1891 #[test]
1892 fn classify_from_text_detects_socket_closed() {
1893 let text = "API Error: The socket connection was closed unexpectedly. \
1896 For more information, pass `verbose: true` in the second argument to fetch()";
1897 let err = patterns::classify_from_text(1, text);
1898 assert!(
1899 matches!(err, AgentError::NetworkError { .. }),
1900 "socket closed must be NetworkError, got: {err:?}"
1901 );
1902 }
1903
1904 #[test]
1905 fn classify_from_text_detects_socket_hang_up() {
1906 let err = patterns::classify_from_text(1, "Error: socket hang up");
1907 assert!(
1908 matches!(err, AgentError::NetworkError { .. }),
1909 "socket hang up must be NetworkError, got: {err:?}"
1910 );
1911 }
1912
1913 #[test]
1914 fn classify_from_text_detects_fetch_failed() {
1915 let err = patterns::classify_from_text(1, "TypeError: fetch failed");
1916 assert!(
1917 matches!(err, AgentError::NetworkError { .. }),
1918 "fetch failed must be NetworkError, got: {err:?}"
1919 );
1920 }
1921
1922 #[test]
1923 fn classify_from_text_detects_econnreset() {
1924 let err = patterns::classify_from_text(1, "read ECONNRESET");
1925 assert!(
1926 matches!(err, AgentError::NetworkError { .. }),
1927 "ECONNRESET must be NetworkError, got: {err:?}"
1928 );
1929 }
1930
1931 #[test]
1932 fn classify_from_text_detects_stalled_mid_stream() {
1933 let err = patterns::classify_from_text(
1936 1,
1937 "API Error: Response stalled mid-stream. The response above may be incomplete.",
1938 );
1939 assert!(
1940 matches!(err, AgentError::NetworkError { .. }),
1941 "stalled mid-stream must be NetworkError, got: {err:?}"
1942 );
1943 }
1944
1945 #[test]
1946 fn classify_from_text_detects_connection_closed_mid_response() {
1947 let err = patterns::classify_from_text(
1950 1,
1951 "API Error: Connection closed mid-response. The response above may be incomplete.",
1952 );
1953 assert!(
1954 matches!(err, AgentError::NetworkError { .. }),
1955 "connection closed mid-response must be NetworkError, got: {err:?}"
1956 );
1957 }
1958
1959 #[test]
1960 fn synthesize_response_marks_done_for_plain_text_no_op() {
1961 let response = synthesize_response_from_text(
1962 "No open positions, no trade executions, no conditions change. Full cash, holding.",
1963 )
1964 .unwrap();
1965
1966 assert_eq!(response.status, "done");
1967 assert_eq!(
1968 response.summary,
1969 "No open positions, no trade executions, no conditions change. Full cash, holding."
1970 );
1971 assert!(response.error.is_none());
1972 }
1973
1974 #[test]
1975 fn synthesize_response_marks_needs_review_for_plain_text_error() {
1976 let response =
1977 synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
1978
1979 assert_eq!(response.status, "needs_review");
1980 assert_eq!(
1981 response.error.as_deref(),
1982 Some("agent returned plain text instead of JSON")
1983 );
1984 }
1985
1986 #[test]
1987 fn synthesize_response_rejects_empty_text() {
1988 assert!(synthesize_response_from_text(" \n\t ").is_none());
1989 }
1990
1991 #[test]
1997 fn synthesize_response_rejects_ndjson_event_stream() {
1998 let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1999{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2000{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2001 assert!(
2002 synthesize_response_from_text(ndjson).is_none(),
2003 "raw NDJSON must not be interpreted as agent prose"
2004 );
2005
2006 let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2007 assert!(
2008 synthesize_response_from_text(single).is_none(),
2009 "single-line NDJSON event must also be rejected"
2010 );
2011 }
2012
2013 #[test]
2014 fn synthesize_response_accepts_json_without_event_type() {
2015 let text = "The task is complete. Filed issue #1234 successfully.";
2019 let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2020 assert_eq!(response.status, "done");
2021 }
2022
2023 #[test]
2030 fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2031 assert!(
2033 synthesize_response_from_text("Let's go!").is_none(),
2034 "startup greeting must not synthesize as needs_review"
2035 );
2036 assert!(
2038 synthesize_response_from_text(
2039 "I need to find where the stream command is implemented. \
2040 Looking at the CLI structure, it seems like the stream functionality \
2041 might be in the task module or another file. Let me check the CLI \
2042 command structure. First, let me look at the task module since stream \
2043 is related to tasks."
2044 )
2045 .is_none(),
2046 "exploratory prose must not synthesize as needs_review"
2047 );
2048 }
2049
2050 #[test]
2051 fn synthesize_response_does_not_mark_done_for_incomplete() {
2052 let response = synthesize_response_from_text("The task is incomplete").unwrap();
2053 assert_eq!(
2054 response.status, "needs_review",
2055 "\"incomplete\" should not match \"complete\""
2056 );
2057 }
2058
2059 #[test]
2060 fn synthesize_response_does_not_mark_done_for_not_done() {
2061 let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2062 assert_eq!(
2063 response.status, "needs_review",
2064 "\"not done\" should not match bare \"done\""
2065 );
2066 }
2067
2068 #[test]
2069 fn synthesize_response_does_not_mark_done_for_not_complete() {
2070 let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2071 assert_eq!(
2072 response.status, "needs_review",
2073 "\"not complete\" should not match \"complete\""
2074 );
2075 }
2076
2077 #[test]
2078 fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2079 let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2080 assert_eq!(
2081 response.status, "needs_review",
2082 "\"not yet completed\" should not match \"completed\""
2083 );
2084 }
2085
2086 #[test]
2087 fn synthesize_response_marks_done_for_completed() {
2088 let response =
2089 synthesize_response_from_text("Task has been completed successfully").unwrap();
2090 assert_eq!(response.status, "done");
2091 }
2092
2093 #[test]
2094 fn synthesize_response_marks_done_for_word_boundary_done() {
2095 let response = synthesize_response_from_text("All changes are done.").unwrap();
2096 assert_eq!(response.status, "done");
2097 }
2098
2099 #[test]
2100 fn synthesize_response_marks_done_for_issue_creation_text() {
2101 let response =
2102 synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2103 .unwrap();
2104
2105 assert_eq!(response.status, "done");
2106 assert!(response.error.is_none());
2107 assert!(
2108 response
2109 .summary
2110 .contains("Filed 3 high-value GitHub issues")
2111 );
2112 }
2113
2114 #[test]
2115 fn synthesize_response_marks_done_for_committed() {
2116 let response =
2117 synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2118 assert_eq!(response.status, "done");
2119 }
2120
2121 #[test]
2122 fn synthesize_response_rejects_exploratory_prose() {
2123 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.";
2129 match synthesize_response_from_text(exploratory) {
2130 None => {
2131 }
2133 Some(resp) => {
2134 assert_ne!(
2135 resp.status, "done",
2136 "Exploratory prose must not be auto-marked done"
2137 );
2138 }
2139 }
2140 }
2141
2142 #[test]
2143 fn synthesize_response_marks_done_for_commit_created() {
2144 let response = synthesize_response_from_text(
2145 "Commit created locally. The push is being blocked by permissions.",
2146 )
2147 .unwrap();
2148 assert_eq!(response.status, "done");
2149 }
2150
2151 #[test]
2152 fn synthesize_response_does_not_mark_done_for_not_committed() {
2153 let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
2154 assert_eq!(
2155 response.status, "needs_review",
2156 "\"not committed\" should not match \"committed\""
2157 );
2158 }
2159
2160 #[test]
2163 fn synthesize_response_marks_done_for_fix_is_complete() {
2164 let response = synthesize_response_from_text(
2168 "All 73 cooldown tests pass. The fix is complete.\n\n\
2169 **Summary of changes:**\n\n\
2170 1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
2171 `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
2172 )
2173 .unwrap();
2174 assert_eq!(
2175 response.status, "done",
2176 "\"The fix is complete\" must be classified as done, not needs_review"
2177 );
2178 }
2179
2180 #[test]
2181 fn synthesize_response_marks_done_for_tests_pass_fix_working() {
2182 let response = synthesize_response_from_text(
2186 "All tests pass. The fix is clean and working.\n\n\
2187 **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
2188 - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
2189 - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
2190 )
2191 .unwrap();
2192 assert_eq!(
2193 response.status, "done",
2194 "\"All tests pass. The fix is clean and working.\" must be classified as done"
2195 );
2196 }
2197
2198 #[test]
2201 fn parse_ndjson_handles_standard_lines() {
2202 let raw = r#"{"type":"text","text":"hello"}
2203{"type":"text","text":"world"}"#;
2204 let events = parse_ndjson(raw);
2205 assert_eq!(events.len(), 2);
2206 assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
2207 assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
2208 }
2209
2210 #[test]
2211 fn parse_ndjson_skips_empty_lines() {
2212 let raw = r#"{"type":"text"}
2213
2214{"type":"text"}
2215"#;
2216 let events = parse_ndjson(raw);
2217 assert_eq!(events.len(), 2);
2218 }
2219
2220 #[test]
2221 fn parse_ndjson_skips_malformed_lines() {
2222 let raw = r#"{"type":"valid"}
2223not json at all
2224{"type":"also_valid"}"#;
2225 let events = parse_ndjson(raw);
2226 assert_eq!(events.len(), 2);
2227 }
2228
2229 #[test]
2230 fn parse_ndjson_fallback_extracts_embedded_json() {
2231 let raw =
2233 r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
2234 let events = parse_ndjson(raw);
2235 assert!(
2237 !events.is_empty(),
2238 "should extract JSON objects via fallback"
2239 );
2240 }
2241
2242 #[test]
2243 fn parse_ndjson_fallback_handles_fragmented_objects() {
2244 let raw = r#"{"type": "text
2246", "content": "hello
2247world"}"#;
2248 let events = parse_ndjson(raw);
2249 assert!(!events.is_empty(), "should handle fragmented JSON");
2251 }
2252
2253 #[test]
2256 fn permission_rules_default_is_autonomous() {
2257 let rules = PermissionRules::default();
2258 assert!(rules.autonomous);
2259 }
2260
2261 #[test]
2262 fn permission_rules_default_sandbox_is_workspace_write() {
2263 let rules = PermissionRules::default();
2264 assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
2265 }
2266
2267 #[test]
2268 fn permission_rules_default_disallows_rm() {
2269 let rules = PermissionRules::default();
2270 assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
2271 assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
2272 }
2273
2274 #[test]
2275 fn permission_rules_default_no_allowed_edit_paths() {
2276 let rules = PermissionRules::default();
2277 assert!(
2278 rules.allowed_edit_paths.is_empty(),
2279 "{:?}",
2280 rules.allowed_edit_paths
2281 );
2282 }
2283
2284 #[test]
2288 fn all_agents_handle_default_permissions() {
2289 let perms = PermissionRules::default();
2290 let sys = "/tmp/sys.md";
2291 let msg = "/tmp/msg.md";
2292
2293 let claude = get_runner("claude");
2295 let cmd = claude.build_command(None, "", sys, msg, &perms);
2296 assert!(
2297 cmd.contains("--permission-mode bypassPermissions"),
2298 "claude default: expected bypassPermissions, got: {cmd}"
2299 );
2300 assert!(
2301 cmd.contains("Bash(rm *)"),
2302 "claude default: expected rm disallowed, got: {cmd}"
2303 );
2304
2305 let codex = get_runner("codex");
2307 let cmd = codex.build_command(None, "", sys, msg, &perms);
2308 assert!(
2309 cmd.contains("--sandbox workspace-write"),
2310 "codex default: expected --sandbox workspace-write, got: {cmd}"
2311 );
2312 assert!(
2313 cmd.contains(r#"-c 'approval_policy="never"'"#),
2314 "codex default: expected -c approval_policy=never, got: {cmd}"
2315 );
2316 assert!(
2317 !cmd.contains("--ask-for-approval"),
2318 "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
2319 );
2320 assert!(
2321 !cmd.contains("--full-auto"),
2322 "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
2323 );
2324
2325 let opencode = get_runner("opencode");
2328 let cmd = opencode.build_command(None, "", sys, msg, &perms);
2329 assert!(
2330 cmd.contains("--dangerously-skip-permissions"),
2331 "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
2332 );
2333 assert!(
2334 !cmd.contains("opencode.json"),
2335 "opencode default: should not write a config file, got: {cmd}"
2336 );
2337 assert!(
2338 !cmd.contains("XDG_CONFIG_HOME"),
2339 "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
2340 );
2341 assert!(
2342 !cmd.contains(".orch-opencode"),
2343 "opencode default: should not reference .orch-opencode, got: {cmd}"
2344 );
2345 }
2346
2347 #[test]
2349 fn all_agents_handle_supervised_mode() {
2350 let perms = PermissionRules {
2351 autonomous: false,
2352 sandbox: SandboxLevel::WorkspaceWrite,
2353 disallowed_tools: vec![],
2354 allowed_tools: vec![],
2355 allowed_edit_paths: vec![],
2356 deny_read_only: false,
2357 extra_writable_dirs: vec![],
2358 };
2359 let sys = "/tmp/sys.md";
2360 let msg = "/tmp/msg.md";
2361
2362 let claude = get_runner("claude");
2364 let cmd = claude.build_command(None, "", sys, msg, &perms);
2365 assert!(
2366 cmd.contains("--permission-mode acceptEdits"),
2367 "claude supervised: expected acceptEdits, got: {cmd}"
2368 );
2369
2370 let codex = get_runner("codex");
2372 let cmd = codex.build_command(None, "", sys, msg, &perms);
2373 assert!(
2374 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
2375 "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
2376 );
2377 assert!(
2378 !cmd.contains("--ask-for-approval"),
2379 "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
2380 );
2381 }
2382
2383 #[test]
2385 fn all_agents_handle_full_access_sandbox() {
2386 let perms = PermissionRules {
2387 autonomous: true,
2388 sandbox: SandboxLevel::FullAccess,
2389 disallowed_tools: vec![],
2390 allowed_tools: vec![],
2391 allowed_edit_paths: vec![],
2392 deny_read_only: false,
2393 extra_writable_dirs: vec![],
2394 };
2395 let sys = "/tmp/sys.md";
2396 let msg = "/tmp/msg.md";
2397
2398 let claude = get_runner("claude");
2400 let cmd = claude.build_command(None, "", sys, msg, &perms);
2401 assert!(
2402 !cmd.contains("--sandbox"),
2403 "claude full_access: should have no sandbox flag, got: {cmd}"
2404 );
2405
2406 let codex = get_runner("codex");
2408 let cmd = codex.build_command(None, "", sys, msg, &perms);
2409 assert!(
2410 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
2411 "codex full_access: expected dangerously-bypass, got: {cmd}"
2412 );
2413 }
2414
2415 #[test]
2417 fn codex_sandbox_none_uses_workspace_write() {
2418 let perms = PermissionRules {
2419 autonomous: true,
2420 sandbox: SandboxLevel::None,
2421 disallowed_tools: vec![],
2422 allowed_tools: vec![],
2423 allowed_edit_paths: vec![],
2424 deny_read_only: false,
2425 extra_writable_dirs: vec![],
2426 };
2427 let codex = get_runner("codex");
2428 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2429 assert!(
2430 cmd.contains("--sandbox workspace-write"),
2431 "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
2432 );
2433 assert!(
2434 cmd.contains(r#"-c 'approval_policy="never"'"#),
2435 "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
2436 );
2437 assert!(
2438 !cmd.contains("--ask-for-approval"),
2439 "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
2440 );
2441 assert!(
2442 !cmd.contains("--full-auto"),
2443 "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
2444 );
2445 }
2446
2447 #[test]
2449 fn claude_allowed_edit_paths_scope_edit_write() {
2450 let perms = PermissionRules {
2451 autonomous: true,
2452 sandbox: SandboxLevel::WorkspaceWrite,
2453 disallowed_tools: vec![],
2454 allowed_tools: vec![
2455 "Edit".to_string(),
2456 "Write".to_string(),
2457 "Read".to_string(),
2458 "Bash".to_string(),
2459 ],
2460 allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
2461 deny_read_only: false,
2462 extra_writable_dirs: vec![],
2463 };
2464 let claude = get_runner("claude");
2465 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2466
2467 assert!(
2469 cmd.contains("Edit(/home/user/worktree/*)"),
2470 "missing scoped Edit, got: {cmd}"
2471 );
2472 assert!(
2473 cmd.contains("Write(/home/user/worktree/*)"),
2474 "missing scoped Write, got: {cmd}"
2475 );
2476 assert!(cmd.contains("Read"), "missing Read");
2478 }
2479
2480 #[test]
2482 fn codex_ignores_allowed_edit_paths() {
2483 let perms = PermissionRules {
2484 autonomous: true,
2485 sandbox: SandboxLevel::WorkspaceWrite,
2486 disallowed_tools: vec![],
2487 allowed_tools: vec![],
2488 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
2489 deny_read_only: false,
2490 extra_writable_dirs: vec![],
2491 };
2492 let codex = get_runner("codex");
2493 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2494 assert!(
2495 !cmd.contains("/home/user/project"),
2496 "codex: should not reference blocked paths, got: {cmd}"
2497 );
2498 }
2499
2500 #[test]
2502 fn opencode_ignores_allowed_edit_paths() {
2503 let perms = PermissionRules {
2504 autonomous: true,
2505 sandbox: SandboxLevel::WorkspaceWrite,
2506 disallowed_tools: vec![],
2507 allowed_tools: vec![],
2508 allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
2509 deny_read_only: false,
2510 extra_writable_dirs: vec![],
2511 };
2512 let opencode = get_runner("opencode");
2513 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2514 assert!(
2515 !cmd.contains("/home/user/project"),
2516 "opencode: should not reference blocked paths, got: {cmd}"
2517 );
2518 }
2519
2520 #[test]
2522 fn claude_merges_disallowed_tools() {
2523 let perms = PermissionRules {
2524 autonomous: true,
2525 sandbox: SandboxLevel::WorkspaceWrite,
2526 disallowed_tools: vec![
2527 "Bash(rm *)".to_string(),
2528 "Bash(sudo *)".to_string(),
2529 "WebFetch".to_string(),
2530 ],
2531 allowed_tools: vec![],
2532 allowed_edit_paths: vec![],
2533 deny_read_only: false,
2534 extra_writable_dirs: vec![],
2535 };
2536 let claude = get_runner("claude");
2537 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2538 assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
2539 assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
2540 assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
2541 }
2542
2543 #[test]
2545 fn codex_ignores_disallowed_tools() {
2546 let perms = PermissionRules {
2547 autonomous: true,
2548 sandbox: SandboxLevel::WorkspaceWrite,
2549 disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
2550 allowed_tools: vec![],
2551 allowed_edit_paths: vec![],
2552 deny_read_only: false,
2553 extra_writable_dirs: vec![],
2554 };
2555 let codex = get_runner("codex");
2556 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2557 assert!(
2558 !cmd.contains("disallow"),
2559 "codex: should not have disallow flags, got: {cmd}"
2560 );
2561 }
2562
2563 #[test]
2565 fn claude_no_disallowed_flag_when_empty() {
2566 let perms = PermissionRules {
2567 autonomous: true,
2568 sandbox: SandboxLevel::WorkspaceWrite,
2569 disallowed_tools: vec![],
2570 allowed_tools: vec![],
2571 allowed_edit_paths: vec![],
2572 deny_read_only: false,
2573 extra_writable_dirs: vec![],
2574 };
2575 let claude = get_runner("claude");
2576 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2577 assert!(
2578 !cmd.contains("--disallowedTools"),
2579 "claude: should have no disallowed flag when empty, got: {cmd}"
2580 );
2581 }
2582
2583 #[test]
2585 fn kimi_minimax_use_claude_permissions() {
2586 let perms = PermissionRules {
2587 autonomous: false,
2588 sandbox: SandboxLevel::WorkspaceWrite,
2589 disallowed_tools: vec!["Bash(rm *)".to_string()],
2590 allowed_tools: vec![],
2591 allowed_edit_paths: vec![],
2592 deny_read_only: false,
2593 extra_writable_dirs: vec![],
2594 };
2595 let sys = "/tmp/sys.md";
2596 let msg = "/tmp/msg.md";
2597
2598 for agent in &["kimi", "minimax"] {
2599 let runner = get_runner(agent);
2600 let cmd = runner.build_command(None, "", sys, msg, &perms);
2601 assert!(
2602 cmd.contains("--permission-mode acceptEdits"),
2603 "{agent}: expected acceptEdits"
2604 );
2605 assert!(
2606 cmd.contains("Bash(rm *)"),
2607 "{agent}: expected rm disallowed"
2608 );
2609 }
2610 }
2611
2612 #[test]
2614 fn kimi_minimax_use_allowed_tools() {
2615 let perms = PermissionRules {
2616 autonomous: true,
2617 sandbox: SandboxLevel::WorkspaceWrite,
2618 disallowed_tools: vec![],
2619 allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
2620 allowed_edit_paths: vec![],
2621 deny_read_only: false,
2622 extra_writable_dirs: vec![],
2623 };
2624
2625 for agent in &["kimi", "minimax"] {
2626 let runner = get_runner(agent);
2627 let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2628 assert!(
2629 cmd.contains("--allowedTools"),
2630 "{agent}: expected --allowedTools, got: {cmd}"
2631 );
2632 assert!(
2633 cmd.contains("Bash(git *)"),
2634 "{agent}: expected Bash(git *) pattern, got: {cmd}"
2635 );
2636 }
2637 }
2638
2639 #[test]
2643 fn claude_allowed_tools_generates_allowedtools_flag() {
2644 let perms = PermissionRules {
2645 autonomous: true,
2646 sandbox: SandboxLevel::WorkspaceWrite,
2647 disallowed_tools: vec!["Bash(rm *)".to_string()],
2648 allowed_tools: vec![
2649 "Edit".to_string(),
2650 "Write".to_string(),
2651 "Read".to_string(),
2652 "Bash".to_string(),
2653 "git".to_string(),
2654 ],
2655 allowed_edit_paths: vec![],
2656 deny_read_only: false,
2657 extra_writable_dirs: vec![],
2658 };
2659
2660 let claude = get_runner("claude");
2661 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2662 assert!(
2663 cmd.contains("--allowedTools"),
2664 "expected --allowedTools, got: {cmd}"
2665 );
2666 assert!(
2667 !cmd.contains("--disallowedTools"),
2668 "should not have --disallowedTools when allowed_tools is set"
2669 );
2670 }
2671
2672 #[test]
2674 fn codex_opencode_ignore_allowed_tools() {
2675 let perms = PermissionRules {
2676 autonomous: true,
2677 sandbox: SandboxLevel::WorkspaceWrite,
2678 disallowed_tools: vec![],
2679 allowed_tools: vec!["Edit".to_string(), "git".to_string()],
2680 allowed_edit_paths: vec![],
2681 deny_read_only: false,
2682 extra_writable_dirs: vec![],
2683 };
2684
2685 let codex = get_runner("codex");
2686 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2687 assert!(
2688 !cmd.contains("--allowedTools"),
2689 "codex should not have --allowedTools, got: {cmd}"
2690 );
2691
2692 let opencode = get_runner("opencode");
2693 let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2694 assert!(
2695 !cmd.contains("--allowedTools"),
2696 "opencode should not have --allowedTools, got: {cmd}"
2697 );
2698 }
2699
2700 #[test]
2705 #[ignore = "reads the operator config"]
2706 fn integration_from_config_to_agent_commands() {
2707 let perms = PermissionRules::from_config();
2708
2709 assert!(
2711 perms.autonomous,
2712 "from_config: expected autonomous=true by default"
2713 );
2714
2715 for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
2717 let runner = get_runner(agent_name);
2718 let cmd =
2719 runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
2720 assert!(
2721 !cmd.is_empty(),
2722 "{agent_name}: build_command returned empty string"
2723 );
2724 assert!(
2725 cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
2726 "{agent_name}: command should reference agent binary, got: {cmd}"
2727 );
2728 }
2729 }
2730
2731 #[test]
2734 #[ignore = "reads the operator config"]
2735 fn integration_supervised_config_translates_correctly() {
2736 let perms = PermissionRules::from_config();
2741
2742 let claude = get_runner("claude");
2743 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2744
2745 if perms.autonomous {
2746 assert!(
2747 cmd.contains("bypassPermissions"),
2748 "autonomous config → bypassPermissions"
2749 );
2750 } else {
2751 assert!(
2752 cmd.contains("acceptEdits"),
2753 "supervised config → acceptEdits"
2754 );
2755 }
2756
2757 let codex = get_runner("codex");
2758 let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2759
2760 if perms.autonomous {
2761 assert!(
2763 cmd.contains("--sandbox workspace-write"),
2764 "autonomous config → --sandbox workspace-write, got: {cmd}"
2765 );
2766 assert!(
2767 cmd.contains(r#"-c 'approval_policy="never"'"#),
2768 "autonomous config → -c approval_policy=never, got: {cmd}"
2769 );
2770 } else {
2771 assert!(
2772 cmd.contains(r#"-c 'approval_policy="on-request"'"#),
2773 "supervised config → -c approval_policy=on-request, got: {cmd}"
2774 );
2775 }
2776 assert!(
2777 !cmd.contains("--ask-for-approval"),
2778 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
2779 );
2780 }
2781
2782 #[test]
2786 fn deny_read_only_blocks_reading_tools() {
2787 let perms = PermissionRules::deny_read_only();
2788
2789 let read_tools = [
2790 "Bash(ls *)",
2791 "Bash(ls)",
2792 "Bash(find *)",
2793 "Bash(cat *)",
2794 "Bash(head *)",
2795 "Bash(tail *)",
2796 "Bash(fzf *)",
2797 "Bash(less *)",
2798 "Bash(more *)",
2799 "Read",
2800 "Glob",
2801 "Grep",
2802 ];
2803
2804 for tool in read_tools {
2805 assert!(
2806 perms.disallowed_tools.contains(&tool.to_string()),
2807 "deny_read_only should block {tool}, got: {:?}",
2808 perms.disallowed_tools
2809 );
2810 }
2811 }
2812
2813 #[test]
2815 fn deny_read_only_claude_command() {
2816 let perms = PermissionRules::deny_read_only();
2817 let claude = get_runner("claude");
2818 let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
2819
2820 assert!(
2822 cmd.contains("Read"),
2823 "should block Read for control session"
2824 );
2825 assert!(
2826 cmd.contains("Glob"),
2827 "should block Glob for control session"
2828 );
2829 assert!(
2830 cmd.contains("Grep"),
2831 "should block Grep for control session"
2832 );
2833 assert!(
2835 cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
2836 "should block ls"
2837 );
2838 assert!(
2840 cmd.contains("--permission-mode bypassPermissions"),
2841 "should be autonomous (bypassPermissions)"
2842 );
2843 }
2844
2845 #[test]
2848 fn synthesize_extracts_file_paths() {
2849 let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
2850 let resp = synthesize_response_from_text(text).unwrap();
2851 assert!(
2852 resp.files
2853 .contains(&"src/engine/runner/agents/mod.rs".to_string()),
2854 "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
2855 resp.files
2856 );
2857 assert!(
2858 resp.files.contains(&"tests/integration.rs".to_string()),
2859 "expected tests/integration.rs in files, got: {:?}",
2860 resp.files
2861 );
2862 }
2863
2864 #[test]
2865 fn synthesize_extracts_accomplished_bullets() {
2866 let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
2867 let resp = synthesize_response_from_text(text).unwrap();
2868 assert_eq!(resp.status, "done");
2869 assert!(
2870 resp.accomplished
2871 .iter()
2872 .any(|a| a.contains("Added logging")),
2873 "expected accomplished bullet, got: {:?}",
2874 resp.accomplished
2875 );
2876 assert!(
2877 resp.accomplished
2878 .iter()
2879 .any(|a| a.contains("Updated migration")),
2880 "expected accomplished bullet, got: {:?}",
2881 resp.accomplished
2882 );
2883 }
2884
2885 #[test]
2886 fn synthesize_extracts_remaining_section() {
2887 let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
2888 let resp = synthesize_response_from_text(text).unwrap();
2889 assert!(
2890 resp.remaining
2891 .iter()
2892 .any(|r| r.contains("Write unit tests")),
2893 "expected remaining item, got: {:?}",
2894 resp.remaining
2895 );
2896 assert!(
2897 resp.remaining.iter().any(|r| r.contains("Update docs")),
2898 "expected remaining item, got: {:?}",
2899 resp.remaining
2900 );
2901 assert!(
2902 resp.accomplished.iter().any(|a| a.contains("Patched")),
2903 "expected accomplished item, got: {:?}",
2904 resp.accomplished
2905 );
2906 }
2907
2908 #[test]
2909 fn synthesize_fixed_phrase_is_done() {
2910 let text = "Fixed. The bug no longer reproduces.";
2911 let resp = synthesize_response_from_text(text).unwrap();
2912 assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
2913 }
2914
2915 #[test]
2922 fn detect_rate_limit_fires_on_bare_429_in_token_count() {
2923 let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
2927 assert!(
2928 patterns::detect_rate_limit(payload).is_some(),
2929 "detect_rate_limit must still fire on bare 429 so callers apply the guard"
2930 );
2931 }
2932
2933 #[test]
2934 fn detect_rate_limit_ignores_429_inside_longer_number() {
2935 let uuid = r#"{"type":"hook","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b"}"#;
2936 assert!(
2937 patterns::detect_rate_limit(uuid).is_none(),
2938 "429 inside a UUID digit run must not match"
2939 );
2940 }
2941
2942 #[test]
2943 fn detect_rate_limit_ignores_429_in_uuid_hex_group() {
2944 let hook = r#"{"type":"system","subtype":"hook_response","hook_id":"18db61b6-7b72-429c-8279-fae3089bf565"}"#;
2945 assert!(patterns::detect_rate_limit(hook).is_none());
2946 }
2947
2948 #[test]
2949 fn detect_rate_limit_429_message_comes_from_matched_line() {
2950 let filler = "unrelated agent prose that is long enough to fill the window. ".repeat(20);
2951 let text = format!("HTTP 429 Too Many Requests\n{filler}");
2952 match patterns::detect_rate_limit(&text) {
2953 Some(AgentError::RateLimit { message }) => assert!(message.contains("429")),
2954 other => panic!("expected RateLimit, got {other:?}"),
2955 }
2956 }
2957
2958 #[test]
2962 fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
2963 let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
2964 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
2965 && !raw_output.contains("\"is_error\":true");
2966 let detected = (!ndjson_completed)
2968 .then(|| patterns::detect_rate_limit(raw_output))
2969 .flatten();
2970 assert!(
2971 detected.is_none(),
2972 "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
2973 );
2974 }
2975
2976 #[test]
2978 fn terminal_reason_completed_guard_passes_real_rate_limit() {
2979 let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
2981 let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
2982 && !raw_output.contains("\"is_error\":true");
2983 let detected = (!ndjson_completed)
2984 .then(|| patterns::detect_rate_limit(raw_output))
2985 .flatten();
2986 assert!(
2987 detected.is_some(),
2988 "guard must NOT suppress a real rate-limit error"
2989 );
2990 }
2991
2992 #[test]
2993 fn synthesize_no_false_positive_file_paths() {
2994 let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
2998 match synthesize_response_from_text(text) {
2999 None => {} Some(resp) => {
3001 assert!(
3002 resp.files.is_empty(),
3003 "expected no file paths extracted from URL text, got: {:?}",
3004 resp.files
3005 );
3006 }
3007 }
3008 }
3009
3010 #[test]
3015 fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3016 let text = "error: unexpected argument '--ask-for-approval' found\n \
3018 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3019 Usage: codex exec [OPTIONS] [PROMPT]";
3020 assert!(
3021 synthesize_response_from_text(text).is_none(),
3022 "CLI parser diagnostic must not synthesize as agent response"
3023 );
3024 }
3025
3026 #[test]
3027 fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3028 let text = "Usage: mycommand [OPTIONS] <arg>";
3029 assert!(
3030 synthesize_response_from_text(text).is_none(),
3031 "Usage line must not synthesize as agent response"
3032 );
3033 }
3034
3035 #[test]
3036 fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3037 let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3038 assert!(
3039 synthesize_response_from_text(text).is_none(),
3040 "Help hint must not synthesize as agent response"
3041 );
3042 }
3043
3044 #[test]
3045 fn synthesize_rejects_cli_parser_diagnostics_tip() {
3046 let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3047 assert!(
3048 synthesize_response_from_text(text).is_none(),
3049 "Clap tip must not synthesize as agent response"
3050 );
3051 }
3052
3053 #[test]
3054 fn synthesize_accepts_agent_error_messages_that_mention_error() {
3055 let text = "I tried to run the command but got an error: permission denied on that file.";
3058 let response =
3059 synthesize_response_from_text(text).expect("agent error message should synthesize");
3060 assert_eq!(response.status, "needs_review");
3061 assert!(response.error.is_some());
3062 }
3063
3064 #[test]
3069 fn synthesize_rejects_exact_codex_cli_error() {
3070 let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n \
3071 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3072 Usage: codex exec [OPTIONS] [PROMPT]";
3073 assert!(
3074 synthesize_response_from_text(stderr_from_codex).is_none(),
3075 "exact codex CLI error from #3197 must not synthesize as agent response"
3076 );
3077 }
3078}