From b778095052d80788bc8d0791ab14269664e6ed79 Mon Sep 17 00:00:00 2001 From: simota Date: Sat, 5 Sep 2026 15:43:55 +0900 Subject: [PATCH 1/2] feat(app): support additional agent approval dialogs Prevent supported command and question menus from stalling opted-in agent sessions. Confirm the selected one-time approval or recommended answer with Enter, and rescan static prompts after input cooldowns. Validate with workspace tests, workspace build, formatting checks, and synthetic terminal-grid fixtures. Live CLI acceptance remains unverified. --- crates/noa-app/src/auto_approve.rs | 585 ++++++++++++++++++++++++++++- docs/specs/auto-approve-mode.md | 35 ++ 2 files changed, 613 insertions(+), 7 deletions(-) diff --git a/crates/noa-app/src/auto_approve.rs b/crates/noa-app/src/auto_approve.rs index d2a6f0f2..6d1400c2 100644 --- a/crates/noa-app/src/auto_approve.rs +++ b/crates/noa-app/src/auto_approve.rs @@ -21,6 +21,7 @@ pub(crate) enum PromptKind { Edit, Write, Read, + Command, AskUserQuestion, EnterConfirm, } @@ -31,6 +32,7 @@ impl PromptKind { Self::Edit => "Edit", Self::Write => "Write", Self::Read => "Read", + Self::Command => "Command", Self::AskUserQuestion => "Question", Self::EnterConfirm => "Enter", } @@ -44,6 +46,8 @@ pub enum AutoApproveSignature { ClaudeRead, ClaudeAskUserQuestion, ClaudeEnterConfirm, + CodexCommand, + AgyAskUserQuestion, } impl AutoApproveSignature { @@ -121,6 +125,24 @@ const SIGNATURES: &[Signature] = &[ requires_marker: false, bytes: b"\r", }, + Signature { + id: AutoApproveSignature::CodexCommand, + agent: AgentKind::Codex, + kind: PromptKind::Command, + anchors: &["would you like to run the following command?"], + yes_label: Some("1. Yes, proceed (y)"), + requires_marker: true, + bytes: b"\r", + }, + Signature { + id: AutoApproveSignature::AgyAskUserQuestion, + agent: AgentKind::Agy, + kind: PromptKind::AskUserQuestion, + anchors: &["question"], + yes_label: Some("1. (Recommended) "), + requires_marker: true, + bytes: b"\r", + }, ]; fn signature(id: AutoApproveSignature) -> &'static Signature { @@ -196,9 +218,11 @@ impl AutoApproveState { pub(crate) fn needs_static_rescan(&self) -> bool { !self.disabled_by_runaway && self.pending_fire.is_none() - && self.awaiting_change.is_none() - && self.last_match.is_some() - && self.match_count >= 1 + && self.last_match.is_some_and(|key| { + self.awaiting_change.is_none_or(|consumed| { + consumed.signature != key.signature || consumed.region_hash != key.region_hash + }) + }) } pub(crate) fn apply_feedback( @@ -389,8 +413,8 @@ fn apply_decision_state( state.pending_fire = None; } if state.awaiting_change.is_some_and(|consumed| { - consumed.signature == matched.signature - && consumed.region_hash != matched.region_hash + consumed.signature != matched.signature + || consumed.region_hash != matched.region_hash }) { state.awaiting_change = None; } @@ -411,8 +435,21 @@ fn apply_decision_state( state.awaiting_change = None; } } - Decision::Suppressed(_) => { - state.last_match = None; + Decision::Suppressed(reason) => { + // A fast reply can become static during the input cooldown. Keep + // rescanning that known prompt so it can arm when the guard expires, + // while still requiring two unsuppressed matches before sending. + state.last_match = if matches!( + reason, + SuppressReason::RecentUserInput | SuppressReason::PasteActive + ) { + find_prompt(rows, cursor, None).map(|matched| MatchKey { + signature: matched.signature, + region_hash: matched.region_hash, + }) + } else { + None + }; state.match_count = 0; state.pending_fire = None; } @@ -467,6 +504,18 @@ fn find_signature_with_lowercase( return None; } + if matches!( + sig.id, + AutoApproveSignature::CodexCommand | AutoApproveSignature::AgyAskUserQuestion + ) { + let region = menu_prompt_region(rows, lowercase_rows, sig)?; + return Some(MatchedPrompt { + signature: sig.id, + region_hash: region_hash(rows, region.clone()), + region, + }); + } + let anchor_index = lowercase_rows .iter() .position(|row| sig.anchors.iter().any(|anchor| row.contains(anchor)))?; @@ -498,10 +547,139 @@ fn find_signature_with_lowercase( }) } +/// These TUIs select with a painted marker and can park the terminal cursor +/// below the menu (or hide it). Require the complete dialog at the live tail +/// instead of treating the terminal cursor as the selected option. +fn menu_prompt_region( + rows: &[RowText], + lowercase_rows: &[RowText], + sig: &Signature, +) -> Option> { + let anchor = lowercase_rows + .iter() + .rposition(|row| sig.anchors.contains(&row.trim()))?; + let footer_text = match sig.id { + AutoApproveSignature::CodexCommand => "Press enter to confirm or esc to cancel", + AutoApproveSignature::AgyAskUserQuestion => "↑/↓ Navigate · enter Select · esc Skip", + _ => return None, + }; + let footer = (anchor + 1..rows.len()) + .find(|&i| rows[i].split_whitespace().collect::>().join(" ") == footer_text)?; + let mut tail = rows[footer + 1..] + .iter() + .map(|row| row.trim()) + .filter(|row| !row.is_empty()); + if let Some(status) = tail.next() + && (sig.id != AutoApproveSignature::AgyAskUserQuestion + || !status.starts_with('[') + || !status.split_once("] Cost: $").is_some_and(|(_, cost)| { + !cost.is_empty() && cost.bytes().all(|ch| ch.is_ascii_digit() || ch == b'.') + }) + || tail.next().is_some()) + { + return None; + } + + let mut selected = + (anchor + 1..footer).filter_map(|i| selected_option(&rows[i]).map(|label| (i, label))); + let (option, label) = selected.next()?; + if selected.next().is_some() { + return None; + } + let expected = sig.yes_label?; + let valid = match sig.id { + AutoApproveSignature::CodexCommand => { + label == expected && codex_command_menu(rows, anchor, option, footer) + } + AutoApproveSignature::AgyAskUserQuestion => { + label + .strip_prefix(expected) + .is_some_and(|text| !text.trim().is_empty()) + && agy_question_menu(rows, anchor, option, footer) + } + _ => false, + }; + valid.then_some(anchor..=footer) +} + +fn codex_command_menu(rows: &[RowText], anchor: usize, option: usize, footer: usize) -> bool { + let context = &rows[anchor + 1..option]; + if !context.iter().any(|row| row.trim() == "Environment: local") + || !context.iter().any(|row| { + row.trim() + .strip_prefix("$ ") + .is_some_and(|command| !command.trim().is_empty()) + }) + { + return false; + } + let options: Vec<_> = rows[option + 1..footer] + .iter() + .map(|row| row.trim()) + .filter(|row| !row.is_empty()) + .collect(); + match options.split_last() { + Some((&"2. No, and tell Codex what to do differently (esc)", [])) => true, + Some((&"3. No, and tell Codex what to do differently (esc)", remember)) => { + let remember = remember.join(" "); + remember.starts_with("2. Yes, and don't ask again for commands that start with ") + && remember.ends_with("(p)") + } + _ => false, + } +} + +fn agy_question_menu(rows: &[RowText], anchor: usize, option: usize, footer: usize) -> bool { + let has_question = rows[anchor + 1..option].iter().any(|row| { + let Some((counter, question)) = row + .trim() + .strip_prefix("Question ") + .and_then(|text| text.split_once(':')) + else { + return false; + }; + let Some((number, total)) = counter.split_once('/') else { + return false; + }; + matches!((number.parse::(), total.parse::()), (Ok(n), Ok(t)) if n > 0 && n <= t) + && !question.trim().is_empty() + }); + if !has_question { + return false; + } + let mut next = 2; + let mut write_in = false; + for row in &rows[option + 1..footer] { + let row = row.trim(); + if row.is_empty() { + continue; + } + if write_in { + return false; + } + if let Some((number, text)) = row.split_once(". ") + && let Ok(number) = number.parse::() + { + if number != next || text.is_empty() { + return false; + } + next += 1; + write_in = text == "Write-in..."; + } + } + write_in +} + fn lowercase_rows(rows: &[RowText]) -> Vec { rows.iter().map(|row| row.to_ascii_lowercase()).collect() } +fn selected_option(row: &str) -> Option<&str> { + row.trim_start() + .strip_prefix(['❯', '›', '>']) + .map(str::trim_start) +} + fn affirmative_selected(row: &str, yes_label: &str, requires_marker: bool) -> bool { let trimmed = row.trim_start(); let selected = trimmed @@ -574,6 +752,399 @@ mod tests { input.iter().map(|line| (*line).to_string()).collect() } + // Synthetic content with the layouts supplied in the September 2026 + // screenshots; no local terminal transcript or command output is stored. + fn codex_command_prompt() -> Vec { + rows(&[ + "Would you like to run the following command?", + "", + "Environment: local", + "", + "Reason: 変更をステージしてよいですか?", + "", + "$ git add sample.rs", + "", + "› 1. Yes, proceed (y)", + " 2. Yes, and don't ask again for commands that start with `git add` (p)", + " 3. No, and tell Codex what to do differently (esc)", + "", + "Press enter to confirm or esc to cancel", + ]) + } + + fn agy_question_prompt() -> Vec { + rows(&[ + "Question", + "────────────────────", + "", + "Question 1/1: どの作業を進めますか?", + "", + "> 1. (Recommended) テストを実行する", + " 2. 静的解析を実行する", + " 3. 変更内容を確認する", + " 4. ドキュメントを読む", + " 5. Write-in...", + "", + " ↑/↓ Navigate · enter Select · esc Skip", + "[Gemini 3.8 Flash (High)] Cost: $0.0000", + ]) + } + + fn assert_no_auto_approval(prompt: &[RowText]) { + let mut state = AutoApproveState::default(); + let ctx = base_ctx(fixed_now()); + for _ in 0..3 { + assert_eq!( + detect_and_update_any_agent(prompt, cursor(0), ctx, &mut state), + Decision::Hold, + "must not approve {prompt:?}" + ); + } + } + + #[test] + fn detect_codex_and_agy_screenshot_layouts_confirm_only_once_with_enter() { + let now = fixed_now(); + for (prompt, expected, agent, label) in [ + ( + codex_command_prompt(), + AutoApproveSignature::CodexCommand, + AgentKind::Codex, + "Command", + ), + ( + agy_question_prompt(), + AutoApproveSignature::AgyAskUserQuestion, + AgentKind::Agy, + "Question", + ), + ] { + let mut state = AutoApproveState::default(); + let mut ctx = base_ctx(now); + ctx.alt_screen = false; + let cursor = cursor((prompt.len() - 1) as u16); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor, ctx, &mut state), + Decision::Hold + ); + assert!(state.needs_static_rescan()); + let Decision::Fire { + signature, + region_hash, + disable_after, + } = detect_and_update_any_agent(&prompt, cursor, ctx, &mut state) + else { + panic!("stable menu should fire"); + }; + assert_eq!(signature, expected); + assert_eq!(signature.agent(), agent); + assert_eq!(signature.bytes(), b"\r"); + assert_eq!(signature.label(), label); + assert!(!disable_after); + assert_eq!( + rescan_signature(&prompt, signature, cursor, ctx) + .unwrap() + .region_hash, + region_hash + ); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor, ctx, &mut state), + Decision::Hold + ); + state.apply_feedback(signature, region_hash, true, now); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor, ctx, &mut state), + Decision::Hold + ); + assert!(!state.needs_static_rescan()); + } + } + + #[test] + fn detect_menu_requires_complete_context_and_selected_safe_choice() { + for (prompt, mutations) in [ + ( + codex_command_prompt(), + vec![ + (0, "Would you like to do something else?"), + (2, "Environment: remote"), + (6, "$ "), + (8, " 1. Yes, proceed (y)"), + (8, "› 1. Yes, proceed (y), and remember this choice"), + (8, "› 2. Yes, and don't ask again (p)"), + ( + 9, + "› 2. Yes, and don't ask again for commands that start with `git add` (p)", + ), + (10, " 3. Yes, approve everything"), + (12, "Press enter to confirm"), + ], + ), + ( + agy_question_prompt(), + vec![ + (0, "Not a question dialog"), + (3, "Question 0/1: Choose a task"), + (3, "Question 2/1: Choose a task"), + (3, "Question 1/1:"), + (5, " 1. (Recommended) テストを実行する"), + (5, "> 1. テストを実行する"), + (5, "> 2. (Recommended) テストを実行する"), + (6, "> 2. 静的解析を実行する"), + (9, " 5. Delete everything"), + (11, "space Toggle · enter Submit"), + (12, "$ another command"), + ], + ), + ] { + for (index, replacement) in mutations { + let mut changed = prompt.clone(); + changed[index] = replacement.to_string(); + assert_no_auto_approval(&changed); + } + // Earlier output cannot be mistaken for an active dialog. + let mut stale = prompt.clone(); + stale.push("The task is complete. Type another request.".to_string()); + assert_no_auto_approval(&stale); + for prefix_len in 0..prompt.len() - 1 { + assert_no_auto_approval(&prompt[..prefix_len]); + } + } + } + + #[test] + fn detect_menu_supports_wrapped_choices_and_no_remember_option() { + let mut codex = codex_command_prompt(); + codex.remove(9); + codex[9] = " 2. No, and tell Codex what to do differently (esc)".to_string(); + let mut agy = agy_question_prompt(); + agy.insert( + 6, + " with additional details on the next row".to_string(), + ); + agy[3] = "Question 2/3: 次の作業は?".to_string(); + for prompt in [codex, agy] { + let mut state = AutoApproveState::default(); + let ctx = base_ctx(fixed_now()); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Hold + ); + assert!(matches!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Fire { .. } + )); + } + } + + #[test] + fn menu_signatures_remain_bound_to_their_agent_and_input_guards() { + let now = fixed_now(); + for (prompt, agent) in [ + (codex_command_prompt(), AgentKind::Codex), + (agy_question_prompt(), AgentKind::Agy), + ] { + let mut state = AutoApproveState::default(); + let ctx = base_ctx(now); + let _ = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state); + assert!(matches!( + detect(&prompt, cursor(0), agent, ctx, &state), + Decision::Fire { .. } + )); + for other in [ + AgentKind::ClaudeCode, + AgentKind::Codex, + AgentKind::Agy, + AgentKind::Generic, + ] { + if other != agent { + assert!(!matches!( + detect(&prompt, cursor(0), other, ctx, &state), + Decision::Fire { .. } + )); + } + } + let mut blocked = ctx; + blocked.alt_screen = false; + blocked.scrollback_offset = 1; + assert_eq!( + detect(&prompt, cursor(0), agent, blocked, &state), + Decision::Suppressed(SuppressReason::ViewportNotLive) + ); + blocked = ctx; + blocked.guards.ime_preedit_active = true; + assert_eq!( + detect(&prompt, cursor(0), agent, blocked, &state), + Decision::Suppressed(SuppressReason::ImePreedit) + ); + } + } + + #[test] + fn menu_hash_covers_command_question_and_all_choices_but_not_cost() { + let now = fixed_now(); + let ctx = base_ctx(now); + for (mut prompt, change_row) in [(codex_command_prompt(), 6), (agy_question_prompt(), 8)] { + let mut state = AutoApproveState::default(); + let _ = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state); + let Decision::Fire { + signature, + region_hash, + .. + } = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state) + else { + panic!("stable menu should fire"); + }; + state.apply_feedback(signature, region_hash, true, now); + if signature == AutoApproveSignature::AgyAskUserQuestion { + prompt[12] = "[Gemini 3.8 Flash (High)] Cost: $0.0100".to_string(); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Hold + ); + } + prompt[change_row].push_str(" --changed"); + assert_ne!( + rescan_signature(&prompt, signature, cursor(0), ctx) + .unwrap() + .region_hash, + region_hash + ); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Hold + ); + assert!(matches!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Fire { .. } + )); + let selected = prompt + .iter_mut() + .find(|row| selected_option(row).is_some()) + .unwrap(); + *selected = selected_option(selected).unwrap().to_string(); + assert!(rescan_signature(&prompt, signature, cursor(0), ctx).is_none()); + } + } + + #[test] + fn static_menu_rearms_after_input_cooldown_without_new_output() { + let now = fixed_now(); + for prompt in [codex_command_prompt(), agy_question_prompt()] { + for paste in [false, true] { + let mut state = AutoApproveState::default(); + let mut ctx = base_ctx(now); + if paste { + ctx.guards.mark_paste(now); + } else { + ctx.guards.mark_user_input(now); + } + assert!(matches!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Suppressed(_) + )); + assert!(state.needs_static_rescan()); + ctx.now += USER_INPUT_SUPPRESSION; + assert_eq!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Hold + ); + assert!(matches!( + detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state), + Decision::Fire { .. } + )); + } + } + let mut state = AutoApproveState::default(); + let mut ctx = base_ctx(now); + ctx.guards.mark_user_input(now); + let _ = detect_and_update_any_agent(&codex_command_prompt(), cursor(0), ctx, &mut state); + 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(); + let mut state = AutoApproveState::default(); + let mut ctx = base_ctx(now); + let prompt = codex_command_prompt(); + let _ = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state); + let Decision::Fire { + signature, + region_hash, + .. + } = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state) + else { + panic!("stable menu should fire"); + }; + state.apply_feedback(signature, region_hash, true, now); + ctx.guards.mark_user_input(now); + let _ = detect_and_update_any_agent(&prompt, cursor(0), ctx, &mut state); + assert!( + !state.needs_static_rescan(), + "an unchanged consumed dialog must stay idle" + ); + let mut changed = prompt; + changed[6] = "$ git diff --cached".to_string(); + let _ = detect_and_update_any_agent(&changed, cursor(0), ctx, &mut state); + assert!( + state.needs_static_rescan(), + "a new dialog must survive the cooldown" + ); + ctx.now += USER_INPUT_SUPPRESSION; + let _ = detect_and_update_any_agent(&changed, cursor(0), ctx, &mut state); + let Decision::Fire { + signature, + region_hash, + .. + } = detect_and_update_any_agent(&changed, cursor(0), ctx, &mut state) + else { + panic!("new command should fire"); + }; + state.apply_feedback(signature, region_hash, true, ctx.now); + let _ = detect_and_update_any_agent(&agy_question_prompt(), cursor(0), ctx, &mut state); + assert!( + state.needs_static_rescan(), + "changing signature also advances the screen" + ); + } + + #[test] + fn detect_menu_from_vt_grid_with_hidden_cursor_and_split_utf8() { + for (prompt, expected) in [ + (codex_command_prompt(), AutoApproveSignature::CodexCommand), + ( + agy_question_prompt(), + AutoApproveSignature::AgyAskUserQuestion, + ), + ] { + let mut terminal = Terminal::new(noa_core::GridSize::new(140, 24)); + let mut stream = noa_vt::Stream::new(); + let frame = format!("\x1b[?25l\x1b[36m{}\x1b[0m\r\n", prompt.join("\r\n")); + for chunk in frame.as_bytes().chunks(7) { + stream.feed(chunk, &mut terminal); + } + let screen = viewport_rows_from_terminal(&terminal); + let cursor = terminal.active().cursor; + assert!(!cursor.visible); + let cursor = Point { + x: cursor.x, + y: cursor.y, + }; + let mut state = AutoApproveState::default(); + let ctx = base_ctx(fixed_now()); + assert_eq!( + detect_and_update_any_agent(&screen, cursor, ctx, &mut state), + Decision::Hold + ); + assert!( + matches!(detect_and_update_any_agent(&screen, cursor, ctx, &mut state), Decision::Fire { signature, .. } if signature == expected) + ); + } + } + #[test] fn detect_holds_for_generic_agent_even_with_known_signature() { let now = fixed_now(); diff --git a/docs/specs/auto-approve-mode.md b/docs/specs/auto-approve-mode.md index 4668d1fb..a259faea 100644 --- a/docs/specs/auto-approve-mode.md +++ b/docs/specs/auto-approve-mode.md @@ -5,6 +5,41 @@ - 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-05 extension — Codex commands and agy questions + +The user supplied screenshots of both dialogs and requested support. This +extension supersedes the v1 exclusions below **for Codex command execution** +and adds agy question selection. Other unknown dialogs and Claude Bash +approvals remain outside the signature table. + +| Agent | Recognized dialog | Automatic response | +|---|---|---| +| Codex | `Would you like to run the following command?`, `Environment: local`, a displayed `$` command, selected `1. Yes, proceed (y)`, the known rejection/optional remember choices, and `Press enter to confirm or esc to cancel` | Enter (`\r`), accepting this execution only | +| agy | `Question`, a valid `Question n/total:` heading, selected `1. (Recommended)` with a nonempty answer, sequential remaining choices ending in `Write-in...`, and `↑/↓ Navigate · enter Select · esc Skip` | Enter (`\r`), answering with the first recommended choice | + +- Enable with **Toggle Auto Approve** for the current tab, or seed new tabs + with `auto-approve = true` (default remains `false`). The setting applies to + the tab's local panes and uses the existing foreground-agent check. +- Codex command content is **not** filtered by an allowlist or denylist. Turning + the mode on opts into execution of commands shown in this recognized dialog. + The persistent "don't ask again" choice is never selected. +- agy support answers workflow questions, not just permission requests. If the + first choice is not recommended and selected, it waits for the user. +- These menus use a painted selection marker; the terminal cursor may be hidden + or parked elsewhere. Require a complete dialog at the live tail instead of + requiring the terminal cursor to occupy the selected row. Trailing output + invalidates the match, except for agy's model/cost status line and blank rows. +- Hash the whole dialog through its footer, including the command, question, + 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. +- 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; + acceptance by a running CLI still requires the live AC-13 check. + ## L0 — Vision - **Problem:** When running Claude Code / Codex / agy inside a noa tab, every tool execution stops at an approval prompt (y/n, numbered menu, Enter confirmation), and the agent keeps waiting until the user comes back to the terminal and types. With multiple tabs running in parallel, waiting for approvals dominates throughput. From 2ac9c6400c116e3576a1542b7582214bc293f518 Mon Sep 17 00:00:00 2001 From: simota Date: Sat, 5 Sep 2026 16:05:41 +0900 Subject: [PATCH 2/2] fix(app): prevent duplicate and missed dialog approvals --- crates/noa-app/src/auto_approve.rs | 217 +++++++++++++++++++++++++---- 1 file changed, 191 insertions(+), 26 deletions(-) diff --git a/crates/noa-app/src/auto_approve.rs b/crates/noa-app/src/auto_approve.rs index 6d1400c2..0e9208a8 100644 --- a/crates/noa-app/src/auto_approve.rs +++ b/crates/noa-app/src/auto_approve.rs @@ -432,7 +432,12 @@ fn apply_decision_state( state.last_match = None; state.match_count = 0; state.pending_fire = None; - state.awaiting_change = None; + // A partial status redraw can invalidate the live tail while + // leaving the accepted dialog itself unchanged. + state.awaiting_change = state.awaiting_change.filter(|consumed| { + menu_prompt_region(rows, &lowercase_rows(rows), signature(consumed.signature)) + .is_some_and(|region| region_hash(rows, region) == consumed.region_hash) + }); } } Decision::Suppressed(reason) => { @@ -509,6 +514,9 @@ fn find_signature_with_lowercase( AutoApproveSignature::CodexCommand | AutoApproveSignature::AgyAskUserQuestion ) { let region = menu_prompt_region(rows, lowercase_rows, sig)?; + if !menu_has_live_tail(rows, *region.end(), sig) { + return None; + } return Some(MatchedPrompt { signature: sig.id, region_hash: region_hash(rows, region.clone()), @@ -548,8 +556,8 @@ fn find_signature_with_lowercase( } /// These TUIs select with a painted marker and can park the terminal cursor -/// below the menu (or hide it). Require the complete dialog at the live tail -/// instead of treating the terminal cursor as the selected option. +/// below the menu (or hide it). The dialog's identity excludes mutable status +/// rows beneath its footer. fn menu_prompt_region( rows: &[RowText], lowercase_rows: &[RowText], @@ -565,21 +573,6 @@ fn menu_prompt_region( }; let footer = (anchor + 1..rows.len()) .find(|&i| rows[i].split_whitespace().collect::>().join(" ") == footer_text)?; - let mut tail = rows[footer + 1..] - .iter() - .map(|row| row.trim()) - .filter(|row| !row.is_empty()); - if let Some(status) = tail.next() - && (sig.id != AutoApproveSignature::AgyAskUserQuestion - || !status.starts_with('[') - || !status.split_once("] Cost: $").is_some_and(|(_, cost)| { - !cost.is_empty() && cost.bytes().all(|ch| ch.is_ascii_digit() || ch == b'.') - }) - || tail.next().is_some()) - { - return None; - } - let mut selected = (anchor + 1..footer).filter_map(|i| selected_option(&rows[i]).map(|label| (i, label))); let (option, label) = selected.next()?; @@ -602,6 +595,22 @@ fn menu_prompt_region( valid.then_some(anchor..=footer) } +fn menu_has_live_tail(rows: &[RowText], footer: usize, sig: &Signature) -> bool { + let mut tail = rows[footer + 1..] + .iter() + .map(|row| row.trim()) + .filter(|row| !row.is_empty()); + let Some(status) = tail.next() else { + return true; + }; + sig.id == AutoApproveSignature::AgyAskUserQuestion + && status.starts_with('[') + && status.split_once("] Cost: $").is_some_and(|(_, cost)| { + !cost.is_empty() && cost.bytes().all(|ch| ch.is_ascii_digit() || ch == b'.') + }) + && tail.next().is_none() +} + fn codex_command_menu(rows: &[RowText], anchor: usize, option: usize, footer: usize) -> bool { let context = &rows[anchor + 1..option]; if !context.iter().any(|row| row.trim() == "Environment: local") @@ -618,15 +627,30 @@ fn codex_command_menu(rows: &[RowText], anchor: usize, option: usize, footer: us .map(|row| row.trim()) .filter(|row| !row.is_empty()) .collect(); - match options.split_last() { - Some((&"2. No, and tell Codex what to do differently (esc)", [])) => true, - Some((&"3. No, and tell Codex what to do differently (esc)", remember)) => { - let remember = remember.join(" "); - remember.starts_with("2. Yes, and don't ask again for commands that start with ") - && remember.ends_with("(p)") - } - _ => false, + let Some(last_option) = options + .iter() + .rposition(|row| row.starts_with("2. ") || row.starts_with("3. ")) + else { + return false; + }; + let (remember, rejection) = options.split_at(last_option); + let expected = if remember.is_empty() { + "2. No, and tell Codex what to do differently (esc)" + } else { + "3. No, and tell Codex what to do differently (esc)" + }; + // A physical row can end inside a word or shortcut, so match each + // fragment against the remaining label without inserting spaces. + let remaining = rejection.iter().try_fold(expected, |remaining, row| { + remaining.trim_start().strip_prefix(*row) + }); + if remaining != Some("") { + return false; } + let remember = remember.join(" "); + remember.is_empty() + || (remember.starts_with("2. Yes, and don't ask again for commands that start with ") + && remember.ends_with("(p)")) } fn agy_question_menu(rows: &[RowText], anchor: usize, option: usize, footer: usize) -> bool { @@ -857,6 +881,23 @@ mod tests { Decision::Hold ); assert!(!state.needs_static_rescan()); + assert_eq!( + detect_and_update_any_agent( + &rows(&["Working..."]), + Point { x: 0, y: 0 }, + ctx, + &mut state + ), + Decision::Hold + ); + assert_eq!( + detect_and_update_any_agent(&prompt, cursor, ctx, &mut state), + Decision::Hold + ); + assert!(matches!( + detect_and_update_any_agent(&prompt, cursor, ctx, &mut state), + Decision::Fire { .. } + )); } } @@ -1145,6 +1186,130 @@ mod tests { } } + #[test] + fn agy_partial_cost_redraw_does_not_repeat_accepted_approval() { + let mut terminal = Terminal::new(noa_core::GridSize::new(140, 24)); + let mut stream = noa_vt::Stream::new(); + stream.feed(agy_question_prompt().join("\r\n").as_bytes(), &mut terminal); + let now = fixed_now(); + let ctx = base_ctx(now); + let mut state = AutoApproveState::default(); + let screen = viewport_rows_from_terminal(&terminal); + assert_eq!( + detect_and_update_any_agent(&screen, cursor(12), ctx, &mut state), + Decision::Hold + ); + let Decision::Fire { + signature, + region_hash, + .. + } = detect_and_update_any_agent(&screen, cursor(12), ctx, &mut state) + else { + panic!("stable question should fire"); + }; + state.apply_feedback(signature, region_hash, true, now); + + stream.feed( + b"\x1b[13;1H\x1b[2K[Gemini 3.8 Flash (High)] Cost: $", + &mut terminal, + ); + let partial = viewport_rows_from_terminal(&terminal); + assert_no_auto_approval(&partial); + assert_eq!( + detect_and_update_any_agent(&partial, cursor(12), ctx, &mut state), + Decision::Hold + ); + stream.feed(b"0.0100", &mut terminal); + let restored = viewport_rows_from_terminal(&terminal); + assert_eq!( + rescan_signature(&restored, signature, cursor(12), ctx) + .unwrap() + .region_hash, + region_hash + ); + for _ in 0..3 { + assert_eq!( + detect_and_update_any_agent(&restored, cursor(12), ctx, &mut state), + Decision::Hold, + "redrawing only the cost must not send another Enter" + ); + } + assert!(!state.needs_static_rescan()); + assert_eq!(state.approvals.len(), 1); + + stream.feed( + "\x1b[4;1H\x1b[2KQuestion 1/1: 次はどの作業を進めますか?".as_bytes(), + &mut terminal, + ); + let changed = viewport_rows_from_terminal(&terminal); + assert_eq!( + detect_and_update_any_agent(&changed, cursor(3), ctx, &mut state), + Decision::Hold + ); + assert!(matches!( + detect_and_update_any_agent(&changed, cursor(3), ctx, &mut state), + Decision::Fire { region_hash: new_hash, .. } if new_hash != region_hash + )); + } + + #[test] + fn codex_wrapped_rejection_is_detected_from_vt_grid() { + for remember in [false, true] { + let mut prompt = codex_command_prompt(); + if !remember { + prompt.remove(9); + prompt[9] = " 2. No, and tell Codex what to do differently (esc)".to_string(); + } + let mut terminal = Terminal::new(noa_core::GridSize::new(48, 30)); + let mut stream = noa_vt::Stream::new(); + stream.feed(prompt.join("\r\n").as_bytes(), &mut terminal); + let screen = viewport_rows_from_terminal(&terminal); + let rejection_end = screen + .iter() + .position(|row| row.trim() == "esc)") + .expect("the rejection shortcut should wrap onto another physical row"); + let mut state = AutoApproveState::default(); + let ctx = base_ctx(fixed_now()); + let position = terminal.active().cursor; + let cursor = Point { + x: position.x, + y: position.y, + }; + assert_eq!( + detect_and_update_any_agent(&screen, cursor, ctx, &mut state), + Decision::Hold + ); + let Decision::Fire { + signature, + region_hash, + .. + } = detect_and_update_any_agent(&screen, cursor, ctx, &mut state) + else { + panic!("complete wrapped menu should fire (remember={remember}): {screen:?}"); + }; + assert_eq!(signature, AutoApproveSignature::CodexCommand); + assert_eq!(signature.bytes(), b"\r"); + assert_eq!( + rescan_signature(&screen, signature, cursor, ctx) + .unwrap() + .region_hash, + region_hash + ); + state.apply_feedback(signature, region_hash, true, ctx.now); + for _ in 0..3 { + assert_eq!( + detect_and_update_any_agent(&screen, cursor, ctx, &mut state), + Decision::Hold + ); + } + for invalid_suffix in ["", "esc", "enter)", "esc) extra output"] { + let mut incomplete = screen.clone(); + incomplete[rejection_end] = invalid_suffix.to_string(); + assert_no_auto_approval(&incomplete); + } + } + } + #[test] fn detect_holds_for_generic_agent_even_with_known_signature() { let now = fixed_now();