M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression (resume sync): the on-disk replay stream re-emits persisted
|
||||
/// notifications through the generic `x.ai/session/update` envelope. A
|
||||
/// background `monitor`/bash task (`TaskBackgrounded`) must restore into
|
||||
/// `bg_tasks` on a resumed / second terminal — not be dropped by the
|
||||
/// default match arm — so the idle "watching" status line and the Tasks pane
|
||||
/// match the originating terminal. (Before this routing only subagents
|
||||
/// survived resume.)
|
||||
#[test]
|
||||
fn ext_session_update_replay_restores_bg_task() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
assert!(app.agents[&id].session.bg_tasks.is_empty());
|
||||
|
||||
let update = XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: "tc-mon".into(),
|
||||
task_id: "mon-1".into(),
|
||||
command: "tail -f deploy.log".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/mon-1.log".into(),
|
||||
monitor_description: Some("errors in deploy.log".into()),
|
||||
description: None,
|
||||
};
|
||||
handle(
|
||||
make_ext_session_notification_with_method("sess-1", "x.ai/session/update", update),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let task = app.agents[&id]
|
||||
.session
|
||||
.bg_tasks
|
||||
.get("mon-1")
|
||||
.expect("replayed TaskBackgrounded must restore bg_tasks on resume");
|
||||
assert!(task.is_monitor, "monitor_description must mark is_monitor");
|
||||
assert_eq!(task.status, BgTaskStatus::Running);
|
||||
}
|
||||
|
||||
/// Companion for scheduled `/loop`s: a replayed `ScheduledTaskCreated` must
|
||||
/// restore `scheduled_tasks`, and a later `ScheduledTaskDeleted` must net it
|
||||
/// back out — so a resumed terminal's loop count matches instead of staying
|
||||
/// empty until the next live fire. (Pairs with the shell-side persistence of
|
||||
/// these notifications in `notification_bridge.rs`.)
|
||||
#[test]
|
||||
fn ext_session_update_replay_restores_then_removes_scheduled_task() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
assert!(app.agents[&id].session.scheduled_tasks.is_empty());
|
||||
|
||||
handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-1",
|
||||
"x.ai/session/update",
|
||||
XaiSessionUpdate::ScheduledTaskCreated {
|
||||
task_id: "loop-1".into(),
|
||||
prompt: "check deploy".into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id]
|
||||
.session
|
||||
.scheduled_tasks
|
||||
.contains_key("loop-1"),
|
||||
"replayed ScheduledTaskCreated must restore scheduled_tasks on resume"
|
||||
);
|
||||
|
||||
handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-1",
|
||||
"x.ai/session/update",
|
||||
XaiSessionUpdate::ScheduledTaskDeleted {
|
||||
task_id: "loop-1".into(),
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].session.scheduled_tasks.is_empty(),
|
||||
"replayed ScheduledTaskDeleted must remove the loop on resume"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: demotion path (foreground Execute → BgTask) must call
|
||||
/// finish_running() so the entry is removed from the running set.
|
||||
/// Without this, the entry stays orphaned as "running" forever.
|
||||
#[test]
|
||||
fn task_backgrounded_demotion_clears_running_state() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-abc-123";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
assert!(agent.scrollback.needs_animation());
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_some());
|
||||
|
||||
let notif = make_task_backgrounded_notif("sess-1", tc_id, "task-001", "sleep 9999");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"entry must not be in the running set after demotion"
|
||||
);
|
||||
let entry = agent.scrollback.get(0).unwrap();
|
||||
assert!(
|
||||
matches!(entry.block, RenderBlock::BgTask(_)),
|
||||
"block should be demoted to BgTask"
|
||||
);
|
||||
assert!(!entry.is_running);
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_none());
|
||||
}
|
||||
|
||||
/// Regression: late-detected is_background=true (raw_input arrives after the
|
||||
/// Execute block exists) followed by task_backgrounded must correctly demote
|
||||
/// the existing Execute block — not create a duplicate BgTask.
|
||||
#[test]
|
||||
fn task_backgrounded_late_detection_demotes_existing_entry() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-late-bg-42";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
// Tool should be in BOTH pending_tools and bg_deferred_tools
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_some());
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
|
||||
let notif = make_task_backgrounded_notif("sess-1", tc_id, "task-late-001", "sleep 9999");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1, "must not create duplicate");
|
||||
let entry = agent.scrollback.get(0).unwrap();
|
||||
assert!(matches!(entry.block, RenderBlock::BgTask(_)));
|
||||
assert!(!entry.is_running);
|
||||
assert!(!agent.scrollback.needs_animation());
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_none());
|
||||
assert!(!agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
}
|
||||
|
||||
/// Regression: even when the wire notification carries its own
|
||||
/// `description`, the deferred-tool suppression key must still be drained
|
||||
/// (it is preferred but the entry otherwise leaks and keeps dropping late
|
||||
/// stdout updates for the rest of the session).
|
||||
#[test]
|
||||
fn task_backgrounded_with_wire_description_still_drains_deferred_tool() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-late-bg-desc";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-1"),
|
||||
update: XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: tc_id.into(),
|
||||
task_id: "task-late-desc".into(),
|
||||
command: "sleep 9999".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/output.log".into(),
|
||||
monitor_description: None,
|
||||
description: Some("Wait a while".into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
|
||||
assert!(handle_task_backgrounded(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.session.tracker.bg_deferred_tools.contains_key(tc_id),
|
||||
"deferred-tool key must be drained even when wire description wins"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a blank/whitespace wire `description` must not shadow a
|
||||
/// non-blank deferred (raw_input) description when there is no Execute
|
||||
/// block to recover from (fresh BgTask path). The label must come from the
|
||||
/// deferred description, not the blank wire value (which would otherwise
|
||||
/// fall back to the raw command).
|
||||
#[test]
|
||||
fn task_backgrounded_blank_wire_description_prefers_deferred() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-blank-wire";
|
||||
|
||||
// Simulate late is_background detection having stashed a real
|
||||
// description, with the placeholder entry dropped (no pending tool).
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent
|
||||
.session
|
||||
.tracker
|
||||
.bg_deferred_tools
|
||||
.insert(tc_id.to_string(), Some("deploy the server".to_string()));
|
||||
}
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.tracker
|
||||
.pending_tool_entry_id(tc_id)
|
||||
.is_none(),
|
||||
"no Execute entry to recover from — exercises the merge chain"
|
||||
);
|
||||
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-1"),
|
||||
update: XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: tc_id.into(),
|
||||
task_id: "task-blank-wire".into(),
|
||||
command: "sleep 9999".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/output.log".into(),
|
||||
monitor_description: None,
|
||||
description: Some(" ".into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
|
||||
assert!(handle_task_backgrounded(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.session.tracker.bg_deferred_tools.contains_key(tc_id),
|
||||
"deferred-tool key drained"
|
||||
);
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
match &agent.scrollback.get(0).unwrap().block {
|
||||
RenderBlock::BgTask(bg) => assert_eq!(
|
||||
bg.description.as_deref(),
|
||||
Some("deploy the server"),
|
||||
"blank wire description must not shadow the deferred description"
|
||||
),
|
||||
other => panic!("expected BgTask, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-1", "task-child-1", "sleep 100");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent scrollback must NOT have the bg task block.
|
||||
assert_eq!(agent.scrollback.len(), 0, "parent scrollback must be empty");
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent session must not have the bg task"
|
||||
);
|
||||
|
||||
// Child view must have the bg task.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
assert!(child.session.bg_tasks.contains_key("task-child-1"));
|
||||
assert!(
|
||||
child
|
||||
.session
|
||||
.bg_tool_call_to_task
|
||||
.contains_key("tc-child-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-1", "task-root-1", "ls -la");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
assert!(agent.session.bg_tasks.contains_key("task-root-1"));
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(
|
||||
child.scrollback.len(),
|
||||
0,
|
||||
"child scrollback must not be affected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_monitor_prefix_marks_is_monitor() {
|
||||
// Reparented monitor / older backend: the command carries the
|
||||
// "[monitor] <desc>" prefix but the notification has no
|
||||
// monitor_description. The pager must still mark it a monitor and use
|
||||
// the stripped text as the description so it renders as a Monitor row.
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_task_backgrounded_notif(
|
||||
"parent-sess",
|
||||
"tc-mon-1",
|
||||
"task-mon-1",
|
||||
"[monitor] event counter",
|
||||
);
|
||||
handle_task_backgrounded(¬if, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let task = agent
|
||||
.session
|
||||
.bg_tasks
|
||||
.get("task-mon-1")
|
||||
.expect("bg task registered");
|
||||
assert!(
|
||||
task.is_monitor,
|
||||
"a `[monitor] ` command prefix should mark the task as a monitor"
|
||||
);
|
||||
assert_eq!(task.description.as_deref(), Some("event counter"));
|
||||
}
|
||||
|
||||
/// Resume regression: the agent's cold-load reconciliation
|
||||
/// completes replay-restored dead tasks with `signal: "session_restart"`.
|
||||
/// That synthetic completion must finalize state QUIETLY — finish the
|
||||
/// replayed "Task started" entry and mark the task not-running — without
|
||||
/// pushing a fresh red "Task failed" block into the resumed scrollback.
|
||||
#[test]
|
||||
fn session_restart_completion_finalizes_without_failure_block() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let replayed =
|
||||
make_replayed_task_backgrounded_notif("sess-1", "tc-r", "task-r", "tail -f x.log");
|
||||
handle_task_backgrounded(&replayed, &mut app);
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1, "replay restores started block");
|
||||
assert!(agent.scrollback.needs_animation(), "restored row runs");
|
||||
}
|
||||
|
||||
let notif = make_task_completed_notif_with_signal(
|
||||
"sess-1",
|
||||
"task-r",
|
||||
"tail -f x.log",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
);
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
1,
|
||||
"stale-on-load completion must not push a 'Task failed' block"
|
||||
);
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"the replayed started entry must be finished (no running accent)"
|
||||
);
|
||||
let task = &agent.session.bg_tasks["task-r"];
|
||||
assert_eq!(
|
||||
task.status,
|
||||
BgTaskStatus::Failed,
|
||||
"state still records the task as not running"
|
||||
);
|
||||
}
|
||||
|
||||
/// Guard that the quiet path is NARROW: any other kill signal keeps the
|
||||
/// live behavior of pushing a completion/failure block.
|
||||
#[test]
|
||||
fn non_restart_signal_completion_still_pushes_failure_block() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let bg = make_task_backgrounded_notif("sess-1", "tc-k", "task-k", "sleep 999");
|
||||
handle_task_backgrounded(&bg, &mut app);
|
||||
|
||||
let notif = make_task_completed_notif_with_signal(
|
||||
"sess-1",
|
||||
"task-k",
|
||||
"sleep 999",
|
||||
None,
|
||||
Some("SIGKILL"),
|
||||
);
|
||||
handle_task_completed(¬if, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
2,
|
||||
"a real kill must still render started + failed blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// First, background a task on the child.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-2", "task-child-2", "echo hi");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now complete it.
|
||||
let notif = make_task_completed_notif("child-sess", "task-child-2", "echo hi", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent must NOT have a completion block.
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have completion block"
|
||||
);
|
||||
|
||||
// Child must have both the started and completed blocks.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + completed");
|
||||
let bg = child.session.bg_tasks.get("task-child-2").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background and complete a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-2", "task-root-2", "echo root");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
let notif = make_task_completed_notif("parent-sess", "task-root-2", "echo root", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
2,
|
||||
"parent: started + completed blocks"
|
||||
);
|
||||
let bg = agent.session.bg_tasks.get("task-root-2").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(
|
||||
child.scrollback.len(),
|
||||
0,
|
||||
"child scrollback must not be affected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_failure_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
let bg_notif = make_task_backgrounded_notif("child-sess", "tc-fail", "task-fail", "exit 1");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_task_completed_notif("child-sess", "task-fail", "exit 1", Some(1));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have failure block"
|
||||
);
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + failed");
|
||||
let bg = child.session.bg_tasks.get("task-fail").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Failed));
|
||||
assert_eq!(bg.exit_code, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child so monitor event has somewhere to land.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-3", "task-child-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_monitor_event_notif("child-sess", "task-child-3", "new log line");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent must not have the bg task"
|
||||
);
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
let task = child.session.bg_tasks.get("task-child-3").unwrap();
|
||||
assert_eq!(task.stdout, "new log line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-3", "task-root-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_monitor_event_notif("parent-sess", "task-root-3", "root event");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let task = agent.session.bg_tasks.get("task-root-3").unwrap();
|
||||
assert_eq!(task.stdout, "root event");
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert!(
|
||||
child.session.bg_tasks.is_empty(),
|
||||
"child must not have the bg task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
// Insert a second agent and switch to it so the first agent is inactive.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
&mut app,
|
||||
AgentId(1),
|
||||
crate::app::dispatch::SwitchCause::New,
|
||||
);
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(1))));
|
||||
|
||||
let notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-bg-inact", "task-bg-inact", "sleep 1");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
// Active view was NOT affected — should return false.
|
||||
assert!(!changed);
|
||||
|
||||
// But the bg task state must still land in the child view.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert!(child.session.bg_tasks.contains_key("task-bg-inact"));
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child first.
|
||||
let bg_notif = make_task_backgrounded_notif(
|
||||
"child-sess",
|
||||
"tc-compl-inact",
|
||||
"task-compl-inact",
|
||||
"echo",
|
||||
);
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now switch away.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
&mut app,
|
||||
AgentId(1),
|
||||
crate::app::dispatch::SwitchCause::New,
|
||||
);
|
||||
|
||||
let notif = make_task_completed_notif("child-sess", "task-compl-inact", "echo", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
let bg = child.session.bg_tasks.get("task-compl-inact").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
assert_eq!(child.scrollback.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("unknown-sess", "tc-unknown", "task-unknown", "sleep 1");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_task_completed_notif("unknown-sess", "task-unknown", "echo x", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_monitor_event_notif("unknown-sess", "task-unknown", "event");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_task_backgrounded_marks_restored_from_replay() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let replayed =
|
||||
make_replayed_task_backgrounded_notif("sess-1", "tc-r", "task-r", "tail -f x.log");
|
||||
handle_task_backgrounded(&replayed, &mut app);
|
||||
let live = make_task_backgrounded_notif("sess-1", "tc-l", "task-l", "sleep 5");
|
||||
handle_task_backgrounded(&live, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.bg_tasks["task-r"].restored_from_replay,
|
||||
"isReplay-stamped TaskBackgrounded must mark restored_from_replay"
|
||||
);
|
||||
assert!(
|
||||
!agent.session.bg_tasks["task-l"].restored_from_replay,
|
||||
"live TaskBackgrounded must not mark restored_from_replay"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn follow_ups_render_chips_on_active_agent() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["Tell me more", "Summarize"]),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected, "fresh chips on the active agent warrant a redraw");
|
||||
let fu = app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.expect("chips set on the active agent");
|
||||
assert_eq!(fu.response_id, "resp-1");
|
||||
assert_eq!(fu.suggestions, vec!["Tell me more", "Summarize"]);
|
||||
}
|
||||
|
||||
/// End-to-end through the wire: the stamped `promptId` flows from the
|
||||
/// notification params into the dedup. (a) a re-delivery of the active
|
||||
/// turn's follow_ups re-renders after a clear; (b) a prior turn's replay is
|
||||
/// rejected.
|
||||
#[test]
|
||||
fn follow_ups_prompt_id_makes_dedup_deterministic() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.current_prompt_id = Some("p1".into());
|
||||
|
||||
// Active turn (p1) chips applied via the wire.
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
));
|
||||
// Turn-boundary clear (keeps the seen ring).
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
|
||||
// (a) Re-delivery of the active turn re-renders.
|
||||
assert!(
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
),
|
||||
"active-turn re-delivery must re-render via promptId match"
|
||||
);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.response_id,
|
||||
"resp-1"
|
||||
);
|
||||
|
||||
// Adopt a new turn p2; clear.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.current_prompt_id = Some("p2".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
|
||||
// (b) Prior turn (p1) replay must NOT revive.
|
||||
assert!(
|
||||
!handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
),
|
||||
"prior-turn replay must be rejected"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_replayed_meta_suppresses_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let params = serde_json::json!({
|
||||
"response_id": "resp-1",
|
||||
"suggestions": [{ "label": "x" }],
|
||||
"_meta": { "x.ai/replayed": true },
|
||||
});
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
let affected = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!affected, "a replayed chunk must not render chips");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_malformed_params_are_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let bad = [
|
||||
serde_json::Value::String("not an object".into()),
|
||||
serde_json::json!([1, 2, 3]),
|
||||
serde_json::json!({ "suggestions": 7 }),
|
||||
serde_json::json!({}),
|
||||
];
|
||||
for params in bad {
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
let affected = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!affected, "malformed params must be ignored: {params}");
|
||||
}
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_sanitizes_control_characters() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// A label carrying an ESC-based SGR sequence and a newline: control
|
||||
// characters are stripped so a chip cannot inject terminal escapes.
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["safe\u{1b}[31mred\nmore"]),
|
||||
&mut app,
|
||||
);
|
||||
let fu = app.agents[&AgentId(0)].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.suggestions, vec!["safe[31mredmore"]);
|
||||
assert!(!fu.suggestions[0].contains('\u{1b}'));
|
||||
assert!(!fu.suggestions[0].contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_response_id_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("", &["x"]), &mut app);
|
||||
assert!(
|
||||
!affected,
|
||||
"without a response_id there is no newest-wins key"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_blank_labels_yield_no_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[" ", ""]), &mut app);
|
||||
assert!(!affected);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_strips_bidi_and_zero_width() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// U+202E RIGHT-TO-LEFT OVERRIDE + U+200B ZERO WIDTH SPACE: stripped so
|
||||
// server text cannot visually disguise a leading `/` (Trojan Source).
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["\u{202e}/rm\u{200b}-rf"]),
|
||||
&mut app,
|
||||
);
|
||||
let fu = app.agents[&AgentId(0)].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.suggestions, vec!["/rm-rf"]);
|
||||
assert!(!fu.suggestions[0].contains('\u{202e}'));
|
||||
assert!(!fu.suggestions[0].contains('\u{200b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_caps_count_and_label_length() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let labels: Vec<String> = (0..20).map(|i| format!("s{i}")).collect();
|
||||
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
handle_ext_notification(&follow_ups_ext("resp-1", &refs), &mut app);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.suggestions
|
||||
.len(),
|
||||
super::MAX_FOLLOW_UPS,
|
||||
"suggestion count capped at ingestion"
|
||||
);
|
||||
let long = "x".repeat(10_000);
|
||||
handle_ext_notification(&follow_ups_ext("resp-2", &[&long]), &mut app);
|
||||
let label = &app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.suggestions[0];
|
||||
assert!(
|
||||
label.len() <= super::MAX_FOLLOW_UP_LABEL,
|
||||
"label length clamped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_oversized_response_id_is_rejected() {
|
||||
// An oversized response_id is rejected (not truncated — that
|
||||
// could collide ids) so it can't bloat the retained seen ring.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let big = "r".repeat(super::MAX_RESPONSE_ID_LEN + 1);
|
||||
let affected = handle_ext_notification(&follow_ups_ext(&big, &["x"]), &mut app);
|
||||
assert!(!affected, "an oversized response_id must be rejected");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
// A sane-length id still works.
|
||||
let ok = "r".repeat(super::MAX_RESPONSE_ID_LEN);
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext(&ok, &["x"]),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_replayed_meta_false_renders() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let params = serde_json::json!({
|
||||
"response_id": "resp-1",
|
||||
"suggestions": [{ "label": "x" }],
|
||||
"_meta": { "x.ai/replayed": false },
|
||||
});
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
assert!(
|
||||
handle_ext_notification(¬if, &mut app),
|
||||
"_meta replayed=false must still render"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_per_element_malformed_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
for bad in [
|
||||
serde_json::json!({ "response_id": "r", "suggestions": [{ "label": 7 }] }),
|
||||
serde_json::json!({ "response_id": "r", "suggestions": [null] }),
|
||||
] {
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(&bad).unwrap().into(),
|
||||
);
|
||||
assert!(
|
||||
!handle_ext_notification(¬if, &mut app),
|
||||
"a malformed suggestion element drops the notification: {bad}"
|
||||
);
|
||||
}
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_array_renders_no_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[]), &mut app);
|
||||
assert!(!affected);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_for_current_response_clears_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
handle_ext_notification(&follow_ups_ext("resp-1", &["a"]), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[]), &mut app);
|
||||
assert!(affected, "empty for the shown response clears the chips");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_viewer_turn_transition_renders_newer_chips() {
|
||||
// A viewer holding resp-1's chips adopts the driver's NEXT
|
||||
// turn via a live delta (which clears the prior chips), then resp-2's
|
||||
// follow_ups render — not suppressed by the held resp-1.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.attached_as_viewer = true;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
agent.apply_follow_ups("resp-1".into(), vec!["old".into()]);
|
||||
}
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let request = acp::SessionNotification::new(
|
||||
acp::SessionId::new("sess-1"),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
acp::TextContent::new("resp-2 streaming"),
|
||||
))),
|
||||
)
|
||||
.meta(
|
||||
serde_json::json!({ "promptId": "p2", "agentTimestampMs": 1 })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
);
|
||||
handle(
|
||||
AcpClientMessage::SessionNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].follow_ups.is_none(),
|
||||
"viewer adopting a new turn must clear the prior chips"
|
||||
);
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-2", &["new"]), &mut app);
|
||||
assert!(affected);
|
||||
let fu = app.agents[&id].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.response_id, "resp-2");
|
||||
assert_eq!(fu.suggestions, vec!["new"]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── derive_child_cwd ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_uses_child_cwd_from_info() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.child_cwd = Some("/child/worktree".into());
|
||||
info.worktree_path = Some("/child/worktree".into());
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/child/worktree"));
|
||||
assert!(is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_falls_back_to_parent_when_child_cwd_is_none() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let info = make_subagent_info("child-2");
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"));
|
||||
assert!(!is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_worktree_independent_of_child_cwd() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let mut info = make_subagent_info("child-3");
|
||||
info.child_cwd = None;
|
||||
info.worktree_path = Some("/some/worktree".into());
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"), "falls back to parent");
|
||||
assert!(
|
||||
is_wt,
|
||||
"worktree flag must be set even when child_cwd is None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_no_info_falls_back() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, None);
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"));
|
||||
assert!(!is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_updates_root_agent() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let notif = make_git_head_changed_notif("sess-A", Some("feature/x"), false, None);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(changed);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.current_branch.as_deref(), Some("feature/x"));
|
||||
assert!(!agent.is_worktree);
|
||||
assert!(agent.main_repo.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_routes_to_child_subagent_view() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let child_sid = "child-sess-1";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
}
|
||||
|
||||
let notif = make_git_head_changed_notif(
|
||||
child_sid,
|
||||
Some("worktree-branch"),
|
||||
true,
|
||||
Some("main-repo"),
|
||||
);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(changed);
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child_view = parent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.current_branch.as_deref(),
|
||||
Some("worktree-branch")
|
||||
);
|
||||
assert!(child_view.is_worktree);
|
||||
assert_eq!(child_view.main_repo.as_deref(), Some("main-repo"));
|
||||
// Parent must not be affected.
|
||||
assert!(parent.current_branch.is_none());
|
||||
assert!(!parent.is_worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let notif = make_git_head_changed_notif("unknown-sess", Some("main"), false, None);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(!changed);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.current_branch.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_root_agent_not_affected_when_child_matches() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let child_sid = "child-sess-2";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
parent.current_branch = Some("parent-branch".into());
|
||||
}
|
||||
|
||||
let notif = make_git_head_changed_notif(child_sid, Some("child-branch"), true, None);
|
||||
handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parent.current_branch.as_deref(),
|
||||
Some("parent-branch"),
|
||||
"parent's branch must not change when child is updated"
|
||||
);
|
||||
let child_view = parent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(child_view.current_branch.as_deref(), Some("child-branch"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn goal_updated_ignores_unknown_json_fields_via_serde() {
|
||||
// Serde-side half of the forward-compat story: a payload that
|
||||
// carries an extra JSON field absent on today's
|
||||
// `SessionUpdate::GoalUpdated` (no `deny_unknown_fields` on the
|
||||
// variant) must still deserialize and drive a full
|
||||
// `GoalDisplayState`. This guards against someone later adding
|
||||
// `#[serde(deny_unknown_fields)]` to the variant, which would
|
||||
// silently break wire compatibility with older shells.
|
||||
//
|
||||
// The complementary Rust-level half — that the destructure with
|
||||
// trailing `..` keeps absent additive `Option<T>` fields landing
|
||||
// as `None` in the mapped `GoalDisplayState` — is exercised by
|
||||
// `goal_updated_absent_optional_fields_deserialize_to_none`.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g-ext",
|
||||
"objective": "build forward-compat tolerance",
|
||||
"status": "active",
|
||||
"phase": "executing",
|
||||
"token_budget": 200_000,
|
||||
"tokens_used": 12_345,
|
||||
"elapsed_ms": 750,
|
||||
"total_deliverables": 2,
|
||||
"completed_deliverables": 1,
|
||||
"current_deliverable_idx": 1,
|
||||
"current_deliverable_title": "Wire compat",
|
||||
"current_subagent_role": "verifier",
|
||||
"total_worker_rounds": 5,
|
||||
"total_verify_rounds": 2,
|
||||
"token_baseline": 100,
|
||||
"finished_subagent_tokens": 99,
|
||||
"live_subagent_tokens": 4_321,
|
||||
"live_tokens_by_model": [["grok-4", 6_000], ["grok-3", 4_000]],
|
||||
"live_context_pct": 42,
|
||||
"live_turn_count": 7,
|
||||
"live_tool_call_count": 11,
|
||||
"last_event": "verify_started",
|
||||
"last_event_detail": "round 2 of 3",
|
||||
"last_event_timestamp": "2026-05-24T00:00:00Z",
|
||||
// Field absent on today's `SessionUpdate::GoalUpdated` — simulates
|
||||
// a future shell adding a new wire field. With trailing `..` in
|
||||
// the destructure and no `deny_unknown_fields` on the variant,
|
||||
// this must parse and the pager must still produce a
|
||||
// GoalDisplayState mapped from the known subset.
|
||||
"future_field_for_pr5": "ignored-by-todays-pager"
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session_notification", raw.into());
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
assert!(
|
||||
affected,
|
||||
"GoalUpdated for the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let goal = agent
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.expect("GoalUpdated should populate goal_state even with unknown wire fields");
|
||||
assert_eq!(goal.goal_id, "g-ext");
|
||||
assert_eq!(goal.objective, "build forward-compat tolerance");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
assert_eq!(goal.phase, GoalDisplayPhase::Executing);
|
||||
assert_eq!(goal.token_budget, Some(200_000));
|
||||
assert_eq!(goal.tokens_used, 12_345);
|
||||
assert_eq!(goal.elapsed_ms, 750);
|
||||
assert_eq!(goal.total_deliverables, 2);
|
||||
assert_eq!(goal.completed_deliverables, 1);
|
||||
assert_eq!(goal.current_deliverable_id, Some(1));
|
||||
assert_eq!(
|
||||
goal.current_deliverable_title.as_deref(),
|
||||
Some("Wire compat")
|
||||
);
|
||||
assert_eq!(goal.current_subagent_role.as_deref(), Some("verifier"));
|
||||
assert_eq!(goal.total_worker_rounds, 5);
|
||||
assert_eq!(goal.total_verify_rounds, 2);
|
||||
assert_eq!(goal.token_baseline, 100);
|
||||
assert_eq!(goal.finished_subagent_tokens, 99);
|
||||
assert_eq!(goal.live_subagent_tokens, Some(4_321));
|
||||
assert_eq!(
|
||||
goal.live_tokens_by_model,
|
||||
vec![("grok-4".to_owned(), 6_000), ("grok-3".to_owned(), 4_000)],
|
||||
"populated per-model breakdown must round-trip wire->display"
|
||||
);
|
||||
assert_eq!(goal.live_context_pct, Some(42));
|
||||
assert_eq!(goal.live_turn_count, Some(7));
|
||||
assert_eq!(goal.live_tool_call_count, Some(11));
|
||||
assert_eq!(goal.last_event.as_deref(), Some("verify_started"));
|
||||
assert_eq!(goal.last_event_detail.as_deref(), Some("round 2 of 3"));
|
||||
assert_eq!(
|
||||
goal.last_event_timestamp.as_deref(),
|
||||
Some("2026-05-24T00:00:00Z")
|
||||
);
|
||||
assert_eq!(goal.pause_message, None);
|
||||
// Classifier fields default to `None` / `false` when absent.
|
||||
assert_eq!(goal.classifier_runs_attempted, None);
|
||||
assert_eq!(goal.classifier_max_runs, None);
|
||||
assert_eq!(goal.last_classifier_verdict, None);
|
||||
assert_eq!(goal.last_classifier_details_path, None);
|
||||
assert!(!goal.verifying_completion);
|
||||
assert!(!goal.planning);
|
||||
assert!(
|
||||
goal.deliverables.is_empty(),
|
||||
"deliverables is wire-compat-only in the simplified goal model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_complete_transition_pushes_end_to_end_marker_once() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let send = |app: &mut AppView, status: &str, elapsed_ms: u64| {
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g1",
|
||||
"objective": "obj",
|
||||
"status": status,
|
||||
"phase": "executing",
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
handle(
|
||||
AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtNotification::new("x.ai/session_notification", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
let goal_markers = |app: &AppView| -> Vec<std::time::Duration> {
|
||||
let sb = &app.agents.get(&AgentId(0)).unwrap().scrollback;
|
||||
(0..sb.len())
|
||||
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) => match &b.event {
|
||||
SessionEvent::GoalCompleted { elapsed } => Some(*elapsed),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
send(&mut app, "active", 1_000);
|
||||
assert!(goal_markers(&app).is_empty(), "no marker while Active");
|
||||
|
||||
send(&mut app, "complete", 619_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app),
|
||||
vec![std::time::Duration::from_millis(619_000)],
|
||||
"transition to Complete pushes one e2e marker with the goal's total time",
|
||||
);
|
||||
|
||||
// A repeat Complete update (e.g. a late notification) must not
|
||||
// duplicate the marker.
|
||||
send(&mut app, "complete", 620_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app).len(),
|
||||
1,
|
||||
"repeat Complete must not push a second marker",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_elapsed_is_monotonic_across_updates() {
|
||||
// The displayed elapsed must never tick backward when a notification's
|
||||
// authoritative base is below the already-extrapolated value;
|
||||
// `elapsed_floor_ms` clamps it.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
assert!(send_goal_update(&mut app, "g1", "active", 10_000));
|
||||
let a = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(a >= 10_000);
|
||||
|
||||
// Same goal, but a LOWER authoritative base (extrapolation outran the
|
||||
// shell's flush point).
|
||||
send_goal_update(&mut app, "g1", "active", 8_000);
|
||||
let b = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(b >= a, "elapsed must not tick backward: {b} < {a}");
|
||||
assert!(b >= 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_goal_is_not_resurrected_by_late_update() {
|
||||
// After a goal is cleared, a late in-flight GoalUpdated for the same
|
||||
// goal_id (queued before the clear) must be dropped so the "Done"
|
||||
// chip / modal stay cleared and don't resurrect.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_some(),
|
||||
"goal present after complete"
|
||||
);
|
||||
|
||||
// Clear (the cleared event itself carries an empty goal_id).
|
||||
send_goal_update(&mut app, "", "cleared", 0);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
"chip cleared on cleared status"
|
||||
);
|
||||
|
||||
// A stale late update for the cleared goal must not resurrect it.
|
||||
let affected = send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
"cleared goal must not resurrect"
|
||||
);
|
||||
assert!(!affected, "ignored stale update must not request a redraw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_goal_after_clear_is_not_suppressed() {
|
||||
// A genuinely new goal (different id) after a clear must start
|
||||
// normally — the cleared-id guard only drops the SAME id.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 1_000);
|
||||
send_goal_update(&mut app, "", "cleared", 0);
|
||||
assert!(send_goal_update(&mut app, "g2", "active", 500));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.goal_state.as_ref().expect("new goal present").goal_id,
|
||||
"g2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_switch_resets_elapsed_floor() {
|
||||
// A NEW goal (different id) must start its own clock and NOT inherit
|
||||
// the prior goal's carried elapsed floor.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 10_000);
|
||||
// Switch directly to a different goal with a small elapsed base.
|
||||
send_goal_update(&mut app, "g2", "active", 500);
|
||||
let elapsed = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(
|
||||
elapsed < 5_000,
|
||||
"new goal must start from its own base, not the prior 10s floor: {elapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_updated_resolves_details_path_existence_on_receipt() {
|
||||
// The handler resolves last_classifier_details_path's existence ONCE
|
||||
// on receipt into the cached bool (no per-frame stat).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
// A real on-disk path → cached exists = true.
|
||||
let f = tempfile::NamedTempFile::new().unwrap();
|
||||
let real_path = f.path().to_string_lossy().into_owned();
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!(real_path);
|
||||
dispatch_goal_update(&mut app, update);
|
||||
let g = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
g.last_classifier_details_exists,
|
||||
"existing details path must cache exists = true"
|
||||
);
|
||||
assert_eq!(
|
||||
g.last_classifier_details_path.as_deref(),
|
||||
Some(real_path.as_str())
|
||||
);
|
||||
|
||||
// A missing path → cached exists = false (modal renders "(unavailable)").
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!("/no/such/details-xyz.md");
|
||||
dispatch_goal_update(&mut app, update);
|
||||
let g = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!g.last_classifier_details_exists,
|
||||
"missing details path must cache exists = false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_updated_absent_optional_fields_deserialize_to_none() {
|
||||
// Rust-level forward-compat half: every additive
|
||||
// `Option<T>` field on `SessionUpdate::GoalUpdated` is allowed to
|
||||
// be omitted from the wire payload and must surface as `None` in
|
||||
// the destructured arm — i.e. the pager keeps mapping the known
|
||||
// subset cleanly when the shell-side struct grows or when an
|
||||
// older shell omits newer optional fields. Drop a handful of
|
||||
// optional keys from the payload and assert they materialise as
|
||||
// `None` on the resulting `GoalDisplayState`.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g-min",
|
||||
"objective": "minimal payload",
|
||||
"status": "active",
|
||||
"phase": "idle",
|
||||
// token_budget omitted — Option<i64> must default to None.
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": 0,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
// current_deliverable_idx omitted — Option<u32> -> None.
|
||||
// current_deliverable_title omitted — Option<String> -> None.
|
||||
// current_subagent_role omitted — Option<String> -> None.
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
// live_subagent_tokens omitted — Option<u64> -> None.
|
||||
// live_context_pct omitted — Option<u8> -> None.
|
||||
// live_turn_count omitted — Option<u32> -> None.
|
||||
// live_tool_call_count omitted — Option<u32> -> None.
|
||||
// last_event omitted — Option<String> -> None.
|
||||
// last_event_detail omitted — Option<String> -> None.
|
||||
// last_event_timestamp omitted — Option<String> -> None.
|
||||
// pause_message omitted — Option<String> -> None.
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session_notification", raw.into());
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
assert!(
|
||||
affected,
|
||||
"minimal GoalUpdated for the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let goal = agent
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.expect("GoalUpdated must populate goal_state even with all Option fields omitted");
|
||||
|
||||
// Required fields landed as sent.
|
||||
assert_eq!(goal.goal_id, "g-min");
|
||||
assert_eq!(goal.objective, "minimal payload");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
assert_eq!(goal.phase, GoalDisplayPhase::Idle);
|
||||
assert_eq!(goal.tokens_used, 0);
|
||||
assert_eq!(goal.elapsed_ms, 0);
|
||||
assert_eq!(goal.total_deliverables, 0);
|
||||
assert_eq!(goal.completed_deliverables, 0);
|
||||
assert_eq!(goal.total_worker_rounds, 0);
|
||||
assert_eq!(goal.total_verify_rounds, 0);
|
||||
assert_eq!(goal.token_baseline, 0);
|
||||
assert_eq!(goal.finished_subagent_tokens, 0);
|
||||
|
||||
// Every omitted Option<T> wire field must surface as None — this
|
||||
// is the property that keeps the destructure stable as the shell
|
||||
// grows additive optional fields.
|
||||
assert_eq!(goal.token_budget, None, "token_budget");
|
||||
assert_eq!(goal.current_deliverable_id, None, "current_deliverable_id");
|
||||
assert_eq!(
|
||||
goal.current_deliverable_title, None,
|
||||
"current_deliverable_title"
|
||||
);
|
||||
assert_eq!(goal.current_subagent_role, None, "current_subagent_role");
|
||||
assert_eq!(goal.live_subagent_tokens, None, "live_subagent_tokens");
|
||||
assert!(
|
||||
goal.live_tokens_by_model.is_empty(),
|
||||
"omitted live_tokens_by_model must default to empty via #[serde(default)]"
|
||||
);
|
||||
assert_eq!(goal.live_context_pct, None, "live_context_pct");
|
||||
assert_eq!(goal.live_turn_count, None, "live_turn_count");
|
||||
assert_eq!(goal.live_tool_call_count, None, "live_tool_call_count");
|
||||
assert_eq!(goal.last_event, None, "last_event");
|
||||
assert_eq!(goal.last_event_detail, None, "last_event_detail");
|
||||
assert_eq!(goal.last_event_timestamp, None, "last_event_timestamp");
|
||||
assert_eq!(goal.pause_message, None, "pause_message");
|
||||
assert_eq!(
|
||||
goal.classifier_runs_attempted, None,
|
||||
"classifier_runs_attempted"
|
||||
);
|
||||
assert_eq!(goal.classifier_max_runs, None, "classifier_max_runs");
|
||||
assert_eq!(
|
||||
goal.last_classifier_verdict, None,
|
||||
"last_classifier_verdict"
|
||||
);
|
||||
assert_eq!(
|
||||
goal.last_classifier_details_path, None,
|
||||
"last_classifier_details_path"
|
||||
);
|
||||
assert!(
|
||||
!goal.verifying_completion,
|
||||
"verifying_completion defaults to false"
|
||||
);
|
||||
assert!(!goal.planning, "planning defaults to false");
|
||||
assert!(
|
||||
goal.deliverables.is_empty(),
|
||||
"deliverables is wire-compat-only in the simplified goal model"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_permission() {
|
||||
// A peer answered a shared permission → this pane retracts its copy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (msg, _rx) = make_permission_message("sess-1");
|
||||
handle(msg, &mut app);
|
||||
assert_eq!(app.agents[&AgentId(0)].permission_queue.len(), 1);
|
||||
|
||||
let changed = handle_session_notification(
|
||||
&interaction_resolved_ext("sess-1", "call-perm-1"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(changed, "dismissing a visible permission must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].permission_queue.is_empty(),
|
||||
"the resolved permission must be removed from the queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_question() {
|
||||
use crate::views::question_view::QuestionViewState;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let stashed = agent.prompt.stash();
|
||||
agent.question_view = Some(QuestionViewState::new("call-q".into(), vec![], stashed));
|
||||
}
|
||||
|
||||
let changed =
|
||||
handle_session_notification(&interaction_resolved_ext("sess-1", "call-q"), &mut app);
|
||||
assert!(changed, "dismissing a visible question must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].question_view.is_none(),
|
||||
"the resolved question must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_plan_approval() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext_with_tool_call_id("call-plan", Some("# Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
assert!(app.agents[&AgentId(0)].plan_approval_view.is_some());
|
||||
|
||||
let changed =
|
||||
handle_session_notification(&interaction_resolved_ext("sess-1", "call-plan"), &mut app);
|
||||
assert!(changed, "dismissing a visible plan approval must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].plan_approval_view.is_none(),
|
||||
"the resolved plan approval must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_is_noop_for_unknown_tool_call_id() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (msg, _rx) = make_permission_message("sess-1");
|
||||
handle(msg, &mut app);
|
||||
|
||||
let changed = handle_session_notification(
|
||||
&interaction_resolved_ext("sess-1", "some-other-call"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(!changed, "an unknown tool_call_id must be a silent no-op");
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].permission_queue.len(),
|
||||
1,
|
||||
"an unrelated pending modal must be left intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_agent_queues_on_owning_agent() {
|
||||
// The headline behavior change in handle_permission_request:
|
||||
// permissions for an inactive owning agent now QUEUE (not cancel)
|
||||
// so the user sees them on switching back.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let (msg, mut rx) = make_permission_message("sess-A");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.permission_queue.len(),
|
||||
1,
|
||||
"permission for inactive A must queue on A's permission_queue"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b.permission_queue.len(),
|
||||
0,
|
||||
"active B's permission_queue must remain empty"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"permission queued on a non-active agent must not request a redraw"
|
||||
);
|
||||
// Permission is still pending; the response_tx must still be alive
|
||||
// (no auto-cancel was sent).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"permission must NOT have been answered yet (queued, not cancelled)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_user_question_routes_to_background_session_not_active_view() {
|
||||
// Repro of the dashboard bug: a session started but not entered asks a
|
||||
// question. Active view is agent A (sess-A); the question is for the
|
||||
// BACKGROUND agent B (sess-B). It must land on B, not fail or land on A.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let raw = serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sessionId": "sess-B",
|
||||
"toolCallId": "tc-bg",
|
||||
"questions": [],
|
||||
"mode": "default",
|
||||
}))
|
||||
.unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/ask_user_question", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"a background-session question must not redraw the active view"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().question_view.is_some(),
|
||||
"question must be parked on the session that asked (background agent B)"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().question_view.is_none(),
|
||||
"question must NOT land on the unrelated active agent A"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT be sent yet (parked, waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_user_question_unknown_session_parks_without_error() {
|
||||
// No local view for the session, and the active agent HAS a session_id
|
||||
// (so the race-window fallback does not fire). The reverse-request must
|
||||
// be left UNANSWERED (dropped) — NOT failed with an error, which would
|
||||
// render the tool red. Leader replay-on-attach handles it later.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let raw = serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sessionId": "sess-unknown",
|
||||
"toolCallId": "tc-unknown",
|
||||
"questions": [],
|
||||
"mode": "default",
|
||||
}))
|
||||
.unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/ask_user_question", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().question_view.is_none(),
|
||||
"must not attach the question to an unrelated active agent"
|
||||
);
|
||||
// A dropped oneshot sender yields `Closed`; `Empty` would mean still
|
||||
// held open, `Ok` would mean a (failing) response was sent.
|
||||
match rx.try_recv() {
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
|
||||
panic!("response_tx must be dropped (parked), not held open")
|
||||
}
|
||||
Ok(_) => panic!("must NOT send any response — that would fail/resolve the tool"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_yolo_agent_auto_approves() {
|
||||
// YOLO mode is honored on the OWNING agent, not the active one,
|
||||
// so background turns aren't blocked waiting for a switch.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let (msg, rx) = make_permission_message("sess-A");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected, "YOLO auto-approve never needs a redraw");
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.permission_queue.len(),
|
||||
0,
|
||||
"YOLO must auto-approve in place of queueing"
|
||||
);
|
||||
let response = rx
|
||||
.blocking_recv()
|
||||
.expect("YOLO must have sent a response on response_tx");
|
||||
let resp = response.expect("YOLO response must be Ok");
|
||||
match resp.outcome {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome {
|
||||
option_id,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(option_id.0.as_ref(), "allow-once");
|
||||
}
|
||||
other => panic!("expected Selected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_unknown_session_id_is_cancelled() {
|
||||
// No agent owns the session and the active agent already has a
|
||||
// session_id (so the race-window fallback does not fire). The
|
||||
// permission must be cancelled rather than queued anywhere.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let (msg, rx) = make_permission_message("sess-unknown");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected);
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
assert_eq!(
|
||||
app.agents.get(&id).unwrap().permission_queue.len(),
|
||||
0,
|
||||
"no agent should have queued the unknown-session permission",
|
||||
);
|
||||
}
|
||||
let response = rx
|
||||
.blocking_recv()
|
||||
.expect("cancel_permission must have sent a response");
|
||||
let resp = response.expect("response must be Ok");
|
||||
assert!(
|
||||
matches!(resp.outcome, acp::RequestPermissionOutcome::Cancelled),
|
||||
"unknown session_id permissions must be cancelled, got {:?}",
|
||||
resp.outcome,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan approval persistence tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_viewer_preserves_plan_approval_state() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-persist".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.plan_approval_view.is_some(), "approval should be set");
|
||||
|
||||
// Close the viewer (simulates Esc / close button).
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// Approval state must survive the close.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must persist after viewer close"
|
||||
);
|
||||
assert!(agent.line_viewer.is_none(), "viewer should be closed");
|
||||
|
||||
// Response must NOT have been sent (still waiting for user).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must not be sent on viewer close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_viewer_restores_approval_buttons() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
// Seed a CreatePlan tool so the source is Inline (plan content
|
||||
// is carried in the ext_method params, not read from disk).
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "tc-reopen", "CreatePlan");
|
||||
}
|
||||
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-reopen".into(),
|
||||
plan_content: Some("# Plan\nStep 1".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
assert!(agent.line_viewer.is_none());
|
||||
|
||||
// Reopen plan preview — inline content is in plan_approval_view.plan_content.
|
||||
agent.show_plan_preview();
|
||||
|
||||
assert!(agent.line_viewer.is_some(), "viewer should reopen");
|
||||
assert!(
|
||||
agent.line_viewer.as_ref().unwrap().feedback_active(),
|
||||
"feedback_active must be true after reopen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_after_reopen_does_not_overwrite_prompt() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "tc-prompt", "CreatePlan");
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-prompt".into(),
|
||||
plan_content: Some("# Plan\nDo things".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// User types new text in the prompt while viewer is closed.
|
||||
agent.prompt.set_text("my new prompt text");
|
||||
|
||||
agent.reopen_plan_approval();
|
||||
agent.approve_plan();
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.prompt.text(),
|
||||
"my new prompt text",
|
||||
"stashed prompt should be restored after reopen + approve"
|
||||
);
|
||||
|
||||
// Response should be approved.
|
||||
let response = rx.blocking_recv().expect("should have sent response");
|
||||
let raw = response.expect("should be Ok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).unwrap();
|
||||
assert_eq!(parsed["outcome"], "approved");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a shared-queue interjection renders only via the broadcast,
|
||||
/// and the shell emits the queue-emptying `x.ai/queue/changed` right after
|
||||
/// it — which used to fire the withheld parked marker BELOW the just-
|
||||
/// rendered user message ("Worked for …" under the follow-up, flipped
|
||||
/// transcript order). The broadcast must consume the marker slot instead.
|
||||
#[test]
|
||||
fn interjection_broadcast_mid_park_forgoes_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_task_output_wait(agent, "bg-1");
|
||||
assert!(agent.is_parked_on_sendable_wait());
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "queued follow-up"),
|
||||
&mut app,
|
||||
));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.parked_wait_marker_for,
|
||||
Some(crate::app::agent_view::ParkedMarkerSlot::Forgone(
|
||||
"p1".into()
|
||||
)),
|
||||
"broadcast render must consume the parked-marker slot as Forgone"
|
||||
);
|
||||
// The queue-changed following the broadcast must not fire it late.
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no late 'Worked for …' marker under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a Forgone slot (interjection continued
|
||||
/// the parked turn, no marker on screen) must also silence the countdown
|
||||
/// — a full "Worked for …" tick under the interjected message would
|
||||
/// recreate the flipped transcript. Rendered slots keep ticking.
|
||||
#[test]
|
||||
fn forgone_slot_suppresses_countdown_ticks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
// The parked drain interjected a queued row before the marker
|
||||
// became eligible: slot consumed WITHOUT a marker.
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
assert!(agent.renders_parked(), "forgone slot keeps parked chrome");
|
||||
assert_eq!(count_parked(agent), 0, "no marker on screen");
|
||||
}
|
||||
|
||||
// A task completing in the still-parked window must stay silent.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no 'Worked for …' tick under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Feature: "sleep 10, 15, 20 in the background" — while the turn is
|
||||
/// parked, each task completion appends a fresh FULL marker with the
|
||||
/// remaining count, so the user watches it tick down (3 → 2 → 1), each
|
||||
/// line a complete "Worked for X. N commands still running.".
|
||||
/// The last completion pushes nothing (0/0): the wait returns and the
|
||||
/// real completion marker narrates the end. (Elapsed renders as "0.0s":
|
||||
/// `turn_started_at` is unset in this fixture.)
|
||||
#[test]
|
||||
fn parked_countdown_ticks_down_as_tasks_complete() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
insert_running_task(agent, "t20", "sleep 20");
|
||||
simulate_task_output_wait(agent, "t20");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
|
||||
// sleep 10 exits → full marker with "2 commands still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// Duplicate completion for the same task: not a Running→Done edge.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 15 exits → full marker with "1 command still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t15", "sleep 15", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 20 exits → nothing left; no "0 commands" line.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t20", "sleep 20", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 3 commands still running.".to_string(),
|
||||
"Worked for 0.0s. 2 commands still running.".to_string(),
|
||||
"Worked for 0.0s. 1 command still running.".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_subagent_finishes_refresh_one_uncommitted_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
|
||||
// Re-delivered finish for an already-finished subagent: not an edge.
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-3")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_text_thought_and_tool_output_start_new_subagent_segments() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call;
|
||||
|
||||
crate::appearance::cache::set_show_thinking_blocks(true);
|
||||
for output_kind in ["text", "thought", "tool"] {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2", "child-3"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
if output_kind == "tool" {
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
"read_file",
|
||||
)
|
||||
.kind(acp::ToolKind::Read)
|
||||
.status(acp::ToolCallStatus::InProgress)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
}
|
||||
simulate_task_output_wait_call(agent, "wait-1", "not-ours", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let output = match output_kind {
|
||||
"text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent text")),
|
||||
)),
|
||||
"thought" => acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent thought")),
|
||||
)),
|
||||
"tool" => acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.status(Some(acp::ToolCallStatus::Completed)),
|
||||
)),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
output,
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000);
|
||||
}
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 2 subagents still running.".to_string(),
|
||||
"Worked for 0.0s. 1 subagent still running.".to_string(),
|
||||
],
|
||||
"{output_kind} output must start a new segment",
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_subagent_marker_appends_fallback() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
let marker_index = (0..agent.scrollback.len())
|
||||
.find(|&index| agent.scrollback.get(index).is_some_and(|entry| entry.id == marker_id))
|
||||
.unwrap();
|
||||
agent.scrollback.mark_committed(marker_index);
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 2 subagents still running.".to_string(),
|
||||
"Worked for 0.0s. 1 subagent still running.".to_string(),
|
||||
],
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_subagent_marker_handle_appends_fallback() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let old_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2"])
|
||||
};
|
||||
assert!(app
|
||||
.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(old_marker_id));
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 1);
|
||||
assert_ne!(marker_ids[0], old_marker_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_suppresses_later_subagent_refresh() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "continue differently"),
|
||||
&mut app,
|
||||
));
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_subagent_finish_does_not_refresh_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
agent.session.loading_replay = true;
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_subagent_wait_does_not_refresh_marker() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
simulate_task_output_wait(agent, "child-1");
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
/// Synthetic completions from cold-load reconciliation (`session_restart`
|
||||
/// signal) finalize quietly — no countdown line, mirroring the suppressed
|
||||
/// "Task failed" block: nothing happened in THIS session.
|
||||
#[test]
|
||||
fn stale_on_load_completion_pushes_no_countdown() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif_with_signal(
|
||||
"sess-park",
|
||||
"t10",
|
||||
"sleep 10",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
// Only the initial parked marker — no countdown re-push.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(parked_marker_messages(agent).len(), 1);
|
||||
}
|
||||
|
||||
/// Task completions with no parked look (running turn chrome is up, or
|
||||
/// the turn already ended) must not emit countdown lines — the Tasks
|
||||
/// pane and completion blocks already narrate those states.
|
||||
#[test]
|
||||
fn task_completion_without_parked_look_pushes_no_countdown() {
|
||||
let mut app = make_app_with_agent("sess-live");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
// No wait, no parked marker: chrome is the live turn.
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-live", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(parked_marker_messages(agent).is_empty());
|
||||
}
|
||||
|
||||
// -- imminent waits do not park (awaited work already finished) ----------
|
||||
|
||||
/// Waiting on a task that already completed: no marker, slot stays free.
|
||||
#[test]
|
||||
fn wait_on_already_completed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "imminent wait must not park");
|
||||
assert!(
|
||||
agent.parked_wait_marker_for.is_none(),
|
||||
"slot must stay free for a later genuine park"
|
||||
);
|
||||
assert!(!agent.renders_parked());
|
||||
}
|
||||
|
||||
/// A skipped wait leaves the slot free: a later wait on running work in
|
||||
/// the same turn still parks.
|
||||
#[test]
|
||||
fn later_genuine_wait_still_parks_after_imminent_wait_skip() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "done", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0);
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
insert_running_task(agent, "live", "sleep 99");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "live", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "genuine park still renders");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed` is terminal for imminence, not just `Done`.
|
||||
#[test]
|
||||
fn wait_on_failed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Failed;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "failed task wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// Finished-subagent waits do not park — resolved by subagent id, then by
|
||||
/// child session id.
|
||||
#[test]
|
||||
fn wait_on_finished_subagent_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.finished = true;
|
||||
agent.subagent_sessions.insert("child-1".into(), info);
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "sa-child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "finished subagent wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "child-session-id wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// One unresolvable id among terminal ones keeps the park.
|
||||
#[test]
|
||||
fn wait_including_unknown_id_still_parks() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::count_parked;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
let meta = NotificationMeta::default();
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
"get_command_or_subagent_output",
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({
|
||||
"task_ids": ["done", "not-ours"],
|
||||
"timeout_ms": 30_000,
|
||||
}))),
|
||||
)),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "unresolvable id keeps the park");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_zero_work_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_running_work_still_parks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "wait-all on live work parks");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `SubagentSpawned` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn subagent_spawn_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle(
|
||||
make_ext_session_notification(
|
||||
"sess-park",
|
||||
test_subagent_spawned("sess-park", "child-1"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `x.ai/task_backgrounded` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn task_backgrounded_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle_ext_notification(
|
||||
&make_task_backgrounded_notif("sess-park", "tc-late", "t-late", "sleep 99"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
1,
|
||||
"task registration re-evaluates the skipped park"
|
||||
);
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_pushes_block_to_matching_session() {
|
||||
// Multi-client fix: an interjection typed in one pane is broadcast by
|
||||
// the shell as x.ai/session/interjection; EVERY attached pane (incl.
|
||||
// the originator, which no longer pushes a local block) renders it.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "also add tests"), &mut app);
|
||||
assert!(affected, "rendering into the active agent should redraw");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("also add tests"),
|
||||
"the interjection block must be pushed from the broadcast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_for_unknown_session_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected = handle_ext_notification(&interjection_ext("sess-other", "stray"), &mut app);
|
||||
assert!(!affected, "an unmatched session must be a no-op");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
last_interjection_text(&agent.scrollback).is_none(),
|
||||
"no interjection block must be pushed for an unknown session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_renders_for_a_viewer() {
|
||||
// A viewer (attached_as_viewer) watching another client's session must
|
||||
// also render interjections broadcast for that session.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "viewer sees this"), &mut app);
|
||||
assert!(affected);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("viewer sees this"),
|
||||
"a viewer must render interjections broadcast for its session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_dedups_originators_own_echo() {
|
||||
// The originator rendered an optimistic block in dispatch_interject and
|
||||
// recorded the id; its own broadcast echo must be dropped (no dup) and
|
||||
// the id forgotten.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.self_interjection_ids
|
||||
.insert("ij-1".to_string());
|
||||
|
||||
let affected = handle_ext_notification(
|
||||
&interjection_ext_with_id("sess-view", "my own", Some("ij-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"an originator's own echo must be a no-op (already rendered locally)"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
last_interjection_text(&agent.scrollback).is_none(),
|
||||
"the echo must not push a duplicate block"
|
||||
);
|
||||
assert!(
|
||||
!agent.self_interjection_ids.contains("ij-1"),
|
||||
"the id must be forgotten after dedup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_with_foreign_id_renders() {
|
||||
// A broadcast carrying an id this client did NOT mint (another pane's
|
||||
// interjection) must render — only the originator dedups by its own id.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected = handle_ext_notification(
|
||||
&interjection_ext_with_id("sess-view", "from another pane", Some("other-id")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("from another pane"),
|
||||
"an interjection from another pane must render"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_updates_seeded_progress_in_place() {
|
||||
// When a session is seeded with mcp_init_progress{0,0}, a
|
||||
// subsequent init_progress notification must update total and
|
||||
// connected IN PLACE — preserving started_at for timer accuracy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
let started_at = agent.mcp_init_progress.as_ref().unwrap().started_at;
|
||||
|
||||
let notif = make_mcp_init_progress_notif(5, 0);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let progress = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.total, 5, "total must be updated from shell");
|
||||
assert_eq!(progress.connected, 0);
|
||||
assert_eq!(
|
||||
progress.started_at, started_at,
|
||||
"started_at must be preserved (timer anchoring)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_status_routes_to_owning_agent() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
|
||||
// Agent 0 owns sess-owner; Agent 1 is foregrounded.
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Give the active agent its own modal so we can prove it's untouched.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(vec![
|
||||
crate::views::mcps_modal::McpServerInfo {
|
||||
name: "alpha".into(),
|
||||
display_name: None,
|
||||
status: McpServerDisplayStatus::Initializing,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
let tools = Some(serde_json::json!([
|
||||
{ "name": "t1", "description": "one", "enabled": true },
|
||||
{ "name": "t2", "enabled": true },
|
||||
]));
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, tools);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
!redraw,
|
||||
"owner is background — mutation must not request a redraw"
|
||||
);
|
||||
|
||||
// Owner mutated.
|
||||
let owner_modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref owner_servers) = owner_modal.mcps_data else {
|
||||
panic!("owner modal must still be in Loaded state");
|
||||
};
|
||||
assert_eq!(owner_servers[0].status, McpServerDisplayStatus::Ready);
|
||||
assert_eq!(owner_servers[0].tool_count, 2);
|
||||
assert_eq!(owner_servers[0].tools.len(), 2);
|
||||
|
||||
// Active agent's modal must be untouched.
|
||||
let active_modal = app
|
||||
.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref active_servers) = active_modal.mcps_data else {
|
||||
panic!("active modal must still be in Loaded state");
|
||||
};
|
||||
assert_eq!(
|
||||
active_servers[0].status,
|
||||
McpServerDisplayStatus::Initializing,
|
||||
"active-view agent must not absorb the owning agent's push"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_creates_when_none() {
|
||||
// When mcp_init_progress is None (no seed), init_progress
|
||||
// creates a fresh McpInitProgress.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
assert!(app.agents[&AgentId(0)].mcp_init_progress.is_none());
|
||||
|
||||
let notif = make_mcp_init_progress_notif(3, 1);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let progress = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.total, 3);
|
||||
assert_eq!(progress.connected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_clears_progress() {
|
||||
// x.ai/mcp_initialized must set mcp_init_progress to None.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 3,
|
||||
connected: 3,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
let notif = make_mcp_initialized_notif("sess-1");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"mcp_initialized must clear mcp_init_progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_full_lifecycle_seed_to_clear() {
|
||||
// Full N-server lifecycle:
|
||||
// seed(0/0) → init_progress(0/3) → init_progress(2/3)
|
||||
// → init_progress(3/3) → mcp_initialized → None
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell reports real count.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 0));
|
||||
|
||||
// Incremental progress.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 2), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 2));
|
||||
|
||||
// All connected.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 3), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 3));
|
||||
|
||||
// mcp_initialized clears everything.
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"mcp_initialized must clear progress after full lifecycle",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_zero_server_lifecycle() {
|
||||
// 0-server lifecycle (the bug scenario):
|
||||
// seed(0/0) → init_progress(0/0) → mcp_initialized → None
|
||||
// Previously mcp_initialized was never sent for 0 servers,
|
||||
// leaving a stuck progress indicator.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell sends 0/0 for the 0-server case.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(0, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (0, 0));
|
||||
|
||||
// Shell now sends mcp_initialized (root-cause fix).
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"0-server mcp_initialized must clear progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_routes_to_background_session() {
|
||||
// init_progress carrying a background session's sessionId must update
|
||||
// *that* agent's indicator, not the foregrounded one, and must not
|
||||
// force a redraw (the background spinner isn't visible).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
|
||||
let notif = make_mcp_init_progress_notif_for(4, 1, "sess-B");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"background-session progress must not force a redraw"
|
||||
);
|
||||
|
||||
let bg = app.agents[&AgentId(1)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((bg.total, bg.connected), (4, 1));
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"foreground agent must be untouched by a background session's progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_routes_to_background_session() {
|
||||
// mcp_initialized for a background session must clear *that* agent's
|
||||
// indicator while leaving the foreground agent's intact. Previously
|
||||
// the clear was applied to whichever agent was active, so a
|
||||
// background agent's spinner could stick forever.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
app.agents.get_mut(&id).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 2,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-B");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"clearing a background spinner must not force a redraw",
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(1)].mcp_init_progress.is_none(),
|
||||
"background session's spinner must be cleared",
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"foreground agent's spinner must NOT be cleared by another session",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_unknown_session_is_dropped() {
|
||||
// An mcp_initialized for a session that matches no agent must not
|
||||
// clear anyone's indicator (no misrouting to the active agent).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 1,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-unknown");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"unknown-session mcp_initialized must not clear the active agent",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_lifecycle_notif_for_subagent_session_is_dropped() {
|
||||
// A subagent runs its own MCP init, emitting init_progress /
|
||||
// mcp_initialized under the *child* session id. Those must NOT write to
|
||||
// or clear the parent agent's mcp_init_progress — it's a per-root-agent
|
||||
// indicator with no subagent slot, so a subagent's init must not
|
||||
// clobber the parent's spinner.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 2,
|
||||
connected: 1,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
// Register a subagent child view keyed by the child session id.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.subagent_views
|
||||
.insert(
|
||||
"child-sess".to_string(),
|
||||
Box::new(make_agent(Some("child-sess"))),
|
||||
);
|
||||
|
||||
// init_progress for the child session must leave the parent untouched.
|
||||
let changed = handle_ext_notification(
|
||||
&make_mcp_init_progress_notif_for(5, 0, "child-sess"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!changed,
|
||||
"subagent init_progress must not redraw the parent"
|
||||
);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(p.total, p.connected),
|
||||
(2, 1),
|
||||
"parent spinner must be untouched by a subagent's init",
|
||||
);
|
||||
|
||||
// mcp_initialized for the child session must not clear the parent.
|
||||
let changed =
|
||||
handle_ext_notification(&make_mcp_initialized_notif_for("child-sess"), &mut app);
|
||||
assert!(!changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"subagent mcp_initialized must not clear the parent's spinner",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_closed_background() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Owner is background and has NO modal open. server_status
|
||||
// must be a silent no-op (no Effect scheduling, no redraw).
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "closed-modal cheap path must not request a redraw");
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"closed-modal cheap path must not schedule any effects"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.is_none(),
|
||||
"owner modal must remain closed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the cheap-path semantics for the FOREGROUND case too.
|
||||
/// Without this, the background case passes trivially regardless
|
||||
/// of how the cheap path is gated on `is_active`.
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_closed_foreground() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Foreground = agent 1 (sess-active). Send a push targeting
|
||||
// the foregrounded agent, no modal open.
|
||||
let notif = make_server_status_notif("sess-active", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
!redraw,
|
||||
"foreground + closed-modal must still be a no-op (no row → no redraw)"
|
||||
);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"server_status NEVER schedules an effect (push is per-row, not list-refetch)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the cheap-path semantics when the modal is open but the
|
||||
/// data is still loading. The `TabDataState::Loaded`
|
||||
/// gate must early-return without panicking.
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_data_still_loading() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let mut modal = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
modal.mcps_data = TabDataState::Loading;
|
||||
owner.extensions_modal = Some(modal);
|
||||
}
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "Loading state must skip the patch");
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
/// A malformed `status` field must NOT silently coerce to
|
||||
/// `Unavailable`. The whole payload must fail to parse and
|
||||
/// warn-log instead.
|
||||
#[test]
|
||||
fn server_status_malformed_status_does_not_silently_repaint() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Send a payload with the `status` field missing entirely —
|
||||
// a defaulting decoder would silently coerce this to Unavailable.
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-owner",
|
||||
"name": "alpha",
|
||||
"source": "local",
|
||||
"reason": "initialized",
|
||||
// NB: no `status`.
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/mcp/server_status", raw.into());
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "malformed payload must not request a redraw");
|
||||
|
||||
let modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref servers) = modal.mcps_data else {
|
||||
panic!("modal still Loaded");
|
||||
};
|
||||
assert_eq!(
|
||||
servers[0].status,
|
||||
McpServerDisplayStatus::Initializing,
|
||||
"malformed status must NOT silently map to Unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
/// A present-but-non-array `tools` field must drop ONLY the tools
|
||||
/// update. The `status` field still applies — we do not drop the
|
||||
/// whole push.
|
||||
#[test]
|
||||
fn server_status_lenient_tools_decoding_still_applies_status() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
|
||||
// tools = arbitrary non-array shape (a future shell might emit
|
||||
// something like `{ added: [], removed: [] }`). Must NOT take
|
||||
// down the status update.
|
||||
let bad_tools = Some(serde_json::json!({"added": [], "removed": []}));
|
||||
let notif =
|
||||
make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, bad_tools);
|
||||
let _ = handle_mcp_server_status(¬if, &mut app);
|
||||
|
||||
let modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref servers) = modal.mcps_data else {
|
||||
panic!("modal still Loaded");
|
||||
};
|
||||
assert_eq!(
|
||||
servers[0].status,
|
||||
McpServerDisplayStatus::Ready,
|
||||
"malformed tools must not take down the status update"
|
||||
);
|
||||
// tool_count / tools were preserved (we dropped the tools
|
||||
// update, not overwrote with empty).
|
||||
assert_eq!(servers[0].tool_count, 0);
|
||||
assert!(servers[0].tools.is_empty());
|
||||
}
|
||||
|
||||
/// Pin that the pager deserializes against the *shell's*
|
||||
/// `McpServerStatus` enum, so a future new variant doesn't need a
|
||||
/// pager change to be recognized. Round-trip through
|
||||
/// `serde_json::to_string` of the shell type itself.
|
||||
#[test]
|
||||
fn server_status_round_trips_shell_canonical_type() {
|
||||
use kigi_shell::extensions::mcp::{
|
||||
McpServerSource, McpServerStatus, McpServerStatusPayload, McpServerStatusReason,
|
||||
};
|
||||
let payload = McpServerStatusPayload {
|
||||
session_id: "s".into(),
|
||||
name: "alpha".into(),
|
||||
source: McpServerSource::Local,
|
||||
status: McpServerStatus::NeedsAuth,
|
||||
reason: McpServerStatusReason::AuthExpired,
|
||||
detail: Some("token expired".into()),
|
||||
tools: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let roundtripped: McpServerStatusPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(payload, roundtripped);
|
||||
// `needsAuth` must be on the wire as the lowercase form, not
|
||||
// mixed-case — verifies the rename_all = lowercase contract.
|
||||
assert!(
|
||||
json.contains("\"needsauth\""),
|
||||
"wire form must be lowercase 'needsauth'; got {json}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `servers_updated` has NO `sessionId` on the wire. The handler
|
||||
/// must broadcast to every agent with an open modal, NOT fall
|
||||
/// through to `active_view`.
|
||||
#[test]
|
||||
fn servers_updated_broadcasts_to_every_agent_with_open_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Open modals on BOTH agents — broadcast must hit both.
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(vec![
|
||||
crate::views::mcps_modal::McpServerInfo {
|
||||
name: "beta".into(),
|
||||
display_name: None,
|
||||
status: crate::views::mcps_modal::McpServerDisplayStatus::Initializing,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
let notif = make_servers_updated_notif();
|
||||
let redraw = handle_mcp_servers_updated(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
redraw,
|
||||
"active agent had its modal open — redraw must be requested"
|
||||
);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
2,
|
||||
"servers_updated must broadcast: one FetchMcpsList per agent with an open modal"
|
||||
);
|
||||
let mut targets: Vec<usize> = app
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::FetchMcpsList {
|
||||
agent_id, cache, ..
|
||||
} => {
|
||||
assert!(*cache, "broadcast must use the debounced cache=true path");
|
||||
Some(agent_id.0)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// Agents without an open modal must NOT receive a refetch (cheap
|
||||
/// path) even though they are eligible to receive the broadcast in
|
||||
/// principle.
|
||||
#[test]
|
||||
fn servers_updated_skips_agents_with_closed_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Only agent 1 (foregrounded) has a modal.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let notif = make_servers_updated_notif();
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"agent 0 has no modal open — must NOT receive a refetch"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } = app.pending_effects.first().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(1),
|
||||
"only the agent with an open modal gets the refetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second `servers_updated` push that lands before agent A's
|
||||
/// pending fetch drains must coalesce for agent A but still
|
||||
/// schedule fresh for agent B if B's first push was never seen.
|
||||
#[test]
|
||||
fn servers_updated_per_agent_coalescing() {
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
let notif = make_servers_updated_notif();
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(app.pending_effects.len(), 1);
|
||||
|
||||
// Second push: owner is now coalesced (agent 0 has pending);
|
||||
// but we add a modal to agent 1 — that agent's push must NOT
|
||||
// be dropped just because agent 0 has a pending fetch.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
2,
|
||||
"agent 1's first push must schedule a fetch despite agent 0 having a pending one"
|
||||
);
|
||||
let mut targets: Vec<usize> = app
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::FetchMcpsList { agent_id, .. } => Some(agent_id.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// `mcp_initialized` wire shape `{ sessionId, mcpToolCount, elapsedMs }`
|
||||
/// — sessionId routing applies; the matched agent's
|
||||
/// `mcp_init_progress` overlay must be cleared.
|
||||
#[test]
|
||||
fn mcp_initialized_clears_init_progress_on_owner() {
|
||||
use crate::app::agent_view::McpInitProgress;
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed init progress on the OWNER (agent 0) and on the
|
||||
// active view (agent 1). The push must clear only the
|
||||
// owner's overlay.
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
owner.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 5,
|
||||
connected: 3,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 5,
|
||||
connected: 3,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-owner");
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.mcp_init_progress
|
||||
.is_none(),
|
||||
"owner's init progress overlay must be cleared"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.mcp_init_progress
|
||||
.is_some(),
|
||||
"active-view agent's init progress must NOT be cleared by a background-agent init"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_changed_post_h2_routes_to_owning_agent_not_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Active agent has NO modal — a route-by-active-view path
|
||||
// would no-op. Routing by sessionId must schedule a fetch
|
||||
// against the OWNER agent (agent 0).
|
||||
let notif = make_tools_changed_notif_post_h2("sess-owner");
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"tools_changed with sessionId must route to the owner"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } =
|
||||
app.pending_effects.first().expect("effect scheduled")
|
||||
else {
|
||||
panic!("expected FetchMcpsList");
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(0),
|
||||
"tools_changed with sessionId must route to the owner, not the active view"
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward/backward-compat guard: older shells emit
|
||||
/// `{ serverName, tools }` with NO sessionId. The pager must
|
||||
/// gracefully fall back to `active_view`.
|
||||
|
||||
#[test]
|
||||
fn tools_changed_pre_h2_falls_back_to_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Active agent (agent 1) gets a modal; owner (agent 0) does not.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let notif = make_tools_changed_notif_pre_h2();
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"legacy payload without sessionId falls back to active view"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } = app.pending_effects.first().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(1),
|
||||
"legacy fallback targets the foregrounded agent"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a machine-wide `x.ai/models/update` broadcast
|
||||
/// carries each model's static catalog-default effort (`high`), not the
|
||||
/// session's chosen `xhigh`, and must not clobber the per-session choice.
|
||||
#[test]
|
||||
fn models_update_preserves_user_reasoning_effort() {
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id = acp::ModelId::new(std::sync::Arc::from("reason-model"));
|
||||
let mut info = make_model_info("reason-model");
|
||||
info.meta = serde_json::json!({
|
||||
"supportsReasoningEffort": true,
|
||||
"reasoningEffort": "high",
|
||||
})
|
||||
.as_object()
|
||||
.cloned();
|
||||
agent.session.models.available.insert(id.clone(), info);
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.set_current(id, Some(ReasoningEffort::Xhigh));
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::Xhigh)
|
||||
);
|
||||
|
||||
let notif = make_reasoning_models_update_notif("reason-model", "high");
|
||||
assert!(handle_models_update(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::Xhigh),
|
||||
"models/update broadcast must not clobber a user-set per-session effort"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_preserves_active_agent_model() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent.session.models.current = Some(id_3);
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"app.models.current must preserve active agent's model, not remote settings default"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"agent's per-session model must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_uses_shell_default_when_agent_model_removed() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent.session.models.current = Some(id_3);
|
||||
|
||||
// grok-3 removed from catalog.
|
||||
let notif = make_models_update_notif("grok-4.3", &["grok-4.3", "grok-4.5"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4.3"),
|
||||
"app.models.current must use shell default when agent model removed"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4.3"),
|
||||
"agent must fall back to shell default when its model is removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_without_active_agent_uses_shell_default() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"without an active agent, shell default must be used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_noop_when_agent_matches_shell_default() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_4 = acp::ModelId::new(std::sync::Arc::from("grok-4"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_4.clone(), make_model_info("grok-4"));
|
||||
agent.session.models.current = Some(id_4);
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"app.models.current must be grok-4 when agent and shell agree"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"agent model must remain grok-4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_non_active_agent_uses_shell_fallback_not_active_model() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent_a
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent_a.session.models.current = Some(id_3);
|
||||
}
|
||||
|
||||
{
|
||||
let agent_b = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
let id_5 = acp::ModelId::new(std::sync::Arc::from("grok-4.5"));
|
||||
agent_b
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_5.clone(), make_model_info("grok-4.5"));
|
||||
agent_b.session.models.current = Some(id_5);
|
||||
}
|
||||
|
||||
// grok-5 removed from catalog.
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
);
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"agent A's model must be preserved"
|
||||
);
|
||||
|
||||
// B's grok-5 was removed — must fall back to shell's grok-4, not A's grok-3.
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"inactive agent must fall back to shell default, not active agent's model"
|
||||
);
|
||||
}
|
||||
|
||||
/// A follower client (no in-flight switch of its own) receives the
|
||||
/// leader's `ModelChanged` broadcast and silently mirrors the new model
|
||||
/// into its local state — no scrollback entry, no toast, just enough
|
||||
/// state for the status bar / `/model` dropdown to render correctly.
|
||||
#[test]
|
||||
fn model_changed_updates_state_silently_on_follower() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
// Follower: no local switch in flight.
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
changed,
|
||||
"follower's state changed → handler must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"follower must mirror the remote switch into its local model state",
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
scrollback_before,
|
||||
"follower must NOT push a 'Switched to' scrollback entry — that is \
|
||||
the invoking client's job (SwitchModelComplete owns the system message)"
|
||||
);
|
||||
assert!(
|
||||
!agent.session.model_switch_pending,
|
||||
"follower's pending flag must stay false (no local switch was issued)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A live remote `ModelChanged` (leader-mode fan-out from another client)
|
||||
/// must apply even when this client already has a local
|
||||
/// `user_model_preference` — otherwise the status bar desyncs from the
|
||||
/// gateway session. Preference is updated to track the new live model.
|
||||
/// (History-replay silent-revert is suppressed on the shell side via
|
||||
/// `ReconnectState::user_selected_model`, not by permanently blocking
|
||||
/// remote ModelChanged here.)
|
||||
#[test]
|
||||
fn model_changed_applies_and_updates_user_model_preference() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "heavy", &["auto", "heavy"]);
|
||||
agent.session.user_model_preference =
|
||||
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "auto", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
changed,
|
||||
"remote live ModelChanged must apply despite prior local preference"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("auto"),
|
||||
"selector must mirror the remote switch"
|
||||
);
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.user_model_preference
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("auto"),
|
||||
"preference must track the applied remote switch"
|
||||
);
|
||||
}
|
||||
|
||||
/// The invoking client is also a subscriber to its own session and so
|
||||
/// receives the broadcast it triggered. Its in-flight
|
||||
/// `SetSessionModelResponse` is the authority for its local state +
|
||||
/// the single "Switched to X" scrollback entry, so the broadcast handler
|
||||
/// must be a no-op here — gated on `model_switch_pending == true`.
|
||||
///
|
||||
/// Concretely we verify the broadcast does NOT touch
|
||||
/// `models.current` (preserving the pre-response snapshot) — that
|
||||
/// snapshot is what `SwitchModelComplete`'s `unchanged` check compares
|
||||
/// against to decide whether to render the "Switched to X" message. If
|
||||
/// the broadcast optimistically updated state here, the response
|
||||
/// handler would see `prev == new`, mark it unchanged, and suppress the
|
||||
/// user-facing message entirely.
|
||||
#[test]
|
||||
fn model_changed_skipped_when_local_switch_in_flight() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
// Invoker: a local switch is in flight (set by Action::SwitchModel /
|
||||
// set_default_model before the SetSessionModelRequest is sent).
|
||||
agent.session.model_switch_pending = true;
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"broadcast must be a no-op while local switch is pending"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"models.current must stay at the pre-response snapshot — \
|
||||
SwitchModelComplete owns the final apply + system message"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
scrollback_before,
|
||||
"broadcast must not push any scrollback entry on the invoker"
|
||||
);
|
||||
assert!(
|
||||
agent.session.model_switch_pending,
|
||||
"pending flag must remain set until SwitchModelComplete arrives"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `ModelChanged` broadcast carrying a model id the local catalog
|
||||
/// doesn't know about must be dropped — applying it would render an
|
||||
/// unresolvable id in the status bar and desync the `/model` dropdown.
|
||||
/// This can happen when leader and a follower client briefly disagree
|
||||
/// on the model catalog (etag drift, custom-model config skew).
|
||||
#[test]
|
||||
fn model_changed_dropped_when_model_unknown_to_catalog() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-99-unknown", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"unknown model must NOT trigger a redraw — no state changed"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"models.current must stay on the previously-known model"
|
||||
);
|
||||
}
|
||||
|
||||
/// `reasoning_effort` round-trips through the broadcast: the follower
|
||||
/// applies it alongside the model id so the prompt header / status bar
|
||||
/// show the right effort without waiting for a subsequent
|
||||
/// `x.ai/models/update`.
|
||||
#[test]
|
||||
fn model_changed_applies_reasoning_effort_on_follower() {
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", Some("high"));
|
||||
assert!(handle_ext_notification(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::High),
|
||||
"follower must mirror the broadcast's reasoning_effort"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ModelChanged` for a session this client doesn't own / hasn't loaded
|
||||
/// must be dropped — `find_session_match` returns `None`. The bug-flavored
|
||||
/// version of this would be: leader-mode A switches model on session X
|
||||
/// (which this client never opened) and we accidentally apply the change
|
||||
/// to the active agent.
|
||||
#[test]
|
||||
fn model_changed_dropped_for_unknown_session_id() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-OTHER", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"unrelated-session broadcast must not touch this agent's model"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// The permission prompt must surface the payload an MCP call would
|
||||
/// send — both `UseTool` (meta-dispatch) and `MCPTool` (natively
|
||||
/// registered) raw_input shapes.
|
||||
#[test]
|
||||
fn mcp_args_lines_extracts_planned_tool_input() {
|
||||
for variant in ["UseTool", "MCPTool"] {
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": variant,
|
||||
"tool_name": "jira__AddjiraComment",
|
||||
"tool_input": {"issue": "ABC-123", "body": "hello"},
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
let joined = lines.join("\n");
|
||||
assert!(
|
||||
joined.contains("\"issue\": \"ABC-123\""),
|
||||
"{variant}: {joined}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains("\"body\": \"hello\""),
|
||||
"{variant}: {joined}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-MCP raw_input (bash, edit, gateway `{command}` shapes) must not
|
||||
/// grow a JSON dump — those prompts have dedicated displays.
|
||||
#[test]
|
||||
fn mcp_args_lines_empty_for_non_mcp_shapes() {
|
||||
for raw in [
|
||||
None,
|
||||
Some(serde_json::json!({"variant": "Bash", "command": "ls", "description": "d"})),
|
||||
Some(serde_json::json!({"command": "rm -rf /"})),
|
||||
Some(serde_json::json!({"file_path": "/tmp/x"})),
|
||||
Some(serde_json::json!("not-an-object")),
|
||||
] {
|
||||
let req = permission_req_with_raw_input(raw.clone());
|
||||
assert!(
|
||||
mcp_args_lines(&req).is_empty(),
|
||||
"expected empty for {raw:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tool_input` that is missing or JSON null renders nothing rather
|
||||
/// than a misleading `null`.
|
||||
#[test]
|
||||
fn mcp_args_lines_empty_for_missing_or_null_input() {
|
||||
for raw in [
|
||||
serde_json::json!({"variant": "UseTool", "tool_name": "t"}),
|
||||
serde_json::json!({"variant": "UseTool", "tool_name": "t", "tool_input": null}),
|
||||
] {
|
||||
let req = permission_req_with_raw_input(Some(raw));
|
||||
assert!(mcp_args_lines(&req).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A pathological single-line value (e.g. an embedded base64 blob) is
|
||||
/// elided at `MCP_ARGS_MAX_LINE_CHARS` so per-frame wrap cost stays
|
||||
/// bounded. Uses a multi-byte char to pin char (not byte) slicing.
|
||||
#[test]
|
||||
fn mcp_args_lines_caps_line_length() {
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": "UseTool",
|
||||
"tool_name": "t",
|
||||
"tool_input": {"blob": "é".repeat(MCP_ARGS_MAX_LINE_CHARS * 2)},
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
let long = lines
|
||||
.iter()
|
||||
.find(|l| l.contains("é"))
|
||||
.expect("blob line present");
|
||||
assert_eq!(long.chars().count(), MCP_ARGS_MAX_LINE_CHARS + 1);
|
||||
assert!(long.ends_with('…'));
|
||||
}
|
||||
|
||||
/// Pathologically large payloads are capped in storage with an explicit
|
||||
/// hidden-line count (the overlay clips further at render time).
|
||||
#[test]
|
||||
fn mcp_args_lines_caps_stored_lines() {
|
||||
let big: serde_json::Map<String, serde_json::Value> = (0..MCP_ARGS_MAX_LINES + 50)
|
||||
.map(|i| (format!("k{i:04}"), serde_json::Value::from(i)))
|
||||
.collect();
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": "UseTool",
|
||||
"tool_name": "t",
|
||||
"tool_input": big,
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
assert_eq!(lines.len(), MCP_ARGS_MAX_LINES + 1);
|
||||
let last = lines.last().unwrap();
|
||||
assert!(
|
||||
last.starts_with("… (+") && last.ends_with(" more lines)"),
|
||||
"unexpected tail: {last}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Manual recap with an uncommitted in-flight spinner: filled in place
|
||||
/// (no second block), animation stopped.
|
||||
#[test]
|
||||
fn recap_fills_uncommitted_spinner_in_place() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
|
||||
apply_recap_block(&mut agent, false, recap_block("THE RECAP"));
|
||||
|
||||
assert_eq!(agent.scrollback.len(), 1, "filled in place, not appended");
|
||||
let entry = agent.scrollback.get_by_id(spinner).expect("entry kept");
|
||||
assert!(!entry.is_running, "spinner animation stopped");
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
}
|
||||
|
||||
/// Regression (minimal mode): the spinner was already committed into
|
||||
/// native scrollback (print-once) — an in-place fill would never reach the
|
||||
/// terminal. The stale committed entry is dropped from state and the recap
|
||||
/// appended as a fresh (uncommitted) block so the commit pass prints it.
|
||||
#[test]
|
||||
fn recap_reprints_fresh_block_when_spinner_already_committed() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
// The minimal idle commit pass consumed the spinner.
|
||||
agent.scrollback.finish_running(spinner);
|
||||
agent.scrollback.mark_committed(0);
|
||||
agent.scrollback.set_commit_scan_cursor(1);
|
||||
assert!(agent.scrollback.is_committed(spinner));
|
||||
|
||||
apply_recap_block(&mut agent, false, recap_block("THE RECAP"));
|
||||
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
1,
|
||||
"stale committed spinner dropped, fresh block appended"
|
||||
);
|
||||
let fresh = agent.scrollback.get(0).expect("fresh block");
|
||||
assert_ne!(fresh.id, spinner, "a NEW entry, not the committed one");
|
||||
assert!(
|
||||
!agent.scrollback.is_committed(fresh.id),
|
||||
"fresh block is uncommitted so the commit pass will print it"
|
||||
);
|
||||
}
|
||||
|
||||
/// An automatic recap never consumes the manual loading slot — it always
|
||||
/// appends its own block and leaves the pending spinner alone.
|
||||
#[test]
|
||||
fn auto_recap_appends_and_leaves_manual_spinner_pending() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
|
||||
apply_recap_block(&mut agent, true, recap_block("AUTO RECAP"));
|
||||
|
||||
assert_eq!(agent.scrollback.len(), 2, "auto recap appended");
|
||||
assert_eq!(agent.pending_recap_entry, Some(spinner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_auto_recap_dropped_when_agent_not_idle() {
|
||||
assert!(should_drop_late_auto_recap(true, false, false));
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(true, false, true),
|
||||
"idle agent: show auto recap"
|
||||
);
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(false, false, false),
|
||||
"manual /recap always shown"
|
||||
);
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(true, true, false),
|
||||
"history replay rebuilds scrollback even mid-turn"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_auto_opens_inline_cursor_plan_preview() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# Cursor Plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_keeps_inline_plan_preview_available() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# First Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|s| s.source),
|
||||
Some(crate::views::plan_approval_view::PlanReviewSource::Inline)
|
||||
);
|
||||
agent.line_viewer = None;
|
||||
agent.show_plan_preview();
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# First Plan")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_without_inline_content_uses_file_backed_source() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext(Some("# File Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(agent.latest_inline_plan_content.is_none());
|
||||
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|s| s.source),
|
||||
Some(crate::views::plan_approval_view::PlanReviewSource::FileBacked)
|
||||
);
|
||||
// File-backed bodies still open via request plan_content even when
|
||||
// plan.md is not on disk under the agent's cwd.
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# File Plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_empty_opens_placeholder_preview() {
|
||||
// Empty plan.md must still surface a decision UI — otherwise the user
|
||||
// only sees "Waiting on plan approval" with a dead Tab:plan and thinks
|
||||
// the session is stuck.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext(None);
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
||||
let pav = agent
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.expect("plan_approval_view must be set");
|
||||
assert!(!pav.has_plan);
|
||||
assert_eq!(
|
||||
pav.focus,
|
||||
crate::views::plan_approval_view::PlanApprovalFocus::Preview,
|
||||
"empty approval must keep Preview focus once the placeholder opens"
|
||||
);
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some(crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_dismisses_open_modal() {
|
||||
// Regression: if the user has Ctrl+P command palette open when the
|
||||
// agent calls exit_plan_mode, the modal must be dismissed so the
|
||||
// plan preview is visible and input routes correctly. Otherwise the
|
||||
// modal hides the line viewer in draw order while input gets
|
||||
// routed to the invisible line viewer, leaving the user stuck.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.active_modal.is_none(),
|
||||
"exit_plan_mode must dismiss the open modal so the plan preview is visible"
|
||||
);
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert!(agent.line_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_dismisses_open_block_viewer() {
|
||||
// Regression: if the user has an Edit/tool block_viewer open when
|
||||
// exit_plan_mode opens, dismiss it so wheel scroll reaches the plan
|
||||
// line_viewer. Draw returns on line_viewer (plan visible) but
|
||||
// handle_scroll prefers block_viewer while it remains in state.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
|
||||
"edit",
|
||||
"diff content",
|
||||
));
|
||||
}
|
||||
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.block_viewer.is_none(),
|
||||
"exit_plan_mode must dismiss open block_viewer so the plan can scroll"
|
||||
);
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert!(agent.line_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_empty_exit_plan_request_clears_stale_inline_plan() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (first, _first_rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# First Plan"));
|
||||
let (second, _second_rx) = make_exit_plan_ext(None);
|
||||
|
||||
assert!(handle_exit_plan_mode(first, &mut app));
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.latest_inline_plan_content.as_deref(),
|
||||
Some("# First Plan")
|
||||
);
|
||||
}
|
||||
assert!(handle_exit_plan_mode(second, &mut app));
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(agent.latest_inline_plan_content.is_none());
|
||||
// Empty approval still opens the placeholder decision surface (not a
|
||||
// silent "no plan" toast) so the user always sees a way to proceed.
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some(crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_shows_overlay() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
assert!(!app.agents.get(&AgentId(0)).unwrap().session.is_yolo());
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-normal".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(affected, "opening the overlay should need a redraw");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must be set for interactive approval"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT have been sent yet (waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_shows_overlay_even_in_yolo() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-yolo".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(affected, "overlay should open even in yolo mode");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must be set even in always-approve mode"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT have been sent yet (waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_routes_to_background_session_not_active_view() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-B".into(),
|
||||
tool_call_id: "tc-bg-plan".into(),
|
||||
plan_content: Some("# Plan".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"a background-session plan approval must not redraw the active view"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.plan_approval_view
|
||||
.is_some(),
|
||||
"plan approval must be parked on the session that asked (background agent B)"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.plan_approval_view
|
||||
.is_none(),
|
||||
"plan approval must NOT land on the unrelated active agent A"
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "response must NOT be sent yet");
|
||||
}
|
||||
|
||||
/// Regression: tool-call titles containing `"enter_plan_mode"` must not
|
||||
/// flip plan mode (the substring matcher used to brick sessions on any
|
||||
/// tool mentioning the phrase, e.g. a Grep with that pattern).
|
||||
#[test]
|
||||
fn tool_call_with_enter_plan_mode_substring_does_not_activate_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
assert!(!agent.plan_mode_active);
|
||||
|
||||
let updates = [
|
||||
make_tool_call("enter_plan_mode"),
|
||||
make_tool_call_update("enter_plan_mode"),
|
||||
make_tool_call("Execute `rg enter_plan_mode`"),
|
||||
make_tool_call_update("Execute `rg enter_plan_mode`"),
|
||||
make_tool_call_update("Plan mode entered"),
|
||||
make_tool_call("mcp__foo__enter_plan_mode"),
|
||||
];
|
||||
for update in &updates {
|
||||
let refresh_needed = detect_plan_mode_change(update, &mut agent);
|
||||
assert!(
|
||||
!refresh_needed,
|
||||
"tool-call title (not a CurrentModeUpdate) must not request refresh"
|
||||
);
|
||||
assert!(
|
||||
!agent.plan_mode_active,
|
||||
"tool-call title must not flip plan mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric: tool-call titles containing `"exit_plan_mode"` must not
|
||||
/// deactivate plan mode either. Exit is signaled by `CurrentModeUpdate`.
|
||||
#[test]
|
||||
fn tool_call_with_exit_plan_mode_substring_does_not_deactivate_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
|
||||
let updates = [
|
||||
make_tool_call("exit_plan_mode"),
|
||||
make_tool_call_update("exit_plan_mode"),
|
||||
make_tool_call_update("Plan mode exited"),
|
||||
make_tool_call("Execute `rg exit_plan_mode`"),
|
||||
];
|
||||
for update in &updates {
|
||||
let refresh_needed = detect_plan_mode_change(update, &mut agent);
|
||||
assert!(!refresh_needed);
|
||||
assert!(
|
||||
agent.plan_mode_active,
|
||||
"tool-call title must not flip plan mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_mode_update_plan_activates_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
assert!(!agent.plan_mode_active);
|
||||
|
||||
let refresh_needed = detect_plan_mode_change(&make_current_mode_update("plan"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_mode_update_default_deactivates_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
agent.plan_mode_pending = Some(true);
|
||||
|
||||
let refresh_needed =
|
||||
detect_plan_mode_change(&make_current_mode_update("default"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(!agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
/// Unknown mode ids (e.g. a custom agent definition name like
|
||||
/// `"browser_use"`) parse to `SessionMode::Default` and deactivate
|
||||
/// plan mode.
|
||||
#[test]
|
||||
fn current_mode_update_unknown_id_treated_as_default() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
|
||||
let refresh_needed =
|
||||
detect_plan_mode_change(&make_current_mode_update("browser_use"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(!agent.plan_mode_active);
|
||||
}
|
||||
|
||||
/// Idempotent CurrentModeUpdate still signals refresh because
|
||||
/// `plan_mode_pending` was cleared (affects effective state).
|
||||
#[test]
|
||||
fn current_mode_update_signals_refresh_even_on_no_op_active_change() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
agent.plan_mode_pending = Some(true);
|
||||
|
||||
let refresh_needed = detect_plan_mode_change(&make_current_mode_update("plan"), &mut agent);
|
||||
assert!(
|
||||
refresh_needed,
|
||||
"CurrentModeUpdate must always signal refresh — pending was cleared"
|
||||
);
|
||||
assert!(agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugins_changed_update_refreshes_data_without_reseeding_collapse_state() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
|
||||
let mut app = make_app_with_agent("sess-plugins");
|
||||
let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
|
||||
modal.plugins_data =
|
||||
TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins: vec![] });
|
||||
modal.plugins_groups_seeded = true;
|
||||
modal
|
||||
.plugins_collapsed_groups
|
||||
.insert("origin:user-claude".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().extensions_modal = Some(modal);
|
||||
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
)],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => {
|
||||
assert_eq!(response.plugins.len(), 1);
|
||||
assert_eq!(response.plugins[0].name, "user-tool");
|
||||
}
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from(["origin:user-claude".to_string()]),
|
||||
"live PluginsChanged refresh must not touch collapse state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_changed_seeds_collapse_when_it_wins_the_first_load_race() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
|
||||
// Fresh modal: the push lands before the initial list fetch returns.
|
||||
let mut app = make_app_with_agent("sess-plugins");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().extensions_modal =
|
||||
Some(ExtensionsModalState::new(ExtensionsTab::Plugins));
|
||||
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![
|
||||
crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
),
|
||||
crate::views::extensions_modal::test_plugin_info(
|
||||
"claude-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserClaude),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from([
|
||||
"origin:user".to_string(),
|
||||
"origin:user-claude".to_string()
|
||||
]),
|
||||
"push winning the first-load race must seed the collapsed default"
|
||||
);
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => assert_eq!(response.plugins.len(), 2),
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
|
||||
// The push counts as the one seeding: expand a group, deliver again,
|
||||
// and the expansion must survive.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.plugins_collapsed_groups
|
||||
.remove("origin:user");
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
)],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from(["origin:user-claude".to_string()]),
|
||||
"deliveries after the push-seed must preserve expand state"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,492 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_happy_path_enqueues_and_drains() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "task-42",
|
||||
"prompt": "/pr-babysit check",
|
||||
"humanSchedule": "every 5m",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Agent should now be in TurnRunning (drain happened, prompt was sent).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// Scrollback should have a cron prompt block.
|
||||
assert!(!agent.scrollback.is_empty());
|
||||
|
||||
// pending_effects should contain a SendPromptBlocks with system-reminder framing,
|
||||
// displayText/displayAsCron meta, and a scheduler-fired- prompt_id prefix.
|
||||
match &app.pending_effects[0] {
|
||||
Effect::SendPromptBlocks {
|
||||
blocks, prompt_id, ..
|
||||
} => {
|
||||
let text = match &blocks[0] {
|
||||
acp::ContentBlock::Text(t) => &t.text,
|
||||
_ => panic!("expected Text block"),
|
||||
};
|
||||
assert!(
|
||||
text.contains("<system-reminder>"),
|
||||
"missing system-reminder framing"
|
||||
);
|
||||
assert!(text.contains("task-42"), "missing task_id");
|
||||
assert!(text.contains("every 5m"), "missing schedule");
|
||||
assert!(text.contains("/pr-babysit check"), "missing prompt");
|
||||
assert!(
|
||||
prompt_id.starts_with("scheduler-fired-"),
|
||||
"cron prompt_id must start with 'scheduler-fired-' for data pipeline tagging, got: {prompt_id}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SendPromptBlocks, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_drives_even_when_attached_as_viewer() {
|
||||
// The leader routes `x.ai/scheduled_task_inject_prompt` to the SINGLE
|
||||
// session driver, so any client that receives it IS the driver and must
|
||||
// enqueue + run it — even one that attached via `session/load`
|
||||
// (`attached_as_viewer == true`). Previously this handler latched on
|
||||
// `attached_as_viewer` and skipped, which stranded the cron loop with no
|
||||
// output whenever the designated driver was an attacher (the sticky-flag
|
||||
// bug). Pin the corrected behavior: the inject drives the turn.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "task-42",
|
||||
"prompt": "echo hello",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
let result = handle_scheduled_task_inject_prompt(&make_inject_notif(&payload), &mut app);
|
||||
assert!(result);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.state.is_turn_running(),
|
||||
"the designated driver must start the cron turn even when it attached as a viewer"
|
||||
);
|
||||
assert!(
|
||||
agent.session.pending_prompts.is_empty(),
|
||||
"the cron prompt must drain (not linger queued) on the driver"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
app.pending_effects.first(),
|
||||
Some(Effect::SendPromptBlocks { .. })
|
||||
),
|
||||
"the driver must emit a SendPromptBlocks effect for the cron turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_malformed_json_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let raw = serde_json::value::to_raw_value(&"not a json object").unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/scheduled_task_inject_prompt", raw.into());
|
||||
|
||||
// The JSON is valid (a string), but sessionId/prompt fields won't exist.
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_empty_prompt_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"prompt": "",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Nothing should be enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_no_matching_agent_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-other",
|
||||
"prompt": "do something",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Agent should still be idle, nothing enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_busy_agent_enqueues_without_draining() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Make the agent busy.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"prompt": "/pr-babysit check",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Prompt should be queued but not drained (agent was busy).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||||
assert_eq!(
|
||||
agent.session.pending_prompts[0].kind,
|
||||
crate::app::agent::QueueEntryKind::Cron
|
||||
);
|
||||
// No effects produced (drain was a no-op since agent was busy).
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_skips_duplicate_while_queued() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Busy with an unrelated turn so the cron prompt queues instead of draining.
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "loop-7",
|
||||
"prompt": "check deploy",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// A re-fire of the same task while it is still queued must not pile up.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.len(),
|
||||
1,
|
||||
"a re-fire of the same loop must dedupe in the queue, not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_skips_duplicate_while_running() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "loop-7",
|
||||
"prompt": "check deploy",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
// First fire on an idle agent drains into a running cron turn.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// A re-fire while that same loop turn is running must be skipped, not queued.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.is_empty(),
|
||||
"a re-fire while the loop turn runs must not enqueue a duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_missing_session_id_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"prompt": "do something",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_known_task_updates_next_fire_at_only() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let original_created_at = Instant::now() - std::time::Duration::from_secs(60);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-1".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-1".into(),
|
||||
prompt: "original prompt".into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
created_at: original_created_at,
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-1",
|
||||
// Different field values to verify they are NOT copied over.
|
||||
"DIFFERENT",
|
||||
"every 1 hour",
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-1").unwrap();
|
||||
assert_eq!(info.prompt, "original prompt", "prompt must not change");
|
||||
assert_eq!(
|
||||
info.human_schedule, "every 5 minutes",
|
||||
"human_schedule must not change"
|
||||
);
|
||||
assert_eq!(
|
||||
info.created_at, original_created_at,
|
||||
"created_at must not change"
|
||||
);
|
||||
assert_eq!(
|
||||
info.next_fire_at.as_deref(),
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
"next_fire_at must be updated"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.session.scheduled_tasks.len(),
|
||||
1,
|
||||
"no extra entry should be inserted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_unknown_task_inserts_entry_from_payload() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let before = Instant::now();
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-new",
|
||||
"scheduled prompt",
|
||||
"every 10 minutes",
|
||||
Some("2026-03-03T03:03:03Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.scheduled_tasks.len(), 1);
|
||||
let info = agent.session.scheduled_tasks.get("task-new").unwrap();
|
||||
assert_eq!(info.task_id, "task-new");
|
||||
assert_eq!(info.prompt, "scheduled prompt");
|
||||
assert_eq!(info.human_schedule, "every 10 minutes");
|
||||
assert_eq!(info.next_fire_at.as_deref(), Some("2026-03-03T03:03:03Z"));
|
||||
assert!(
|
||||
info.created_at >= before && info.created_at <= Instant::now(),
|
||||
"created_at should be set to roughly now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_unknown_task_with_none_next_fire_skips_insert() {
|
||||
// Mirrors handle_missed_tasks output: a missed one-shot fires with
|
||||
// next_fire_at: None and is immediately removed. The pane should not
|
||||
// flicker an entry that the Removed will instantly drop.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"missed-1",
|
||||
"missed prompt",
|
||||
"every 1 minute",
|
||||
None,
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.scheduled_tasks.is_empty(),
|
||||
"no entry should be inserted for a Vacant + None fire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_known_task_with_none_next_fire_clears_field() {
|
||||
// The Vacant short-circuit on next_fire_at: None must NOT apply to
|
||||
// Occupied — clearing an existing countdown is correct behaviour.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-1".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-1".into(),
|
||||
prompt: "p".into(),
|
||||
human_schedule: "every 1 minute".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let notif = make_fired_notif("sess-1", "task-1", "p", "every 1 minute", None);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-1").unwrap();
|
||||
assert!(info.next_fire_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed a known task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
"task-owner".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-owner".into(),
|
||||
prompt: "check PR".into(),
|
||||
human_schedule: "every 5m".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Fire notification targets agent 0's session, but active_view
|
||||
// points to agent 1. Return value is "needs redraw" — false is
|
||||
// correct when the mutated agent is not the active view.
|
||||
let notif = make_fired_notif(
|
||||
"sess-owner",
|
||||
"task-owner",
|
||||
"check PR",
|
||||
"every 5m",
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
let needs_redraw = handle_scheduled_task_fired(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
// Agent 0's next_fire_at must be updated.
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent0.session.scheduled_tasks.get("task-owner").unwrap();
|
||||
assert_eq!(
|
||||
info.next_fire_at.as_deref(),
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
"next_fire_at must update on the owning agent, not the active one"
|
||||
);
|
||||
|
||||
// Agent 1 must be completely untouched.
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
"non-owning agent must not receive the update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
let notif = make_created_ext_notif(
|
||||
"sess-owner",
|
||||
"task-new",
|
||||
"check PR",
|
||||
"every 5m",
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
let needs_redraw = handle_scheduled_task_created(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent0.session.scheduled_tasks.contains_key("task-new"),
|
||||
"task must be created on the owning agent"
|
||||
);
|
||||
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
"non-owning agent must not receive the task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_removes_from_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
"task-rm".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-rm".into(),
|
||||
prompt: "check PR".into(),
|
||||
human_schedule: "every 5m".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_deleted_ext_notif("sess-owner", "task-rm");
|
||||
let needs_redraw = handle_scheduled_task_deleted(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent0.session.scheduled_tasks.is_empty(),
|
||||
"task must be removed from the owning agent"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── apply_session_event ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_started_sets_activity() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "hi".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(1),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
percentage: 85,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"compaction start implies server activity — cancel must not rewind prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ImageDropped` joins notes with `\n` and pushes a system block.
|
||||
/// Pin the `\n` separator so a `notes.join(" ")` regression is caught.
|
||||
#[test]
|
||||
fn apply_image_dropped_pushes_scrollback_block() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let before = scrollback.len();
|
||||
let notes = vec![
|
||||
"Image 1 was dropped: corrupt.".to_string(),
|
||||
"Image 2 was dropped: too small (4×3).".to_string(),
|
||||
];
|
||||
let update = XaiSessionUpdate::ImageDropped {
|
||||
notes: notes.clone(),
|
||||
};
|
||||
let changed = apply_session_event(&update, &mut session, &mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(changed);
|
||||
assert_eq!(scrollback.len(), before + 1);
|
||||
let entry = scrollback.entries_mut().last().expect("entry pushed");
|
||||
match &entry.block {
|
||||
RenderBlock::System(b) => {
|
||||
assert!(b.text.contains(¬es[0]));
|
||||
assert!(b.text.contains(¬es[1]));
|
||||
assert!(
|
||||
b.text.contains('\n'),
|
||||
"expected \\n separator between dropped notes, got: {:?}",
|
||||
b.text
|
||||
);
|
||||
}
|
||||
other => panic!("expected System block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A successful compression needs no user action: log-only — no toast,
|
||||
/// no scrollback block, no redraw. Same live and on session replay.
|
||||
#[test]
|
||||
fn image_compressed_is_invisible_in_tui() {
|
||||
for replay in [false, true] {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.session.loading_replay = replay;
|
||||
assert!(!apply_image_compressed(
|
||||
&mut agent,
|
||||
&[compressed_entry(1), compressed_entry(2)],
|
||||
"Compressed Image 1: 4.2 MB (3024x1964) \u{2192} 780 KB (1568x1018)",
|
||||
));
|
||||
assert!(agent.toast.is_none(), "no toast (replay={replay})");
|
||||
assert_eq!(agent.scrollback.len(), 0, "no block (replay={replay})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The re-encode fallback (empty `images`) means the oversized original
|
||||
/// was kept — a persistent warning line, not a transient toast.
|
||||
#[test]
|
||||
fn image_compressed_fallback_warning_stays_in_scrollback() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let msg = "Image 1 could not be re-encoded under the 1.5 MB limit; the original attachment was kept.";
|
||||
assert!(apply_image_compressed(&mut agent, &[], msg));
|
||||
assert!(agent.toast.is_none(), "warning must not be transient");
|
||||
let entry = agent.scrollback.entries_mut().last().expect("block pushed");
|
||||
match &entry.block {
|
||||
RenderBlock::System(b) => assert_eq!(b.text, msg),
|
||||
other => panic!("expected System block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_retrying_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "retry me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
let retry = RetryState::Retrying {
|
||||
attempt: 1,
|
||||
max_retries: 3,
|
||||
reason: "rate limited".into(),
|
||||
};
|
||||
apply_retry_state(&retry, &mut session, &mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"RetryState bypasses session/update in_flight hook"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_rate_limited_sets_flag() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
assert!(!session.rate_limited);
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "rate limited".into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"rate_limited flag must be set when is_rate_limited is true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_rate_limited_message_is_auth_aware() {
|
||||
use kigi_shell::sampling::error::{
|
||||
RATE_LIMITED_USER_MESSAGE_API_KEY, RATE_LIMITED_USER_MESSAGE_OAUTH,
|
||||
};
|
||||
|
||||
let exhausted = RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "rate limited".into(),
|
||||
is_rate_limited: true,
|
||||
};
|
||||
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, false);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH);
|
||||
}
|
||||
other => panic!("expected OAuth rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, true);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_API_KEY);
|
||||
}
|
||||
other => panic!("expected API-key rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_non_rate_limited_does_not_set_flag() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "server error".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.rate_limited,
|
||||
"rate_limited flag must not be set when is_rate_limited is false"
|
||||
);
|
||||
}
|
||||
|
||||
/// A rate-limit exhaustion whose flattened reason carries the
|
||||
/// free-usage code sets both flags and pushes NO generic block (the
|
||||
/// driver shows the paywall modal on PromptResponse; viewers keep no
|
||||
/// marker).
|
||||
#[test]
|
||||
fn retry_exhausted_free_usage_sets_paywall_flag_without_marker() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "try me again".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 0,
|
||||
reason: "API error (status 429 Too Many Requests): \
|
||||
subscription:free-usage-exhausted: You have used all your free usage."
|
||||
.into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"free-usage keeps rate_limited (TurnFailed/toast suppression)"
|
||||
);
|
||||
assert!(session.free_usage_blocked);
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"no RetryFailed marker — the paywall modal shows instead"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"free-usage exhaustion clears in_flight_prompt like other failures"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_exhausted_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "status 403: run out of credits".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_failed_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me too".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(3),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message: "status 403: run out of credits".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_pool_402_sets_credit_limit_blocked() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "pool blocked".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message:
|
||||
"API error (status 402 Payment Required): Grok Build usage balance exhausted"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for pool 402 balance exhausted"
|
||||
);
|
||||
assert!(session.in_flight_prompt.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_non_credit_limit_failed_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "gone".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(4),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "server_error".into(),
|
||||
message: "internal server error".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.credit_limit_blocked,
|
||||
"credit_limit_blocked must NOT be set for non-credit-limit errors"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"in_flight_prompt must be cleared for non-credit-limit errors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_reauthable_failure_matrix() {
|
||||
assert!(is_reauthable_failure(Some("auth"), "Unauthorized (401)"));
|
||||
assert!(is_reauthable_failure(
|
||||
Some("api"),
|
||||
"Unauthorized (401) from https://proxy/v1/responses"
|
||||
));
|
||||
assert!(is_reauthable_failure(None, "Unauthorized (401)"));
|
||||
// legacy_auth carries its own migration guidance — excluded.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("legacy_auth"),
|
||||
"Unauthorized (401) ... deprecated authentication method"
|
||||
));
|
||||
// Unrelated failures must not be treated as re-authable.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("server_error"),
|
||||
"internal server error"
|
||||
));
|
||||
assert!(!is_reauthable_failure(Some("api"), "model not found"));
|
||||
}
|
||||
|
||||
/// A 401 with `error_type == "auth"` surfaces the actionable re-auth
|
||||
/// prompt instead of the raw "Retry failed: Unauthorized (401) …" dump.
|
||||
#[test]
|
||||
fn apply_retry_state_auth_failure_pushes_reauth_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "auth".into(),
|
||||
message: "Unauthorized (401) from https://cli-chat-proxy.kigi.com/v1/messages: \
|
||||
no auth context"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ReAuthRequired)
|
||||
),
|
||||
"auth 401 must surface the actionable re-auth prompt"
|
||||
);
|
||||
assert!(!session.credit_limit_blocked);
|
||||
}
|
||||
|
||||
/// A recoverable auth failure preserves `in_flight_prompt` so the
|
||||
/// PromptResponse handler can stash it for auto-resubmit after re-auth.
|
||||
#[test]
|
||||
fn apply_retry_state_auth_failure_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "retry after login".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "auth".into(),
|
||||
message: "Unauthorized (401) from https://proxy/v1/messages".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved on a recoverable auth failure"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "retry after login");
|
||||
}
|
||||
|
||||
/// A 401 reported with a non-auth `error_type` but an "Unauthorized
|
||||
/// (401)" message (the `SamplingErrorKind::Api` path) also prompts.
|
||||
#[test]
|
||||
fn apply_retry_state_401_message_without_auth_type_prompts_reauth() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "api".into(),
|
||||
message: "Unauthorized (401) from https://proxy/v1/responses: invalid credentials"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ReAuthRequired)
|
||||
));
|
||||
}
|
||||
|
||||
/// Legacy WebLogin auth keeps its verbose message (with `grok logout` /
|
||||
/// `grok login` guidance), not the generic re-auth prompt.
|
||||
#[test]
|
||||
fn apply_retry_state_legacy_auth_keeps_detailed_message() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "legacy_auth".into(),
|
||||
message: "Unauthorized (401) ... deprecated authentication method (WebLogin) ... \
|
||||
run `grok logout` then `grok login`"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::RetryFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Non-auth terminal failures still render the standard RetryFailed.
|
||||
#[test]
|
||||
fn apply_retry_state_generic_failure_still_shows_retry_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "server_error".into(),
|
||||
message: "internal server error".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::RetryFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// A context overflow surfaces the actionable `ContextTooLarge` prompt (not the
|
||||
/// raw `RetryFailed`); `PromptResponse` then suppresses the redundant `TurnFailed`.
|
||||
#[test]
|
||||
fn apply_retry_state_context_length_shows_context_too_large() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "context_length".into(),
|
||||
message: "API error (status 500): the prompt is too long for this model's \
|
||||
context window"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ContextTooLarge)
|
||||
),
|
||||
"context overflow must surface the actionable ContextTooLarge prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the compaction handler already showed its "too large to compact" message,
|
||||
/// the overflow path does NOT stack a second `ContextTooLarge` prompt on top.
|
||||
#[test]
|
||||
fn apply_retry_state_context_length_does_not_duplicate_compaction_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
scrollback.push_block(RenderBlock::session_event(SessionEvent::CompactionFailed {
|
||||
error: "this conversation is too large to compact.".into(),
|
||||
}));
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "context_length".into(),
|
||||
message: "the prompt is too long for this model's context window".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::CompactionFailed { .. })
|
||||
),
|
||||
"must not push a duplicate prompt on top of CompactionFailed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_defers_message_until_turn_end() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(858_000),
|
||||
tokens_after: 66_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"live compaction completion must be deferred, not pushed immediately"
|
||||
);
|
||||
|
||||
session.note_context_used(43_000);
|
||||
|
||||
session.finish_turn(&mut scrollback);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted {
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(tokens_before, Some(858_000));
|
||||
assert_eq!(
|
||||
tokens_after, 43_000,
|
||||
"must flush the model-confirmed count, not the 66k estimate"
|
||||
);
|
||||
}
|
||||
other => panic!("expected deferred CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_falls_back_to_estimate_without_confirmation() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 20_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
session.finish_turn(&mut scrollback);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => {
|
||||
assert_eq!(
|
||||
tokens_after, 20_000,
|
||||
"fallback to estimate when unconfirmed"
|
||||
);
|
||||
}
|
||||
other => panic!("expected fallback CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_renders_immediately_during_replay() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
session.loading_replay = true;
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 20_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => {
|
||||
assert_eq!(
|
||||
tokens_after, 20_000,
|
||||
"replay renders the recorded count immediately"
|
||||
);
|
||||
}
|
||||
other => panic!("expected immediate CompactionCompleted on replay, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_compaction_flushes_confirmed_count_over_estimate_refresh() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent
|
||||
.session
|
||||
.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(858_000),
|
||||
tokens_after: 66_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(
|
||||
&update,
|
||||
&mut agent.session,
|
||||
&mut agent.scrollback,
|
||||
false,
|
||||
));
|
||||
|
||||
refresh_context_used(&mut agent, 66_000);
|
||||
confirm_context_used(&mut agent, 43_000);
|
||||
|
||||
agent.session.finish_turn(&mut agent.scrollback);
|
||||
match last_session_event(&agent.scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted {
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(tokens_before, Some(858_000));
|
||||
assert_eq!(
|
||||
tokens_after, 43_000,
|
||||
"deferred line must flush the confirmed 43k, not the 66k \
|
||||
estimate refresh that updated the bar first"
|
||||
);
|
||||
}
|
||||
other => panic!("expected deferred CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_unhandled_event_returns_false() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::MemoryFlushStarted;
|
||||
assert!(!apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
}
|
||||
|
||||
// ── handle_child_session_notification ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_updates_subagent_info() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-1";
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
let child_view = make_agent(Some(child_sid));
|
||||
agent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(child_view));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90000),
|
||||
tokens_after: 25000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
};
|
||||
let changed = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
assert!(changed);
|
||||
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.tokens_used, Some(25000));
|
||||
// 25000 / 131072 * 100 ~= 19
|
||||
assert_eq!(info.context_usage_pct, Some(19));
|
||||
|
||||
// The child view's context_state.used (context-bar numerator) must
|
||||
// also be reset — see the comment in handle_child_session_notification.
|
||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.context_state.as_ref().map(|c| c.used),
|
||||
Some(25000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_compact_started_does_not_reset_context_used() {
|
||||
// Sibling variants in the same outer arm must not touch the numerator;
|
||||
// guards against accidental widening of the AutoCompactCompleted gate.
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-3";
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
let mut child_view = make_agent(Some(child_sid));
|
||||
child_view.context_state = Some(kigi_shell::session::ContextInfo::from_notification(
|
||||
90_000, 131_072,
|
||||
));
|
||||
agent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(child_view));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 95_000,
|
||||
context_window: 131_072,
|
||||
percentage: 72,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
let _ = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
|
||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.context_state.as_ref().map(|c| c.used),
|
||||
Some(90_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_notification_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
// No child view registered.
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
percentage: 85,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
let changed = handle_child_session_notification(update, "unknown-child", &mut agent, false);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-2";
|
||||
// SubagentInfo exists but no child view (race between notification and spawn).
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90000),
|
||||
tokens_after: 25000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
};
|
||||
let changed = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
// No child_view means nothing visible changed — must not trigger redraw.
|
||||
assert!(!changed);
|
||||
// SubagentInfo should still be updated (data correctness).
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.tokens_used, Some(25000));
|
||||
assert_eq!(info.context_usage_pct, Some(19));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_unknown_event_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let update = XaiSessionUpdate::MemoryFlushStarted;
|
||||
let changed = handle_child_session_notification(update, "child-1", &mut agent, false);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
// ── apply_retry_state ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn retry_failed_encrypted_content_sets_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
assert!(!session.model_incompatible);
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "encrypted_content_mismatch".into(),
|
||||
message: "incompatible history".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.model_incompatible,
|
||||
"encrypted_content_mismatch should set model_incompatible flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_failed_other_type_does_not_set_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "api_400".into(),
|
||||
message: "bad request".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.model_incompatible,
|
||||
"non-encrypted_content error types must not set model_incompatible"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_inactive_agent_lands_in_its_scrollback() {
|
||||
// Regression: switching away from a streaming agent must not
|
||||
// discard chunks bound for that agent. Before this fix, only
|
||||
// `TaskResult::PromptResponse` survived, so the user saw a bare
|
||||
// "Worked for X.Xs" with no body text.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(make_agent_chunk_message("sess-A", "hello from A"), &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_message_text(agent_a),
|
||||
"hello from A",
|
||||
"chunk for inactive agent A must land in A's scrollback"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"chunk routed to a non-active agent must not request a redraw"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent_b.scrollback.is_empty(),
|
||||
"active agent B's scrollback must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_active_agent_returns_affected_true() {
|
||||
// Baseline: chunk for the visible agent triggers a redraw.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(make_agent_chunk_message("sess-B", "hello from B"), &mut app);
|
||||
|
||||
assert!(affected, "chunk for active agent must request a redraw");
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(agent_message_text(agent_b), "hello from B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_subagent_routes_through_parent() {
|
||||
// Subagent (child) chunk must land in the parent's
|
||||
// `subagent_views[child_sid]` even when a different agent is
|
||||
// currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let child_sid = "sess-A-child";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
}
|
||||
|
||||
let affected = handle(
|
||||
make_agent_chunk_message(child_sid, "hello from subagent"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child_view = parent
|
||||
.subagent_views
|
||||
.get(child_sid)
|
||||
.expect("child view must still exist");
|
||||
assert_eq!(
|
||||
agent_message_text(child_view),
|
||||
"hello from subagent",
|
||||
"subagent chunk must land in subagent_views[child_sid]"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"subagent chunk for non-active parent must not request a redraw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_with_unknown_session_id_is_dropped_and_no_redraw() {
|
||||
// No agent owns the session_id and the active agent already has a
|
||||
// session_id assigned (so the race-window fallback does not fire).
|
||||
// The notification must be dropped silently.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let affected = handle(
|
||||
make_agent_chunk_message("sess-unknown", "stray text"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!affected, "unknown session_id must not request a redraw");
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"agent A must not have absorbed a notification for sess-unknown"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().scrollback.is_empty(),
|
||||
"agent B must not have absorbed a notification for sess-unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_none_race_window_routes_to_active_agent() {
|
||||
// Pin the existing race-window semantics: notifications that arrive
|
||||
// before `TaskResult::SessionCreated` (active agent has no session_id
|
||||
// yet) must still land on the active agent.
|
||||
|
||||
// Case 1: active agent A has session_id == None; everyone else has
|
||||
// a real id. Stray notification routes to A.
|
||||
{
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let _ = handle(
|
||||
make_agent_chunk_message("not-yet-assigned", "racing chunk"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(0)).unwrap()),
|
||||
"racing chunk",
|
||||
"race-window fallback should land on active agent A"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().scrollback.is_empty(),
|
||||
"non-active agent B must not absorb the race chunk"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: both A and B have session_id == None; the active one wins.
|
||||
{
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
||||
insert_agent(&mut app, AgentId(1), None);
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let _ = handle(
|
||||
make_agent_chunk_message("not-yet-assigned", "racing chunk"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"non-active agent A must not absorb the race chunk"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(1)).unwrap()),
|
||||
"racing chunk",
|
||||
"race-window fallback must prefer the active agent (B)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_for_inactive_agent_lands_in_its_todo() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
// Sanity: A's todo starts empty.
|
||||
assert_eq!(
|
||||
app.agents.get(&AgentId(0)).unwrap().todo.counts().total(),
|
||||
0,
|
||||
);
|
||||
|
||||
let _ = handle(make_plan_message("sess-A", &["task1", "task2"]), &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.todo.counts().total(),
|
||||
2,
|
||||
"Plan update must mutate A's todo even when B is active"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b.todo.counts().total(),
|
||||
0,
|
||||
"active agent B's todo must not absorb A's plan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_update_for_inactive_agent_bumps_its_generation() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
let initial_gen_a = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.available_commands_generation;
|
||||
|
||||
let _ = handle(
|
||||
make_commands_update_message("sess-A", &["compact", "fork"]),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.session.available_commands.len(),
|
||||
2,
|
||||
"AvailableCommandsUpdate must replace A's commands list"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_a.session.available_commands_generation,
|
||||
initial_gen_a + 1,
|
||||
"AvailableCommandsUpdate must bump A's generation counter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_task_stdout_for_inactive_agent_lands_in_its_bg_tasks() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
// Pre-register a bg task on A so route_bg_task_stdout has a target.
|
||||
let task_id = "task-A-1";
|
||||
let tool_call_id = "call-A-1";
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent_a.session.bg_tasks.insert(
|
||||
task_id.into(),
|
||||
BgTaskState {
|
||||
task_id: task_id.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
command: "sleep 5".into(),
|
||||
description: None,
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/out".into(),
|
||||
status: BgTaskStatus::Running,
|
||||
start_time: std::time::SystemTime::now(),
|
||||
end_time: None,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdout: String::new(),
|
||||
stdout_line_count: 0,
|
||||
truncated: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
is_monitor: false,
|
||||
restored_from_replay: false,
|
||||
},
|
||||
);
|
||||
agent_a
|
||||
.session
|
||||
.bg_tool_call_to_task
|
||||
.insert(tool_call_id.into(), task_id.into());
|
||||
}
|
||||
|
||||
let _ = handle(
|
||||
make_bash_stdout_message("sess-A", tool_call_id, "stdout-from-A"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.session.bg_tasks.get(task_id).unwrap().stdout,
|
||||
"stdout-from-A",
|
||||
"Bash stdout must land in A's bg_tasks even when B is active"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunks_for_two_agents_dont_cross_contaminate() {
|
||||
// Send chunks to both A and B in sequence; each landing in its own
|
||||
// scrollback proves the demux works in both directions regardless
|
||||
// of which agent is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let _ = handle(make_agent_chunk_message("sess-A", "A only"), &mut app);
|
||||
let _ = handle(make_agent_chunk_message("sess-B", "B only"), &mut app);
|
||||
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(0)).unwrap()),
|
||||
"A only",
|
||||
);
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(1)).unwrap()),
|
||||
"B only",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_non_api_key_tier_clears_stale_api_key_flag() {
|
||||
let mut app = make_app_with_agent("sess-stale-key");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.is_api_key_auth);
|
||||
assert!(!app.usage_visible);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
|
||||
// Later personal Free stamp must not keep the API-key bypass.
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("Free"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.usage_visible);
|
||||
assert!(!app.tier_restricted_commands.is_empty());
|
||||
|
||||
// A paid tier after API Key clears the api-key flag and tier limits.
|
||||
let mut app = make_app_with_agent("sess-paid-tier");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("SuperGrok"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_group_tool_verbs_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
// test holds regardless of host config/env (a local `[ui]` or env
|
||||
// override legitimately beats the remote tier on both legs).
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
let managed_config = kigi_shell::config::load_managed_config().ok();
|
||||
let resolve = |remote_val: Option<bool>| {
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
group_tool_verbs: remote_val,
|
||||
..Default::default()
|
||||
};
|
||||
kigi_shell::util::config::resolve_group_tool_verbs(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value
|
||||
};
|
||||
let expect_on = resolve(Some(true));
|
||||
let expect_cleared = resolve(None);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// Remote enable arrives (redundant with the on-default, still latched).
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_group_tool_verbs(),
|
||||
expect_on,
|
||||
"remote Some(true) must re-resolve into the cache"
|
||||
);
|
||||
|
||||
// remote settings clears the remote tier (field absent → None). Seed the
|
||||
// cache opposite to the expected outcome — the latched remote enable —
|
||||
// so only a real re-resolve can pass; the update must revert it to the
|
||||
// local/default resolution instead of skipping the field. An old
|
||||
// payload without the field takes this same path.
|
||||
crate::appearance::cache::set_group_tool_verbs(!expect_cleared);
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(None),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_group_tool_verbs(),
|
||||
expect_cleared,
|
||||
"cleared remote tier must re-resolve the full chain, not stay latched"
|
||||
);
|
||||
// Restore default (on) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_collapsed_edit_blocks_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
// test holds regardless of host config/env (a local `[ui]` or env
|
||||
// override legitimately beats the remote tier on both legs).
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
let managed_config = kigi_shell::config::load_managed_config().ok();
|
||||
let resolve = |remote_val: Option<bool>| {
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
collapsed_edit_blocks: remote_val,
|
||||
..Default::default()
|
||||
};
|
||||
kigi_shell::util::config::resolve_collapsed_edit_blocks(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value
|
||||
};
|
||||
let expect_on = resolve(Some(true));
|
||||
let expect_cleared = resolve(None);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// remote settings enable arrives (the team rollout path).
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_collapsed_edit_blocks(),
|
||||
expect_on,
|
||||
"remote Some(true) must re-resolve into the cache"
|
||||
);
|
||||
|
||||
// remote settings clears the remote tier (field absent → None). Seed the
|
||||
// cache opposite to the expected outcome — the latched remote enable —
|
||||
// so only a real re-resolve can pass; the update must revert it to the
|
||||
// local/default resolution instead of skipping the field. An old
|
||||
// payload without the field takes this same path.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(!expect_cleared);
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(None),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_collapsed_edit_blocks(),
|
||||
expect_cleared,
|
||||
"cleared remote tier must re-resolve the full chain, not stay latched"
|
||||
);
|
||||
// Restore default (off) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
}
|
||||
|
||||
/// A remote collapsed_edit_blocks flip re-materializes on-default Edit
|
||||
/// rows in the live transcript (the same policy the settings toggle
|
||||
/// applies via `apply_collapsed_edit_blocks_flip`).
|
||||
#[test]
|
||||
fn settings_update_collapsed_edit_blocks_flip_refolds_live_edits() {
|
||||
use crate::scrollback::types::DisplayMode;
|
||||
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = {
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
sb.push_block(crate::scrollback::block::RenderBlock::ToolCall(
|
||||
crate::scrollback::blocks::tool::ToolCallBlock::Edit(
|
||||
crate::scrollback::blocks::tool::EditToolCallBlock::new("f.rs", vec![]),
|
||||
),
|
||||
))
|
||||
};
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.get_by_id(id).unwrap().display_mode,
|
||||
DisplayMode::Expanded,
|
||||
"flag off materializes expanded"
|
||||
);
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
if !crate::appearance::cache::load_collapsed_edit_blocks() {
|
||||
// A host-level env/config override outranked the remote value, so
|
||||
// no real flip occurred and the re-fold didn't run — nothing to
|
||||
// assert on this machine (CI runs with clean layers).
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.get_by_id(id).unwrap().display_mode,
|
||||
DisplayMode::Collapsed,
|
||||
"remote enable must collapse the on-default Edit row"
|
||||
);
|
||||
// Restore default (off) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
}
|
||||
|
||||
/// The live-refresh flip mirrors `set_group_tool_verbs_inner`'s stale
|
||||
/// group-expansion cleanup: a previously expanded verb slot must not
|
||||
/// survive a remote flip as an expanded header.
|
||||
#[test]
|
||||
fn settings_update_flip_resets_stale_group_expansion() {
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
for i in 0..3 {
|
||||
sb.push_block(crate::scrollback::block::RenderBlock::read(
|
||||
format!("f{i}.rs"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
sb.prepare_layout(80, 40);
|
||||
sb.set_selected(Some(0));
|
||||
assert!(sb.toggle_group_expansion());
|
||||
sb.prepare_layout(80, 40);
|
||||
let info = sb.get_cached_entry_layouts().unwrap()[0];
|
||||
assert!(info.group_collapse_header, "expanded verb slot armed");
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(Some(false)),
|
||||
&mut app
|
||||
));
|
||||
if crate::appearance::cache::load_group_tool_verbs() {
|
||||
// A host-level env/config override outranked the remote value, so
|
||||
// no real flip occurred and the cleanup path didn't run — nothing
|
||||
// to assert on this machine (CI runs with clean layers).
|
||||
return;
|
||||
}
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
sb.prepare_layout(80, 40);
|
||||
let info = sb.get_cached_entry_layouts().unwrap()[0];
|
||||
assert!(
|
||||
!info.group_collapse_header,
|
||||
"remote flip must drop the stale expansion"
|
||||
);
|
||||
assert!(
|
||||
sb.get_cached_entry_height(1).unwrap_or(0) > 0,
|
||||
"rows render individually after the flip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_gate_killswitch_clears_all_agents_regardless_of_active_mirror() {
|
||||
// Two agents both in auto; the active tab's global mirror reads "ask"
|
||||
// (a tab switch / Shift+Tab re-anchored it away from auto). A
|
||||
// mid-session gate kill-switch (`auto_permission_mode_enabled=false`)
|
||||
// must clear the per-session auto flag on BOTH agents. The old code
|
||||
// gated this fan-out on `current_ui.permission_mode == "auto"`, so it
|
||||
// skipped background agents and left stale `auto_mode` that
|
||||
// `switch_to_agent` could re-anchor back to "auto" on return.
|
||||
let mut app = make_app_two_agents();
|
||||
app.auto_mode_gate = true;
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.session.auto_mode = true;
|
||||
}
|
||||
// Active tab's mirror is NOT "auto" — the old bug's skip condition.
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "auto_permission_mode_enabled": false }),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
for (id, agent) in &app.agents {
|
||||
assert!(
|
||||
!agent.session.auto_mode,
|
||||
"agent {id:?} auto_mode must be cleared by the kill-switch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_gate_killswitch_notifies_agents_to_leave_auto() {
|
||||
// The kill-switch must tell live sessions to leave Auto, else the agent
|
||||
// keeps classifier-approving while the UI shows "Ask". The notification is
|
||||
// CLIENT-scoped, so exactly ONE fires regardless of how many tabs were in
|
||||
// auto; it omits `yolo_mode` so a sibling always-approve tab is preserved.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
// Two auto agents + one always-approve sibling, all with live sessions.
|
||||
app.agents.insert(AgentId(0), make_agent(Some("sess-0")));
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-1")));
|
||||
app.agents.insert(AgentId(2), make_agent(Some("sess-yolo")));
|
||||
app.auto_mode_gate = true;
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.auto_mode = true;
|
||||
app.agents.get_mut(&AgentId(1)).unwrap().session.auto_mode = true;
|
||||
app.agents.get_mut(&AgentId(2)).unwrap().session.yolo_mode = true;
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "auto_permission_mode_enabled": false }),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
// Sibling always-approve is untouched — the kill-switch clears only auto.
|
||||
assert!(
|
||||
app.agents[&AgentId(2)].session.is_yolo(),
|
||||
"sibling always-approve must stay yolo after the auto kill-switch"
|
||||
);
|
||||
|
||||
let mut leave_auto_notifs = 0;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
if let kigi_acp_lib::AcpAgentMessage::ExtNotification(args) = msg {
|
||||
if args.request.method.as_ref() != "x.ai/yolo_mode_changed" {
|
||||
continue;
|
||||
}
|
||||
let params: serde_json::Value =
|
||||
serde_json::from_str(args.request.params.get()).unwrap();
|
||||
assert_eq!(params["auto_mode"], serde_json::json!(false));
|
||||
assert_eq!(params["permission_mode"], serde_json::json!("ask"));
|
||||
assert!(
|
||||
params.get("yolo_mode").is_none(),
|
||||
"yolo_mode must be omitted so a sibling always-approve session is preserved"
|
||||
);
|
||||
leave_auto_notifs += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
leave_auto_notifs, 1,
|
||||
"exactly one client-scoped leave-auto notification, regardless of agent count"
|
||||
);
|
||||
}
|
||||
|
||||
/// User-owned mode must not re-arm default_yolo or rewrite UI from remote.
|
||||
#[test]
|
||||
fn permission_mode_user_claim_blocks_default_yolo_rearm() {
|
||||
let mut app = make_app_with_agent("sess-user-claim");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = false;
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
app.default_yolo = false;
|
||||
|
||||
let apply_yolo = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&apply_yolo, &mut app);
|
||||
assert!(
|
||||
!app.default_yolo,
|
||||
"user-claimed mode must not re-arm default_yolo from remote always-approve"
|
||||
);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"user-claimed UI must not be rewritten by remote soft-default"
|
||||
);
|
||||
assert!(
|
||||
!app.permission_mode_from_soft_default,
|
||||
"user claim origin stays false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_mode_omitted_does_not_clear_soft_default() {
|
||||
let mut app = make_app_with_agent("sess-omit-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
app.default_yolo = false;
|
||||
app.auto_mode_gate = true;
|
||||
|
||||
let unrelated = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"show_resolved_model": true,
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&unrelated, &mut app);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("auto"),
|
||||
"omitted permission_mode must not clear soft-applied UI mode"
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"origin must stay SoftDefault when field is omitted"
|
||||
);
|
||||
assert!(
|
||||
!app.default_yolo,
|
||||
"omitted permission_mode must not recompute default_yolo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Positive wiring: a permission_mode-bearing push with the latch held
|
||||
/// must reach the applier through the real handler. The handler's ambient
|
||||
/// effective-config read decides WHICH mode wins (exact outcomes are
|
||||
/// pinned on the applier with injected TOML), so this asserts the
|
||||
/// applier's host-independent signature instead: the non-canonical
|
||||
/// sentinel display is rewritten to a canonical mode, latch preserved.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_push_reaches_applier() {
|
||||
let mut app = make_app_with_agent("sess-wire-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
// Outside the applier's output alphabet — only the applier rewrites it.
|
||||
app.current_ui.permission_mode = Some("sentinel-not-a-mode".into());
|
||||
|
||||
let push = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&push, &mut app);
|
||||
let display = app
|
||||
.current_ui
|
||||
.permission_mode
|
||||
.as_deref()
|
||||
.expect("applier always writes a display mode");
|
||||
assert!(
|
||||
matches!(display, "ask" | "auto" | "always-approve" | "default"),
|
||||
"soft push must rewrite the sentinel display via the applier, got {display:?}"
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"a soft re-arm must keep SoftDefault origin"
|
||||
);
|
||||
}
|
||||
|
||||
/// Soft-origin recompute with injected TOML (deterministic — no host
|
||||
/// config): remote always-approve arms default_yolo + UI, keeps the soft
|
||||
/// latch, and persists nothing.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_applies_remote_always_approve() {
|
||||
let mut app = make_app_with_agent("sess-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = None;
|
||||
app.default_yolo = false;
|
||||
|
||||
super::super::settings::apply_soft_default_permission_mode(
|
||||
&mut app,
|
||||
None,
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(app.default_yolo, "remote always-approve must arm default_yolo");
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"a soft re-arm must keep SoftDefault origin"
|
||||
);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"a soft default must never be persisted to disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Explicit `null` recomputes with remote=None (unlike field omission):
|
||||
/// with no TOML permission key the soft always-approve drops back to Ask.
|
||||
#[test]
|
||||
fn permission_mode_explicit_null_clears_soft_always_approve() {
|
||||
let mut app = make_app_with_agent("sess-null-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
app.default_yolo = true;
|
||||
|
||||
super::super::settings::apply_soft_default_permission_mode(&mut app, None, None);
|
||||
assert!(!app.default_yolo, "remote null must disarm a soft always-approve");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
assert!(app.permission_mode_from_soft_default);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"a soft default must never be persisted to disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Policy pin and auto gate clamp a soft re-arm to Ask enforcement/display.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_respects_pin_and_gate() {
|
||||
let mut app = make_app_with_agent("sess-pin-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.yolo_policy_block = Some("pinned");
|
||||
app.default_yolo = false;
|
||||
super::super::settings::apply_soft_default_permission_mode(
|
||||
&mut app,
|
||||
None,
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(!app.default_yolo, "policy pin must block a remote always-approve");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
|
||||
let mut app = make_app_with_agent("sess-gate-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.auto_mode_gate = false;
|
||||
super::super::settings::apply_soft_default_permission_mode(&mut app, None, Some("auto"));
|
||||
assert!(!app.default_yolo);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"gated-off Auto must display as Ask"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// On resume, a replayed spawn+finish pair leaves the subagent terminal.
|
||||
#[test]
|
||||
fn replayed_subagent_finished_marks_orphan_terminal() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
|
||||
let finished = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_finished",
|
||||
"subagent_id": "sa-1",
|
||||
"child_session_id": "child-1",
|
||||
"status": "cancelled",
|
||||
"error": "interrupted by process restart",
|
||||
"tool_calls": 0,
|
||||
"turns": 0,
|
||||
"duration_ms": 1000,
|
||||
"tokens_used": 0,
|
||||
}),
|
||||
"sess-1-2",
|
||||
);
|
||||
handle_ext_notification(&finished, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent
|
||||
.subagent_sessions
|
||||
.get("child-1")
|
||||
.expect("subagent present after replay");
|
||||
assert!(
|
||||
info.finished,
|
||||
"orphan must be terminal after replayed subagent_finished"
|
||||
);
|
||||
assert_eq!(info.status.as_deref(), Some("cancelled"));
|
||||
}
|
||||
|
||||
/// `cancelled = false` must finalize the row, not revert "killing" to "running".
|
||||
#[test]
|
||||
fn kill_finalizes_orphan_when_shell_reports_not_cancelled() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
|
||||
// User clicks kill after load.
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.loading_replay = false;
|
||||
let info = agent.subagent_sessions.get_mut("child-1").unwrap();
|
||||
assert!(!info.finished);
|
||||
info.pending_kill = true;
|
||||
info.kill_requested_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
// Shell: cancelled=false (nothing live), no real status → "cancelled".
|
||||
let finalized = finalize_killed_subagent(
|
||||
&mut app,
|
||||
&acp::SessionId::new("sess-1".to_owned()),
|
||||
"sa-1",
|
||||
"cancelled",
|
||||
);
|
||||
assert!(finalized, "row should have been finalized");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get("child-1").unwrap();
|
||||
assert!(info.finished, "kill must finalize the stuck orphan row");
|
||||
assert_eq!(info.status.as_deref(), Some("cancelled"));
|
||||
assert!(
|
||||
!info.pending_kill,
|
||||
"pending_kill must clear so it can't revert"
|
||||
);
|
||||
assert!(info.kill_requested_at.is_none());
|
||||
}
|
||||
|
||||
/// An already-finished subagent killed → finalize stamps the REAL terminal
|
||||
/// status (e.g. "completed"), not a forced "cancelled".
|
||||
#[test]
|
||||
fn kill_finalizes_orphan_with_real_status_when_already_finished() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.loading_replay = false;
|
||||
let info = agent.subagent_sessions.get_mut("child-1").unwrap();
|
||||
info.pending_kill = true;
|
||||
}
|
||||
|
||||
let finalized = finalize_killed_subagent(
|
||||
&mut app,
|
||||
&acp::SessionId::new("sess-1".to_owned()),
|
||||
"sa-1",
|
||||
"completed",
|
||||
);
|
||||
assert!(finalized, "row should have been finalized");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get("child-1").unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(
|
||||
info.status.as_deref(),
|
||||
Some("completed"),
|
||||
"already-finished kill must stamp the real terminal status"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: replay from `updates.jsonl` emits `x.ai/session/update` (not
|
||||
/// `session_notification`). Subagent lifecycle events must still populate
|
||||
/// `subagent_sessions` and the parent scrollback `SubagentBlock`.
|
||||
#[test]
|
||||
fn ext_session_update_replay_handles_subagent_spawned_and_finished() {
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
let child_sid = "child-sess-replay";
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
affected,
|
||||
"SubagentSpawned on the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.expect("SubagentSpawned must register subagent_sessions");
|
||||
assert_eq!(info.description.as_ref(), "scan src/");
|
||||
assert_eq!(info.subagent_type.as_ref(), "explore");
|
||||
assert!(
|
||||
agent.subagent_views.contains_key(child_sid),
|
||||
"SubagentSpawned must create subagent_views eagerly"
|
||||
);
|
||||
let entry_id = info
|
||||
.scrollback_entry_id
|
||||
.expect("spawn must stash scrollback_entry_id on SubagentInfo");
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("SubagentSpawned must push a SubagentBlock to parent scrollback");
|
||||
};
|
||||
assert_eq!(sb.child_session_id, child_sid);
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Started));
|
||||
assert!(agent.scrollback.needs_animation());
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
affected,
|
||||
"SubagentFinished on the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(info.status.as_deref(), Some("completed"));
|
||||
assert_eq!(info.tool_calls, Some(2));
|
||||
assert_eq!(info.turns, Some(1));
|
||||
assert_eq!(info.duration_ms, Some(500));
|
||||
assert_eq!(info.scrollback_entry_id, Some(entry_id));
|
||||
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("finished subagent must keep the started scrollback entry");
|
||||
};
|
||||
match &sb.kind {
|
||||
SubagentBlockKind::Completed { elapsed } => {
|
||||
assert_eq!(*elapsed, std::time::Duration::from_millis(500));
|
||||
}
|
||||
other => {
|
||||
panic!("blocking subagent must mutate started block to Completed, got {other:?}")
|
||||
}
|
||||
}
|
||||
assert!(!entry.is_running, "finish_running must clear running flag");
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"finished subagent entry must not keep scrollback animation"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live activity label fans out to `SubagentInfo` (tasks pane /
|
||||
/// dashboard rows) alongside the scrollback block — from both the child
|
||||
/// session/update path and the `SubagentProgress` path — and
|
||||
/// `SubagentFinished` clears both surfaces.
|
||||
#[test]
|
||||
fn subagent_activity_label_stamps_info_and_clears_on_finish() {
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
let child_sid = "child-activity";
|
||||
let _ = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-parent",
|
||||
test_subagent_spawned("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// A live child message chunk resolves "Responding" and stamps both
|
||||
// the block and the info.
|
||||
let _ = handle(
|
||||
make_agent_chunk_with_event(child_sid, "child text", "p-child", None),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.activity_label.as_deref(), Some("Responding"));
|
||||
let entry_id = info.scrollback_entry_id.unwrap();
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("expected Subagent block");
|
||||
};
|
||||
assert_eq!(sb.activity_label, info.activity_label);
|
||||
|
||||
// SubagentProgress recomputes from the child tracker and restamps.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.subagent_sessions
|
||||
.get_mut(child_sid)
|
||||
.unwrap()
|
||||
.activity_label = None;
|
||||
let _ = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-parent",
|
||||
test_subagent_progress("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.unwrap()
|
||||
.activity_label
|
||||
.as_deref(),
|
||||
Some("Responding")
|
||||
);
|
||||
|
||||
let _ = handle(
|
||||
make_ext_session_notification("sess-parent", test_subagent_finished(child_sid)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(
|
||||
info.activity_label.is_none(),
|
||||
"finish must clear the info label"
|
||||
);
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("expected Subagent block");
|
||||
};
|
||||
assert!(
|
||||
sb.activity_label.is_none(),
|
||||
"finish must clear the block label"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: replayed SubagentSpawned (resumed_from unset) must load child
|
||||
/// updates.jsonl so fullscreen scrollback is not prompt-only.
|
||||
#[test]
|
||||
fn subagent_spawned_replays_child_updates_without_resumed_from() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-with-updates";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"spawn must replay exactly one tool call"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed),
|
||||
"spawn must set child_updates_replayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Resume: a `SubagentSpawned` during `loading_replay` must defer the child
|
||||
/// transcript load (the dominant large-session resume cost) to first open.
|
||||
#[test]
|
||||
fn subagent_spawned_during_resume_defers_child_replay_until_open() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-resume-defer";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
// Simulate resume: the parent agent is replaying its own session.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
0,
|
||||
"resume spawn must NOT eagerly replay the child transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| !i.child_updates_replayed),
|
||||
"resume spawn must leave child_updates_replayed unset for lazy load"
|
||||
);
|
||||
|
||||
// Opening the subagent later triggers the deferred (lazy) replay.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"opening the subagent after resume must lazily replay its transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed),
|
||||
"lazy open must set child_updates_replayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression (resume): a subagent that already finished must still show its
|
||||
/// full transcript on open. The finished handler's `TurnCompleted` push is
|
||||
/// suppressed during replay — otherwise it vetoes the deferred load
|
||||
/// (`subagent_child_needs_replay`), leaving a permanently empty transcript.
|
||||
#[test]
|
||||
fn subagent_resume_finished_then_open_shows_full_transcript() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-resume-finished";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
let _ = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
0,
|
||||
"resume must not eagerly load the finished subagent transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| !i.child_updates_replayed),
|
||||
"finished-during-resume must leave child_updates_replayed unset"
|
||||
);
|
||||
// Even deferred, a finished subagent must not show a running spinner.
|
||||
assert!(
|
||||
matches!(
|
||||
agent.subagent_views.get(child_sid).unwrap().session.state,
|
||||
AgentState::Idle
|
||||
),
|
||||
"finished subagent must be Idle after resume, not TurnRunning"
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"opening a finished subagent after resume must show its transcript"
|
||||
);
|
||||
// The lazy load reapplies the "Worked for" footer (live parity).
|
||||
let child = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert!(
|
||||
(0..child.scrollback.len()).any(|i| child
|
||||
.scrollback
|
||||
.entry(i)
|
||||
.is_some_and(|e| matches!(e.block, RenderBlock::SessionEvent(_)))),
|
||||
"opened finished subagent must show a TurnCompleted footer"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression (resume): with a meta.json task prompt AND a persisted child
|
||||
/// transcript that echoes that prompt, opening after resume shows the task
|
||||
/// exactly once — the deferred open must dedup the replayed prompt echo.
|
||||
#[test]
|
||||
fn subagent_resume_with_meta_prompt_shows_task_once_after_open() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-resume-meta";
|
||||
let task = "scan src/ for auth";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let updates = format!(
|
||||
"{}\n{}",
|
||||
child_user_message_line(child_sid, task),
|
||||
child_tool_line(child_sid)
|
||||
);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, Some(&updates));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"task prompt must appear exactly once after resume + open"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression: replayed user_message_chunk + meta prompt must not duplicate via injection.
|
||||
#[test]
|
||||
fn subagent_spawn_replay_and_meta_prompt_shows_task_once() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-prompt-once";
|
||||
let task = "scan src/ for auth";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
let updates = format!(
|
||||
"{}\n{}",
|
||||
child_user_message_line(child_sid, task),
|
||||
child_tool_line(child_sid)
|
||||
);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, Some(&updates));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"task prompt must appear exactly once in child scrollback"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
assert!(
|
||||
!child_tracker_expects_user_echo(agent, child_sid),
|
||||
"replay path must not set expect_user_echo when injection is skipped"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Live spawn: meta prompt without updates.jsonl still injects the task once.
|
||||
#[test]
|
||||
fn subagent_spawn_live_injects_meta_prompt_once_without_updates() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-live-prompt";
|
||||
let task = "explore handlers only";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"live spawn must inject meta prompt when updates.jsonl is absent"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 0);
|
||||
assert!(
|
||||
child_tracker_expects_user_echo(agent, child_sid),
|
||||
"live spawn must set expect_user_echo after injecting meta prompt"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_skips_injection_for_whitespace_only_meta_prompt() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-empty-meta";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, " ");
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, " "),
|
||||
0,
|
||||
"whitespace-only meta prompt must not inject a user block"
|
||||
);
|
||||
assert!(
|
||||
!child_tracker_expects_user_echo(agent, child_sid),
|
||||
"whitespace-only meta prompt must not set expect_user_echo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_without_updates_jsonl_is_noop() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-no-updates";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 0);
|
||||
assert_eq!(
|
||||
agent
|
||||
.subagent_views
|
||||
.get(child_sid)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_and_open_replay_is_idempotent() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-idempotent";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"open must not duplicate spawn replay when child_updates_replayed is set"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_subagent_fullscreen_replays_when_flag_false_and_prompt_only() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-open-replay";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
if let Some(child) = agent.subagent_views.get_mut(child_sid) {
|
||||
child.scrollback.clear();
|
||||
child
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("task only"));
|
||||
}
|
||||
if let Some(info) = agent.subagent_sessions.get_mut(child_sid) {
|
||||
info.child_updates_replayed = false;
|
||||
}
|
||||
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_notification_and_update_equivalent_for_subagent_spawned() {
|
||||
let child_sid = "child-equiv";
|
||||
let (spawn_notif, finish_notif) =
|
||||
run_subagent_lifecycle_via_method("x.ai/session_notification", child_sid);
|
||||
let (spawn_update, finish_update) =
|
||||
run_subagent_lifecycle_via_method("x.ai/session/update", child_sid);
|
||||
|
||||
assert_eq!(spawn_notif.description, spawn_update.description);
|
||||
assert_eq!(spawn_notif.subagent_type, spawn_update.subagent_type);
|
||||
assert_eq!(spawn_notif.has_child_view, spawn_update.has_child_view);
|
||||
assert_eq!(spawn_notif.scrollback_len, spawn_update.scrollback_len);
|
||||
assert_eq!(spawn_notif.child_session_id, child_sid);
|
||||
assert_eq!(spawn_update.child_session_id, child_sid);
|
||||
assert!(matches!(spawn_notif.block_kind, SubagentBlockKind::Started));
|
||||
assert!(matches!(
|
||||
spawn_update.block_kind,
|
||||
SubagentBlockKind::Started
|
||||
));
|
||||
assert_eq!(
|
||||
spawn_notif.scrollback_entry_id,
|
||||
spawn_update.scrollback_entry_id
|
||||
);
|
||||
assert!(spawn_notif.scrollback_entry_id.is_some());
|
||||
|
||||
assert!(finish_notif.finished);
|
||||
assert!(finish_update.finished);
|
||||
assert_eq!(finish_notif.status.as_deref(), Some("completed"));
|
||||
assert_eq!(finish_update.status.as_deref(), Some("completed"));
|
||||
assert_eq!(finish_notif.tool_calls, Some(2));
|
||||
assert_eq!(finish_update.tool_calls, Some(2));
|
||||
assert_eq!(finish_notif.turns, Some(1));
|
||||
assert_eq!(finish_update.turns, Some(1));
|
||||
assert_eq!(finish_notif.duration_ms, Some(500));
|
||||
assert_eq!(finish_update.duration_ms, Some(500));
|
||||
assert!(matches!(
|
||||
finish_notif.block_kind,
|
||||
SubagentBlockKind::Completed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
finish_update.block_kind,
|
||||
SubagentBlockKind::Completed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_for_inactive_agent_registers_subagent_without_redraw() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let child_sid = "child-inactive";
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-A",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-A", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent_a
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.expect("SubagentSpawned must register on inactive agent A");
|
||||
assert!(
|
||||
agent_a.subagent_views.contains_key(child_sid),
|
||||
"SubagentSpawned must create subagent_views on inactive agent A"
|
||||
);
|
||||
assert_eq!(agent_a.scrollback.len(), 1);
|
||||
let entry_id = info
|
||||
.scrollback_entry_id
|
||||
.expect("inactive spawn must stash scrollback_entry_id");
|
||||
let entry = agent_a.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("inactive spawn must push SubagentBlock");
|
||||
};
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Started));
|
||||
assert!(
|
||||
!affected,
|
||||
"SubagentSpawned on inactive agent must not request a redraw"
|
||||
);
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-A",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"SubagentFinished on inactive agent must not request a redraw"
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent_a.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(info.status.as_deref(), Some("completed"));
|
||||
let entry = agent_a.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("inactive finish must keep SubagentBlock");
|
||||
};
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Completed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_unknown_session_subagent_spawned_no_op() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-unknown",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-unknown", "child-unknown"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!affected, "unknown session_id must not request a redraw");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.subagent_sessions.is_empty(),
|
||||
"SubagentSpawned for unknown session must not register subagent_sessions"
|
||||
);
|
||||
assert!(
|
||||
agent.scrollback.is_empty(),
|
||||
"SubagentSpawned for unknown session must not push scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_malformed_params_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
// Valid JSON but not a SessionNotification — parse must fail quietly.
|
||||
let raw =
|
||||
serde_json::value::to_raw_value(&serde_json::json!({"unexpected": true})).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session/update", raw.into());
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"malformed x.ai/session/update params must not redraw"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"malformed notification must not mutate scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_notification_for_inactive_agent_updates_its_context_used() {
|
||||
// AutoCompactCompleted on the xAI ext path resets the context bar
|
||||
// numerator via refresh_context_used. That side effect must run on
|
||||
// the matched agent regardless of which view is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// Seed A with a stale context-used reading so we can prove the
|
||||
// notification reset it.
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent_a.apply_context_used(90_000, 131_072);
|
||||
}
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-A",
|
||||
XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 25_000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.context_state.as_ref().map(|c| c.used),
|
||||
Some(25_000),
|
||||
"AutoCompactCompleted must reset A's context_used even when B is active"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"ext notification routed to a non-active agent must not request a redraw"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user