//! Tests for the dispatch module tree: shared fixtures and per-domain test modules. mod auth; mod billing; mod dashboard; mod jump; mod modes; mod notes; mod permissions; mod prompt; mod rewind; mod router; mod session; mod settings; mod status; mod task_result; mod transcript; mod turn; use super::ctx::{find_agent_by_session_id, get_active_agent, get_active_agent_mut}; use super::dashboard::{ apply_pending_dispatch_config, dispatch_dashboard_attach, dispatch_dashboard_begin_rename, dispatch_dashboard_commit_rename, dispatch_dashboard_confirm_worktree, dispatch_dashboard_create_new_agent_with_detail, dispatch_dashboard_dispatch, dispatch_dashboard_dispatch_slash, dispatch_dashboard_overlay_cycle, dispatch_dashboard_overlay_exit, dispatch_dashboard_overlay_stop, dispatch_dashboard_peek_reply, dispatch_dashboard_permission_followup, dispatch_dashboard_permission_select, dispatch_dashboard_question_answer, dispatch_dashboard_stop, dispatch_dashboard_toggle_auto_approve, dispatch_exit_dashboard, dispatch_open_dashboard, ensure_dashboard_state, resolve_location_input, }; use super::modes::{ YOLO_ON_UNDER_PLAN_TOAST, active_agent_plan_nudge_state, dispatch_cycle_mode_and_sync, permission_mode_toast, }; use super::permissions::drain_permission_queue; use super::prompt::{ dispatch_send_prompt, dispatch_send_prompt_inner, input_can_trigger_project_picker, }; use super::session::fork::build_child_fork_marker; use super::session::lifecycle::{dispatch_new_session_inner, drain_startup_actions, finish_trust}; use super::session::load::{dispatch_load_session_with_restore, reanchor_grouped_selection}; use super::session::modal::{dispatch_rename_session, dispatch_sessions_confirm_close}; use super::settings::setters::set_default_model_inner; use super::settings::ui::{action_for_reset, apply_setting_rollback}; use super::status::scrub_error_for_toast; use super::task_result::dispatch_task_result; use super::*; use crate::acp::model_state::ModelState; use crate::acp::tracker::AcpUpdateTracker; use crate::app::actions::{Action, Effect, SubagentKillOutcome, SwitchModelError, TaskResult}; use crate::app::agent::{AgentId, AgentSession, AgentState}; use crate::app::agent_view::{ActivePane, AgentView, PromptMode}; use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, TrustState}; use crate::scrollback::block::RenderBlock; use crate::scrollback::blocks::{SessionEvent, ToolCallBlock}; use crate::scrollback::state::ScrollbackState; use agent_client_protocol as acp; use indexmap::IndexMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; fn test_app() -> AppView { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); AppView { active_view: ActiveView::Welcome, auth_return_view: None, agents: IndexMap::new(), next_agent_id: 0, models: ModelState::default(), registry: crate::actions::ActionRegistry::defaults(), settings_registry: std::sync::Arc::new(crate::settings::SettingsRegistry::defaults()), current_ui: kigi_shell::agent::config::UiConfig::default(), cwd: PathBuf::from("/tmp"), project_picker_shown: true, project_picker_disabled: false, cwd_has_git_ancestor: false, acp_tx: tx, scratch: crate::scrollback::render::ScratchBuffer::new(), cursor: crate::render::draw::CursorState::new(), pending_action: None, exit_session_pending: None, scroll_state: crate::input::mouse::MouseScrollState::default(), scroll_config: crate::input::mouse::ScrollConfig::default(), appearance: crate::appearance::AppearanceConfig::default(), notification_service: crate::notifications::NotificationService::new(Default::default()), pending_notification_escapes: None, deferred_notification: None, tracing_rx: None, tips: Vec::new(), tip: None, cli_model_override: None, cli_effort_token: None, default_yolo: false, permission_mode_from_soft_default: true, auto_mode_gate: true, yolo_policy_block: None, yolo_launch_block_notice: None, screen_mode_switch_hint: None, require_plan_approval: false, plan_mode: false, chat_mode: false, subagents: false, ask_user: false, mouse_captured: true, new_worktree_dialog: None, contextual_hints: Default::default(), remote_contextual_hints: None, tip_seen_counts: Default::default(), last_known_terminal_rows: 0, small_screen_tip_evaluated: false, clipboard_focus_tip: Default::default(), new_session_worktree_mode: crate::app::app_view::WorktreeMode::Never, fork_worktree_mode: crate::app::app_view::WorktreeMode::Ask, restore_code: None, agent_override: None, bootstrap_acp_commands: Vec::new(), auth_methods: vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new( acp::AuthMethodId::new("kimi-code"), "Kigi".to_string(), ))], auth_state: AuthState::Done, trust_state: TrustState::Done, login_label: None, login_method_id: None, auth_start_mode: AuthMode::Pending, auth_code_input: String::new(), next_auth_request_seq: 1, deferred_startup: Default::default(), auth_use_oauth: false, auth_clipboard_copied: false, show_tips: None, auto_update: None, ask_user_question_timeout_enabled: None, bundle_state: crate::app::bundle::BundleState::default(), scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(), fps_hud: crate::views::fps_hud::FpsHud::new(), welcome_prompt: crate::views::prompt_widget::PromptWidget::new(), slash_mru: std::rc::Rc::new(std::cell::RefCell::new( crate::slash::mru::SlashMru::new_in_memory(), )), welcome_prompt_focused: false, welcome_tip_typing_dismissed: false, welcome_menu_index: None, welcome_menu_rects: Vec::new(), welcome_import_banner_rect: None, last_mouse_pos: None, last_scroll_pos: None, last_cache_evict_at: None, welcome_prompt_rect: None, welcome_auth_url_rect: None, welcome_on_auth_url: false, welcome_auth_fallback_rect: None, auth_show_raw_url: false, auth_mouse_disabled: false, session_picker_entries: None, session_picker_loading: false, session_picker_state: crate::views::picker::PickerState::with_mode( crate::views::picker::PickerMode::FullScreen, ), session_picker_source_filter: crate::views::session_picker::SourceFilter::default(), session_picker_content_results: None, session_picker_content_loading: false, session_picker_deep_search_seq: 0, session_picker_list_seq: 0, foreign_session_compat: Default::default(), foreign_session_scan_seq: 0, foreign_scan_coordinator: Default::default(), session_picker_lanes: Default::default(), session_picker_detail_generation: 0, session_picker_entries_query: None, welcome_tick: 0, welcome_shimmer_frame: 0, startup_warnings: Vec::new(), is_api_key_auth: false, pending_update_version: None, foreign_resume_launch_generation: 0, foreign_resume_launch: None, quit_for_update: false, relaunch: None, import_claude_modal: None, welcome_doc_viewer: None, screen_mode: crate::app::ScreenMode::Inline, pending_effects: Vec::new(), pending_editor_path: None, pending_agents_modal_refresh: None, pending_pager_path: None, pending_pager_ansi: false, minimal_state: crate::minimal_api::MinimalState::default(), reconnect_pending: false, show_resolved_model: true, usage_visible: true, leader_mode: true, leader_roster: Vec::new(), dashboard_local_sessions: Vec::new(), dashboard_sessions_loading: false, shared_prompt_queues: std::collections::HashMap::new(), optimistic_prompt_echoes: std::collections::HashMap::new(), pending_running_adoptions: std::collections::HashMap::new(), session_picker_grouped: false, cancel_rewind_enabled: true, session_recap_available: false, dashboard: None, dashboard_persisted: None, keyboard_normalizer: crate::input::KeyboardNormalizer::from_terminal_context(), has_claude_import: false, } } /// Build a default `AgentSession` for /// tests. Centralises the fixture so new fields on `AgentSession` /// don't break every test that constructs one by hand. The /// `acp_tx` is cloned from the test `AppView`; the /// `deferred_model_switch` is pulled from the `AppView`'s CLI /// overrides for parity with `dispatch_new_session_inner`. fn make_test_agent_session(app: &AppView, id: AgentId, sid: &str) -> AgentSession { AgentSession { id, acp_tx: app.acp_tx.clone(), session_id: Some(sid.to_string().into()), models: ModelState::default(), state: AgentState::Idle, tracker: AcpUpdateTracker::new(), cwd: PathBuf::from("/tmp"), is_worktree: false, forked_from: None, pending_prompts: std::collections::VecDeque::new(), next_queue_id: 0, yolo_mode: false, auto_mode: false, prompt_history: Vec::new(), prompt_history_loading: false, loading_replay: false, restore_degree: None, rate_limited: false, model_incompatible: false, available_commands: Vec::new(), available_commands_generation: 0, available_tools: None, model_switch_pending: false, user_model_preference: None, deferred_model_switch: app.deferred_model_switch_from_cli(), bg_tasks: std::collections::BTreeMap::new(), bg_tool_call_to_task: std::collections::HashMap::new(), scheduled_tasks: std::collections::HashMap::new(), in_flight_prompt: None, current_prompt_id: None, created_via_new: false, } } pub(super) fn test_app_with_agent() -> AppView { let mut app = test_app(); let id = AgentId(0); let session = make_test_agent_session(&app, id, "test-session"); let mut agent = AgentView::new(session, ScrollbackState::new()); agent.active_pane = ActivePane::Scrollback; app.agents.insert(id, agent); app.next_agent_id = 1; switch_to_agent(&mut app, id, SwitchCause::New); app } /// Give a test agent a generated title so the dashboard renders it. /// /// The dashboard hides empty (no-real-turn) sessions /// (`views::dashboard::row::is_empty_top_level`); nav/render tests that /// rely on their placeholder agents being visible call this to opt in. fn mark_agent_nonempty(app: &mut AppView, id: AgentId) { if let Some(a) = app.agents.get_mut(&id) { a.generated_session_title = Some(format!("Session {}", id.0)); } } /// Push a plain prompt directly onto the LOCAL drip-feed queue /// (`pending_prompts`), bypassing the server-authoritative /// immediate-send routing. Used by tests that exercise the local /// `maybe_drain_queue` / editing / `DrainQueue` machinery, which is still /// the path for image/skill/bash/editing prompts and idle drains. pub(super) fn enqueue_local(app: &mut AppView, id: AgentId, text: &str) { app.agents .get_mut(&id) .unwrap() .session .enqueue_prompt(text.to_string()); } fn make_test_subagent(child_sid: &str, sa_id: &str) -> crate::app::subagent::SubagentInfo { crate::app::subagent::SubagentInfo { subagent_id: Arc::from(sa_id), child_session_id: Arc::from(child_sid), description: Arc::from("test subagent"), subagent_type: Arc::from("general-purpose"), persona: None, role: None, model: None, context_source: None, resumed_from: None, capability_mode: None, context_normalized: false, parent_prompt_id: None, started_at: std::time::Instant::now(), last_progress_at: std::time::Instant::now(), finished: false, status: None, error: None, duration_ms: None, tool_calls: None, turns: None, turn_count: None, tool_call_count: None, tokens_used: None, context_window_tokens: None, context_usage_pct: None, tools_used: Vec::new(), error_count: None, activity_label: None, is_background: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, prompt: None, child_cwd: None, worktree_path: None, child_updates_replayed: false, } } fn arm_reconcile( app: &mut AppView, id: AgentId, prompt_id: &str, stop_reason: &str, age: std::time::Duration, ) { arm_reconcile_with_trigger(app, id, prompt_id, stop_reason, None, age); } /// [`arm_reconcile`] with an explicit `_meta.cancelTrigger`. fn arm_reconcile_with_trigger( app: &mut AppView, id: AgentId, prompt_id: &str, stop_reason: &str, cancel_trigger: Option<&str>, age: std::time::Duration, ) { app.agents.get_mut(&id).unwrap().pending_turn_end_reconcile = Some(crate::app::agent_view::PendingTurnEnd { prompt_id: prompt_id.into(), stop_reason: Some(stop_reason.into()), agent_result: None, cancel_trigger: cancel_trigger.map(str::to_string), received_at: std::time::Instant::now() - age, }); } pub(super) fn end_turn() -> Action { Action::TaskComplete(TaskResult::PromptResponse { agent_id: AgentId(0), result: Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)), http_status: None, prompt_id: None, }) } /// Plant a Build session under the process `kigi_home()` (OnceLock-cached; /// do not rely on setting `KIGI_SHARE_DIR` mid-process). Caller must remove `sess_dir`. fn plant_local_build_session(cwd: &std::path::Path, session_id: &str) -> std::path::PathBuf { let home = kigi_shell::util::kigi_home::kigi_home(); let encoded = kigi_shell::util::kigi_home::encode_cwd_dirname(&cwd.to_string_lossy()); let sess_dir = home.join("sessions").join(encoded).join(session_id); std::fs::create_dir_all(&sess_dir).expect("plant session dir"); std::fs::write(sess_dir.join("summary.json"), b"{}").expect("plant summary"); sess_dir } /// Extract the in-flight auth request sequence, panicking if the auth /// state is not `Authenticating`. fn authenticating_seq(app: &AppView) -> u64 { match app.auth_state { AuthState::Authenticating { request_seq, .. } => request_seq, ref other => panic!("expected Authenticating, got {other:?}"), } } /// Extract text from the last system message in an agent's scrollback. fn last_system_text(app: &AppView, id: AgentId) -> String { system_text_from_end(app, id, 0) } /// Like [`last_system_text`] but takes an offset from the end. /// `offset = 0` is the last entry, `offset = 1` is second-to-last, etc. fn system_text_from_end(app: &AppView, id: AgentId, offset: usize) -> String { let sb = &app.agents[&id].scrollback; let idx = sb.len() - 1 - offset; let entry = sb.get(idx).expect("scrollback index out of bounds"); match &entry.block { RenderBlock::System(sys) => sys.text.clone(), other => panic!("expected System block at index {idx}, got {other:?}"), } } /// Insert a placeholder agent at `id` so `switch_to_agent` recognises /// it (the helper's defensive check uses `app.agents.contains_key`). /// `session_id` and `active_pane` are populated to mirror the /// existing `test_app_with_agent` setup; these tests do not read /// either field. fn insert_placeholder_agent(app: &mut AppView, id: AgentId) { let mut agent = AgentView::new( AgentSession { id, acp_tx: app.acp_tx.clone(), session_id: Some("placeholder".into()), models: ModelState::default(), state: AgentState::Idle, tracker: AcpUpdateTracker::new(), cwd: PathBuf::from("/tmp"), is_worktree: false, forked_from: None, pending_prompts: std::collections::VecDeque::new(), next_queue_id: 0, yolo_mode: false, auto_mode: false, prompt_history: Vec::new(), prompt_history_loading: false, loading_replay: false, restore_degree: None, rate_limited: false, model_incompatible: false, available_commands: Vec::new(), available_commands_generation: 0, available_tools: None, model_switch_pending: false, user_model_preference: None, deferred_model_switch: None, bg_tasks: std::collections::BTreeMap::new(), bg_tool_call_to_task: std::collections::HashMap::new(), scheduled_tasks: std::collections::HashMap::new(), in_flight_prompt: None, current_prompt_id: None, created_via_new: false, }, ScrollbackState::new(), ); agent.active_pane = ActivePane::Scrollback; app.agents.insert(id, agent); } /// Build an app with three agents (ids 0, 1, 2) and `active_view` set /// to agent 0. fn three_agent_app() -> AppView { let mut app = test_app_with_agent(); insert_placeholder_agent(&mut app, AgentId(1)); insert_placeholder_agent(&mut app, AgentId(2)); app } use crate::slash::commands::fork::ForkArgs; fn fork_args(worktree_override: Option, directive: Option<&str>) -> ForkArgs { ForkArgs { worktree_override, directive: directive.map(String::from), } } /// Build a single-agent app for the `/fork` dispatcher tests. /// /// Sets `current_branch` to `Some("main")` so the agent appears to be /// inside a git repo. This is required because `dispatch_fork` skips /// the worktree question when `current_branch` is `None` (non-git cwd). fn fork_test_app() -> AppView { let mut app = test_app_with_agent(); app.agents.get_mut(&AgentId(0)).unwrap().current_branch = Some("main".into()); app } /// Build a minimal `AcpArgs` for an /// `kigi/ask_user_question` ext-method request. Returns the args /// plus the receiver half of the response oneshot so the test can /// assert the handler completes the ACP roundtrip. fn make_ask_user_question_args( tool_call_id: &str, ) -> ( kigi_acp_lib::AcpArgs, tokio::sync::oneshot::Receiver>, ) { use kigi_tools::implementations::kigi::ask_user_question::{ AskUserQuestionExtRequest, Question, QuestionOption, }; let req = AskUserQuestionExtRequest { session_id: "test-session".into(), tool_call_id: tool_call_id.into(), mode: kigi_tools::implementations::kigi::ask_user_question::AskUserQuestionMode::Default, questions: vec![Question { question: "ACP-driven question".into(), options: vec![QuestionOption { label: "ok".into(), description: "ok".into(), preview: None, id: None, }], multi_select: Some(false), id: None, }], }; let (tx, rx) = tokio::sync::oneshot::channel(); let ext = acp::ExtRequest::new( "kigi/ask_user_question", serde_json::value::to_raw_value(&req) .expect("serialize AskUserQuestionExtRequest") .into(), ); ( kigi_acp_lib::AcpArgs { request: ext, response_tx: tx, }, rx, ) } fn set_forked_from(app: &mut AppView, child: AgentId, parent: AgentId) { if let Some(agent) = app.agents.get_mut(&child) { agent.session.forked_from = Some(parent); } } fn make_bg_task(task_id: &str) -> crate::app::agent::BgTaskState { crate::app::agent::BgTaskState { task_id: task_id.into(), tool_call_id: String::new(), command: "sleep 99".into(), description: None, cwd: String::new(), output_file: String::new(), status: crate::app::agent::BgTaskStatus::Running, start_time: std::time::SystemTime::now(), end_time: None, exit_code: None, signal: None, stdout: String::new(), stdout_line_count: 0, truncated: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, is_monitor: false, restored_from_replay: false, } } /// Set up a two-agent app: agent 0 is active with "sess-A", /// agent 1 is inactive with "sess-B" and a bg task. fn two_agent_app_with_bg_task() -> AppView { let mut app = test_app_with_agent(); app.agents[&AgentId(0)].session.session_id = Some(acp::SessionId::new("sess-A")); let id1 = AgentId(1); let mut agent1 = AgentView::new( AgentSession { id: id1, acp_tx: app.acp_tx.clone(), session_id: Some(acp::SessionId::new("sess-B")), models: ModelState::default(), state: AgentState::Idle, tracker: AcpUpdateTracker::new(), cwd: PathBuf::from("/tmp"), is_worktree: false, forked_from: None, pending_prompts: std::collections::VecDeque::new(), next_queue_id: 0, yolo_mode: false, auto_mode: false, prompt_history: Vec::new(), prompt_history_loading: false, loading_replay: false, restore_degree: None, rate_limited: false, model_incompatible: false, available_commands: Vec::new(), available_commands_generation: 0, available_tools: None, model_switch_pending: false, user_model_preference: None, deferred_model_switch: None, bg_tasks: std::collections::BTreeMap::new(), bg_tool_call_to_task: std::collections::HashMap::new(), scheduled_tasks: std::collections::HashMap::new(), in_flight_prompt: None, current_prompt_id: None, created_via_new: false, }, ScrollbackState::new(), ); let mut task = make_bg_task("task-B-1"); task.pending_kill = true; task.kill_requested_at = Some(std::time::Instant::now()); agent1.session.bg_tasks.insert("task-B-1".into(), task); app.agents.insert(id1, agent1); app.next_agent_id = 2; assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0)))); app } fn project_picker_app() -> AppView { let mut app = test_app(); app.cwd = PathBuf::from("/tmp"); app.project_picker_shown = false; app } /// Test helper: open Settings then OpenResetConfirm for `key`. /// Extracted so individual tests don't have to repeat the /// open-then-open ritual. fn setup_reset_confirm_open(app: &mut AppView, key: crate::settings::SettingKey) { use crate::views::modal::ActiveModal; let _ = dispatch(Action::OpenSettings, app); let _ = dispatch(Action::OpenResetConfirm { key }, app); let agent = app.agents.get(&AgentId(0)).expect("agent must exist"); assert!( matches!( agent.active_modal, Some(ActiveModal::ResetSettingsConfirm { .. }) ), "setup_reset_confirm_open: ResetSettingsConfirm must be active", ); } fn make_picker_entry(id: &str, cwd: &str) -> crate::app::app_view::SessionPickerEntry { crate::app::app_view::SessionPickerEntry { id: id.into(), summary: id.into(), updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), cwd: cwd.into(), hostname: None, source: "local".into(), model_id: None, num_messages: 0, last_active_at: None, branch: None, repo_name: "repo".into(), worktree_label: None, card_detail: None, } } fn make_conversation_entry(id: &str) -> crate::app::app_view::SessionPickerEntry { let mut e = make_picker_entry(id, ""); e.source = "conversation".into(); e } /// Open a SessionPicker modal on the active agent seeded with `entries`. fn open_session_picker_with( app: &mut AppView, entries: Vec, ) { use crate::views::modal::ActiveModal; let agent = get_active_agent_mut(app).expect("active agent"); agent.active_modal = Some(ActiveModal::SessionPicker { state: crate::views::picker::PickerState::default(), entries: Some(entries), loading: false, lanes: Default::default(), previous_palette: None, window: crate::views::modal_window::ModalWindowState::new(), content_results: None, content_loading: false, deep_search_seq: 0, entries_query: None, source_filter: crate::views::session_picker::SourceFilter::default(), pending_delete: None, }); } /// Toast strings match the expected format and contain on/off /// status. fn read_toast(app: &AppView) -> String { let agent = app.agents.get(&AgentId(0)).expect("agent must exist"); agent .toast .as_ref() .map(|(s, _)| s.clone()) .expect("toast should be set") } /// Helper: enqueue a single permission containing the new /// "enable-always-approve" option (AllowOnce kind, position 0 — /// default-selected by the real `enqueue_permission` helper), /// a regular "opt-allow-once" (AllowOnce kind, position 1), and /// a "opt-reject-once" (RejectOnce, position 2). Mirrors the /// option list the shell builds for TUI/Pager/Desktop. /// Returns the response receiver for the injected permission. fn enqueue_permission_with_enable_always_approve( app: &mut AppView, ) -> tokio::sync::oneshot::Receiver> { use crate::views::permission_view::{PermissionFocus, PermissionViewState}; use std::sync::Arc; let agent = app.agents.get_mut(&AgentId(0)).unwrap(); let (response_tx, 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-enable-aa-1")), acp::ToolCallUpdateFields::default(), ), vec![ acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from( kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID, )), "Yes, and don't ask again for anything", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("opt-allow-once")), "Yes, proceed", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("opt-reject-once")), "No", 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-enable-always-approve".to_string(), description: vec![], args_expanded: false, desc_scroll: 0, subagent_label: None, options_area_height: 0, options_scroll_offset: 0, }); response_rx } const POLICY_WARNING: &str = kigi_workspace::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS; fn agent_toast(app: &AppView) -> Option { app.agents[&AgentId(0)] .toast .as_ref() .map(|(s, _)| s.clone()) } /// Use the `theme_cache::test_lock` to serialize tests that touch /// the in-memory theme state (single mutable global). Mirrors the /// pattern used by `theme::cache::tests`. fn with_theme_test_env(f: impl FnOnce()) { let _guard = crate::theme::cache::test_lock() .lock() .unwrap_or_else(|e| e.into_inner()); crate::theme::cache::reset_for_test(); crate::theme::cache::seed_auto_theme_defaults_for_test(); crate::theme::cache::set(crate::theme::ThemeKind::KigiNight); crate::theme::system_appearance::clear_mock(); f(); crate::theme::system_appearance::clear_mock(); crate::theme::cache::reset_for_test(); } fn agent_scrollback_len(app: &AppView) -> usize { app.agents.get(&AgentId(0)).unwrap().scrollback.len() } use crate::scrollback::blocks::UserPromptBlock; /// Helper: open the dashboard against an existing `app`. fn open_dashboard(app: &mut AppView) { let _ = dispatch_open_dashboard(app); } /// Display-order list of selectable row ids — the same order /// `dashboard_neighbor_row` and the renderer walk. Test-only mirror /// of the row build in `dispatch_dashboard_select`. fn dashboard_row_order(app: &AppView) -> Vec { let d = app.dashboard.as_ref().unwrap(); let home = crate::views::dashboard::render::cached_home(); let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode { &app.leader_roster } else { &app.dashboard_local_sessions }; let rows = crate::views::dashboard::build_rows_with_roster( &app.agents, &d.pinned, &d.reorder, None, d.grouping, &d.filter, home, roster, ); crate::views::dashboard::render::focusables( &rows, d.grouping, &d.filter, &d.collapsed_sections, d.idle_show_all, d.search_mode, ) .into_iter() .filter_map(|f| match f { crate::views::dashboard::Focusable::Row(id) => Some(id), crate::views::dashboard::Focusable::Section(_) | crate::views::dashboard::Focusable::IdleOverflow => None, }) .collect() } /// Build a synthetic `PermissionViewState` with the given id and /// options. Pushes it to the agent's permission_queue. /// /// Returns the response receiver so tests can verify /// the response was actually `send`'d through the oneshot. The /// previous version dropped the receiver (`_rx`), which let /// "happy-path" tests assert the queue was popped but masked /// regressions where the pop happened without the corresponding /// send. fn push_synthetic_permission( agent: &mut crate::app::agent_view::AgentView, id: usize, options: Vec<(&str, &str)>, ) -> tokio::sync::oneshot::Receiver> { use crate::views::permission_view::{PermissionFocus, PermissionViewState}; let (tx, rx) = tokio::sync::oneshot::channel::>(); let request = kigi_acp_lib::AcpArgs { request: acp::RequestPermissionRequest::new( acp::SessionId::new(std::sync::Arc::from("sess-1")), acp::ToolCallUpdate::new( acp::ToolCallId::new(std::sync::Arc::from("tc-1")), acp::ToolCallUpdateFields::default(), ), options .iter() .map(|(oid, name)| { acp::PermissionOption::new( acp::PermissionOptionId::new(std::sync::Arc::from(*oid)), name.to_string(), if *oid == "reject" { acp::PermissionOptionKind::RejectOnce } else { acp::PermissionOptionKind::AllowOnce }, ) }) .collect(), ), response_tx: tx, }; let opts = request.request.options.clone(); let state = PermissionViewState { request, id, focus: PermissionFocus::Options, options: opts, active_idx: 0, bash_highlights: None, bash_selection_count: 0, bash_command_raw: None, mcp_scope: None, title: "Test permission".to_string(), description: Vec::new(), args_expanded: false, desc_scroll: 0, subagent_label: None, options_area_height: 0, options_scroll_offset: 0, }; agent.permission_queue.push_back(state); rx } const MOUSE_OFF_STICKY: &str = crate::app::MOUSE_OFF_HINT_SCROLLBACK; fn reset_mouse_capture_enabled(on: bool) { crate::app::MOUSE_CAPTURE_ENABLED.store(on, std::sync::atomic::Ordering::Release); } fn mouse_capture_is_enabled() -> bool { crate::app::MOUSE_CAPTURE_ENABLED.load(std::sync::atomic::Ordering::Acquire) }