Launching kigi in a non-project directory (~/Downloads, ~, /tmp — anywhere
`is_project_dir` rejects) leaves the agent view session-less behind the
project-picker question, which only a PLAIN prompt can open. Slash
commands still execute there, so `/model <name> <effort>` stashed its
switch in `deferred_model_switch` — a stash that assumes a create is in
flight — and with none pending it dangled forever with zero feedback:
the picker rendered, the user chose model and effort, and nothing
changed. Not a 0.1.10 regression: 0.1.9 pty-reproduces identically; the
report correlated with the upgrade only because every earlier launch
happened to be from a project directory.
Apply the QueueCommand precedent ("queued slash work bypasses the
picker, so create the deferred session or it never drains") to both
deferral sites — Action::SwitchModel in the router and
set_default_model's no-session branch — via
skip_picker_and_create_session, whose in-flight guard already makes it a
no-op while a create is pending, so the racing-create case is unchanged.
SessionCreated then applies the stash through the existing
apply_deferred_model_switch path; pty-verified end-to-end from
~/Downloads (session created, model changed, effort applied).
1904 lines
66 KiB
Rust
1904 lines
66 KiB
Rust
//! Tests for session create, exit, trust, startup actions, worktree creation, and cloud lifecycle.
|
|
use super::*;
|
|
/// Simulate a release-stamped build so folder-trust is active (a local/dev
|
|
/// build auto-trusts and persists nothing). Mirrors this module's raw env idiom.
|
|
fn simulate_release_build() {
|
|
unsafe { std::env::set_var(kigi_version::TEST_VERSION_ENV, "0.0.0-sim") };
|
|
}
|
|
#[test]
|
|
fn chip_submit_without_session_keeps_chips_and_does_not_send() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
{
|
|
let agent = app.agents.get_mut(&id).unwrap();
|
|
agent.session.session_id = None;
|
|
agent.apply_follow_ups("resp-1".into(), vec!["Summarize".into()]);
|
|
assert!(agent.follow_ups.is_some(), "precondition: chips shown");
|
|
}
|
|
let effects = dispatch(Action::SubmitFollowUp("Summarize".into()), &mut app);
|
|
assert!(
|
|
!effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SendPrompt { .. })),
|
|
"an unbound-session submit must emit no SendPrompt, got {effects:?}"
|
|
);
|
|
assert!(
|
|
app.agents[&id].follow_ups.is_some(),
|
|
"an unbound-session submit must NOT clear the chips"
|
|
);
|
|
}
|
|
#[test]
|
|
fn send_prompt_without_session_queues_but_no_effect() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
|
let effects = dispatch(Action::SendPrompt("hello".into()), &mut app);
|
|
assert!(effects.is_empty());
|
|
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
|
assert_eq!(app.agents[&id].session.pending_prompts[0].text, "hello");
|
|
}
|
|
#[test]
|
|
fn session_created_sets_session_id() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SessionCreated {
|
|
agent_id: id,
|
|
session_id: "new-session-123".into(),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert_eq!(effects.len(), 4);
|
|
assert!(
|
|
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. } if
|
|
session_id == "new-session-123")
|
|
);
|
|
assert!(matches!(&effects[1], Effect::FetchSessionAgentName { .. }));
|
|
assert!(matches!(
|
|
&effects[2],
|
|
Effect::RefreshAvailableCommands { .. }
|
|
));
|
|
assert!(matches!(&effects[3], Effect::RegisterActiveSession { .. }));
|
|
assert_eq!(
|
|
app.agents[&id]
|
|
.session
|
|
.session_id
|
|
.as_ref()
|
|
.map(|s| s.0.as_ref()),
|
|
Some("new-session-123")
|
|
);
|
|
}
|
|
/// All System-block texts in an agent's scrollback, in order.
|
|
fn all_system_texts(app: &AppView, id: AgentId) -> Vec<String> {
|
|
let sb = &app.agents[&id].scrollback;
|
|
(0..sb.len())
|
|
.filter_map(|i| match &sb.get(i).expect("index in range").block {
|
|
RenderBlock::System(sys) => Some(sys.text.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
/// In minimal mode the `/new` session banner must advertise `/resume`, not
|
|
/// `/dashboard` — the dashboard command is refused there, while the
|
|
/// `/resume` session picker works. Deterministic regardless of the
|
|
/// dashboard feature flag: the minimal branch of
|
|
/// `session_switch_hint_command` never consults it.
|
|
#[test]
|
|
fn session_created_banner_advertises_resume_in_minimal_mode() {
|
|
let mut app = test_app_with_agent();
|
|
app.screen_mode = crate::app::ScreenMode::Minimal;
|
|
let id = AgentId(1);
|
|
let mut session = make_test_agent_session(&app, id, "unused");
|
|
session.session_id = None;
|
|
session.created_via_new = true;
|
|
app.agents
|
|
.insert(id, AgentView::new(session, ScrollbackState::new()));
|
|
dispatch(
|
|
Action::TaskComplete(TaskResult::SessionCreated {
|
|
agent_id: id,
|
|
session_id: "new-session-123".into(),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
let texts = all_system_texts(&app, id);
|
|
let banner = texts
|
|
.iter()
|
|
.find(|t| t.contains("switch between sessions"))
|
|
.unwrap_or_else(|| panic!("expected a session-switch banner, got: {texts:?}"));
|
|
assert!(
|
|
banner.contains("Session new-session-123 \u{2014} use /resume to switch between sessions"),
|
|
"minimal mode must advertise /resume: {banner}"
|
|
);
|
|
assert!(
|
|
!texts.iter().any(|t| t.contains("/dashboard")),
|
|
"minimal mode must NOT advertise /dashboard: {texts:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn global_cancel_subagents_pref_skips_panel_without_session_override() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
{
|
|
let agent = app.agents.get_mut(&id).unwrap();
|
|
agent.session.state = AgentState::TurnRunning;
|
|
agent
|
|
.subagent_sessions
|
|
.insert("child-1".into(), make_test_subagent("child-1", "sa-1"));
|
|
}
|
|
app.current_ui.cancel_subagents_on_turn_cancel = Some("always_continue".into());
|
|
let effects = dispatch(Action::CancelTurn, &mut app);
|
|
assert!(app.agents[&id].cancel_turn_view.is_none());
|
|
assert!(matches!(
|
|
effects.as_slice(),
|
|
[Effect::CancelTurn {
|
|
cancel_subagents: false,
|
|
..
|
|
}]
|
|
));
|
|
}
|
|
#[test]
|
|
fn new_worktree_session_creates_agent_and_returns_effect() {
|
|
let mut app = test_app_git();
|
|
let effects = dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
assert_eq!(effects.len(), 1);
|
|
assert!(matches!(effects[0], Effect::CreateWorktreeSession { .. }));
|
|
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0))));
|
|
assert!(app.agents.contains_key(&AgentId(0)));
|
|
assert_eq!(app.agents[&AgentId(0)].scrollback.len(), 0);
|
|
assert!(matches!(
|
|
app.agents[&AgentId(0)].session.state,
|
|
AgentState::CommandRunning { .. }
|
|
));
|
|
}
|
|
#[test]
|
|
fn worktree_session_created_sets_session_and_cwd() {
|
|
let mut app = test_app_git();
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
let worktree_path = PathBuf::from("/tmp/kigi-worktrees/pager-123");
|
|
let session_cwd = worktree_path.clone();
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::WorktreeSessionCreated {
|
|
agent_id: id,
|
|
session_id: acp::SessionId::new("wt-session-1"),
|
|
worktree_path: worktree_path.clone(),
|
|
session_cwd: session_cwd.clone(),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::FetchPromptHistory { .. }))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::FetchSessionAgentName { .. }))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::RegisterActiveSession { .. }))
|
|
);
|
|
assert_eq!(
|
|
app.agents[&id]
|
|
.session
|
|
.session_id
|
|
.as_ref()
|
|
.map(|s| s.0.as_ref()),
|
|
Some("wt-session-1")
|
|
);
|
|
assert_eq!(app.agents[&id].session.cwd, session_cwd);
|
|
assert_eq!(app.agents[&id].scrollback.len(), 1);
|
|
assert!(app.agents[&id].session.state.is_idle());
|
|
}
|
|
#[test]
|
|
fn worktree_session_preserves_subdirectory_offset() {
|
|
let mut app = test_app();
|
|
app.cwd = PathBuf::from("/repo/crates/codegen/kigi-tui");
|
|
app.cwd_has_git_ancestor = true;
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
let worktree_root = PathBuf::from("/home/user/.kigi/worktrees/repo/pager-123");
|
|
let session_cwd = worktree_root.join("crates/codegen/kigi-tui");
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::WorktreeSessionCreated {
|
|
agent_id: id,
|
|
session_id: acp::SessionId::new("wt-nested-1"),
|
|
worktree_path: worktree_root.clone(),
|
|
session_cwd: session_cwd.clone(),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::FetchPromptHistory { .. }))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::RegisterActiveSession { .. }))
|
|
);
|
|
assert_eq!(app.agents[&id].session.cwd, session_cwd);
|
|
assert_ne!(app.agents[&id].session.cwd, worktree_root);
|
|
}
|
|
#[test]
|
|
fn worktree_session_failed_without_session_returns_to_welcome() {
|
|
let mut app = test_app_git();
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
assert!(app.agents.contains_key(&id));
|
|
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0))));
|
|
let error_msg = "worktree creation failed: not a git repo";
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::WorktreeSessionFailed {
|
|
agent_id: id,
|
|
error: error_msg.into(),
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty());
|
|
assert!(!app.agents.contains_key(&id));
|
|
assert!(matches!(app.active_view, ActiveView::Welcome));
|
|
assert!(
|
|
app.startup_warnings
|
|
.iter()
|
|
.any(|w| w.message.contains(error_msg)),
|
|
"expected warning containing the error message"
|
|
);
|
|
}
|
|
#[test]
|
|
fn worktree_session_failed_with_fork_parent_keeps_agent() {
|
|
let mut app = test_app_git();
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
app.agents.get_mut(&id).unwrap().session.forked_from = Some(AgentId(99));
|
|
let error_msg = "worktree creation failed: permission denied";
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::WorktreeSessionFailed {
|
|
agent_id: id,
|
|
error: error_msg.into(),
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty());
|
|
assert!(app.agents.contains_key(&id));
|
|
assert!(app.agents[&id].session.state.is_idle());
|
|
let entry = app.agents[&id].scrollback.entry(0).unwrap();
|
|
match &entry.block {
|
|
RenderBlock::SessionEvent(block) => match &block.event {
|
|
SessionEvent::TurnFailed { error, .. } => {
|
|
assert_eq!(error, error_msg);
|
|
}
|
|
other => panic!("expected TurnFailed, got {other:?}"),
|
|
},
|
|
other => panic!("expected SessionEvent block, got {other:?}"),
|
|
}
|
|
}
|
|
#[test]
|
|
fn new_worktree_session_rejects_non_git_cwd() {
|
|
let mut app = test_app();
|
|
let effects = dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty());
|
|
assert!(app.agents.is_empty());
|
|
assert!(matches!(app.active_view, ActiveView::Welcome));
|
|
assert!(
|
|
app.startup_warnings
|
|
.iter()
|
|
.any(|w| w.message.contains("Not inside a git repository")),
|
|
"expected git-repo warning"
|
|
);
|
|
}
|
|
#[test]
|
|
fn worktree_session_created_drains_queued_prompts() {
|
|
let mut app = test_app_git();
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
let effects = dispatch(Action::SendPrompt("hello".into()), &mut app);
|
|
assert!(effects.is_empty(), "no session_id yet, can't drain");
|
|
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
|
let worktree_path = PathBuf::from("/tmp/kigi-worktrees/pager-abc");
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::WorktreeSessionCreated {
|
|
agent_id: id,
|
|
session_id: acp::SessionId::new("wt-drain-1"),
|
|
worktree_path,
|
|
session_cwd: PathBuf::from("/tmp/kigi-worktrees/pager-abc"),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
|
|
"hello"))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::FetchPromptHistory { .. }))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::RegisterActiveSession { .. }))
|
|
);
|
|
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
|
}
|
|
#[test]
|
|
fn session_created_drains_queued_prompts() {
|
|
let mut app = test_app();
|
|
dispatch(Action::NewSession, &mut app);
|
|
let id = AgentId(0);
|
|
let effects = dispatch(Action::SendPrompt("queued msg".into()), &mut app);
|
|
assert!(effects.is_empty());
|
|
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SessionCreated {
|
|
agent_id: id,
|
|
session_id: acp::SessionId::new("sess-drain-1"),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
|
|
"queued msg"))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::FetchPromptHistory { .. }))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::RegisterActiveSession { .. }))
|
|
);
|
|
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
|
}
|
|
#[test]
|
|
fn session_created_with_flag_emits_four_fetches_and_clears_flag() {
|
|
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
{
|
|
let a = app.agents.get_mut(&id).unwrap();
|
|
a.session.session_id = None;
|
|
a.pending_extensions_fetch = true;
|
|
a.extensions_modal = Some(ExtensionsModalState::new(ExtensionsTab::Hooks));
|
|
}
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SessionCreated {
|
|
agent_id: id,
|
|
session_id: acp::SessionId::new("s"),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert_eq!(count_extension_fetches(&effects), 4);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::FetchMcpsList { cache: true, .. }))
|
|
);
|
|
assert!(!app.agents[&id].pending_extensions_fetch);
|
|
}
|
|
#[test]
|
|
fn session_created_without_flag_emits_no_extension_fetches() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
|
assert!(!app.agents[&id].pending_extensions_fetch);
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SessionCreated {
|
|
agent_id: id,
|
|
session_id: acp::SessionId::new("s"),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert_eq!(count_extension_fetches(&effects), 0);
|
|
}
|
|
#[test]
|
|
fn session_failed_clears_flag_no_fetches() {
|
|
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
{
|
|
let a = app.agents.get_mut(&id).unwrap();
|
|
a.session.session_id = None;
|
|
a.pending_extensions_fetch = true;
|
|
a.extensions_modal = Some(ExtensionsModalState::new(ExtensionsTab::Hooks));
|
|
}
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SessionFailed {
|
|
agent_id: id,
|
|
error: "boom".to_string(),
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert_eq!(count_extension_fetches(&effects), 0);
|
|
assert!(!app.agents[&id].pending_extensions_fetch);
|
|
}
|
|
#[test]
|
|
fn switch_model_without_session_starts_the_session() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
|
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
|
|
let effects = dispatch(
|
|
Action::SwitchModel {
|
|
model_id,
|
|
effort: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
// No create was in flight, so the switch starts the session its stash
|
|
// drains into (the stash alone dangled forever — the /model-in-Downloads
|
|
// silent no-op). `model_switch_pending` flips on SessionCreated.
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. }))
|
|
);
|
|
assert!(!app.agents[&id].session.model_switch_pending);
|
|
}
|
|
#[test]
|
|
fn agent_type_mismatch_start_new_creates_session_with_model_id() {
|
|
let mut app = test_app_with_agent();
|
|
let model_id = acp::ModelId::new(std::sync::Arc::from("cursor-model"));
|
|
let effects = dispatch(
|
|
Action::AgentTypeMismatchAnswered {
|
|
start_new: true,
|
|
model_id: model_id.clone(),
|
|
effort: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let create = effects
|
|
.iter()
|
|
.find(|e| matches!(e, Effect::CreateSession { .. }));
|
|
assert!(create.is_some(), "expected CreateSession effect");
|
|
match create.unwrap() {
|
|
Effect::CreateSession { model_id: mid, .. } => {
|
|
assert_eq!(
|
|
mid.as_ref(),
|
|
Some(&model_id),
|
|
"CreateSession must carry the target model_id",
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
if let ActiveView::Agent(new_aid) = app.active_view {
|
|
let agent = &app.agents[&new_aid];
|
|
assert!(
|
|
agent.session.deferred_model_switch.is_none(),
|
|
"no-effort mismatch must not set deferred_model_switch",
|
|
);
|
|
} else {
|
|
panic!("expected active view to be an Agent");
|
|
}
|
|
}
|
|
#[test]
|
|
fn new_session_seeds_mcp_init_progress() {
|
|
let mut app = test_app();
|
|
dispatch(Action::NewSession, &mut app);
|
|
let id = AgentId(0);
|
|
let progress = app.agents[&id].mcp_init_progress.as_ref();
|
|
assert!(
|
|
progress.is_some(),
|
|
"new session must seed mcp_init_progress",
|
|
);
|
|
let p = progress.unwrap();
|
|
assert_eq!(
|
|
p.total, 0,
|
|
"seeded total must be 0 (unknown until shell reports)"
|
|
);
|
|
assert_eq!(p.connected, 0, "seeded connected must be 0");
|
|
}
|
|
#[test]
|
|
fn new_session_without_model_switch_has_no_model_id() {
|
|
let mut app = test_app();
|
|
let effects = dispatch(Action::NewSession, &mut app);
|
|
let create = effects
|
|
.iter()
|
|
.find(|e| matches!(e, Effect::CreateSession { .. }));
|
|
assert!(create.is_some());
|
|
match create.unwrap() {
|
|
Effect::CreateSession { model_id, .. } => {
|
|
assert!(model_id.is_none(), "plain /new must not carry a model_id",);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
#[test]
|
|
fn new_session_seeds_available_commands_from_bootstrap() {
|
|
let mut app = test_app();
|
|
app.bootstrap_acp_commands = vec![acp::AvailableCommand::new(
|
|
"flush".to_string(),
|
|
"Flush memory".to_string(),
|
|
)];
|
|
dispatch(Action::NewSession, &mut app);
|
|
let id = AgentId(0);
|
|
assert_eq!(app.agents[&id].session.available_commands.len(), 1);
|
|
assert_eq!(app.agents[&id].session.available_commands[0].name, "flush");
|
|
assert_eq!(app.agents[&id].session.available_commands_generation, 1);
|
|
}
|
|
#[test]
|
|
fn new_session_empty_bootstrap_starts_at_generation_1() {
|
|
let mut app = test_app();
|
|
app.bootstrap_acp_commands = Vec::new();
|
|
dispatch(Action::NewSession, &mut app);
|
|
let id = AgentId(0);
|
|
assert!(app.agents[&id].session.available_commands.is_empty());
|
|
assert_eq!(app.agents[&id].session.available_commands_generation, 1);
|
|
}
|
|
#[test]
|
|
fn worktree_session_seeds_available_commands_from_bootstrap() {
|
|
let mut app = test_app_git();
|
|
app.bootstrap_acp_commands = vec![acp::AvailableCommand::new(
|
|
"plugins".to_string(),
|
|
"List plugins".to_string(),
|
|
)];
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
assert_eq!(app.agents[&id].session.available_commands.len(), 1);
|
|
assert_eq!(
|
|
app.agents[&id].session.available_commands[0].name,
|
|
"plugins"
|
|
);
|
|
assert_eq!(app.agents[&id].session.available_commands_generation, 1);
|
|
}
|
|
#[test]
|
|
fn exit_session_unregisters_active_session() {
|
|
let mut app = test_app_with_agent();
|
|
let effects = dispatch(Action::ExitSession, &mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::UnregisterActiveSession { .. })),
|
|
"ExitSession must emit UnregisterActiveSession, got: {effects:?}"
|
|
);
|
|
assert!(matches!(app.active_view, ActiveView::Welcome));
|
|
}
|
|
#[test]
|
|
fn slash_new_dispatches_new_session() {
|
|
let mut app = test_app_with_agent();
|
|
let effects = dispatch(Action::SendPrompt("/new".into()), &mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. }))
|
|
);
|
|
}
|
|
#[test]
|
|
fn new_session_falls_back_to_app_cwd_on_welcome_screen() {
|
|
let mut app = test_app();
|
|
assert!(matches!(app.active_view, ActiveView::Welcome));
|
|
let effects = dispatch(Action::NewSession, &mut app);
|
|
let create = effects
|
|
.iter()
|
|
.find(|e| matches!(e, Effect::CreateSession { .. }));
|
|
assert!(create.is_some(), "expected CreateSession effect");
|
|
match create.unwrap() {
|
|
Effect::CreateSession { cwd, .. } => assert_eq!(cwd, &PathBuf::from("/tmp")),
|
|
_ => unreachable!(),
|
|
}
|
|
let new_id = AgentId(0);
|
|
assert!(!app.agents[&new_id].session.is_worktree);
|
|
}
|
|
#[test]
|
|
fn new_session_starts_with_prompt_focused() {
|
|
let mut app = test_app();
|
|
assert!(matches!(app.active_view, ActiveView::Welcome));
|
|
dispatch(Action::NewSession, &mut app);
|
|
let id = AgentId(0);
|
|
assert_eq!(app.agents[&id].active_pane, ActivePane::Prompt);
|
|
}
|
|
#[test]
|
|
fn switch_model_deferred_when_no_session_id() {
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
|
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
|
let effects = dispatch(
|
|
Action::SwitchModel {
|
|
model_id: model_id.clone(),
|
|
effort: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
// Stashed for SessionCreated — and the session it drains into is started
|
|
// (no create was in flight; a bare stash never drained).
|
|
assert_eq!(
|
|
app.agents[&id].session.deferred_model_switch,
|
|
Some((model_id, None))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. }))
|
|
);
|
|
assert!(!app.agents[&id].session.model_switch_pending);
|
|
}
|
|
#[test]
|
|
fn deferred_model_switch_applied_on_session_created() {
|
|
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 session_id: acp::SessionId = "new-session".into();
|
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
|
app.agents
|
|
.get_mut(&id)
|
|
.unwrap()
|
|
.session
|
|
.deferred_model_switch = Some((model_id.clone(), None));
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SessionCreated {
|
|
agent_id: id,
|
|
session_id: session_id.clone(),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(app.agents[&id].session.deferred_model_switch.is_none());
|
|
assert!(app.agents[&id].session.model_switch_pending);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id,
|
|
session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id
|
|
&& * m_id == model_id))
|
|
);
|
|
}
|
|
#[test]
|
|
fn deferred_model_switch_applied_on_worktree_session_created() {
|
|
let mut app = test_app_git();
|
|
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
|
|
dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
let id = AgentId(0);
|
|
app.agents
|
|
.get_mut(&id)
|
|
.unwrap()
|
|
.session
|
|
.deferred_model_switch = Some((model_id.clone(), None));
|
|
let session_id: acp::SessionId = "wt-session".into();
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::WorktreeSessionCreated {
|
|
agent_id: id,
|
|
session_id: session_id.clone(),
|
|
worktree_path: PathBuf::from("/tmp/worktree"),
|
|
session_cwd: PathBuf::from("/tmp/worktree"),
|
|
models: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(app.agents[&id].session.deferred_model_switch.is_none());
|
|
assert!(app.agents[&id].session.model_switch_pending);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id,
|
|
session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id
|
|
&& * m_id == model_id))
|
|
);
|
|
}
|
|
/// The session-startup gate requires BOTH auth AND trust resolved. Trust is
|
|
/// gated AFTER auth, so either one pending defers session creation.
|
|
#[test]
|
|
fn session_startup_allowed_requires_auth_and_trust() {
|
|
let mut app = test_app();
|
|
assert!(app.session_startup_allowed());
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
assert!(
|
|
!app.session_startup_allowed(),
|
|
"pending trust must block session startup",
|
|
);
|
|
app.trust_state = TrustState::Done;
|
|
app.auth_state = AuthState::Pending { error: None };
|
|
assert!(
|
|
!app.session_startup_allowed(),
|
|
"pending auth must block session startup",
|
|
);
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
assert!(
|
|
!app.session_startup_allowed(),
|
|
"both pending must block session startup",
|
|
);
|
|
}
|
|
/// Accepting the trust question (its `finish_trust` tail) resolves trust and
|
|
/// replays the deferred startup when auth is already done. (Declining quits
|
|
/// instead -- see `welcome_trust_decline_keys_quit` in `app_view`.)
|
|
#[test]
|
|
fn finish_trust_resolves_and_replays_startup() {
|
|
let mut app = test_app();
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
app.deferred_startup.session =
|
|
Some(crate::app::session_startup::DeferredSessionStartup::Load {
|
|
session_id: "deferred-session".into(),
|
|
session_cwd: None,
|
|
chat_kind: false,
|
|
});
|
|
let effects = finish_trust(&mut app);
|
|
assert!(matches!(app.trust_state, TrustState::Done));
|
|
assert!(
|
|
app.deferred_startup.session.is_none(),
|
|
"resolving trust must drain the deferred startup",
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::LoadSession { .. })),
|
|
"deferred load must replay once trust resolves",
|
|
);
|
|
}
|
|
/// Accepting the trust question persists the grant to the store and resolves
|
|
/// trust. KIGI_SHARE_DIR-isolated so the write hits a temp store, not the real one.
|
|
#[serial_test::serial(KIGI_SHARE_DIR)]
|
|
#[test]
|
|
fn trust_folder_grants_and_resolves() {
|
|
use kigi_workspace::trust::{TrustStore, workspace_key};
|
|
let home = tempfile::tempdir().expect("home tempdir");
|
|
unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) };
|
|
simulate_release_build();
|
|
let repo = tempfile::tempdir().expect("repo tempdir");
|
|
let workspace = workspace_key(repo.path());
|
|
let mut app = test_app();
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: workspace.clone(),
|
|
};
|
|
let _ = dispatch(Action::TrustFolder, &mut app);
|
|
assert!(matches!(app.trust_state, TrustState::Done));
|
|
assert!(
|
|
TrustStore::load().is_trusted(&workspace),
|
|
"accepting must persist the trust grant for the workspace",
|
|
);
|
|
}
|
|
/// When BOTH auth and trust are pending, `AuthComplete` must NOT replay the
|
|
/// deferred startup -- the trust question renders next, and its answer drains
|
|
/// it. Verifies the symmetric two-gate hand-off.
|
|
#[test]
|
|
fn auth_complete_defers_startup_until_trust_resolved() {
|
|
let mut app = test_app();
|
|
app.auth_state = AuthState::Authenticating {
|
|
request_seq: 1,
|
|
handle: None,
|
|
auth_url: None,
|
|
mode: AuthMode::Pending,
|
|
};
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
app.deferred_startup.session =
|
|
Some(crate::app::session_startup::DeferredSessionStartup::Load {
|
|
session_id: "deferred-session".into(),
|
|
session_cwd: None,
|
|
chat_kind: false,
|
|
});
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::AuthComplete {
|
|
request_seq: 1,
|
|
meta: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(matches!(app.auth_state, AuthState::Done));
|
|
assert!(
|
|
matches!(app.trust_state, TrustState::Pending { .. }),
|
|
"trust stays pending after auth completes",
|
|
);
|
|
assert!(
|
|
app.deferred_startup.session.is_some(),
|
|
"startup must remain deferred while trust is pending",
|
|
);
|
|
assert!(
|
|
!effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::LoadSession { .. })),
|
|
"no session may load before trust is answered",
|
|
);
|
|
let effects = finish_trust(&mut app);
|
|
assert!(app.deferred_startup.session.is_none());
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::LoadSession { .. })),
|
|
"trust answer must replay the deferred startup",
|
|
);
|
|
}
|
|
/// Symmetric to the above: trust answered FIRST while auth is still pending
|
|
/// exercises `finish_trust`'s `else` (deferred) branch -- it must NOT drain;
|
|
/// the later `AuthComplete` (the last gate) drains.
|
|
#[test]
|
|
fn trust_answered_first_defers_startup_until_auth_completes() {
|
|
let mut app = test_app();
|
|
app.auth_state = AuthState::Authenticating {
|
|
request_seq: 1,
|
|
handle: None,
|
|
auth_url: None,
|
|
mode: AuthMode::Pending,
|
|
};
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
app.deferred_startup.session =
|
|
Some(crate::app::session_startup::DeferredSessionStartup::Load {
|
|
session_id: "deferred-session".into(),
|
|
session_cwd: None,
|
|
chat_kind: false,
|
|
});
|
|
let effects = finish_trust(&mut app);
|
|
assert!(matches!(app.trust_state, TrustState::Done));
|
|
assert!(
|
|
app.deferred_startup.session.is_some(),
|
|
"startup stays deferred while auth is still pending",
|
|
);
|
|
assert!(
|
|
!effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::LoadSession { .. })),
|
|
"no session may load before auth completes",
|
|
);
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::AuthComplete {
|
|
request_seq: 1,
|
|
meta: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(matches!(app.auth_state, AuthState::Done));
|
|
assert!(app.deferred_startup.session.is_none());
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::LoadSession { .. })),
|
|
"auth completion replays the deferred startup",
|
|
);
|
|
}
|
|
/// Regression / negative-space: a session-creating dispatch (e.g. the
|
|
/// global `Ctrl+N` `NewSession`, which bypasses the welcome interceptor) must
|
|
/// NOT create a session while `TrustState::Pending` -- the dispatch
|
|
/// chokepoint stashes it, and it replays exactly once when trust resolves.
|
|
#[test]
|
|
fn new_session_is_gated_while_trust_pending() {
|
|
let mut app = test_app();
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
assert!(app.agents.is_empty());
|
|
let effects = dispatch(Action::NewSession, &mut app);
|
|
assert!(
|
|
app.agents.is_empty(),
|
|
"no session may be created while trust is Pending",
|
|
);
|
|
assert!(
|
|
app.deferred_startup.new_session,
|
|
"the new-session intent must be stashed for replay",
|
|
);
|
|
assert!(effects.is_empty(), "a gated NewSession produces no effects");
|
|
let _ = finish_trust(&mut app);
|
|
assert!(matches!(app.trust_state, TrustState::Done));
|
|
assert!(
|
|
!app.deferred_startup.new_session,
|
|
"the stashed new-session intent is consumed on replay",
|
|
);
|
|
assert_eq!(
|
|
app.agents.len(),
|
|
1,
|
|
"the deferred new session replays exactly once after trust resolves",
|
|
);
|
|
}
|
|
/// Sticky `--chat` first create (NewSession) stamps chat_kind.
|
|
#[test]
|
|
fn chat_mode_new_session_creates_with_chat_kind() {
|
|
let mut app = test_app();
|
|
app.chat_mode = true;
|
|
let effects = dispatch(Action::NewSession, &mut app);
|
|
assert!(
|
|
effects.iter().any(|e| matches!(
|
|
e,
|
|
Effect::CreateSession {
|
|
chat_kind: true,
|
|
..
|
|
}
|
|
)),
|
|
"expected chat CreateSession under --chat, got {effects:?}"
|
|
);
|
|
}
|
|
/// Atomicity: when several startup intents coexist (e.g. CLI
|
|
/// `--resume` + an incidental `Ctrl+N` deferred during the trust question),
|
|
/// `drain_startup_actions` replays the highest-priority one and leaves NO
|
|
/// `startup_*` field set — no stale intent can fire on a later drain.
|
|
#[test]
|
|
fn drain_clears_all_startup_fields_even_when_intents_coexist() {
|
|
let mut app = test_app();
|
|
app.deferred_startup.session =
|
|
Some(crate::app::session_startup::DeferredSessionStartup::Load {
|
|
session_id: "deferred-session".into(),
|
|
session_cwd: None,
|
|
chat_kind: false,
|
|
});
|
|
app.deferred_startup.new_session = true;
|
|
app.deferred_startup.worktree_label = Some("stray".into());
|
|
let effects = drain_startup_actions(&mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::LoadSession { .. })),
|
|
"the higher-priority deferred resume replays",
|
|
);
|
|
assert!(app.deferred_startup.session.is_none());
|
|
assert!(app.deferred_startup.session.is_none());
|
|
assert!(!app.deferred_startup.worktree);
|
|
assert!(app.deferred_startup.worktree_label.is_none());
|
|
assert!(
|
|
!app.deferred_startup.new_session,
|
|
"the superseded Ctrl+N intent must be cleared, not left set",
|
|
);
|
|
assert!(app.deferred_startup.prompt.is_none());
|
|
assert!(!app.deferred_startup.open_dashboard);
|
|
}
|
|
/// The `--worktree <ref>` feature must keep
|
|
/// working through the startup gate. A `NewWorktreeSession` carrying a
|
|
/// `git_ref` dispatched while the trust gate is closed is stashed into
|
|
/// `deferred_startup.worktree_ref` by the chokepoint; once the gate opens the drain
|
|
/// replays it as the worktree's git ref and clears the field.
|
|
#[test]
|
|
fn deferred_worktree_ref_replays_through_gate() {
|
|
let mut app = test_app();
|
|
app.cwd_has_git_ancestor = true;
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
let effects = dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: Some("wt".into()),
|
|
git_ref: Some("feature-branch".into()),
|
|
},
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
effects.is_empty(),
|
|
"a gated worktree session produces no effects",
|
|
);
|
|
assert_eq!(
|
|
app.deferred_startup.worktree_ref.as_deref(),
|
|
Some("feature-branch"),
|
|
"the --worktree <ref> must be stashed for replay",
|
|
);
|
|
assert!(app.deferred_startup.worktree);
|
|
let effects = finish_trust(&mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateWorktreeSession { git_ref :
|
|
Some(r), .. } if r == "feature-branch")),
|
|
"the deferred --worktree <ref> replays with its git ref",
|
|
);
|
|
assert!(
|
|
app.deferred_startup.worktree_ref.is_none(),
|
|
"the stashed worktree ref is consumed by the drain",
|
|
);
|
|
assert!(!app.deferred_startup.worktree);
|
|
}
|
|
/// Regression: a gated worktree-WITHOUT-load-id must
|
|
/// not clobber a `--resume`/`LoadSession` id a previous gated action already
|
|
/// stashed. Otherwise `drain_startup_actions` loses the resume intent and
|
|
/// opens a blank worktree instead of a worktree-with-resume.
|
|
#[test]
|
|
fn gated_worktree_without_load_id_preserves_stashed_resume() {
|
|
let mut app = test_app();
|
|
app.cwd_has_git_ancestor = true;
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
let effects = dispatch(
|
|
Action::LoadSession("resume-me".into(), None, false),
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty(), "a gated resume produces no effects");
|
|
assert_eq!(
|
|
app.deferred_startup.session.as_ref().and_then(|d| match d {
|
|
crate::app::session_startup::DeferredSessionStartup::Load { session_id, .. } =>
|
|
Some(session_id.as_str()),
|
|
_ => None,
|
|
}),
|
|
Some("resume-me"),
|
|
"the gated resume id must be stashed",
|
|
);
|
|
let effects = dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty(), "a gated worktree produces no effects");
|
|
assert_eq!(
|
|
app.deferred_startup.session.as_ref().and_then(|d| match d {
|
|
crate::app::session_startup::DeferredSessionStartup::Load { session_id, .. } =>
|
|
Some(session_id.as_str()),
|
|
_ => None,
|
|
}),
|
|
Some("resume-me"),
|
|
"the worktree-without-load-id must preserve the stashed resume id",
|
|
);
|
|
assert!(
|
|
app.deferred_startup.worktree,
|
|
"the worktree intent is stashed too"
|
|
);
|
|
let effects = finish_trust(&mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateWorktreeSession {
|
|
load_session_id : Some(id), .. } if id == "resume-me")),
|
|
"the deferred worktree replays with the preserved resume id",
|
|
);
|
|
assert!(app.deferred_startup.session.is_none());
|
|
assert!(!app.deferred_startup.worktree);
|
|
}
|
|
/// Regression (derisk pass): the same clobber class as the resume-id fix,
|
|
/// now for the worktree companion fields. A gated worktree dispatch carrying
|
|
/// `None` label/ref must NOT erase a `--worktree <label>` / `--worktree-ref
|
|
/// <ref>` a previous gated action already stashed -- otherwise the drain
|
|
/// drops the label and builds off the default branch instead of the ref.
|
|
#[test]
|
|
fn gated_worktree_with_none_companions_preserves_stashed_label_and_ref() {
|
|
let mut app = test_app();
|
|
app.cwd_has_git_ancestor = true;
|
|
app.trust_state = TrustState::Pending {
|
|
workspace: PathBuf::from("/x"),
|
|
};
|
|
let effects = dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: Some("mysess".into()),
|
|
label: Some("mylabel".into()),
|
|
git_ref: Some("featbranch".into()),
|
|
},
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty(), "a gated worktree produces no effects");
|
|
assert!(matches!(app.deferred_startup.session.as_ref(), Some(crate
|
|
::app::session_startup::DeferredSessionStartup::Load { session_id, .. }) if
|
|
session_id == "mysess"));
|
|
assert_eq!(
|
|
app.deferred_startup.worktree_label.as_deref(),
|
|
Some("mylabel")
|
|
);
|
|
assert_eq!(
|
|
app.deferred_startup.worktree_ref.as_deref(),
|
|
Some("featbranch")
|
|
);
|
|
let effects = dispatch(
|
|
Action::NewWorktreeSession {
|
|
load_session_id: None,
|
|
label: None,
|
|
git_ref: None,
|
|
},
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty(), "a gated worktree produces no effects");
|
|
assert_eq!(
|
|
app.deferred_startup.session.as_ref().and_then(|d| match d {
|
|
crate::app::session_startup::DeferredSessionStartup::Load { session_id, .. } =>
|
|
Some(session_id.as_str()),
|
|
_ => None,
|
|
}),
|
|
Some("mysess"),
|
|
"the blank worktree must preserve the stashed resume id",
|
|
);
|
|
assert_eq!(
|
|
app.deferred_startup.worktree_label.as_deref(),
|
|
Some("mylabel"),
|
|
"the blank worktree must preserve the stashed label",
|
|
);
|
|
assert_eq!(
|
|
app.deferred_startup.worktree_ref.as_deref(),
|
|
Some("featbranch"),
|
|
"the blank worktree must preserve the stashed git ref",
|
|
);
|
|
assert!(app.deferred_startup.worktree);
|
|
let effects = finish_trust(&mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateWorktreeSession {
|
|
load_session_id : Some(id), label : Some(l), git_ref : Some(r), .. } if id ==
|
|
"mysess" && l == "mylabel" && r == "featbranch")),
|
|
"the deferred worktree replays with the preserved id, label, and ref",
|
|
);
|
|
assert!(app.deferred_startup.worktree_ref.is_none());
|
|
assert!(app.deferred_startup.worktree_label.is_none());
|
|
assert!(app.deferred_startup.session.is_none());
|
|
assert!(!app.deferred_startup.worktree);
|
|
}
|
|
/// `/login` from inside a session must move to the welcome screen (the
|
|
/// only view that renders the auth flow / external-provider URL) and
|
|
/// stash the agent view for restoration. This is the core fix for the
|
|
/// "external auth provider /login does nothing mid-session" bug.
|
|
#[test]
|
|
fn login_mid_session_switches_to_welcome_and_stashes_view() {
|
|
let mut app = test_app_with_agent();
|
|
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
|
let effects = dispatch(Action::Login, &mut app);
|
|
assert_eq!(app.active_view, ActiveView::Welcome);
|
|
assert_eq!(app.auth_return_view, Some(ActiveView::Agent(AgentId(0))));
|
|
assert!(matches!(app.auth_state, AuthState::Authenticating { .. }));
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::Authenticate { .. })),
|
|
"must still kick off the auth flow",
|
|
);
|
|
}
|
|
/// After a successful mid-session re-auth, the stale `ReAuthRequired`
|
|
/// prompt (pushed when the 401 surfaced) is stripped so the user
|
|
/// returns to a clean session.
|
|
#[test]
|
|
fn auth_complete_strips_reauth_prompt_after_mid_session_login() {
|
|
use crate::scrollback::block::RenderBlock;
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
app.agents
|
|
.get_mut(&id)
|
|
.unwrap()
|
|
.scrollback
|
|
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
|
dispatch(Action::Login, &mut app);
|
|
let seq = authenticating_seq(&app);
|
|
dispatch(
|
|
Action::TaskComplete(TaskResult::AuthComplete {
|
|
request_seq: seq,
|
|
meta: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert_eq!(app.active_view, ActiveView::Agent(id));
|
|
let sb = &app.agents[&id].scrollback;
|
|
let has_reauth = (0..sb.len()).any(|i| {
|
|
matches!(
|
|
sb.entry(i).map(| e | & e.block), Some(RenderBlock::SessionEvent(ev)) if
|
|
matches!(ev.event, SessionEvent::ReAuthRequired)
|
|
)
|
|
});
|
|
assert!(
|
|
!has_reauth,
|
|
"re-auth prompt must be stripped after successful re-auth"
|
|
);
|
|
}
|
|
/// After a successful mid-session re-auth, the prompt that failed on the
|
|
/// expired login is auto-resubmitted so the user doesn't have to retype it.
|
|
#[test]
|
|
fn auth_complete_retries_stashed_prompt_after_mid_session_login() {
|
|
use crate::scrollback::block::RenderBlock;
|
|
let mut app = test_app_with_agent();
|
|
let id = AgentId(0);
|
|
{
|
|
let agent = app.agents.get_mut(&id).unwrap();
|
|
agent
|
|
.scrollback
|
|
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
|
|
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
|
|
text: "retry me".into(),
|
|
images: Vec::new(),
|
|
scrollback_entry: crate::scrollback::EntryId::new(0),
|
|
chip_elements: Vec::new(),
|
|
});
|
|
}
|
|
dispatch(Action::Login, &mut app);
|
|
let seq = authenticating_seq(&app);
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::AuthComplete {
|
|
request_seq: seq,
|
|
meta: None,
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert_eq!(app.active_view, ActiveView::Agent(id));
|
|
let agent = &app.agents[&id];
|
|
assert!(
|
|
agent.reauth_stashed_prompt.is_none(),
|
|
"stashed prompt must be consumed on re-auth"
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
|
|
"retry me")),
|
|
"the stashed prompt must be auto-resubmitted, got: {effects:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn dispatch_new_session_answered_with_persist_never_updates_mode_and_emits_effect() {
|
|
let mut app = new_session_test_app();
|
|
app.new_session_worktree_mode = crate::app::app_view::WorktreeMode::Ask;
|
|
let effects = dispatch(
|
|
Action::NewSessionAnswered {
|
|
worktree: false,
|
|
persist_mode: Some(crate::app::app_view::WorktreeMode::Never),
|
|
},
|
|
&mut app,
|
|
);
|
|
assert_eq!(
|
|
app.new_session_worktree_mode,
|
|
crate::app::app_view::WorktreeMode::Never
|
|
);
|
|
assert!(
|
|
effects.iter().any(|e| matches!(
|
|
e,
|
|
Effect::PersistWorktreeMode {
|
|
config_key: "new_session_worktree_mode",
|
|
..
|
|
}
|
|
)),
|
|
"expected PersistWorktreeMode with config_key new_session_worktree_mode"
|
|
);
|
|
}
|
|
#[test]
|
|
fn dispatch_new_session_has_empty_scrollback() {
|
|
let mut app = test_app_with_agent();
|
|
dispatch(Action::NewSession, &mut app);
|
|
let new_id = AgentId(1);
|
|
assert_eq!(app.agents[&new_id].scrollback.len(), 0);
|
|
}
|
|
#[test]
|
|
fn translate_local_submit_always_returns_persist_always_for_new_session() {
|
|
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
|
use kigi_tools::implementations::kigi::ask_user_question::{Question, QuestionOption};
|
|
let q = Question {
|
|
question: "?".into(),
|
|
options: (0..4)
|
|
.map(|i| QuestionOption {
|
|
label: format!("opt{i}"),
|
|
description: String::new(),
|
|
preview: None,
|
|
id: None,
|
|
})
|
|
.collect(),
|
|
multi_select: Some(false),
|
|
id: None,
|
|
};
|
|
let mut state = QuestionViewState::new(
|
|
"x".into(),
|
|
vec![q],
|
|
crate::views::prompt_widget::StashedPrompt::default(),
|
|
)
|
|
.with_local_kind(LocalQuestionKind::NewSession);
|
|
state.selections[0] = crate::views::question_view::QuestionSelection::Single(Some(2));
|
|
let kind = state.local_kind.take().unwrap();
|
|
let outcome = crate::app::agent_view::translate_local_submit_for_test(&state, kind, false);
|
|
match outcome {
|
|
crate::app::app_view::InputOutcome::Action(Action::NewSessionAnswered {
|
|
worktree,
|
|
persist_mode,
|
|
}) => {
|
|
assert!(worktree);
|
|
assert_eq!(
|
|
persist_mode,
|
|
Some(crate::app::app_view::WorktreeMode::Always)
|
|
);
|
|
}
|
|
other => panic!("expected NewSessionAnswered with persist Always, got {other:?}"),
|
|
}
|
|
}
|
|
#[test]
|
|
fn translate_local_submit_never_returns_persist_never_for_new_session() {
|
|
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
|
use kigi_tools::implementations::kigi::ask_user_question::{Question, QuestionOption};
|
|
let q = Question {
|
|
question: "?".into(),
|
|
options: (0..4)
|
|
.map(|i| QuestionOption {
|
|
label: format!("opt{i}"),
|
|
description: String::new(),
|
|
preview: None,
|
|
id: None,
|
|
})
|
|
.collect(),
|
|
multi_select: Some(false),
|
|
id: None,
|
|
};
|
|
let mut state = QuestionViewState::new(
|
|
"x".into(),
|
|
vec![q],
|
|
crate::views::prompt_widget::StashedPrompt::default(),
|
|
)
|
|
.with_local_kind(LocalQuestionKind::NewSession);
|
|
state.selections[0] = crate::views::question_view::QuestionSelection::Single(Some(3));
|
|
let kind = state.local_kind.take().unwrap();
|
|
let outcome = crate::app::agent_view::translate_local_submit_for_test(&state, kind, false);
|
|
match outcome {
|
|
crate::app::app_view::InputOutcome::Action(Action::NewSessionAnswered {
|
|
worktree,
|
|
persist_mode,
|
|
}) => {
|
|
assert!(!worktree);
|
|
assert_eq!(
|
|
persist_mode,
|
|
Some(crate::app::app_view::WorktreeMode::Never)
|
|
);
|
|
}
|
|
other => panic!("expected NewSessionAnswered with persist Never, got {other:?}"),
|
|
}
|
|
}
|
|
#[test]
|
|
fn delete_session_action_emits_delete_effect() {
|
|
let mut app = test_app_with_agent();
|
|
open_session_picker_with(&mut app, vec![make_picker_entry("s1", "/repo")]);
|
|
let effects = dispatch(
|
|
Action::DeleteSession {
|
|
source: "local".into(),
|
|
session_id: "s1".into(),
|
|
cwd: "/repo".into(),
|
|
},
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
matches!(effects.as_slice(), [Effect::DeleteSession { source, session_id, cwd, }]
|
|
if source == "local" && session_id == "s1" && cwd == "/repo"),
|
|
"DeleteSession action must emit exactly one matching DeleteSession effect"
|
|
);
|
|
}
|
|
#[test]
|
|
fn entry_title_falls_back_to_short_session_id_when_no_prompt() {
|
|
use crate::views::session_title::entry_title;
|
|
let mut app = test_app_with_agent();
|
|
if let Some(a) = app.agents.get_mut(&AgentId(0)) {
|
|
a.session.session_id = Some("abcdef0123456".into());
|
|
}
|
|
let title = entry_title(&app.agents[&AgentId(0)]);
|
|
assert_eq!(title, "session abcdef01");
|
|
}
|
|
#[test]
|
|
fn new_session_defers_create_session_for_non_project_dir() {
|
|
let mut app = project_picker_app();
|
|
let effects = dispatch(Action::NewSession, &mut app);
|
|
assert!(app.agents.contains_key(&AgentId(0)));
|
|
assert!(
|
|
!effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
|
);
|
|
}
|
|
#[test]
|
|
fn new_session_creates_session_for_project_dir() {
|
|
let mut app = test_app();
|
|
app.project_picker_shown = false;
|
|
app.cwd = PathBuf::from("/Users/someone/my-project");
|
|
let effects = dispatch(Action::NewSession, &mut app);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn project_selected_creates_session_and_sends_prompt() {
|
|
let mut app = project_picker_app();
|
|
dispatch(Action::NewSession, &mut app);
|
|
let id = AgentId(0);
|
|
let dir = std::env::temp_dir();
|
|
let selected = dunce::canonicalize(&dir).unwrap_or(dir);
|
|
let effects = dispatch(
|
|
Action::ProjectSelected {
|
|
path: selected.clone(),
|
|
stashed_prompt: "hello".into(),
|
|
disable_picker: false,
|
|
},
|
|
&mut app,
|
|
);
|
|
assert_eq!(app.agents[&id].session.cwd, selected);
|
|
assert_eq!(app.cwd, selected);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SetWorkingDir { path } if path == &
|
|
selected))
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { cwd, .. } if cwd ==
|
|
& selected))
|
|
);
|
|
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
|
}
|
|
#[test]
|
|
fn bg_task_killed_no_op_for_unknown_session() {
|
|
let mut app = two_agent_app_with_bg_task();
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::BgTaskKilled {
|
|
session_id: "nonexistent".into(),
|
|
task_id: "task-B-1".into(),
|
|
outcome: Some(kigi_tools::types::KillOutcome::AlreadyExited),
|
|
}),
|
|
&mut app,
|
|
);
|
|
assert!(effects.is_empty());
|
|
assert!(app.agents[&AgentId(1)].session.bg_tasks["task-B-1"].pending_kill);
|
|
}
|
|
/// Mutual exclusion: enabling always-approve via the dispatch seam clears
|
|
/// the per-session `auto_mode` display flag (yolo wins).
|
|
#[test]
|
|
fn set_yolo_on_clears_session_auto_mode() {
|
|
use crate::app::actions::PermissionModeKind;
|
|
let mut app = test_app_with_agent();
|
|
dispatch(
|
|
Action::SetPermissionMode(PermissionModeKind::Auto),
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
app.agents[&AgentId(0)].session.is_auto(),
|
|
"precondition: active agent is in auto"
|
|
);
|
|
dispatch(Action::SetYoloMode(true), &mut app);
|
|
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
|
assert!(
|
|
!app.agents[&AgentId(0)].session.is_auto(),
|
|
"enabling always-approve must clear the per-session auto flag (yolo wins)"
|
|
);
|
|
}
|
|
/// Gate OFF + policy pin: Plan exit lands on Normal (ask) but MUST still
|
|
/// push `SetSessionMode(Default)` so the agent leaves Plan — otherwise the
|
|
/// session stays in Plan while the UI reads Normal.
|
|
#[test]
|
|
fn cycle_mode_plan_exit_under_gate_off_pin_emits_set_session_mode() {
|
|
let mut app = test_app_with_agent();
|
|
app.auto_mode_gate = false;
|
|
app.yolo_policy_block = Some(POLICY_WARNING);
|
|
app.agents.get_mut(&AgentId(0)).unwrap().plan_mode_pending = Some(true);
|
|
let effects = dispatch(Action::CycleMode, &mut app);
|
|
assert!(
|
|
!app.agents[&AgentId(0)].session.is_yolo(),
|
|
"the pin must keep yolo off when exiting Plan"
|
|
);
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
|
assert_eq!(app.agents[&AgentId(0)].plan_mode_pending, Some(false));
|
|
assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING));
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::SetSessionMode { .. })),
|
|
"Plan exit under the pin must still send SetSessionMode(Default), got {effects:?}"
|
|
);
|
|
assert!(
|
|
effects.iter().any(|e| matches!(
|
|
e,
|
|
Effect::PersistPermissionMode {
|
|
canonical: "ask",
|
|
..
|
|
}
|
|
)),
|
|
"expected PersistPermissionMode(ask) under pin, got {effects:?}"
|
|
);
|
|
}
|
|
/// Pre-session cycle (no session id yet) under the pin: Plan → Auto does
|
|
/// not stage yolo (auto is the next mode; pin applies on Auto → Always-Approve).
|
|
#[test]
|
|
fn cycle_mode_pre_session_blocked_by_policy_pin() {
|
|
let mut app = test_app_with_agent();
|
|
app.yolo_policy_block = Some(POLICY_WARNING);
|
|
{
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
agent.session.session_id = None;
|
|
agent.plan_mode_pending = Some(true);
|
|
agent.deferred_session_mode = Some(kigi_tools::types::SessionMode::Plan);
|
|
}
|
|
let _ = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert!(
|
|
!agent.session.is_yolo(),
|
|
"pre-session Plan→Auto must not stage yolo under the pin"
|
|
);
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("auto"));
|
|
let _ = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert!(
|
|
!agent.session.is_yolo(),
|
|
"pre-session cycle must not stage yolo under the pin"
|
|
);
|
|
assert_eq!(agent.plan_mode_pending, Some(false));
|
|
assert_eq!(agent.deferred_session_mode, None);
|
|
assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING));
|
|
}
|
|
/// Pre-session cycle from Plan with STALE `yolo_mode = true` (e.g. client
|
|
/// state restored from before the pin landed): resets to Normal with yolo
|
|
/// cleared (matching the with-session catch-all for the same Plan+yolo input)
|
|
/// so always-approve is not left active behind another banner.
|
|
#[test]
|
|
fn cycle_mode_pre_session_clears_stale_yolo_under_pin() {
|
|
let mut app = test_app_with_agent();
|
|
app.yolo_policy_block = Some(POLICY_WARNING);
|
|
{
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
agent.session.session_id = None;
|
|
agent.plan_mode_pending = Some(true);
|
|
agent.deferred_session_mode = Some(kigi_tools::types::SessionMode::Plan);
|
|
agent.session.yolo_mode = true;
|
|
}
|
|
let _ = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert!(
|
|
!agent.session.is_yolo(),
|
|
"stale pre-session yolo must be cleared so Normal is enforced, not just displayed"
|
|
);
|
|
assert!(!app.default_yolo, "clamp must clear the global mirror too");
|
|
assert_eq!(agent.plan_mode_pending, Some(false));
|
|
assert_eq!(agent.deferred_session_mode, None);
|
|
assert_eq!(
|
|
app.current_ui.permission_mode.as_deref(),
|
|
Some("ask"),
|
|
"Plan+yolo resets to Normal (matches the with-session catch-all), not always-approve"
|
|
);
|
|
}
|
|
/// Pre-session (welcome screen / fresh tab): Shift+Tab must cycle the
|
|
/// mode locally (optimistic pending + deferred ACP push) AND kick off
|
|
/// session creation — without emitting duplicate CreateSession effects
|
|
/// on repeated presses.
|
|
#[test]
|
|
fn dispatch_cycle_mode_pre_session_cycles_locally_and_creates_session() {
|
|
let mut app = test_app_with_agent();
|
|
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
|
let effects = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert_eq!(
|
|
agent.plan_mode_pending,
|
|
Some(true),
|
|
"pre-session Normal → Plan must set optimistic pending"
|
|
);
|
|
assert_eq!(
|
|
agent.deferred_session_mode,
|
|
Some(kigi_tools::types::SessionMode::Plan),
|
|
"Plan must be deferred to SessionCreated"
|
|
);
|
|
assert!(
|
|
effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
|
"first press must create the session, got {effects:?}"
|
|
);
|
|
let effects = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert!(!agent.session.is_yolo(), "Plan → Auto must not enable yolo");
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("auto"));
|
|
assert_eq!(agent.plan_mode_pending, Some(false));
|
|
assert!(agent.deferred_session_mode.is_none());
|
|
assert!(
|
|
!effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
|
"no duplicate CreateSession, got {effects:?}"
|
|
);
|
|
assert!(
|
|
effects.iter().any(|e| matches!(
|
|
e,
|
|
Effect::PersistPermissionMode {
|
|
canonical: "auto",
|
|
session_id: None,
|
|
..
|
|
}
|
|
)),
|
|
"pre-session Plan → Auto must persist the displayed mode, got {effects:?}"
|
|
);
|
|
let _ = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert!(agent.session.is_yolo(), "Auto → Always-Approve flips yolo");
|
|
let _ = dispatch(Action::CycleMode, &mut app);
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert!(!agent.session.is_yolo());
|
|
assert_eq!(agent.plan_mode_pending, Some(false));
|
|
}
|
|
/// No-session bail-out: when the active agent
|
|
/// has no ACP session, the setter toasts "No active session" and
|
|
/// returns empty effects. Pins the safety contract that we never
|
|
/// dispatch a mode change to a non-existent session.
|
|
#[test]
|
|
fn set_plan_mode_no_session_toasts_and_bails() {
|
|
let mut app = test_app_with_agent();
|
|
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
|
let effects = dispatch(
|
|
Action::SetPlanMode(crate::app::actions::PlanModeKind::On),
|
|
&mut app,
|
|
);
|
|
assert!(
|
|
effects.is_empty(),
|
|
"no-session bail must NOT emit Effect (no live session to set mode on)"
|
|
);
|
|
let toast = read_toast(&app);
|
|
assert!(
|
|
toast.contains("No active session"),
|
|
"bail-out must surface the reason to the user: {toast}",
|
|
);
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert!(agent.plan_mode_pending.is_none());
|
|
assert!(!agent.plan_mode_active);
|
|
}
|
|
/// Non-idempotent ON transition: emits
|
|
/// `Effect::SetSessionMode(plan)` and sets `plan_mode_pending`.
|
|
/// Complement to the idempotent-ON test above.
|
|
#[test]
|
|
fn set_plan_mode_on_from_off_emits_set_session_mode() {
|
|
let mut app = test_app_with_agent();
|
|
let effects = dispatch(
|
|
Action::SetPlanMode(crate::app::actions::PlanModeKind::On),
|
|
&mut app,
|
|
);
|
|
assert_eq!(effects.len(), 1);
|
|
assert!(
|
|
matches!(& effects[0], Effect::SetSessionMode { mode_id, .. } if &* mode_id.0 ==
|
|
"plan"),
|
|
"expected SetSessionMode(plan), got: {effects:?}"
|
|
);
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent.plan_mode_pending,
|
|
Some(true),
|
|
"optimistic pending must be set to Some(true)"
|
|
);
|
|
}
|
|
/// Real-world repro: the peek panel is OPEN for the selected row
|
|
/// (it auto-opens on render), and the close is driven END-TO-END
|
|
/// through `handle_input` (Ctrl+X twice) exactly as the event loop
|
|
/// does. Verifies the selection moves to the next row AND the peek
|
|
/// follows it — the path the direct-`dispatch_dashboard_stop` tests
|
|
/// above don't exercise.
|
|
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
|
#[test]
|
|
fn dashboard_stop_with_peek_open_moves_selection_and_peek_down_one() {
|
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
let mut app = test_app();
|
|
let _ = dispatch_new_session_inner(&mut app, None);
|
|
let _ = dispatch_new_session_inner(&mut app, None);
|
|
let _ = dispatch_new_session_inner(&mut app, None);
|
|
for (i, agent) in app.agents.values_mut().enumerate() {
|
|
agent.display_name = Some(format!("agent-{i}"));
|
|
}
|
|
open_dashboard(&mut app);
|
|
let order = dashboard_row_order(&app);
|
|
assert!(order.len() >= 3, "need >=3 rows, got {}", order.len());
|
|
let first = order[0].clone();
|
|
let second = order[1].clone();
|
|
app.dashboard.as_mut().unwrap().focus_row(first.clone());
|
|
let area = Rect::new(0, 0, 80, 24);
|
|
let reg = crate::actions::ActionRegistry::defaults();
|
|
let render = |app: &mut AppView| {
|
|
let mut buf = Buffer::empty(area);
|
|
let _ = crate::views::dashboard::render_dashboard(
|
|
&mut buf,
|
|
area,
|
|
app.dashboard.as_mut().unwrap(),
|
|
&app.agents,
|
|
®,
|
|
None,
|
|
&[],
|
|
false,
|
|
);
|
|
};
|
|
render(&mut app);
|
|
assert!(
|
|
app.dashboard.as_ref().unwrap().peek.is_some(),
|
|
"peek must auto-open for the selected row",
|
|
);
|
|
let ctrl_x = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
|
for _ in 0..2 {
|
|
let outcome = app
|
|
.dashboard
|
|
.as_mut()
|
|
.unwrap()
|
|
.handle_input(&ctrl_x, &app.registry);
|
|
match outcome {
|
|
crate::app::app_view::InputOutcome::Action(
|
|
crate::app::actions::Action::DashboardStop,
|
|
) => {
|
|
let _ = dispatch(crate::app::actions::Action::DashboardStop, &mut app);
|
|
}
|
|
other => panic!("Ctrl+X must produce DashboardStop, got {other:?}"),
|
|
}
|
|
}
|
|
let crate::views::dashboard::DashboardRowId::TopLevel(first_id) = first else {
|
|
panic!("first row should be top-level");
|
|
};
|
|
assert!(
|
|
!app.agents.contains_key(&first_id),
|
|
"closed agent must be gone"
|
|
);
|
|
assert_eq!(
|
|
app.dashboard.as_ref().unwrap().selected,
|
|
Some(second.clone()),
|
|
"closing with peek open should still select the next row down",
|
|
);
|
|
render(&mut app);
|
|
assert_eq!(
|
|
app.dashboard
|
|
.as_ref()
|
|
.unwrap()
|
|
.peek
|
|
.as_ref()
|
|
.map(|p| p.row.clone()),
|
|
Some(second),
|
|
"peek must follow the selection onto the next row",
|
|
);
|
|
}
|
|
/// Regression — the same Ctrl+X double-press path driven
|
|
/// END-TO-END through `DashboardState::handle_input` (which the
|
|
/// existing `dashboard_stop_double_press_closes_top_level` test
|
|
/// bypasses by calling `dispatch_dashboard_stop` directly).
|
|
///
|
|
/// Without the fix, the second `handle_input` call
|
|
/// runs the top-of-`handle_key` toast/confirm clear BEFORE the
|
|
/// registry resolves the key to `DashboardStop`, wiping the
|
|
/// just-armed `stop_confirm`. The dispatcher then sees a fresh
|
|
/// state and re-arms instead of closing. The session never closes
|
|
/// no matter how many times the user presses Ctrl+X.
|
|
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
|
#[test]
|
|
fn dashboard_stop_double_press_via_handle_key_closes_top_level() {
|
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
|
let mut app = test_app();
|
|
let _ = dispatch_new_session_inner(&mut app, None);
|
|
let _ = dispatch_new_session_inner(&mut app, None);
|
|
open_dashboard(&mut app);
|
|
let target = *app.agents.keys().next().unwrap();
|
|
if let Some(d) = app.dashboard.as_mut() {
|
|
d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(target));
|
|
}
|
|
let ctrl_x = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
|
let outcome1 = app
|
|
.dashboard
|
|
.as_mut()
|
|
.unwrap()
|
|
.handle_input(&ctrl_x, &app.registry);
|
|
match outcome1 {
|
|
crate::app::app_view::InputOutcome::Action(crate::app::actions::Action::DashboardStop) => {
|
|
let _ = dispatch(crate::app::actions::Action::DashboardStop, &mut app);
|
|
}
|
|
other => panic!("first Ctrl+X must produce DashboardStop, got {other:?}"),
|
|
}
|
|
assert!(
|
|
app.dashboard.as_ref().unwrap().stop_confirm.is_some(),
|
|
"first Ctrl+X must arm stop_confirm"
|
|
);
|
|
let outcome2 = app
|
|
.dashboard
|
|
.as_mut()
|
|
.unwrap()
|
|
.handle_input(&ctrl_x, &app.registry);
|
|
match outcome2 {
|
|
crate::app::app_view::InputOutcome::Action(crate::app::actions::Action::DashboardStop) => {
|
|
let _ = dispatch(crate::app::actions::Action::DashboardStop, &mut app);
|
|
}
|
|
other => panic!("second Ctrl+X must produce DashboardStop, got {other:?}"),
|
|
}
|
|
assert!(
|
|
!app.agents.contains_key(&target),
|
|
"second Ctrl+X via handle_input must close the target agent (Issue 300 regression)",
|
|
);
|
|
}
|
|
#[test]
|
|
fn session_id_resolver_round_trip_top_level() {
|
|
use crate::views::dashboard::{DashboardRowId, PersistedRowId, SessionIdResolver};
|
|
let app = test_app_with_agent();
|
|
let resolver = SessionIdResolver::from_agents(&app.agents);
|
|
let pid = PersistedRowId::TopLevel {
|
|
session_id: "test-session".into(),
|
|
};
|
|
let live = resolver.resolve(&pid).expect("must resolve");
|
|
assert_eq!(live, DashboardRowId::TopLevel(AgentId(0)));
|
|
let back = resolver.to_persisted(&live).expect("must reverse");
|
|
assert_eq!(back, pid);
|
|
let absent = PersistedRowId::TopLevel {
|
|
session_id: "no-such-session".into(),
|
|
};
|
|
assert!(resolver.resolve(&absent).is_none());
|
|
}
|
|
#[test]
|
|
fn session_id_resolver_round_trip_subagent() {
|
|
use crate::views::dashboard::{DashboardRowId, PersistedRowId, SessionIdResolver};
|
|
let mut app = test_app_with_agent();
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let info = make_test_subagent("child-1", "sa-1");
|
|
agent
|
|
.subagent_sessions
|
|
.insert(info.child_session_id.to_string(), info);
|
|
let resolver = SessionIdResolver::from_agents(&app.agents);
|
|
let pid = PersistedRowId::Subagent {
|
|
parent_session_id: "test-session".into(),
|
|
child_session_id: "child-1".into(),
|
|
};
|
|
let live = resolver.resolve(&pid).expect("must resolve");
|
|
assert_eq!(
|
|
live,
|
|
DashboardRowId::Subagent {
|
|
parent: AgentId(0),
|
|
child_session_id: "child-1".into(),
|
|
}
|
|
);
|
|
let back = resolver.to_persisted(&live).expect("must reverse");
|
|
assert_eq!(back, pid);
|
|
}
|