diff options
Diffstat (limited to 'src/detect')
| -rw-r--r-- | src/detect/manifest/tests.rs | 1177 | ||||
| -rw-r--r-- | src/detect/mod.rs | 103 |
2 files changed, 208 insertions, 1072 deletions
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index fc519c7f..07e9aa38 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -1,5 +1,6 @@ use super::*; +// Codex is only a registry key here; behavior tests supply synthetic rules. fn remote_manifest(version: &str, state: &str, contains: &str) -> String { format!( r#" @@ -89,14 +90,17 @@ fn write_local_codex(content: &str) { #[test] fn known_agent_no_match_defaults_to_idle_fallback() { - let explain = explain(Agent::Codex, "ordinary prompt text"); + with_manifest_dirs("no-match", || { + write_local_codex(&local_manifest("working", "active-marker")); + let explain = explain(Agent::Codex, "unmatched-marker"); - assert_eq!(explain.state, AgentState::Idle); - assert!(!explain.visible_idle); - assert_eq!( - explain.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); + assert_eq!(explain.state, AgentState::Idle); + assert!(!explain.visible_idle); + assert_eq!( + explain.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + }); } #[test] @@ -200,7 +204,6 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { let explain = explain(Agent::Codex, "remote-ready"); - assert_eq!(explain.state, AgentState::Idle); assert!(matches!(explain.source, Some(ManifestSource::Bundled))); assert_eq!( explain.cached_remote_version.as_deref(), @@ -358,119 +361,209 @@ fn compiled_rules_are_shared_until_manifest_reload() { } #[test] -fn all_bundled_manifests_parse_and_validate() { - for agent in Agent::SCREEN_MANIFEST_AGENTS { - assert!( - bundled_manifest(agent).is_some(), - "missing bundled manifest for {}", - agent_label(agent) +fn osc_regions_use_separate_inputs_and_share_rule_priority() { + with_manifest_dirs("osc-regions", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "screen" +state = "idle" +priority = 10 +region = "whole_recent" +visible_idle = true +contains = ["screen-marker"] + +[[rules]] +id = "title" +state = "working" +priority = 20 +region = "osc_title" +visible_working = true +regex = ['^title-marker$'] + +[[rules]] +id = "progress" +state = "blocked" +priority = 30 +region = "osc_progress" +visible_blocker = true +regex = ['^progress-marker$'] +"#, + )); + for (screen, title, progress, state, rule) in [ + ("screen-marker", "", "", AgentState::Idle, "screen"), + ( + "screen-marker", + "title-marker", + "", + AgentState::Working, + "title", + ), + ( + "screen-marker", + "title-marker", + "progress-marker", + AgentState::Blocked, + "progress", + ), + ( + "screen-marker title-marker progress-marker", + "", + "", + AgentState::Idle, + "screen", + ), + ] { + let input = DetectionInput { + screen, + osc_title: title, + osc_progress: progress, + }; + let result = explain_with_input(Agent::Codex, input); + assert_eq!(result.state, state); + assert_eq!( + result + .matched_rule + .as_ref() + .map(|matched| matched.id.as_str()), + Some(rule) + ); + let detection = + crate::detect::detect_agent_with_osc(Some(Agent::Codex), screen, title, progress); + assert_eq!(detection.state, state); + assert_eq!(detection.visible_idle, state == AgentState::Idle); + assert_eq!(detection.visible_working, state == AgentState::Working); + assert_eq!(detection.visible_blocker, state == AgentState::Blocked); + } + let swapped = explain_with_input( + Agent::Codex, + DetectionInput { + screen: "", + osc_title: "progress-marker", + osc_progress: "title-marker", + }, ); - } + assert!(swapped.matched_rule.is_none()); + }); } #[test] -fn devin_manifest_detects_idle_working_and_blocked_states() { - let idle = explain( - Agent::Devin, - "─────────────────────────────────────────────────────\n❭ Ask Devin to build features, fix bugs, or work on\n your code\n─────────────────────────────────────────────────────\nSWE-1.6 Context: 16k / 200k tokens (7%)", - ); - assert_eq!(idle.state, AgentState::Idle); - assert!(idle.visible_idle); - - let live_footer_idle = explain( - Agent::Devin, - "Done.\n\n────────────────────────────────────────────────── (bypass permissions on) ─\n❭\n────────────────────────────────────────────────────────────────────────────\nClaude Opus 4.6 Thinking Context: 38k / 200k tokens (18%)", - ); - assert_eq!(live_footer_idle.state, AgentState::Idle); - assert_eq!( - live_footer_idle - .matched_rule - .as_ref() - .map(|rule| rule.id.as_str()), - Some("live_prompt_footer") - ); - assert!(live_footer_idle.visible_idle); - - let welcome_footer_idle = explain( - Agent::Devin, - "⠀⠀⠀⠀⠀⣴⣾⣶⡄⠀⠀⠀⠀\n⠀⣴⣾⣶⡾⠛⠿⠟⠃⣴⣾⣶⡄ Devin CLI\n⠀⠛⠿⠟⠃⣴⣾⣶⡾⠛⠿⠟⠃ v2026.5.26-8\n⠀⣤⣶⣦⡄⠻⢿⠿⢷⣤⣶⣦⡄\n⠀⠻⢿⠿⢷⣤⣶⣦⡄⠻⢿⠿⠃ Hybrid\n⠀⠀⠀⠀⠀⠻⢿⠿⠃⠀⠀⠀⠀\n\n───────────────────────────\n❭ Ask Devin to build\n features, fix bugs, or\n work on your code\n───────────────────────────\nClaude Opus Looking for\n4.6 Thinkingplan mode? /\n plan", - ); - assert_eq!(welcome_footer_idle.state, AgentState::Idle); - assert_eq!( - welcome_footer_idle - .matched_rule - .as_ref() - .map(|rule| rule.id.as_str()), - Some("welcome_prompt_footer") - ); - assert!(welcome_footer_idle.visible_idle); - - let working = explain( - Agent::Devin, - "◔ Reading shell 91b655\n │ Timeout: 35s\n\n⠀⡆ Running tools · 27s (esc to interrupt)\n─────────────────────────────────────────────────────\n❭ Guide Devin while it works", - ); - assert_eq!(working.state, AgentState::Working); - assert!(working.visible_working); - - let trust_prompt = explain( - Agent::Devin, - "Do you trust the authors of this directory?\nFor security, devin should not be run in directories\nwith untrusted content.\n❭ 1 Yes, trust /private/tmp/devin-hook-probe\n· 2 No, exit", - ); - assert_eq!(trust_prompt.state, AgentState::Blocked); - assert!(trust_prompt.visible_blocker); +fn skip_rule_suppresses_state_update_without_visible_state_evidence() { + with_manifest_dirs("skip-rule", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "activity" +state = "working" +priority = 10 +visible_working = true +contains = ["activity-marker"] - let permission_prompt = explain( - Agent::Devin, - "⏺ Running command\n └ $ sleep 30\n\n❭ 1 Yes (Approve once)\n· 2 Yes, allow `sleep` commands\n· 3 Yes, always allow `sleep` commands\n· 4 No\n↑↓ select · ↵ confirm · esc cancel", - ); - assert_eq!(permission_prompt.state, AgentState::Blocked); - assert!(permission_prompt.visible_blocker); +[[rules]] +id = "overlay" +state = "unknown" +priority = 20 +skip_state_update = true +contains = ["overlay-marker"] +"#, + )); + let screen = "activity-marker overlay-marker"; + let result = explain(Agent::Codex, screen); + assert_eq!(result.state, AgentState::Unknown); + assert!(result.skip_state_update); + assert_eq!( + result.skipped_update_reason.as_deref(), + Some("matched_rule:overlay") + ); + assert!(!result.visible_idle); + assert!(!result.visible_working); + assert!(!result.visible_blocker); + assert!(detect(Agent::Codex, screen).skip_state_update); + assert!(should_skip_state_update(Agent::Codex, screen)); + assert!(!should_skip_state_update(Agent::Codex, "activity-marker")); + }); } #[test] -fn muse_manifest_requires_complete_live_controls() { - let working = explain( - Agent::Muse, - "⟩ hello\n\n◆ Working (0s · esc to interrupt)\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(working.state, AgentState::Working); - assert!(working.visible_working); - - let picker = explain( - Agent::Muse, - "Which option should I use?\n\n› 1. Alpha\n 2. Beta\n\nEnter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(picker.state, AgentState::Blocked); - assert!(picker.visible_blocker); - - let command_approval = explain( - Agent::Muse, - "Would you like to run the following command?\n\n$ printf muse-safe-probe\n\n› 1. Allow this stage once (y)\n 2. Always allow in this workspace: printf muse-safe-probe ... (p)\n 3. Abort the entire command (esc)\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(command_approval.state, AgentState::Blocked); - assert!(command_approval.visible_blocker); - - let network_approval = explain( - Agent::Muse, - "network: example.com:443 https\nrequested by:\n$ curl -fsS https://example.com\n\n› 1. Yes, proceed (y)\n 2. Yes, don't ask again this session (p) example.com:443 (https)\n 3. No, and tell Muse Code what to do differently (esc)\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(network_approval.state, AgentState::Blocked); - assert!(network_approval.visible_blocker); - - let menu = explain( - Agent::Muse, - "Theme\n\n⟩ Default (active)\n Dynamic\n\n↑↓ move · enter save · esc go back", - ); - assert_eq!(menu.state, AgentState::Unknown); - assert!(menu.skip_state_update); - assert!(!menu.visible_blocker); +fn screen_regions_extract_structure_without_classifying_agent_state() { + for (screen, spec, expected) in [ + ("old\n\nnew\n", "bottom_lines(2)", "\nnew\n"), + ( + "before\n› input\nafter\n", + "after_last_prompt_marker", + "after\n", + ), + ( + "before\n› input\nafter\n", + "before_current_prompt_marker", + "before\n", + ), + ( + "before\n› input\nafter\n", + "whole_recent_without_current_prompt_marker", + "", + ), + ( + "no marker\n", + "whole_recent_without_current_prompt_marker", + "no marker\n", + ), + ( + "• old\n■ latest\n› input\n", + "current_prompt_block_marker", + "■ latest", + ), + ( + "• old\n■ latest\n› input\n", + "after_current_prompt_block_marker", + "■ latest\n› input\n", + ), + ("› old\n• new\n", "current_prompt_block_marker", ""), + ( + "above\n\n───\nbody\n───\nfooter\n", + "above_prompt_box", + "above\n\n", + ), + ( + "above\n\n───\nbody\n───\nfooter\n", + "last_non_empty_above_prompt_box", + "above", + ), + ( + "above\n───\nbody\n───\nfooter\n", + "prompt_box_body", + "body\n", + ), + ( + "above\n───\nbody\n───\nfooter\n", + "after_last_horizontal_rule", + "footer\n", + ), + ] { + assert_eq!( + region( + DetectionInput { + screen, + osc_title: "", + osc_progress: "" + }, + spec + ), + expected, + "region={spec}" + ); + } +} - let ordinary_reply = explain( - Agent::Muse, - "⟩ say the phrase\n\n◆ Yes, proceed\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(ordinary_reply.state, AgentState::Idle); - assert!(ordinary_reply.visible_idle); +#[test] +fn all_bundled_manifests_parse_and_validate() { + for agent in Agent::SCREEN_MANIFEST_AGENTS { + assert!( + bundled_manifest(agent).is_some(), + "missing bundled manifest for {}", + agent_label(agent) + ); + } } #[test] @@ -486,7 +579,6 @@ contain = ["Working"] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -497,7 +589,6 @@ state = "working" "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -510,7 +601,6 @@ contains = ["Working"] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -522,7 +612,6 @@ regex = ["["] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -550,7 +639,6 @@ contains = ["menu"] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -583,7 +671,6 @@ contains = ["ready"] "# )); } - assert!(parse_manifest(&manifest).is_err()); } @@ -616,7 +703,6 @@ all = [ ] }, ] "#; - assert!(parse_manifest(manifest).is_err()); } @@ -636,20 +722,18 @@ state = "idle" contains = [{matchers}] "# ); - assert!(parse_manifest(&manifest).is_err()); } #[test] fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { let content = "marker\nold\n\nmiddle\nmarker\nnew\n"; - assert_eq!( region( DetectionInput { screen: content, osc_title: "", - osc_progress: "", + osc_progress: "" }, "bottom_non_empty_lines(2)" ), @@ -660,13 +744,12 @@ fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { #[test] fn top_non_empty_lines_uses_top_occurrence_for_repeated_text() { let content = "\nmarker\nold\n\nmiddle\nmarker\nnew\n"; - assert_eq!( region( DetectionInput { screen: content, osc_title: "", - osc_progress: "", + osc_progress: "" }, "top_non_empty_lines(2)" ), @@ -690,7 +773,7 @@ fn top_non_empty_lines_requires_a_canonical_positive_bounded_count() { #[test] fn top_non_empty_lines_requires_engine_three_when_declared() { let manifest = r#" -id = "grok" +id = "codex" version = "1" min_engine_version = 2 @@ -700,849 +783,5 @@ state = "working" region = " top_non_empty_lines(1) " contains = ["active"] "#; - assert!(parse_manifest(manifest).is_err()); } - -// --------------------------------------------------------------------------- -// OSC rule tests — exercise the new osc_title / osc_progress regions against -// the bundled Claude and Codex manifests. -// --------------------------------------------------------------------------- - -fn osc_explain( - agent: Agent, - screen: &str, - osc_title: &str, - osc_progress: &str, -) -> DetectionExplain { - explain_with_input( - agent, - DetectionInput { - screen, - osc_title, - osc_progress, - }, - ) -} - -#[test] -fn grok_osc_activity_does_not_depend_on_configurable_title_text() { - for (title, progress, expected_state, expected_rule) in [ - ( - "project · session · id", - "4;0;0", - AgentState::Idle, - "osc_progress_idle", - ), - ( - "project · session · id", - "", - AgentState::Idle, - "prompt_hints_idle", - ), - ("grok", "", AgentState::Idle, "osc_title_idle"), - ( - "⠋ - Waiting for response… - project", - "", - AgentState::Working, - "osc_title_working", - ), - ( - "project - ⠹ - session", - "4;0;0", - AgentState::Working, - "osc_title_working", - ), - ( - "project · session · id", - "4;1;-1", - AgentState::Working, - "osc_progress_working", - ), - ( - "grok", - "4;1;-1", - AgentState::Working, - "osc_progress_working", - ), - ( - "⚠ Action Required - project", - "4;1;-1", - AgentState::Blocked, - "osc_title_blocked", - ), - ] { - let result = osc_explain( - Agent::Grok, - "Shift+Tab:mode │ Ctrl+.:shortcuts\n", - title, - progress, - ); - assert_eq!( - result.state, expected_state, - "title={title}, progress={progress}" - ); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some(expected_rule), - "title={title}, progress={progress}", - ); - } -} - -#[test] -fn grok_visible_activity_outranks_idle_osc_signals() { - for (screen, expected_rule) in [ - ( - "⠴ Sleep for 8 … 1.9s 5.0s ⇣19.5k [↓][stop]\n", - "spinner_status_working", - ), - ( - "Shift+Tab:mode │ Ctrl+c:cancel │ Ctrl+.:shortcuts\n", - "esc_cancel_hints_working", - ), - ( - "Shift+Tab:mode │ Esc:cancel │ Ctrl+.:shortcuts\n", - "esc_cancel_hints_working", - ), - ("◎ 1 command still running\n", "background_status_working"), - ("○ 1 command still running\n", "background_status_working"), - ("◉ 1 command still running\n", "background_status_working"), - ( - "◎ 2 commands · 1 subagent still running · send a message to interrupt\n", - "background_status_working", - ), - ] { - for progress in ["", "4;0;0"] { - let result = osc_explain(Agent::Grok, screen, "grok", progress); - assert_eq!(result.state, AgentState::Working, "screen={screen}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some(expected_rule) - ); - } - let blocked = osc_explain(Agent::Grok, screen, "⚠ Action Required - grok", "4;1;-1"); - assert_eq!(blocked.state, AgentState::Blocked); - } -} - -#[test] -fn grok_background_activity_requires_a_live_nonzero_status_row() { - for screen in [ - "1 command still running\n", - "◎ 0 commands still running\n", - "Discussed: ◎ 1 command still running\n", - "◎ 1 command still running\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", - "Worked for 3.9s\nShift+Tab:mode │ Ctrl+.:shortcuts\n", - ] { - assert_eq!( - osc_explain(Agent::Grok, screen, "project · session", "4;0;0").state, - AgentState::Idle - ); - } -} - -// --- Claude OSC rules --- - -#[test] -fn claude_idle_prompt_with_background_shell_is_idle() { - // Captured from Claude Code 2.1.251 after its foreground turn ended while - // a long-lived background shell remained active (issue #3414). - let screen = concat!( - "✻ Sautéed for 10s · 1 shell still running\n\n", - "──────────────────────────────────────────────────────── WINDOWS ─\n", - "❯\n", - "────────────────────────────────────────────────────────────────\n", - " ⏵⏵ auto mode on · 1 shell · ← for agents /rc\n", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("live_prompt_box") - ); - assert!(result.visible_idle); - assert!(!result.visible_working); -} - -#[test] -fn claude_background_shell_without_foreground_evidence_is_idle_fallback() { - let result = osc_explain( - Agent::Claude, - " ⏵⏵ auto mode on · 1 shell · ← for agents\n", - "", - "", - ); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!(result.matched_rule, None); - assert_eq!( - result.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert!(!result.visible_working); -} - -#[test] -fn claude_live_turn_with_background_shell_remains_working() { - let screen = concat!( - "────────────────────────────────────────────────────────────────\n", - "❯\n", - "────────────────────────────────────────────────────────────────\n", - " ⏵⏵ auto mode on · 1 shell · esc to interrupt\n", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("live_turn_working") - ); - assert!(result.visible_working); -} - -#[test] -fn claude_blocker_with_background_shell_remains_blocked() { - let screen = concat!( - "do you want to proceed?\n", - "bash command: rm -rf /tmp/test\n", - "❯ 1. Yes\n", - " 2. No\n\n", - "Esc to cancel · Tab to amend · ctrl+e to explain\n", - " ⏵⏵ auto mode on · 1 shell · ← for agents\n", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("bash_permission_prompt") - ); - assert!(result.visible_blocker); - assert!(!result.visible_working); -} - -#[test] -fn claude_bash_prompt_with_dont_ask_again_option_matches_bash_rule() { - // Captured from a Bash approval prompt at its resting cursor position. The - // "don't ask again" choice pushes No to option 3, so the only cursor-free - // option line is one bash_permission_prompt used not to cover, which let - // the narrower generic_permission_prompt claim the prompt instead (#2650). - let screen = concat!( - "──────────────────────────────────────────────────────────────── -", - " Bash command - -", - " curl -sS -o /tmp/probe.html https://example.com -", - " Download example.com to /tmp/probe.html - -", - " This command requires approval - -", - " Do you want to proceed? -", - " ❯ 1. Yes -", - " 2. Yes, and don't ask again for: curl * -", - " 3. No - -", - " Esc to cancel · Tab to amend · ctrl+e to explain -", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("bash_permission_prompt") - ); - assert!(result.visible_blocker); -} - -#[test] -fn claude_permission_prompt_matches_at_every_cursor_position() { - // The selected option carries "❯", so no option branch may assume its line - // is cursor-free. Walk the cursor across both option layouts. - let layouts: [&[&str]; 2] = [ - &[" ❯ 1. Yes", " 2. No"], - &[ - " ❯ 1. Yes", - " 2. Yes, and don't ask again for: curl *", - " 3. No", - ], - ]; - - for layout in layouts { - for selected in 0..layout.len() { - let options: Vec<String> = layout - .iter() - .enumerate() - .map(|(index, line)| { - let bare = line.trim_start().trim_start_matches('❯').trim_start(); - if index == selected { - format!(" ❯ {bare}") - } else { - format!(" {bare}") - } - }) - .collect(); - let screen = format!( - concat!( - "──────────────────────────────────────────────────────────────── -", - " Bash command - -", - " curl -sS https://example.com - -", - " Do you want to proceed? -", - "{} - -", - " Esc to cancel · Tab to amend · ctrl+e to explain -", - ), - options.join("\n"), - ); - let result = osc_explain(Agent::Claude, &screen, "", ""); - - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("bash_permission_prompt"), - "{options:?} selected={selected}" - ); - assert_eq!(result.state, AgentState::Blocked, "selected={selected}"); - assert!(result.visible_blocker, "selected={selected}"); - } - } -} - -#[test] -fn claude_osc_title_braille_prefix_is_working() { - // "⠂" is U+2802, in the braille block U+2800-U+28FF - let result = osc_explain(Agent::Claude, "", "⠂ project", ""); - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); - assert!(result.visible_working); -} - -#[test] -fn claude_osc_title_half_circle_frames_are_working() { - for frame in ['◐', '◓', '◑', '◒'] { - let title = format!("{frame} Initial conversation with Claude"); - let result = osc_explain(Agent::Claude, "", &title, ""); - assert_eq!(result.state, AgentState::Working, "frame {frame}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("osc_title_working"), - "frame {frame}" - ); - assert!(result.visible_working, "frame {frame}"); - } -} - -#[test] -fn claude_osc_title_static_prefix_is_idle() { - // "✳" is U+2733, static prefix when Claude is not working - let result = osc_explain(Agent::Claude, "", "✳ Claude Code", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); -} - -#[test] -fn claude_osc_progress_4_3_alone_does_not_force_working() { - // Claude leaves progress stuck at 4;3 while waiting for permission, so - // 4;3 must not be a working signal on its own. With no other evidence it - // falls back to idle; blocked screen rules can win when present. - let result = osc_explain(Agent::Claude, "", "", "4;3;"); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert!(!result.visible_working); -} - -#[test] -fn claude_blocker_screen_outranks_stale_osc_progress() { - // Regression: progress 4;3 persists during permission prompts. The - // blocked form on screen must win because no rule treats 4;3 as working. - let blocker_screen = - "──────────\n 1. Yes\n 2. No\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n"; - let result = osc_explain(Agent::Claude, blocker_screen, "✳ Task title", "4;3;"); - assert_eq!(result.state, AgentState::Blocked); - assert!(result.visible_blocker); -} - -#[test] -fn claude_osc_progress_4_0_is_idle() { - let result = osc_explain(Agent::Claude, "", "", "4;0;"); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_progress_idle") - ); -} - -#[test] -fn claude_blocker_screen_outranks_osc_idle_title() { - // When the OSC title shows ✳ (idle) but the screen has a bash permission - // prompt, the blocked rule at priority 850 beats osc_title_idle at 250. - let blocker_screen = "do you want to proceed?\n\ - bash command: rm -rf /tmp/test\n\ - ❯ 1. Yes\n 2. No\n\n\ - Esc to cancel · Tab to amend · ctrl+e to explain\n"; - let result = osc_explain(Agent::Claude, blocker_screen, "✳ Claude Code", ""); - assert_eq!(result.state, AgentState::Blocked); - assert!(result.visible_blocker); -} - -#[test] -fn claude_mcp_elicitation_is_blocked() { - // Regression for issue #3283: an MCP elicitation dialog has Accept/Decline - // controls and an "Esc to cancel" footer but no Enter hint, so no blocked - // rule matched and the static OSC title reported idle. - // Live capture uses curly quotes around the server name; the issue report - // transcribed straight quotes. Both must classify as blocked. - for screen in [ - "MCP server \u{201c}my-server\u{201d} requests your input\n\nGrant temporary access to the demo gateway for 15 minutes?\n\n\u{276f} Accept Decline\n\nEsc to cancel \u{b7} \u{2191}/\u{2193} to navigate\n", - "MCP server \"my-server\" requests your input\n\nserver-supplied message\n\n\u{276f} Accept Decline\n\nEsc to cancel \u{b7} \u{2191}/\u{2193} to navigate\n", - ] { - let result = with_manifest_dirs("claude-mcp-elicitation", || { - osc_explain(Agent::Claude, screen, "\u{2733} Claude Code", "") - }); - assert_eq!(result.state, AgentState::Blocked, "{result:#?}"); - assert!(result.visible_blocker, "{result:#?}"); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("mcp_elicitation_prompt"), - "{result:#?}" - ); - } -} - -#[test] -fn claude_empty_osc_empty_screen_is_idle_fallback() { - // No OSC data, no matching screen rule → fallback idle (unchanged V3 behavior) - let result = osc_explain(Agent::Claude, "", "", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert!(!result.visible_idle); -} - -// --- Codex OSC rules --- - -#[test] -fn codex_osc_title_braille_spinner_is_working() { - // "⠋" is U+280B, in the braille block - let result = osc_explain(Agent::Codex, "", "⠋ llm-proxy", ""); - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_osc_title_action_required_is_blocked() { - let result = osc_explain(Agent::Codex, "", "[ . ] Action Required | llm-proxy", ""); - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_blocked") - ); - assert!(result.visible_blocker); -} - -#[test] -fn codex_osc_title_plain_is_idle() { - let result = osc_explain(Agent::Codex, "", "llm-proxy", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); -} - -#[test] -fn codex_trust_directory_requires_live_top_region() { - let screen = "> You are in C:\\Users\\user\\project\n\n\ - Do you trust the contents of this\n\ - directory? Working with untrusted\n\ - contents comes with higher risk of\n\ - prompt injection. Trusting the\n\ - directory allows project-local config,\n\ - hooks, and exec policies to load.\n\n\ - › 1. Yes, continue\n\ - 2. No, quit\n\n\ - Press enter to continue\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("trust_directory") - ); - assert!(result.visible_blocker); - - let transcript = "› > You are in C:\\Users\\user\\project\n\n\ - Do you trust the contents of this\n\ - directory? Working with untrusted contents comes with higher risk.\n"; - let result = osc_explain(Agent::Codex, transcript, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_ne!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("trust_directory") - ); - assert!(!result.visible_blocker); -} - -#[test] -fn codex_startup_update_requires_complete_live_chooser() { - let chooser = "Update available! 0.153.0 -> 9.8.7\n\ - Run bun add -g @openai/codex to update.\n\n\ - › 1. Update now\n\ - 2. Skip until next version\n\n\ - Press enter to continue \n"; - let wrapped = "✨ Update available! 0.153.0\n\n\ - Release notes: https://example\n\n\ - › 1. Update now (runs `npm\n\ - install -g\n\ - @openai/codex`)\n\ - 2. Skip\n\ - 3. Skip until next\n\ - version\n\n\ - Press enter to continue\n"; - - for screen in [chooser, wrapped] { - let result = osc_explain(Agent::Codex, screen, "project", ""); - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("startup_update") - ); - assert!(result.visible_blocker); - } - - for screen in [ - chooser.replace("Update now", "Install"), - format!("{wrapped}\n› Ask Codex to do anything\n"), - ] { - let result = osc_explain(Agent::Codex, &screen, "project", ""); - assert_eq!(result.state, AgentState::Idle); - assert_ne!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("startup_update") - ); - assert!(!result.visible_blocker); - } -} - -#[test] -fn codex_background_terminal_screen_does_not_override_osc_idle() { - // Background terminal tasks can be long-lived helpers such as dev servers. - // They should not make Codex look busy once the foreground turn is idle. - let screen = "background terminal running · /ps to view · /stop to close\n"; - let result = osc_explain(Agent::Codex, screen, "llm-proxy", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); -} - -#[test] -fn codex_screen_working_fallback_handles_static_osc_title() { - let screen = "• I’ll run it and wait for completion.\n\n\ - ◦ Working (1m 16s • esc to interrupt) · 1 background…\n\n\ - › Use /skills to list available skills\n\n\ - gpt-5.6-sol default · /work\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("screen_working_fallback") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_screen_working_fallback_handles_activity_labels_and_queued_inputs() { - for prefix in ["", "• ", "◦ "] { - for label in ["Working", "Fixing bug in queue region"] { - for queue in [ - "", - "\n• Queued follow-up inputs\n ↳ Follow up after this turn\n alt + ↑ edit last queued message\n", - "\n• Messages to be submitted after next tool call\n (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted after next tool call (press esc to interrupt\n and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted after next\n tool call (press esc to interrupt and\n send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted at end of turn\n ↳ Follow up after this turn\n", - "\n• Messages to be submitted after next tool call\n (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n\n• Queued follow-up inputs\n ↳ After this turn reply ok.\n alt + ↑ edit last queued message\n", - ] { - let screen = format!( - "{prefix}{label} (1m 16s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close\n\ - {queue}\n› Ask Codex to do anything\n\n model · /work\n" - ); - let result = osc_explain(Agent::Codex, &screen, "project", ""); - - assert_eq!(result.state, AgentState::Working, "{screen}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("screen_working_fallback"), - "{screen}" - ); - assert!(result.visible_working); - } - } - } -} - -#[test] -fn codex_screen_working_fallback_uses_latest_activity_after_interruption() { - let screen = "■ Conversation interrupted\n\n\ - › Try again\n\n\ - Working (4s • esc to interrupt)\n\n\ - › Ask Codex to do anything\n\n model · /work\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Working); - assert!(result.visible_working); -} - -#[test] -fn codex_osc_working_remains_preferred_over_screen_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\n\ - › Use /skills to list available skills\n\n\ - gpt-5.6-sol default · /work\n"; - let result = osc_explain(Agent::Codex, screen, "⠸ project", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_screen_blocker_outranks_working_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\ - › 1. Yes, proceed\n\ - Press enter to confirm or esc to cancel\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("live_strong_blocker") - ); - assert!(result.visible_blocker); - assert!(!result.visible_working); -} - -#[test] -fn codex_weak_blocker_without_current_prompt_is_blocked() { - let result = osc_explain( - Agent::Codex, - "do you want to continue? [y/n]\n", - "project", - "", - ); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("weak_blocker") - ); -} - -#[test] -fn codex_current_prompt_keeps_weak_text_from_overriding_working_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\ - do you want to continue? [y/n]\n\ - › Use /skills to list available skills\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("screen_working_fallback") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_weak_blocker_ignores_finished_response_above_current_prompt() { - let screen = "• The `wt rm` transcript now shows [y/N] / esc, matching the real prompt.\n\n\ - ─ Worked for 4m 59s ─\n\n\ - › Ask Codex to do anything\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); -} - -#[test] -fn codex_weak_blocker_ignores_wrapped_current_prompt_text() { - let screen = "› Explain why this prompt wraps before quoting the confirmation text\n\ - [y/N] / esc and whether the docs should include it\n\n\ - gpt-5.6-sol default · /work\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); -} - -#[test] -fn codex_sparkle_prompt_preserves_live_states() { - for marker in ["› ", "›⠁", "›⠂", "›⠄", "›⠈", "›⠐", "›⠠", "›⡀", "›⢀"] - { - let screen = format!("Do you want to proceed? [y/n]\n{marker}unsent draft\n"); - let result = osc_explain(Agent::Codex, &screen, "project | Ready", ""); - assert_eq!(result.state, AgentState::Idle, "{marker}"); - - let working = format!( - "Do you want to proceed? [y/n]\n• Working (4s • esc to interrupt)\n{marker}draft\n" - ); - let result = osc_explain(Agent::Codex, &working, "project", ""); - assert_eq!(result.state, AgentState::Working, "{marker}"); - - let approval = format!("{screen}Press enter to confirm or esc to cancel\n"); - let result = osc_explain(Agent::Codex, &approval, "project", ""); - assert_eq!(result.state, AgentState::Blocked, "{marker}"); - assert!(result.visible_blocker); - - for response_marker in ['•', '■', '✗', '✓'] { - let response = format!("{screen}{response_marker} Do you want to proceed? [y/n]\n"); - let result = osc_explain(Agent::Codex, &response, "project", ""); - assert_eq!( - result.state, - AgentState::Blocked, - "{marker} {response_marker}" - ); - } - } -} - -#[test] -fn codex_weak_blocker_does_not_ignore_arbitrary_prompt_suffixes() { - for line in ["›text", "›⠋draft", "›⠀draft", " ›⠁draft", "quoted ›⠁draft"] { - let screen = format!("Do you want to proceed? [y/n]\n{line}\n"); - let result = osc_explain(Agent::Codex, &screen, "project", ""); - assert_eq!(result.state, AgentState::Blocked, "{line}"); - } -} - -#[test] -fn codex_transcript_viewer_outranks_working_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\ - › transcript\n\ - ↑/↓ to scroll · pgup/pgdn to move · home/end to jump · q to quit · esc to edit prev\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Unknown); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("transcript_viewer") - ); - assert!(result.skip_state_update); - assert!(!result.visible_working); -} - -#[test] -fn codex_screen_working_fallback_ignores_stale_and_prompt_text() { - let screens = [ - "◦ Working (1m 16s • esc to interrupt)\n\ - ■ Conversation interrupted\n\ - › Use /skills to list available skills\n\ - gpt-5.6-sol default · /work\n", - "› Explain the text ◦ Working (1m 16s • esc to interrupt)\n\ - gpt-5.6-sol default · /work\n", - " ◦ Working (1m 16s • esc to interrupt)\n\ - › Use /skills to list available skills\n\ - gpt-5.6-sol default · /work\n", - "Working (1m 16s • esc to interrupt)\n\ - • Finished the task\n\ - › Ask Codex to do anything\n", - "Working (1m 16s • esc to interrupt)\n\ - ■ Conversation interrupted\n\ - › Ask Codex to do anything\n", - "Working (1m 16s • esc to interrupt)\n\ - ─ Worked for 1m 16s ─\n\ - › Ask Codex to do anything\n", - "Working (1m 16s • esc to interrupt)\n•\nMessages to be submitted after next tool call\n› Ask Codex to do anything\n", - "› Explain this status:\n Working (1m 16s • esc to interrupt)\n", - "• Example (press esc to interrupt)\n\ - › Ask Codex to do anything\n", - ]; - - for screen in screens { - let result = osc_explain(Agent::Codex, screen, "project", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); - assert!(!result.visible_working); - } -} - -#[test] -fn codex_screen_working_fallback_ignores_interrupted_short_terminal() { - let screen = "◦ Working (1m 16s • esc to interrupt)\n\ - ■ Conversation interrupted\n\ - ›\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); - assert!(!result.visible_working); -} - -#[test] -fn codex_osc_working_beats_weak_blocker_screen() { - // A stale [y/n] on screen triggers weak_blocker at priority 600, but an - // active braille spinner in the OSC title is priority 1050 — OSC wins. - let screen = "do you want to continue? [y/n]\n"; - let result = osc_explain(Agent::Codex, screen, "⠋ llm-proxy", ""); - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); -} diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 0c773f0e..4ed32344 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -893,14 +893,6 @@ mod tests { std::env::temp_dir().join(unique) } - #[test] - fn moved_agent_detection_routes_through_production_dispatch() { - let detection = detect_agent(Some(Agent::Pi), "Working..."); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - // ---- Agent identification ---- #[test] @@ -1279,101 +1271,6 @@ mod tests { } #[test] - fn letta_manifest_detects_observed_working_and_idle_chrome() { - let empty = manifest::explain(Agent::Letta, ""); - assert_eq!(empty.state, AgentState::Unknown); - assert_eq!( - empty.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("no_live_state_evidence") - ); - for screen in [ - "✻ Thinking…\nTutor is reflecting… (esc to interrupt · 2m 3s)", - "My Tutor is thinking about thinking… (esc to interrupt · 3s)", - "Tutor is calibrating… (interrupting)", - "• Run Compile the integration\n└ Running... (1s)", - ] { - assert_eq!( - detect_state(Some(Agent::Letta), screen), - AgentState::Working - ); - } - assert_eq!( - detect_state( - Some(Agent::Letta), - "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · No model selected" - ), - AgentState::Idle - ); - assert_eq!( - detect_state( - Some(Agent::Letta), - "────────────────\n› explain this code\n────────────────\nTutor · No model selected" - ), - AgentState::Unknown - ); - let selector = manifest::explain( - Agent::Letta, - "8 pinned agents available.\n\n> Resume Bob (pinned)\n View all 8 profiles\n Create a new agent (--new)\n\n ↑↓ navigate · Enter select · Esc exit", - ); - assert_eq!(selector.state, AgentState::Unknown); - assert_eq!( - selector.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("profile_selector") - ); - } - - #[test] - fn letta_manifest_uses_osc_activity_and_approval_signals() { - let idle_screen = - "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · GPT-5.5"; - - for title in ["⠋ Tutor", "project | ⠏ Tutor"] { - let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - for title in [ - "[ ! ] Action Required | Tutor", - "[ . ] Action Required | Tutor", - ] { - let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, "Tutor", "4;3;0"); - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn letta_manifest_detects_observed_command_approval() { - let approval = r#"✻ Thinking… - -──────────────────────────────────────────────────────────────── -Run this command? - - $ rm -f /var/tmp/herdr-blocked-capture-never-created - -❯ 1. Yes - 2. No, and tell Letta Code what to do differently - -Enter to select · Esc to cancel"#; - assert_eq!( - detect_state(Some(Agent::Letta), approval), - AgentState::Blocked - ); - assert_eq!( - detect_state( - Some(Agent::Letta), - "The user asked: Run this command?\n›\n────\nTutor · No model selected" - ), - AgentState::Idle - ); - } - - #[test] fn identify_agent_in_job_detects_windows_cursor_install() { let job = crate::platform::ForegroundJob { process_group_id: 123, |
