fix(agents): correct Antigravity confirmation and input status detection
(cherry picked from commit 8252d8ccb5)
This commit is contained in:
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
@@ -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 ...",但此时已完成并显示空闲提示符,
|
||||
|
||||
Reference in new issue
Block a user