diff --git a/nebula_app/src/agent_detection/antigravity.toml b/nebula_app/src/agent_detection/antigravity.toml index 5e9eff8..12a2146 100644 --- a/nebula_app/src/agent_detection/antigravity.toml +++ b/nebula_app/src/agent_detection/antigravity.toml @@ -1,12 +1,37 @@ # Screen-chrome evidence for this CLI (idle / working / blocked). id = "antigravity" aliases = ["agy", "antigravity-cli"] +# Wrap inherited regions without changing their original 1/10/12-line windows. +shared_rule_region = "after_last_prompt_row" +# Local input rules first select their window, then discard rows before the last +# empty prompt. Spinner/task rules intentionally retain evidence above the prompt. + +# Captured from agy 1.1.27 ask_question: choices and navigation footer +# coexist with "esc to cancel", which otherwise matches shared working. +[[rules]] +id = "question_selection" +state = "blocked" +priority = 900 +region = "after_last_prompt_row(bottom_non_empty_lines(12))" +line_regex = ['(?i)^\s*↑/↓\s+Navigate\b.*\benter\s+Select\b'] +# Only the current bottom working hint excludes quoted form chrome; older +# interrupt text in command/diff content must not hide a real approval. +not = [{ regex = ['(?im)^\s*(?:press\s+)?esc to interrupt[^\n]*(?:\n\s*)*\z'] }] + +# agy 1.1.27 creation/edit reviews, captured with request-review enabled. +[[rules]] +id = "file_review" +state = "blocked" +priority = 900 +region = "after_last_prompt_row(bottom_non_empty_lines(12))" +line_regex = ['(?i)^\s*↑/↓\s+Navigate\b.*\bf\s+full diff\b'] +not = [{ regex = ['(?im)^\s*(?:press\s+)?esc to interrupt[^\n]*(?:\n\s*)*\z'] }] [[rules]] id = "permission_prompt" state = "blocked" priority = 900 -region = "bottom_non_empty_lines(12)" +region = "after_last_prompt_row(bottom_non_empty_lines(12))" any = [ { contains = ["requesting approval"], any = [ { contains = ["[y]"] }, @@ -54,7 +79,7 @@ line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] id = "prompt_idle" state = "idle" priority = 100 -region = "bottom_non_empty_lines(6)" +region = "after_last_prompt_row(bottom_non_empty_lines(6))" any = [ { contains = ["shift+tab to cycle"] }, { contains = ["? for shortcuts"] }, diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index 1753e72..d867a9f 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -382,6 +382,9 @@ struct Manifest { aliases: Vec, #[serde(default)] identity: Vec, + /// Optional region wrapper for inherited rules; their original windows remain intact. + #[serde(default)] + shared_rule_region: Option, rules: Vec, } @@ -590,6 +593,7 @@ pub fn detect(program: &str, screen: &str) -> Option { if !gate.matches(text) { continue; } + // Equal priorities keep the first declared match (local rules precede shared rules). if best.is_none_or(|(previous, _)| rule.priority > previous.priority) { best = Some((rule, gate)); } @@ -630,11 +634,37 @@ fn compile_manifest( // 共享骨架并入每一份 manifest——bundled 与用户 override 一视同仁,装了 // 新 CLI 或改了本地规则都自动带上中断提示判据。理由见 _shared.toml 顶部: // per-agent 的 working 规则各写各的,同一个 AND 陷阱在多份文件里重复出现。 - manifest.rules.extend(shared_rules().iter().cloned()); + if manifest.shared_rule_region.as_deref().is_some_and(|value| value != "after_last_prompt_row") + { + return Err("unsupported shared_rule_region wrapper".to_owned()); + } + for mut rule in shared_rules().iter().cloned() { + if let Some(wrapper) = &manifest.shared_rule_region { + rule.region = format!("{wrapper}({})", rule.region); + } + manifest.rules.push(rule); + } let rules = manifest .rules .iter() - .map(|rule| compile_gate(&rule.gate)) + .map(|rule| { + // Validate only the newly introduced wrapper; legacy regions retain + // their existing loading behavior. + if rule.region.starts_with("after_last_prompt_row") { + let inner = rule + .region + .strip_prefix("after_last_prompt_row(") + .and_then(|value| value.strip_suffix(')')); + if !inner.is_some_and(|value| { + value == "whole_recent" + || value == "after_last_horizontal_rule" + || region_count(value, "bottom_non_empty_lines").is_some_and(|n| n > 0) + }) { + return Err(format!("invalid prompt region: {}", rule.region)); + } + } + compile_gate(&rule.gate) + }) .collect::, _>>()?; let identity = manifest.identity.iter().map(compile_gate).collect::, _>>()?; Ok(CompiledManifest { manifest, identity, rules, override_mtime }) @@ -684,6 +714,40 @@ fn manifest_matches(manifest: &Manifest, agent: AgentKind) -> bool { || manifest.aliases.iter().any(|alias| agent.aliases().contains(&alias.as_str())) } +/// Keep the latest prompt row and its footer, as captured in agy 1.1.27. +/// Empty prompts may have a box. Drafts require horizontal rules immediately +/// above and below; an arbitrary quoted or selected `> text` is not a boundary. +/// Without an identifiable boundary, preserve the supplied window so real approvals +/// are not silently discarded. This deliberately does not guess arbitrary input text. +fn from_last_empty_prompt(screen: &str) -> &str { + let mut offset = 0; + let mut start = 0; + let mut lines = screen.split_inclusive('\n').peekable(); + let mut previous = ""; + while let Some(line) = lines.next() { + let row = line.trim(); + let row = row.strip_prefix('│').and_then(|row| row.strip_suffix('│')).unwrap_or(row).trim(); + let draft = row.strip_prefix(['›', '❯', '>']).is_some_and(|rest| { + rest.starts_with(char::is_whitespace) + && !rest.trim_start().starts_with(|c: char| c.is_ascii_digit() || c == '[') + }); + let horizontal_rule = |line: &str| { + let row = line.trim(); + row.chars().count() >= 3 && row.chars().all(|c| c == '─') + }; + if matches!(row, "›" | "❯" | ">") + || (draft + && horizontal_rule(previous) + && lines.peek().is_some_and(|next| horizontal_rule(next))) + { + start = offset; + } + previous = line; + offset += line.len(); + } + &screen[start..] +} + fn whole_recent() -> String { "whole_recent".to_owned() } @@ -692,6 +756,11 @@ fn region<'a>(screen: &'a str, spec: &str) -> &'a str { if spec == "whole_recent" { return screen; } + if let Some(inner) = + spec.strip_prefix("after_last_prompt_row(").and_then(|s| s.strip_suffix(')')) + { + return from_last_empty_prompt(region(screen, inner)); + } if let Some(count) = region_count(spec, "bottom_non_empty_lines") { let lines: Vec<&str> = screen.lines().collect(); let Some(start) = lines @@ -978,6 +1047,177 @@ mod tests { assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id); } + #[test] + fn antigravity_previous_confirmation_does_not_override_current_prompt() { + for phrase in [ + "The tool requires approval before running (y/n).", + "Use enter to confirm or tab to amend the command.", + "Do you want to proceed?", + "waiting for permission", + "requesting permission for: cargo test\n[y] Yes\nesc to cancel · enter to confirm", + ] { + for (activity, footer, expected) in [ + ("", "? for shortcuts", AgentStatus::Idle), + ("", "esc to interrupt", AgentStatus::Working), + ("⠋ Thinking... (8s)\n", "esc to interrupt", AgentStatus::Working), + ] { + let screen = format!("{phrase}\n{activity}────────\n› \n────────\n{footer}"); + let detection = detect("antigravity-cli", &screen).unwrap(); + assert_eq!(detection.status, expected, "{screen}: rule={}", detection.rule_id); + } + } + } + + #[test] + fn antigravity_working_footer_excludes_quoted_form_hints() { + for footer in + ["↑/↓ Navigate · enter Select · esc Skip", "↑/↓ Navigate · tab Amend · f full diff"] + { + let working = format!( + "The footer reads:\n{footer}\nand that means a choice is pending.\n⠋ Thinking... (2s)\nesc to interrupt" + ); + assert_eq!(detect("agy", &working).unwrap().status, AgentStatus::Working); + let approval = format!("Previous output: esc to interrupt\n{footer}\nesc to cancel"); + assert_eq!(detect("agy", &approval).unwrap().status, AgentStatus::Blocked); + } + } + + #[test] + fn prompt_drafts_need_input_box_context() { + for row in ["> fix the tests please", "│ › fix the tests │", "› Ask anything…"] { + let screen = + format!("Do you want to proceed?\n────────\n{row}\n────────\n? for shortcuts"); + assert_eq!(detect("agy", &screen).unwrap().status, AgentStatus::Idle); + } + for row in + ["> Yes, I trust this folder", "> Allow once", "> [ ] 中文", "> 1) Yes", "> 1. Yes"] + { + let screen = format!("Do you want to proceed?\n{row}\nesc to cancel"); + assert_eq!(from_last_empty_prompt(&screen), screen); + } + } + + #[test] + fn malformed_prompt_regions_are_rejected_at_load() { + for region in [ + "after_last_prompt_row()", + "after_last_prompt_row(", + "after_last_prompt_row(unknown)", + "after_last_prompt_row(bottom_non_empty_lines(0))", + ] { + let source = format!( + "id = 'custom'\n[[rules]]\nid = 'test'\nstate = 'idle'\nregion = '{region}'\ncontains = ['ready']" + ); + assert!(compile_manifest(&source, None).unwrap_err().contains("invalid prompt region")); + } + } + + #[test] + fn prompt_regions_are_explicit_and_preserve_unknown_input() { + let screen = "Do you want to proceed?\n│ › │\n? for shortcuts"; + assert_eq!( + region(screen, "after_last_prompt_row(bottom_non_empty_lines(12))"), + "│ › │\n? for shortcuts" + ); + for row in ["› Ask anything…", "> 1. Yes", "› unfinished draft"] { + let screen = format!("Do you want to proceed?\n{row}"); + assert_eq!(from_last_empty_prompt(&screen), screen); + } + let source = "id = 'custom'\nshared_rule_region = 'after_last_prompt_row'\n\ + [[rules]]\nid = 'shared_custom'\nstate = 'blocked'\n\ + region = 'whole_recent'\ncontains = ['approval']"; + let compiled = compile_manifest(source, None).unwrap(); + assert_eq!(compiled.manifest.rules[0].region, "whole_recent"); + assert_eq!( + compiled.manifest.rules[1].region, + "after_last_prompt_row(bottom_non_empty_lines(1))" + ); + assert!( + compile_manifest(&source.replace("after_last_prompt_row", "unknown"), None).is_err() + ); + } + + #[test] + fn antigravity_long_forms_and_extended_footers_still_block() { + for (footer, rule) in [ + ("↑/↓ Navigate · enter Select · esc Skip", "question_selection"), + ("↑/↓ Navigate · tab Amend · f full diff", "file_review"), + ] { + let screen = format!( + "Question 1/1: Choose\n{}\n{footer} · ctrl+g expand\nesc to cancel", + "long wrapped option\n".repeat(20) + ); + let found = detect("agy", &screen).unwrap(); + assert_eq!(found.status, AgentStatus::Blocked); + assert_eq!(found.rule_id, rule); + // Even an exact standalone footer quoted in an answer is history + // once the current input prompt is visible. + for prompt in [">", "│ › │"] { + let answered = format!("{screen}\n{prompt}\n? for shortcuts"); + assert_eq!(detect("agy", &answered).unwrap().status, AgentStatus::Idle); + } + } + } + + #[test] + fn antigravity_real_command_permission_requires_attention() { + let screen = "Command\n────────\nRequesting permission for:\n\ + cmd /c echo agy-command-probe\nDo you want to proceed?\n\ + > 1. Yes\n\ + 2. Yes, and always allow in this conversation for commands that start with 'cmd'\n\ + 3. Yes, and always allow for commands that start with 'cmd' (Persist to settings.json)\n\ + 4. No\n↑/↓ Navigate · tab Amend · ctrl+g edit/expand command\n\ + esc to cancel Gemini 3.8 Flash · high"; + assert_eq!(detect("agy", screen).unwrap().status, AgentStatus::Blocked); + } + + #[test] + fn antigravity_real_file_creation_review_requires_attention() { + let review = "Create file\n────────\nagy-permission-check.txt +1\n\ + 1 + permission probe\nAllow creation of this file?\n\ + > 1. Yes, allow creation\n 2. No, deny creation\n\ + ↑/↓ Navigate · tab Amend · f full diff\n\ + esc to cancel Gemini 3.8 Flash · high"; + let detection = detect("agy", review).unwrap(); + assert_eq!(detection.status, AgentStatus::Blocked); + assert_eq!(detection.rule_id, "file_review"); + let edit = review + .replace("Allow creation of this file?", "Accept this file edit?") + .replace("Yes, allow creation", "Yes, accept this change") + .replace("No, deny creation", "No, reject this change"); + assert_eq!(detect("agy", &edit).unwrap().status, AgentStatus::Blocked); + let accepted = format!("{review}\nCreated file.\n────────\n>\n────────\n? for shortcuts"); + assert_eq!(detect("agy", &accepted).unwrap().status, AgentStatus::Idle); + let automatic = "Created agy-permission-check.txt\n────────\n>\n────────\n? for shortcuts"; + assert_eq!(detect("agy", automatic).unwrap().status, AgentStatus::Idle); + } + + #[test] + fn antigravity_real_ask_question_overrides_cancel_working_hint() { + // Captured from agy 1.1.27 in a Windows ConPTY, before choosing Chinese. + let question = "Question\n────────\n\ + Question 1/1: 请选择接下来交流使用的语言 / Please select the language to use:\n\ + > 1. 中文\n 2. English\n 3. Write-in...\n\ + ↑/↓ Navigate · enter Select · esc Skip\n\ + esc to cancel Gemini 3.8 Flash · high"; + let detection = detect("agy", question).unwrap(); + assert_eq!(detection.status, AgentStatus::Blocked); + assert_eq!(detection.rule_id, "question_selection"); + + let answered = format!("{question}\n已选择中文。\n────────\n>\n────────\n? for shortcuts"); + assert_eq!(detect("agy", &answered).unwrap().status, AgentStatus::Idle); + let prose = "The shortcuts are ↑/↓ Navigate · enter Select · esc Skip\n>\n? for shortcuts"; + assert_eq!(detect("agy", prose).unwrap().status, AgentStatus::Idle); + } + + #[test] + fn antigravity_active_confirmation_after_prompt_still_blocks() { + let screen = "› \nrequesting permission for: cargo test\n› 1. Yes, allow\n\ + 2. No\nesc to cancel · enter to confirm"; + let detection = detect("antigravity-cli", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id); + } + #[test] fn antigravity_prose_mentioning_approval_while_idle_reads_idle() { // 回答正文中提及 "needs approval for ...",但此时已完成并显示空闲提示符,