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.
1709 lines
54 KiB
Rust
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"));
|
|
}
|