fix(agents): correct Antigravity confirmation and input status detection

(cherry picked from commit 8252d8ccb5)
This commit is contained in:
bufan authored and kuddev committed 2026-09-10 19:26:25 +08:00
1 parent 3a1572b7f6
commit 7885244e7a
2 files changed
+269 -4

No files matched your search

@@ -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"] },
+242 -2
View File
@@ -382,6 +382,9 @@ struct Manifest {
aliases: Vec<String>,
#[serde(default)]
identity: Vec<Gate>,
/// Optional region wrapper for inherited rules; their original windows remain intact.
#[serde(default)]
shared_rule_region: Option<String>,
rules: Vec<Rule>,
}
@@ -590,6 +593,7 @@ pub fn detect(program: &str, screen: &str) -> Option<Detection> {
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::<Result<Vec<_>, _>>()?;
let identity = manifest.identity.iter().map(compile_gate).collect::<Result<Vec<_>, _>>()?;
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 ...",但此时已完成并显示空闲提示符,