M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,320 @@
|
||||
//! Tests for login, logout, account switching, and auth-code dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── agent-bound kinds (bash) ─────────
|
||||
|
||||
/// A bash command typed while a turn is RUNNING takes the
|
||||
/// server-authoritative immediate path (Effect + optimistic echo, no local
|
||||
/// queue entry).
|
||||
#[test]
|
||||
fn bash_while_running_is_server_authoritative() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
let effects = dispatch(Action::SendBashCommand("ls -la".into()), &mut app);
|
||||
let pid = match &effects[0] {
|
||||
Effect::SendBashCommand {
|
||||
command, prompt_id, ..
|
||||
} => {
|
||||
assert_eq!(command, "ls -la");
|
||||
prompt_id.clone()
|
||||
}
|
||||
other => panic!("expected immediate SendBashCommand, got {other:?}"),
|
||||
};
|
||||
// Not in the local queue.
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
||||
// Optimistic echo present with kind="bash".
|
||||
let q = app
|
||||
.shared_prompt_queue("test-session")
|
||||
.expect("echo present");
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_eq!(q[0].id, pid);
|
||||
assert_eq!(q[0].kind, "bash");
|
||||
assert_eq!(q[0].text, "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_triggers_bundle_status_fetch() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(matches!(app.auth_state, AuthState::Done));
|
||||
// Pager only refreshes the on-disk catalog snapshot; the actual
|
||||
// bundle download now runs inside the shell post-auth.
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchBundleStatus))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_with_deferred_load_also_fetches_status() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
app.deferred_startup.session =
|
||||
Some(crate::app::session_startup::DeferredSessionStartup::Load {
|
||||
session_id: "test-session".into(),
|
||||
session_cwd: None,
|
||||
chat_kind: false,
|
||||
});
|
||||
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::FetchBundleStatus))
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { .. }))
|
||||
);
|
||||
assert!(app.deferred_startup.session.is_none());
|
||||
}
|
||||
|
||||
/// `/login` from the welcome screen (startup / logged-out) must NOT
|
||||
/// stash a return view — the normal login-then-load flow is preserved.
|
||||
#[test]
|
||||
fn login_from_welcome_does_not_stash_return_view() {
|
||||
let mut app = test_app();
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
|
||||
dispatch(Action::Login, &mut app);
|
||||
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
assert_eq!(app.auth_return_view, None);
|
||||
}
|
||||
|
||||
/// A second auth-failed turn with no rewindable prompt
|
||||
/// (`in_flight_prompt == None`) must not clobber the stash from an
|
||||
/// earlier 401.
|
||||
#[test]
|
||||
fn second_auth_failure_does_not_clobber_reauth_stash() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
|
||||
text: "first prompt".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: crate::scrollback::EntryId::new(0),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.turn_started_at = Some(std::time::Instant::now());
|
||||
agent.session.in_flight_prompt = None;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::PromptResponse {
|
||||
agent_id: id,
|
||||
result: Err("Unauthorized (401)".to_string()),
|
||||
http_status: Some(401),
|
||||
prompt_id: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.agents[&id]
|
||||
.reauth_stashed_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.text.as_str()),
|
||||
Some("first prompt"),
|
||||
"a None in_flight_prompt must not wipe an earlier stash"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancelling a mid-session re-auth drops the stashed prompt so it is
|
||||
/// not silently resubmitted on a later, unrelated login.
|
||||
#[test]
|
||||
fn cancel_login_drops_reauth_stashed_prompt() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().reauth_stashed_prompt =
|
||||
Some(crate::app::agent::InFlightPrompt {
|
||||
text: "stale".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: crate::scrollback::EntryId::new(0),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
|
||||
dispatch(Action::Login, &mut app);
|
||||
dispatch(Action::CancelLogin, &mut app);
|
||||
|
||||
assert!(
|
||||
app.agents[&id].reauth_stashed_prompt.is_none(),
|
||||
"cancelling re-auth must drop the stashed prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancelling a mid-session re-auth strips the stale `ReAuthRequired`
|
||||
/// prompt from scrollback so a later `PromptResponse` cannot re-detect
|
||||
/// it and re-stash the prompt for silent resubmission.
|
||||
#[test]
|
||||
fn cancel_login_strips_reauth_prompt_from_scrollback() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
|
||||
text: "stale".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: crate::scrollback::EntryId::new(0),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
||||
}
|
||||
|
||||
dispatch(Action::Login, &mut app);
|
||||
dispatch(Action::CancelLogin, &mut app);
|
||||
|
||||
let sb = &app.agents[&id].scrollback;
|
||||
let has_reauth = (0..sb.len()).any(|i| {
|
||||
matches!(
|
||||
sb.entry(i).map(|e| &e.block),
|
||||
Some(RenderBlock::SessionEvent(ev)) if matches!(ev.event, SessionEvent::ReAuthRequired)
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!has_reauth,
|
||||
"cancelling re-auth must strip the stale re-auth prompt from scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty `auth_methods` (preferred_method pin unavailable) must not invent
|
||||
/// `grok.com` or start an OIDC flow the agent did not advertise.
|
||||
#[test]
|
||||
fn login_with_empty_auth_methods_fails_closed() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.auth_methods.clear();
|
||||
app.login_method_id = None;
|
||||
|
||||
let effects = dispatch(Action::Login, &mut app);
|
||||
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"must not start Authenticate without an advertised method"
|
||||
);
|
||||
assert_eq!(
|
||||
app.active_view,
|
||||
ActiveView::Agent(AgentId(0)),
|
||||
"must stay on the session view"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
&app.auth_state,
|
||||
AuthState::Pending { error: Some(msg) }
|
||||
if msg.contains("preferred_method=api_key")
|
||||
),
|
||||
"must surface pin-unavailable error, got {:?}",
|
||||
app.auth_state
|
||||
);
|
||||
assert!(app.login_method_id.is_none());
|
||||
}
|
||||
|
||||
/// Cancelling a mid-session login returns to the session rather than
|
||||
/// quitting the app, and clears the stashed view + auth state.
|
||||
#[test]
|
||||
fn cancel_login_restores_view() {
|
||||
let mut app = test_app_with_agent();
|
||||
dispatch(Action::Login, &mut app);
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
|
||||
let effects = dispatch(Action::CancelLogin, &mut app);
|
||||
|
||||
assert!(effects.is_empty(), "cancel is pure state, no effects");
|
||||
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
||||
assert_eq!(app.auth_return_view, None);
|
||||
assert!(matches!(app.auth_state, AuthState::Done));
|
||||
}
|
||||
|
||||
/// `CancelLogin` outside a mid-session login is a no-op (must not move
|
||||
/// off the welcome screen or panic).
|
||||
#[test]
|
||||
fn cancel_login_noop_without_stashed_view() {
|
||||
let mut app = test_app();
|
||||
let effects = dispatch(Action::CancelLogin, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
assert_eq!(app.auth_return_view, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_extracts_show_resolved_model_from_meta() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
assert!(app.show_resolved_model);
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: Some(serde_json::json!({ "show_resolved_model": false })),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.show_resolved_model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_complete_preserves_show_resolved_model_when_absent() {
|
||||
let mut app = test_app();
|
||||
app.show_resolved_model = false;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq: 1,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: 1,
|
||||
meta: Some(serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!app.show_resolved_model);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,414 @@
|
||||
//! Tests for the `/jump` picker dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn push_turns(app: &mut AppView, id: AgentId, n: usize) {
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
for i in 0..n {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt(format!("question {i}")));
|
||||
let tall = (0..8)
|
||||
.map(|p| format!("answer {i} para {p}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::agent_message(tall));
|
||||
}
|
||||
agent.scrollback.prepare_layout(80, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_needs_two_turns() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 1);
|
||||
|
||||
let effects = dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"a single turn has nothing to jump to"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_snapshots_viewport_and_opens_on_active_turn() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
let js = agent.jump_state.as_ref().expect("picker open");
|
||||
assert_eq!(js.entries.len(), 3);
|
||||
assert_eq!(js.entries[0].preview, "question 0");
|
||||
assert_eq!(js.selected, 2, "opens on the turn at the viewport top");
|
||||
assert!(
|
||||
js.restore.bookmark.is_some(),
|
||||
"captured a viewport bookmark"
|
||||
);
|
||||
assert!(js.restore.follow_mode, "goto_bottom left follow on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_rewind_open() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().rewind_state = Some(
|
||||
crate::views::rewind::RewindState::new_cancel_offer(0, None, None),
|
||||
);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_inline_edit_open() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
assert!(
|
||||
app.agents.get_mut(&id).unwrap().enter_inline_edit(0),
|
||||
"entered inline edit on the first prompt"
|
||||
);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"picker must not stack on an open inline edit (wheel scroll would leak)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_input_overlay_pending() {
|
||||
// A pending permission / question / cancel-turn / plan-approval overlay
|
||||
// suppresses the picker's rendering, so opening one would be invisible but
|
||||
// still eat wheel/keys — `/jump` must refuse.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().cancel_turn_view =
|
||||
Some(crate::views::modal::CancelTurnViewState {
|
||||
active_idx: 0,
|
||||
running_count: 1,
|
||||
});
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"/jump must not open behind a pending input overlay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_drops_hidden_jump_picker_behind_input_overlay() {
|
||||
// If an input overlay arrives (async) after the picker opened, the picker
|
||||
// is hidden but `jump_state` lingers; a wheel event must drop it instead of
|
||||
// scrolling a cursor the user can't see (and shifting the transcript).
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
app.agents.get_mut(&id).unwrap().cancel_turn_view =
|
||||
Some(crate::views::modal::CancelTurnViewState {
|
||||
active_idx: 0,
|
||||
running_count: 1,
|
||||
});
|
||||
app.agents.get_mut(&id).unwrap().handle_scroll(1, 0, 0);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(
|
||||
agent.jump_state.is_none(),
|
||||
"a hidden picker is dropped on scroll, not driven"
|
||||
);
|
||||
assert!(
|
||||
agent.cancel_turn_view.is_some(),
|
||||
"the suppressing overlay is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_drops_hidden_jump_picker_behind_input_overlay() {
|
||||
// The key-path mirror: with an input overlay pending (and, as here, the
|
||||
// scrollback pane focused so the pane-gated cancel-turn panel is skipped),
|
||||
// a key must drop the hidden picker instead of the picker handling it.
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
app.agents.get_mut(&id).unwrap().cancel_turn_view =
|
||||
Some(crate::views::modal::CancelTurnViewState {
|
||||
active_idx: 0,
|
||||
running_count: 1,
|
||||
});
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
let ev = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
let _ = app.agents.get_mut(&id).unwrap().handle_input(&ev, ®);
|
||||
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"a hidden picker is dropped before it can handle keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_stays_cancellable_with_jump_open() {
|
||||
use crate::app::agent::AgentState;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
// /jump must not swallow the advertised Ctrl+C while a turn is running.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
let ev = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
let outcome = app.agents.get_mut(&id).unwrap().handle_input(&ev, ®);
|
||||
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"Ctrl+C dismissed the picker"
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
|
||||
"and cancelled the running turn, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_picker_refused_while_btw_open() {
|
||||
// /btw owns the prompt slot; opening /jump behind it would split input.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().btw_state = Some(
|
||||
crate::views::btw_overlay::BtwOverlayState::done("q".into(), "a".into()),
|
||||
);
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"/jump must not open behind /btw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_reload_dismisses_jump_picker() {
|
||||
// jump_state indexes the pre-reload transcript; a reconnect must drop it.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
|
||||
|
||||
app.agents.get_mut(&id).unwrap().begin_session_reload(1);
|
||||
assert!(
|
||||
app.agents[&id].jump_state.is_none(),
|
||||
"reload cleared the picker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_select_jumps_and_closes() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
let target_id = app.agents[&id].jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let target_entry = app.agents[&id].scrollback.index_of_id(target_id).unwrap();
|
||||
|
||||
dispatch(Action::JumpPickerSelect(target_id), &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none(), "picker closed");
|
||||
assert_eq!(agent.scrollback.selected(), Some(target_entry));
|
||||
assert_eq!(agent.scrollback.current_turn(), Some(0));
|
||||
assert!(!agent.scrollback.is_follow_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_select_uses_stable_id_across_removal() {
|
||||
// The picker carries a stable EntryId, so removing an earlier entry (which
|
||||
// shifts every positional index) still lands the jump on the intended
|
||||
// prompt — a positional turn index would target the wrong block.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 4);
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
|
||||
let (first_id, target_id) = {
|
||||
let entries = &app.agents[&id].jump_state.as_ref().unwrap().entries;
|
||||
(
|
||||
entries[0].prompt_entry_id,
|
||||
entries.last().unwrap().prompt_entry_id,
|
||||
)
|
||||
};
|
||||
// Remove the first turn's prompt, shifting the positional indices.
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(first_id);
|
||||
|
||||
dispatch(Action::JumpPickerSelect(target_id), &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none(), "picker closed");
|
||||
let expected = agent
|
||||
.scrollback
|
||||
.index_of_id(target_id)
|
||||
.expect("target prompt still present");
|
||||
assert_eq!(
|
||||
agent.scrollback.selected(),
|
||||
Some(expected),
|
||||
"stable id lands on the intended prompt even after indices shifted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_select_restores_viewport_on_out_of_range_turn() {
|
||||
// A turn index can go stale if the turn list shrank (async clear/rewind)
|
||||
// while the picker was open; selecting it must restore the captured
|
||||
// viewport instead of stranding the transcript at the last preview.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
let at_bottom = app.agents[&id].scrollback.scroll_offset();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
// Move the preview far from the snapshot so a restore is observable.
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let first = agent.scrollback.index_of_id(first_id).unwrap();
|
||||
agent.scrollback.scroll_to_entry_center(first);
|
||||
}
|
||||
assert_ne!(app.agents[&id].scrollback.scroll_offset(), at_bottom);
|
||||
|
||||
dispatch(
|
||||
Action::JumpPickerSelect(crate::scrollback::entry::EntryId::new(999_999)),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none(), "picker closed");
|
||||
assert_eq!(
|
||||
agent.scrollback.scroll_offset(),
|
||||
at_bottom,
|
||||
"a failed jump restores the captured viewport"
|
||||
);
|
||||
assert!(agent.scrollback.is_follow_mode(), "follow restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_dismisses_open_jump_picker() {
|
||||
// The mirror of `show_picker_refused_while_rewind_open`: starting rewind
|
||||
// while the picker is open must dismiss it (and restore its viewport), so
|
||||
// the input-shadowed picker can't reappear stale once rewind closes.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
let before_offset = app.agents[&id].scrollback.scroll_offset();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
// Preview a far turn so the viewport actually moved under the picker.
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let first = agent.scrollback.index_of_id(first_id).unwrap();
|
||||
agent.scrollback.scroll_to_entry_center(first);
|
||||
}
|
||||
assert!(app.agents[&id].jump_state.is_some());
|
||||
assert_ne!(app.agents[&id].scrollback.scroll_offset(), before_offset);
|
||||
|
||||
dispatch(Action::Rewind, &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(
|
||||
agent.jump_state.is_none(),
|
||||
"rewind dismissed the jump picker"
|
||||
);
|
||||
assert!(agent.rewind_state.is_some(), "rewind opened");
|
||||
assert_eq!(
|
||||
agent.scrollback.scroll_offset(),
|
||||
before_offset,
|
||||
"the jump viewport was restored before rewind took over"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_edit_dismisses_open_jump_picker() {
|
||||
// The mirror of `show_picker_refused_while_inline_edit_open`: entering
|
||||
// inline edit while the picker is open dismisses it so it can't reappear
|
||||
// stale. (Inline edit re-centers on the edited entry, so only the picker
|
||||
// teardown is asserted, not the viewport.)
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
assert!(app.agents[&id].jump_state.is_some());
|
||||
|
||||
let entered = app.agents.get_mut(&id).unwrap().enter_inline_edit(0);
|
||||
assert!(entered, "entered inline edit on the first prompt");
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(
|
||||
agent.jump_state.is_none(),
|
||||
"entering inline edit dismissed the jump picker"
|
||||
);
|
||||
assert!(agent.inline_edit.is_some(), "inline edit opened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_restores_viewport() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
push_turns(&mut app, id, 3);
|
||||
{
|
||||
let sb = &mut app.agents.get_mut(&id).unwrap().scrollback;
|
||||
sb.goto_bottom();
|
||||
}
|
||||
let before_offset = app.agents[&id].scrollback.scroll_offset();
|
||||
let before_selected = app.agents[&id].scrollback.selected();
|
||||
|
||||
dispatch(Action::JumpShowPicker, &mut app);
|
||||
// Preview a far-away turn so the transcript actually moved.
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
|
||||
let first = agent.scrollback.index_of_id(first_id).unwrap();
|
||||
agent.scrollback.scroll_to_entry_center(first);
|
||||
}
|
||||
assert_ne!(app.agents[&id].scrollback.scroll_offset(), before_offset);
|
||||
|
||||
dispatch(Action::JumpDismiss, &mut app);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert!(agent.jump_state.is_none());
|
||||
assert_eq!(agent.scrollback.scroll_offset(), before_offset);
|
||||
assert_eq!(agent.scrollback.selected(), before_selected);
|
||||
assert!(agent.scrollback.is_follow_mode(), "follow restored");
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! 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::billing::{
|
||||
CreditLimitUpsellMode, credit_limit_upsell_mode, is_max_tier, open_credit_limit_upsell,
|
||||
open_free_usage_upsell,
|
||||
};
|
||||
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,
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
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("grok.com"),
|
||||
"Grok".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,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
is_zdr: false,
|
||||
team_role: None,
|
||||
coding_data_retention_opt_out: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
ask_user_question_timeout_enabled: None,
|
||||
zdr_access_enabled: false,
|
||||
usage_billing_redirect_url: None,
|
||||
access_gate_shown_logged: false,
|
||||
gate: None,
|
||||
subscription_tier: None,
|
||||
paywall_check_started: None,
|
||||
last_subscription_check_at: None,
|
||||
subscription_watch_interval_secs: None,
|
||||
pending_gate_verification: None,
|
||||
gate_verify_gen: 0,
|
||||
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_show_changelog_action: false,
|
||||
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_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_refresh_rect: None,
|
||||
welcome_gate_url_rect: None,
|
||||
welcome_changelog_cta_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,
|
||||
sharing_enabled: false,
|
||||
usage_visible: true,
|
||||
tier_restricted_commands: Vec::new(),
|
||||
leader_mode: true,
|
||||
credit_balance: None,
|
||||
auto_topup: None,
|
||||
billing_poll_wanted: false,
|
||||
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,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: 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,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: 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<bool>, 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<acp::ExtRequest>` for an
|
||||
/// `x.ai/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<acp::ExtRequest>,
|
||||
tokio::sync::oneshot::Receiver<kigi_acp_lib::AcpResult<acp::ExtResponse>>,
|
||||
) {
|
||||
use kigi_tools::implementations::grok_build::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::grok_build::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(
|
||||
"x.ai/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,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: 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<crate::app::app_view::SessionPickerEntry>,
|
||||
) {
|
||||
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<acp::Result<acp::RequestPermissionResponse>> {
|
||||
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<String> {
|
||||
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::GrokNight);
|
||||
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<crate::views::dashboard::DashboardRowId> {
|
||||
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<Result<acp::RequestPermissionResponse, acp::Error>> {
|
||||
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
|
||||
let (tx, rx) =
|
||||
tokio::sync::oneshot::channel::<Result<acp::RequestPermissionResponse, acp::Error>>();
|
||||
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)
|
||||
}
|
||||
/// Build a minimal `CreditBalance` for billing dispatch tests.
|
||||
fn test_bal(usage_pct: f64) -> crate::views::credit_bar::CreditBalance {
|
||||
crate::views::credit_bar::CreditBalance {
|
||||
usage_pct,
|
||||
effective_usage_pct: usage_pct,
|
||||
period_end_display: None,
|
||||
pay_as_you_go: false,
|
||||
on_demand_cap_cents: None,
|
||||
on_demand_used_cents: None,
|
||||
prepaid_balance_cents: None,
|
||||
period_type: None,
|
||||
is_unified_billing_user: None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
//! Tests for feedback / remember / btw / recap dispatchers.
|
||||
|
||||
use super::*;
|
||||
use crate::app::dispatch::{recap_unavailable_toast, scrollback_has_user_messages};
|
||||
|
||||
#[test]
|
||||
fn recap_unavailable_toast_empty_vs_with_messages() {
|
||||
assert_eq!(recap_unavailable_toast(false), "No messages yet");
|
||||
assert_eq!(recap_unavailable_toast(true), "Couldn't generate recap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_recap_with_no_messages_toasts_empty_state_and_skips_request() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.prompt.set_text("/recap");
|
||||
assert!(!scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"empty session must not fire x.ai/recap: {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_none(), "no loading spinner");
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(s, _)| s.as_str()),
|
||||
Some("No messages yet"),
|
||||
"empty session should say No messages yet, not Couldn't generate recap"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), "", "slash command text is cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_recap_with_messages_requests_and_shows_spinner() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("hello"));
|
||||
assert!(scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
|
||||
"expected SendRecap effect, got {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(
|
||||
agent.pending_recap_entry.is_some(),
|
||||
"manual recap shows a loading spinner when there is something to summarize"
|
||||
);
|
||||
assert!(agent.toast.is_none());
|
||||
}
|
||||
|
||||
/// Regression: during session/load, scrollback is batched so
|
||||
/// `turn_count()` stays 0 until `end_batch`, but UserPrompt entries may already
|
||||
/// be present. Manual `/recap` must still request a recap.
|
||||
#[test]
|
||||
fn manual_recap_during_batch_load_with_prompts_still_requests() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.scrollback.begin_batch();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("hello from resume"));
|
||||
// Batched push defers rebuild_turns — turn index is stale, entries aren't.
|
||||
assert_eq!(agent.scrollback.turn_count(), 0);
|
||||
assert!(scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
|
||||
"batched resume with user prompts must still fire x.ai/recap: {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_some());
|
||||
assert!(agent.toast.is_none());
|
||||
// Clean up batch for the test fixture (not required for the assertion).
|
||||
app.agents.get_mut(&id).unwrap().scrollback.end_batch();
|
||||
}
|
||||
|
||||
/// While session replay is still streaming, don't claim "No messages yet" even
|
||||
/// if scrollback looks empty — history may arrive on the next notification.
|
||||
#[test]
|
||||
fn manual_recap_while_loading_replay_still_requests() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.session_recap_available = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.session.loading_replay = true;
|
||||
assert!(!scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
|
||||
"loading_replay must not short-circuit to No messages yet: {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_some());
|
||||
assert!(agent.toast.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_request_transport_failure_with_no_turns_uses_empty_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let session_id = app.agents[&id].session.session_id.clone().unwrap();
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
RenderBlock::session_event(SessionEvent::Recap {
|
||||
summary: String::new(),
|
||||
auto: false,
|
||||
}),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
assert!(!scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::RecapRequested {
|
||||
session_id,
|
||||
auto: false,
|
||||
error: Some("transport down".into()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(s, _)| s.as_str()),
|
||||
Some("No messages yet")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_request_transport_failure_with_turns_uses_generic_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let session_id = app.agents[&id].session.session_id.clone().unwrap();
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("hello"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
RenderBlock::session_event(SessionEvent::Recap {
|
||||
summary: String::new(),
|
||||
auto: false,
|
||||
}),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
assert!(scrollback_has_user_messages(&agent.scrollback));
|
||||
}
|
||||
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::RecapRequested {
|
||||
session_id,
|
||||
auto: false,
|
||||
error: Some("transport down".into()),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
assert_eq!(
|
||||
agent.toast.as_ref().map(|(s, _)| s.as_str()),
|
||||
Some("Couldn't generate recap")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
||||
//! Tests for session lifecycle, loading, pickers, modals, forking, and trust.
|
||||
|
||||
use super::*;
|
||||
|
||||
mod foreign;
|
||||
mod fork;
|
||||
mod lifecycle;
|
||||
mod load;
|
||||
mod modal;
|
||||
mod take_deferred;
|
||||
|
||||
/// Like [`test_app`] but with `cwd` set to this crate's directory,
|
||||
/// which lives inside the git repo. Worktree tests require a git
|
||||
/// ancestor to pass the `has_git_ancestor` pre-check.
|
||||
fn test_app_git() -> AppView {
|
||||
let mut app = test_app();
|
||||
app.cwd = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
app.cwd_has_git_ancestor = true;
|
||||
app
|
||||
}
|
||||
|
||||
fn count_extension_fetches(effects: &[Effect]) -> usize {
|
||||
effects
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Effect::FetchHooksList { .. }
|
||||
| Effect::FetchPluginsList { .. }
|
||||
| Effect::FetchMcpsList { .. }
|
||||
| Effect::FetchSkillsList { .. }
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Build a single-agent app for the `/new` dispatcher tests.
|
||||
///
|
||||
/// Sets `current_branch` to `Some("main")` so the agent appears to be
|
||||
/// inside a git repo (mirrors `fork_test_app`).
|
||||
fn new_session_test_app() -> AppView {
|
||||
let mut app = test_app_with_agent();
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().current_branch = Some("main".into());
|
||||
app.cwd_has_git_ancestor = true;
|
||||
app
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Tests for session-related modals (extensions, /new worktree question)
|
||||
//! and session close helpers shared with the dashboard.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_extensions_modal_no_session_sets_flag_no_fetches() {
|
||||
use crate::views::extensions_modal::ExtensionsTab;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
||||
let effects = dispatch(
|
||||
Action::OpenExtensionsModal {
|
||||
tab: ExtensionsTab::Hooks,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(count_extension_fetches(&effects), 0);
|
||||
assert!(app.agents[&id].pending_extensions_fetch);
|
||||
assert!(app.agents[&id].extensions_modal.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_extensions_modal_with_session_emits_fetches_no_flag() {
|
||||
use crate::views::extensions_modal::ExtensionsTab;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::OpenExtensionsModal {
|
||||
tab: ExtensionsTab::Hooks,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(count_extension_fetches(&effects), 4);
|
||||
assert!(!app.agents[&id].pending_extensions_fetch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_extensions_modal_with_session_resets_stale_flag() {
|
||||
use crate::views::extensions_modal::ExtensionsTab;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().pending_extensions_fetch = true;
|
||||
let effects = dispatch(
|
||||
Action::OpenExtensionsModal {
|
||||
tab: ExtensionsTab::Hooks,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(count_extension_fetches(&effects), 4);
|
||||
assert!(!app.agents[&id].pending_extensions_fetch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_created_with_flag_but_modal_closed_clears_flag_no_fetches() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let a = app.agents.get_mut(&id).unwrap();
|
||||
a.session.session_id = None;
|
||||
a.pending_extensions_fetch = true;
|
||||
a.extensions_modal = None;
|
||||
}
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionCreated {
|
||||
agent_id: id,
|
||||
session_id: acp::SessionId::new("s"),
|
||||
models: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(count_extension_fetches(&effects), 0);
|
||||
assert!(!app.agents[&id].pending_extensions_fetch);
|
||||
}
|
||||
|
||||
// ── /new dispatcher tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dispatch_new_session_opens_question_modal_in_git_repo() {
|
||||
let mut app = new_session_test_app();
|
||||
app.new_session_worktree_mode = crate::app::app_view::WorktreeMode::Ask;
|
||||
let effects = dispatch(Action::NewSession, &mut app);
|
||||
assert!(effects.is_empty(), "no effects until modal answered");
|
||||
// No new agent yet (creation is deferred until modal answered).
|
||||
assert_eq!(app.agents.len(), 1);
|
||||
let qv = app.agents[&AgentId(0)]
|
||||
.question_view
|
||||
.as_ref()
|
||||
.expect("modal must be open");
|
||||
match qv.local_kind.as_ref().expect("local_kind must be set") {
|
||||
crate::views::question_view::LocalQuestionKind::NewSession => {}
|
||||
other => panic!("expected NewSession, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
qv.questions[0].options.len(),
|
||||
4,
|
||||
"modal must offer exactly 4 options (Yes/No/Always/Never)"
|
||||
);
|
||||
let labels: Vec<&str> = qv.questions[0]
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| o.label.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["Yes", "No", "Always worktree", "Never worktree"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_new_session_skips_modal_in_non_git_repo() {
|
||||
// current_branch stays None (no git repo) → no modal, straight
|
||||
// to dispatch_new_session_inner.
|
||||
let mut app = test_app_with_agent();
|
||||
let effects = dispatch(Action::NewSession, &mut app);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
||||
"non-git path must emit CreateSession, got {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
app.agents.values().all(|a| a.question_view.is_none()),
|
||||
"non-git path must not open the modal"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session close (shared with dashboard) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_inactive_agent_drops_it() {
|
||||
let mut app = three_agent_app();
|
||||
let effects = dispatch_sessions_confirm_close(&mut app, AgentId(2));
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.all(|e| matches!(e, Effect::UnregisterActiveSession { .. }))
|
||||
);
|
||||
assert!(!app.agents.contains_key(&AgentId(2)));
|
||||
assert_eq!(app.agents.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_agent_releases_retained_memory() {
|
||||
use crate::memory_release::test_support;
|
||||
test_support::install_counting_hook();
|
||||
|
||||
let mut app = three_agent_app();
|
||||
|
||||
// Dropping a real AgentView (scrollback + caches + child views) → purge.
|
||||
let before = test_support::calls();
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(2));
|
||||
assert!(!app.agents.contains_key(&AgentId(2)));
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before + 1,
|
||||
"dropping the closed AgentView must purge retained pages"
|
||||
);
|
||||
|
||||
// Closing an unknown agent drops nothing → no purge.
|
||||
let before = test_support::calls();
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(999));
|
||||
assert_eq!(
|
||||
test_support::calls(),
|
||||
before,
|
||||
"a no-op close must not purge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_clears_forked_from_on_surviving_children() {
|
||||
let mut app = three_agent_app();
|
||||
set_forked_from(&mut app, AgentId(2), AgentId(1));
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(1));
|
||||
assert!(
|
||||
app.agents[&AgentId(2)].session.forked_from.is_none(),
|
||||
"stale forked_from pointer must be cleared after parent close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_only_agent_is_refused_with_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
let agents_before = app.agents.len();
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(0));
|
||||
assert_eq!(
|
||||
app.agents.len(),
|
||||
agents_before,
|
||||
"the only agent must NOT be closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_unknown_agent_is_silent_noop() {
|
||||
let mut app = three_agent_app();
|
||||
let agents_before = app.agents.len();
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(999));
|
||||
assert_eq!(app.agents.len(), agents_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_only_agent_short_circuits_before_reaching_welcome_fallback() {
|
||||
let mut app = test_app_with_agent();
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(0)));
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(0));
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(0)));
|
||||
assert!(app.agents.contains_key(&AgentId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_does_not_disturb_unrelated_forked_from_pointers() {
|
||||
let mut app = three_agent_app();
|
||||
set_forked_from(&mut app, AgentId(1), AgentId(0));
|
||||
set_forked_from(&mut app, AgentId(2), AgentId(0));
|
||||
dispatch_sessions_confirm_close(&mut app, AgentId(1));
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(2)].session.forked_from,
|
||||
Some(AgentId(0)),
|
||||
"unrelated forked_from must NOT be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensions_modal_in_non_project_dir_creates_session() {
|
||||
let mut app = project_picker_app();
|
||||
dispatch(Action::NewSession, &mut app);
|
||||
let id = AgentId(0);
|
||||
|
||||
let effects = dispatch(
|
||||
Action::OpenExtensionsModal {
|
||||
tab: crate::views::extensions_modal::ExtensionsTab::McpServers,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
||||
"session-less modal open must create the deferred session"
|
||||
);
|
||||
assert!(app.agents[&id].pending_extensions_fetch);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use crate::acp::model_state::{EffortTokenError, ModelState};
|
||||
use crate::app::dispatch::session::lifecycle::{DeferredSwitchOutcome, take_deferred_model_switch};
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn model_with_support(id: &str, supports: bool) -> (acp::ModelId, acp::ModelInfo) {
|
||||
let id = acp::ModelId::new(Arc::from(id));
|
||||
let meta = if supports {
|
||||
Some(serde_json::json!({
|
||||
"supportsReasoningEffort": true,
|
||||
"reasoningEffort": "medium",
|
||||
"reasoningEfforts": [
|
||||
{ "id": "deep", "value": "xhigh", "label": "Deep" },
|
||||
{ "id": "high", "value": "high", "label": "High" },
|
||||
],
|
||||
}))
|
||||
} else {
|
||||
Some(serde_json::json!({ "reasoningEffort": "medium" }))
|
||||
};
|
||||
let info = acp::ModelInfo::new(id.clone(), id.0.to_string())
|
||||
.meta(meta.and_then(|v| v.as_object().cloned()));
|
||||
(id, info)
|
||||
}
|
||||
|
||||
fn models_with_current(supports: bool) -> ModelState {
|
||||
let (id, info) = model_with_support("grok-build", supports);
|
||||
let mut models = ModelState::default();
|
||||
models.available.insert(id.clone(), info);
|
||||
models.current = Some(id);
|
||||
models.reasoning_effort = Some(ReasoningEffort::Medium);
|
||||
models
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_resolves_canonical_token() {
|
||||
let models = models_with_current(true);
|
||||
let out = take_deferred_model_switch(None, &models, Some("high"));
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((models.current.clone().unwrap(), Some(ReasoningEffort::High))),
|
||||
effort_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_resolves_remapped_menu_id() {
|
||||
let models = models_with_current(true);
|
||||
let out = take_deferred_model_switch(None, &models, Some("deep"));
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((
|
||||
models.current.clone().unwrap(),
|
||||
Some(ReasoningEffort::Xhigh)
|
||||
)),
|
||||
effort_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_unsupported_canonical_token_is_unsupported() {
|
||||
// Gate-first: a canonical token on a model that doesn't support reasoning
|
||||
// effort surfaces Unsupported (matching `/effort` and headless) rather than
|
||||
// silently applying an effort the server would drop.
|
||||
let models = models_with_current(false);
|
||||
assert_eq!(
|
||||
take_deferred_model_switch(None, &models, Some("high")),
|
||||
DeferredSwitchOutcome {
|
||||
switch: None,
|
||||
effort_error: Some(EffortTokenError::Unsupported),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_unsupported_unknown_token_is_unsupported() {
|
||||
let models = models_with_current(false);
|
||||
assert_eq!(
|
||||
take_deferred_model_switch(None, &models, Some("bogus")),
|
||||
DeferredSwitchOutcome {
|
||||
switch: None,
|
||||
effort_error: Some(EffortTokenError::Unsupported),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_skips_when_already_equal() {
|
||||
let mut models = models_with_current(true);
|
||||
models.reasoning_effort = Some(ReasoningEffort::High);
|
||||
assert_eq!(
|
||||
take_deferred_model_switch(None, &models, Some("high")),
|
||||
DeferredSwitchOutcome {
|
||||
switch: None,
|
||||
effort_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_errors_on_unknown_token() {
|
||||
let models = models_with_current(true);
|
||||
assert_eq!(
|
||||
take_deferred_model_switch(None, &models, Some("bogus")),
|
||||
DeferredSwitchOutcome {
|
||||
switch: None,
|
||||
effort_error: Some(EffortTokenError::UnknownToken {
|
||||
token: "bogus".into(),
|
||||
offered: vec!["deep".into(), "high".into()],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stashed_model_switch_prefers_explicit_stash() {
|
||||
let models = models_with_current(true);
|
||||
let other = acp::ModelId::new(Arc::from("other-model"));
|
||||
let out = take_deferred_model_switch(
|
||||
Some((other.clone(), Some(ReasoningEffort::Low))),
|
||||
&models,
|
||||
Some("high"),
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((other, Some(ReasoningEffort::Low))),
|
||||
effort_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stashed_model_re_resolves_remap_when_effort_missing() {
|
||||
let models = models_with_current(true);
|
||||
let current = models.current.clone().unwrap();
|
||||
let out = take_deferred_model_switch(Some((current.clone(), None)), &models, Some("deep"));
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((current, Some(ReasoningEffort::Xhigh))),
|
||||
effort_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stashed_model_keeps_model_when_token_unresolvable() {
|
||||
let models = models_with_current(true);
|
||||
let current = models.current.clone().unwrap();
|
||||
let out = take_deferred_model_switch(Some((current.clone(), None)), &models, Some("bogus"));
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((current, None)),
|
||||
effort_error: Some(EffortTokenError::UnknownToken {
|
||||
token: "bogus".into(),
|
||||
offered: vec!["deep".into(), "high".into()],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stashed_model_keeps_model_when_unsupported() {
|
||||
// -m targets a non-reasoning model plus an effort token: keep the model
|
||||
// switch, drop the effort, and surface Unsupported.
|
||||
let mut models = models_with_current(true);
|
||||
let (plain, plain_info) = model_with_support("plain-model", false);
|
||||
models.available.insert(plain.clone(), plain_info);
|
||||
let out = take_deferred_model_switch(Some((plain.clone(), None)), &models, Some("high"));
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((plain, None)),
|
||||
effort_error: Some(EffortTokenError::Unsupported),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_accepts_max_as_xhigh() {
|
||||
let models = models_with_current(true);
|
||||
let out = take_deferred_model_switch(None, &models, Some("max"));
|
||||
assert_eq!(
|
||||
out,
|
||||
DeferredSwitchOutcome {
|
||||
switch: Some((
|
||||
models.current.clone().unwrap(),
|
||||
Some(ReasoningEffort::Xhigh)
|
||||
)),
|
||||
effort_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_only_errors_without_active_model() {
|
||||
let models = ModelState::default();
|
||||
assert_eq!(
|
||||
take_deferred_model_switch(None, &models, Some("high")),
|
||||
DeferredSwitchOutcome {
|
||||
switch: None,
|
||||
effort_error: Some(EffortTokenError::NoActiveModel),
|
||||
}
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,940 @@
|
||||
//! Tests for session status, sharing, privacy, and coding-data-sharing dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Regression (leader-mode turn-end race): when this client is briefly Idle
|
||||
/// (`is_turn_running() == false`, `current_prompt_id` cleared) but the server
|
||||
/// still has queued prompts — visible as a non-empty `shared_queue` mirror —
|
||||
/// a newly-sent prompt must route to the SERVER (immediate-send), NOT be
|
||||
/// locally drained as a phantom running turn. The failure mode: a
|
||||
/// `send_route_plain immediate=false is_turn_running=false shared_queue_len=5`
|
||||
/// path taking `local_drain`, leaving the prompt shown running on the sender
|
||||
/// while it was actually queued behind the existing entries on the leader and
|
||||
/// every other client.
|
||||
#[test]
|
||||
fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
// Two prompts already queued on the server (as a broadcast would leave
|
||||
// things): populate the authoritative map AND mirror it into the agent.
|
||||
app.push_optimistic_prompt_echo("test-session", "q1", "a", "prompt");
|
||||
app.push_optimistic_prompt_echo("test-session", "q2", "b", "prompt");
|
||||
{
|
||||
let snapshot = app.shared_prompt_queue("test-session").cloned().unwrap();
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
// Turn-end window: locally Idle with no current prompt, but the
|
||||
// server's queue (mirrored from the last broadcast) still has work.
|
||||
agent.session.state = AgentState::Idle;
|
||||
agent.session.current_prompt_id = None;
|
||||
agent.shared_queue = snapshot;
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
}
|
||||
|
||||
let effects = dispatch(Action::SendPrompt("c".into()), &mut app);
|
||||
|
||||
// Routed to the server (immediate-send), keyed by a fresh prompt_id.
|
||||
let pid = effects
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
Effect::SendPrompt {
|
||||
text, prompt_id, ..
|
||||
} if text == "c" => Some(prompt_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected immediate SendPrompt for 'c', got {effects:?}"));
|
||||
// Did NOT start a local turn or adopt "c" as the running prompt.
|
||||
assert!(
|
||||
!app.agents[&id].session.state.is_turn_running(),
|
||||
"must not promote 'c' to a local running turn"
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].session.current_prompt_id.is_none(),
|
||||
"must not set current_prompt_id locally for a server-queued prompt"
|
||||
);
|
||||
// Echoed into the shared queue BEHIND the existing entries (position 3).
|
||||
let q = app
|
||||
.shared_prompt_queue("test-session")
|
||||
.expect("optimistic echo present");
|
||||
assert_eq!(q.len(), 3, "c queued behind q1, q2");
|
||||
assert_eq!(q.last().map(|e| e.id.as_str()), Some(pid.as_str()));
|
||||
assert_eq!(q.last().map(|e| e.text.as_str()), Some("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_privacy_info_zdr() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(text.contains("Zero Data Retention"));
|
||||
}
|
||||
|
||||
/// `/privacy` info-print uses the desktop-aligned "privacy mode" /
|
||||
/// "share data" labels from the user's intentional rewrite.
|
||||
#[test]
|
||||
fn show_privacy_info_opted_out() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("Privacy: privacy mode"),
|
||||
"info-print must use 'Privacy: privacy mode' (desktop-aligned label): {text}",
|
||||
);
|
||||
assert!(text.contains("/privacy opt-in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_privacy_info_opted_in() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("Privacy: share data"),
|
||||
"info-print must use 'Privacy: share data' (desktop-aligned label): {text}",
|
||||
);
|
||||
assert!(text.contains("/privacy opt-out"));
|
||||
}
|
||||
|
||||
/// The info-print uses desktop-aligned labels ("privacy mode" /
|
||||
/// "share data"). This test pins those labels to catch accidental
|
||||
/// regressions to the registry's "Opt in" / "Opt out" display
|
||||
/// strings.
|
||||
#[test]
|
||||
fn show_privacy_info_does_not_use_old_desktop_labels() {
|
||||
// opted-out → "Privacy: privacy mode"
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("privacy mode"),
|
||||
"[opted-out] info-print must contain 'privacy mode': {text:?}",
|
||||
);
|
||||
|
||||
// opted-in → "Privacy: share data"
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(
|
||||
text.contains("share data"),
|
||||
"[opted-in] info-print must contain 'share data': {text:?}",
|
||||
);
|
||||
}
|
||||
|
||||
// ── coding_data_sharing dispatch tests ───
|
||||
//
|
||||
// The dispatcher uses **optimistic + rollback + toast**, matching the
|
||||
// `set_yolo_mode` pattern. These tests pin the contract:
|
||||
// - Guards (ZDR, non-admin team) toast and short-circuit.
|
||||
// - Idempotent dispatch toasts but emits no Effect.
|
||||
// - Optimistic mutation flips `app.coding_data_retention_opt_out`
|
||||
// BEFORE the Effect is emitted.
|
||||
// - `Effect::SetCodingDataSharing` carries
|
||||
// `rollback_to_opted_in = previous_value`.
|
||||
// - `TaskResult::CodingDataSharingFailed` reverts the optimistic
|
||||
// mutation; `TaskResult::CodingDataSharingUpdated` re-anchors
|
||||
// to the server-confirmed value.
|
||||
|
||||
/// Idempotent re-dispatch when already opted-in toasts but emits
|
||||
/// no Effect (avoids a wasted ACP round-trip).
|
||||
///
|
||||
/// Toast uses the **display name** ("Opt in", not the
|
||||
/// snake-case canonical "opt-in") AND the **destructive `⚠`
|
||||
/// glyph** on the opt-in direction (privacy-degrading).
|
||||
#[test]
|
||||
fn set_coding_data_sharing_idempotent_opt_in() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"idempotent re-dispatch must NOT emit Effect"
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt in"),
|
||||
"toast must show display name 'Opt in' (PR 9 R1, General-3 Issue 6): {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains("opt-in"),
|
||||
"toast must NOT use snake-case canonical 'opt-in' — display name only: {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{26A0}'),
|
||||
"idempotent opt-in toast uses ⚠ destructive-warning glyph (PR 9 R1, \
|
||||
General-3 Issue 5): {toast}",
|
||||
);
|
||||
// State unchanged.
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"idempotent path must not mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotent re-dispatch when already opted-out toasts but emits
|
||||
/// no Effect.
|
||||
///
|
||||
/// Opt-out direction uses the **uniform `✓` glyph**
|
||||
/// (restoring the safe default) and the display name "Opt out".
|
||||
#[test]
|
||||
fn set_coding_data_sharing_idempotent_opt_out() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true; // currently opted-out
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"idempotent re-dispatch must NOT emit Effect"
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt out"),
|
||||
"toast must show display name 'Opt out': {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{2713}'),
|
||||
"idempotent opt-out toast uses ✓ safe-default glyph: {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains('\u{26A0}'),
|
||||
"opt-out is the safe direction — must NOT use ⚠: {toast}",
|
||||
);
|
||||
// State unchanged.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"idempotent path must not mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
/// ZDR teams are blocked from toggling. The blocked path
|
||||
/// toasts (not scrollback) and short-circuits with no Effect.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_blocked_by_zdr() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(effects.is_empty(), "ZDR block must NOT emit Effect");
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Zero Data Retention"),
|
||||
"ZDR toast must surface the policy: {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{2717}'),
|
||||
"blocked toast uses ✗ glyph: {toast}"
|
||||
);
|
||||
// State unchanged — the user was blocked, the optimistic
|
||||
// mutation never happened.
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"ZDR block must not mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
/// ZDR block fires even when the toggle would be a no-op
|
||||
/// (defense-in-depth: don't quietly accept a same-value toggle
|
||||
/// from a user the policy says shouldn't be touching this).
|
||||
#[test]
|
||||
fn set_coding_data_sharing_blocked_by_zdr_even_if_idempotent() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.is_zdr = true;
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
assert!(read_toast(&app).contains("Zero Data Retention"));
|
||||
}
|
||||
|
||||
/// Non-admin team members are blocked from toggling (matches
|
||||
/// desktop). The blocked path toasts and short-circuits.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_blocked_non_admin() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.team_name = Some("Acme".into());
|
||||
app.team_role = Some("Member".into());
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("team admin"),
|
||||
"non-admin toast must mention team admin: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Admin team members CAN toggle. The admin-allowed path produces
|
||||
/// an Effect carrying the rollback value.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_allowed_for_admin() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.team_name = Some("Acme".into());
|
||||
app.team_role = Some("Admin".into());
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
match &effects[0] {
|
||||
Effect::SetCodingDataSharing {
|
||||
opted_in,
|
||||
rollback_to_opted_in,
|
||||
..
|
||||
} => {
|
||||
assert!(!*opted_in, "Effect must carry opted_in=false");
|
||||
assert!(
|
||||
*rollback_to_opted_in,
|
||||
"rollback_to_opted_in must capture pre-toggle opt-in=true",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
|
||||
}
|
||||
// Optimistic mutation already applied.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"admin-allowed dispatch must optimistically flip state",
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-idempotent dispatch emits one Effect AND mutates state
|
||||
/// optimistically AND toasts.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert_eq!(effects.len(), 1, "non-idempotent dispatch emits one Effect");
|
||||
match &effects[0] {
|
||||
Effect::SetCodingDataSharing {
|
||||
agent_id,
|
||||
opted_in,
|
||||
rollback_to_opted_in,
|
||||
} => {
|
||||
assert_eq!(*agent_id, AgentId(0));
|
||||
assert!(!*opted_in);
|
||||
assert!(
|
||||
*rollback_to_opted_in,
|
||||
"rollback_to_opted_in must be pre-toggle value (true == opted-in)",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
|
||||
}
|
||||
// Optimistic mutation applied.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"dispatch must optimistically mutate state",
|
||||
);
|
||||
// Toast on every dispatch (SHELL setter contract).
|
||||
assert!(app.agents[&AgentId(0)].toast.is_some());
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingUpdated` re-anchors state to the
|
||||
/// server-confirmed value (defense-in-depth) and re-toasts.
|
||||
#[test]
|
||||
fn coding_data_sharing_updated_re_anchors_state_and_re_toasts() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Simulate post-optimistic state: opted-out.
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
// Server confirms opt-out (same as optimistic).
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
|
||||
agent_id: id,
|
||||
opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty(), "TaskResult arm must NOT emit Effect");
|
||||
// State re-anchored (was already true, stays true).
|
||||
assert!(app.coding_data_retention_opt_out);
|
||||
// Re-toast on confirmation uses display name + ✓.
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt out"),
|
||||
"confirmation toast must use display name 'Opt out': {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{2713}'),
|
||||
"opt-out confirmation toast uses ✓: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingUpdated` corrects the in-memory
|
||||
/// state if the server reshapes the boolean (e.g. policy
|
||||
/// override). Pins the defense-in-depth re-anchor contract.
|
||||
#[test]
|
||||
fn coding_data_sharing_updated_corrects_state_if_server_disagrees() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Optimistic mutation said "opt-out" — but the server
|
||||
// overrides to "opt-in" (e.g. policy that prevents opt-out).
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
|
||||
agent_id: id,
|
||||
opted_in: true, // server says opted-in
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
// State corrected to match server.
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"server-confirmed opt-in must overwrite optimistic opt-out",
|
||||
);
|
||||
// Server-correction toast uses the destructive ⚠
|
||||
// pattern for the opt-in direction (the privacy-degrading
|
||||
// override deserves the warning glyph even if the SERVER, not
|
||||
// the user, made the call).
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("Opt in"),
|
||||
"post-correction toast uses display name 'Opt in': {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains('\u{26A0}'),
|
||||
"opt-in direction always uses ⚠ glyph, even on server-correction path: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingFailed` REVERTS the optimistic
|
||||
/// mutation and surfaces a failure toast. Pins the rollback
|
||||
/// contract.
|
||||
///
|
||||
/// Failure toast uses the standardised "coding data sharing"
|
||||
/// wording.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_rolls_back_and_toasts_error() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Simulate post-optimistic state: user picked opt-out, state
|
||||
// was flipped, then the ACP call failed. The pre-toggle value
|
||||
// was opt-in (true), so `rollback_to_opted_in = true`.
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: "server error".into(),
|
||||
rollback_to_opted_in: true,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty(), "rollback path must NOT emit Effect");
|
||||
// State reverted to pre-toggle (opted-in).
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"rollback must revert optimistic mutation",
|
||||
);
|
||||
// Failure toast surfaces the error using full label.
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains("coding data sharing"),
|
||||
"PR 9 R1: failure toast wording standardised to include 'coding data sharing' \
|
||||
(G2 Issue 2): {toast}",
|
||||
);
|
||||
assert!(toast.contains("server error"), "error in toast: {toast}");
|
||||
assert!(toast.contains('\u{2717}'), "failure toast uses ✗: {toast}");
|
||||
}
|
||||
|
||||
/// `TaskResult::CodingDataSharingFailed` reverts in the OTHER
|
||||
/// direction too (the pre-toggle state could have been either).
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_rolls_back_to_opt_out() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Post-optimistic: opted-in (user picked opt-in, server
|
||||
// failed, pre-toggle was opt-out).
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let id = AgentId(0);
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: "network timeout".into(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
// Reverted to pre-toggle opt-out.
|
||||
assert!(
|
||||
app.coding_data_retention_opt_out,
|
||||
"rollback to opt-out must set state=true",
|
||||
);
|
||||
}
|
||||
|
||||
/// Optimistic mutation refreshes any open settings modal.
|
||||
/// Without this refresh, the modal indicator would stay at the
|
||||
/// pre-toggle value until manual re-render.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_refreshes_open_modal_snapshot() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
// Open a settings modal (capture initial snapshot).
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
// Verify snapshot reads opted-in.
|
||||
let agent_id = AgentId(0);
|
||||
{
|
||||
let state = match &app.agents[&agent_id].active_modal {
|
||||
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
|
||||
_ => panic!("expected Settings modal open after OpenSettings dispatch"),
|
||||
};
|
||||
assert!(
|
||||
!state.pager_snapshot.coding_data_sharing_opt_out,
|
||||
"initial snapshot must read opt_out=false (opted-in)",
|
||||
);
|
||||
}
|
||||
// Dispatch the toggle.
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
// Snapshot now reflects the optimistic mutation.
|
||||
let state = match &app.agents[&agent_id].active_modal {
|
||||
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
|
||||
_ => panic!("Settings modal must still be open after SetCodingDataSharing dispatch"),
|
||||
};
|
||||
assert!(
|
||||
state.pager_snapshot.coding_data_sharing_opt_out,
|
||||
"snapshot must refresh to reflect opt_out=true (opted-out) after dispatch",
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback also refreshes the modal — the user sees the
|
||||
/// reverted value, not the stale optimistic one.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_refreshes_open_modal_snapshot() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
// Optimistic flip.
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
// ACP failure.
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: AgentId(0),
|
||||
error: "x".into(),
|
||||
rollback_to_opted_in: true,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let state = match &app.agents[&AgentId(0)].active_modal {
|
||||
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
|
||||
_ => panic!("Settings modal must still be open after rollback TaskResult"),
|
||||
};
|
||||
assert!(
|
||||
!state.pager_snapshot.coding_data_sharing_opt_out,
|
||||
"rollback must refresh snapshot back to opt_out=false (opted-in)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── coding_data_sharing toast tests ─────────────
|
||||
|
||||
/// The opt-in transition
|
||||
/// uses the **`⚠` destructive-warning glyph** + spelled-out
|
||||
/// consequence text — mirroring `yolo_toast`'s
|
||||
/// "Always-approve ON: all tool actions auto-run" pattern. The
|
||||
/// consequence text is verbatim-pinned because the toast is the
|
||||
/// only post-commit feedback for a privacy-degrading transition;
|
||||
/// a future PR that softens the wording silently degrades the
|
||||
/// safety affordance.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_opt_in_renders_destructive_warning_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true; // currently opted-out
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
assert_eq!(effects.len(), 1, "non-idempotent opt-in must emit Effect");
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains('\u{26A0}'),
|
||||
"opt-in toast MUST use ⚠ glyph (PR 9 R1, General-3 Issue 5 — \
|
||||
privacy-degrading transition deserves destructive-warning glyph): {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains('\u{2713}'),
|
||||
"opt-in toast MUST NOT use the uniform ✓ glyph — that's the \
|
||||
safe-default toast for opt-out: {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains("Opt in"),
|
||||
"destructive toast still uses display name 'Opt in': {toast}",
|
||||
);
|
||||
// Consequence text pinned: a future PR softening this loses
|
||||
// the safety affordance.
|
||||
assert!(
|
||||
toast.contains("code samples"),
|
||||
"destructive toast must spell out the consequence \
|
||||
(mention 'code samples'): {toast}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains("training"),
|
||||
"destructive toast must spell out the consequence \
|
||||
(mention 'training'): {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The opt-out transition uses the
|
||||
/// uniform `✓` glyph (safe default), NOT the destructive `⚠`.
|
||||
/// Mirrors `yolo_toast(false)` precedent — restoring the safe
|
||||
/// default doesn't warrant the heavier visual.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_opt_out_renders_safe_default_toast() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = false; // currently opted-in
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains('\u{2713}'),
|
||||
"opt-out toast uses ✓ safe-default glyph: {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains('\u{26A0}'),
|
||||
"opt-out toast MUST NOT use ⚠ — that's reserved for the privacy-degrading \
|
||||
direction (PR 9 R1): {toast}",
|
||||
);
|
||||
assert!(toast.contains("Opt out"));
|
||||
}
|
||||
|
||||
/// The toast renders
|
||||
/// the registered `EnumChoice.display` ("Opt in" / "Opt out"),
|
||||
/// NOT the persisted canonical ("opt-in" / "opt-out"). Mirrors
|
||||
/// the `set_theme_toast_format_uses_display_name` contract.
|
||||
/// The display strings here are pinned by the
|
||||
/// `coding_data_sharing_choices_use_canonical_strings` e2e test
|
||||
/// (registry side) AND
|
||||
/// `pr9_coding_data_sharing_choices_use_canonical_strings` (which
|
||||
/// also pins the display labels via the same EnumChoice
|
||||
/// entries).
|
||||
#[test]
|
||||
fn coding_data_sharing_toast_format_uses_display_name() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Opt-in direction.
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
|
||||
let opt_in_toast = read_toast(&app);
|
||||
assert!(
|
||||
opt_in_toast.contains("Opt in"),
|
||||
"opt-in toast uses display 'Opt in', not canonical 'opt-in': {opt_in_toast}",
|
||||
);
|
||||
// Clear and test opt-out direction.
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
|
||||
app.coding_data_retention_opt_out = false;
|
||||
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
let opt_out_toast = read_toast(&app);
|
||||
assert!(
|
||||
opt_out_toast.contains("Opt out"),
|
||||
"opt-out toast uses display 'Opt out', not canonical 'opt-out': {opt_out_toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure toast
|
||||
/// substitutes a generic placeholder when the error string is
|
||||
/// too long OR contains control characters / newlines. Pins the
|
||||
/// scrub contract.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_scrubs_long_error_messages() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
// ~500-char error simulating a stack trace / HTML 502 page.
|
||||
let huge_error = "a".repeat(500);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: huge_error.clone(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
!toast.contains(&huge_error),
|
||||
"long error MUST be scrubbed from the toast: {} chars",
|
||||
toast.len(),
|
||||
);
|
||||
assert!(
|
||||
toast.contains("see logs"),
|
||||
"scrubbed toast must point at the log for full details: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Control characters (CR/LF/NUL)
|
||||
/// in the error trigger the scrub path even on short strings —
|
||||
/// preserves the toast's single-line layout.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_scrubs_control_chars_in_error() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
// Short message with embedded newlines.
|
||||
let multiline = "line1\nline2\nline3".to_string();
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: multiline.clone(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
!toast.contains('\n'),
|
||||
"newlines MUST be scrubbed from the toast (would break single-line layout): \
|
||||
{toast:?}",
|
||||
);
|
||||
assert!(
|
||||
toast.contains("see logs"),
|
||||
"control-char-scrubbed toast points at logs: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The scrub path preserves short,
|
||||
/// sanitised error messages verbatim — the typical happy-path
|
||||
/// shell-side error string stays unscrubbed.
|
||||
#[test]
|
||||
fn coding_data_sharing_failed_preserves_short_clean_error_message() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.coding_data_retention_opt_out = true;
|
||||
let id = AgentId(0);
|
||||
let short_clean = "network timeout".to_string();
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
|
||||
agent_id: id,
|
||||
error: short_clean.clone(),
|
||||
rollback_to_opted_in: false,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
let toast = read_toast(&app);
|
||||
assert!(
|
||||
toast.contains(&short_clean),
|
||||
"short clean error must appear verbatim in the toast: {toast}",
|
||||
);
|
||||
assert!(
|
||||
!toast.contains("see logs"),
|
||||
"short clean error must NOT trigger the scrub fallback: {toast}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct unit test of the `scrub_error_for_toast` helper —
|
||||
/// pins the threshold and the fallback string against drift.
|
||||
#[test]
|
||||
fn scrub_error_for_toast_unit() {
|
||||
// Empty + short messages pass through.
|
||||
assert_eq!(scrub_error_for_toast(""), "");
|
||||
assert_eq!(scrub_error_for_toast("ok"), "ok");
|
||||
assert_eq!(scrub_error_for_toast("network timeout"), "network timeout");
|
||||
// At-threshold (120 chars) still passes through.
|
||||
let len_120 = "x".repeat(120);
|
||||
assert_eq!(scrub_error_for_toast(&len_120), len_120);
|
||||
// Over-threshold (121 chars) triggers scrub.
|
||||
let len_121 = "x".repeat(121);
|
||||
assert_eq!(
|
||||
scrub_error_for_toast(&len_121),
|
||||
"server error (see logs for details)"
|
||||
);
|
||||
// Control chars trigger scrub even at short lengths.
|
||||
assert_eq!(
|
||||
scrub_error_for_toast("hi\nthere"),
|
||||
"server error (see logs for details)"
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_error_for_toast("hi\rthere"),
|
||||
"server error (see logs for details)"
|
||||
);
|
||||
// Format-category (Cf) chars also trigger scrub — bidi
|
||||
// overrides, zero-width joiner / space, BOM. Prevents
|
||||
// Trojan-Source-style visual spoofing
|
||||
// where a toast READS as one thing but bytes encode
|
||||
// another via embedded RIGHT-TO-LEFT-OVERRIDE.
|
||||
assert_eq!(
|
||||
scrub_error_for_toast("opt\u{202E}-out"),
|
||||
"server error (see logs for details)",
|
||||
"RIGHT-TO-LEFT OVERRIDE (U+202E) must be scrubbed",
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_error_for_toast("opt\u{200B}out"),
|
||||
"server error (see logs for details)",
|
||||
"ZERO WIDTH SPACE (U+200B) must be scrubbed",
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_error_for_toast("\u{FEFF}leading BOM"),
|
||||
"server error (see logs for details)",
|
||||
"BOM (U+FEFF) must be scrubbed",
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_error_for_toast("zwj\u{200D}joiner"),
|
||||
"server error (see logs for details)",
|
||||
"ZERO WIDTH JOINER (U+200D) must be scrubbed",
|
||||
);
|
||||
}
|
||||
|
||||
/// The no-agent path
|
||||
/// returns empty cleanly — no toast (the show_toast call would
|
||||
/// no-op anyway), no panic, no Effect emitted. A "✗ No active
|
||||
/// session" toast would be dead UX (no agent = no toast surface
|
||||
/// to render on), so this path emits a tracing::warn! instead.
|
||||
#[test]
|
||||
fn set_coding_data_sharing_no_agents_returns_empty_without_panic() {
|
||||
let mut app = test_app_with_agent();
|
||||
// Remove every agent so the dispatcher hits the no-agent path.
|
||||
app.agents.clear();
|
||||
// Force the view off Agent so the dispatcher falls through to
|
||||
// app.agents.keys().next() which is now empty.
|
||||
app.active_view = ActiveView::Welcome;
|
||||
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"no-agent path must return empty (no Effect to fire)",
|
||||
);
|
||||
// State unchanged (we never reach the optimistic mutation).
|
||||
assert!(
|
||||
!app.coding_data_retention_opt_out,
|
||||
"no-agent path must NOT mutate state",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_rename_session_updates_display_name_locally() {
|
||||
let mut app = test_app_with_agent();
|
||||
let effects = dispatch_rename_session(&mut app, "renamed via slash".into());
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].display_name.as_deref(),
|
||||
Some("renamed via slash"),
|
||||
"/rename must also update local display_name cache"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ConfirmResetSetting { choice: Reset }` on a SHARED Bool
|
||||
/// target restores the Settings modal AND fires the typed
|
||||
/// `Action::SetCompactMode(default)` via recursive dispatch —
|
||||
/// the `Effect::PersistSetting` is the externally-observable
|
||||
/// signal. Also asserts the ui_snapshot was
|
||||
/// refreshed to the new (post-reset) value (symmetric with the
|
||||
/// Cancel test's snapshot assertion).
|
||||
#[test]
|
||||
fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_shared_bool() {
|
||||
use crate::settings::SettingValue;
|
||||
use crate::views::modal::{ActiveModal, ResetSettingsResult};
|
||||
let mut app = test_app_with_agent();
|
||||
// Flip compact_mode to true so we can observe the reset back
|
||||
// to its default (false).
|
||||
let _ = dispatch(Action::SetCompactMode(true), &mut app);
|
||||
assert!(app.current_ui.compact_mode);
|
||||
|
||||
setup_reset_confirm_open(&mut app, "compact_mode");
|
||||
|
||||
let effects = dispatch(
|
||||
Action::ConfirmResetSetting {
|
||||
choice: ResetSettingsResult::Reset,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Recursive dispatch into Action::SetCompactMode(false) emits
|
||||
// the persist effect.
|
||||
assert_eq!(effects.len(), 1);
|
||||
match &effects[0] {
|
||||
Effect::PersistSetting { key, value, .. } => {
|
||||
assert_eq!(*key, "compact_mode");
|
||||
assert_eq!(value, &SettingValue::Bool(false));
|
||||
}
|
||||
other => panic!("expected PersistSetting, got {other:?}"),
|
||||
}
|
||||
// In-memory state is reset to the default.
|
||||
assert!(!app.current_ui.compact_mode);
|
||||
// Modal is restored AND ui_snapshot reflects the new value
|
||||
// (symmetric with the Cancel test).
|
||||
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
|
||||
match &agent.active_modal {
|
||||
Some(ActiveModal::Settings { state }) => {
|
||||
assert!(
|
||||
!state.ui_snapshot.compact_mode,
|
||||
"ui_snapshot must reflect the post-reset value"
|
||||
);
|
||||
}
|
||||
_ => panic!("Reset branch must restore the Settings modal"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ConfirmResetSetting { choice: Reset }` on a SHARED Enum
|
||||
/// target (`theme`) dispatches `Action::SetTheme(default)` via
|
||||
/// recursive dispatch — verifies the action_for_reset Enum arm.
|
||||
#[test]
|
||||
fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_shared_enum() {
|
||||
use crate::settings::SettingValue;
|
||||
use crate::views::modal::ResetSettingsResult;
|
||||
let mut app = test_app_with_agent();
|
||||
// Flip theme to a non-default first.
|
||||
let _ = dispatch(Action::SetTheme("tokyonight".to_string()), &mut app);
|
||||
assert_eq!(app.current_ui.theme.as_deref(), Some("tokyonight"));
|
||||
|
||||
setup_reset_confirm_open(&mut app, "theme");
|
||||
|
||||
let effects = dispatch(
|
||||
Action::ConfirmResetSetting {
|
||||
choice: ResetSettingsResult::Reset,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Reset → SetTheme("groknight") (the registered default).
|
||||
assert_eq!(effects.len(), 1);
|
||||
match &effects[0] {
|
||||
Effect::PersistSetting { key, value, .. } => {
|
||||
assert_eq!(*key, "theme");
|
||||
assert_eq!(value, &SettingValue::Enum("groknight"));
|
||||
}
|
||||
other => panic!("expected PersistSetting, got {other:?}"),
|
||||
}
|
||||
assert_eq!(app.current_ui.theme.as_deref(), Some("groknight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_usage_on_welcome_screen_is_noop() {
|
||||
let mut app = test_app();
|
||||
let effects = dispatch(Action::ShowUsage, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"ShowUsage with no active agent should be a no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_usage_with_redirect_url_shows_link_and_skips_fetch() {
|
||||
let mut app = test_app_with_agent();
|
||||
app.usage_billing_redirect_url = Some("https://billing.example.com/me".to_string());
|
||||
let before = agent_scrollback_len(&app);
|
||||
let effects = dispatch(Action::ShowUsage, &mut app);
|
||||
assert!(
|
||||
effects.is_empty(),
|
||||
"with a redirect URL set, ShowUsage should not fetch (billing or auto-topup), got: {effects:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_scrollback_len(&app),
|
||||
before + 1,
|
||||
"redirect path should push one system message with the billing link"
|
||||
);
|
||||
assert!(
|
||||
last_system_text(&app, AgentId(0)).contains("https://billing.example.com/me"),
|
||||
"redirect message should use the remote settings-provided URL"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Minimal update-notice tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn minimal_update_notice_commits_a_system_block() {
|
||||
let mut app = test_app_with_agent();
|
||||
let before = agent_scrollback_len(&app);
|
||||
commit_minimal_update_notice(&mut app, "9.9.9");
|
||||
assert_eq!(agent_scrollback_len(&app), before + 1);
|
||||
let text = last_system_text(&app, AgentId(0));
|
||||
assert!(text.contains("Update available: v9.9.9"), "got: {text:?}");
|
||||
assert!(text.contains("restart to apply"), "got: {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_update_notice_no_active_agent_is_noop() {
|
||||
let mut app = test_app();
|
||||
// Must not panic and must not require an agent.
|
||||
commit_minimal_update_notice(&mut app, "9.9.9");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,379 @@
|
||||
//! Tests for the block viewer and transcript dispatchers.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgba};
|
||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgb};
|
||||
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_pixel(width, height, Rgb([128, 64, 32]));
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Jpeg,
|
||||
)
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_on_group_header_toggles_group() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let mut appearance = crate::appearance::AppearanceConfig::default();
|
||||
appearance.scrollback.display.group_max_visible = 3;
|
||||
agent.scrollback.set_appearance(appearance);
|
||||
for i in 0..6 {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::tool_call(
|
||||
format!("Tool{i}"),
|
||||
"info",
|
||||
true,
|
||||
));
|
||||
}
|
||||
agent.scrollback.prepare_layout(80, 40);
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
assert!(agent.scrollback.is_selected_group_header());
|
||||
}
|
||||
|
||||
// Enter on the "N more" header expands the group instead of opening
|
||||
// the hidden first entry in the block viewer.
|
||||
dispatch(Action::OpenBlockViewer, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
assert!(
|
||||
agent.block_viewer.is_none(),
|
||||
"viewer must not open on a group header"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.selected(),
|
||||
None,
|
||||
"expanding a group clears the selection"
|
||||
);
|
||||
agent.scrollback.prepare_layout(80, 40);
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
assert_eq!(
|
||||
agent.scrollback.selected_group_header_fold_label(),
|
||||
Some("collapse"),
|
||||
"entry 0 should now be the expanded group's collapse header"
|
||||
);
|
||||
}
|
||||
|
||||
// Enter on the collapse header collapses the group back.
|
||||
dispatch(Action::OpenBlockViewer, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_none());
|
||||
agent.scrollback.prepare_layout(80, 40);
|
||||
assert_eq!(
|
||||
agent.scrollback.selected_group_header_fold_label(),
|
||||
Some("expand"),
|
||||
"group should be truncated again ('N more' header)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_opens_grep_search_block() {
|
||||
use crate::scrollback::blocks::{SearchFileMatch, SearchLineMatch};
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.scrollback.push_block(RenderBlock::search(
|
||||
"fn main",
|
||||
1,
|
||||
vec![SearchFileMatch {
|
||||
path: "src/main.rs".into(),
|
||||
matches: vec![SearchLineMatch {
|
||||
line_number: 1,
|
||||
content: "fn main() {}".into(),
|
||||
}],
|
||||
}],
|
||||
));
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
|
||||
let entry = agent.scrollback.entry(0).unwrap();
|
||||
assert!(entry.block.has_normal_fullscreen_viewer());
|
||||
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_some());
|
||||
assert_eq!(
|
||||
agent.block_viewer.as_ref().unwrap().kind,
|
||||
crate::views::block_viewer::ViewerKind::Grep
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_opens_list_dir_block() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::list_dir_with_output("/tmp", "a.txt\nb.txt"));
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
|
||||
let entry = agent.scrollback.entry(0).unwrap();
|
||||
assert!(entry.block.has_normal_fullscreen_viewer());
|
||||
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_some());
|
||||
assert_eq!(
|
||||
agent.block_viewer.as_ref().unwrap().kind,
|
||||
crate::views::block_viewer::ViewerKind::PlainText
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_prefers_markdown_viewer_over_image_refs() {
|
||||
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image_path = dir.path().join("referenced.png");
|
||||
std::fs::write(&image_path, make_test_png(20, 10)).unwrap();
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::agent_message(format!(
|
||||
"Here is an image: ",
|
||||
image_path.display()
|
||||
)));
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
|
||||
// Need a graphics protocol so the top-level media guard doesn't
|
||||
// short-circuit before reaching the block viewer.
|
||||
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_some());
|
||||
assert!(agent.image_viewer.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_uses_markdown_viewer_for_agent_message_with_image_ref() {
|
||||
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jpg_path = dir.path().join("generated.jpg");
|
||||
std::fs::write(&jpg_path, make_test_jpeg(20, 10)).unwrap();
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::agent_message(format!(
|
||||
"",
|
||||
jpg_path.display()
|
||||
)));
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
|
||||
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
// Agent messages with image refs now open the normal markdown viewer
|
||||
// (inline media rendering moved to the tool call block).
|
||||
assert!(agent.block_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_opens_image_only_blocks_natively() {
|
||||
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image_path = dir.path().join("referenced.png");
|
||||
std::fs::write(&image_path, make_test_png(20, 10)).unwrap();
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::ToolCall(ToolCallBlock::Other(
|
||||
crate::scrollback::blocks::OtherToolCallBlock::new("image_tool", "saved image")
|
||||
.with_output(format!("Saved image: {}", image_path.display())),
|
||||
)));
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
|
||||
let entry = agent.scrollback.entry(0).unwrap();
|
||||
assert!(entry.block.supports_fullscreen());
|
||||
assert!(!entry.block.has_normal_fullscreen_viewer());
|
||||
|
||||
// Pretend the host terminal speaks Kitty graphics so the media
|
||||
// short-circuit guard (`guard_image_support`) doesn't fire and the
|
||||
// dispatch reaches the image branch, which opens the file natively
|
||||
// rather than an in-app viewer.
|
||||
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
|
||||
// Generated media now opens in the OS-native viewer (fire-and-forget),
|
||||
// so neither the in-app block viewer nor image viewer is shown.
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_none());
|
||||
assert!(agent.image_viewer.is_none());
|
||||
}
|
||||
|
||||
// -- Plugins tab: group-collapse seeding on PluginsListLoaded --------------
|
||||
|
||||
fn plugins_list_response() -> kigi_hooks_plugins_types::PluginsListResponse {
|
||||
use crate::views::extensions_modal::test_plugin_info;
|
||||
kigi_hooks_plugins_types::PluginsListResponse {
|
||||
plugins: vec![
|
||||
test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
),
|
||||
test_plugin_info(
|
||||
"claude-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserClaude),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn open_plugins_modal(app: &mut AppView, id: AgentId) {
|
||||
app.agents.get_mut(&id).unwrap().extensions_modal =
|
||||
Some(crate::views::extensions_modal::ExtensionsModalState::new(
|
||||
crate::views::extensions_modal::ExtensionsTab::Plugins,
|
||||
));
|
||||
}
|
||||
|
||||
fn deliver_plugins_list(app: &mut AppView, id: AgentId) {
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::PluginsListLoaded {
|
||||
agent_id: id,
|
||||
result: Ok(plugins_list_response()),
|
||||
}),
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
fn plugins_collapsed_keys(app: &AppView, id: AgentId) -> Vec<String> {
|
||||
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
|
||||
let mut keys: Vec<String> = modal.plugins_collapsed_groups.iter().cloned().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_list_loaded_seeds_all_groups_collapsed_on_first_load() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
open_plugins_modal(&mut app, id);
|
||||
|
||||
deliver_plugins_list(&mut app, id);
|
||||
|
||||
assert_eq!(
|
||||
plugins_collapsed_keys(&app, id),
|
||||
vec!["origin:user".to_string(), "origin:user-claude".to_string()]
|
||||
);
|
||||
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => assert_eq!(response.plugins.len(), 2),
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_list_delivery_seeds_once_then_always_preserves() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
open_plugins_modal(&mut app, id);
|
||||
deliver_plugins_list(&mut app, id);
|
||||
|
||||
// User expands a group, then the post-action refetch arrives.
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.plugins_collapsed_groups
|
||||
.remove("origin:user");
|
||||
deliver_plugins_list(&mut app, id);
|
||||
|
||||
assert_eq!(
|
||||
plugins_collapsed_keys(&app, id),
|
||||
vec!["origin:user-claude".to_string()],
|
||||
"post-action refetch must not re-collapse an expanded group"
|
||||
);
|
||||
|
||||
// Reload sets Loading, but seeding is once-per-modal: still preserves.
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.plugins_data = TabDataState::Loading;
|
||||
deliver_plugins_list(&mut app, id);
|
||||
|
||||
assert_eq!(
|
||||
plugins_collapsed_keys(&app, id),
|
||||
vec!["origin:user-claude".to_string()],
|
||||
"reload must not re-collapse groups the user expanded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_block_viewer_skips_image_viewer_when_no_graphics() {
|
||||
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image_path = dir.path().join("referenced.png");
|
||||
std::fs::write(&image_path, make_test_png(20, 10)).unwrap();
|
||||
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::ToolCall(ToolCallBlock::Other(
|
||||
crate::scrollback::blocks::OtherToolCallBlock::new("image_tool", "saved image")
|
||||
.with_output(format!("Saved image: {}", image_path.display())),
|
||||
)));
|
||||
agent.scrollback.set_selected(Some(0));
|
||||
|
||||
// Terminal has no inline-image protocol (e.g. Windows / ConPTY).
|
||||
// The dispatch should refuse to open the image-viewer modal and
|
||||
// surface the situation via a toast instead.
|
||||
let _guard = set_protocol_for_test(GraphicsProtocol::None);
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_none());
|
||||
assert!(
|
||||
agent.image_viewer.is_none(),
|
||||
"image_viewer modal should not open on terminals without a graphics protocol"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user