//! Tests for permission request selection, follow-ups, and queue draining. use super::*; /// `ConfirmResetSetting /// { Reset }` on `permission_mode` (the security-critical SHELL Enum) /// dispatches `Action::SetPermissionMode(PermissionModeKind::Ask)` /// (the typed Action, per the modal-commit ↔ typed-setter /// rule) via recursive dispatch. Emits /// `Effect::PersistPermissionMode` — verifies the recursive /// dispatch reaches the YOLO pipeline through /// `set_permission_mode` rather than the legacy `set_yolo_mode`. #[test] fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permission_mode() { use crate::views::modal::ResetSettingsResult; let mut app = test_app_with_agent(); // Flip yolo on first (default is OFF = "ask"). let _ = dispatch(Action::SetYoloMode(true), &mut app); assert!(app.agents[&AgentId(0)].session.is_yolo()); setup_reset_confirm_open(&mut app, "permission_mode"); let effects = dispatch( Action::ConfirmResetSetting { choice: ResetSettingsResult::Reset, }, &mut app, ); // Recursive dispatch into Action::SetYoloMode(false) emits a // PersistPermissionMode effect. let has_persist = effects .iter() .any(|e| matches!(e, Effect::PersistPermissionMode { .. })); assert!( has_persist, "Reset of permission_mode must emit PersistPermissionMode, got {effects:?}", ); // Agent's yolo flag is reset to default (off). assert!( !app.agents[&AgentId(0)].session.is_yolo(), "agent.session.yolo_mode must be reset to default (off)", ); } /// **Security-critical:** YOLO ON must drain the per-agent /// `permission_queue` with `AllowOnce` responses. If this drain /// path regresses (e.g., the setter falls back to `Cancelled` /// without an `AllowOnce` lookup), the user enables YOLO and /// their queued permissions silently get rejected. #[test] fn set_yolo_mode_on_drains_permission_queue_with_allow_once() { use crate::views::permission_view::{PermissionFocus, PermissionViewState}; use std::sync::Arc; let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); // Inject a fake queued permission. The drain semantics use // `find(|o| o.kind == AllowOnce)` so we need ≥1 AllowOnce // option for the test to exercise the happy path. let (response_tx, mut response_rx) = tokio::sync::oneshot::channel(); let request = acp::RequestPermissionRequest::new( acp::SessionId::new(Arc::from("test-sess")), acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from("tc-1")), acp::ToolCallUpdateFields::default(), ), vec![ acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("opt-allow-once")), "Allow once", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("opt-reject")), "Reject", acp::PermissionOptionKind::RejectOnce, ), ], ); let options = request.options.clone(); agent.permission_queue.push_back(PermissionViewState { request: kigi_acp_lib::AcpArgs { request, response_tx, }, id: 1, focus: PermissionFocus::Options, options, active_idx: 0, bash_highlights: None, bash_selection_count: 0, bash_command_raw: None, mcp_scope: None, title: "test".to_string(), description: vec![], args_expanded: false, desc_scroll: 0, subagent_label: None, options_area_height: 0, options_scroll_offset: 0, }); assert_eq!(agent.permission_queue.len(), 1); let _ = dispatch(Action::SetYoloMode(true), &mut app); // Queue is drained. assert!( app.agents[&AgentId(0)].permission_queue.is_empty(), "YOLO ON must drain the permission_queue", ); // Verify the `AllowOnce` response was actually sent (NOT // `Cancelled`). The drain semantics use `find(|o| o.kind == // AllowOnce)` — a regression to `Cancelled` here would // silently reject every queued permission when the user // enables YOLO, which is the exact security failure mode // this test prevents. match response_rx.try_recv() { Ok(Ok(acp::RequestPermissionResponse { outcome: acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome { option_id, .. }), .. })) => { assert_eq!( option_id, acp::PermissionOptionId::new(Arc::from("opt-allow-once")), "the drain must select the AllowOnce option (NOT Cancelled / RejectOnce)", ); } other => panic!( "queue drain must send an `AllowOnce` Selected response, got {other:?} — \ security regression: queued permissions are NOT being auto-approved on YOLO ON", ), } } #[test] fn permission_select_clears_double_click_tracker_for_next_prompt() { use crate::views::permission_view::PermissionFocus; use std::sync::Arc; let mut app = test_app_with_agent(); let _rx_front = enqueue_permission_with_enable_always_approve(&mut app); let _rx_next = enqueue_permission_with_enable_always_approve(&mut app); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.permission_queue.get_mut(1).unwrap().focus = PermissionFocus::FollowupInput; agent.last_permission_click = Some((Instant::now(), 1)); let _ = dispatch( Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))), &mut app, ); let agent = &app.agents[&AgentId(0)]; assert_eq!(agent.permission_queue.len(), 1); assert!( agent.last_permission_click.is_none(), "armed click on the resolved prompt must not pair with a click on the next prompt" ); assert_eq!( agent.permission_queue.front().unwrap().focus, PermissionFocus::Options, "next front must be reset to Options" ); } #[test] fn drain_permission_queue_clears_double_click_tracker() { let mut app = test_app_with_agent(); let _rx = enqueue_permission_with_enable_always_approve(&mut app); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.last_permission_click = Some((Instant::now(), 1)); drain_permission_queue(agent); assert!(agent.permission_queue.is_empty()); assert!( agent.last_permission_click.is_none(), "turn-end/turn-cancel drain must invalidate the armed click" ); } #[test] fn set_permission_mode_always_approve_blocked_by_policy_pin() { use crate::app::actions::PermissionModeKind; use crate::views::modal::ActiveModal; let mut app = test_app_with_agent(); app.yolo_policy_block = Some(POLICY_WARNING); // Open the settings modal so the blocked path's snapshot refresh is // exercised: the modal must keep showing the live (non-yolo) value. let _ = dispatch(Action::OpenSettings, &mut app); let effects = dispatch( Action::SetPermissionMode(PermissionModeKind::AlwaysApprove), &mut app, ); assert!( effects.is_empty(), "blocked modal commit must not persist, got {effects:?}", ); assert!(!app.agents[&AgentId(0)].session.is_yolo()); assert_eq!( app.current_ui.permission_mode, None, "canonical mirror must stay untouched" ); let agent = app.agents.get(&AgentId(0)).unwrap(); let Some(ActiveModal::Settings { state }) = &agent.active_modal else { panic!("Settings modal must remain open across the blocked dispatch") }; assert!( !state.pager_snapshot.yolo_mode, "modal snapshot must show the live (non-yolo) value after the block", ); assert_ne!( state.ui_snapshot.permission_mode.as_deref(), Some("always-approve"), "modal canonical must not show the refused mode", ); assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING)); // Non-yolo kinds still commit under the pin. let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app); assert_eq!(effects.len(), 1, "Ask must persist under the pin"); assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask")); } /// SetPermissionMode(Auto) persists auto and does not enable yolo. #[test] fn set_permission_mode_auto_persists_without_yolo() { use crate::app::actions::PermissionModeKind; let mut app = test_app_with_agent(); let effects = dispatch( Action::SetPermissionMode(PermissionModeKind::Auto), &mut app, ); assert!(!app.agents[&AgentId(0)].session.is_yolo()); assert_eq!(app.current_ui.permission_mode.as_deref(), Some("auto")); assert!( effects.iter().any(|e| matches!( e, Effect::PersistPermissionMode { canonical: "auto", .. } )), "expected PersistPermissionMode(auto), got {effects:?}" ); } /// Feature gate OFF: a SetPermissionMode(Auto) commit (e.g. from the /// settings modal) degrades to Ask — same `app.auto_mode_gate` source the /// Shift+Tab cycle uses, so the two never disagree. #[test] fn set_permission_mode_auto_degrades_to_ask_when_gated_off() { use crate::app::actions::PermissionModeKind; let mut app = test_app_with_agent(); app.auto_mode_gate = false; let effects = dispatch( Action::SetPermissionMode(PermissionModeKind::Auto), &mut app, ); assert_eq!( app.current_ui.permission_mode.as_deref(), Some("ask"), "gate OFF: Auto commit must land on Ask, not auto" ); assert!( !effects.iter().any(|e| matches!( e, Effect::PersistPermissionMode { canonical: "auto", .. } )), "gate OFF: must not persist 'auto', got {effects:?}" ); } /// Rollback with an unknown canonical: defensively defaults to /// "ask" (the safe fallback — fewer prompts on a corrupt /// rollback value is worse, more prompts is safer). /// /// The previous docstring claimed "logs a /// warning and defaults to 'ask'" — the warning log is fired via /// `tracing::warn!` in `apply_setting_rollback`'s arm, but the /// test doesn't capture/assert it. The fix is documentary: the /// test pins the OBSERVABLE behaviour (state defaults to "ask") /// and acknowledges that the warn-log is best-effort visibility /// for developers, not a contract surface the test enforces. /// `tracing_test::traced_test` capture would be more rigorous /// but is not currently used in this crate. #[test] fn rollback_permission_mode_unknown_canonical_defaults_to_ask() { use crate::settings::SettingValue; let mut app = test_app_with_agent(); // Pre-set to true. let _ = dispatch(Action::SetYoloMode(true), &mut app); // Garbage canonical rolls back to "ask" (the safe default). let _ = dispatch( Action::TaskComplete(TaskResult::SettingPersistFailed { key: "permission_mode", rollback_value: SettingValue::Enum("garbage-value"), error: "test-error".into(), }), &mut app, ); assert!( !app.agents[&AgentId(0)].session.is_yolo(), "unknown canonical → safe default (ask = no auto-approve)", ); assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask")); // The failure toast is the standard // `✗ Could not save permission_mode: …` format. A future // enhancement could differentiate "schema corruption" from // "real disk failure" in the toast text, but currently the // user sees the same wording; pinned here so a future // divergence is intentional. } /// Rollback path refreshes open modal /// snapshots in the same way the success path does. Mirror of /// `set_yolo_mode_refreshes_open_modal_snapshots` for the /// `apply_setting_rollback` entry into `set_yolo_mode_inner`. /// Without this, a modal that's open when a disk write fails /// shows a stale "always-approve" indicator after the state /// has rolled back to "ask". #[test] fn rollback_permission_mode_refreshes_open_modal_snapshots() { use crate::settings::SettingValue; use crate::views::modal::ActiveModal; let mut app = test_app_with_agent(); // Pre-set yolo=true via the typed setter so the rollback // captures real prior state. let _ = dispatch(Action::SetYoloMode(true), &mut app); // Open the modal AFTER the optimistic toggle so the open-time // snapshot reflects yolo=true. let _ = dispatch(Action::OpenSettings, &mut app); let agent = app.agents.get(&AgentId(0)).unwrap(); let Some(ActiveModal::Settings { state }) = &agent.active_modal else { panic!("expected Settings modal"); }; assert!( state.pager_snapshot.yolo_mode, "pre-rollback snapshot reflects optimistic state (yolo=true)", ); // Simulate disk-write failure → rollback to "ask". let _ = dispatch( Action::TaskComplete(TaskResult::SettingPersistFailed { key: "permission_mode", rollback_value: SettingValue::Enum("ask"), error: "test-error".into(), }), &mut app, ); // The modal's snapshot MUST refresh to the rolled-back value. let agent = app.agents.get(&AgentId(0)).unwrap(); let Some(ActiveModal::Settings { state }) = &agent.active_modal else { panic!("modal must stay open after rollback"); }; assert!( !state.pager_snapshot.yolo_mode, "rollback path MUST refresh pager_snapshot.yolo_mode (false after revert)", ); assert_eq!( state.ui_snapshot.permission_mode.as_deref(), Some("ask"), "rollback path MUST refresh ui_snapshot.permission_mode to 'ask'", ); } #[test] fn set_permission_mode_ask_emits_brand_consistent_toast() { use crate::app::actions::PermissionModeKind; let mut app = test_app_with_agent(); // Pre-set to AlwaysApprove so the Ask dispatch is a real // transition (avoids idempotent fast-path). let _ = dispatch(Action::SetYoloMode(true), &mut app); // Clear toast so we observe the Ask dispatch's fresh toast. app.agents.get_mut(&AgentId(0)).unwrap().toast = None; let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app); assert!(!app.agents[&AgentId(0)].session.is_yolo()); assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask")); // Toast brands as "Permission mode" not // "Always-approve". Previously the Ask arm reused `yolo_toast(false)` // which produced "✓ Always-approve: off" — a brand mismatch. let toast = app.agents[&AgentId(0)] .toast .as_ref() .map(|(s, _)| s.clone()) .expect("toast must be set"); assert_eq!( toast, "\u{2713} Permission mode: Ask", "PR 11 R1 G-3 #11: Ask toast must brand as 'Permission mode' not 'Always-approve'", ); // Effect carries the new canonical + the prior canonical // (was "always-approve" from the test-setup pre-set). assert_eq!(effects.len(), 1); match &effects[0] { Effect::PersistPermissionMode { canonical, persist, .. } => { assert_eq!(*canonical, "ask"); assert_eq!( *persist, crate::app::actions::PermissionModePersist::WithRollback("always-approve"), "prior canonical was 'always-approve' (pre-set by SetYoloMode(true))", ); } other => panic!("expected PersistPermissionMode, got {other:?}"), } } /// Regression test. A `--yolo` /// startup sets `agent.session.yolo_mode = true` but leaves /// `app.current_ui.permission_mode` at `None`. Without the /// LIVE-precedence capture, dispatching `SetPermissionMode(Default)` /// would produce `WithRollback("ask")` — diverging the pager from /// the shell on disk failure (the ACP suppress-on-failure gate /// keeps the shell at YOLO, but the pager would roll back to /// non-YOLO). This test pins the LIVE-precedence fix. #[test] fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_approve() { use crate::app::actions::PermissionModeKind; let mut app = test_app_with_agent(); // Simulate `--yolo` startup: agent yolo + default_yolo set, // but `current_ui.permission_mode = None` (config has no // `[ui] permission_mode` setting). app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true; app.default_yolo = true; app.current_ui.permission_mode = None; let effects = dispatch( Action::SetPermissionMode(PermissionModeKind::Default), &mut app, ); // The dispatch flipped yolo off (Default projects onto // bool=false) and set the canonical to "default". assert!(!app.agents[&AgentId(0)].session.is_yolo()); assert_eq!(app.current_ui.permission_mode.as_deref(), Some("default")); // **Rollback contract.** Rollback must target // "always-approve" (the LIVE state at dispatch time), NOT // "ask" (a bool-projected guess from the None mirror). match &effects[0] { Effect::PersistPermissionMode { persist, .. } => { assert_eq!( *persist, crate::app::actions::PermissionModePersist::WithRollback("always-approve"), "PR 11 R1 Security #8: LIVE yolo state must take precedence over the \ None on-disk mirror when computing the rollback canonical — \ otherwise a `--yolo` startup + Default-commit + disk-failure diverges \ the pager from the shell", ); } other => panic!("expected PersistPermissionMode, got {other:?}"), } } /// `apply_setting_rollback("permission_mode", /// Enum("default"))` — the rollback arm that preserves the /// "default" canonical through a failed-persist. The headline /// architectural contract: rolling back to "default" must NOT /// collapse onto "ask" via the inner's bool projection. #[test] fn rollback_permission_mode_default_canonical_preserves_default() { use crate::settings::SettingValue; let mut app = test_app_with_agent(); // Pre-flip to YOLO so the rollback has somewhere to roll // back FROM. let _ = dispatch(Action::SetYoloMode(true), &mut app); assert!(app.agents[&AgentId(0)].session.is_yolo()); assert_eq!( app.current_ui.permission_mode.as_deref(), Some("always-approve"), ); // Simulate disk-write failure with `rollback_value = // Enum("default")`. let effects = dispatch( Action::TaskComplete(TaskResult::SettingPersistFailed { key: "permission_mode", rollback_value: SettingValue::Enum("default"), error: "simulated".into(), }), &mut app, ); // Rollback path MUST NOT re-emit any Effect — that would // loop on persistent disk failure. assert!( effects.is_empty(), "rollback path must not re-emit Effects, got {effects:?}", ); // Yolo flipped to false (Default projects onto bool=false). assert!( !app.agents[&AgentId(0)].session.is_yolo(), "Default projects onto yolo=false; agent.session.yolo_mode must flip back", ); // Canonical preserved as "default" — the headline // contract. Without the post-inner override in the rollback // arm, the inner's bool-projection write would leave this // at "ask". assert_eq!( app.current_ui.permission_mode.as_deref(), Some("default"), "PR 11 R1 Tests #22: rollback to 'default' canonical must NOT collapse \ onto 'ask' — the post-inner override restores the canonical", ); } /// Non-empty permission_queue → NeedsInput. #[test] fn classify_top_level_permission_queue_non_empty_is_needs_input() { use crate::views::dashboard::{RowState, classify_top_level}; let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); let _rx = push_synthetic_permission(agent, 1, vec![("allow", "Allow")]); assert_eq!(classify_top_level(agent), RowState::NeedsInput); } #[test] fn permission_select_reject_does_not_steer_sticky_cursor() { use crate::appearance::permission_cursor::{ DefaultSelectedPermission, last_used_permission, set_last_used_permission, }; use std::sync::Arc; let mut app = test_app_with_agent(); let _rx_allow = enqueue_permission_with_enable_always_approve(&mut app); let _rx_reject = enqueue_permission_with_enable_always_approve(&mut app); set_last_used_permission(DefaultSelectedPermission::AlwaysAllowAllSessions); let _ = dispatch( Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))), &mut app, ); assert_eq!( last_used_permission(), DefaultSelectedPermission::AllowOnce, "allow selection records the sticky cursor target" ); let _ = dispatch( Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-reject-once"))), &mut app, ); assert_eq!( last_used_permission(), DefaultSelectedPermission::AllowOnce, "reject selection must not steer the sticky cursor" ); }