Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 90 additions & 9 deletions crates/noa-app/src/auto_approve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,12 @@ pub(crate) fn detect_and_update_any_agent(
) -> Decision {
let (decision, matched) = match suppression(ctx, state.disabled_by_runaway) {
Some(reason) => {
// Only the input-cooldown suppressions keep tracking the prompt
// Temporary input/viewport guards keep tracking the prompt
// (see `apply_decision_state`), so only they pay for a scan.
let matched = matches!(
reason,
SuppressReason::RecentUserInput
SuppressReason::ImePreedit
| SuppressReason::RecentUserInput
| SuppressReason::PasteActive
| SuppressReason::ViewportNotLive
)
Expand Down Expand Up @@ -507,15 +508,15 @@ fn apply_decision_state(
}
}
Decision::Suppressed(reason) => {
// A fast reply can become static during the input cooldown, and a
// prompt can land while the viewport is scrolled back. Keep
// rescanning that known prompt so it can arm when the guard expires
// or the user returns to the live rows (a wheel scroll produces no
// pty output to rescan on), while still requiring two unsuppressed
// matches before sending.
// A prompt can become static during composition, the input
// cooldown, or scrollback viewing. Clearing a preedit or scrolling
// back to the live rows need not produce any pty output. Keep the
// known prompt tracked, while requiring two unsuppressed matches
// before sending.
state.last_match = if matches!(
reason,
SuppressReason::RecentUserInput
SuppressReason::ImePreedit
| SuppressReason::RecentUserInput
| SuppressReason::PasteActive
| SuppressReason::ViewportNotLive
) {
Expand Down Expand Up @@ -1198,6 +1199,27 @@ mod tests {
])
}

fn agy_log_search_command_prompt() -> Vec<RowText> {
rows(&[
"────────────────────",
"",
"Requesting permission for:",
" rg -z -m 10 '\"errorCode\"' sample-webapi*.log.gz",
"",
"Run this command?",
"> 1. Yes, run command",
" 2. Yes, and always allow in this conversation for commands that start with 'rg'",
" 3. Yes, and always allow for commands that start with 'rg' (Persist to settings.json)",
" 4. No, cancel",
"",
" ↑/↓ Navigate · tab Amend · ctrl+g edit/expand command",
" TOOL USE | Gemini 3.8 Flash (High) | Context: 3.39%",
"",
"",
"",
])
}

fn agy_review_command_prompt() -> Vec<RowText> {
let mut prompt = agy_multiline_run_command_prompt();
let footer = prompt.len() - 2;
Expand Down Expand Up @@ -1257,6 +1279,12 @@ mod tests {
AgentKind::Agy,
"Command",
),
(
agy_log_search_command_prompt(),
AutoApproveSignature::AgyCommand,
AgentKind::Agy,
"Command",
),
] {
let mut state = AutoApproveState::default();
let mut ctx = base_ctx(now);
Expand Down Expand Up @@ -1745,6 +1773,54 @@ mod tests {
);
}

#[test]
fn static_menu_rearms_after_ime_preedit_without_new_output() {
let prompt = agy_log_search_command_prompt();
let mut ctx = base_ctx(fixed_now());
let mut state = AutoApproveState::default();
ctx.guards.ime_preedit_active = true;
for _ in 0..3 {
assert_eq!(
detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state),
Decision::Suppressed(SuppressReason::ImePreedit)
);
assert!(
state.needs_static_rescan(),
"a prompt arriving during IME composition must survive until composition ends"
);
}

ctx.guards.ime_preedit_active = false;
assert_eq!(
detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state),
Decision::Hold
);
let Decision::Fire {
signature,
region_hash,
..
} = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state)
else {
panic!("the unchanged prompt must fire after two unsuppressed scans");
};
assert_eq!(signature, AutoApproveSignature::AgyCommand);
assert_eq!(signature.bytes(), b"\r");
state.apply_feedback(signature, region_hash, true, ctx.now);
ctx.guards.ime_preedit_active = true;
let _ = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state);
assert!(!state.needs_static_rescan());
ctx.guards.ime_preedit_active = false;
assert_eq!(
detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state),
Decision::Hold
);

ctx.guards.ime_preedit_active = true;
let _ =
detect_and_update_any_agent(&rows(&["unrelated output"]), cursor(0), ctx, &mut state);
assert!(!state.needs_static_rescan());
}

#[test]
fn static_rescan_tracks_changed_prompts_after_an_accepted_approval() {
let now = fixed_now();
Expand Down Expand Up @@ -1795,6 +1871,11 @@ mod tests {
#[test]
fn detect_menu_from_vt_grid_with_hidden_cursor_and_split_utf8() {
for (prompt, expected, cols) in [
(
agy_log_search_command_prompt(),
AutoApproveSignature::AgyCommand,
94,
),
(
codex_command_prompt(),
AutoApproveSignature::CodexCommand,
Expand Down
20 changes: 18 additions & 2 deletions docs/specs/auto-approve-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@
- owner: simota
- build-path decision: **apex** (`/nexus apex` — live AC: T-1 signature capture and AC-11/12/13 GUI visual checks remain manual)

## 2026-09-14 investigation — agy approval remains pending

The supplied log-search dialog passes both text and VT/grid detection tests.
The user confirmed auto-approve was enabled before the dialog appeared; the
later observed Off menu does not establish its state at the time of the stall.
An independent reproduction found that a dialog arriving during IME composition
lost its rescan deadline. This is fixed and covered by a failing-before,
passing-after regression, but the original stall's cause remains unconfirmed
because the user cannot recall the composition state.

#TODO(agent): UNVERIFIED — On recurrence, launch Noa with
`NOA_AUTO_APPROVE_TRACE=1` and inspect only the `[auto-approve]` diagnostics to
identify the suppression or pre-send rejection; do not infer the cause from
the menu state observed afterward.

## 2026-09-09 extension — agy run-command wording

Also recognize `Requesting permission for:` followed by a displayed command,
Expand Down Expand Up @@ -66,8 +81,9 @@ approvals remain outside the signature table.
and every choice. Cost-only changes do not rearm an accepted question.
- Keep two stable scans, pre-send revalidation, IME/paste/recent-input guards,
audit/flash feedback, and the six-approvals-per-60-seconds breaker. A known
prompt that becomes static during the three-second input cooldown is rescanned
until the cooldown expires; it then needs two unsuppressed matches.
prompt that becomes static during IME composition or the three-second input
cooldown stays tracked until the guard clears; it then needs two unsuppressed
matches. Ending composition needs no further PTY output to resume detection.
- Synthetic layout tests cover both screenshots, incomplete/changed selections,
agent separation, UTF-8/ANSI terminal-grid decoding, duplicate suppression,
and cooldown recovery. The screenshots establish the Enter key binding;
Expand Down