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