aboutsummaryrefslogtreecommitdiff
path: root/src/detect/mod.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:24:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:33:45 +0300
commit36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (patch)
tree4a2d100eef7f26af7feaa93ce661423c3994c54e /src/detect/mod.rs
parent8d13492926829c5cf0ea42c8613344cd65ff59fa (diff)
feat: distribute agent detection manifests
Diffstat (limited to 'src/detect/mod.rs')
-rw-r--r--src/detect/mod.rs2104
1 files changed, 43 insertions, 2061 deletions
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index f2def663..62dc3d05 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -3,7 +3,8 @@
//! Each pane's live bottom-of-buffer text is read periodically and matched
//! against known agent output patterns to determine state.
-mod agents;
+pub mod manifest;
+pub mod manifest_update;
/// The detected state of a terminal pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -25,6 +26,8 @@ pub struct AgentDetection {
/// True when the current screen is an agent-owned viewer that shows
/// transcript/history instead of the live prompt state.
pub skip_state_update: bool,
+ /// True when the current screen visibly shows live idle chrome.
+ pub visible_idle: bool,
/// True when the current screen visibly shows live UI chrome that needs
/// human input. This is stronger than arbitrary prompt-like text in the
/// scrollback and may override a non-blocked integration state.
@@ -35,12 +38,6 @@ pub struct AgentDetection {
pub visible_working: bool,
}
-/// A narrow screen-derived exception that can veto PTY activity as semantic work.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum AgentActivityVeto {
- ClaudeIdleRecap,
-}
-
/// Which agent we detected running in a pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
@@ -63,6 +60,28 @@ pub enum Agent {
Qodercli,
}
+impl Agent {
+ pub const ALL: [Self; 17] = [
+ Self::Pi,
+ Self::Claude,
+ Self::Codex,
+ Self::Gemini,
+ Self::Cursor,
+ Self::Antigravity,
+ Self::Cline,
+ Self::OpenCode,
+ Self::GithubCopilot,
+ Self::Kimi,
+ Self::Kiro,
+ Self::Droid,
+ Self::Amp,
+ Self::Grok,
+ Self::Hermes,
+ Self::Kilo,
+ Self::Qodercli,
+ ];
+}
+
pub fn agent_label(agent: Agent) -> &'static str {
match agent {
Agent::Pi => "pi",
@@ -179,162 +198,27 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio
return AgentDetection {
state: AgentState::Unknown,
skip_state_update: false,
+ visible_idle: false,
visible_blocker: false,
visible_working: false,
};
};
- agents::detect(agent, screen_content)
-}
-
-pub(crate) fn agent_activity_veto(
- agent: Option<Agent>,
- screen_content: &str,
-) -> Option<AgentActivityVeto> {
- match agent {
- Some(Agent::Claude) if agents::claude_code::has_idle_recap_notice(screen_content) => {
- Some(AgentActivityVeto::ClaudeIdleRecap)
- }
- _ => None,
- }
+ manifest::detect(agent, screen_content)
}
pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool {
- agent.is_some_and(|agent| agents::should_skip_state_update(agent, screen_content))
-}
-
-// ---------------------------------------------------------------------------
-// Per-agent detectors
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-fn detect_pi(content: &str) -> AgentState {
- detect_state(Some(Agent::Pi), content)
-}
-
-#[cfg(test)]
-fn detect_claude(content: &str) -> AgentState {
- detect_state(Some(Agent::Claude), content)
-}
-
-#[cfg(test)]
-fn detect_codex(content: &str) -> AgentState {
- detect_state(Some(Agent::Codex), content)
-}
-
-#[cfg(test)]
-fn detect_gemini(content: &str) -> AgentState {
- detect_state(Some(Agent::Gemini), content)
-}
-
-#[cfg(test)]
-fn detect_cursor(content: &str) -> AgentState {
- detect_state(Some(Agent::Cursor), content)
-}
-
-#[cfg(test)]
-fn detect_antigravity(content: &str) -> AgentState {
- detect_state(Some(Agent::Antigravity), content)
-}
-
-#[cfg(test)]
-fn detect_cline(content: &str) -> AgentState {
- detect_state(Some(Agent::Cline), content)
-}
-
-#[cfg(test)]
-fn detect_opencode(content: &str) -> AgentState {
- detect_state(Some(Agent::OpenCode), content)
-}
-
-#[cfg(test)]
-fn detect_github_copilot(content: &str) -> AgentState {
- detect_state(Some(Agent::GithubCopilot), content)
-}
-
-#[cfg(test)]
-fn detect_kimi(content: &str) -> AgentState {
- detect_state(Some(Agent::Kimi), content)
-}
-
-#[cfg(test)]
-fn detect_droid(content: &str) -> AgentState {
- detect_state(Some(Agent::Droid), content)
-}
-
-/// Check for braille spinner characters at the start of a line.
-/// These are the Unicode braille pattern dots used by CLI spinners.
-fn has_braille_spinner(content: &str) -> bool {
- for line in content.lines() {
- let trimmed = line.trim();
- if let Some(c) = trimmed.chars().next() {
- if ('\u{2800}'..='\u{28FF}').contains(&c) {
- return true;
- }
- }
- }
- false
-}
-
-#[cfg(test)]
-fn detect_qodercli(content: &str) -> AgentState {
- detect_state(Some(Agent::Qodercli), content)
-}
-
-#[cfg(test)]
-fn detect_kilo(content: &str) -> AgentState {
- detect_state(Some(Agent::Kilo), content)
-}
-
-// ---------------------------------------------------------------------------
-// Shared helpers
-// ---------------------------------------------------------------------------
-
-/// Check for action confirmations followed by "yes" or "❯".
-fn has_confirmation_prompt(lower_content: &str) -> bool {
- if let Some(pos) = lower_content
- .find("do you want to")
- .or_else(|| lower_content.find("would you like to"))
- {
- let after = &lower_content[pos..];
- return after.contains("yes") || after.contains('❯');
- }
- false
-}
-
-/// Check for "❯" followed by numbered options like "1."
-fn has_selection_prompt(content: &str) -> bool {
- for line in content.lines() {
- let trimmed = line.trim();
- if trimmed.starts_with('❯') {
- // Check if there's a digit followed by a dot nearby
- if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') {
- return true;
- }
- }
- }
- false
-}
-
-/// Check for real interrupt hints, not unrelated "esc" and "interrupt" text.
-fn has_interrupt_pattern(lower_content: &str) -> bool {
- lower_content.contains("esc to interrupt")
- || lower_content.contains("ctrl+c to interrupt")
- || lower_content.contains("press esc to interrupt")
-}
-
-#[cfg(test)]
-fn has_cursor_spinner(content: &str) -> bool {
- agents::cursor::has_cursor_spinner(content)
-}
-
-#[cfg(test)]
-fn content_above_prompt_box(content: &str) -> &str {
- agents::claude_code::content_above_prompt_box(content)
+ agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content))
}
-#[cfg(test)]
-fn has_spinner_activity(content: &str) -> bool {
- agents::claude_code::has_spinner_activity(content)
+pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
+ matches!(
+ (source, agent_label),
+ ("herdr:pi", "pi")
+ | ("herdr:omp", "omp")
+ | ("herdr:hermes", "hermes")
+ | ("herdr:opencode", "opencode")
+ | ("herdr:kilo", "kilo")
+ )
}
// ---------------------------------------------------------------------------
@@ -662,57 +546,10 @@ mod tests {
#[test]
fn moved_agent_detection_routes_through_production_dispatch() {
- let cases = [
- (Agent::Pi, "Working...", AgentState::Working),
- (
- Agent::Claude,
- "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────",
- AgentState::Working,
- ),
- (
- Agent::Codex,
- "generating code\nesc to interrupt",
- AgentState::Working,
- ),
- (Agent::Gemini, "Esc to cancel", AgentState::Working),
- (Agent::Cursor, "Ctrl+C to stop", AgentState::Working),
- (
- Agent::Antigravity,
- "⠋ Thinking through the request",
- AgentState::Working,
- ),
- (Agent::Cline, "Reading project files", AgentState::Working),
- (
- Agent::OpenCode,
- "running tool\nesc to interrupt",
- AgentState::Working,
- ),
- (Agent::GithubCopilot, "Esc cancel", AgentState::Working),
- (Agent::Kimi, "🌕", AgentState::Working),
- (Agent::Kiro, "Kiro is working", AgentState::Working),
- (Agent::Droid, "Press ESC to stop", AgentState::Working),
- (Agent::Amp, "Esc to cancel", AgentState::Working),
- (
- Agent::Grok,
- "⠋ Waiting… 1.8s\nCtrl+c:cancel Ctrl+Enter:interject",
- AgentState::Working,
- ),
- (Agent::Hermes, "msg=interrupt", AgentState::Working),
- (
- Agent::Kilo,
- "Ask · DeepSeek V4 Pro\nesc interrupt",
- AgentState::Working,
- ),
- (Agent::Qodercli, "\u{280B} Thinking...", AgentState::Working),
- ];
-
- for (agent, screen, expected) in cases {
- assert_eq!(
- detect_agent(Some(agent), screen).state,
- expected,
- "production dispatch for {agent:?}"
- );
- }
+ let detection = detect_agent(Some(Agent::Pi), "Working...");
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
}
// ---- Agent identification ----
@@ -1079,1852 +916,13 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
- // ---- Workspace state rollup ----
-
- // ---- No agent → Unknown ----
+ // ---- Screen detection routing ----
#[test]
fn no_agent_returns_unknown() {
assert_eq!(detect_state(None, "anything"), AgentState::Unknown);
}
- // ---- Pi ----
-
- #[test]
- fn pi_working_when_working() {
- assert_eq!(detect_pi("some output\nWorking..."), AgentState::Working);
- }
-
- #[test]
- fn pi_working_working_in_middle() {
- assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Working);
- }
-
- #[test]
- fn pi_idle_at_prompt() {
- assert_eq!(detect_pi("❯ "), AgentState::Idle);
- }
-
- #[test]
- fn pi_idle_no_working_text() {
- assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle);
- }
-
- // ---- Claude Code ----
-
- #[test]
- fn claude_working_esc_to_interrupt() {
- let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_ctrl_c_to_interrupt() {
- let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_spinner() {
- let screen = "✽ Tempering…\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_middle_dot_spinner() {
- let screen = "· Thinking…\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_spinner_with_detail() {
- let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_detailed_transcript_skips_state_update() {
- let screen = "● I read the root and README.md.\n\n✻ Cogitated for 14s\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all verbose";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(detection.skip_state_update);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_detailed_transcript_collapse_skips_state_update() {
- let screen = "● Running tool\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to collapse";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn claude_wrapped_detailed_transcript_controls_skip_state_update() {
- let screen = "● Running tool\n\n────────────────────────\n Showing detailed transcript · ctrl+o\n to toggle · ctrl+e to\n collapse";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn claude_transcript_text_above_prompt_does_not_skip_state_update() {
- let screen = "Docs mention: Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n❯ \n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(!detection.skip_state_update);
- }
-
- #[test]
- fn claude_waiting_do_you_want() {
- let screen = "Do you want to run this command?\n\nYes No";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_waiting_would_you_like() {
- let screen = "Would you like to apply these changes?\n\n❯ Yes";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_waiting_selection_prompt() {
- let screen = "Do you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_waiting_esc_to_cancel() {
- let screen = "Allow bash: rm -rf /tmp/test?\n\nDo you want to proceed?\n\nesc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_bash_permission_modal_is_visible_blocker() {
- let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && for i in 1 2 3; do dd if=/dev/urandom)\n ⎿ Waiting…\n\n────────────────────────\n Bash command\n\n mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test\n Create random files in temporary detector directory\n\n Contains expansion\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_cropped_bash_permission_modal_is_visible_blocker() {
- let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_waiting_ask_user_question_menu() {
- let screen =
- "Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_dynamic_workflow_prompt_is_visible_blocker() {
- let screen = "● Workflow(Review the current worktree diff and verify the resulting findings)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n Review the current worktree diff and verify the resulting findings\n\n This dynamic workflow will spin up multiple subagents across the following phases:\n 1. Overview — map the diff and identify coupled subsystems\n 2. Review — review each changed file in parallel\n 3. Verify — adversarially verify each finding\n 4. Synthesize — summarize confirmed issues and residual risk\n\n Dynamic workflows can use a lot of tokens quickly by running many subagents in parallel — which counts against your usage limit. Stop a running workflow at any time with\n /workflows, or disable dynamic workflows in /config.\n\n ❯ 1. Yes, run it\n 2. View raw script\n 3. No\n\n Esc to cancel · Tab to amend\n ctrl+g to edit script in $EDITOR";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn claude_dynamic_workflow_prompt_uses_stable_modal_gate() {
- let screen = "● Workflow(Evaluate changes)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n 1. Scope\n 2. Review\n 3. Synthesize\n\n Esc to cancel · Tab to amend";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_question_form_selected_top_is_visible_blocker() {
- let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_question_form_with_arrow_glyph_footer_is_visible_blocker() {
- let screen = "❯ this is a test can you use question otol to ask questiosns\n\n Thought for 16s (ctrl+o to expand)\n─────────────────────────────────────────────────────────────────────────────────────────\n ☐ Test type\n\nWhich kind of test question should I ask you next?\n\n❯ 1. Single choice\n Ask one multiple-choice question.\n 2. Multi choice\n Ask one question that allows several answers.\n 3. With preview\n Ask with side-by-side previews.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_question_form_selected_bottom_is_visible_blocker() {
- let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Zed · Esc to cancel";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_idle_hooks_menu() {
- let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
-
- #[test]
- fn claude_idle_theme_menu() {
- let screen = "Theme\nChoose the text style that looks best with your terminal\n\n❯ 1. Dark mode ✔\n 2. Light mode\n 3. Dark mode (colorblind-friendly)\n\nSyntax theme: Monokai Extended (ctrl+t to disable)\n\nEnter to select · Esc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
-
- #[test]
- fn claude_idle_prompt_box() {
- let screen = "Task complete.\n─────────────\n❯ \n─────────────";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
-
- #[test]
- fn claude_idle_recap_notice_is_activity_veto() {
- let screen = "✻ Churned for 9s\n\n※ recap: We’re debugging issue 494: kitty graphics render in shell panes but not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path against Herdr’s placeholder\n handling to confirm the exact failure point. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────";
-
- assert_eq!(
- agent_activity_veto(Some(Agent::Claude), screen),
- Some(AgentActivityVeto::ClaudeIdleRecap)
- );
- }
-
- #[test]
- fn wrapped_claude_idle_recap_notice_is_activity_veto() {
- let screen = "※ recap: We’re debugging issue 494: kitty graphics render in shell panes but\n not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path\n against Herdr’s placeholder handling to confirm the exact failure point…\n and then I will inspect the redraw cache. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────";
-
- assert_eq!(
- agent_activity_veto(Some(Agent::Claude), screen),
- Some(AgentActivityVeto::ClaudeIdleRecap)
- );
- }
-
- #[test]
- fn stale_claude_recap_above_new_work_does_not_veto_activity() {
- let screen = "※ recap: Old recap. (disable recaps in /config)\n\n● Reading src/pane.rs\n\n✻ Crunching…\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────";
-
- assert_eq!(agent_activity_veto(Some(Agent::Claude), screen), None);
- }
-
- #[test]
- fn claude_prompt_box_does_not_set_screen_idle_signal() {
- let screen = "Interrupted.\n─────────────\n❯ \n─────────────";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- }
-
- #[test]
- fn claude_prompt_box_with_status_text_and_custom_status_does_not_set_screen_idle_signal() {
- let screen = "──────────────────────────────────────────── ◐ medium · /effort\n❯ \n────────────────────────────────────────────\n thommie-backend | refactor/cleanup-codebase | Opus 4.8 (1M context)\nIppy Tippy\n/coach-dive to chat about this\n▸▸ auto mode on (shift+tab to cycle) · ← for agents";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_interrupted_permission_prompt_box_does_not_set_screen_idle_signal() {
- let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_declined_questions_in_scrollback_with_prompt_box_are_idle() {
- let screen = "● User declined to answer questions\n ⎿ · What do you want help with? (Code task / PR review / Research / Claude setup)\n · How detailed should I be? (Short / Medium / Detailed)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~ ⊘ no git ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_old_permission_prompt_with_live_prompt_box_is_idle() {
- let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_old_permission_prompt_above_wrapped_live_prompt_is_not_visible_blocker() {
- let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ explain why this stale approval prompt should not be considered live anymore because\n my current prompt wrapped onto a second line\n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(!detection.visible_blocker);
- assert_eq!(
- crate::agent_detection_policy::apply_detection_policy(
- crate::agent_detection_policy::DetectionPolicyInput {
- agent: Some(Agent::Claude),
- screen_detection: detection,
- process_exited: false,
- startup_grace_active: false,
- pty_signal: Some(crate::agent_detection_policy::PtySignal {
- active: false,
- tainted: false,
- }),
- },
- ),
- crate::agent_detection_policy::DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Idle,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- })
- );
- }
-
- #[test]
- fn claude_spinner_after_interrupted_permission_is_visible_working() {
- let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n❯ test\n\n✢ Garnishing… (1s · thinking with high effort)\n ⎿ Tip: Run claude --continue or claude --resume to resume a conversation\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_separators_without_prompt_do_not_set_screen_idle_signal() {
- let screen = "Task complete.\n─────────────\nplain text\n─────────────";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- }
-
- #[test]
- fn claude_spinner_above_prompt_box_is_working() {
- let screen = "✢ Imagining… (3s · thinking with high effort)\n ⎿ Tip: Run /terminal-setup\n\n─────────────\n❯ \n─────────────\n~/project";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn claude_waiting_for_background_agent_is_working() {
- let screen = "● Done. I’ve delegated a read-only repo investigation to a subagent, and it will come back with a detailed report.\n\n✻ Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/llm-proxy ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ Explore Investigate repo and report 33s · ↓ 225 tokens";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_waiting_for_multiple_background_agents_is_working() {
- let screen = "● Done. I’ve delegated two investigations.\n\n✻ Waiting for 2 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn claude_completed_background_agent_wait_in_scrollback_is_idle() {
- let screen = "❯ please create a background agent that sleeps 15 sec then say hi\n\n Thought for 8s (ctrl+o to expand)\n\n● claude(Sleep then say hi)\n ⎿ Backgrounded agent (↓ to manage · ctrl+o to expand)\n\n● Done. I launched a background agent that will wait 15 seconds and then reply with hi.\n\n✻ Waiting for 1 background agent to finish\n\n● Agent \"Sleep then say hi\" completed · 17s\n\n● hi\n\n✻ Brewed for 28s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_completed_background_agent_with_prompt_box_is_idle() {
- let screen = "● Done. I’ve started a background agent.\n\n✻ Waiting for 1 background agent to finish\n\n⏺ Agent \"Sleep then say hi\" completed · 31s\n\n⏺ hi\n\n✻ Baked for 42s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ general-purpose Sleep then say hi 31s";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_zero_background_agent_wait_with_prompt_box_is_idle() {
- let screen = "✻ Waiting for 0 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn claude_background_agent_wait_phrase_in_prose_is_idle() {
- let screen = "Claude mentioned: Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn claude_working_not_confused_by_old_prompt() {
- // The "esc to interrupt" is ABOVE the prompt box — should be working
- let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- // ---- Codex ----
-
- #[test]
- fn codex_waiting_confirm() {
- assert_eq!(
- detect_codex("press enter to confirm or esc to cancel"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn codex_waiting_allow_command() {
- assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Blocked);
- }
-
- #[test]
- fn codex_waiting_submit_answer() {
- assert_eq!(
- detect_codex("Question about approach\n| enter to submit answer"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn codex_question_ui_is_visible_blocker() {
- let screen = "Question 1/1 (1 unanswered)\nWhat kind of code improvement do you want?\n› 1. Reduce complexity\n 2. Improve reliability\n\ntab to add notes | enter to submit answer | esc to interrupt";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn codex_bare_yes_no_hint_is_not_visible_blocker() {
- let detection = detect_agent(Some(Agent::Codex), "The docs mention [y/n] prompts.");
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_not_visible_blocker() {
- let screen = "Codex\nBlocked signals in src/detect/agents/codex.rs:6: confirm footer, submit answer/all, allow command, [y/n], yes (y), or generic confirmation.\n\nLikely false positives: [y/n] and generic confirmation prose still mark Blocked.\n\n• Agent thread 019e7670-ba31-7641-b6e0-545c101de8c3 is closed. Replaying saved transcript.\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master · gpt-5.5 default · Context 37% used";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_replayed_allow_command_text_above_prompt_is_idle() {
- let screen = "• Ran grep for blockers\n └ The phrase allow command? appears in our discussion above.\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.\n\n\n› Write tests for @filename\n\n gpt-5.5 high · ~/.herdr/worktrees/herdr/plugin-v1 · plugin-v1 · Context 18% used · 5h 93% left · weekly 71% left";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_generic_confirmation_prompt_is_not_visible_blocker() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "Earlier output asked: do you want to continue? The answer was yes.",
- );
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn gemini_confirmation_prompt_is_visible_blocker() {
- let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No");
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn codex_waiting_submit_answer_wrapped_footer() {
- assert_eq!(
- detect_codex(
- "Question 1/2\nChoose an option.\nenter to submit answer\nesc to interrupt"
- ),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn codex_waiting_submit_all_multi_question_footer() {
- let screen = "Question 2/2 (1 unanswered)\nAt a high level, what is issue 249 supposed to fix?\n› 1. State arbitration (Recommended)\n 2. UI behavior\n 3. Test reliability\n 4. None of the above\n\ntab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn codex_interrupted_prompt_does_not_set_screen_idle_signal() {
- let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- }
-
- #[test]
- fn codex_working_interrupt() {
- assert_eq!(
- detect_codex("generating code\nesc to interrupt"),
- AgentState::Working
- );
- }
-
- #[test]
- fn codex_working_truncated_status_header() {
- assert_eq!(detect_codex("• Working (0s • esc…"), AgentState::Working);
- }
-
- #[test]
- fn codex_status_line_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran git status --short\n └ M src/detect.rs\n\n• Working (17s • esc to interrupt)\n\n\n› Implement {feature}",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_live_working_status_above_current_prompt_stays_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran git diff --name-status v0.6.3..HEAD\n └ A website/src/pages/releases/index.astro\n\n• Working (28s • esc to interrupt)\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 96% left",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_generic_interrupt_status_above_current_prompt_stays_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• I need to inspect more code first.\n\n• Investigating code output (44s • esc to interrupt)\n\n\n› Summarize recent commits\n\n gpt-5.5 high · ~/Codes/herdr",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_truncated_status_with_arbitrary_label_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran git diff -- src/detect/mod.rs\n └ diff output...\n\n• Considering patch updates (2m 26s • esc …\n\n\n› Fix Codex detection\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_truncated_status_without_prompt_is_working() {
- assert_eq!(
- detect_codex("• Considering patch updates (2m 26s • esc …"),
- AgentState::Working
- );
- }
-
- #[test]
- fn codex_bullet_with_esc_text_without_timer_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Notes mention esc to interrupt as a phrase\n\n› Fix Codex detection\n\n ~/Projects/herdr · master",
- );
-
- assert!(!detection.visible_blocker);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_background_terminal_status_above_current_prompt_stays_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran just check\n └ test output...\n\n• Working (1m 36s) · 1 background terminal running · /ps to view · /stop to close\n\n\n› Run /review on my current changes",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_reviewing_approval_request_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Reviewing approval request (22s • esc to interrupt)\n └ /bin/zsh -lc 'rm -rf /tmp/codex-rm-test'\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_reviewing_approval_request_without_prompt_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Running git push --force-with-lease origin codex/ci-flake-diagnostics\n\n• Reviewing approval request (2m 53s • esc to interrupt)\n └ /bin/zsh -lc 'git push --force-with-lease origin codex/ci-flake-diagnostics'",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_reviewing_multiple_approval_requests_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Reviewing 2 approval requests (0s • esc to interrupt)\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_booting_mcp_server_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Booting MCP server: codex_apps (7s • esc to interrupt)\n\n\n› ok spawn one again please\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_stale_working_status_above_current_prompt_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› `/feedback` to report the issue.\n\n\n › Working\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used\n\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 95% left",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_pasted_transcript_in_current_prompt_does_not_trigger_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "› so there is a problem\n\n\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly\n 36% …\n\n wdyt\n\n\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly 36% …",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_background_terminal_wait_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core -- --exact…\n\n\n› Ask Codex to do anything",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_working_header_without_interrupt_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Working (17s)\n\n› Implement {feature}",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_old_working_line_before_later_block_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_old_background_terminal_wait_before_later_block_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_queued_follow_up_keeps_active_working_status() {
- let cases = [
- "• Working (9m 24s • esc to interrupt)\n\n • Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- "• Working (9m 24s • esc to interrupt)\n\n• Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- "• Working (4s • esc to interrupt)\n\n • Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- "• Working (4s • esc to interrupt)\n\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- ];
-
- for screen in cases {
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
- }
-
- #[test]
- fn codex_idle() {
- assert_eq!(detect_codex("❯ "), AgentState::Idle);
- }
-
- #[test]
- fn codex_transcript_viewer_skips_state_update() {
- let screen = "/ T R A N S C R I P T / / / / / / / / / / / / / / / /\n\n› i did thats why our latest commit is also a claude fix D\n\n• Yes, then I would release.\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(detection.skip_state_update);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_wrapped_transcript_controls_skip_state_update() {
- let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to\n page home/end to jump\n q to quit esc to edit prev";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn codex_esc_transcript_controls_skip_state_update() {
- let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc/← to edit prev → to edit next enter to edit message";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn codex_transcript_controls_above_prompt_do_not_skip_state_update() {
- let screen = "Old output:\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev\n\n› ";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(!detection.skip_state_update);
- }
-
- #[test]
- fn codex_exit_control_without_scroll_control_does_not_skip_state_update() {
- let screen = "Some output\n\n q to quit esc to edit prev";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(!detection.skip_state_update);
- }
-
- // ---- Gemini ----
-
- #[test]
- fn gemini_waiting_confirmation() {
- assert_eq!(
- detect_gemini("waiting for user confirmation\nYes No"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn gemini_waiting_text_without_choices_is_idle() {
- assert_eq!(
- detect_gemini("waiting for user confirmation"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn gemini_waiting_apply() {
- assert_eq!(
- detect_gemini("│ Apply this change\n│ Yes │ No"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn gemini_waiting_allow_execution() {
- assert_eq!(
- detect_gemini("│ Allow execution of: rm test.txt"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn gemini_working() {
- assert_eq!(
- detect_gemini("thinking...\nesc to cancel"),
- AgentState::Working
- );
- }
-
- #[test]
- fn gemini_idle() {
- assert_eq!(detect_gemini("❯ "), AgentState::Idle);
- }
-
- // ---- Cursor ----
-
- #[test]
- fn cursor_waiting_accept() {
- assert_eq!(
- detect_cursor("Apply changes? (y) (enter) or keep (n)"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn cursor_waiting_allow() {
- assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Blocked);
- }
-
- #[test]
- fn cursor_working_spinner() {
- assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Working);
- }
-
- #[test]
- fn cursor_working_braille_status() {
- assert_eq!(
- detect_cursor("⠠⠜ Running 5.52k tokens"),
- AgentState::Working
- );
- assert_eq!(
- detect_cursor("⠞ Working 5.62k tokens"),
- AgentState::Working
- );
- assert_eq!(
- detect_cursor("⠛ Grepping 1.2k tokens"),
- AgentState::Working
- );
- }
-
- #[test]
- fn cursor_blocked_command_approval() {
- let screen =
- "Waiting for approval...\nRun this command?\n→ Run (once) (y)\nSkip (esc or n)";
- assert_eq!(detect_cursor(screen), AgentState::Blocked);
- }
-
- #[test]
- fn cursor_approval_words_without_visible_controls_are_idle() {
- assert_eq!(
- detect_cursor("previous output: waiting for approval"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn cursor_running_text_with_unrelated_yes_is_not_blocked() {
- let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens";
- assert_eq!(detect_cursor(screen), AgentState::Working);
- }
-
- #[test]
- fn cursor_working_ctrl_c() {
- assert_eq!(
- detect_cursor("processing\nctrl+c to stop"),
- AgentState::Working
- );
- }
-
- #[test]
- fn cursor_idle() {
- assert_eq!(detect_cursor("> "), AgentState::Idle);
- }
-
- // ---- Antigravity ----
-
- #[test]
- fn antigravity_blocked_permission_prompt() {
- let screen = "Requesting permission for: git log -n 50\nDo you want to proceed?\n> 1. Yes\n↑/↓ Navigate · tab Amend · e edit command";
- assert_eq!(detect_antigravity(screen), AgentState::Blocked);
- }
-
- #[test]
- fn antigravity_question_without_permission_request_stays_idle() {
- assert_eq!(
- detect_antigravity("Do you want to proceed?\n>"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn antigravity_working_spinner() {
- assert_eq!(detect_antigravity("⡿ Working..."), AgentState::Working);
- assert_eq!(detect_antigravity("⣯ Loading..."), AgentState::Working);
- assert_eq!(detect_antigravity("⢿ Generating..."), AgentState::Working);
- assert_eq!(detect_antigravity("⣷ Running..."), AgentState::Working);
- }
-
- #[test]
- fn antigravity_background_task_footer_is_working() {
- let screen = "? for shortcuts Gemini 3.5 Flash (High) · 1 task(s) · /tasks";
- assert_eq!(detect_antigravity(screen), AgentState::Working);
- }
-
- #[test]
- fn antigravity_background_task_footer_parses_plural_variants() {
- assert_eq!(
- detect_antigravity("Gemini 3.5 Flash (High) · 10 task(s) · /tasks"),
- AgentState::Working
- );
- assert_eq!(
- detect_antigravity("model · 2 tasks · /tasks"),
- AgentState::Working
- );
- assert_eq!(
- detect_antigravity("model · 1 task · /tasks"),
- AgentState::Working
- );
- }
-
- #[test]
- fn antigravity_zero_background_tasks_is_idle() {
- let screen = "Gemini 3.5 Flash (High) · 0 task(s) · /tasks";
- assert_eq!(detect_antigravity(screen), AgentState::Idle);
- }
-
- #[test]
- fn antigravity_task_text_outside_bottom_footer_is_idle() {
- let screen =
- "User said the footer had /tasks and 1 task(s)\nline 1\nline 2\nline 3\nline 4\nline 5";
- assert_eq!(detect_antigravity(screen), AgentState::Idle);
- }
-
- #[test]
- fn antigravity_idle_prompt() {
- let screen = "Antigravity CLI\n────────────────\n>\n────────────────\n? for shortcuts";
- assert_eq!(detect_antigravity(screen), AgentState::Idle);
- }
-
- // ---- Cline ----
-
- #[test]
- fn cline_waiting_tool_use() {
- assert_eq!(detect_cline("let cline use this tool"), AgentState::Blocked);
- }
-
- #[test]
- fn cline_waiting_act_mode() {
- assert_eq!(
- detect_cline("[act mode] execute command?\nyes"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn cline_mode_text_with_unrelated_yes_is_not_blocked() {
- assert_eq!(
- detect_cline("[act mode]\nPrevious answer was yes."),
- AgentState::Working
- );
- }
-
- #[test]
- fn cline_ready_text_is_not_visible_blocker() {
- let detection = detect_agent(Some(Agent::Cline), "cline is ready for your message");
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn cline_defaults_to_working() {
- // Cline's default is working (unlike other agents)
- assert_eq!(detect_cline("some random output"), AgentState::Working);
- }
-
- // ---- OpenCode ----
-
- #[test]
- fn opencode_waiting_permission() {
- assert_eq!(
- detect_opencode("△ Permission required"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn opencode_working() {
- assert_eq!(
- detect_opencode("running tool\nesc to interrupt"),
- AgentState::Working
- );
- }
-
- #[test]
- fn opencode_working_on_footer_interrupt() {
- let screen = "\
- ▣ Build · MiniMax M3 Free\n\
-\n\
- ┃\n\
- ┃ Build · MiniMax M3 Free OpenCode Zen ~/Projects/llm-proxy:master\n\
- ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n\
- ⬝⬝⬝■■■■■ esc interrupt 24.4K (12%) ctrl+p commands • OpenCode 1.15.13";
- assert_eq!(detect_opencode(screen), AgentState::Working);
- }
-
- #[test]
- fn opencode_working_on_progress_footer_without_product_text() {
- assert_eq!(
- detect_opencode("■■■■■■⬝⬝ esc interrupt"),
- AgentState::Working
- );
- }
-
- #[test]
- fn opencode_working_on_escape_again_footer() {
- assert_eq!(
- detect_opencode(
- "⬝⬝■■■■■■ esc again to interrupt 14.3K (7%) ctrl+p commands • OpenCode 1.15.13"
- ),
- AgentState::Working
- );
- }
-
- #[test]
- fn opencode_progress_row_alone_is_working() {
- assert_eq!(detect_opencode("■■■■■■⬝⬝"), AgentState::Working);
- }
-
- #[test]
- fn opencode_ctrl_p_commands_alone_is_not_working() {
- assert_eq!(
- detect_opencode("esc interrupt 24.4K (12%) ctrl+p commands"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn opencode_waiting_question_prompt() {
- assert_eq!(
- detect_opencode(
- "Goal Detail Confirm\n\
- What do you want help with right now?\n\
- 1. Code change\n\
- 5. Type your own answer\n\
- ⇆ tab ↑↓ select enter confirm esc dismiss",
- ),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn opencode_waiting_question_confirm_tab() {
- assert_eq!(
- detect_opencode("Goal Detail Confirm\nReview\n⇆ tab enter submit esc dismiss"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn opencode_idle() {
- assert_eq!(detect_opencode("> "), AgentState::Idle);
- }
-
- // ---- Kilo ----
-
- #[test]
- fn kilo_waiting_question_prompt() {
- assert_eq!(
- detect_kilo(
- "Write the following joke to ~/joke.md?\n\
- 1. Yes, write it\n\
- 2. No, thanks\n\
- 3. Type your own answer\n\
- ↑↓ select enter submit esc dismiss",
- ),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn kilo_working() {
- assert_eq!(
- detect_kilo("Ask · DeepSeek V4 Pro\nesc interrupt\n12.8K (1%) · $0.01"),
- AgentState::Working
- );
- }
-
- #[test]
- fn kilo_idle() {
- assert_eq!(
- detect_kilo("Ask anything... \"Fix broken tests\"\nCode · DeepSeek V4 Pro"),
- AgentState::Idle
- );
- }
-
- // ---- GitHub Copilot ----
-
- #[test]
- fn copilot_waiting_fetch_approval() {
- let content = "\
-╭───────────────────────────────────────────────────────────────────╮
-│ Fetch web content │
-│ ───────────────────────────────────────────────────────────────── │
-│ Copilot is attempting to access the following URL: │
-│ │
-│ ╭───────────────────────────────────────────────────────────────╮ │
-│ │ https://www.google.com/ │ │
-│ ╰───────────────────────────────────────────────────────────────╯ │
-│ │
-│ Do you want to allow this access? │
-│ │
-│ ❯ 1. Yes │
-│ 2. No │
-│ │
-│ ↑/↓ to navigate · enter to select · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_allow_directory_access() {
- let content = "\
-╭──────────────────────────────────────────────────────────────────╮
-│ Allow directory access │
-│ ──────────────────────────────────────────────────────────────── │
-│ This action may read or write the following path outside your │
-│ allowed directory list. │
-│ │
-│ ╭──────────────────────────────────────────────────────────────╮ │
-│ │ /Users/user/Dev/workspace │ │
-│ ╰──────────────────────────────────────────────────────────────╯ │
-│ │
-│ Do you want to allow this? │
-│ │
-│ 1. Yes │
-│ ❯ 2. Yes, and add these directories to the allowed list │
-│ 3. No (Esc) │
-│ │
-│ ↑/↓ to navigate · enter to select · esc to cancel │
-╰──────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_directory_permission() {
- let content = "\
-○ Asking user Requesting permission to access directory 'src/' for r…
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Requesting permission to access directory 'src/' for reading │
-│ files. Allow? │
-│ │
-│ ❯ 1. Yes (Allow) │
-│ 2. No (Deny) │
-│ │
-│ ↑/↓ to select · enter to confirm · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_action_confirmation() {
- let content = "\
-○ Asking user Confirm action: 'Reset local changes in working tree' …
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Confirm action: 'Reset local changes in working tree' — proceed? │
-│ │
-│ ❯ 1. Yes, reset changes │
-│ 2. No, cancel │
-│ │
-│ ↑/↓ to select · enter to confirm · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_db_choice() {
- let content = "\
-○ Asking user Choose a database for the project:
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Choose a database for the project: │
-│ │
-│ ❯ 1. PostgreSQL (Recommended) │
-│ 2. SQLite │
-│ 3. MySQL │
-│ 4. MongoDB │
-│ │
-│ ↑/↓ to select · enter to confirm · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_freeform_input() {
- let content = "\
-○ Asking user Enter the name for the new branch:
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Enter the name for the new branch: │
-│ │
-│ ❯ Type your answer... │
-│ │
-│ enter to submit · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_plan_review() {
- let content = "\
-○ Plan ready for review - Create a 200-word inspirational poem. - Fi…
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Plan Ready for Review │
-│ ───────────────────────────────────────────────────────────────── │
-│ - Create a 200-word inspirational poem. │
-│ - Files/changes: none (deliver poem in chat). │
-│ - Steps: draft poem, verify ~200 words, offer up to 2 revisions. │
-│ - Decision: Inspirational tone, exact length target ~200 words. │
-│ │
-│ ❯ 1. Accept plan and build on default permissions (recommended) │
-│ 2. Exit plan mode and I will prompt myself │
-│ 3. Suggest changes │
-│ │
-│ ↑/↓ to navigate · enter to select · ctrl+e to show full plan · │
-│ esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_enable_autopilot() {
- let content = "\
-╭───────────────────────────────────────────────────────────────────╮
-│ Enable autopilot mode │
-│ ───────────────────────────────────────────────────────────────── │
-│ Autopilot mode works best with all permissions enabled. Without │
-│ them, permission requests will be auto-denied and the agent may │
-│ not complete tasks requiring file edits or shell commands. │
-│ │
-│ You can also enable permissions later with /allow-all │
-│ │
-│ ❯ 1. Enable all permissions (recommended) │
-│ 2. Continue with limited permissions │
-│ 3. Cancel (Esc) │
-│ │
-│ ↑/↓ to navigate · enter to select · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_working_thinking_spinner() {
- assert_eq!(
- detect_github_copilot("○ Thinking esc cancel"),
- AgentState::Working
- );
- assert_eq!(
- detect_github_copilot("◎ Thinking esc cancel"),
- AgentState::Working
- );
- assert_eq!(
- detect_github_copilot("◉ Thinking esc cancel"),
- AgentState::Working
- );
- }
-
- // ---- Kimi ----
-
- #[test]
- fn kimi_blocked_approval_prompt_wins_over_spinner() {
- let screen = "⠋ Using Shell (git log --oneline -10)\n╭─ approval ─╮\nShell is requesting approval to run command:\ngit log --oneline -10\n→ [1] Approve once\n[2] Approve for this session\n[3] Reject\n[4] Reject, tell the model what to do instead\n▲/▼ select 1/2/3/4 choose ↵ confirm";
- assert_eq!(detect_kimi(screen), AgentState::Blocked);
- }
-
- #[test]
- fn kimi_current_approval_panel_is_visible_blocker() {
- let screen = "────────────────────────────────────────────────────\n ▶ Run this command?\n\n $ git status --short\n\n ▶ 1. Approve once\n 2. Approve for this session\n 3. Reject\n 4. Reject with feedback\n\n ↑/↓ select · 1/2/3/4 choose · ↵ confirm\n────────────────────────────────────────────────────";
- let detection = detect_agent(Some(Agent::Kimi), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn kimi_question_panel_is_visible_blocker() {
- let screen = "────────────────────────────────────────────────────\n question\n\n (○) Destination Submit\n\n ? Which destination should I use?\n\n → [1] Local checkout\n [2] Remote branch\n [3] Other\n\n ↑↓ select 1-3 / ↵ choose ←/→/tab switch esc cancel\n────────────────────────────────────────────────────";
- let detection = detect_agent(Some(Agent::Kimi), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn kimi_approval_words_without_prompt_stay_idle() {
- assert_eq!(detect_kimi("approve?"), AgentState::Idle);
- assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Idle);
- }
-
- #[test]
- fn kimi_working_braille_thinking() {
- assert_eq!(
- detect_kimi("⠦ Thinking... <1s · 19 tokens"),
- AgentState::Working
- );
- }
-
- #[test]
- fn kimi_working_braille_using_tool() {
- assert_eq!(
- detect_kimi("⠹ Using Shell (git log -20 --name-status)"),
- AgentState::Working
- );
- }
-
- #[test]
- fn kimi_working_braille_working_status_is_visible_working() {
- let detection = detect_agent(Some(Agent::Kimi), "⠋ working...");
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn kimi_working_moon_spinner() {
- assert_eq!(detect_kimi("🌕"), AgentState::Working);
- assert_eq!(detect_kimi("🌗"), AgentState::Working);
- assert_eq!(detect_kimi("🌘"), AgentState::Working);
- }
-
- #[test]
- fn kimi_working_moon_spinner_above_input_box() {
- let screen = "✨ yo\n\n🌗\n\n── input ─────────────────────────";
- assert_eq!(detect_kimi(screen), AgentState::Working);
- }
-
- #[test]
- fn kimi_old_transcript_words_stay_idle() {
- assert_eq!(detect_kimi("thinking"), AgentState::Idle);
- assert_eq!(detect_kimi("generating code"), AgentState::Idle);
- assert_eq!(
- detect_kimi("Used Shell (git log --oneline -10)"),
- AgentState::Idle
- );
- assert_eq!(detect_kimi("some 🌕 in prose"), AgentState::Idle);
- }
-
- #[test]
- fn kimi_idle() {
- let screen = "Welcome to Kimi Code CLI!\n── input ─\n────────────────\nagent (Kimi-k2.6 ●) ~/Projects/herdr";
- assert_eq!(detect_kimi(screen), AgentState::Idle);
- }
-
- #[test]
- fn kimi_current_editor_box_does_not_set_screen_idle_signal() {
- let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%";
- let detection = detect_agent(Some(Agent::Kimi), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- // ---- Kiro ----
-
- #[test]
- fn kiro_working_on_status_bar() {
- let screen = "◕ Shell\n esc to cancel\n● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n Kiro is working · type to queue a message";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working);
- }
-
- #[test]
- fn kiro_working_on_tool_spinner_and_cancel_hint() {
- let screen = "◕ Shell\n esc to cancel\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working);
- }
-
- #[test]
- fn kiro_idle_at_prompt() {
- let screen = "● 1 MCP failure — see /mcp\n──────────────────────────────────────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n ask a question or describe a task ↵\n /copy to clipboard";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle);
- }
-
- #[test]
- fn kiro_blocked_on_tool_approval_prompt() {
- let screen = "↓ Shell mkdir -p /tmp/test-kiro-{a,b,c} && ls /tmp/test-kiro-*\n\n─────────────────────────────────────────────────────────────────────────────────────────\n shell requires approval\n ❯ Yes, single permission\n Trust, always allow in this session\n No (Tab to edit)\n─────────────────────────────────────────────────────────────────────────────────────────\n ESC to close | Tab to edit";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked);
- }
-
- #[test]
- fn kiro_blocked_on_subagent_tool_approval_prompt() {
- let screen = " Please delegate to a sub-agent to search the web\n\n● Orchestrating (1 agent)\n esc to cancel\n ctrl+g open agent monitor\n ● web-research kiro_default ⚠ tool approval needed\n\n ◐ Tasks · 1 done · 2 remaining ctrl+x to expand\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ⚠ 3 tool approvals pending from subagents\n ❯ (a) Approve all pending\n (f) Approve all pending and auto-approve all future requests\n (c) Configure individually (agent monitor)\n (x) Exit (cancel subagents) %";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked);
- }
-
- #[test]
- fn kiro_does_not_treat_stale_failure_spinner_as_working() {
- let screen = "● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%\n\n ask a question or describe a task ↵";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle);
- }
-
- #[test]
- fn kiro_identified_by_process_name() {
- assert_eq!(identify_agent("kiro"), Some(Agent::Kiro));
- assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro));
- }
-
- // ---- Droid ----
-
- #[test]
- fn droid_working_thinking_with_spinner() {
- let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)";
- assert_eq!(detect_droid(screen), AgentState::Working);
- }
-
- #[test]
- fn droid_working_esc_to_stop_alone() {
- // ESC to stop without spinner is still working (UI chrome)
- let screen = "Processing\n(Press ESC to stop)";
- assert_eq!(detect_droid(screen), AgentState::Working);
- }
-
- #[test]
- fn droid_waiting_execute_approval() {
- let screen = concat!(
- "⛬ I'll create some folders.\n\n",
- " EXECUTE (mkdir -p /tmp/test, impact: medium)\n\n",
- "╭────────────────────╮\n",
- "│ > Yes, allow │\n",
- "│ Yes, always allow │\n",
- "│ No, cancel │\n",
- "╰────────────────────╯\n",
- " Use ↑↓ to navigate, Enter to select, Esc to cancel\n",
- );
- assert_eq!(detect_droid(screen), AgentState::Blocked);
- }
-
- #[test]
- fn droid_waiting_selection_with_chrome() {
- let screen = "│ > Yes, allow │\n│ No, cancel │\n Use ↑↓ to navigate, Enter to select, Esc to cancel";
- assert_eq!(detect_droid(screen), AgentState::Blocked);
- }
-
- #[test]
- fn droid_not_waiting_on_options_text_alone() {
- // "Yes, allow" in normal conversation should NOT trigger blocked
- let screen = "The user said > Yes, allow the changes";
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_idle_prompt() {
- let screen =
- "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help";
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_idle_after_response() {
- let screen =
- "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯";
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_braille_spinner_detected() {
- assert!(has_braille_spinner("⠴ Thinking..."));
- assert!(has_braille_spinner(" ⠧ Loading..."));
- assert!(has_braille_spinner("text\n⠋ Working\nmore"));
- }
-
- #[test]
- fn droid_braille_spinner_no_false_positive() {
- assert!(!has_braille_spinner("normal text"));
- assert!(!has_braille_spinner("Thinking..."));
- assert!(!has_braille_spinner("some ⠴ in middle of text"));
- }
-
- #[test]
- fn droid_identified_by_process_name() {
- assert_eq!(identify_agent("droid"), Some(Agent::Droid));
- }
-
- // ---- Amp ----
-
- #[test]
- fn amp_blocked_waiting_for_approval() {
- let screen = "Invoke tool shell_command?\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]\nWaiting for approval...";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_blocked_run_this_command() {
- let screen = "Run this command?\nrg --files\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_blocked_allow_editing_file() {
- let screen = "Allow editing file:\nsrc/detect.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_blocked_allow_creating_file() {
- let screen = "Allow creating file:\nsrc/new_file.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_working_running_tools() {
- let screen = " ✓ Search Map the core runtime architecture\n ⋯ Oracle ▼\n ≈ Running tools... Esc to cancel";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Working);
- }
-
- #[test]
- fn amp_idle() {
- let screen = " Response complete.\n\n╭─100% of 272k · $1.20─────────────────────────╮\n│ │\n╰───────────────────────~/Projects/herdr (master)╯";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Idle);
- }
-
- #[test]
- fn amp_identified_by_process_name() {
- assert_eq!(identify_agent("amp"), Some(Agent::Amp));
- assert_eq!(identify_agent("amp-local"), Some(Agent::Amp));
- }
-
- // ---- Grok ----
-
- #[test]
- fn grok_blocked_on_permission_prompt() {
- let screen = "Show recent commit history for analysis\n\
- git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\
- Use ← → to choose permission whitelist scope\n\n\
- 1 (○) Always allow: git -C\n\
- 2 (●) Yes, proceed\n\
- 3 (○) No, reject (type to add feedback)\n\n\
- 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked);
- }
-
- #[test]
- fn grok_blocked_wins_over_spinner() {
- let screen = "⠹ Run git 30s\nUse ← → to choose permission whitelist scope\nYes, proceed\nNo, reject (type to add feedback)";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked);
- }
-
- #[test]
- fn grok_choice_words_without_scope_selector_are_not_blocked() {
- let screen = "Yes, proceed\nNo, reject (type to add feedback)";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle);
- }
-
- #[test]
- fn grok_working_on_waiting_spinner() {
- let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working);
- }
-
- #[test]
- fn grok_working_on_tool_spinner() {
- let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working);
- }
-
- #[test]
- fn grok_idle_after_turn_completed() {
- let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle);
- }
-
- // ---- Hermes ----
-
- #[test]
- fn hermes_identified_by_process_name() {
- assert_eq!(identify_agent("hermes"), Some(Agent::Hermes));
- assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes));
- }
-
- #[test]
- fn hermes_working_on_interrupt_footer() {
- let screen = " (⌐■_■) computing...\n\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏱ 3s\n─────────────────────────────────────────────────────────────────────────────────────────\n⚕ ❯ msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel";
- assert_eq!(
- detect_state(Some(Agent::Hermes), screen),
- AgentState::Working
- );
- }
-
- #[test]
- fn hermes_idle_ignores_stale_initializing_agent_text() {
- let screen = "● say exactly READY and stop\nInitializing agent...\n\n╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯";
- assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
- }
-
- #[test]
- fn hermes_blocked_on_dangerous_command_prompt() {
- let screen = "╭────────────────────────────────────────────────────────────╮\n│ ⚠️ Dangerous Command │\n│ mkdir -p /tmp/herdr-hermes-block-test/subdir && touch │\n│ ❯ 1. Allow once │\n│ 2. Allow for this session │\n│ 3. Add to permanent allowlist │\n│ 4. Deny │\n│ 5. Show full command │\n╰────────────────────────────────────────────────────────────╯\n ↑/↓ to select, Enter to confirm\n⚠ ❯";
- assert_eq!(
- detect_state(Some(Agent::Hermes), screen),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn hermes_idle_at_prompt_after_response() {
- let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯\n─────────────────────────────────────────────────────────────────────────────────────────";
- assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
- }
-
- #[test]
- fn hermes_denied_message_is_idle() {
- let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n Command was blocked/denied by the safety layer. I did not retry.\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.4K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏲ 11s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯";
- assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
- }
-
- // ---- Qodercli ----
-
- #[test]
- fn qodercli_identified_by_process_name() {
- assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli));
- assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli));
- assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli));
- assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli));
- }
-
- #[test]
- fn qodercli_blocked_on_confirmation() {
- assert_eq!(
- detect_qodercli("Waiting for user confirmation...\nAllow\nReject"),
- AgentState::Blocked,
- );
- }
-
- #[test]
- fn qodercli_waiting_confirmation_without_choices_is_idle() {
- assert_eq!(
- detect_qodercli("Waiting for user confirmation..."),
- AgentState::Idle,
- );
- }
-
- #[test]
- fn qodercli_working_on_spinner() {
- assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working);
- }
-
- #[test]
- fn qodercli_idle_on_prompt() {
- assert_eq!(detect_qodercli("> "), AgentState::Idle);
- }
-
- #[test]
- fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() {
- // A single stray braille character in a previous output line must not
- // flip the pane to Working — only an actual spinner *row* should.
- let screen = "\
-agent finished a previous task.\n\
-\u{280B}\n\
-> \n";
- assert_eq!(detect_qodercli(screen), AgentState::Idle);
- }
-
- #[test]
- fn qodercli_working_on_full_spinner_row() {
- // Real spinner row: braille glyph + space + alphabetic phrase.
- let screen = "\u{280B} Thinking...\n";
- assert_eq!(detect_qodercli(screen), AgentState::Working);
- }
-
- #[test]
- fn qodercli_working_on_esc_to_cancel_hint() {
- // The "(esc to cancel, …)" suffix is qodercli's explicit working
- // marker. It must trigger Working even if a hook icon replaced the
- // spinner glyph in this frame.
- let screen = "Thinking... (esc to cancel, 5s)\n";
- assert_eq!(detect_qodercli(screen), AgentState::Working);
- }
-
- #[test]
- fn qodercli_idle_when_text_mentions_working_in_prose() {
- // The previous heuristic treated the bare word "working" as Working,
- // which produced false positives for narrative output (commits, logs,
- // Markdown). The pane should remain Idle until a real working signal
- // appears.
- let screen = "\
-fix: keep working set warm across reloads\n\
-\n\
-> \n";
- assert_eq!(detect_qodercli(screen), AgentState::Idle);
- }
-
- #[test]
- fn qodercli_exit_hint_is_not_visible_blocker() {
- let screen = "\
-\u{280B} Thinking...\n\
-Press Ctrl+C again to exit.\n";
- let detection = detect_agent(Some(Agent::Qodercli), screen);
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn qodercli_esc_rewind_is_not_visible_blocker() {
- let screen = "Press Esc again to rewind.\n";
- let detection = detect_agent(Some(Agent::Qodercli), screen);
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn qodercli_blocked_on_permission_required_dialog() {
- // qodercli renders this dialog when a tool call needs user approval.
- let screen = "\
-Permission Required\n\
-Caller: test\n\
-Command: mkdir -p /root/example\n\
-Allow once or always?\n\
- \u{276F} 1. Allow Once - allow `mkdir` for one\n\
- 2. Always allow `mkdir` for future sessions\n\
- 3. Reject and tell qodercli something\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_permission_required_alone() {
- // Even when the prompt copy gets truncated by the viewport, the title
- // alone should be enough to flip the pane to blocked.
- assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,);
- }
-
- #[test]
- fn qodercli_blocked_on_askuser_enter_response_placeholder() {
- // qodercli's ask-user tool renders an input box with this placeholder
- // when waiting for the user to type a response.
- let screen = "\
-What kind of project are you working on?\n\
-> \n\
- Enter your response\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_askuser_review_tab() {
- // The multi-question/multi-select review tab heading is unique to the
- // ask-user dialog and means the agent is parked waiting on user input.
- let screen = "\
-Review your answers:\n\
-\n\
-Project type \u{2192} Web app\n\
-Stack \u{2192} (not answered)\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_interactive_shell_waiting() {
- // When qodercli spawns an interactive shell, the loading row turns
- // into a "Shell awaiting input" hint until the user takes focus.
- let screen = "! Shell awaiting input (Tab to focus)\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_askuser_single_choice_dialog() {
- // Single-select ask-user has no "Enter your response" placeholder and
- // no "Review your answers:" heading. The BaseTabDialog title
- // "Asking User" is the only stable signal across every ask-user form.
- let screen = "\
-Asking User\n\
-\n\
-Which framework should we use?\n\
- React\n\
- Vue\n\
- Svelte\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- // ---- Helpers ----
-
- #[test]
- fn content_above_prompt_box_extracts_correctly() {
- let screen = "line1\nline2\n──────\n❯ \n──────";
- let above = content_above_prompt_box(screen);
- assert!(above.contains("line1"));
- assert!(above.contains("line2"));
- assert!(!above.contains('❯'));
- }
-
- #[test]
- fn content_above_prompt_box_no_box() {
- let screen = "just some text\nno borders here";
- let above = content_above_prompt_box(screen);
- assert_eq!(above, screen);
- }
-
- #[test]
- fn spinner_activity_detected() {
- assert!(has_spinner_activity("· Thinking…"));
- assert!(has_spinner_activity("✽ Tempering…"));
- assert!(has_spinner_activity("✳ Simplifying recompute_tangents…"));
- assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace
- assert!(has_spinner_activity("✻ Pouncing…"));
- assert!(has_spinner_activity("✽ Processing…"));
- }
-
- #[test]
- fn spinner_activity_not_false_positive() {
- assert!(!has_spinner_activity("normal text"));
- assert!(!has_spinner_activity("✽ no ellipsis here"));
- assert!(!has_spinner_activity("✽ …"));
- assert!(!has_spinner_activity("some ✽ in the middle"));
- }
-
- #[test]
- fn cursor_spinner_detected() {
- assert!(has_cursor_spinner("⬡ Grepping.."));
- assert!(has_cursor_spinner("⬢ Reading…"));
- assert!(has_cursor_spinner("⠠⠜ Running 5.52k tokens"));
- assert!(has_cursor_spinner("⠞ Working 5.62k tokens"));
- assert!(has_cursor_spinner("⠛ Grepping 1.2k tokens"));
- assert!(has_cursor_spinner("⠛ Analyzing 1.2k tokens"));
- }
-
- #[test]
- fn cursor_spinner_not_false_positive() {
- assert!(!has_cursor_spinner("normal text"));
- assert!(!has_cursor_spinner("some ⬡ in middle"));
- assert!(!has_cursor_spinner("⠛ Read notes"));
- }
-
// ---- Process identification (real PTY) ----
#[cfg(target_os = "linux")]
@@ -3043,20 +1041,4 @@ Which framework should we use?\n\
// In CI/test environments without a terminal, tpgid is typically -1
let _ = tpgid;
}
-
- #[test]
- fn terminal_tail_content_works_with_detection() {
- assert_eq!(detect_pi("Working..."), AgentState::Working);
- }
-
- #[test]
- fn ansi_colored_content_still_detects_working() {
- assert_eq!(detect_pi("\x1b[31mWorking...\x1b[0m"), AgentState::Working);
- }
-
- #[test]
- fn visible_claude_prompt_box_is_idle() {
- let screen = "Task complete.\n─────────────\n❯ \n─────────────";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
}