Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/tests/task_result.rs
T
ZacharyZhang-NY f403c38a94 fix(tui): a default-model persist failure no longer reverts the live model
Persist-failure ≠ switch-failure. The default_model rollback arm re-ran
set_default_model_inner(prev) AND issued a reverse SwitchModel whenever
the config.toml write failed — the only mechanism in the codebase that
deliberately re-shows the ORIGINAL model after a successful pick. On
Windows, where AV/indexer file locks routinely failed that write (until
27d009c), every /model selection appeared to not take.

The session switch succeeds independently and reports its own failures
via handle_switch_model_complete; a disk-persist failure now keeps the
live model, logs, and surfaces only the 'Could not save' toast — the
same policy PersistPreferredModel already ships ('still active for this
session').

Verified: kigi-tui 6874 tests green, clippy clean.
2026-07-22 19:55:02 -04:00

1709 lines
54 KiB
Rust

//! Tests for async task-result application arms.
use super::super::task_result::{
X11_PRIMARY_PASTE_HINT, maybe_show_x11_primary_paste_hint, show_clipboard_failure,
};
use super::*;
fn foreign_resume_hint(
tool: kigi_workspace::foreign_sessions::ForeignSessionTool,
) -> kigi_workspace::foreign_sessions::RecentForeignSession {
kigi_workspace::foreign_sessions::RecentForeignSession {
tool,
native_id: "native-session".into(),
age: std::time::Duration::from_secs(30),
}
}
#[test]
fn foreign_resume_results_require_launch_token_and_canonical_cwd() {
let mut launch = test_app();
launch.foreign_session_compat =
kigi_workspace::foreign_sessions::EnabledForeignSessionSources {
cursor: true,
..Default::default()
};
let Effect::CanonicalizeForeignResumeCwd {
requested_cwd,
launch_token,
} = launch
.begin_foreign_resume_detection()
.expect("pristine launch schedules canonicalization")
else {
panic!("expected canonicalization effect");
};
let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap();
let effects = dispatch(
Action::TaskComplete(TaskResult::ForeignResumeCwdCanonicalized {
requested_cwd: requested_cwd.clone(),
canonical_cwd: Some(canonical_cwd.clone()),
launch_token,
}),
&mut launch,
);
assert!(matches!(
effects.as_slice(),
[Effect::DetectForeignResumeHint {
canonical_cwd: effect_cwd,
launch_token: effect_token,
..
}] if effect_cwd == &canonical_cwd && *effect_token == launch_token
));
assert!(launch.foreign_resume_hint().is_none());
dispatch(
Action::TaskComplete(TaskResult::ForeignResumeHintDetected {
canonical_cwd: canonical_cwd.clone(),
launch_token,
hint: Some(foreign_resume_hint(
kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor,
)),
}),
&mut launch,
);
assert_eq!(
launch.foreign_resume_hint().map(|hint| hint.tool),
Some(kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor)
);
let mut stale = test_app();
stale.foreign_session_compat = launch.foreign_session_compat;
let Effect::CanonicalizeForeignResumeCwd {
requested_cwd,
launch_token,
} = stale.begin_foreign_resume_detection().unwrap()
else {
panic!("expected canonicalization effect");
};
let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap();
dispatch(
Action::TaskComplete(TaskResult::ForeignResumeHintDetected {
canonical_cwd: canonical_cwd.clone(),
launch_token: launch_token + 1,
hint: Some(foreign_resume_hint(
kigi_workspace::foreign_sessions::ForeignSessionTool::Codex,
)),
}),
&mut stale,
);
assert!(stale.foreign_resume_hint().is_none());
stale.cwd = tempfile::tempdir().unwrap().path().to_path_buf();
dispatch(
Action::TaskComplete(TaskResult::ForeignResumeCwdCanonicalized {
requested_cwd,
canonical_cwd: Some(canonical_cwd),
launch_token,
}),
&mut stale,
);
assert!(stale.foreign_resume_hint().is_none());
}
#[test]
fn foreign_resume_result_rejects_startup_conflict_before_completion() {
let mut app = test_app();
app.foreign_session_compat = kigi_workspace::foreign_sessions::EnabledForeignSessionSources {
cursor: true,
..Default::default()
};
let Effect::CanonicalizeForeignResumeCwd {
requested_cwd,
launch_token,
} = app.begin_foreign_resume_detection().unwrap()
else {
panic!("expected canonicalization effect");
};
let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap();
assert!(app.accept_foreign_resume_canonical_cwd(
launch_token,
&requested_cwd,
Some(canonical_cwd.clone()),
));
app.deferred_startup.new_session = true;
dispatch(
Action::TaskComplete(TaskResult::ForeignResumeHintDetected {
canonical_cwd,
launch_token,
hint: Some(foreign_resume_hint(
kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor,
)),
}),
&mut app,
);
assert!(app.foreign_resume_hint().is_none());
}
#[test]
fn x11_primary_hint_requires_canonical_full_miss_outcome() {
use crate::app::actions::{ClipboardPasteCompletion, ClipboardPasteTarget};
assert_eq!(
X11_PRIMARY_PASTE_HINT,
"Try Shift+Insert to paste selected text"
);
let target = ClipboardPasteTarget::AgentPrompt {
agent_id: AgentId(0),
images_dir: None,
};
for completion in [
ClipboardPasteCompletion::Handled,
ClipboardPasteCompletion::Dropped,
ClipboardPasteCompletion::Failed(
crate::app::actions::ClipboardPasteFailure::AttachmentRead,
),
] {
let mut app = test_app_with_agent();
maybe_show_x11_primary_paste_hint(true, completion, &target, &mut app);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"{completion:?} must not show full-miss guidance"
);
}
let mut app = test_app_with_agent();
maybe_show_x11_primary_paste_hint(false, ClipboardPasteCompletion::FullMiss, &target, &mut app);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"non-X11 FullMiss must not show X11 guidance"
);
}
#[test]
fn x11_primary_hint_routes_to_originating_agent() {
let mut app = test_app_with_agent();
let origin = AgentId(0);
let active = AgentId(1);
let session = make_test_agent_session(&app, active, "active-session");
app.agents
.insert(active, AgentView::new(session, ScrollbackState::new()));
app.active_view = ActiveView::Agent(active);
let target = crate::app::actions::ClipboardPasteTarget::AgentPrompt {
agent_id: origin,
images_dir: None,
};
maybe_show_x11_primary_paste_hint(
true,
crate::app::actions::ClipboardPasteCompletion::FullMiss,
&target,
&mut app,
);
assert_eq!(
app.agents[&origin]
.toast
.as_ref()
.map(|(msg, _)| msg.as_str()),
Some(X11_PRIMARY_PASTE_HINT),
);
assert!(
app.agents[&active].toast.is_none(),
"an unrelated active agent must not receive X11 paste guidance"
);
}
#[test]
fn x11_primary_hint_routes_to_originating_dashboard() {
let mut app = test_app_with_agent();
open_dashboard(&mut app);
app.active_view = ActiveView::Agent(AgentId(0));
maybe_show_x11_primary_paste_hint(
true,
crate::app::actions::ClipboardPasteCompletion::FullMiss,
&crate::app::actions::ClipboardPasteTarget::DashboardDispatch,
&mut app,
);
assert_eq!(
app.dashboard
.as_ref()
.and_then(|dashboard| dashboard.error_toast.as_deref()),
Some(X11_PRIMARY_PASTE_HINT),
);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"an unrelated active agent must not receive dashboard guidance"
);
}
#[test]
fn clipboard_failure_routes_to_originating_agent_without_duplicate() {
let mut app = test_app_with_agent();
let origin = AgentId(0);
let active = AgentId(1);
let session = make_test_agent_session(&app, active, "active-session");
app.agents
.insert(active, AgentView::new(session, ScrollbackState::new()));
app.active_view = ActiveView::Agent(active);
let target = crate::app::actions::ClipboardPasteTarget::AgentPrompt {
agent_id: origin,
images_dir: None,
};
show_clipboard_failure(
&target,
crate::app::actions::ClipboardPasteFailure::TextRead,
&mut app,
);
assert_eq!(
app.agents[&origin]
.toast
.as_ref()
.map(|(text, _)| text.as_str()),
Some("Couldn't read clipboard text")
);
assert!(app.agents[&active].toast.is_none());
app.agents[&origin].toast = Some(("Couldn't save pasted image".to_owned(), 90));
show_clipboard_failure(
&target,
crate::app::actions::ClipboardPasteFailure::AlreadyReported,
&mut app,
);
assert_eq!(
app.agents[&origin]
.toast
.as_ref()
.map(|(text, _)| text.as_str()),
Some("Couldn't save pasted image")
);
}
#[test]
fn clipboard_failure_routes_to_originating_dashboard() {
let mut app = test_app_with_agent();
open_dashboard(&mut app);
app.active_view = ActiveView::Agent(AgentId(0));
show_clipboard_failure(
&crate::app::actions::ClipboardPasteTarget::DashboardDispatch,
crate::app::actions::ClipboardPasteFailure::AttachmentRead,
&mut app,
);
assert_eq!(
app.dashboard
.as_ref()
.and_then(|dashboard| dashboard.error_toast.as_deref()),
Some("Couldn't read clipboard contents")
);
assert!(app.agents[&AgentId(0)].toast.is_none());
}
#[test]
fn plugins_action_success_sets_result_notice_and_autoreload_preserves_it() {
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
// Simulate a per-row action (`u` update) in flight on row 2.
modal.pending_entry_index = Some(2);
app.agents.get_mut(&id).unwrap().extensions_modal = Some(modal);
}
// A successful update surfaces its message as a row-anchored result notice
// (the fix for "I can't tell what happened after r/u"), non-covering.
dispatch(
Action::TaskComplete(TaskResult::PluginsActionResult {
agent_id: id,
result: Ok(kigi_hooks_plugins_types::ActionOutcome {
status: kigi_hooks_plugins_types::OutcomeStatus::Success,
message: "user/abcd1234/my-plugin: updated".into(),
requires_reload: true,
requires_restart: false,
}),
}),
&mut app,
);
{
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
let n = modal
.result_notice
.as_ref()
.expect("success must set a result notice");
assert_eq!(n.message, "user/abcd1234/my-plugin: updated");
assert_eq!(n.entry_index, Some(2), "anchored to the acted row");
}
// The auto-reload chained from `requires_reload` returns a generic message;
// it must NOT clobber the specific update notice already on screen.
dispatch(
Action::TaskComplete(TaskResult::PluginsActionResult {
agent_id: id,
result: Ok(kigi_hooks_plugins_types::ActionOutcome {
status: kigi_hooks_plugins_types::OutcomeStatus::Success,
message: "Plugin registry rebuilt: 5 plugin(s).".into(),
requires_reload: false,
requires_restart: false,
}),
}),
&mut app,
);
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
let n = modal.result_notice.as_ref().expect("notice still present");
assert_eq!(
n.message, "user/abcd1234/my-plugin: updated",
"auto-reload must not clobber the triggering action's notice"
);
}
#[test]
fn tab_wide_action_success_sets_tab_wide_result_notice() {
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
// Tab-wide reload carries no row anchor.
modal.pending_entry_index = None;
app.agents.get_mut(&id).unwrap().extensions_modal = Some(modal);
}
dispatch(
Action::TaskComplete(TaskResult::PluginsActionResult {
agent_id: id,
result: Ok(kigi_hooks_plugins_types::ActionOutcome {
status: kigi_hooks_plugins_types::OutcomeStatus::Success,
message: "Plugin registry rebuilt: 7 plugin(s).".into(),
requires_reload: false,
requires_restart: false,
}),
}),
&mut app,
);
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
let n = modal.result_notice.as_ref().expect("result notice set");
assert_eq!(n.entry_index, None, "tab-wide action → footer status line");
assert_eq!(n.message, "Plugin registry rebuilt: 7 plugin(s).");
}
#[test]
fn uninstall_result_notice_is_footer_only_not_row_anchored() {
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
// A row action was in flight, but it's an uninstall — the row goes away.
modal.pending_entry_index = Some(1);
modal.last_plugins_action = Some(kigi_hooks_plugins_types::PluginsAction::Uninstall {
plugin_id: "user/ab12/gone".into(),
confirmed: true,
});
app.agents.get_mut(&id).unwrap().extensions_modal = Some(modal);
}
dispatch(
Action::TaskComplete(TaskResult::PluginsActionResult {
agent_id: id,
result: Ok(kigi_hooks_plugins_types::ActionOutcome {
status: kigi_hooks_plugins_types::OutcomeStatus::Success,
message: "Uninstalled repo \"user/ab12/gone\" (1 plugin(s): gone)".into(),
requires_reload: true,
requires_restart: false,
}),
}),
&mut app,
);
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
let n = modal.result_notice.as_ref().expect("result notice set");
assert_eq!(
n.entry_index, None,
"uninstall removes the row → footer-only, no stale row checkmark"
);
}
/// Regression (Bugbot): a failed `kigi/subagent/cancel` RPC must NOT
/// finalize the row — the subagent may still be running. Only a shell
/// response of "nothing live" finalizes it.
#[test]
fn kill_rpc_failure_does_not_finalize_but_nothing_live_does() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let sid = acp::SessionId::new("test-session".to_owned());
{
let agent = app.agents.get_mut(&id).unwrap();
let mut info = make_test_subagent("child-1", "sa-1");
info.pending_kill = true;
agent.subagent_sessions.insert("child-1".into(), info);
}
// RPC failed → leave the row (the subagent may still be running).
dispatch_task_result(
TaskResult::KillSubagentComplete {
session_id: sid.clone(),
subagent_id: "sa-1".into(),
outcome: SubagentKillOutcome::RpcFailed,
},
&mut app,
);
assert!(
!app.agents[&id].subagent_sessions["child-1"].finished,
"a failed cancel RPC must not finalize the row"
);
// Nothing live → no finish coming, so the pager finalizes the row.
dispatch_task_result(
TaskResult::KillSubagentComplete {
session_id: sid,
subagent_id: "sa-1".into(),
outcome: SubagentKillOutcome::NothingLive { status: None },
},
&mut app,
);
assert!(
app.agents[&id].subagent_sessions["child-1"].finished,
"nothing-live must finalize the orphan row"
);
}
/// An already-finished orphan's real terminal status (`NothingLive { status:
/// Some(..) }`) is forwarded to the finalized row, not flattened to "cancelled".
#[test]
fn kill_nothing_live_with_status_stamps_real_terminal_status() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let sid = acp::SessionId::new("test-session".to_owned());
{
let agent = app.agents.get_mut(&id).unwrap();
let mut info = make_test_subagent("child-1", "sa-1");
info.pending_kill = true;
agent.subagent_sessions.insert("child-1".into(), info);
}
dispatch_task_result(
TaskResult::KillSubagentComplete {
session_id: sid,
subagent_id: "sa-1".into(),
outcome: SubagentKillOutcome::NothingLive {
status: Some("completed".into()),
},
},
&mut app,
);
let info = &app.agents[&id].subagent_sessions["child-1"];
assert!(info.finished, "already-finished orphan must be finalized");
assert_eq!(
info.status.as_deref(),
Some("completed"),
"the shell's real terminal status must be stamped, not 'cancelled'"
);
}
#[test]
fn cancel_complete_does_nothing() {
let mut app = test_app_with_agent();
let effects = dispatch(Action::TaskComplete(TaskResult::CancelComplete), &mut app);
assert!(effects.is_empty());
}
#[test]
fn switch_model_complete_success_updates_model_and_pushes_message() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
// Set up available models so the display name can be resolved.
app.agents
.get_mut(&id)
.unwrap()
.session
.models
.available
.insert(
model_id.clone(),
acp::ModelInfo::new(model_id.clone(), "Kigi 4.5".to_string()),
);
app.agents
.get_mut(&id)
.unwrap()
.session
.model_switch_pending = true;
let initial_scrollback = app.agents[&id].scrollback.len();
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
prev_model_id: None,
}),
&mut app,
);
// Pending flag cleared.
assert!(!app.agents[&id].session.model_switch_pending);
// Current model updated.
assert_eq!(
app.agents[&id].session.models.current,
Some(model_id.clone())
);
// Success message pushed to scrollback.
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
// PersistPreferredModel effect emitted.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PersistPreferredModel { model_id: mid, .. } if *mid == model_id.clone()
));
}
#[test]
fn switch_model_complete_skips_message_and_persist_when_unchanged() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
let agent = app.agents.get_mut(&id).unwrap();
agent.session.models.available.insert(
model_id.clone(),
acp::ModelInfo::new(model_id.clone(), "Kigi 4.5".to_string()),
);
agent.session.models.current = Some(model_id.clone());
agent.session.models.reasoning_effort = None;
agent.session.model_switch_pending = true;
let before = app.agents[&id].scrollback.len();
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
prev_model_id: None,
}),
&mut app,
);
assert!(!app.agents[&id].session.model_switch_pending);
assert_eq!(app.agents[&id].scrollback.len(), before, "no message added");
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::PersistPreferredModel { .. })),
"no persist effect for no-op switch"
);
}
#[test]
fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
use kigi_shell::sampling::types::ReasoningEffort;
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("byok-model-47"));
let mut meta = serde_json::Map::new();
meta.insert(
"supportsReasoningEffort".into(),
serde_json::Value::Bool(true),
);
meta.insert(
"reasoningEffort".into(),
serde_json::Value::String("xhigh".into()),
);
app.agents
.get_mut(&id)
.unwrap()
.session
.models
.available
.insert(
model_id.clone(),
acp::ModelInfo::new(model_id.clone(), "BYOK Model 4.7".to_string())
.meta(serde_json::Value::Object(meta).as_object().cloned()),
);
app.agents
.get_mut(&id)
.unwrap()
.session
.model_switch_pending = true;
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: model_id.clone(),
effort: None, // user typed `/model Blackbox 4.7` with no effort
result: Ok(()),
prev_model_id: None,
}),
&mut app,
);
assert_eq!(
app.agents[&id].session.models.reasoning_effort,
Some(ReasoningEffort::Xhigh)
);
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::PersistPreferredModel {
model_id: mid,
reasoning_effort,
} => {
assert_eq!(*mid, model_id);
assert_eq!(
*reasoning_effort,
Some(ReasoningEffort::Xhigh),
"persisted effort must mirror the live UI effort so a \
fresh chat does not reset reasoning to a stale default",
);
}
other => panic!("expected PersistPreferredModel, got {other:?}"),
}
}
#[test]
fn switch_to_non_reasoning_model_clears_persisted_effort() {
use kigi_shell::sampling::types::ReasoningEffort;
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
// Simulate prior reasoning effort from a previous model.
app.agents
.get_mut(&id)
.unwrap()
.session
.models
.reasoning_effort = Some(ReasoningEffort::High);
// The new model does NOT have supportsReasoningEffort in its meta.
app.agents
.get_mut(&id)
.unwrap()
.session
.models
.available
.insert(
model_id.clone(),
acp::ModelInfo::new(model_id.clone(), "Kigi".to_string()),
);
app.agents
.get_mut(&id)
.unwrap()
.session
.model_switch_pending = true;
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
prev_model_id: None,
}),
&mut app,
);
assert_eq!(
app.agents[&id].session.models.reasoning_effort, None,
"reasoning_effort must be cleared when switching to a non-reasoning model",
);
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::PersistPreferredModel {
reasoning_effort, ..
} => {
assert_eq!(
*reasoning_effort, None,
"persisted effort must be None so config.toml clears the stale value",
);
}
other => panic!("expected PersistPreferredModel, got {other:?}"),
}
}
#[test]
fn switch_model_complete_failure_pushes_error_and_clears_pending() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("bad-model"));
app.agents
.get_mut(&id)
.unwrap()
.session
.model_switch_pending = true;
let old_current = app.agents[&id].session.models.current.clone();
let initial_scrollback = app.agents[&id].scrollback.len();
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id,
effort: None,
result: Err(SwitchModelError::Other("model not found".into())),
prev_model_id: None,
}),
&mut app,
);
assert!(effects.is_empty());
// Pending flag cleared.
assert!(!app.agents[&id].session.model_switch_pending);
// Current model unchanged.
assert_eq!(app.agents[&id].session.models.current, old_current);
// Error message pushed to scrollback.
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
}
#[test]
fn switch_model_incompatible_agent_shows_question_modal() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("cursor-model"));
app.agents
.get_mut(&id)
.unwrap()
.session
.model_switch_pending = true;
let initial_scrollback = app.agents[&id].scrollback.len();
let err = kigi_shell::agent::config::ModelSwitchIncompatibleAgentError {
code: "MODEL_SWITCH_INCOMPATIBLE_AGENT".into(),
active_agent_type: "kigi".into(),
required_agent_type: "cursor".into(),
model_id: "cursor-model".into(),
suggestion: "start_new_session".into(),
};
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id,
effort: None,
result: Err(SwitchModelError::IncompatibleAgent {
error: err,
prev_model_id: None,
}),
prev_model_id: None,
}),
&mut app,
);
// No effects emitted (modal is synchronous state).
assert!(effects.is_empty());
// Pending flag cleared.
assert!(!app.agents[&id].session.model_switch_pending);
// Question modal is open.
assert!(app.agents[&id].question_view.is_some());
let qv = app.agents[&id].question_view.as_ref().unwrap();
assert!(matches!(
qv.local_kind,
Some(crate::views::question_view::LocalQuestionKind::AgentTypeMismatch { .. })
));
// No error message pushed to scrollback.
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback);
}
#[test]
fn incompatible_agent_rollback_restores_previous_model() {
// When SetDefaultModel optimistically updates models.current and the
// shell rejects with IncompatibleAgent, the handler must roll back
// models.current to the prev_model_id.
let mut app = test_app_with_agent();
let id = AgentId(0);
let prev_model = acp::ModelId::new(std::sync::Arc::from("original-model"));
let new_model = acp::ModelId::new(std::sync::Arc::from("cursor-model"));
// Set up catalog with both models.
let agent = app.agents.get_mut(&id).unwrap();
agent.session.models.available.insert(
prev_model.clone(),
acp::ModelInfo::new(prev_model.clone(), "Original".to_string()),
);
agent.session.models.available.insert(
new_model.clone(),
acp::ModelInfo::new(new_model.clone(), "Cursor".to_string()),
);
// Simulate the optimistic update that set_default_model_inner does.
agent.session.models.set_current(new_model.clone(), None);
agent.session.model_switch_pending = true;
assert_eq!(agent.session.models.current, Some(new_model.clone()));
let err = kigi_shell::agent::config::ModelSwitchIncompatibleAgentError {
code: "MODEL_SWITCH_INCOMPATIBLE_AGENT".into(),
active_agent_type: "kigi".into(),
required_agent_type: "cursor".into(),
model_id: "cursor-model".into(),
suggestion: "start_new_session".into(),
};
dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: new_model,
effort: None,
result: Err(SwitchModelError::IncompatibleAgent {
error: err,
prev_model_id: Some(prev_model.clone()),
}),
prev_model_id: Some(prev_model.clone()),
}),
&mut app,
);
// models.current must be rolled back to the previous model.
assert_eq!(
app.agents[&id].session.models.current,
Some(prev_model),
"models.current must be rolled back on IncompatibleAgent",
);
}
#[test]
fn incompatible_agent_closes_active_modal() {
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("cursor-model"));
// Open any modal to verify it gets closed.
let agent = app.agents.get_mut(&id).unwrap();
agent.active_modal = Some(ActiveModal::CommandPalette {
entries: vec![],
state: crate::views::picker::PickerState::input_active(),
window: crate::views::modal_window::ModalWindowState::new(),
});
agent.session.model_switch_pending = true;
let err = kigi_shell::agent::config::ModelSwitchIncompatibleAgentError {
code: "MODEL_SWITCH_INCOMPATIBLE_AGENT".into(),
active_agent_type: "kigi".into(),
required_agent_type: "cursor".into(),
model_id: "cursor-model".into(),
suggestion: "start_new_session".into(),
};
dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id,
effort: None,
result: Err(SwitchModelError::IncompatibleAgent {
error: err,
prev_model_id: None,
}),
prev_model_id: None,
}),
&mut app,
);
// Active modal must be closed.
assert!(
app.agents[&id].active_modal.is_none(),
"active modal must be closed when IncompatibleAgent fires",
);
// Question modal must be open.
assert!(
app.agents[&id].question_view.is_some(),
"question modal must be open",
);
}
#[test]
fn same_agent_type_switch_no_modal() {
// Switching between two models with the same (or no) agent type
// should succeed normally — no modal, no IncompatibleAgent error.
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_a = acp::ModelId::new(std::sync::Arc::from("kigi-a"));
let model_b = acp::ModelId::new(std::sync::Arc::from("kigi-b"));
// Add both models to the catalog (no agentType → both use kigi).
let agent = app.agents.get_mut(&id).unwrap();
agent.session.models.available.insert(
model_a.clone(),
acp::ModelInfo::new(model_a.clone(), "Kigi A".to_string()),
);
agent.session.models.set_current(model_a, None);
agent.session.models.available.insert(
model_b.clone(),
acp::ModelInfo::new(model_b.clone(), "Kigi B".to_string()),
);
agent.session.model_switch_pending = true;
// Shell returns Ok (same agent type, no mismatch).
let effects = dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: model_b.clone(),
effort: None,
result: Ok(()),
prev_model_id: None,
}),
&mut app,
);
// Model should be switched, no modal.
assert_eq!(app.agents[&id].session.models.current, Some(model_b));
assert!(app.agents[&id].question_view.is_none());
// Should emit PersistPreferredModel.
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::PersistPreferredModel { .. }))
);
}
#[test]
fn switch_model_pending_lifecycle() {
// Full lifecycle: false -> dispatch SwitchModel -> true -> SwitchModelComplete -> false
let mut app = test_app_with_agent();
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
// Initially false.
assert!(!app.agents[&id].session.model_switch_pending);
// Action sets pending.
dispatch(
Action::SwitchModel {
model_id: model_id.clone(),
effort: None,
},
&mut app,
);
assert!(app.agents[&id].session.model_switch_pending);
// TaskResult clears pending.
dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id,
effort: None,
result: Ok(()),
prev_model_id: None,
}),
&mut app,
);
assert!(!app.agents[&id].session.model_switch_pending);
}
#[test]
fn no_deferred_switch_means_no_extra_effect() {
// When there is no deferred model switch, SessionCreated should
// not produce a SwitchModel effect.
let mut app = test_app_with_agent();
let id = AgentId(0);
// Remove session_id to simulate pre-session state.
app.agents.get_mut(&id).unwrap().session.session_id = None;
assert!(app.agents[&id].session.deferred_model_switch.is_none());
let effects = dispatch(
Action::TaskComplete(TaskResult::SessionCreated {
agent_id: id,
session_id: "new-session".into(),
models: None,
}),
&mut app,
);
// No SwitchModel effect.
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { .. }))
);
assert!(!app.agents[&id].session.model_switch_pending);
}
#[test]
fn bundle_status_ready_populates_state() {
let mut app = test_app();
dispatch(
Action::TaskComplete(TaskResult::BundleStatusReady {
has_cache: true,
version: Some("v2".into()),
personas: vec!["researcher".into(), "auditor".into()],
roles: vec!["reviewer".into()],
agents: vec!["default".into()],
skills: vec!["commit".into(), "code-review".into()],
persona_details: vec![crate::app::bundle::PersonaDetail {
name: "researcher".into(),
description: Some("thorough researcher".into()),
has_inputs: true,
has_outputs: false,
source_path: None,
scope_label: None,
}],
role_details: vec![crate::app::bundle::RoleDetail {
name: "reviewer".into(),
description: "code reviewer".into(),
}],
}),
&mut app,
);
assert!(app.bundle_state.has_cache);
assert_eq!(app.bundle_state.version, "v2");
assert_eq!(app.bundle_state.personas, vec!["researcher", "auditor"]);
assert_eq!(app.bundle_state.roles, vec!["reviewer"]);
assert_eq!(app.bundle_state.agents, vec!["default"]);
assert_eq!(app.bundle_state.skills, vec!["commit", "code-review"]);
assert_eq!(app.bundle_state.persona_details.len(), 1);
assert_eq!(app.bundle_state.persona_details[0].name, "researcher");
assert_eq!(app.bundle_state.role_details.len(), 1);
assert_eq!(app.bundle_state.role_details[0].name, "reviewer");
}
#[test]
fn bundle_status_failed_logs_but_keeps_state() {
let mut app = test_app();
app.bundle_state.has_cache = true;
app.bundle_state.version = "keep-me".into();
dispatch(
Action::TaskComplete(TaskResult::BundleStatusFailed {
error: "status fetch failed".into(),
}),
&mut app,
);
assert!(app.bundle_state.has_cache);
assert_eq!(app.bundle_state.version, "keep-me");
}
#[test]
fn catalog_entry_ready_opens_viewer() {
let mut app = test_app_with_agent();
dispatch(
Action::TaskComplete(TaskResult::CatalogEntryReady {
kind: "persona".into(),
name: "researcher".into(),
content: "instructions = \"deep research\"".into(),
}),
&mut app,
);
let ActiveView::Agent(id) = app.active_view else {
panic!("expected agent view");
};
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_some());
let viewer = agent.block_viewer.as_ref().unwrap();
assert_eq!(
viewer.kind,
crate::views::block_viewer::ViewerKind::PlainText
);
}
#[test]
fn catalog_entry_failed_shows_system_message() {
let mut app = test_app_with_agent();
let initial_len = {
let ActiveView::Agent(id) = app.active_view else {
panic!("expected agent view");
};
app.agents[&id].scrollback.len()
};
let effects = dispatch(
Action::TaskComplete(TaskResult::CatalogEntryFailed {
error: "not found".into(),
}),
&mut app,
);
assert!(effects.is_empty());
let ActiveView::Agent(id) = app.active_view else {
panic!("expected agent view");
};
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_none());
assert!(agent.scrollback.len() > initial_len);
}
#[test]
fn available_commands_refreshed_updates_generation() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let gen_before = app.agents[&id].session.available_commands_generation;
let commands = vec![
acp::AvailableCommand::new("commit", "Create a commit").meta(
serde_json::json!({"scope": "local", "path": "/skill/SKILL.md"})
.as_object()
.cloned(),
),
];
let effects = dispatch(
Action::TaskComplete(TaskResult::AvailableCommandsRefreshed {
agent_id: id,
commands,
}),
&mut app,
);
assert!(effects.is_empty());
assert_eq!(
app.agents[&id].session.available_commands_generation,
gen_before + 1
);
assert_eq!(app.agents[&id].session.available_commands.len(), 1);
assert_eq!(app.agents[&id].session.available_commands[0].name, "commit");
}
#[test]
fn available_commands_refreshed_empty_is_noop() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let gen_before = app.agents[&id].session.available_commands_generation;
let effects = dispatch(
Action::TaskComplete(TaskResult::AvailableCommandsRefreshed {
agent_id: id,
commands: vec![],
}),
&mut app,
);
assert!(effects.is_empty());
assert_eq!(
app.agents[&id].session.available_commands_generation,
gen_before,
);
}
// -- Session deletion from the /resume picker -----------------------
#[test]
fn delete_session_complete_removes_only_matching_source_and_id() {
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
let mut foreign_same_id = make_picker_entry("s1", "/r");
foreign_same_id.source = "codex".into();
let mut remote_same_id = make_picker_entry("s1", "/r");
remote_same_id.source = "remote".into();
open_session_picker_with(
&mut app,
vec![
make_picker_entry("s0", "/r"),
foreign_same_id,
make_picker_entry("s1", "/r"),
remote_same_id,
make_picker_entry("s2", "/r"),
],
);
let mut welcome_foreign = make_picker_entry("s1", "/r");
welcome_foreign.source = "codex".into();
let mut welcome_remote = make_picker_entry("s1", "/r");
welcome_remote.source = "remote".into();
app.session_picker_entries = Some(vec![
welcome_foreign,
make_picker_entry("s1", "/r"),
welcome_remote,
]);
if let Some(ActiveModal::SessionPicker { pending_delete, .. }) = get_active_agent_mut(&mut app)
.unwrap()
.active_modal
.as_mut()
{
*pending_delete = Some(("local".into(), "s1".into(), "/r".into()));
}
let _ = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "local".into(),
session_id: "s1".into(),
},
&mut app,
);
let agent = get_active_agent(&app).expect("active agent");
let Some(ActiveModal::SessionPicker {
entries: Some(list),
pending_delete,
..
}) = agent.active_modal.as_ref()
else {
panic!("expected SessionPicker modal");
};
let identities: Vec<_> = list
.iter()
.map(|entry| (entry.source.as_str(), entry.id.as_str()))
.collect();
assert_eq!(
identities,
vec![
("local", "s0"),
("codex", "s1"),
("remote", "s1"),
("local", "s2"),
]
);
assert!(
pending_delete.is_none(),
"pending_delete must be cleared after deletion completes"
);
let welcome_identities: Vec<_> = app
.session_picker_entries
.as_ref()
.unwrap()
.iter()
.map(|entry| (entry.source.as_str(), entry.id.as_str()))
.collect();
assert_eq!(welcome_identities, vec![("codex", "s1"), ("remote", "s1")]);
}
#[test]
fn delete_both_session_clears_modal_and_welcome_content_hits() {
use crate::views::modal::ActiveModal;
use crate::views::session_picker::{PickerItem, SourceFilter, build_entry_map};
let mut app = test_app_with_agent();
let mut both = make_picker_entry("shared", "/r");
both.source = "both".into();
let mut foreign = make_picker_entry("shared", "/r");
foreign.source = "codex".into();
open_session_picker_with(&mut app, vec![both.clone(), foreign.clone()]);
let hit = kigi_shell::extensions::session_search::SearchSessionHit {
session_id: "shared".into(),
summary: "shared".into(),
cwd: "/r".into(),
updated_at: chrono::Utc::now().to_rfc3339(),
snippet: Some("deleted content".into()),
score: 1.0,
matched_fields: vec![],
};
if let Some(ActiveModal::SessionPicker {
state,
content_results,
..
}) = get_active_agent_mut(&mut app)
.unwrap()
.active_modal
.as_mut()
{
state.query = "shared".into();
*content_results = Some(vec![hit.clone()]);
}
app.session_picker_entries = Some(vec![both, foreign]);
app.session_picker_state.query = "shared".into();
app.session_picker_content_results = Some(vec![hit]);
let _ = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "both".into(),
session_id: "shared".into(),
},
&mut app,
);
let agent = get_active_agent(&app).unwrap();
let Some(ActiveModal::SessionPicker {
entries: Some(modal_entries),
content_results: Some(modal_hits),
state: modal_state,
..
}) = agent.active_modal.as_ref()
else {
panic!("expected modal picker");
};
assert_eq!(
modal_entries
.iter()
.map(|entry| (entry.source.as_str(), entry.id.as_str()))
.collect::<Vec<_>>(),
vec![("codex", "shared")]
);
assert!(modal_hits.is_empty());
let modal_map = build_entry_map(
Some(modal_entries),
Some(modal_hits),
"shared",
true,
false,
SourceFilter::All,
None,
);
assert!(
!modal_map
.iter()
.any(|item| matches!(item, Some(PickerItem::Content { .. })))
);
assert_eq!(modal_state.query.as_str(), "shared");
let welcome_entries = app.session_picker_entries.as_deref().unwrap();
let welcome_hits = app.session_picker_content_results.as_deref().unwrap();
assert_eq!(
welcome_entries
.iter()
.map(|entry| (entry.source.as_str(), entry.id.as_str()))
.collect::<Vec<_>>(),
vec![("codex", "shared")]
);
assert!(welcome_hits.is_empty());
let welcome_map = build_entry_map(
Some(welcome_entries),
Some(welcome_hits),
"shared",
false,
false,
SourceFilter::All,
None,
);
assert!(
!welcome_map
.iter()
.any(|item| matches!(item, Some(PickerItem::Content { .. })))
);
}
#[test]
fn delete_remote_session_clears_modal_and_welcome_content_hits() {
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
let mut remote = make_picker_entry("remote-only", "/r");
remote.source = "remote".into();
open_session_picker_with(&mut app, vec![remote.clone()]);
let hit = kigi_shell::extensions::session_search::SearchSessionHit {
session_id: "remote-only".into(),
summary: "remote-only".into(),
cwd: "/r".into(),
updated_at: chrono::Utc::now().to_rfc3339(),
snippet: Some("stale deleted content".into()),
score: 1.0,
matched_fields: vec![],
};
if let Some(ActiveModal::SessionPicker {
content_results, ..
}) = get_active_agent_mut(&mut app)
.unwrap()
.active_modal
.as_mut()
{
*content_results = Some(vec![hit.clone()]);
}
app.session_picker_entries = Some(vec![remote]);
app.session_picker_content_results = Some(vec![hit]);
let _ = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "remote".into(),
session_id: "remote-only".into(),
},
&mut app,
);
let Some(ActiveModal::SessionPicker {
entries: Some(modal_entries),
content_results: Some(modal_hits),
..
}) = app.agents[&AgentId(0)].active_modal.as_ref()
else {
panic!("expected modal picker");
};
assert!(modal_entries.is_empty());
assert!(modal_hits.is_empty());
assert!(app.session_picker_entries.as_ref().unwrap().is_empty());
assert!(
app.session_picker_content_results
.as_ref()
.unwrap()
.is_empty()
);
}
#[test]
fn delete_session_failed_keeps_all_entries() {
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
open_session_picker_with(
&mut app,
vec![make_picker_entry("s0", "/r"), make_picker_entry("s1", "/r")],
);
let _ = dispatch_task_result(
TaskResult::DeleteSessionFailed {
source: "local".into(),
session_id: "s1".into(),
error: "boom".into(),
},
&mut app,
);
let agent = get_active_agent(&app).expect("active agent");
let Some(ActiveModal::SessionPicker {
entries: Some(list),
..
}) = agent.active_modal.as_ref()
else {
panic!("expected SessionPicker modal");
};
assert_eq!(list.len(), 2, "a failed delete must not remove any entry");
}
#[test]
fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
// Pins the documented design decision: a failed on-disk rename
// does NOT roll back the local `display_name` cache (the cache
// is the source of truth for the modal's rendering and the
// disk write is best-effort). The user sees the failure via
// the system-block message instead.
let mut app = test_app_with_agent();
// Seed the cache as the rename path would have done.
if let Some(a) = app.agents.get_mut(&AgentId(0)) {
a.display_name = Some("optimistic title".into());
}
let scrollback_len_before = app.agents[&AgentId(0)].scrollback.len();
let _effects = dispatch_task_result(
TaskResult::RenameSessionFailed {
agent_id: AgentId(0),
error: "boom".into(),
},
&mut app,
);
// No rollback.
assert_eq!(
app.agents[&AgentId(0)].display_name.as_deref(),
Some("optimistic title"),
"display_name must NOT roll back on RenameSessionFailed"
);
// System block appended with the error.
let scrollback = &app.agents[&AgentId(0)].scrollback;
assert_eq!(
scrollback.len(),
scrollback_len_before + 1,
"system block must be appended"
);
let last = scrollback.entry(scrollback.len() - 1).expect("last entry");
let text = match &last.block {
crate::scrollback::block::RenderBlock::System(b) => b.text.clone(),
other => panic!("expected System block, got {other:?}"),
};
assert!(
text.contains("Couldn't rename session: boom"),
"system block must surface the error; got: {text:?}"
);
}
/// `apply_setting_rollback` on a known key reverts the in-memory
/// cache without emitting any new effects.
#[test]
fn rollback_known_key_reverts_cache_and_no_effect() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
// Toggle to true first.
let _ = dispatch(Action::SetCompactMode(true), &mut app);
assert!(app.current_ui.compact_mode);
// Now simulate persist failure that rolls back to false.
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "compact_mode",
rollback_value: SettingValue::Bool(false),
error: "test-error".into(),
}),
&mut app,
);
assert!(
effects.is_empty(),
"rollback path must NOT emit any new Effects (would loop)",
);
// Cache is reverted.
assert!(!app.current_ui.compact_mode);
}
/// `apply_setting_rollback` on an unknown key surfaces a tracing
/// error and a secondary toast, but does not panic.
#[test]
fn rollback_unknown_key_does_not_panic() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "nonexistent_setting",
rollback_value: SettingValue::Bool(true),
error: "test-error".into(),
}),
&mut app,
);
assert!(effects.is_empty());
// No assertion on cache state (no arm to rollback through);
// the toast surfaces the inconsistency to the user.
}
#[test]
fn persist_failed_toast_contains_key_and_error() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
let _ = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "compact_mode",
rollback_value: SettingValue::Bool(false),
error: "permission denied".into(),
}),
&mut app,
);
let toast = read_toast(&app);
assert!(toast.contains("compact_mode"));
assert!(toast.contains("permission denied"));
assert!(toast.contains('\u{2717}'));
}
/// Rollback path must revert BOTH `app.current_ui` AND the
/// thread-local cache — failing only on one is the bug class
/// the inner/outer split exists to prevent.
#[test]
fn rollback_reverts_thread_local_cache_too() {
use crate::settings::SettingValue;
std::thread::spawn(|| {
let mut app = test_app_with_agent();
// Optimistic set: cache → true.
let _ = dispatch(Action::SetCompactMode(true), &mut app);
assert!(crate::appearance::cache::load());
// Synthesize failure: rollback to false.
let _ = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "compact_mode",
rollback_value: SettingValue::Bool(false),
error: "test-error".into(),
}),
&mut app,
);
assert!(
!app.current_ui.compact_mode,
"rollback must update app.current_ui"
);
assert!(
!crate::appearance::cache::load(),
"rollback must also revert the cache — otherwise the renderer \
still shows the optimistic value while current_ui shows the rolled-back one"
);
})
.join()
.unwrap();
}
/// A default_model persist failure must NOT revert the live session's
/// model. The switch already succeeded in the session (its own failure
/// path reports separately); a DISK write failure only means the default
/// won't stick for the next launch — same policy as PersistPreferredModel
/// ("still active for this session"). Regression: the rollback arm
/// re-showed the ORIGINAL model and issued a reverse SwitchModel, which
/// on Windows (persist failures from AV/indexer file locks) made every
/// picker selection appear to not take.
#[test]
fn default_model_persist_failure_keeps_live_model() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
let id = AgentId(0);
for (mid, name) in [("old-model", "Old Model"), ("new-model", "New Model")] {
let model_id = acp::ModelId::new(std::sync::Arc::from(mid));
let info = acp::ModelInfo::new(model_id.clone(), name.to_string());
app.agents
.get_mut(&id)
.unwrap()
.session
.models
.available
.insert(model_id.clone(), info.clone());
app.models.available.insert(model_id, info);
}
// User picked the new model; optimistic update applied.
let _ = dispatch(
Action::SetDefaultModel(acp::ModelId::new(std::sync::Arc::from("new-model"))),
&mut app,
);
assert_eq!(
app.agents[&id]
.session
.models
.current
.as_ref()
.map(|m| m.0.as_ref()),
Some("new-model"),
);
// Disk persist fails (the Windows sharing-violation shape).
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "default_model",
rollback_value: SettingValue::String("Old Model".into()),
error: "Access is denied. (os error 5)".into(),
}),
&mut app,
);
assert_eq!(
app.agents[&id]
.session
.models
.current
.as_ref()
.map(|m| m.0.as_ref()),
Some("new-model"),
"a persist failure must not revert the live session's model"
);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { .. })),
"no reverse SwitchModel may be issued for a disk-persist failure"
);
assert!(
read_toast(&app).contains("Could not save"),
"the save failure must still be surfaced"
);
}
/// `set_yolo_mode_inner` is the backstop: even a (stale) rollback
/// value of "always-approve" must not re-enable yolo under the pin.
#[test]
fn rollback_to_always_approve_blocked_by_policy_pin() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
app.yolo_policy_block = Some(POLICY_WARNING);
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "permission_mode",
rollback_value: SettingValue::Enum("always-approve"),
error: "permission denied".into(),
}),
&mut app,
);
assert!(effects.is_empty(), "rollback path never re-emits effects");
assert!(
!app.agents[&AgentId(0)].session.is_yolo(),
"inner backstop must hold on the rollback path"
);
assert!(!app.default_yolo);
}
// -- SessionListLoaded ------------------------------------------------
/// Canary: an empty list surfaces the generic "no sessions" toast.
#[test]
fn session_list_empty_shows_generic_toast() {
let mut app = test_app_with_agent();
open_session_picker_with(&mut app, vec![]);
let _ = dispatch(
Action::TaskComplete(TaskResult::SessionListLoaded {
sessions: vec![],
seq: 0,
query: None,
}),
&mut app,
);
assert!(read_toast(&app).contains("No sessions found"));
}