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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,727 @@
use super::*;
/// Route a `ToolCallUpdate` stdout chunk to the central bg task store.
///
/// Returns `true` if the update was consumed (belongs to a bg task),
/// `false` if it should be passed to the normal tracker.
pub(super) fn route_bg_task_stdout(
tcu: &acp::ToolCallUpdate,
session: &mut super::super::agent::AgentSession,
) -> bool {
let tc_id = tcu.tool_call_id.0.to_string();
// Check if this tool_call_id maps to a bg task
let task_id = match session.bg_tool_call_to_task.get(&tc_id) {
Some(tid) => tid.clone(),
None => return false,
};
// Extract stdout from the raw_output BashOutput
if let Some(ref raw_output) = tcu.fields.raw_output {
// The shell sends full cumulative output buffer — just overwrite.
// Check for BashOutput type
if raw_output.get("type").and_then(|v| v.as_str()) == Some("Bash") {
// Try output_for_prompt first (pre-stripped string)
let stdout =
if let Some(s) = raw_output.get("output_for_prompt").and_then(|v| v.as_str()) {
s.to_string()
} else if let Some(arr) = raw_output.get("output").and_then(|v| v.as_array()) {
// Decode output bytes (Vec<u8> serialized as JSON array)
let bytes: Vec<u8> = arr
.iter()
.filter_map(|v| v.as_u64().map(|n| n as u8))
.collect();
String::from_utf8_lossy(&bytes).into_owned()
} else {
return true; // Consumed but no extractable output
};
// Capture the shell-side `truncated` flag — once true, it stays
// true for the rest of the task (the rolling buffer can't
// "un-truncate").
let chunk_truncated = raw_output
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Don't overwrite with empty stdout (shell clears buffer on completion).
// `set_stdout` handles the BG_TASK_MAX_STDOUT trim, flips
// `truncated` on TUI-side overflow, and refreshes the cached
// `stdout_line_count` in one shot.
if !stdout.is_empty()
&& let Some(bg_task) = session.bg_tasks.get_mut(&task_id)
{
bg_task.set_stdout(stdout);
if chunk_truncated {
bg_task.truncated = true;
}
}
}
}
true // Consumed — don't pass to tracker
}
/// Handle `x.ai/task_backgrounded` — a bash command transitioned to background.
///
/// Creates a `BgTaskState` in the central store and sets up the
/// `tool_call_id → task_id` correlation for stdout routing.
///
/// If the tool already has an Execute block in scrollback (demotion),
/// the existing block is replaced in-place with a `BgTask` and the
/// entry's running state is cleared. Otherwise a fresh `BgTask` block
/// is pushed.
pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
// Parse the SessionNotification envelope
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
tracing::warn!("Failed to parse x.ai/task_backgrounded");
return false;
};
// Extract TaskBackgrounded fields
let (tool_call_id, task_id, command, cwd, output_file, monitor_description, notif_description) =
match session_notif.update {
XaiSessionUpdate::TaskBackgrounded {
tool_call_id,
task_id,
command,
cwd,
output_file,
monitor_description,
description,
} => (
tool_call_id,
task_id,
command,
cwd,
output_file,
monitor_description,
description,
),
_ => return false,
};
// Replayed (`session/load`) restores are historical context, not new
// activity: mark them so the tasks pane doesn't auto-open on resume.
let meta = NotificationMeta::from_json(session_notif.meta.as_ref().and_then(|v| v.as_object()));
let restored_from_replay = meta.is_replay;
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
Some(t) => t,
None => return false,
};
tracing::info!(
tool_call_id = %tool_call_id,
task_id = %task_id,
restored_from_replay,
"Background task started"
);
let child_sid: &str = session_notif.session_id.0.as_ref();
let Some((session, scrollback)) = resolve_target_view(agent, matched, child_sid) else {
return false;
};
// Check if this is a demotion (foreground→background): the execute block
// already exists in scrollback as a pending tool in the tracker.
let demotion_eid = session.tracker.pending_tool_entry_id(&tool_call_id);
// A monitor is identified by the structured `monitor_description` field
// (current path). Fallback: reparented monitors (subagent session sharing)
// and backends predating that field still bake a "[monitor] <desc>" prefix
// into the command — detect it and strip the prefix so those render as a
// "Monitor" row instead of a bash-highlighted "[monitor] …" under Tasks.
let monitor_prefix = command.strip_prefix("[monitor] ").map(str::to_string);
let is_monitor = monitor_description.is_some() || monitor_prefix.is_some();
// Always drain the deferred-tool suppression key now that routing is being
// set up — even when we end up preferring the wire `description`. This entry
// also suppresses late stdout ToolCallUpdates (see tracker), so leaving it
// behind would leak per bg task and keep dropping updates for the session.
let deferred_description = session
.tracker
.bg_deferred_tools
.remove(&tool_call_id)
.flatten();
// Prefer monitor label, then notification/tool description, then deferred
// raw_input description (late is_background detection). Blank/whitespace
// values count as absent so an empty wire `description` can't shadow a real
// fallback. On demotion we also fall back to the Execute block's description.
let non_blank = |d: Option<String>| d.filter(|s| !s.trim().is_empty());
let mut description = non_blank(monitor_description)
.or_else(|| non_blank(monitor_prefix))
.or_else(|| non_blank(notif_description))
.or_else(|| non_blank(deferred_description));
// Create central bg task state (description may still be filled from the
// Execute block on demotion before we insert into the map).
let mut bg_task = BgTaskState {
task_id: task_id.clone(),
tool_call_id: tool_call_id.clone(),
command: command.clone(),
description: None,
cwd,
output_file,
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,
restored_from_replay,
};
let entry_id = if let Some(eid) = demotion_eid {
// Demotion: extract stdout and swap the block in a single mutable borrow.
if let Some(entry) = scrollback.get_by_id_mut(eid) {
if let RenderBlock::ToolCall(crate::scrollback::blocks::ToolCallBlock::Execute(exec)) =
&mut entry.block
{
// Use `set_stdout` so the cached `stdout_line_count` and the
// TUI-side trim/truncated flag invariants stay in sync.
bg_task.set_stdout(exec.output.take().unwrap_or_default());
if description.as_ref().is_none_or(|d| d.trim().is_empty()) {
description = exec.description.take();
}
}
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
.with_description(description.clone());
entry.block = RenderBlock::BgTask(block);
entry.display_mode = crate::scrollback::types::DisplayMode::Collapsed;
entry.display_mode_pinned = false;
entry.invalidate_cache();
scrollback.mark_height_dirty(eid);
scrollback.finish_running(eid);
session.tracker.remove_pending_tool(&tool_call_id);
eid
} else {
// Entry was removed between the tracker lookup and now (compaction,
// clear, etc.). Create a fresh BgTask so the task has UI presence.
session.tracker.remove_pending_tool(&tool_call_id);
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
.with_description(description.clone());
let fallback = scrollback.push_block(RenderBlock::BgTask(block));
scrollback.set_last_running(true);
fallback
}
} else {
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
.with_description(description.clone());
let eid = scrollback.push_block(RenderBlock::BgTask(block));
scrollback.set_last_running(true);
eid
};
bg_task.description = description;
session.bg_tasks.insert(task_id.clone(), bg_task);
session
.bg_tool_call_to_task
.insert(tool_call_id.clone(), task_id.clone());
if let Some(bg) = session.bg_tasks.get_mut(&task_id) {
bg.scrollback_entry_id = Some(entry_id);
}
// Ext notifications reorder vs session updates: work registering after
// its awaiting wait must re-evaluate the skipped park. Root only — child
// tasks never enter root `bg_tasks`.
if !matches!(matched, SessionMatch::Child(_))
&& let Some((_, _, agent)) = resolve_notif_agent(app, &session_notif.session_id)
{
agent.maybe_push_parked_marker();
}
is_active
}
/// Handle `x.ai/monitor_event` — background task or monitor emitted new output.
pub(super) fn handle_monitor_event(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
return false;
};
let (task_id, _description, event_text) = match session_notif.update {
XaiSessionUpdate::MonitorEvent {
task_id,
description,
event_text,
} => (task_id, description, event_text),
_ => return false,
};
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
Some(t) => t,
None => return false,
};
let child_sid: &str = session_notif.session_id.0.as_ref();
let session = if matches!(matched, SessionMatch::Child(_)) {
match agent.subagent_views.get_mut(child_sid) {
Some(child_view) => &mut child_view.session,
None => return false,
}
} else {
&mut agent.session
};
// Append the event text to the bg task's stdout buffer so the
// block viewer shows it (same as bash output chunks for bg tasks).
// `append_stdout` handles the trim, flips `truncated` on overflow,
// and refreshes `stdout_line_count`.
if let Some(task) = session.bg_tasks.get_mut(&task_id) {
task.append_stdout(&event_text);
}
is_active
}
pub(super) fn handle_scheduled_task_created(
notif: &acp::ExtNotification,
app: &mut AppView,
) -> bool {
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
return false;
};
let (task_id, prompt, human_schedule, next_fire_at) = match session_notif.update {
XaiSessionUpdate::ScheduledTaskCreated {
task_id,
prompt,
human_schedule,
next_fire_at,
} => (task_id, prompt, human_schedule, next_fire_at),
_ => return false,
};
let matched = match find_session_match(app, &session_notif.session_id) {
Some(m) => m,
None => return false,
};
let agent_id = matched.agent_id();
let is_active = is_matched_agent_active(app, agent_id);
let agent = app
.agents
.get_mut(&agent_id)
.expect("find_session_match returned an existing AgentId");
// Remove provisional entries (created by /loop for instant UI feedback).
agent
.session
.scheduled_tasks
.retain(|k, _| !k.starts_with("provisional-"));
agent
.session
.scheduled_tasks
.entry(task_id.clone())
.or_insert_with(|| crate::app::agent::ScheduledTaskInfo {
task_id,
prompt,
human_schedule,
created_at: std::time::Instant::now(),
next_fire_at,
tag: "loop".into(),
});
is_active
}
pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
return false;
};
let (task_id, prompt, human_schedule, next_fire_at) = match session_notif.update {
XaiSessionUpdate::ScheduledTaskFired {
task_id,
prompt,
human_schedule,
next_fire_at,
} => (task_id, prompt, human_schedule, next_fire_at),
_ => return false,
};
let matched = match find_session_match(app, &session_notif.session_id) {
Some(m) => m,
None => return false,
};
let agent_id = matched.agent_id();
let is_active = is_matched_agent_active(app, agent_id);
let agent = app
.agents
.get_mut(&agent_id)
.expect("find_session_match returned an existing AgentId");
// Self-heal: if the task is unknown (e.g. a regression of the shell-side
// re-announce on session restore), insert a fresh entry from the fire
// payload so the tasks pane still shows the loop.
match agent.session.scheduled_tasks.entry(task_id) {
Entry::Occupied(mut e) => {
e.get_mut().next_fire_at = next_fire_at;
}
Entry::Vacant(e) => {
// next_fire_at: None marks a missed-one-shot fire from
// handle_missed_tasks(); a ScheduledTaskRemoved follows
// immediately. Skip the insert to avoid a one-frame flicker.
if next_fire_at.is_none() {
return is_active;
}
let task_id = e.key().clone();
e.insert(crate::app::agent::ScheduledTaskInfo {
task_id,
prompt,
human_schedule,
created_at: std::time::Instant::now(),
next_fire_at,
tag: "loop".into(),
});
}
}
is_active
}
pub(super) fn handle_scheduled_task_deleted(
notif: &acp::ExtNotification,
app: &mut AppView,
) -> bool {
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
return false;
};
let task_id = match session_notif.update {
XaiSessionUpdate::ScheduledTaskDeleted { task_id } => task_id,
_ => return false,
};
let matched = match find_session_match(app, &session_notif.session_id) {
Some(m) => m,
None => return false,
};
let agent_id = matched.agent_id();
let is_active = is_matched_agent_active(app, agent_id);
let agent = app
.agents
.get_mut(&agent_id)
.expect("find_session_match returned an existing AgentId");
agent.session.scheduled_tasks.remove(&task_id);
is_active
}
pub(super) fn handle_scheduled_task_inject_prompt(
notif: &acp::ExtNotification,
app: &mut AppView,
) -> bool {
let payload: serde_json::Value = match serde_json::from_str(notif.params.get()) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "Failed to parse x.ai/scheduled_task_inject_prompt");
return false;
}
};
let Some(session_id) = payload["sessionId"].as_str() else {
tracing::warn!("x.ai/scheduled_task_inject_prompt: missing or non-string sessionId");
return false;
};
let Some(prompt) = payload["prompt"].as_str().filter(|s| !s.is_empty()) else {
tracing::warn!("x.ai/scheduled_task_inject_prompt: missing or empty prompt");
return false;
};
let task_id = payload["taskId"].as_str().unwrap_or("unknown");
let human_schedule = payload["humanSchedule"].as_str().unwrap_or("unknown");
tracing::debug!(task_id, human_schedule, "Enqueuing scheduled cron prompt");
let agent = app.agents.values_mut().find(|a| {
a.session
.session_id
.as_ref()
.is_some_and(|sid| sid.0.as_ref() == session_id)
});
let Some(agent) = agent else {
return false;
};
// Only the driver injects + runs the scheduled prompt. In leader mode the
// `x.ai/scheduled_task_inject_prompt` notification is routed by the leader
// to the SINGLE session driver (see `is_scheduled_task_inject_prompt` in
// leader/server.rs), so any client that receives it IS the driver and must
// enqueue + run it — including a client that attached via `session/load`
// (`attached_as_viewer == true`) but is the designated driver. We therefore
// do NOT skip on `attached_as_viewer` here: that latched flag wrongly
// suppressed cron on an attacher-driver, leaving the loop stuck with no
// output. The other clients render the resulting turn from the broadcast
// deltas. (The de-dup guards below still prevent a double enqueue.)
// Skip if this specific task is already running or queued.
if agent.cron_task_id.as_deref() == Some(task_id) {
tracing::debug!(task_id, "cron prompt skipped: task already running");
return true;
}
let already_queued = agent
.session
.pending_prompts
.iter()
.any(|p| p.task_id.as_deref() == Some(task_id));
if already_queued {
tracing::debug!(task_id, "cron prompt already queued, skipping duplicate");
return true;
}
agent.session.enqueue_cron_prompt(
prompt.to_string(),
task_id.to_string(),
human_schedule.to_string(),
);
let effects = super::super::dispatch::maybe_drain_queue(agent);
app.pending_effects.extend(effects);
true
}
/// Derive the effective CWD and worktree flag for a child session.
///
/// Each field is derived independently: `child_cwd` controls the path,
/// `worktree_path` controls the worktree flag. Either can be present
/// without the other.
pub(super) fn derive_child_cwd(
parent_cwd: &std::path::Path,
info: Option<&crate::app::subagent::SubagentInfo>,
) -> (PathBuf, bool) {
let cwd = info
.and_then(|i| i.child_cwd.as_deref())
.map(PathBuf::from)
.unwrap_or_else(|| parent_cwd.to_path_buf());
let is_worktree = info.is_some_and(|i| i.worktree_path.is_some());
(cwd, is_worktree)
}
/// Updates the cached branch/worktree display on the matching agent so the
/// status bar can render without spawning `git` on every frame.
pub(super) fn handle_git_head_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(params) =
serde_json::from_str::<kigi_workspace::session::git::GitHeadChanged>(notif.params.get())
else {
return false;
};
// Find the agent by ACP session id (not local AgentId) and update its git display cache
if let Some(agent) = app.agents.values_mut().find(|a| {
a.session
.session_id
.as_ref()
.is_some_and(|s| s.0.as_ref() == params.session_id.as_str())
}) {
// Refresh the shared per-cwd git cache so views keyed on this
// directory (the header / top bar when it's the process cwd) pick
// up the new branch without spawning subprocesses; the agent's own
// fields below drive its status bar / dashboard row directly.
crate::git_info::update_from_notification(
&agent.session.cwd,
params.branch.as_deref(),
params.main_repo.clone(),
);
agent.current_branch = params.branch;
agent.is_worktree = params.is_worktree;
agent.main_repo = params.main_repo;
return true;
}
// Fallback: check child subagent views.
for agent in app.agents.values_mut() {
if let Some(child_view) = agent.subagent_views.values_mut().find(|cv| {
cv.session
.session_id
.as_ref()
.is_some_and(|s| s.0.as_ref() == params.session_id.as_str())
}) {
crate::git_info::update_from_notification(
&child_view.session.cwd,
params.branch.as_deref(),
params.main_repo.clone(),
);
child_view.current_branch = params.branch;
child_view.is_worktree = params.is_worktree;
child_view.main_repo = params.main_repo;
return true;
}
}
false
}
pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
// The payload is a SessionNotification wrapping TaskCompleted { task_snapshot }
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
tracing::warn!("Failed to parse x.ai/task_completed");
return false;
};
let (task_snapshot, will_wake) = match session_notif.update {
XaiSessionUpdate::TaskCompleted {
task_snapshot,
will_wake,
} => (task_snapshot, will_wake),
_ => return false,
};
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
Some(t) => t,
None => return false,
};
let task_id = &task_snapshot.task_id;
let exit_code = task_snapshot.exit_code;
let signal = task_snapshot.signal.clone();
tracing::info!(
task_id = %task_id,
exit_code = ?exit_code,
signal = ?signal,
"Background task completed"
);
// Determine success once, reused for both bg_task status and scrollback block.
let success = exit_code == Some(0) || (exit_code.is_none() && signal.is_none());
// Synthetic completion emitted by the agent's cold-load reconciliation
// (`reconcile_stale_background_tasks`): the task's process died with a
// previous session lifetime — it did not fail NOW. Finalize pane/state
// quietly instead of pushing a fresh red "Task failed" block into the
// resumed scrollback (one per dead task — pure noise on every resume).
let stale_on_load = signal.as_deref() == Some("session_restart");
let child_sid: &str = session_notif.session_id.0.as_ref();
let Some((session, scrollback)) = resolve_target_view(agent, matched, child_sid) else {
return false;
};
// Compute elapsed duration from the bg task state (if we have it).
// Prefer the human description for "Task completed/failed: …" labels
// (same as "Task started"), falling back to the raw command only when
// no description was supplied.
let (command, elapsed, mut description, scrollback_entry_id, was_running) =
if let Some(bg_task) = session.bg_tasks.get_mut(task_id) {
let was_running = bg_task.status == BgTaskStatus::Running;
bg_task.status = if success {
BgTaskStatus::Done
} else {
BgTaskStatus::Failed
};
bg_task.exit_code = exit_code;
bg_task.signal = signal.clone();
bg_task.end_time = Some(std::time::SystemTime::now());
bg_task.pending_kill = false;
bg_task.kill_requested_at = None;
(
bg_task.command.clone(),
bg_task.elapsed(),
bg_task.description.clone(),
bg_task.scrollback_entry_id,
was_running,
)
} else {
// Task we didn't know about — use snapshot data. Prefer
// display_command when it differs from the raw command (monitors /
// isolation-wrapped shells); treat equal values as non-labels.
let command = task_snapshot.command.clone();
let elapsed = task_snapshot
.end_time
.and_then(|end| end.duration_since(task_snapshot.start_time).ok())
.unwrap_or_default();
let description = task_snapshot.display_command.clone().and_then(|d| {
// Strip the baked "[monitor] " prefix so the completed label
// matches the "Task started" path (which uses the bare
// monitor description), not "[monitor] …".
let d = d
.strip_prefix("[monitor] ")
.map(str::to_string)
.unwrap_or(d);
let t = d.trim();
if t.is_empty() || t == command.trim() {
None
} else {
Some(d)
}
});
// Unknown task: it never counted toward the parked marker's
// running total, so its completion is not a countdown edge.
(command, elapsed, description, None, false)
};
// Finish the "Task started" scrollback entry (stops bullet animation).
// Also sync description onto that block so the historical "Task started"
// line shows the label if it was missing at background time.
if let Some(entry_id) = scrollback_entry_id {
if let Some(entry) = scrollback.get_by_id_mut(entry_id)
&& let RenderBlock::BgTask(bg) = &mut entry.block
{
if description.as_ref().is_none_or(|d| d.trim().is_empty()) {
if let Some(d) = bg.description.clone().filter(|d| !d.trim().is_empty()) {
description = Some(d);
}
} else if bg.description.as_ref().is_none_or(|d| d.trim().is_empty()) {
bg.description = description.clone();
entry.invalidate_cache();
}
}
scrollback.finish_running(entry_id);
}
// Keep central store in sync when we recovered a description from the
// scrollback block (or display_command fallback).
if let Some(bg_task) = session.bg_tasks.get_mut(task_id)
&& bg_task
.description
.as_ref()
.is_none_or(|d| d.trim().is_empty())
&& let Some(ref d) = description
&& !d.trim().is_empty()
{
bg_task.description = Some(d.clone());
}
if stale_on_load {
// The replayed "Task started" block above is finished (static
// bullet); the pane row leaves the running filter via the status
// update. No new scrollback block: nothing happened in THIS session.
return is_active;
}
// Emit "Task completed/failed" scrollback block — uses description when
// present so the label matches "Task started: <desc>" (not raw command).
let block = if success {
RenderBlock::bg_task_completed(&command, task_id, elapsed)
.with_bg_task_description(description)
} else {
RenderBlock::bg_task_failed(&command, task_id, elapsed, exit_code, signal)
.with_bg_task_description(description)
};
scrollback.push_block(block);
// Parked countdown: a Running command just finished under the parked
// "Worked for … still running" story. Root sessions only: a subagent-local
// task never counted toward the root marker's total. Re-borrow the
// agent — `resolve_target_view` consumed the earlier `&mut`.
if was_running
&& !matches!(matched, SessionMatch::Child(_))
&& let Some(agent) = app.agents.get_mut(&matched.agent_id())
{
agent.maybe_push_parked_marker();
}
// Between turns, a root-session completion re-emits the work-only status
// line so the story stays chronological (zero left: no line). When a wake
// response follows (`will_wake`, stamped by the shell), the wake turn's
// end marker carries the fresh counts instead — skip the line. Child
// (subagent) tasks route their chip to the child view above and never
// count toward the root marker — no root status line for them. Mutually
// exclusive with the parked tick above: parked means the turn is still
// running, which `maybe_push_work_status`'s busy gate refuses.
if !will_wake
&& !matches!(matched, SessionMatch::Child(_))
&& let Some(agent) = app.agents.get_mut(&matched.agent_id())
{
agent.maybe_push_work_status();
}
is_active
}
@@ -0,0 +1,109 @@
use super::*;
/// Max follow-up chips kept from a single (server-controlled) notification.
pub(super) const MAX_FOLLOW_UPS: usize = 6;
/// Max chars kept per (server-controlled) suggestion label.
pub(super) const MAX_FOLLOW_UP_LABEL: usize = 256;
/// Max `response_id` length accepted; longer ids are rejected (not truncated —
/// truncation could collide ids) so an oversized server id can't bloat the
/// retained `follow_up_seen` ring.
pub(super) const MAX_RESPONSE_ID_LEN: usize = 128;
/// Deserialize shape of the `x.ai/follow_ups` params emitted by the shell
/// translator: `{ response_id, suggestions: [{ label, .. }] }`. The keys are
/// prost-derived snake_case — NOT camelCase like most other pager
/// notification payloads — so this struct must match snake_case verbatim.
/// Every field defaults so a malformed/partial payload degrades to "no
/// chips" instead of erroring.
#[derive(serde::Deserialize)]
pub(super) struct FollowUpsParams {
#[serde(default)]
response_id: String,
#[serde(default)]
suggestions: Vec<FollowUpSuggestionParam>,
/// Turn identity stamped by the shell (the same `promptId` it puts on every
/// `session/update`). OPTIONAL — older shells omit it; when present it makes
/// viewer-adoption dedup deterministic (see
/// [`AgentView::apply_follow_ups_with_prompt`]). camelCase to match the
/// `promptId` convention the shell uses on `session/update` meta.
#[serde(default, rename = "promptId")]
prompt_id: Option<String>,
/// Reserved replay marker carrier. Absent in v1 (the shell never sets
/// it); honored from day one so future replay producers need no pager
/// change. Parsed loosely as a JSON value to read the `"x.ai/replayed"`
/// key (a slash-bearing key prost cannot model as a field).
#[serde(default, rename = "_meta")]
meta: Option<serde_json::Value>,
}
/// A single `x.ai/follow_ups` suggestion. Only the human-facing `label` is
/// consumed; `properties` / `tool_overrides` (also in the wire shape) are
/// ignored.
#[derive(serde::Deserialize)]
pub(super) struct FollowUpSuggestionParam {
#[serde(default)]
label: String,
}
/// Sanitize a server-supplied suggestion label for safe chip rendering and
/// submission: drop control + bidi/format characters
/// ([`is_unsafe_display_char`](crate::render::line_utils::is_unsafe_display_char)),
/// bound the length, and trim surrounding whitespace.
pub(super) fn sanitize_suggestion(label: &str) -> String {
let cleaned: String = label
.chars()
.filter(|c| !crate::render::line_utils::is_unsafe_display_char(*c))
.take(MAX_FOLLOW_UP_LABEL)
.collect();
cleaned.trim().to_owned()
}
/// Handle `x.ai/follow_ups` — render follow-up suggestion chips for the
/// latest assistant response.
///
/// Newest-response-wins keying lives in [`AgentView::apply_follow_ups`]. The
/// reserved `_meta["x.ai/replayed"] == true` marker suppresses rendering (it
/// is absent today and treated as optional). The params carry no session id,
/// so chips target the active agent; a background agent's follow-ups would
/// mis-route — a forwarding obligation for the shell to add a session id.
/// Server-controlled count and label length are bounded and labels sanitized
/// at ingestion. Malformed/partial payloads are ignored (no chip, no panic).
pub(super) fn handle_follow_ups(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(params) = serde_json::from_str::<FollowUpsParams>(notif.params.get()) else {
return false;
};
if params
.meta
.as_ref()
.and_then(|m| m.get("x.ai/replayed"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return false;
}
// The response id is the newest-wins key (and is retained in the seen
// ring); drop a notification with a missing or oversized one.
if params.response_id.is_empty() || params.response_id.len() > MAX_RESPONSE_ID_LEN {
return false;
}
// Bound count + length at ingestion (a stored cap, not just render-time).
let suggestions: Vec<String> = params
.suggestions
.into_iter()
.take(MAX_FOLLOW_UPS)
.filter_map(|s| {
let label = sanitize_suggestion(&s.label);
(!label.is_empty()).then_some(label)
})
.collect();
let ActiveView::Agent(id) = app.active_view else {
return false;
};
let Some(agent) = app.agents.get_mut(&id) else {
return false;
};
agent.apply_follow_ups_with_prompt(params.response_id, params.prompt_id.as_deref(), suggestions)
}
@@ -0,0 +1,252 @@
use super::*;
/// Handle `x.ai/ask_user_question` ext-method.
///
/// Parses the typed request, creates a `QuestionViewState` with the
/// `response_tx` stashed, and opens the question overlay. The pager does
/// NOT respond immediately — the response is sent later when the user
/// submits, cancels, or is replaced by another question.
///
/// If a question is already active, the old one is cancelled first
/// (`Cancelled` is sent on its stashed `response_tx`).
pub(crate) fn handle_ask_user_question(
ext: kigi_acp_lib::AcpArgs<acp::ExtRequest>,
app: &mut AppView,
) -> bool {
use crate::views::question_view::QuestionViewState;
use kigi_tools::implementations::grok_build::ask_user_question::{
AskUserQuestionExtRequest, AskUserQuestionExtResponse,
};
// Parse the typed request from the ext-method params.
let ext_req: AskUserQuestionExtRequest = match serde_json::from_str(ext.request.params.get()) {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, "Failed to parse AskUserQuestionExtRequest");
ext.response_tx
.send(Err(acp::Error::new(-32602, format!("Invalid params: {e}"))))
.ok();
return false;
}
};
// Route by the request's session id (like `session/update`), so a question
// raised by a BACKGROUND session lands on its own view even when the user is
// on the dashboard or another session — rather than failing because the
// user hasn't entered the session yet.
let Some(id) = interaction_target_agent(app, &ext_req.session_id) else {
// No local view for this session. Do NOT send an error — that would FAIL
// the tool (rendered red). Leave the reverse-request unanswered: the
// agent keeps awaiting and the leader replays it when a client attaches
// via `session/load`.
tracing::info!(
session_id = %ext_req.session_id,
"ask_user_question for a session with no local view; parked for leader replay-on-attach"
);
drop(ext.response_tx);
return false;
};
let is_active = is_matched_agent_active(app, id);
let Some(agent) = app.agents.get_mut(&id) else {
// `interaction_target_agent` only returns ids that exist; defensive.
tracing::warn!("ask_user_question: agent {id:?} not found");
drop(ext.response_tx);
return false;
};
// If a question is already active, cancel it before replacing.
if let Some(mut old_qv) = agent.question_view.take() {
agent.turn_paused_duration += old_qv.opened_at.elapsed();
tracing::warn!(
old_tool_call_id = %old_qv.tool_call_id,
new_tool_call_id = %ext_req.tool_call_id,
"Replacing active question - cancelling previous"
);
if let Some(old_tx) = old_qv.response_tx.take() {
let cancelled = AskUserQuestionExtResponse::Cancelled;
let raw = serde_json::value::to_raw_value(&cancelled)
.expect("Cancelled serialization should not fail");
old_tx.send(Ok(acp::ExtResponse::new(raw.into()))).ok();
}
// Restore the old stashed prompt before stashing the new one.
agent.prompt.restore(old_qv.stashed_prompt);
// Inverse-collision: the displaced question was a
// local one (e.g. /fork, /new) -- surface a system-block marker so
// the user understands why their modal vanished. The directive
// payload (if any) is dropped; the user can re-issue the command
// after answering the model's question.
if let Some(ref kind) = old_qv.local_kind {
use crate::views::question_view::LocalQuestionKind;
let cmd = match kind {
LocalQuestionKind::Fork { .. } => "/fork",
LocalQuestionKind::NewSession => "/new",
LocalQuestionKind::CreditLimitUpsell => "credit-limit upsell",
LocalQuestionKind::FreeUsageUpsell => "SuperGrok upsell",
LocalQuestionKind::AgentTypeMismatch { .. } => "model switch",
LocalQuestionKind::ProjectSelect { .. } => "project select",
};
agent.scrollback.push_block(RenderBlock::system(format!(
"{cmd} cancelled by model question"
)));
}
}
// Stash the current prompt and create the question view.
agent.question_view = Some(QuestionViewState::with_response_tx(
ext_req.tool_call_id,
ext_req.questions,
agent.prompt.stash(),
Some(ext.response_tx),
ext_req.mode,
));
// Clear prompt for question interaction.
agent.prompt.set_text("");
// Stamp the "last activity" anchor so the
// dashboard's NeedsInput row reflects "time since this question
// arrived" rather than the previous turn's end time.
agent.last_active_at = Some(std::time::Instant::now());
tracing::info!(
mode = ?ext_req.mode,
question_count = agent.question_view.as_ref().map(|q| q.questions.len()).unwrap_or(0),
target_active = is_active,
"Opened question view from ext_method"
);
// Only the currently-displayed view needs an immediate redraw; a question
// parked on a background agent surfaces via the roster `NeedsInput` delta
// and renders when the user switches to that session.
is_active
}
/// Handle an `x.ai/exit_plan_mode` ext_method request.
///
/// Creates a `PlanApprovalViewState` overlay for interactive approval.
///
/// Follows the `handle_ask_user_question` pattern: parse → guard → cancel old
/// → stash prompt → create state → clear prompt → return true.
pub(super) fn handle_exit_plan_mode(
ext: kigi_acp_lib::AcpArgs<acp::ExtRequest>,
app: &mut AppView,
) -> bool {
use crate::views::plan_approval_view::{ExitPlanModeExtRequest, PlanApprovalViewState};
// 1. Parse typed request from raw JSON params.
let params: ExitPlanModeExtRequest = match serde_json::from_str(ext.request.params.get()) {
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to parse ExitPlanModeExtRequest: {e}");
ext.response_tx
.send(Err(acp::Error::new(
-32602,
format!("Invalid exit_plan_mode params: {e}"),
)))
.ok();
return false;
}
};
// 2. Route by the request's session id (like `session/update`), so a
// plan-approval raised by a BACKGROUND session lands on its own view even
// when the user isn't currently focused on it — rather than failing.
let Some(id) = interaction_target_agent(app, &params.session_id) else {
// No local view for this session. Do NOT error (that fails the tool):
// leave the reverse-request unanswered and rely on the leader's
// replay-on-attach.
tracing::info!(
session_id = %params.session_id,
"exit_plan_mode for a session with no local view; parked for leader replay-on-attach"
);
drop(ext.response_tx);
return false;
};
let is_active = is_matched_agent_active(app, id);
let Some(agent) = app.agents.get_mut(&id) else {
// `interaction_target_agent` only returns ids that exist; defensive.
tracing::warn!("exit_plan_mode: agent {id:?} not found");
drop(ext.response_tx);
return false;
};
if let Some(mut old) = agent.plan_approval_view.take() {
tracing::warn!(
old_tool_call_id = %old.tool_call_id,
new_tool_call_id = %params.tool_call_id,
"Replacing active plan approval — dismissing previous"
);
old.send_stale_cancel();
agent.plan_next_comment_id = old.next_comment_id;
agent.prompt.restore(old.stashed_prompt);
agent.line_viewer = None;
}
// Dismiss competing overlays so plan approval owns the screen.
// - active_modal: draw returns before line_viewer (plan never paints);
// keys still route to the invisible plan viewer.
// - block_viewer: draw returns on line_viewer (plan visible) but
// handle_scroll prefers block_viewer, so wheel hits the hidden Edit pane.
agent.active_modal = None;
agent.block_viewer = None;
let source = plan_review_source_for_tool(&params.tool_call_id, agent);
// If the user was mid-casual-comment when this new plan-approval
// request arrived, restore the pre-comment prompt first so the
// upcoming `stash()` captures the user's original text rather
// than the in-progress comment draft. Also clears the now-stale
// `casual_stashed_prompt` so it doesn't dangle into the next
// casual entry.
if let Some(stashed) = agent.casual_stashed_prompt.take() {
agent.prompt.restore(stashed);
}
let stashed = agent.prompt.stash();
let state = PlanApprovalViewState::with_source(params, source, stashed, ext.response_tx);
agent.plan_comments.clear();
agent.plan_next_comment_id = 0;
if state.source == PlanReviewSource::Inline {
agent.latest_inline_plan_content = state.plan_content.clone();
} else {
agent.latest_inline_plan_content = None;
}
agent.plan_approval_view = Some(state);
agent.prompt.set_text("");
agent.casual_commenting_range = None;
agent.casual_editing_comment_id = None;
agent.show_plan_preview_if_available();
if agent.line_viewer.is_some() {
if let Some(ref mut viewer) = agent.line_viewer {
viewer.plan_mut().feedback_active = true;
}
} else if let Some(ref mut pav) = agent.plan_approval_view {
pav.focus = crate::views::plan_approval_view::PlanApprovalFocus::Prompt;
}
tracing::info!(
target_active = is_active,
"Opened plan approval view from ext_method"
);
// Background-parked approval renders when the user switches to the session;
// only the active view needs an immediate redraw.
is_active
}
pub(super) fn plan_review_source_for_tool(
tool_call_id: &str,
agent: &AgentView,
) -> PlanReviewSource {
agent
.session
.tracker
.tool_title(tool_call_id)
.filter(|title| *title == "CreatePlan" || *title == "Plan: Submit for approval")
.map_or(PlanReviewSource::FileBacked, |_| PlanReviewSource::Inline)
}
@@ -0,0 +1,331 @@
use super::*;
/// Cached `mcp.push_server_status` flag resolution.
///
/// Resolution mirrors the `mcp.liveness_watchers` flag pattern but
/// only stacks the env+default layers — the pager process does not load
/// `config.toml` / `requirements.toml`. The first call performs one
/// `BoolFlag::env` read; every subsequent call is a pure
/// `OnceLock::get` (single atomic load). Default `true`; set
/// `KIGI_MCP_PUSH_SERVER_STATUS=0` to disable.
pub(super) fn push_server_status_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
kigi_shell::util::config::resolve_mcp_push_server_status(
/* requirements */ None, /* user */ None, /* managed */ None,
)
})
}
pub(super) fn handle_mcp_init_progress(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
total: u32,
connected: u32,
#[serde(default)]
session_id: Option<String>,
}
let Ok(payload) = serde_json::from_str::<Payload>(notif.params.get()) else {
return false;
};
let Some((is_active, agent)) = mcp_target_agent(app, payload.session_id.as_deref()) else {
return false;
};
if let Some(ref mut progress) = agent.mcp_init_progress {
progress.total = payload.total;
progress.connected = payload.connected;
} else {
agent.mcp_init_progress = Some(super::super::agent_view::McpInitProgress {
total: payload.total,
connected: payload.connected,
started_at: std::time::Instant::now(),
});
}
is_active
}
/// Handle `x.ai/mcp/tools_changed` and `x.ai/mcp_initialized`.
///
/// Routing rules (verified against the four shell emit sites in
/// `kigi-shell/src/session/acp_session.rs` — toggle-tool ~L6661,
/// `emit_mcp_tools_changed_notifications` ~L8997 and post-handshake
/// ~L10156, plus `mcp_initialized` ~L10157):
///
/// 1. Try `notif.params.sessionId`. All `tools_changed` emit
/// sites carry `sessionId` (the typed
/// [`kigi_shell::extensions::mcp::McpToolsChanged`] struct), and
/// `mcp_initialized` already carried it. So the sessionId branch
/// is the primary path for current builds.
///
/// 2. Older shells / forward-compat (**`tools_changed` only**): a
/// payload with no `sessionId` falls back to
/// `app.active_view`. Older shells emit `tools_changed` as
/// `{serverName, tools}` with no `sessionId`; the fallback keeps
/// those in-flight payloads working. The `mcp_initialized`
/// variant does NOT need this fallback — its emitter already
/// carries `sessionId`, so
/// the sessionId branch (step 1) is the only matched-build path
/// for `mcp_initialized`.
///
/// 3. When the owning agent has an open extensions modal, schedules
/// a debounced [`Effect::FetchMcpsList`] coalesced **per-agent**
/// (see [`agent_has_pending_mcps_fetch`]). A pending fetch
/// on agent A does NOT drop a notification for agent B.
///
/// Always clears `mcp_init_progress` on the `mcp_initialized` variant.
pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let method = notif.method.as_ref();
// Both `x.ai/mcp_initialized` and (newer shell)
// `x.ai/mcp/tools_changed` carry `sessionId`. Route by it so a
// background agent's notification updates *its* state — not
// whichever agent is foregrounded. Unknown and subagent (child)
// sessions are dropped; a missing sessionId falls back to the
// active agent (legacy shells).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
#[serde(default)]
session_id: Option<String>,
}
let session_id = serde_json::from_str::<Payload>(notif.params.get())
.ok()
.and_then(|p| p.session_id);
let target: Option<(bool, AgentId)> = match session_id.as_deref() {
Some(sid) => {
let sid = acp::SessionId::new(sid);
match find_session_match(app, &sid) {
// Subagent (child) sessions don't own the top-level MCP
// modal / connecting indicator — drop them.
Some(SessionMatch::Child(_)) => None,
Some(matched) => {
let id = matched.agent_id();
Some((is_matched_agent_active(app, id), id))
}
None => None,
}
}
None => match app.active_view {
ActiveView::Agent(id) => Some((true, id)),
_ => None,
},
};
let Some((is_active, id)) = target else {
return false;
};
let mut redraw = false;
// `mcp_initialized` clears the matched agent's connecting indicator.
if method == "x.ai/mcp_initialized"
&& let Some(agent) = app.agents.get_mut(&id)
&& agent.mcp_init_progress.take().is_some()
{
redraw |= is_active;
}
// Modal refresh: schedule a debounced refetch for the OWNING agent
// (routed by sessionId — was active_view), per-agent coalesced.
let modal_open = app
.agents
.get(&id)
.is_some_and(|a| a.extensions_modal.is_some());
if modal_open
&& !agent_has_pending_mcps_fetch(app, id)
&& let Some(session_id) = app
.agents
.get(&id)
.and_then(|a| a.session.session_id.clone())
{
app.pending_effects.push(Effect::FetchMcpsList {
agent_id: id,
session_id,
cache: true,
});
redraw |= is_active;
}
redraw
}
/// Per-agent coalescing test for [`Effect::FetchMcpsList`].
/// An earlier approach used `matches!(e, FetchMcpsList { .. })`
/// which collapsed across agents — a pending fetch on agent A would
/// drop the push for agent B. Now we key on `agent_id` so each
/// agent's refetch is independently debounced.
pub(super) fn agent_has_pending_mcps_fetch(app: &AppView, agent_id: AgentId) -> bool {
app.pending_effects.iter().any(|e| {
matches!(
e,
Effect::FetchMcpsList { agent_id: a, .. } if *a == agent_id
)
})
}
/// Handle `x.ai/mcp/server_status`.
///
/// Routes by the notification's `sessionId` via
/// [`find_session_match`] — the matched agent's extensions modal is
/// patched in-place via [`crate::views::mcps_modal::patch_server_row`]
/// using the per-row delta, avoiding the full `mcp/list` round trip
/// the legacy `tools_changed` debounced refetch path requires.
///
/// No-ops when:
/// - the `sessionId` does not match any known agent (drop),
/// - the matched agent has no extensions modal open (cheap path —
/// the next `/mcps` open will pull fresh data anyway),
/// - the modal's `mcps_data` is not yet `Loaded` (Loading / Error
/// states would produce incoherent patches; the in-flight fetch
/// will land a consistent snapshot shortly),
/// - the named server is not present in the cached `servers` vec
/// ([`patch_server_row`] silently returns).
///
/// Re-uses the shell's canonical wire types
/// ([`kigi_shell::extensions::mcp::McpServerStatusPayload`] +
/// [`kigi_shell::extensions::mcp::McpServerStatus`]) instead of
/// re-declaring a parallel pager enum. Later variants (e.g.
/// `RestartSucceeded` / `RestartFailed`) ride through automatically
/// without a pager code change.
///
/// `status` is **not** `serde(default)`; a malformed
/// payload falls into the `tracing::warn!` arm rather than silently
/// re-painting the row red.
///
/// `tools` is decoded loosely as
/// `Option<serde_json::Value>` so a future non-array shape doesn't
/// drop the entire push — `status` still applies, and `tools` is
/// silently skipped (warn-logged) on shape mismatch.
///
/// Returns `true` (request redraw) only when the row mutation
/// happened AND the matched agent is the currently active view.
pub(super) fn handle_mcp_server_status(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
use crate::views::extensions_modal::TabDataState;
use crate::views::mcps_modal::{McpServerDisplayStatus, McpToolDetail, patch_server_row};
use kigi_shell::extensions::mcp::{McpServerStatus, McpServerStatusPayload, McpToolEntry};
let Ok(payload) = serde_json::from_str::<McpServerStatusPayload>(notif.params.get()) else {
tracing::warn!(
"Failed to parse x.ai/mcp/server_status: {}",
&notif.params.get()
[..crate::render::line_utils::floor_char_boundary(notif.params.get(), 100)]
);
return false;
};
let session_id = acp::SessionId::new(payload.session_id);
let Some(matched) = find_session_match(app, &session_id) else {
return false;
};
let id = matched.agent_id();
let is_active = is_matched_agent_active(app, id);
let Some(agent) = app.agents.get_mut(&id) else {
return false;
};
// Cheap path: modal closed. Drop the push — the next `/mcps`
// open will fetch a fresh full list.
let Some(modal) = agent.extensions_modal.as_mut() else {
return false;
};
// Cheap path: list still loading / errored. Patching would
// produce incoherent state; the in-flight fetch will land
// a consistent snapshot momentarily.
let TabDataState::Loaded(ref mut servers) = modal.mcps_data else {
return false;
};
let display_status = match payload.status {
McpServerStatus::Ready => McpServerDisplayStatus::Ready,
McpServerStatus::Initializing => McpServerDisplayStatus::Initializing,
McpServerStatus::Unavailable => McpServerDisplayStatus::Unavailable,
McpServerStatus::NeedsAuth => McpServerDisplayStatus::NeedsAuth,
};
// Decode `tools` loosely. Shell types this as
// `Option<serde_json::Value>` (always `null` today;
// reserved). If the value is present but not an array of
// `McpToolEntry`-isomorphic objects we drop ONLY the tools
// update and still apply the status — the previous strict
// typing would have dropped the whole push on any shape
// mismatch.
let new_tools = payload.tools.and_then(|raw| {
match serde_json::from_value::<Vec<McpToolEntry>>(raw) {
Ok(entries) => Some(
entries
.into_iter()
.map(|t| McpToolDetail {
name: t.name,
display_name: t.display_name,
description: t.description,
enabled: t.enabled,
})
.collect::<Vec<_>>(),
),
Err(e) => {
tracing::warn!(
server = %payload.name,
error = %e,
"x.ai/mcp/server_status: tools field present but not Vec<McpToolEntry>; status still applied"
);
None
}
}
});
let mutated = patch_server_row(servers, &payload.name, display_status, new_tools);
mutated && is_active
}
/// Handle `x.ai/mcp/servers_updated`.
///
/// Emitted by the shell from `MvpAgent` on managed-config resolve and
/// on config reload (`crates/codegen/kigi-shell/src/agent/mvp_agent.rs`
/// → `notify_servers_updated`). The shell's
/// `McpServersUpdated` wire shape (`{ mcpServers: [...] }`) is
/// intentionally session-agnostic by design
/// An attempt to route by
/// `sessionId` therefore always fell back to `app.active_view` and
/// re-created the multi-agent bug.
///
/// Routing now correctly broadcasts: every agent with an open
/// extensions modal gets a per-agent debounced [`Effect::FetchMcpsList`].
/// Per-agent coalescing keeps a second push from displacing an
/// in-flight fetch on the same agent. Agents without an open modal
/// drop the push (cheap path).
pub(super) fn handle_mcp_servers_updated(_notif: &acp::ExtNotification, app: &mut AppView) -> bool {
// `_notif` is intentionally unread. The shell's
// `McpServersUpdated` payload is `{ mcpServers: [...] }` with no
// `sessionId` (the protocol forbids extending it), so there is
// nothing in the notification body the broadcast model needs.
// Do NOT "fix" this back to per-session routing without
// re-reading the rustdoc above.
//
// Snapshot (agent_id, session_id, modal_open) up front so the
// mutable `pending_effects` borrow can proceed without
// aliasing `app.agents`.
let targets: Vec<(AgentId, acp::SessionId)> = app
.agents
.iter()
.filter_map(|(id, agent)| {
if agent.extensions_modal.is_some() {
agent.session.session_id.clone().map(|sid| (*id, sid))
} else {
None
}
})
.collect();
if targets.is_empty() {
return false;
}
let mut redraw = false;
for (id, session_id) in targets {
if agent_has_pending_mcps_fetch(app, id) {
continue;
}
let is_active = is_matched_agent_active(app, id);
app.pending_effects.push(Effect::FetchMcpsList {
agent_id: id,
session_id,
cache: true,
});
redraw |= is_active;
}
redraw
}
@@ -0,0 +1,705 @@
//! ACP message handling.
//!
//! Routes incoming [`AcpClientMessage`] notifications to the appropriate
//! agent's tracker, queues permission requests for interactive handling,
//! and xAI session extension notifications (`x.ai/session_notification` and
//! replay-path `x.ai/session/update`).
use std::collections::hash_map::Entry;
use std::path::PathBuf;
use std::sync::Arc;
use agent_client_protocol as acp;
use kigi_acp_lib::AcpClientMessage;
use super::actions::Effect;
use kigi_shell::extensions::notification::{
SessionNotification, SessionUpdate as XaiSessionUpdate, is_reauthable_failure,
};
use kigi_shell::tools::todo::todo_item_from_plan_entry;
use kigi_workspace::permission::bash_command_splitting::BashCommandHighlights;
use crate::acp::meta::NotificationMeta;
use crate::acp::tracker::AcpUpdateTracker;
use crate::acp::tracker::TurnActivity;
use crate::app::agent::{
AgentId, AgentSession, AgentState, BgTaskState, BgTaskStatus, GoalDisplayPhase,
GoalDisplayState, GoalDisplayStatus,
};
use crate::notifications::{NotificationEvent, NotificationEventKind};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
use crate::views::permission_view::{
McpScope, McpScopeState, PermissionFocus, PermissionViewState, SubagentInfo,
};
use crate::views::plan_approval_view::PlanReviewSource;
use super::agent_view::{AgentView, InputMode};
use super::app_view::{ActiveView, AppView};
mod background;
mod follow_ups;
mod interactions;
mod mcp;
mod permissions;
mod prompt_origin;
mod queue;
mod routing;
mod session_notification;
mod settings;
mod subagent_activity;
#[cfg(test)]
use permissions::{MCP_ARGS_MAX_LINE_CHARS, MCP_ARGS_MAX_LINES, mcp_args_lines};
use permissions::{apply_recap_block, handle_permission_request, should_drop_late_auto_recap};
// Hub + child modules (via `use super::*`) need sibling symbols in this scope.
use routing::{
SessionMatch, find_session_match, interaction_target_agent, is_matched_agent_active,
mcp_target_agent, resolve_notif_agent, resolve_target_view,
};
pub(crate) use prompt_origin::{
is_server_initiated_prompt, is_wake_prompt, should_adopt_running_prompt,
};
use prompt_origin::{push_wake_end_marker, viewer_turn_anchor, wake_turn_elapsed};
pub(crate) use subagent_activity::finalize_killed_subagent;
use subagent_activity::{subagent_activity_label, sync_subagent_activity};
#[cfg(test)]
pub(crate) use session_notification::apply_session_event_for_test;
use session_notification::{
advance_reconnect_cursor, confirm_context_used, detect_plan_mode_change,
drop_unexpected_replay, handle_session_notification,
};
pub(crate) use queue::PendingRunningAdoption;
use queue::{handle_prompt_complete, handle_queue_changed};
use background::{
derive_child_cwd, handle_git_head_changed, handle_monitor_event, handle_scheduled_task_created,
handle_scheduled_task_deleted, handle_scheduled_task_fired,
handle_scheduled_task_inject_prompt, handle_task_backgrounded, handle_task_completed,
route_bg_task_stdout,
};
use follow_ups::handle_follow_ups;
pub(crate) use interactions::handle_ask_user_question;
use interactions::handle_exit_plan_mode;
use mcp::{
handle_mcp_init_progress, handle_mcp_server_status, handle_mcp_servers_updated,
handle_mcp_tools_changed, push_server_status_enabled,
};
use settings::{handle_models_update, handle_sessions_changed, handle_settings_update};
// Test-only bare-name surface for `tests/*` (`use super::*`).
#[cfg(test)]
#[allow(unused_imports)]
use background::*;
#[cfg(test)]
#[allow(unused_imports)]
use follow_ups::*;
#[cfg(test)]
#[allow(unused_imports)]
use interactions::*;
#[cfg(test)]
#[allow(unused_imports)]
use mcp::*;
#[cfg(test)]
#[allow(unused_imports)]
use prompt_origin::*;
#[cfg(test)]
#[allow(unused_imports)]
use queue::*;
#[cfg(test)]
#[allow(unused_imports)]
use routing::*;
#[cfg(test)]
#[allow(unused_imports)]
use session_notification::*;
#[cfg(test)]
#[allow(unused_imports)]
use settings::*;
#[cfg(test)]
#[allow(unused_imports)]
use subagent_activity::*;
/// Handle an ACP notification (session update, permission request, etc.).
///
/// Returns `true` if the active view was visually affected (needs redraw).
/// Notifications are routed to the agent whose `session_id` matches, even when
/// that agent is not the currently active view -- streaming chunks for a
/// background agent must still land in its own scrollback so the user sees
/// the full turn after switching back.
pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
match msg {
AcpClientMessage::SessionNotification(notif) => {
let mut meta = NotificationMeta::from_json(notif.request.meta.as_ref());
// Wait-state bookkeeping after the agent borrow ends (parked marker).
let mut wait_state_agent: Option<AgentId> = None;
let affected = match find_session_match(app, &notif.request.session_id) {
Some(SessionMatch::Root(id)) => {
let is_active = is_matched_agent_active(app, id);
wait_state_agent = Some(id);
// Read before the agent borrow below.
let stashed_adoption_pid = app
.pending_running_adoptions
.get(&id)
.map(|p| p.prompt_id.clone());
let agent = app
.agents
.get_mut(&id)
.expect("find_session_match returned an existing AgentId");
// Live-only dedup: a per-session `eventId` highwater drops
// re-delivered live duplicates (leader fan-out, reconnect
// re-emit). Replay is EXEMPT — the per-process counter resets
// each resume, so persisted history concatenates non-monotonic
// 0..N runs; gating it by the highwater would latch a pre-reset
// peak and truncate the restored transcript. Replayed
// history is authoritative + ordered, so it always renders and
// never seeds the highwater.
//
// Premise: ACP-stream live delivery is in id order —
// actor ACP lines (chunks and the plan-mode
// `CurrentModeUpdate`s) are stamped at `event_tx` enqueue
// time and drained FIFO. The xAI stream is direct-emitted
// and keeps a SEPARATE highwater (see the xAI dedup in
// `handle_session_notification`). Residual class: ACP
// lines that skip `event_tx` — the bridge's bash stdout
// (no `event_tx` surface) and the turn-start user echo —
// can mint an id after, but deliver before, queued
// lower-id lines; with chunk buffering off on pager
// sessions that window is one actor drain hop (accepted).
let dedup_drop = !meta.is_replay
&& meta.event_seq.is_some_and(|seq| {
agent.last_applied_event_seq.is_some_and(|last| seq <= last)
});
if let Some(seq) = meta.event_seq
&& !meta.is_replay
&& !dedup_drop
{
agent.last_applied_event_seq = Some(seq);
}
if drop_unexpected_replay(
agent,
&meta,
notif.request.session_id.0.as_ref(),
"session/update",
) {
notif.response_tx.send(Ok(())).ok();
return false;
}
// Re-derive the per-turn viewer flag from prompt-id
// ownership BEFORE the adopt/drop gate below.
//
// `attached_as_viewer` starts true on a `session/load`
// attach and is cleared when this client sends its own
// prompt — but a client that has driven a turn can later
// VIEW a turn ANOTHER client drives (a `/loop` cron, or a
// plain prompt typed in a different pane). Left sticky-false,
// the gate dropped those deltas and the pane rendered
// nothing. A non-synthetic prompt id this client never
// originated is another client's turn → view it; one it
// originated is its own → drive it (strict gate).
//
// Server-initiated / auto-wake turns (synthetic prompt ids)
// are excluded: they have no client finish path, so they
// must not flip the role (see the adopt gate below).
//
// Only re-derive on a real, non-replay, non-duplicate delta
// that does NOT match the active turn.
if !dedup_drop
&& !meta.is_replay
&& let Some(notif_pid) = meta.prompt_id.as_deref()
&& agent.session.current_prompt_id.as_deref() != Some(notif_pid)
&& !is_server_initiated_prompt(notif_pid)
{
agent.attached_as_viewer = !agent.is_self_originated_prompt(notif_pid);
}
// Store context usage and turn timing on agent state.
//
// Gate on `!dedup_drop`: a deduped delta is an
// already-applied or stale out-of-order event (its
// `eventId` is `<=` the highwater). A fresher event has
// already advanced the highwater and set newer `totalTokens`
// / `turnStartMs`, so applying the stale values here would
// REGRESS them. This is the replay/live-overlap case (leader
// fan-out, reconnect, re-emit after the gate): a historical
// replay delta carrying a LOWER `totalTokens` arriving after
// a live one would otherwise drop the context bar below the
// real usage. The dedup already drops the render; the
// token/timing state must respect it too.
if !dedup_drop {
if let Some(tokens) = meta.total_tokens {
confirm_context_used(agent, tokens);
}
if let Some(ts) = meta.turn_start_ms {
agent.turn_start_ms = Some(ts);
// A wake turn's end marker derives elapsed from its
// deltas — non-adopted turns have no other timing
// source. `turnStartMs` is constant per turn, so
// record once per pid (the equality check also
// skips the classifier on the turn's later deltas).
if let Some(pid) = meta.prompt_id.as_deref()
&& agent.wake_turn_start.as_ref().map(|(p, _)| p.as_str())
!= Some(pid)
&& is_wake_prompt(pid)
{
agent.wake_turn_start = Some((pid.to_string(), ts));
}
}
}
// Track CurrentModeUpdate to refresh settings modals
// after the per-agent borrow releases.
let mut plan_mode_modal_refresh_needed = false;
// Extract Plan updates before passing to tracker (tracker skips them).
let mutated = if dedup_drop {
tracing::debug!(
session_id = notif.request.session_id.0.as_ref(),
event_seq = meta.event_seq,
last_applied = agent.last_applied_event_seq,
is_replay = meta.is_replay,
"load-race: session/update DROPPED by dedup highwater (event_seq <= last_applied)"
);
// Already-applied event delivered again — drop it (do not
// re-render). Not a mutation, so no redraw.
false
} else if let acp::SessionUpdate::Plan(plan) = notif.request.update {
let items: Vec<_> = plan
.entries
.into_iter()
.map(todo_item_from_plan_entry)
.collect();
agent.todo.update_todos(items);
agent.mark_reload_todo_update();
advance_reconnect_cursor(agent, &mut meta);
!meta.is_replay && !agent.session.loading_replay
} else if let acp::SessionUpdate::ToolCallUpdate(ref tcu) = notif.request.update
&& route_bg_task_stdout(tcu, &mut agent.session)
{
// Stdout chunk for a bg task — routed to central store,
// not to the scrollback tracker.
advance_reconnect_cursor(agent, &mut meta);
!meta.is_replay && !agent.session.loading_replay
} else if !meta.is_replay
&& let Some(notif_pid) = meta.prompt_id.as_ref()
&& agent.session.current_prompt_id.as_ref() != Some(notif_pid)
&& !agent.attached_as_viewer
&& stashed_adoption_pid.as_deref() == Some(notif_pid.as_str())
{
// FIFO handoff: the server already promoted this
// prompt but its adoption waits on the previous turn's
// PromptResponse — buffer for the shim's flush. Not
// applied, so the reconnect cursor does not advance.
if agent.pending_adoption_updates.len()
< super::agent_view::MAX_PENDING_ADOPTION_UPDATES
{
tracing::debug!(
target: "qtrace",
pid = std::process::id(),
event = "adoption_update_buffered",
prompt_id = %notif_pid,
"buffering session/update for the stashed pending adoption",
);
agent.pending_adoption_updates.push((
notif_pid.clone(),
notif.request.update,
meta.clone(),
));
} else {
tracing::debug!(
prompt_id = %notif_pid,
"pending-adoption buffer full; dropping update (kept prefix)",
);
}
false
} else if !meta.is_replay
&& let Some(notif_pid) = meta.prompt_id.as_ref()
&& agent.session.current_prompt_id.as_ref() != Some(notif_pid)
&& !((agent.session.current_prompt_id.is_none()
|| agent
.session
.current_prompt_id
.as_deref()
.is_some_and(is_server_initiated_prompt))
&& is_server_initiated_prompt(notif_pid))
&& !agent.attached_as_viewer
{
tracing::debug!(
session_id = notif.request.session_id.0.as_ref(),
notif_prompt_id = meta.prompt_id.as_deref(),
current_prompt_id = agent.session.current_prompt_id.as_deref(),
attached_as_viewer = agent.attached_as_viewer,
loading_replay = agent.session.loading_replay,
"load-race: session/update DROPPED by promptId-mismatch gate on a non-viewer (stale/rewound-turn guard)"
);
// The notification's `promptId` does not match the
// currently-active prompt. Drop — belongs to a rewound
// turn or stale in-flight work.
//
// EXCEPTION (multi-client / leader mode): a viewer
// (`attached_as_viewer`) is watching a session another
// client is driving. It has no turn of its own, so a
// mismatching `promptId` is NOT stale — it is the
// driver's live (or next) turn. Fall through to the
// adoption branch below so the delta renders instead of
// freezing the viewer at its load snapshot. This is
// scoped to viewers so a locally-created driver's
// post-rewind stale-chunk drop is preserved (a driver
// always has `attached_as_viewer == false`).
!agent.session.loading_replay
} else {
// Adopt a mismatching `promptId` so subsequent chunks for
// the same turn match and render — but ONLY for a viewer
// watching another client's turn.
//
// Server-initiated / auto-wake turns (synthetic prompt
// ids) are deliberately NOT adopted here: they have no
// client finish path (no PromptResponse, no
// prompt_complete), so occupying `current_prompt_id`
// would strand the turn-status and make later turns'
// PromptResponses get discarded. Their content still
// renders — the drop gate above passes synthetic deltas
// through when `current_prompt_id` is None/synthetic.
//
// (Cron `scheduler-fired-…` turns ARE client-driven and
// have a `prompt_complete` exit; a viewer enters their
// running chrome via the `queue/changed` shim adoption
// in `handle_queue_changed`, not here.)
if let Some(notif_pid) = meta.prompt_id.as_ref()
&& agent.session.current_prompt_id.as_ref() != Some(notif_pid)
&& agent.attached_as_viewer
{
// The driver's next turn closes the between-turns
// status window on this pane too.
agent.end_work_announced = false;
agent.session.current_prompt_id = Some(notif_pid.clone());
// A viewer adopting another client's new turn: drop
// the prior turn's chips but KEEP the seen ring so a
// stale prior-turn replay stays rejected. The adopted
// turn's own follow_ups (if already applied then
// cleared here) still re-render: `apply_follow_ups`
// matches their stamped `promptId` to the now-current
// `current_prompt_id` set just above.
agent.clear_follow_ups();
// The adopted turn's follow_ups may have arrived on
// the ext channel BEFORE this session/update (separate
// channels) and been buffered — render them now that
// the turn is current.
agent.flush_pending_follow_ups(notif_pid);
}
// Detect plan mode transitions from tool call completions.
plan_mode_modal_refresh_needed |=
detect_plan_mode_change(&notif.request.update, agent);
let had_activity_before = agent.session.tracker.activity().is_some();
agent.session.handle_update(
notif.request.update,
&meta,
&mut agent.scrollback,
);
// Once the server has emitted any activity (chunk, tool,
// retry, etc.), the in-flight prompt can no longer be
// "rewound" by Ctrl+C. Clear the stash on the transition.
if !had_activity_before && agent.session.tracker.activity().is_some() {
agent.session.in_flight_prompt = None;
// Log initial TTFA once per turn (activity flips None→Some each loop).
if let Some(started) = agent.turn_started_at
&& agent.first_activity_logged_for != Some(started)
{
agent.first_activity_logged_for = Some(started);
let activity_label = agent
.session
.tracker
.activity()
.map(|a| a.as_label())
.unwrap_or("unknown");
let ttfa_ms = started.elapsed().as_millis() as u64;
let sid = agent.session.session_id.as_ref().map(|s| s.0.as_ref());
crate::unified_log::info(
"turn.first_activity",
sid,
Some(serde_json::json!({
"ttfa_ms": ttfa_ms,
"activity": activity_label,
})),
);
}
}
// Drain pending ACP commands immediately after handle_update.
// This is the SINGLE generation bump site — ensures exactly
// one bump per AvailableCommandsUpdate received.
if let Some(commands) = agent.session.tracker.take_pending_acp_commands() {
agent.session.available_commands = commands;
agent.session.available_commands_generation += 1;
}
// Tools list arrives in the same update's `meta` payload.
// Stash it on the session so the per-frame sync in
// `app_view.rs` can push it through to the slash registry
// alongside the command catalog.
if let Some(tools) = agent.session.tracker.take_pending_acp_tools() {
agent.session.available_tools = Some(tools.into_iter().collect());
}
for entry_id in agent.session.tracker.take_pending_edit_hl() {
agent.submit_edit_highlight(entry_id);
}
// Viewer chrome (leader / multi-client). A viewer has no
// turn of its own and never calls start_turn(), so it
// would stay `Idle` — hiding the "⠿ Responding…" status
// line, the elapsed/token counter, and the Ctrl+c:cancel
// / Ctrl+Enter:interject footer hints (all gated on
// `AgentState::TurnRunning`). Enter TurnRunning whenever a
// turn is in flight (a prompt id is adopted) and we are
// not already running.
//
// This is placed AFTER `handle_update` (not in the adopt
// block above) on purpose: the adopt block only fires on
// a prompt-id MISMATCH and is suppressed during the
// `loading_replay` window. A client that reattaches
// MID-turn adopts the running id during its replay window
// (TurnRunning suppressed there) and then receives
// post-load deltas that MATCH `current_prompt_id` — which
// skip the adopt block — so it would never flip to
// TurnRunning. Checking here on every applied live viewer
// delta closes that gap, independent of whether the load
// response conveyed `runningPromptId`, of delta ordering,
// and of whether a given delta carries a prompt id.
//
// Do NOT call start_turn(): it resets the tracker and
// arms `expect_user_echo()`, which would corrupt the
// driver's live stream. We only flip state + stamp the
// elapsed timer (monotonic: only on the Idle→TurnRunning
// transition).
//
// Enter TurnRunning only for an adoptable prompt — see
// `should_adopt_running_prompt` (true iff the turn has a
// terminal `prompt_complete` exit). This is what lets a
// viewer (and the dashboard's locally-tracked row, which
// reads live turn state) show a running `/loop` session as
// Working without stranding "Responding…" forever on an
// exit-less auto-wake / server-initiated turn.
if agent.attached_as_viewer
&& !meta.is_replay
&& !agent.session.loading_replay
&& agent
.session
.current_prompt_id
.as_deref()
.is_some_and(should_adopt_running_prompt)
&& !matches!(agent.session.state, AgentState::TurnRunning)
{
agent.session.state = AgentState::TurnRunning;
// Back-date from the authoritative `turnStartMs` so a
// viewer's elapsed matches the driver's instead of
// starting at the time-to-first-delta.
agent.turn_started_at = Some(viewer_turn_anchor(agent.turn_start_ms));
}
advance_reconnect_cursor(agent, &mut meta);
!meta.is_replay && !agent.session.loading_replay
};
if plan_mode_modal_refresh_needed {
crate::app::dispatch::refresh_open_settings_modals(app);
}
// Mutation always happens; redraw only when the matched
// agent is the visible one.
mutated && is_active
}
Some(SessionMatch::Child(parent_id)) => {
let is_active = is_matched_agent_active(app, parent_id);
let parent = app
.agents
.get_mut(&parent_id)
.expect("find_session_match returned an existing AgentId");
// Re-derive the &str key to avoid making SessionMatch::Child
// carry an owned String (see find_session_match docs).
let child_key: &str = notif.request.session_id.0.as_ref();
let activity_label = {
let child_view = parent
.subagent_views
.get_mut(child_key)
.expect("find_session_match returned an existing subagent_views key");
if let Some(tokens) = meta.total_tokens {
confirm_context_used(child_view, tokens);
}
if let Some(ts) = meta.turn_start_ms {
child_view.turn_start_ms = Some(ts);
}
child_view.session.handle_update(
notif.request.update,
&meta,
&mut child_view.scrollback,
);
for entry_id in child_view.session.tracker.take_pending_edit_hl() {
child_view.submit_edit_highlight(entry_id);
}
subagent_activity_label(child_view)
};
sync_subagent_activity(parent, child_key, activity_label);
is_active
}
None => {
tracing::debug!(
session_id = notif.request.session_id.0.as_ref(),
agent_count = app.agents.len(),
"load-race: session/update DROPPED — no agent matches session_id (view not loaded yet?)"
);
false
}
};
if let Some(aid) = wait_state_agent {
// Parked marker (any tab — the update that created the wait state stamps the park time).
if let Some(agent) = app.agents.get_mut(&aid) {
agent.maybe_push_parked_marker();
}
}
notif.response_tx.send(Ok(())).ok();
affected
}
AcpClientMessage::RequestPermission(perm) => handle_permission_request(perm, app),
AcpClientMessage::ExtNotification(ext) => {
let affected = handle_ext_notification(&ext.request, app);
ext.response_tx.send(Ok(())).ok();
affected
}
AcpClientMessage::ExtMethod(ext) => handle_ext_method(ext, app),
AcpClientMessage::WaitForTerminalExit(args) => {
args.response_tx
.send(Err(crate::acp::wait_for_exit_not_supported("pager")))
.ok();
false
}
_ => false,
}
}
/// Handle an xAI extension notification.
///
/// Dispatches on method string:
/// - `x.ai/session_notification` / `x.ai/session/update` → per-agent session updates
fn handle_ext_notification(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
match notif.method.as_ref() {
"x.ai/session_notification" | "x.ai/session/update" => {
handle_session_notification(notif, app)
}
"x.ai/follow_ups" => handle_follow_ups(notif, app),
"x.ai/task_backgrounded" => handle_task_backgrounded(notif, app),
"x.ai/task_completed" => handle_task_completed(notif, app),
"x.ai/models/update" => handle_models_update(notif, app),
"x.ai/settings/update" => handle_settings_update(notif, app),
"x.ai/sessions/changed" => handle_sessions_changed(notif, app),
"x.ai/queue/changed" => handle_queue_changed(notif, app),
// TODO(prompt_complete-deprecation): Legacy removal (gated): durable turn_completed is already consumed via finalize_turn_from_terminal; keep & re-point the lost-RPC reconcile to the durable rail before deleting.
"x.ai/session/prompt_complete" => handle_prompt_complete(notif, app),
"x.ai/session/interjection" => handle_interjection(notif, app),
"x.ai/monitor_event" => handle_monitor_event(notif, app),
"x.ai/scheduled_task_created" => handle_scheduled_task_created(notif, app),
"x.ai/scheduled_task_fired" => handle_scheduled_task_fired(notif, app),
"x.ai/scheduled_task_deleted" => handle_scheduled_task_deleted(notif, app),
"x.ai/scheduled_task_inject_prompt" => handle_scheduled_task_inject_prompt(notif, app),
"x.ai/git_head_changed" => handle_git_head_changed(notif, app),
"x.ai/mcp/init_progress" => handle_mcp_init_progress(notif, app),
"x.ai/mcp/tools_changed" | "x.ai/mcp_initialized" => handle_mcp_tools_changed(notif, app),
"x.ai/mcp/server_status" if push_server_status_enabled() => {
handle_mcp_server_status(notif, app)
}
"x.ai/mcp/servers_updated" => handle_mcp_servers_updated(notif, app),
_ => false,
}
}
/// Handle `x.ai/session/interjection` — the leader broadcasts this
/// sessionId-bearing notification to every attached client when a mid-turn
/// interjection is queued (emitted from the session actor's `Interject`
/// command handler). Each client renders the interjection as a scrollback
/// block.
///
/// The originating pager renders an optimistic block immediately in
/// `dispatch_interject` and records the interjection id in
/// `self_interjection_ids`; when its own broadcast echoes back here it is
/// deduped (dropped) by that id. Other panes (which never minted the id) render
/// the block — fixing the multi-client bug where an interjection typed in one
/// pane was invisible in the others. A `null`/absent id (older shell) always
/// renders, so legacy shells degrade to "render everywhere" rather than drop.
fn handle_interjection(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(notif.params.get()) else {
tracing::warn!("Failed to parse x.ai/session/interjection");
return false;
};
let Some(session_id) = parsed.get("sessionId").and_then(|v| v.as_str()) else {
return false;
};
let Some(text) = parsed.get("text").and_then(|v| v.as_str()) else {
return false;
};
let interjection_id = parsed.get("interjectionId").and_then(|v| v.as_str());
let sid = acp::SessionId::new(session_id.to_string());
let Some(SessionMatch::Root(id)) = find_session_match(app, &sid) else {
return false;
};
let is_active = is_matched_agent_active(app, id);
let Some(agent) = app.agents.get_mut(&id) else {
return false;
};
// Dedup our own optimistic echo: if we minted this id we already rendered
// the block locally — drop the broadcast copy (and forget the id).
if let Some(iid) = interjection_id
&& agent.self_interjection_ids.remove(iid)
{
return false;
}
agent
.scrollback
.push_block(RenderBlock::interjection_prompt(text));
// Interjecting into a parked wait continues the turn below this block —
// the withheld "Worked for …" marker must not fire late beneath it
// (shared-queue interjects render only via this broadcast, and the shell
// emits the queue-emptying `x.ai/queue/changed` right after it).
agent.suppress_parked_marker_on_interject();
is_active
}
/// Handle an ACP `ext_method` request (blocking request that expects a response).
///
/// Dispatches on method string. Unknown methods get `method_not_found` error.
/// The response sender is stashed (for `ask_user_question`) or replied to
/// immediately (for unknown methods).
fn handle_ext_method(ext: kigi_acp_lib::AcpArgs<acp::ExtRequest>, app: &mut AppView) -> bool {
match ext.request.method.as_ref() {
"x.ai/ask_user_question" => handle_ask_user_question(ext, app),
"x.ai/exit_plan_mode" => handle_exit_plan_mode(ext, app),
unknown => {
tracing::warn!("Unknown ext_method: {unknown}");
ext.response_tx
.send(Err(acp::Error::new(
-32601,
format!("Method not found: {unknown}"),
)))
.ok();
false
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,424 @@
use super::*;
// ---------------------------------------------------------------------------
// Permission request handling
// ---------------------------------------------------------------------------
/// Route a permission request to the agent that owns its `session_id`, queue
/// it on that agent's view, and return whether the active view needs a redraw.
///
/// Permissions are routed by `session_id` so that requests for an inactive
/// agent still queue on the owning agent's view. When the user switches back
/// to that agent, the queued permission is visible and can be answered.
///
/// If no agent owns the `session_id` (e.g. session was just cleaned up), the
/// request is cancelled rather than left dangling.
///
/// YOLO mode is honored on the owning agent regardless of which agent is
/// currently active, so background turns aren't blocked waiting for an
/// always-yes answer the user has already given.
pub(super) fn handle_permission_request(
perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>,
app: &mut AppView,
) -> bool {
// 1. Look up the owning agent by session_id (root or subagent view).
let matched = match find_session_match(app, &perm.request.session_id) {
Some(m) => m,
None => {
tracing::warn!(
session_id = %perm.request.session_id.0,
"Permission request for unknown session_id; cancelling"
);
cancel_permission(perm);
return false;
}
};
let owning_agent_id = matched.agent_id();
let is_active = is_matched_agent_active(app, owning_agent_id);
let Some(agent) = app.agents.get_mut(&owning_agent_id) else {
cancel_permission(perm);
return false;
};
// 2. YOLO mode: auto-approve immediately on the owning agent so background
// turns aren't blocked waiting for the user to switch back.
//
// If no `AllowOnce` option exists, falls through to
// `enqueue_permission` even in YOLO mode (won't pick
// `AllowAlways` by default).
if agent.session.is_yolo()
&& let Some(allow) = perm
.request
.options
.iter()
.find(|o| o.kind == acp::PermissionOptionKind::AllowOnce)
{
let option_id = allow.option_id.clone();
perm.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
option_id,
)),
)))
.ok();
return false; // no redraw needed
}
// 3. Fire notification so the user notices the pending approval.
// Rate-limit: only fire the bell/popup on the empty→non-empty
// transition to avoid stacking notifications during concurrent
// permission requests.
if !app
.notification_service
.should_suppress_permission_notification()
{
app.notification_service.notify(NotificationEvent {
kind: NotificationEventKind::ApprovalRequired,
title: "Grok".into(),
body: NotificationEventKind::ApprovalRequired.as_str().into(),
session_id: Some(perm.request.session_id.0.to_string()),
});
app.notification_service.mark_permission_notified();
}
// 4. Queue on the owning agent's view. Subagent provenance for display
// is still resolved via subagent_sessions in enqueue_permission().
// Redraw is only needed when the owning agent is currently visible.
let needs_redraw = enqueue_permission(perm, agent);
needs_redraw && is_active
}
/// Enqueue a permission request on an agent view.
///
/// Parses bash highlights, builds display content, stashes the prompt on
/// queue transition, and pushes the request onto the FIFO queue.
fn enqueue_permission(
perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>,
agent: &mut AgentView,
) -> bool {
// 1. Parse bash highlights from request meta (imported from kigi-shell).
let bash_highlights: Option<BashCommandHighlights> = perm
.request
.meta
.as_ref()
.and_then(|meta| serde_json::from_value(serde_json::Value::Object(meta.clone())).ok());
let bash_selection_count = bash_highlights
.as_ref()
.map(|h| kigi_workspace::permission::default_always_allow_scope(&h.highlighted_words))
.unwrap_or(0);
// 1b. Parse MCP scope state from the `allow-always-mcp` option's meta.
// Mutually exclusive with the bash flow at the per-request level —
// the same prompt cannot carry both.
let mcp_scope = perm
.request
.options
.iter()
.find(|o| o.option_id.0.as_ref() == "allow-always-mcp")
.and_then(|opt| opt.meta.as_ref())
.and_then(|m| {
serde_json::from_value::<kigi_workspace::permission::McpToolPermission>(
serde_json::Value::Object(m.clone()),
)
.ok()
})
.map(|perm| McpScopeState {
tool_name: perm.tool_name,
server_prefix: perm.server_prefix,
selected: McpScope::Tool,
});
// 2. Build subagent provenance label.
// If session_id differs from the root session, look up subagent info.
let subagent_label = resolve_subagent_label(agent, &perm.request.session_id);
// 3. Build title and description from the tool call.
let (title, description, bash_command_raw) =
build_permission_display(&perm.request, bash_highlights.as_ref());
// 4. Assign a monotonic ID.
let perm_id = agent.next_perm_req_id;
agent.next_perm_req_id += 1;
// 5. Stash prompt on queue transition: empty -> non-empty.
// Do NOT stash again if the queue is already non-empty (that would
// capture followup text from the current permission as the "original"
// prompt, losing the user's real input).
if agent.permission_queue.is_empty() && agent.permission_stashed_prompt.is_none() {
agent.permission_stashed_prompt = Some(agent.prompt.stash());
agent.prompt.set_text("");
}
// 6. Clone options before moving perm into the struct.
let options = perm.request.options.clone();
// 7. Cursor preselection (sticky last-used → configured default → the
// enable-always-approve row → index 0). See `permission_cursor`.
let active_idx = crate::appearance::permission_cursor::resolve_initial_cursor(&options);
// 8. Queue the request FIFO (do NOT replace/cancel existing requests).
agent.permission_queue.push_back(PermissionViewState {
request: perm,
id: perm_id,
focus: PermissionFocus::Options,
options,
active_idx,
bash_highlights,
bash_selection_count,
bash_command_raw,
mcp_scope,
title,
description,
args_expanded: false,
desc_scroll: 0,
subagent_label,
options_area_height: 0,
options_scroll_offset: 0,
});
// Stamp the agent's "last activity" anchor so
// the dashboard's age column for `NeedsInput` rows reflects
// "time since permission arrived" rather than "time since last
// turn ended". The same field powers the dashboard relative-time label.
agent.last_active_at = Some(std::time::Instant::now());
true // needs redraw
}
/// Build a subagent provenance label for display.
///
/// Two tiers of provenance quality:
///
/// 1. **Tracked provenance** (`SubagentSpawned` was received): renders as
/// `Subagent "Find endpoints" (explore):` with description and type
/// from the tracked `SubagentInfo`. This is the trusted path.
///
/// 2. **Opaque non-root session**: the session_id does not match root and
/// is not in the tracked subagent map. Renders as
/// `Child session (untracked):` to signal reduced confidence.
///
/// Returns `None` for root session (no provenance needed).
fn resolve_subagent_label(agent: &AgentView, session_id: &acp::SessionId) -> Option<String> {
let sid = session_id.0.as_ref();
// Check if this is the root session (no provenance needed).
if let Some(ref root_sid) = agent.session.session_id
&& root_sid.0.as_ref() == sid
{
return None;
}
// Tier 1: tracked subagent with full metadata.
if let Some(info) = agent.subagent_sessions.get(sid) {
return Some(format!(
"Subagent \"{}\" ({}):",
info.description, info.subagent_type
));
}
// Tier 2: non-root session with no tracked info.
Some("Child session (untracked):".to_string())
}
/// Build title, description lines, and optional raw command for a permission request.
///
/// Deserializes `raw_input` into the shared [`BashToolInput`] from
/// `kigi-tools` for typed access to `command` and `description`.
/// Falls back to ACP-level `title`/`kind` fields when deserialization fails.
///
/// Returns `(title, description, bash_command_raw)`.
fn build_permission_display(
req: &acp::RequestPermissionRequest,
bash_highlights: Option<&BashCommandHighlights>,
) -> (String, Vec<String>, Option<String>) {
let is_bash = bash_highlights.is_some();
let bash_input = req.tool_call.fields.raw_input.as_ref().and_then(|v| {
serde_json::from_value::<kigi_tools::implementations::BashToolInput>(v.clone()).ok()
});
let raw_command = bash_input.as_ref().map(|b| b.command.clone()).or_else(|| {
req.tool_call
.fields
.title
.as_deref()
.and_then(|t| t.strip_prefix("Execute `"))
.and_then(|t| t.strip_suffix('`'))
.map(|s| s.to_string())
});
let bash_description = bash_input.map(|b| b.description);
let is_execute = is_bash
|| req.tool_call.fields.kind == Some(acp::ToolKind::Execute)
|| raw_command.is_some();
let title = if is_execute {
bash_description
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| t.to_string())
.unwrap_or_else(
|| match bash_highlights.and_then(|h| h.highlighted_words.first()) {
Some(bin) => format!("Allow `{bin}`?"),
None => "Allow Execute?".to_string(),
},
)
} else if is_edit_permission(req) {
let file_path = req
.tool_call
.fields
.raw_input
.as_ref()
.and_then(|v| v.get("file_path"))
.and_then(|v| v.as_str());
if let Some(path) = file_path {
format!("Allow Edit to {}?", path)
} else if let Some(ref t) = req.tool_call.fields.title {
format!(
"Allow {}?",
kigi_workspace::permission::mcp_pretty_name_if_qualified(t)
)
} else {
"Allow Edit?".to_string()
}
} else if let Some(ref t) = req.tool_call.fields.title {
format!(
"Allow {}?",
kigi_workspace::permission::mcp_pretty_name_if_qualified(t)
)
} else {
match req.tool_call.fields.kind {
Some(acp::ToolKind::Edit) => "Allow Edit?".to_string(),
Some(acp::ToolKind::Execute) => "Allow Execute?".to_string(),
Some(acp::ToolKind::Delete) => "Allow Delete?".to_string(),
_ => "Allow?".to_string(),
}
};
let description = mcp_args_lines(req);
let bash_cmd = if is_execute { raw_command } else { None };
(title, description, bash_cmd)
}
/// Maximum stored lines for the MCP planned-arguments display. The overlay
/// clips further (options always stay visible); this only bounds memory for
/// pathologically large inputs.
pub(super) const MCP_ARGS_MAX_LINES: usize = 200;
/// Maximum stored characters per argument line. Bounds the per-frame
/// wrap/render cost for pathological single-line values (e.g. an embedded
/// base64 blob); anything longer is elided with a marker.
pub(super) const MCP_ARGS_MAX_LINE_CHARS: usize = 2000;
/// Pretty-printed JSON lines of the arguments an MCP tool call plans to
/// send, taken from the serialized `ToolInput` the shell puts in
/// `tool_call.fields.raw_input` (`{"variant": "UseTool"|"MCPTool",
/// "tool_name": …, "tool_input": …}`). The prompt otherwise names the tool
/// without what it would do — the payload is what an approval (especially
/// an always-approve) is judged on.
///
/// Empty for non-MCP requests (bash/edit have dedicated displays) and for
/// MCP requests without a JSON `tool_input`.
pub(super) fn mcp_args_lines(req: &acp::RequestPermissionRequest) -> Vec<String> {
let Some(raw) = req.tool_call.fields.raw_input.as_ref() else {
return Vec::new();
};
// Match by serde tag rather than deserializing the whole enum, so args
// still display when the shell adds variants this build doesn't know.
let is_mcp = matches!(
raw.get("variant").and_then(|v| v.as_str()),
Some("UseTool") | Some("MCPTool")
);
if !is_mcp {
return Vec::new();
}
let args = match raw.get("tool_input") {
Some(serde_json::Value::Null) | None => return Vec::new(),
Some(args) => args,
};
let pretty = match serde_json::to_string_pretty(args) {
Ok(p) => p,
Err(_) => return Vec::new(),
};
let mut lines: Vec<String> = pretty
.lines()
.map(|l| match l.char_indices().nth(MCP_ARGS_MAX_LINE_CHARS) {
Some((byte_idx, _)) => format!("{}", &l[..byte_idx]),
None => l.to_owned(),
})
.collect();
if lines.len() > MCP_ARGS_MAX_LINES {
let hidden = lines.len() - MCP_ARGS_MAX_LINES;
lines.truncate(MCP_ARGS_MAX_LINES);
lines.push(format!("… (+{hidden} more lines)"));
}
lines
}
/// Check if this is an edit permission by looking at option names.
///
/// The shell's edit options include "allow all edits" in the AllowAlways
/// option name. This is reliable even when tool_call.fields.kind is None.
fn is_edit_permission(req: &acp::RequestPermissionRequest) -> bool {
req.options.iter().any(|o| {
o.kind == acp::PermissionOptionKind::AllowAlways && o.name.to_lowercase().contains("edit")
})
}
/// Cancel a permission request by sending `Cancelled` on the response channel.
fn cancel_permission(perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>) {
perm.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Cancelled,
)))
.ok();
}
/// Live auto recap arrived while the agent is busy (turn or command in
/// flight) — drop so it cannot land under newer output. Manual `/recap` and
/// history replay always apply.
pub(super) fn should_drop_late_auto_recap(auto: bool, is_replay: bool, agent_idle: bool) -> bool {
auto && !is_replay && !agent_idle
}
/// Land a `SessionRecap` block: fill a manual `/recap`'s in-flight loading
/// spinner in place (and stop its animation) when one is showing, otherwise
/// append a fresh block. An automatic recap never consumes the manual loading
/// slot (`auto`) — it would orphan the in-flight manual response into a second
/// block — so it always appends.
///
/// Minimal (scrollback-native) mode may have already printed the loading
/// spinner into the terminal's native scrollback (the idle commit pass consumes
/// it print-once). Filling that entry in place would mutate state the terminal
/// never re-reads — the recap text would exist only in `/transcript`, never on
/// screen. Re-print instead: drop the stale committed entry from state (its
/// printed copy can't be un-printed, matching the K10 re-print semantics) and
/// append the real recap as a fresh block so the commit pass emits it.
/// `is_committed` is always false outside minimal, so the fill-in-place path is
/// unchanged for the alt-screen / inline modes.
pub(super) fn apply_recap_block(agent: &mut AgentView, auto: bool, recap_block: RenderBlock) {
let fill_id = if auto {
None
} else {
agent
.pending_recap_entry
.take()
.filter(|&id| agent.scrollback.get_by_id(id).is_some())
};
match fill_id {
Some(id) if agent.scrollback.is_committed(id) => {
agent.scrollback.remove_entry(id);
agent.scrollback.push_block(recap_block);
}
Some(id) => {
// Existence just confirmed; scope the `&mut` borrow so it
// ends before `finish_running` re-borrows the scrollback.
if let Some(entry) = agent.scrollback.get_by_id_mut(id) {
entry.block = recap_block;
}
agent.scrollback.finish_running(id);
}
None => {
agent.scrollback.push_block(recap_block);
}
}
}
@@ -0,0 +1,126 @@
use super::*;
/// Returns true if the prompt_id was generated by the shell for a
/// server-initiated auto-wake turn (background task or subagent completion).
pub(crate) fn is_server_initiated_prompt(prompt_id: &str) -> bool {
kigi_shell::session::PromptOrigin::from_prompt_id(prompt_id).is_synthetic()
}
/// Returns true if the prompt_id is a scheduled-task (`/loop`) fire.
///
/// Cron turns are synthetic (so [`is_server_initiated_prompt`] is also true for
/// them), but UNLIKE auto-wake / subagent-completion turns they are
/// CLIENT-driven via `MvpAgent::prompt()` and therefore DO emit a matching
/// `x.ai/session/prompt_complete` turn-end signal. A viewer can thus safely
/// enter `TurnRunning` for them (the exit exists, so it won't strand) — which is
/// what lets the dashboard show a running `/loop` session as Working.
pub(crate) fn is_scheduler_fired_prompt(prompt_id: &str) -> bool {
matches!(
kigi_shell::session::PromptOrigin::from_prompt_id(prompt_id),
kigi_shell::session::PromptOrigin::SchedulerFired
)
}
/// Returns true for the auto-wake turn families (`task-completed-…`,
/// `subagent-completed-…`, `notifications-…`). These run non-adopted — no
/// `PromptResponse`, no viewer finalize — so their durable `TurnCompleted` is
/// the only signal marking the back-to-idle point, and it pushes the turn-end
/// marker directly. Deliberately narrower than "non-adopted synthetic": goal
/// turns render through the goal chip/loop chrome and `plan-resume-…` keeps
/// its current markerless shape.
pub(crate) fn is_wake_prompt(prompt_id: &str) -> bool {
matches!(
kigi_shell::session::PromptOrigin::from_prompt_id(prompt_id),
kigi_shell::session::PromptOrigin::TaskCompleted { .. }
| kigi_shell::session::PromptOrigin::SubagentCompleted { .. }
| kigi_shell::session::PromptOrigin::NotificationDrain
)
}
/// Whether a running `prompt_id` is adoptable — i.e. safe to bind as the
/// viewer's `current_prompt_id` and show as a live `TurnRunning`. The invariant:
/// adoptable iff the turn emits a terminal `x.ai/session/prompt_complete`, the
/// only non-interactive way a viewer leaves `TurnRunning`. That holds for
/// user-driven turns and `/loop` (`scheduler-fired-…`) fires — both run via
/// `MvpAgent::prompt()` — and is false for actor-run synthetic turns
/// (task-completed / subagent-completion / notification-drain / goal-*), which
/// have no such exit, so adopting one strands the viewer in `TurnRunning`.
///
/// This is the pure pid-only synthetic-turn guard. It serves the live-delta
/// viewer gate and the `queue/changed` turn-start shim (the latter negated, as a
/// skip) directly. The two adoption-on-load paths (`SessionLoaded` in
/// `dispatch.rs`, the reconnect-reload `finalize_reload_and_maybe_adopt`) instead
/// call the agent-aware [`AgentView::should_adopt_running_prompt`], which ANDs
/// this guard with "not terminal-in-replay" so an already-ended turn replayed on
/// reattach is not re-adopted.
///
/// [`AgentView::should_adopt_running_prompt`]: crate::app::agent_view::AgentView::should_adopt_running_prompt
pub(crate) fn should_adopt_running_prompt(prompt_id: &str) -> bool {
!is_server_initiated_prompt(prompt_id) || is_scheduler_fired_prompt(prompt_id)
}
/// Compute the monotonic anchor a VIEWER should use as its turn-start time.
///
/// A viewer adopts the driver's turn mid-stream, so stamping `Instant::now()`
/// would make its elapsed counter — and the final "Worked for X" marker
/// (both read off `turn_started_at` via [`AgentView::turn_elapsed`]) — read
/// LESS than the driver's by the time-to-first-delta. The shell records the
/// authoritative wall-clock turn start in `meta.turnStartMs` (UTC ms, stamped
/// at the top of the turn), so back-date the anchor from it; the viewer's
/// elapsed then matches the driver's, both live and on completion. Falls back
/// to `now` when `turnStartMs` is absent (older shell) or the wall clock is
/// skewed forward.
pub(super) fn viewer_turn_anchor(turn_start_ms: Option<i64>) -> std::time::Instant {
let now = std::time::Instant::now();
let Some(start_ms) = turn_start_ms else {
return now;
};
let elapsed_ms = chrono::Utc::now()
.timestamp_millis()
.saturating_sub(start_ms);
if elapsed_ms <= 0 {
return now;
}
now.checked_sub(std::time::Duration::from_millis(elapsed_ms as u64))
.unwrap_or(now)
}
/// Elapsed for a wake turn's end marker: its delta-borne `turnStartMs`
/// ([`AgentView::wake_turn_start`], consumed here on a pid match) to the
/// terminal's `agentTimestampMs` — both stamped by the shell clock, so client
/// skew cancels (fall back to client now when the stamp is missing). `None`
/// — no tracked start (old shells / no deltas seen) or a nonsensical negative
/// span — renders the marker without a duration rather than lying with
/// "0.0s".
pub(super) fn wake_turn_elapsed(
agent: &mut AgentView,
prompt_id: &str,
end_ms: Option<i64>,
) -> Option<std::time::Duration> {
let (_, start_ms) = agent.wake_turn_start.take_if(|(pid, _)| pid == prompt_id)?;
let end_ms = end_ms.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
u64::try_from(end_ms - start_ms)
.ok()
.map(std::time::Duration::from_millis)
}
/// Push a wake turn's end marker via the shared terminal-marker helper so the
/// wake turn's OWN stop hooks (pid-matched stash) render inline on the marker
/// instead of as a stray block. A REAL turn's leftover stash (pid mismatch)
/// must stay pending for its own marker rail — never fold into, nor flush
/// standalone on, an unrelated wake — hence `preserve_mismatched_stash`.
pub(super) fn push_wake_end_marker(
agent: &mut AgentView,
prompt_id: &str,
elapsed: Option<std::time::Duration>,
) {
// Wake turns skip PromptResponse; finish streaming so a trailing ` is flushed.
agent.session.tracker.finish_turn(&mut agent.scrollback);
crate::app::turn_completion::push_turn_terminal_marker(
agent,
Some(SessionEvent::TurnCompleted { elapsed }),
Some(prompt_id),
/* preserve_mismatched_stash */ true,
);
}
@@ -0,0 +1,413 @@
use super::*;
/// A server-authoritative running prompt that drained into the running slot
/// while the previous turn was still finishing locally (FIFO handoff
/// race). Stashed on [`AppView::pending_running_adoptions`] and consumed by the
/// `PromptResponse` handler after `finish_turn` clears `current_prompt_id`.
#[derive(Debug, Clone)]
pub(crate) struct PendingRunningAdoption {
/// The `prompt_id` the leader reported as `running_prompt_id`.
pub prompt_id: String,
/// The queued prompt's text (for the turn-start shim's user block), if the
/// pager knew about the prompt. `None` for prompts queued by other clients.
pub text: Option<String>,
/// The adopted entry's `kind` (`"prompt"`/`"bash"`/`"verification"`/…),
/// which selects the turn-start shim's display block + focus flag.
pub kind: String,
/// Set when a `running=None` broadcast spares this stash (one-shot: the
/// next `running=None` tears it down).
pub turn_ended: bool,
}
/// Wire payload of `x.ai/session/prompt_complete`, emitted by
/// `MvpAgent::prompt()` on the shell after every turn.
///
/// `Serialize` is derived so tests construct payloads through the same type
/// they are parsed into (shape drift fails at compile time, not at runtime).
/// Unknown fields (e.g. `turnId`, future additions) are ignored; every field
/// except `sessionId` is optional for wire compatibility with older shells —
/// in particular `promptId` only exists on shells with the lost-response fix.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct PromptCompletePayload {
pub(super) session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) stop_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) prompt_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) agent_result: Option<String>,
/// What triggered a cancelled turn's cancel (`"send_now"` suppresses the
/// "Turn cancelled" marker); stamped top-level, absent on older shells.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) cancel_trigger: Option<String>,
/// `_meta` extension point — parsed defensively as a trigger fallback.
#[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")]
pub(super) meta: Option<serde_json::Value>,
}
impl PromptCompletePayload {
/// The cancel trigger, wherever it was stamped: the top-level
/// `cancelTrigger` field (the shell's emission), falling back to
/// `_meta.cancelTrigger` (the envelope shape of the durable rail).
/// `None` (older shells) means a normal cancel.
pub(super) fn cancel_trigger(&self) -> Option<&str> {
self.cancel_trigger
.as_deref()
.or_else(|| self.meta.as_ref()?.get("cancelTrigger")?.as_str())
}
}
pub(super) fn handle_queue_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(changed) =
serde_json::from_str::<crate::app::prompt_queue::QueueChanged>(notif.params.get())
else {
tracing::warn!("Failed to parse x.ai/queue/changed");
return false;
};
let running_prompt_id = changed.running_prompt_id.clone();
let session_id = changed.session_id.clone();
// Capture the running prompt's text + kind from the currently-known queue
// (an optimistic echo, or a prior authoritative entry) BEFORE the broadcast
// is applied — the new broadcast drops the now-running item from `entries`,
// so it's gone afterward. The turn-start shim uses them to render the right
// display block.
let running_entry = running_prompt_id.as_ref().and_then(|pid| {
app.shared_prompt_queue(&session_id)
.and_then(|q| q.iter().find(|e| &e.id == pid).cloned())
});
let running_text: Option<String> = running_entry.as_ref().map(|e| e.text.clone());
let running_kind: String = running_entry
.as_ref()
.map(|e| e.kind.clone())
.unwrap_or_else(|| "prompt".to_string());
// Resolve the owning agent before the queue is replaced.
let sid = acp::SessionId::new(session_id.clone());
let agent_id = match find_session_match(app, &sid) {
Some(SessionMatch::Root(id)) => Some(id),
_ => None,
};
let recv_entry_ids: Vec<&str> = changed.entries.iter().map(|e| e.id.as_str()).collect();
// Raw (pre-merge) broadcast rows for the optimistic-echo reconcile: the
// post-apply snapshot re-pins unconfirmed echoes, so only the broadcast
// itself can prove a row landed shell-side.
let raw_entries: Vec<(String, u64)> = changed
.entries
.iter()
.map(|e| (e.id.clone(), e.version))
.collect();
let local_current_prompt_id = agent_id
.and_then(|aid| app.agents.get(&aid))
.and_then(|a| a.session.current_prompt_id.clone())
.unwrap_or_default();
tracing::debug!(
target: "qtrace",
pid = std::process::id(),
event = "queue_changed_recv",
session = %session_id,
running_prompt_id = running_prompt_id.as_deref().unwrap_or(""),
local_current_prompt_id = %local_current_prompt_id,
entry_count = changed.entries.len(),
entries = ?recv_entry_ids,
"received x.ai/queue/changed broadcast",
);
let rekeyed_echo_ids = app.apply_queue_changed(changed);
// Mirror the reconciled shared queue into the owning agent so the queue
// pane can render the union of local + server rows without needing
// `AppView` access during draw / input handling.
if let Some(aid) = agent_id {
let snapshot = app
.shared_prompt_queue(&session_id)
.cloned()
.unwrap_or_default();
// Stashed adoption: its painted block is about to be consumed.
let stashed_pid = app
.pending_running_adoptions
.get(&aid)
.map(|p| p.prompt_id.clone());
if let Some(agent) = app.agents.get_mut(&aid) {
agent.shared_queue = snapshot;
// A re-keyed echo's id is dead everywhere (only its content
// matched the broadcast): drop it from the optimistic set and
// any send-now parked on it — the row is visible under its new
// id, so a fresh Enter sends it normally. A painted send-now
// block moves to the new id (the message still runs there).
for (old_id, new_id) in &rekeyed_echo_ids {
agent.note_queue_echo_rekeyed(old_id, new_id);
}
// A painted-pending prompt the broadcast no longer lists — and
// is neither running nor a stashed adoption — was removed and
// will never adopt: retire its block. Unconfirmed optimistic ids
// are exempt (their RPC is in flight; absence is expected).
let removed_painted: Vec<String> = agent
.send_now_painted_blocks
.keys()
.filter(|pid| {
running_prompt_id.as_deref() != Some(pid.as_str())
&& stashed_pid.as_deref() != Some(pid.as_str())
&& !raw_entries.iter().any(|(eid, _)| eid == *pid)
&& !agent.optimistic_queue_ids.contains(*pid)
})
.cloned()
.collect();
for pid in &removed_painted {
agent.retire_send_now_painted_block(pid);
}
// Cleanup hook: if the user is editing a server-origin row and
// that row is no longer in the broadcast (started draining,
// removed by another client, etc.), exit editing mode so the
// composer isn't stranded on a ghost row. Don't dispatch any
// follow-up Action — the broadcast already reconciled the
// queue state for every other client.
let stranded_server_id = match &agent.prompt_mode {
super::super::agent_view::PromptMode::EditingQueued {
server_id: Some(sid),
..
} if !agent.shared_queue.iter().any(|e| &e.id == sid) => Some(sid.clone()),
_ => None,
};
if let Some(sid) = stranded_server_id {
tracing::debug!(
server_id = %sid,
"exiting EditingQueued: row is no longer in the shared queue"
);
agent.cancel_editing_queued_for_lost_row();
}
}
// Resolve a queue-row send-now that was parked while its row was
// still an optimistic echo: the broadcast just confirmed the row, so
// fire the interject with the authoritative version (racing it
// earlier would have no-opped shell-side and dropped the send-now).
let fire = app.agents.get_mut(&aid).and_then(|agent| {
agent.resolve_send_now_awaiting_confirm(&raw_entries, running_prompt_id.as_deref())
});
if let Some((id, expected_version)) = fire {
if let Some(agent) = app.agents.get_mut(&aid) {
// Same arming contract as `dispatch_queue_interject_shared`.
super::super::dispatch::arm_send_now_and_paint(agent, &id, None);
}
crate::unified_log::info(
"prompt.queue_send_now_confirmed",
Some(&session_id),
Some(serde_json::json!({ "prompt_id": id, "version": expected_version })),
);
app.pending_effects
.push(crate::app::actions::Effect::QueueInterject {
session_id: sid.clone(),
id,
expected_version,
new_text: None,
});
}
// A queue change can empty the visible queue mid-wait — the marker
// may become eligible now (see `maybe_push_parked_marker`).
if let Some(agent) = app.agents.get_mut(&aid) {
agent.maybe_push_parked_marker();
}
}
// Adoption / turn-start correlation.
//
// Single-client idle path stays inert: the pager sets `current_prompt_id`
// locally at `start_turn`, so when the confirming broadcast arrives with
// `running_prompt_id == current_prompt_id`, the `Some(c) if c == pid` arm
// makes this a no-op.
match (running_prompt_id, agent_id) {
// No turn running on the server — drop any stale pending adoption.
// Exception (`turn_ended`, one-shot): a turn ending inside the handoff
// window must leave the stash for the previous turn's PromptResponse,
// regardless of buffer occupancy — this ext broadcast can overtake the
// turn's `session/update`s (separate, reorderable channels).
(None, Some(aid)) => {
let retain = app
.pending_running_adoptions
.get(&aid)
.is_some_and(|p| !p.turn_ended);
if retain {
if let Some(p) = app.pending_running_adoptions.get_mut(&aid) {
p.turn_ended = true;
}
} else if let Some(p) = app.pending_running_adoptions.remove(&aid)
&& let Some(agent) = app.agents.get_mut(&aid)
{
agent.discard_pending_adoption_updates(&p.prompt_id);
}
}
// Non-adoptable running prompt (see `AgentView::should_adopt_running_prompt`):
// either an actor-run synthetic turn with no `prompt_complete` /
// `PromptResponse` exit (nothing would ever call `finish_turn`), or a turn
// whose durable `TurnCompleted` already arrived in THIS load's replay
// (terminal-in-replay — it already ended). Adopting either via
// `apply_turn_start_shim` would `start_turn()` → `AgentState::TurnRunning`
// and strand the pager on "Responding…"/"Waiting…" forever. The agent-aware
// check is load-bearing here: `replayed_terminal_prompts` stays populated
// after a load, so a later `queue/changed` re-reporting the already-ended
// `running_prompt_id` must NOT re-adopt the turn the `SessionLoaded` /
// reconnect adoption already correctly skipped. Skip the turn-start
// adoption; a live synthetic turn's streaming content still renders via the
// live-delta path in `handle` WITHOUT calling `start_turn`.
(Some(pid), Some(aid))
if app
.agents
.get(&aid)
.is_some_and(|a| !a.should_adopt_running_prompt(&pid)) =>
{
tracing::debug!(
target: "qtrace",
pid = std::process::id(),
prompt_id = %pid,
"queue/changed: skipping turn-start adoption for non-adoptable running \
prompt (synthetic turn with no prompt_complete exit, or terminal-in-replay)",
);
}
(Some(pid), Some(aid)) => {
let current = app
.agents
.get(&aid)
.and_then(|a| a.session.current_prompt_id.clone());
match current {
// Already tracking this running prompt — inert.
Some(c) if c == pid => {}
// Nothing running locally: adopt now + run the turn-start shim
// (render the queued prompt's user block, set `TurnRunning`).
None => {
if let Some(agent) = app.agents.get_mut(&aid) {
super::super::dispatch::apply_turn_start_shim(
agent,
pid,
running_text,
&running_kind,
);
}
}
// A different prompt is still finishing locally (FIFO handoff
// race — the next broadcast can arrive before the previous
// turn's `PromptResponse`). Stash it; the `PromptResponse`
// handler adopts it after `finish_turn` clears
// `current_prompt_id`. Never corrupt the in-flight turn.
Some(_) => {
// The leader emits this prompt's user-echo (no `promptId`,
// so the gate can't drop it) right after this broadcast but
// before the previous turn's `PromptResponse` runs the
// deferred shim. Arm the echo-skip now so it doesn't render
// a duplicate user block — but ONLY when THIS client will
// actually paint that block via the deferred shim.
//
// The deferred shim is run exclusively by the `PromptResponse`
// handler, which fires only for the client that DROVE the
// currently-finishing turn (`!attached_as_viewer`). A viewer
// of that turn ends it via `prompt_complete`, which clears
// (and removes) the stash without ever running the shim — so
// on a viewer the echo is the ONLY source of the user block
// and must not be swallowed.
//
// Key the guard on driver-vs-viewer of the *current* turn,
// NOT on who originated the draining prompt: a client can be
// `attached_as_viewer` on another client's turn yet
// immediate-send (self-originate) a queued prompt of its own.
// That client still won't run the shim, so an
// `is_self_originated`-based guard would wrongly swallow the
// echo and drop the block. Symmetric
// hazard: a driver adopting ANOTHER client's drained prompt
// DOES run the shim, so it must swallow the echo — which an
// origination-based guard would miss, double-rendering.
let drives_current_turn =
app.agents.get(&aid).is_some_and(|a| !a.attached_as_viewer);
let will_render_own_block = drives_current_turn
&& super::super::dispatch::shim_renders_own_user_block(
&running_kind,
running_text.as_deref(),
);
if will_render_own_block && let Some(agent) = app.agents.get_mut(&aid) {
agent.session.tracker.expect_user_echo();
}
tracing::debug!(
target: "qtrace",
pid = std::process::id(),
event = "adoption_stashed",
prompt_id = %pid,
"stashing running-prompt adoption (FIFO handoff race)",
);
// A rebroadcast for the SAME running prompt (every queue
// edit/no-op rebroadcasts) must not clobber the stash: the
// first broadcast consumed the drained row from the mirror,
// so this pass re-derives `text: None` and the deferred
// shim would render no user block (and the echo-skip armed
// above already swallowed the shell's echo).
if app
.pending_running_adoptions
.get(&aid)
.is_some_and(|p| p.prompt_id == pid)
{
return true;
}
// A newer running prompt supersedes any earlier stash.
if let Some(prev) = app.pending_running_adoptions.insert(
aid,
PendingRunningAdoption {
prompt_id: pid.clone(),
text: running_text,
kind: running_kind,
turn_ended: false,
},
) && let Some(agent) = app.agents.get_mut(&aid)
{
agent.discard_pending_adoption_updates(&prev.prompt_id);
}
}
}
}
_ => {}
}
true
}
/// `prompt_complete` carries `sessionId`, `stopReason`, `agentResult`,
/// `turnId`, and (shells ≥ the lost-response fix) `promptId`; for viewers,
/// turns are serialized per session, so "finish the running viewer turn for
/// this session" is unambiguous even without the prompt id.
///
/// This is the one-release compat rail (kept until every leader emits the
/// durable [`XaiSessionUpdate::TurnCompleted`]): it parses the payload and
/// delegates the turn-finalize to
/// [`finalize_turn_from_terminal`](super::super::turn_completion::finalize_turn_from_terminal),
/// which carries the driver-arm / viewer-finish behavior verbatim.
///
/// TODO(prompt_complete-deprecation): Legacy removal (gated): durable turn_completed is already consumed via finalize_turn_from_terminal; keep & re-point the lost-RPC reconcile to the durable rail before deleting.
pub(super) fn handle_prompt_complete(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(payload) = serde_json::from_str::<PromptCompletePayload>(notif.params.get()) else {
tracing::warn!("Failed to parse x.ai/session/prompt_complete");
return false;
};
let session_id = payload.session_id.as_str();
let sid = acp::SessionId::new(session_id.to_string());
let Some(SessionMatch::Root(id)) = find_session_match(app, &sid) else {
return false;
};
let is_active = is_matched_agent_active(app, id);
let Some(agent) = app.agents.get_mut(&id) else {
return false;
};
// Finalize on the agent, then map the outcome to the return bool in the one
// shared place both terminal rails use (returns it directly — arming reports
// a change unconditionally so a background tab still wakes the reconcile tick).
let outcome = super::super::turn_completion::finalize_turn_from_terminal(
agent,
session_id,
payload.prompt_id.as_deref(),
payload.stop_reason.as_deref(),
payload.agent_result.as_deref(),
payload.cancel_trigger(),
);
super::super::turn_completion::apply_terminal_outcome(outcome, app, id, is_active)
}
@@ -0,0 +1,189 @@
use super::*;
/// Result of looking up which view a notification's `session_id` targets.
///
/// The matched view's mutation must happen on the agent identified here,
/// regardless of which view the user is currently looking at.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SessionMatch {
/// The session_id matches the root session of this agent.
Root(AgentId),
/// The session_id matches a subagent view child of this agent
/// (i.e. an entry in `agent.subagent_views`). The child's key is the
/// notification's `session_id.0.as_ref()`; the caller re-derives it
/// to avoid an extra allocation.
Child(AgentId),
}
impl SessionMatch {
/// The owning agent's id, regardless of variant.
///
/// For `Root`, this is the agent whose root session matched. For `Child`,
/// this is the parent agent that owns the matching `subagent_views` entry.
/// Callers that only need to look up the owning agent (without
/// distinguishing root vs child) should use this instead of duplicating
/// the `match { Root(id) | Child(id) => id }` pattern.
pub(super) fn agent_id(self) -> AgentId {
match self {
SessionMatch::Root(id) | SessionMatch::Child(id) => id,
}
}
}
/// Resolve the agent that owns a notification's `session_id` and whether the
/// active view is affected.
///
/// Convenience wrapper around `find_session_match` + `is_matched_agent_active`
/// + `agents.get_mut()`, used by the bg-task notification handlers.
pub(super) fn resolve_notif_agent<'a>(
app: &'a mut AppView,
session_id: &acp::SessionId,
) -> Option<(SessionMatch, bool, &'a mut AgentView)> {
let matched = find_session_match(app, session_id)?;
let parent_id = matched.agent_id();
let is_active = is_matched_agent_active(app, parent_id);
let agent = app.agents.get_mut(&parent_id)?;
Some((matched, is_active, agent))
}
/// Resolve the agent an MCP-lifecycle notification (`init_progress` /
/// `mcp_initialized`) targets.
///
/// Routes by the payload's `sessionId` so a background session's progress
/// updates and completion signal land on *its* agent rather than whichever
/// agent happens to be foregrounded — otherwise a background agent's
/// "Connecting MCPs (N/M)…" spinner is never cleared and sticks forever.
/// Falls back to the active agent when the payload omits a `sessionId`.
///
/// Returns the owning agent plus whether it is the currently displayed one
/// (used to decide whether the notification warrants a redraw).
///
/// Only resolves to a `Root` agent: `mcp_init_progress` is a per-root-agent
/// indicator with no per-subagent slot, so notifications whose sessionId
/// matches a subagent (`Child`) are dropped — otherwise a subagent's own MCP
/// init would clobber its parent's spinner.
pub(super) fn mcp_target_agent<'a>(
app: &'a mut AppView,
session_id: Option<&str>,
) -> Option<(bool, &'a mut AgentView)> {
match session_id {
Some(sid) => {
let sid = acp::SessionId::new(sid);
let (matched, is_active, agent) = resolve_notif_agent(app, &sid)?;
if matches!(matched, SessionMatch::Child(_)) {
return None;
}
Some((is_active, agent))
}
None => {
let ActiveView::Agent(id) = app.active_view else {
return None;
};
let agent = app.agents.get_mut(&id)?;
Some((true, agent))
}
}
}
/// Given a matched session and the owning agent, borrow the correct
/// `(session, scrollback)` pair — the child view's when the notification
/// targets a subagent, the root agent's otherwise.
pub(super) fn resolve_target_view<'a>(
agent: &'a mut AgentView,
matched: SessionMatch,
child_sid: &str,
) -> Option<(
&'a mut AgentSession,
&'a mut crate::scrollback::state::ScrollbackState,
)> {
if matches!(matched, SessionMatch::Child(_)) {
let child_view = agent.subagent_views.get_mut(child_sid)?;
Some((&mut child_view.session, &mut child_view.scrollback))
} else {
Some((&mut agent.session, &mut agent.scrollback))
}
}
/// Locate the agent (or subagent view) a notification's `session_id` belongs to.
///
/// Search order:
/// 1. Exact root match: an agent whose `session.session_id` equals `session_id`.
/// 2. Subagent view: any agent whose `subagent_views` map contains `session_id`
/// as a key.
/// 3. Race-window fallback: when no exact match exists AND the currently active
/// agent has no `session_id` yet, route to it. Notifications can race ahead
/// of `TaskResult::SessionCreated`, and the only agent that could possibly
/// own such a pre-assignment notification is the one the user just created
/// (which is necessarily active and has `session_id == None`).
///
/// Returns `None` when the notification cannot be associated with any agent;
/// the caller should drop it (sending an empty Ok response if applicable).
///
/// All ACP-notification handlers must route through this function rather than
/// gating on `app.active_view` directly; see the `handle_scheduled_task_*`
/// family for the legacy active-view pattern still pending migration.
pub(super) fn find_session_match(
app: &AppView,
session_id: &acp::SessionId,
) -> Option<SessionMatch> {
// Single pass over `app.agents`: prefer an exact root match (returned
// immediately, since root takes precedence) but track the first child
// match seen as a fallback used after the full scan completes.
//
// Comparing `Option<&SessionId>` to `Some(&session_id)` borrows both
// sides -- no SessionId clone. The HashMap lookup uses the inner `&str`
// directly via the `Borrow<str>` impl on `String`, so no allocation
// either. This preserves the previous two-pass semantics (root wins
// when both could match) while halving the iteration cost on the hot
// notification path.
let child_key: &str = session_id.0.as_ref();
let mut child_match: Option<AgentId> = None;
for (id, agent) in &app.agents {
if agent.session.session_id.as_ref() == Some(session_id) {
return Some(SessionMatch::Root(*id));
}
if child_match.is_none() && agent.subagent_views.contains_key(child_key) {
child_match = Some(*id);
}
}
if let Some(id) = child_match {
return Some(SessionMatch::Child(id));
}
// Pass 3: race-window fallback for notifications that arrive before the
// root session_id has been assigned. Only the active agent is eligible,
// and only when its `session_id` is still `None` -- otherwise we would
// misroute a stranger's notification to whichever agent happens to be
// foregrounded.
if let ActiveView::Agent(active_id) = app.active_view
&& let Some(agent) = app.agents.get(&active_id)
&& agent.session.session_id.is_none()
{
return Some(SessionMatch::Root(active_id));
}
None
}
/// Whether the matched agent is the one currently displayed.
pub(super) fn is_matched_agent_active(app: &AppView, matched_agent: AgentId) -> bool {
matches!(app.active_view, ActiveView::Agent(id) if id == matched_agent)
}
/// Resolve the `AgentId` that should own an interactive modal
/// (`ask_user_question` / `exit_plan_mode`) for `session_id`.
///
/// Routes by the request's session id via [`find_session_match`] — exactly like
/// `session/update` notifications — so a modal raised by a **background**
/// session lands on its own view even when the user is on the dashboard or a
/// different session, instead of being gated on `app.active_view`. A child
/// (subagent) match resolves to its parent agent, which owns the overlay.
///
/// Returns `None` when no local view exists for that session; the caller must
/// then leave the reverse-request unanswered (drop, do NOT error) and rely on
/// the leader's replay-on-attach.
pub(super) fn interaction_target_agent(app: &AppView, session_id: &str) -> Option<AgentId> {
let sid = acp::SessionId::new(session_id.to_owned());
match find_session_match(app, &sid) {
Some(SessionMatch::Root(id) | SessionMatch::Child(id)) => Some(id),
None => None,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,445 @@
use super::*;
use serde::Deserialize;
/// Handle `x.ai/models/update` — model list changed (etag-triggered refresh).
pub(super) fn handle_models_update(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
if let Ok(model_state) = serde_json::from_str::<acp::SessionModelState>(notif.params.get()) {
use crate::acp::model_state::ModelState;
let new_models = ModelState::from(Some(model_state));
tracing::info!(
count = new_models.available.len(),
"models updated via x.ai/models/update"
);
let shell_fallback_current = new_models.current.clone();
// Override app-level default with the active agent's model.
let mut app_models = new_models.clone();
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get(&id)
&& let Some(ref agent_model) = agent.session.models.current
&& app_models.available.contains_key(agent_model)
{
app_models.current = Some(agent_model.clone());
}
app.models = app_models;
for agent in app.agents.values_mut() {
// Log when an update drops the agent's active model — this is the
// moment the status bar visibly "switches model mid-conversation"
// (the agent falls back to the shell's current model below).
if let Some(ref current) = agent.session.models.current
&& !new_models.available.contains_key(current)
{
tracing::warn!(
current_model = %current.0,
fallback = ?shell_fallback_current.as_ref().map(|m| m.0.as_ref()),
available_count = new_models.available.len(),
"models update removed this agent's current model; falling back"
);
}
agent
.session
.models
.update_catalog(new_models.available.clone(), shell_fallback_current.clone());
}
true
} else {
tracing::warn!("Failed to parse x.ai/models/update");
false
}
}
/// Handle `x.ai/settings/update` — remote settings refreshed on `/new`.
pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(update) = serde_json::from_str::<PagerSettingsUpdate>(notif.params.get()) else {
tracing::warn!("Failed to parse x.ai/settings/update");
return false;
};
if let Some(v) = update.auto_permission_mode_enabled {
// Keep the pager's auto-permission-mode gate live with the remote settings
// remote tier (the leader caches it agent-side; the pager process needs
// its own copy). Refresh the startup snapshot so the Shift+Tab cycle and
// the settings modal both reflect a remote-only enablement/kill-switch
// without a restart.
kigi_shell::util::config::cache_remote_auto_permission_mode_enabled(Some(v));
app.auto_mode_gate = kigi_shell::util::config::auto_permission_mode_enabled_from_disk();
// Mid-session kill switch: when the gate just went off, drop displayed
// Auto to Ask + clear every agent's per-session flag (shared with the
// startup reconcile), AND tell live sessions to leave Auto. Clearing only
// the display would let the agent keep classifier-approving while the UI
// shows "Ask" — the emergency-off must actually disable enforcement.
if !app.auto_mode_gate {
// Sessions to notify: agents that HAD Auto on (capture before the
// downgrade clears the flag) and have a live session id.
let leaving_auto: Vec<acp::SessionId> = app
.agents
.values()
.filter(|a| a.session.is_auto())
.filter_map(|a| a.session.session_id.clone())
.collect();
super::super::dispatch::downgrade_displayed_auto_if_gated(app);
notify_sessions_leave_auto(app, &leaving_auto);
}
// Reveal/hide `/auto` on every slash surface in lockstep with the gate
// (covers both a mid-session kill-switch and re-enablement).
app.sync_permission_mode_slash_gate();
}
// `permission_mode` is presence-aware (omit / null / string). While the
// soft default still owns the mode, a push re-arms `default_yolo` + UI for
// the next `/new`; once the user claims a mode (Shift+Tab / settings /
// `/mode`) the latch is cleared and pushes leave it alone.
if let Some(remote_opt) = update.permission_mode.as_ref()
&& app.permission_mode_from_soft_default
{
// One config read at the I/O boundary; the applier is deterministic.
let root = kigi_shell::config::load_effective_config().ok();
apply_soft_default_permission_mode(
app,
root.as_ref().and_then(|r| r.get("ui")),
remote_opt.as_deref(),
);
}
if let Some(v) = update.show_resolved_model {
app.show_resolved_model = v;
}
if let Some(v) = update.sharing_enabled {
app.sharing_enabled = v;
// Propagate to existing agents so slash-command registries stay
// in sync (same fan-out pattern used when creating new agents).
for agent in app.agents.values_mut() {
agent.set_sharing_enabled(v);
}
}
// Always recompute is_api_key_auth from the tier so a later Free/SuperGrok
// stamp does not leave API-key bypass / hidden `/usage` stuck.
if let Some(v) = update.subscription_tier_display {
let is_key = super::super::app_view::is_api_key_label(&v);
app.is_api_key_auth = is_key;
app.usage_visible = !is_key && app.team_name.is_none();
app.subscription_tier = Some(v);
app.apply_tier_restrictions();
}
// TODO: extract resolve_session_picker_grouped helper (duplicates event_loop.rs:143-160)
// Respect env var > config > remote precedence (mirrors event_loop.rs startup).
if let Some(remote_val) = update.session_picker_grouped {
let resolved = std::env::var("KIGI_SESSION_PICKER_GROUPED")
.ok()
.and_then(|v| match v.as_str() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
})
.or_else(|| {
kigi_shell::config::load_effective_config()
.ok()
.and_then(|cfg| cfg.get("cli")?.get("session_picker_grouped")?.as_bool())
})
.unwrap_or(remote_val);
app.session_picker_grouped = resolved;
}
if let Some(v) = update.subscription_watch_interval_secs {
app.subscription_watch_interval_secs = Some(v);
}
// Gate update logic:
// - allow_access == Some(true): explicitly granted → lift the gate
// - gate_message.is_some(): server sent a new message → impose/update
// - Neither condition met: don't touch the gate. In particular,
// allow_access=Some(false) without a gate_message must NOT clear the
// gate (gate_from_settings returns None when gate_message is absent,
// which would incorrectly lift an existing gate).
if update.allow_access == Some(true) {
let effs = app.lift_gate();
app.pending_effects.extend(effs);
} else if let Some(msg) = update.gate_message.as_ref()
&& !msg.is_empty()
{
// (An empty gate_message would only clear the gate message text, NOT
// access, so it intentionally does not touch the gate here.)
let effs = app.impose_gate(kigi_shell::auth::GateInfo {
message: msg.clone(),
url: update.gate_url.clone(),
label: update.gate_label.clone(),
});
app.pending_effects.extend(effs);
}
// Load config layers once for tips + group_tool_verbs +
// collapsed_edit_blocks resolution. Loaded unconditionally: the UI flags
// re-resolve on every update (see below), and updates are rare (post-auth
// refresh, `/new`), so three small TOML reads are fine.
let (requirements, user_config, managed_config) = (
kigi_shell::config::load_merged_requirements(),
kigi_shell::config::load_from_disk().ok(),
kigi_shell::config::load_managed_config().ok(),
);
// Local layers may beat remote — re-resolve the full chain into the render
// cache (mirrors the event_loop.rs startup resolve). Runs on None too: the
// shell always publishes this field from its live remote tier, so None
// means remote settings cleared it (or an older shell that cannot deliver the
// remote tier at all) — either way resolving without a remote value is
// correct, and it reverts a previously cached remote enable back to the
// local/default (off) resolution instead of leaving Some(true) stuck
// until restart.
let remote = kigi_shell::util::config::RemoteSettings {
group_tool_verbs: update.group_tool_verbs,
..Default::default()
};
let resolved = kigi_shell::util::config::resolve_group_tool_verbs(
requirements.as_ref(),
user_config.as_ref(),
managed_config.as_ref(),
Some(&remote),
)
.value;
// On a real flip, re-fold every live transcript (mirrors dispatch's
// set_group_tool_verbs_inner); unchanged values keep `/new` cheap.
// Stale expansion ids describe the old grouping shape — drop them so the
// re-fold can't reopen a verb slot expanded or mark a coincident dense
// group expanded (see `clear_group_expansion`).
if resolved != crate::appearance::cache::load_group_tool_verbs() {
crate::appearance::cache::set_group_tool_verbs(resolved);
for agent in app.agents.values_mut() {
agent.scrollback.clear_group_expansion();
agent.scrollback.invalidate_heights();
for child in agent.subagent_views.values_mut() {
child.scrollback.clear_group_expansion();
child.scrollback.invalidate_heights();
}
}
}
// Same None-reverts contract as group_tool_verbs above: re-resolve the
// full local chain with the pushed remote tier so a cleared remote settings
// field falls back to local/default instead of staying latched.
let remote = kigi_shell::util::config::RemoteSettings {
collapsed_edit_blocks: update.collapsed_edit_blocks,
..Default::default()
};
let resolved = kigi_shell::util::config::resolve_collapsed_edit_blocks(
requirements.as_ref(),
user_config.as_ref(),
managed_config.as_ref(),
Some(&remote),
)
.value;
// On a real flip, re-materialize on-default Edit rows + repaint suffixes
// in every live transcript (mirrors dispatch's
// set_collapsed_edit_blocks_inner); unchanged values keep `/new` cheap.
let prev = crate::appearance::cache::load_collapsed_edit_blocks();
if resolved != prev {
crate::appearance::cache::set_collapsed_edit_blocks(resolved);
for agent in app.agents.values_mut() {
agent
.scrollback
.apply_collapsed_edit_blocks_flip(prev, resolved);
for child in agent.subagent_views.values_mut() {
child
.scrollback
.apply_collapsed_edit_blocks_flip(prev, resolved);
}
}
}
// Re-resolve tips from config layers + the updated remote tips.
if let Some(remote_tips) = update.tips {
use kigi_shell::util::config::resolve_tips;
app.tips = resolve_tips(
requirements.as_ref(),
user_config.as_ref(),
managed_config.as_ref(),
Some(&remote_tips),
);
if !app.tips.is_empty() {
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
app.tip = kigi_shell::util::tips::pick_and_advance(&app.tips, &kigi_home);
} else {
app.tip = None;
}
}
tracing::info!("settings updated via x.ai/settings/update");
true
}
/// Re-arm the soft-defaulted launch mode from a pushed `permission_mode`
/// (TOML `[ui]` > remote > Ask), for the next `/new` only — live sessions are
/// untouched and nothing is persisted. `effective_ui` is injected so the
/// resolve is deterministic under test. Enforcement gating reuses the app's
/// startup snapshots (`yolo_policy_block`, `auto_mode_gate`); the agent's
/// permission manager re-clamps authoritatively at decision time.
pub(super) fn apply_soft_default_permission_mode(
app: &mut AppView,
effective_ui: Option<&toml::Value>,
remote: Option<&str>,
) {
let mode = kigi_shell::util::config::resolve_permission_mode(effective_ui, remote);
app.default_yolo = mode.is_always_approve() && app.yolo_policy_block.is_none();
let auto = mode.is_auto() && app.auto_mode_gate && !app.default_yolo;
app.current_ui.permission_mode = Some(if auto {
"auto".to_string()
} else if app.default_yolo {
"always-approve".to_string()
} else {
kigi_shell::util::config::resolved_display_permission_mode(effective_ui, remote).to_string()
});
}
/// Tell live sessions to leave Auto on the mid-session kill-switch: fire the
/// `x.ai/yolo_mode_changed` notification the agent maps to
/// `SetAutoMode { enabled: false }`, fire-and-forget over the shared ACP channel.
/// The notification is CLIENT-scoped (the agent applies it to every session of
/// the sending client), so one send covers all affected sessions. `yolo_mode` is
/// deliberately OMITTED — the agent skips the yolo branch when the key is absent,
/// so a sibling tab's always-approve is preserved; only auto is cleared.
pub(super) fn notify_sessions_leave_auto(app: &AppView, session_ids: &[acp::SessionId]) {
if session_ids.is_empty() {
return;
}
let params = serde_json::json!({
"auto_mode": false,
"permission_mode": "ask",
});
let notification = acp::ExtNotification::new(
"x.ai/yolo_mode_changed",
serde_json::value::to_raw_value(&params)
.expect("serialize yolo_mode_changed params")
.into(),
);
let (response_tx, _response_rx) = tokio::sync::oneshot::channel();
let args = kigi_acp_lib::AcpArgs {
request: notification,
response_tx,
};
let _ = app.acp_tx.send(args.into());
}
/// Handle `x.ai/sessions/changed` — the leader broadcasts roster
/// upserts/removals to all clients (FleetView dashboard).
pub(super) fn handle_sessions_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(changed) = serde_json::from_str::<crate::app::roster::RosterChanged>(notif.params.get())
else {
tracing::warn!("Failed to parse x.ai/sessions/changed");
return false;
};
let mut affected = false;
for entry in changed.upserted {
app.upsert_roster_entry(entry);
affected = true;
}
for sid in changed.removed {
app.remove_roster_entry(&sid);
affected = true;
}
affected
}
/// Deserialization type for the `x.ai/settings/update` notification payload.
///
/// This is intentionally a separate struct from `SettingsUpdateNotification` in
/// `kigi-shell/src/agent/mvp_agent.rs`. The shell side derives `Serialize`
/// and owns the canonical field set from `RemoteSettings`; this pager side
/// derives `Deserialize` and selectively consumes only the fields relevant to
/// the TUI. Keeping them separate avoids coupling the pager to shell internals
/// and lets each side evolve independently (e.g. adding a shell-only field
/// doesn't require a pager change). All fields are `Option` with
/// `#[serde(default)]` so that partial updates and forward-compatible additions
/// are handled gracefully.
///
/// **Keep in sync** with field names/types in `SettingsUpdateNotification` at
/// `kigi-shell/src/agent/mvp_agent.rs` when adding fields that both sides
/// need.
#[derive(serde::Deserialize)]
pub(super) struct PagerSettingsUpdate {
#[serde(default)]
show_resolved_model: Option<bool>,
#[serde(default)]
sharing_enabled: Option<bool>,
#[serde(default)]
session_picker_grouped: Option<bool>,
#[serde(default)]
tips: Option<Vec<String>>,
#[serde(default)]
gate_message: Option<String>,
#[serde(default)]
gate_url: Option<String>,
#[serde(default)]
gate_label: Option<String>,
#[serde(default)]
allow_access: Option<bool>,
#[serde(default)]
subscription_tier_display: Option<String>,
#[serde(default)]
auto_permission_mode_enabled: Option<bool>,
/// Soft-default permission mode. Presence-aware: omit = no update,
/// `null` = recompute with remote=None, string = that soft-default.
/// Omission happens with older shells that predate the field (they can
/// never clear a mode they don't know about) — that version skew is why
/// this is tri-state instead of a plain `Option`.
#[serde(default, deserialize_with = "deserialize_presence_aware_string")]
permission_mode: Option<Option<String>>,
#[serde(default)]
group_tool_verbs: Option<bool>,
#[serde(default)]
collapsed_edit_blocks: Option<bool>,
#[serde(default)]
subscription_watch_interval_secs: Option<u64>,
}
/// Presence-aware string: omit → `None` (`#[serde(default)]`), null →
/// `Some(None)`, string → `Some(Some(_))`.
fn deserialize_presence_aware_string<'de, D>(
deserializer: D,
) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<String>::deserialize(deserializer)?))
}
#[cfg(test)]
mod presence_aware_dto_tests {
use super::*;
#[derive(Deserialize)]
struct Probe {
#[serde(default, deserialize_with = "deserialize_presence_aware_string")]
permission_mode: Option<Option<String>>,
}
#[test]
fn permission_mode_dto_distinguishes_omit_from_null() {
let omit: Probe = serde_json::from_value(serde_json::json!({
"show_resolved_model": true,
}))
.unwrap();
assert_eq!(omit.permission_mode, None, "omit must be None (no update)");
let null_v: Probe = serde_json::from_value(serde_json::json!({
"permission_mode": null,
}))
.unwrap();
assert_eq!(
null_v.permission_mode,
Some(None),
"explicit null must be Some(None)"
);
let some_v: Probe = serde_json::from_value(serde_json::json!({
"permission_mode": "always-approve",
}))
.unwrap();
assert_eq!(
some_v.permission_mode,
Some(Some("always-approve".into())),
"string must be Some(Some(_))"
);
}
}
@@ -0,0 +1,103 @@
use super::*;
/// Update the activity label on a subagent's collapsed scrollback block.
///
/// Skips the write (and cache invalidation) when the label hasn't changed,
/// so the per-delta common case ("Responding" stays "Responding") allocates
/// nothing.
pub(super) fn sync_activity_label(
scrollback: &mut crate::scrollback::state::ScrollbackState,
entry_id: Option<crate::scrollback::entry::EntryId>,
activity_label: Option<&str>,
) {
if let Some(eid) = entry_id
&& let Some(entry) = scrollback.get_by_id_mut(eid)
&& let RenderBlock::Subagent(ref mut sb) = entry.block
&& sb.activity_label.as_deref() != activity_label
{
sb.activity_label = activity_label.map(str::to_owned);
entry.invalidate_cache();
}
}
/// Fan a subagent's computed activity label out to both surfaces that show
/// it — the collapsed scrollback block and the [`SubagentInfo`] backing the
/// tasks pane / dashboard rows — so the two can't drift.
pub(super) fn sync_subagent_activity(
parent: &mut AgentView,
child_key: &str,
activity_label: Option<String>,
) {
let Some(info) = parent.subagent_sessions.get_mut(child_key) else {
return;
};
sync_activity_label(
&mut parent.scrollback,
info.scrollback_entry_id,
activity_label.as_deref(),
);
info.activity_label = activity_label;
}
/// Resolve a subagent child view's live activity into the display label the
/// fan-out stamps ("Waiting" while the child is busy between activities).
pub(super) fn subagent_activity_label(child_view: &AgentView) -> Option<String> {
match child_view.resolve_turn_activity() {
Some(a) => Some(crate::app::subagent::format_activity_label(&a)),
None if child_view.session.state.is_busy() => Some("Waiting".to_string()),
None => None,
}
}
/// Synthesize a finish for a stuck row when a kill found nothing live to stop
/// (else `pending_kill` times out → "running"). `status` is the real terminal
/// status for an already-finished orphan, else `"cancelled"`.
pub(crate) fn finalize_killed_subagent(
app: &mut AppView,
session_id: &acp::SessionId,
subagent_id: &str,
status: &str,
) -> bool {
let Some(SessionMatch::Root(agent_id)) = find_session_match(app, session_id) else {
return false;
};
let Some(agent) = app.agents.get(&agent_id) else {
return false;
};
// Idempotency: skip if already finished.
let Some(child_session_id) = agent
.subagent_sessions
.values()
.find(|i| i.subagent_id.as_ref() == subagent_id && !i.finished)
.map(|i| i.child_session_id.to_string())
else {
return false;
};
let payload = SessionNotification {
session_id: session_id.clone(),
update: XaiSessionUpdate::SubagentFinished {
subagent_id: subagent_id.to_string(),
child_session_id,
// An already-finished orphan may be "failed", but the cancel response
// carries no failure reason (lost across the resume window), so
// `error` stays None.
status: status.to_string(),
error: None,
tool_calls: 0,
turns: 0,
// Real run time is unknown for an already-gone orphan (the row's
// started_at is stamped at resume, not the real spawn), so emit 0.
duration_ms: 0,
tokens_used: 0,
output: None,
will_wake: false,
},
meta: None,
};
let Ok(params) = serde_json::value::to_raw_value(&payload) else {
return false;
};
let notif = acp::ExtNotification::new("x.ai/session/update", params.into());
handle_ext_notification(&notif, app)
}
@@ -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(&notif, &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(&notif, &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(&notif).unwrap();
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
assert!(handle_task_backgrounded(&notif, &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(&notif).unwrap();
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
assert!(handle_task_backgrounded(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&params).unwrap().into(),
);
let affected = handle_ext_notification(&notif, &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(&params).unwrap().into(),
);
let affected = handle_ext_notification(&notif, &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(&params).unwrap().into(),
);
assert!(
handle_ext_notification(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notif, &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(&notes[0]));
assert!(b.text.contains(&notes[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