M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,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, ¶ms.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(¶ms.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: {}",
|
||||
¬if.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, ¬if.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(¬if.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(¶ms)
|
||||
.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(¬if, 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(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"entry must not be in the running set after demotion"
|
||||
);
|
||||
let entry = agent.scrollback.get(0).unwrap();
|
||||
assert!(
|
||||
matches!(entry.block, RenderBlock::BgTask(_)),
|
||||
"block should be demoted to BgTask"
|
||||
);
|
||||
assert!(!entry.is_running);
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_none());
|
||||
}
|
||||
|
||||
/// Regression: late-detected is_background=true (raw_input arrives after the
|
||||
/// Execute block exists) followed by task_backgrounded must correctly demote
|
||||
/// the existing Execute block — not create a duplicate BgTask.
|
||||
#[test]
|
||||
fn task_backgrounded_late_detection_demotes_existing_entry() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-late-bg-42";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
// Tool should be in BOTH pending_tools and bg_deferred_tools
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_some());
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
|
||||
let notif = make_task_backgrounded_notif("sess-1", tc_id, "task-late-001", "sleep 9999");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1, "must not create duplicate");
|
||||
let entry = agent.scrollback.get(0).unwrap();
|
||||
assert!(matches!(entry.block, RenderBlock::BgTask(_)));
|
||||
assert!(!entry.is_running);
|
||||
assert!(!agent.scrollback.needs_animation());
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_none());
|
||||
assert!(!agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
}
|
||||
|
||||
/// Regression: even when the wire notification carries its own
|
||||
/// `description`, the deferred-tool suppression key must still be drained
|
||||
/// (it is preferred but the entry otherwise leaks and keeps dropping late
|
||||
/// stdout updates for the rest of the session).
|
||||
#[test]
|
||||
fn task_backgrounded_with_wire_description_still_drains_deferred_tool() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-late-bg-desc";
|
||||
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-1"),
|
||||
update: XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: tc_id.into(),
|
||||
task_id: "task-late-desc".into(),
|
||||
command: "sleep 9999".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/output.log".into(),
|
||||
monitor_description: None,
|
||||
description: Some("Wait a while".into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
|
||||
assert!(handle_task_backgrounded(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.session.tracker.bg_deferred_tools.contains_key(tc_id),
|
||||
"deferred-tool key must be drained even when wire description wins"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a blank/whitespace wire `description` must not shadow a
|
||||
/// non-blank deferred (raw_input) description when there is no Execute
|
||||
/// block to recover from (fresh BgTask path). The label must come from the
|
||||
/// deferred description, not the blank wire value (which would otherwise
|
||||
/// fall back to the raw command).
|
||||
#[test]
|
||||
fn task_backgrounded_blank_wire_description_prefers_deferred() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let tc_id = "call-blank-wire";
|
||||
|
||||
// Simulate late is_background detection having stashed a real
|
||||
// description, with the placeholder entry dropped (no pending tool).
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent
|
||||
.session
|
||||
.tracker
|
||||
.bg_deferred_tools
|
||||
.insert(tc_id.to_string(), Some("deploy the server".to_string()));
|
||||
}
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.tracker
|
||||
.pending_tool_entry_id(tc_id)
|
||||
.is_none(),
|
||||
"no Execute entry to recover from — exercises the merge chain"
|
||||
);
|
||||
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-1"),
|
||||
update: XaiSessionUpdate::TaskBackgrounded {
|
||||
tool_call_id: tc_id.into(),
|
||||
task_id: "task-blank-wire".into(),
|
||||
command: "sleep 9999".into(),
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/output.log".into(),
|
||||
monitor_description: None,
|
||||
description: Some(" ".into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/task_backgrounded", raw.into());
|
||||
assert!(handle_task_backgrounded(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
!agent.session.tracker.bg_deferred_tools.contains_key(tc_id),
|
||||
"deferred-tool key drained"
|
||||
);
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
match &agent.scrollback.get(0).unwrap().block {
|
||||
RenderBlock::BgTask(bg) => assert_eq!(
|
||||
bg.description.as_deref(),
|
||||
Some("deploy the server"),
|
||||
"blank wire description must not shadow the deferred description"
|
||||
),
|
||||
other => panic!("expected BgTask, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-1", "task-child-1", "sleep 100");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent scrollback must NOT have the bg task block.
|
||||
assert_eq!(agent.scrollback.len(), 0, "parent scrollback must be empty");
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent session must not have the bg task"
|
||||
);
|
||||
|
||||
// Child view must have the bg task.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
assert!(child.session.bg_tasks.contains_key("task-child-1"));
|
||||
assert!(
|
||||
child
|
||||
.session
|
||||
.bg_tool_call_to_task
|
||||
.contains_key("tc-child-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-1", "task-root-1", "ls -la");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
assert!(agent.session.bg_tasks.contains_key("task-root-1"));
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(
|
||||
child.scrollback.len(),
|
||||
0,
|
||||
"child scrollback must not be affected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_monitor_prefix_marks_is_monitor() {
|
||||
// Reparented monitor / older backend: the command carries the
|
||||
// "[monitor] <desc>" prefix but the notification has no
|
||||
// monitor_description. The pager must still mark it a monitor and use
|
||||
// the stripped text as the description so it renders as a Monitor row.
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_task_backgrounded_notif(
|
||||
"parent-sess",
|
||||
"tc-mon-1",
|
||||
"task-mon-1",
|
||||
"[monitor] event counter",
|
||||
);
|
||||
handle_task_backgrounded(¬if, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let task = agent
|
||||
.session
|
||||
.bg_tasks
|
||||
.get("task-mon-1")
|
||||
.expect("bg task registered");
|
||||
assert!(
|
||||
task.is_monitor,
|
||||
"a `[monitor] ` command prefix should mark the task as a monitor"
|
||||
);
|
||||
assert_eq!(task.description.as_deref(), Some("event counter"));
|
||||
}
|
||||
|
||||
/// Resume regression: the agent's cold-load reconciliation
|
||||
/// completes replay-restored dead tasks with `signal: "session_restart"`.
|
||||
/// That synthetic completion must finalize state QUIETLY — finish the
|
||||
/// replayed "Task started" entry and mark the task not-running — without
|
||||
/// pushing a fresh red "Task failed" block into the resumed scrollback.
|
||||
#[test]
|
||||
fn session_restart_completion_finalizes_without_failure_block() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let replayed =
|
||||
make_replayed_task_backgrounded_notif("sess-1", "tc-r", "task-r", "tail -f x.log");
|
||||
handle_task_backgrounded(&replayed, &mut app);
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), 1, "replay restores started block");
|
||||
assert!(agent.scrollback.needs_animation(), "restored row runs");
|
||||
}
|
||||
|
||||
let notif = make_task_completed_notif_with_signal(
|
||||
"sess-1",
|
||||
"task-r",
|
||||
"tail -f x.log",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
);
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
1,
|
||||
"stale-on-load completion must not push a 'Task failed' block"
|
||||
);
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"the replayed started entry must be finished (no running accent)"
|
||||
);
|
||||
let task = &agent.session.bg_tasks["task-r"];
|
||||
assert_eq!(
|
||||
task.status,
|
||||
BgTaskStatus::Failed,
|
||||
"state still records the task as not running"
|
||||
);
|
||||
}
|
||||
|
||||
/// Guard that the quiet path is NARROW: any other kill signal keeps the
|
||||
/// live behavior of pushing a completion/failure block.
|
||||
#[test]
|
||||
fn non_restart_signal_completion_still_pushes_failure_block() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let bg = make_task_backgrounded_notif("sess-1", "tc-k", "task-k", "sleep 999");
|
||||
handle_task_backgrounded(&bg, &mut app);
|
||||
|
||||
let notif = make_task_completed_notif_with_signal(
|
||||
"sess-1",
|
||||
"task-k",
|
||||
"sleep 999",
|
||||
None,
|
||||
Some("SIGKILL"),
|
||||
);
|
||||
handle_task_completed(¬if, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
2,
|
||||
"a real kill must still render started + failed blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// First, background a task on the child.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-2", "task-child-2", "echo hi");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now complete it.
|
||||
let notif = make_task_completed_notif("child-sess", "task-child-2", "echo hi", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent must NOT have a completion block.
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have completion block"
|
||||
);
|
||||
|
||||
// Child must have both the started and completed blocks.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + completed");
|
||||
let bg = child.session.bg_tasks.get("task-child-2").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background and complete a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-2", "task-root-2", "echo root");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
let notif = make_task_completed_notif("parent-sess", "task-root-2", "echo root", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
2,
|
||||
"parent: started + completed blocks"
|
||||
);
|
||||
let bg = agent.session.bg_tasks.get("task-root-2").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(
|
||||
child.scrollback.len(),
|
||||
0,
|
||||
"child scrollback must not be affected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_failure_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
let bg_notif = make_task_backgrounded_notif("child-sess", "tc-fail", "task-fail", "exit 1");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_task_completed_notif("child-sess", "task-fail", "exit 1", Some(1));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have failure block"
|
||||
);
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + failed");
|
||||
let bg = child.session.bg_tasks.get("task-fail").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Failed));
|
||||
assert_eq!(bg.exit_code, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child so monitor event has somewhere to land.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-3", "task-child-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_monitor_event_notif("child-sess", "task-child-3", "new log line");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent must not have the bg task"
|
||||
);
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
let task = child.session.bg_tasks.get("task-child-3").unwrap();
|
||||
assert_eq!(task.stdout, "new log line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-3", "task-root-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
let notif = make_monitor_event_notif("parent-sess", "task-root-3", "root event");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let task = agent.session.bg_tasks.get("task-root-3").unwrap();
|
||||
assert_eq!(task.stdout, "root event");
|
||||
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert!(
|
||||
child.session.bg_tasks.is_empty(),
|
||||
"child must not have the bg task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
// Insert a second agent and switch to it so the first agent is inactive.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
&mut app,
|
||||
AgentId(1),
|
||||
crate::app::dispatch::SwitchCause::New,
|
||||
);
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(1))));
|
||||
|
||||
let notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-bg-inact", "task-bg-inact", "sleep 1");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
// Active view was NOT affected — should return false.
|
||||
assert!(!changed);
|
||||
|
||||
// But the bg task state must still land in the child view.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert!(child.session.bg_tasks.contains_key("task-bg-inact"));
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child first.
|
||||
let bg_notif = make_task_backgrounded_notif(
|
||||
"child-sess",
|
||||
"tc-compl-inact",
|
||||
"task-compl-inact",
|
||||
"echo",
|
||||
);
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now switch away.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
&mut app,
|
||||
AgentId(1),
|
||||
crate::app::dispatch::SwitchCause::New,
|
||||
);
|
||||
|
||||
let notif = make_task_completed_notif("child-sess", "task-compl-inact", "echo", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
let bg = child.session.bg_tasks.get("task-compl-inact").unwrap();
|
||||
assert!(matches!(bg.status, BgTaskStatus::Done));
|
||||
assert_eq!(child.scrollback.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_backgrounded_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif =
|
||||
make_task_backgrounded_notif("unknown-sess", "tc-unknown", "task-unknown", "sleep 1");
|
||||
let changed = handle_task_backgrounded(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_completed_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_task_completed_notif("unknown-sess", "task-unknown", "echo x", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_event_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
let notif = make_monitor_event_notif("unknown-sess", "task-unknown", "event");
|
||||
let changed = handle_monitor_event(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_task_backgrounded_marks_restored_from_replay() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let replayed =
|
||||
make_replayed_task_backgrounded_notif("sess-1", "tc-r", "task-r", "tail -f x.log");
|
||||
handle_task_backgrounded(&replayed, &mut app);
|
||||
let live = make_task_backgrounded_notif("sess-1", "tc-l", "task-l", "sleep 5");
|
||||
handle_task_backgrounded(&live, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.bg_tasks["task-r"].restored_from_replay,
|
||||
"isReplay-stamped TaskBackgrounded must mark restored_from_replay"
|
||||
);
|
||||
assert!(
|
||||
!agent.session.bg_tasks["task-l"].restored_from_replay,
|
||||
"live TaskBackgrounded must not mark restored_from_replay"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn follow_ups_render_chips_on_active_agent() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["Tell me more", "Summarize"]),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected, "fresh chips on the active agent warrant a redraw");
|
||||
let fu = app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.expect("chips set on the active agent");
|
||||
assert_eq!(fu.response_id, "resp-1");
|
||||
assert_eq!(fu.suggestions, vec!["Tell me more", "Summarize"]);
|
||||
}
|
||||
|
||||
/// End-to-end through the wire: the stamped `promptId` flows from the
|
||||
/// notification params into the dedup. (a) a re-delivery of the active
|
||||
/// turn's follow_ups re-renders after a clear; (b) a prior turn's replay is
|
||||
/// rejected.
|
||||
#[test]
|
||||
fn follow_ups_prompt_id_makes_dedup_deterministic() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.current_prompt_id = Some("p1".into());
|
||||
|
||||
// Active turn (p1) chips applied via the wire.
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
));
|
||||
// Turn-boundary clear (keeps the seen ring).
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
|
||||
// (a) Re-delivery of the active turn re-renders.
|
||||
assert!(
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
),
|
||||
"active-turn re-delivery must re-render via promptId match"
|
||||
);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.response_id,
|
||||
"resp-1"
|
||||
);
|
||||
|
||||
// Adopt a new turn p2; clear.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.current_prompt_id = Some("p2".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
|
||||
// (b) Prior turn (p1) replay must NOT revive.
|
||||
assert!(
|
||||
!handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
),
|
||||
"prior-turn replay must be rejected"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_replayed_meta_suppresses_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let params = serde_json::json!({
|
||||
"response_id": "resp-1",
|
||||
"suggestions": [{ "label": "x" }],
|
||||
"_meta": { "x.ai/replayed": true },
|
||||
});
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
let affected = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!affected, "a replayed chunk must not render chips");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_malformed_params_are_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let bad = [
|
||||
serde_json::Value::String("not an object".into()),
|
||||
serde_json::json!([1, 2, 3]),
|
||||
serde_json::json!({ "suggestions": 7 }),
|
||||
serde_json::json!({}),
|
||||
];
|
||||
for params in bad {
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
let affected = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!affected, "malformed params must be ignored: {params}");
|
||||
}
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_sanitizes_control_characters() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// A label carrying an ESC-based SGR sequence and a newline: control
|
||||
// characters are stripped so a chip cannot inject terminal escapes.
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["safe\u{1b}[31mred\nmore"]),
|
||||
&mut app,
|
||||
);
|
||||
let fu = app.agents[&AgentId(0)].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.suggestions, vec!["safe[31mredmore"]);
|
||||
assert!(!fu.suggestions[0].contains('\u{1b}'));
|
||||
assert!(!fu.suggestions[0].contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_response_id_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("", &["x"]), &mut app);
|
||||
assert!(
|
||||
!affected,
|
||||
"without a response_id there is no newest-wins key"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_blank_labels_yield_no_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[" ", ""]), &mut app);
|
||||
assert!(!affected);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_strips_bidi_and_zero_width() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// U+202E RIGHT-TO-LEFT OVERRIDE + U+200B ZERO WIDTH SPACE: stripped so
|
||||
// server text cannot visually disguise a leading `/` (Trojan Source).
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext("resp-1", &["\u{202e}/rm\u{200b}-rf"]),
|
||||
&mut app,
|
||||
);
|
||||
let fu = app.agents[&AgentId(0)].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.suggestions, vec!["/rm-rf"]);
|
||||
assert!(!fu.suggestions[0].contains('\u{202e}'));
|
||||
assert!(!fu.suggestions[0].contains('\u{200b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_caps_count_and_label_length() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let labels: Vec<String> = (0..20).map(|i| format!("s{i}")).collect();
|
||||
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
handle_ext_notification(&follow_ups_ext("resp-1", &refs), &mut app);
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.suggestions
|
||||
.len(),
|
||||
super::MAX_FOLLOW_UPS,
|
||||
"suggestion count capped at ingestion"
|
||||
);
|
||||
let long = "x".repeat(10_000);
|
||||
handle_ext_notification(&follow_ups_ext("resp-2", &[&long]), &mut app);
|
||||
let label = &app.agents[&AgentId(0)]
|
||||
.follow_ups
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.suggestions[0];
|
||||
assert!(
|
||||
label.len() <= super::MAX_FOLLOW_UP_LABEL,
|
||||
"label length clamped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_oversized_response_id_is_rejected() {
|
||||
// An oversized response_id is rejected (not truncated — that
|
||||
// could collide ids) so it can't bloat the retained seen ring.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let big = "r".repeat(super::MAX_RESPONSE_ID_LEN + 1);
|
||||
let affected = handle_ext_notification(&follow_ups_ext(&big, &["x"]), &mut app);
|
||||
assert!(!affected, "an oversized response_id must be rejected");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
// A sane-length id still works.
|
||||
let ok = "r".repeat(super::MAX_RESPONSE_ID_LEN);
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext(&ok, &["x"]),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_replayed_meta_false_renders() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let params = serde_json::json!({
|
||||
"response_id": "resp-1",
|
||||
"suggestions": [{ "label": "x" }],
|
||||
"_meta": { "x.ai/replayed": false },
|
||||
});
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(¶ms).unwrap().into(),
|
||||
);
|
||||
assert!(
|
||||
handle_ext_notification(¬if, &mut app),
|
||||
"_meta replayed=false must still render"
|
||||
);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_per_element_malformed_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
for bad in [
|
||||
serde_json::json!({ "response_id": "r", "suggestions": [{ "label": 7 }] }),
|
||||
serde_json::json!({ "response_id": "r", "suggestions": [null] }),
|
||||
] {
|
||||
let notif = acp::ExtNotification::new(
|
||||
"x.ai/follow_ups",
|
||||
serde_json::value::to_raw_value(&bad).unwrap().into(),
|
||||
);
|
||||
assert!(
|
||||
!handle_ext_notification(¬if, &mut app),
|
||||
"a malformed suggestion element drops the notification: {bad}"
|
||||
);
|
||||
}
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_array_renders_no_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[]), &mut app);
|
||||
assert!(!affected);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_empty_for_current_response_clears_chips() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
handle_ext_notification(&follow_ups_ext("resp-1", &["a"]), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_some());
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-1", &[]), &mut app);
|
||||
assert!(affected, "empty for the shown response clears the chips");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_ups_viewer_turn_transition_renders_newer_chips() {
|
||||
// A viewer holding resp-1's chips adopts the driver's NEXT
|
||||
// turn via a live delta (which clears the prior chips), then resp-2's
|
||||
// follow_ups render — not suppressed by the held resp-1.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.attached_as_viewer = true;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
agent.apply_follow_ups("resp-1".into(), vec!["old".into()]);
|
||||
}
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let request = acp::SessionNotification::new(
|
||||
acp::SessionId::new("sess-1"),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
acp::TextContent::new("resp-2 streaming"),
|
||||
))),
|
||||
)
|
||||
.meta(
|
||||
serde_json::json!({ "promptId": "p2", "agentTimestampMs": 1 })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
);
|
||||
handle(
|
||||
AcpClientMessage::SessionNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].follow_ups.is_none(),
|
||||
"viewer adopting a new turn must clear the prior chips"
|
||||
);
|
||||
let affected = handle_ext_notification(&follow_ups_ext("resp-2", &["new"]), &mut app);
|
||||
assert!(affected);
|
||||
let fu = app.agents[&id].follow_ups.as_ref().unwrap();
|
||||
assert_eq!(fu.response_id, "resp-2");
|
||||
assert_eq!(fu.suggestions, vec!["new"]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── derive_child_cwd ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_uses_child_cwd_from_info() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.child_cwd = Some("/child/worktree".into());
|
||||
info.worktree_path = Some("/child/worktree".into());
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/child/worktree"));
|
||||
assert!(is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_falls_back_to_parent_when_child_cwd_is_none() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let info = make_subagent_info("child-2");
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"));
|
||||
assert!(!is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_worktree_independent_of_child_cwd() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let mut info = make_subagent_info("child-3");
|
||||
info.child_cwd = None;
|
||||
info.worktree_path = Some("/some/worktree".into());
|
||||
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, Some(&info));
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"), "falls back to parent");
|
||||
assert!(
|
||||
is_wt,
|
||||
"worktree flag must be set even when child_cwd is None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_no_info_falls_back() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
let (cwd, is_wt) = derive_child_cwd(&parent_cwd, None);
|
||||
assert_eq!(cwd, PathBuf::from("/parent/cwd"));
|
||||
assert!(!is_wt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_updates_root_agent() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let notif = make_git_head_changed_notif("sess-A", Some("feature/x"), false, None);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(changed);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.current_branch.as_deref(), Some("feature/x"));
|
||||
assert!(!agent.is_worktree);
|
||||
assert!(agent.main_repo.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_routes_to_child_subagent_view() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let child_sid = "child-sess-1";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
}
|
||||
|
||||
let notif = make_git_head_changed_notif(
|
||||
child_sid,
|
||||
Some("worktree-branch"),
|
||||
true,
|
||||
Some("main-repo"),
|
||||
);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(changed);
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child_view = parent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.current_branch.as_deref(),
|
||||
Some("worktree-branch")
|
||||
);
|
||||
assert!(child_view.is_worktree);
|
||||
assert_eq!(child_view.main_repo.as_deref(), Some("main-repo"));
|
||||
// Parent must not be affected.
|
||||
assert!(parent.current_branch.is_none());
|
||||
assert!(!parent.is_worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_unknown_session_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let notif = make_git_head_changed_notif("unknown-sess", Some("main"), false, None);
|
||||
let changed = handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
assert!(!changed);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.current_branch.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_changed_root_agent_not_affected_when_child_matches() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let child_sid = "child-sess-2";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
parent.current_branch = Some("parent-branch".into());
|
||||
}
|
||||
|
||||
let notif = make_git_head_changed_notif(child_sid, Some("child-branch"), true, None);
|
||||
handle_git_head_changed(¬if, &mut app);
|
||||
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parent.current_branch.as_deref(),
|
||||
Some("parent-branch"),
|
||||
"parent's branch must not change when child is updated"
|
||||
);
|
||||
let child_view = parent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(child_view.current_branch.as_deref(), Some("child-branch"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn goal_updated_ignores_unknown_json_fields_via_serde() {
|
||||
// Serde-side half of the forward-compat story: a payload that
|
||||
// carries an extra JSON field absent on today's
|
||||
// `SessionUpdate::GoalUpdated` (no `deny_unknown_fields` on the
|
||||
// variant) must still deserialize and drive a full
|
||||
// `GoalDisplayState`. This guards against someone later adding
|
||||
// `#[serde(deny_unknown_fields)]` to the variant, which would
|
||||
// silently break wire compatibility with older shells.
|
||||
//
|
||||
// The complementary Rust-level half — that the destructure with
|
||||
// trailing `..` keeps absent additive `Option<T>` fields landing
|
||||
// as `None` in the mapped `GoalDisplayState` — is exercised by
|
||||
// `goal_updated_absent_optional_fields_deserialize_to_none`.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g-ext",
|
||||
"objective": "build forward-compat tolerance",
|
||||
"status": "active",
|
||||
"phase": "executing",
|
||||
"token_budget": 200_000,
|
||||
"tokens_used": 12_345,
|
||||
"elapsed_ms": 750,
|
||||
"total_deliverables": 2,
|
||||
"completed_deliverables": 1,
|
||||
"current_deliverable_idx": 1,
|
||||
"current_deliverable_title": "Wire compat",
|
||||
"current_subagent_role": "verifier",
|
||||
"total_worker_rounds": 5,
|
||||
"total_verify_rounds": 2,
|
||||
"token_baseline": 100,
|
||||
"finished_subagent_tokens": 99,
|
||||
"live_subagent_tokens": 4_321,
|
||||
"live_tokens_by_model": [["grok-4", 6_000], ["grok-3", 4_000]],
|
||||
"live_context_pct": 42,
|
||||
"live_turn_count": 7,
|
||||
"live_tool_call_count": 11,
|
||||
"last_event": "verify_started",
|
||||
"last_event_detail": "round 2 of 3",
|
||||
"last_event_timestamp": "2026-05-24T00:00:00Z",
|
||||
// Field absent on today's `SessionUpdate::GoalUpdated` — simulates
|
||||
// a future shell adding a new wire field. With trailing `..` in
|
||||
// the destructure and no `deny_unknown_fields` on the variant,
|
||||
// this must parse and the pager must still produce a
|
||||
// GoalDisplayState mapped from the known subset.
|
||||
"future_field_for_pr5": "ignored-by-todays-pager"
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session_notification", raw.into());
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
assert!(
|
||||
affected,
|
||||
"GoalUpdated for the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let goal = agent
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.expect("GoalUpdated should populate goal_state even with unknown wire fields");
|
||||
assert_eq!(goal.goal_id, "g-ext");
|
||||
assert_eq!(goal.objective, "build forward-compat tolerance");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
assert_eq!(goal.phase, GoalDisplayPhase::Executing);
|
||||
assert_eq!(goal.token_budget, Some(200_000));
|
||||
assert_eq!(goal.tokens_used, 12_345);
|
||||
assert_eq!(goal.elapsed_ms, 750);
|
||||
assert_eq!(goal.total_deliverables, 2);
|
||||
assert_eq!(goal.completed_deliverables, 1);
|
||||
assert_eq!(goal.current_deliverable_id, Some(1));
|
||||
assert_eq!(
|
||||
goal.current_deliverable_title.as_deref(),
|
||||
Some("Wire compat")
|
||||
);
|
||||
assert_eq!(goal.current_subagent_role.as_deref(), Some("verifier"));
|
||||
assert_eq!(goal.total_worker_rounds, 5);
|
||||
assert_eq!(goal.total_verify_rounds, 2);
|
||||
assert_eq!(goal.token_baseline, 100);
|
||||
assert_eq!(goal.finished_subagent_tokens, 99);
|
||||
assert_eq!(goal.live_subagent_tokens, Some(4_321));
|
||||
assert_eq!(
|
||||
goal.live_tokens_by_model,
|
||||
vec![("grok-4".to_owned(), 6_000), ("grok-3".to_owned(), 4_000)],
|
||||
"populated per-model breakdown must round-trip wire->display"
|
||||
);
|
||||
assert_eq!(goal.live_context_pct, Some(42));
|
||||
assert_eq!(goal.live_turn_count, Some(7));
|
||||
assert_eq!(goal.live_tool_call_count, Some(11));
|
||||
assert_eq!(goal.last_event.as_deref(), Some("verify_started"));
|
||||
assert_eq!(goal.last_event_detail.as_deref(), Some("round 2 of 3"));
|
||||
assert_eq!(
|
||||
goal.last_event_timestamp.as_deref(),
|
||||
Some("2026-05-24T00:00:00Z")
|
||||
);
|
||||
assert_eq!(goal.pause_message, None);
|
||||
// Classifier fields default to `None` / `false` when absent.
|
||||
assert_eq!(goal.classifier_runs_attempted, None);
|
||||
assert_eq!(goal.classifier_max_runs, None);
|
||||
assert_eq!(goal.last_classifier_verdict, None);
|
||||
assert_eq!(goal.last_classifier_details_path, None);
|
||||
assert!(!goal.verifying_completion);
|
||||
assert!(!goal.planning);
|
||||
assert!(
|
||||
goal.deliverables.is_empty(),
|
||||
"deliverables is wire-compat-only in the simplified goal model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_complete_transition_pushes_end_to_end_marker_once() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let send = |app: &mut AppView, status: &str, elapsed_ms: u64| {
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g1",
|
||||
"objective": "obj",
|
||||
"status": status,
|
||||
"phase": "executing",
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
handle(
|
||||
AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtNotification::new("x.ai/session_notification", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
let goal_markers = |app: &AppView| -> Vec<std::time::Duration> {
|
||||
let sb = &app.agents.get(&AgentId(0)).unwrap().scrollback;
|
||||
(0..sb.len())
|
||||
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) => match &b.event {
|
||||
SessionEvent::GoalCompleted { elapsed } => Some(*elapsed),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
send(&mut app, "active", 1_000);
|
||||
assert!(goal_markers(&app).is_empty(), "no marker while Active");
|
||||
|
||||
send(&mut app, "complete", 619_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app),
|
||||
vec![std::time::Duration::from_millis(619_000)],
|
||||
"transition to Complete pushes one e2e marker with the goal's total time",
|
||||
);
|
||||
|
||||
// A repeat Complete update (e.g. a late notification) must not
|
||||
// duplicate the marker.
|
||||
send(&mut app, "complete", 620_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app).len(),
|
||||
1,
|
||||
"repeat Complete must not push a second marker",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_elapsed_is_monotonic_across_updates() {
|
||||
// The displayed elapsed must never tick backward when a notification's
|
||||
// authoritative base is below the already-extrapolated value;
|
||||
// `elapsed_floor_ms` clamps it.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
assert!(send_goal_update(&mut app, "g1", "active", 10_000));
|
||||
let a = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(a >= 10_000);
|
||||
|
||||
// Same goal, but a LOWER authoritative base (extrapolation outran the
|
||||
// shell's flush point).
|
||||
send_goal_update(&mut app, "g1", "active", 8_000);
|
||||
let b = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(b >= a, "elapsed must not tick backward: {b} < {a}");
|
||||
assert!(b >= 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_goal_is_not_resurrected_by_late_update() {
|
||||
// After a goal is cleared, a late in-flight GoalUpdated for the same
|
||||
// goal_id (queued before the clear) must be dropped so the "Done"
|
||||
// chip / modal stay cleared and don't resurrect.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_some(),
|
||||
"goal present after complete"
|
||||
);
|
||||
|
||||
// Clear (the cleared event itself carries an empty goal_id).
|
||||
send_goal_update(&mut app, "", "cleared", 0);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
"chip cleared on cleared status"
|
||||
);
|
||||
|
||||
// A stale late update for the cleared goal must not resurrect it.
|
||||
let affected = send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
"cleared goal must not resurrect"
|
||||
);
|
||||
assert!(!affected, "ignored stale update must not request a redraw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_goal_after_clear_is_not_suppressed() {
|
||||
// A genuinely new goal (different id) after a clear must start
|
||||
// normally — the cleared-id guard only drops the SAME id.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 1_000);
|
||||
send_goal_update(&mut app, "", "cleared", 0);
|
||||
assert!(send_goal_update(&mut app, "g2", "active", 500));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.goal_state.as_ref().expect("new goal present").goal_id,
|
||||
"g2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_switch_resets_elapsed_floor() {
|
||||
// A NEW goal (different id) must start its own clock and NOT inherit
|
||||
// the prior goal's carried elapsed floor.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 10_000);
|
||||
// Switch directly to a different goal with a small elapsed base.
|
||||
send_goal_update(&mut app, "g2", "active", 500);
|
||||
let elapsed = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.live_elapsed_ms();
|
||||
assert!(
|
||||
elapsed < 5_000,
|
||||
"new goal must start from its own base, not the prior 10s floor: {elapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_updated_resolves_details_path_existence_on_receipt() {
|
||||
// The handler resolves last_classifier_details_path's existence ONCE
|
||||
// on receipt into the cached bool (no per-frame stat).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
// A real on-disk path → cached exists = true.
|
||||
let f = tempfile::NamedTempFile::new().unwrap();
|
||||
let real_path = f.path().to_string_lossy().into_owned();
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!(real_path);
|
||||
dispatch_goal_update(&mut app, update);
|
||||
let g = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
g.last_classifier_details_exists,
|
||||
"existing details path must cache exists = true"
|
||||
);
|
||||
assert_eq!(
|
||||
g.last_classifier_details_path.as_deref(),
|
||||
Some(real_path.as_str())
|
||||
);
|
||||
|
||||
// A missing path → cached exists = false (modal renders "(unavailable)").
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!("/no/such/details-xyz.md");
|
||||
dispatch_goal_update(&mut app, update);
|
||||
let g = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!g.last_classifier_details_exists,
|
||||
"missing details path must cache exists = false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_updated_absent_optional_fields_deserialize_to_none() {
|
||||
// Rust-level forward-compat half: every additive
|
||||
// `Option<T>` field on `SessionUpdate::GoalUpdated` is allowed to
|
||||
// be omitted from the wire payload and must surface as `None` in
|
||||
// the destructured arm — i.e. the pager keeps mapping the known
|
||||
// subset cleanly when the shell-side struct grows or when an
|
||||
// older shell omits newer optional fields. Drop a handful of
|
||||
// optional keys from the payload and assert they materialise as
|
||||
// `None` on the resulting `GoalDisplayState`.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
"sessionId": "sess-A",
|
||||
"update": {
|
||||
"sessionUpdate": "goal_updated",
|
||||
"goal_id": "g-min",
|
||||
"objective": "minimal payload",
|
||||
"status": "active",
|
||||
"phase": "idle",
|
||||
// token_budget omitted — Option<i64> must default to None.
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": 0,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
// current_deliverable_idx omitted — Option<u32> -> None.
|
||||
// current_deliverable_title omitted — Option<String> -> None.
|
||||
// current_subagent_role omitted — Option<String> -> None.
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
// live_subagent_tokens omitted — Option<u64> -> None.
|
||||
// live_context_pct omitted — Option<u8> -> None.
|
||||
// live_turn_count omitted — Option<u32> -> None.
|
||||
// live_tool_call_count omitted — Option<u32> -> None.
|
||||
// last_event omitted — Option<String> -> None.
|
||||
// last_event_detail omitted — Option<String> -> None.
|
||||
// last_event_timestamp omitted — Option<String> -> None.
|
||||
// pause_message omitted — Option<String> -> None.
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session_notification", raw.into());
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
assert!(
|
||||
affected,
|
||||
"minimal GoalUpdated for the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let goal = agent
|
||||
.goal_state
|
||||
.as_ref()
|
||||
.expect("GoalUpdated must populate goal_state even with all Option fields omitted");
|
||||
|
||||
// Required fields landed as sent.
|
||||
assert_eq!(goal.goal_id, "g-min");
|
||||
assert_eq!(goal.objective, "minimal payload");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
assert_eq!(goal.phase, GoalDisplayPhase::Idle);
|
||||
assert_eq!(goal.tokens_used, 0);
|
||||
assert_eq!(goal.elapsed_ms, 0);
|
||||
assert_eq!(goal.total_deliverables, 0);
|
||||
assert_eq!(goal.completed_deliverables, 0);
|
||||
assert_eq!(goal.total_worker_rounds, 0);
|
||||
assert_eq!(goal.total_verify_rounds, 0);
|
||||
assert_eq!(goal.token_baseline, 0);
|
||||
assert_eq!(goal.finished_subagent_tokens, 0);
|
||||
|
||||
// Every omitted Option<T> wire field must surface as None — this
|
||||
// is the property that keeps the destructure stable as the shell
|
||||
// grows additive optional fields.
|
||||
assert_eq!(goal.token_budget, None, "token_budget");
|
||||
assert_eq!(goal.current_deliverable_id, None, "current_deliverable_id");
|
||||
assert_eq!(
|
||||
goal.current_deliverable_title, None,
|
||||
"current_deliverable_title"
|
||||
);
|
||||
assert_eq!(goal.current_subagent_role, None, "current_subagent_role");
|
||||
assert_eq!(goal.live_subagent_tokens, None, "live_subagent_tokens");
|
||||
assert!(
|
||||
goal.live_tokens_by_model.is_empty(),
|
||||
"omitted live_tokens_by_model must default to empty via #[serde(default)]"
|
||||
);
|
||||
assert_eq!(goal.live_context_pct, None, "live_context_pct");
|
||||
assert_eq!(goal.live_turn_count, None, "live_turn_count");
|
||||
assert_eq!(goal.live_tool_call_count, None, "live_tool_call_count");
|
||||
assert_eq!(goal.last_event, None, "last_event");
|
||||
assert_eq!(goal.last_event_detail, None, "last_event_detail");
|
||||
assert_eq!(goal.last_event_timestamp, None, "last_event_timestamp");
|
||||
assert_eq!(goal.pause_message, None, "pause_message");
|
||||
assert_eq!(
|
||||
goal.classifier_runs_attempted, None,
|
||||
"classifier_runs_attempted"
|
||||
);
|
||||
assert_eq!(goal.classifier_max_runs, None, "classifier_max_runs");
|
||||
assert_eq!(
|
||||
goal.last_classifier_verdict, None,
|
||||
"last_classifier_verdict"
|
||||
);
|
||||
assert_eq!(
|
||||
goal.last_classifier_details_path, None,
|
||||
"last_classifier_details_path"
|
||||
);
|
||||
assert!(
|
||||
!goal.verifying_completion,
|
||||
"verifying_completion defaults to false"
|
||||
);
|
||||
assert!(!goal.planning, "planning defaults to false");
|
||||
assert!(
|
||||
goal.deliverables.is_empty(),
|
||||
"deliverables is wire-compat-only in the simplified goal model"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_permission() {
|
||||
// A peer answered a shared permission → this pane retracts its copy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (msg, _rx) = make_permission_message("sess-1");
|
||||
handle(msg, &mut app);
|
||||
assert_eq!(app.agents[&AgentId(0)].permission_queue.len(), 1);
|
||||
|
||||
let changed = handle_session_notification(
|
||||
&interaction_resolved_ext("sess-1", "call-perm-1"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(changed, "dismissing a visible permission must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].permission_queue.is_empty(),
|
||||
"the resolved permission must be removed from the queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_question() {
|
||||
use crate::views::question_view::QuestionViewState;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let stashed = agent.prompt.stash();
|
||||
agent.question_view = Some(QuestionViewState::new("call-q".into(), vec![], stashed));
|
||||
}
|
||||
|
||||
let changed =
|
||||
handle_session_notification(&interaction_resolved_ext("sess-1", "call-q"), &mut app);
|
||||
assert!(changed, "dismissing a visible question must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].question_view.is_none(),
|
||||
"the resolved question must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_dismisses_matching_plan_approval() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext_with_tool_call_id("call-plan", Some("# Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
assert!(app.agents[&AgentId(0)].plan_approval_view.is_some());
|
||||
|
||||
let changed =
|
||||
handle_session_notification(&interaction_resolved_ext("sess-1", "call-plan"), &mut app);
|
||||
assert!(changed, "dismissing a visible plan approval must redraw");
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].plan_approval_view.is_none(),
|
||||
"the resolved plan approval must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_resolved_is_noop_for_unknown_tool_call_id() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (msg, _rx) = make_permission_message("sess-1");
|
||||
handle(msg, &mut app);
|
||||
|
||||
let changed = handle_session_notification(
|
||||
&interaction_resolved_ext("sess-1", "some-other-call"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(!changed, "an unknown tool_call_id must be a silent no-op");
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].permission_queue.len(),
|
||||
1,
|
||||
"an unrelated pending modal must be left intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_agent_queues_on_owning_agent() {
|
||||
// The headline behavior change in handle_permission_request:
|
||||
// permissions for an inactive owning agent now QUEUE (not cancel)
|
||||
// so the user sees them on switching back.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let (msg, mut rx) = make_permission_message("sess-A");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.permission_queue.len(),
|
||||
1,
|
||||
"permission for inactive A must queue on A's permission_queue"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b.permission_queue.len(),
|
||||
0,
|
||||
"active B's permission_queue must remain empty"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"permission queued on a non-active agent must not request a redraw"
|
||||
);
|
||||
// Permission is still pending; the response_tx must still be alive
|
||||
// (no auto-cancel was sent).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"permission must NOT have been answered yet (queued, not cancelled)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_user_question_routes_to_background_session_not_active_view() {
|
||||
// Repro of the dashboard bug: a session started but not entered asks a
|
||||
// question. Active view is agent A (sess-A); the question is for the
|
||||
// BACKGROUND agent B (sess-B). It must land on B, not fail or land on A.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let raw = serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sessionId": "sess-B",
|
||||
"toolCallId": "tc-bg",
|
||||
"questions": [],
|
||||
"mode": "default",
|
||||
}))
|
||||
.unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/ask_user_question", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"a background-session question must not redraw the active view"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().question_view.is_some(),
|
||||
"question must be parked on the session that asked (background agent B)"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().question_view.is_none(),
|
||||
"question must NOT land on the unrelated active agent A"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT be sent yet (parked, waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_user_question_unknown_session_parks_without_error() {
|
||||
// No local view for the session, and the active agent HAS a session_id
|
||||
// (so the race-window fallback does not fire). The reverse-request must
|
||||
// be left UNANSWERED (dropped) — NOT failed with an error, which would
|
||||
// render the tool red. Leader replay-on-attach handles it later.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let raw = serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"sessionId": "sess-unknown",
|
||||
"toolCallId": "tc-unknown",
|
||||
"questions": [],
|
||||
"mode": "default",
|
||||
}))
|
||||
.unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/ask_user_question", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().question_view.is_none(),
|
||||
"must not attach the question to an unrelated active agent"
|
||||
);
|
||||
// A dropped oneshot sender yields `Closed`; `Empty` would mean still
|
||||
// held open, `Ok` would mean a (failing) response was sent.
|
||||
match rx.try_recv() {
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
|
||||
panic!("response_tx must be dropped (parked), not held open")
|
||||
}
|
||||
Ok(_) => panic!("must NOT send any response — that would fail/resolve the tool"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_yolo_agent_auto_approves() {
|
||||
// YOLO mode is honored on the OWNING agent, not the active one,
|
||||
// so background turns aren't blocked waiting for a switch.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let (msg, rx) = make_permission_message("sess-A");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected, "YOLO auto-approve never needs a redraw");
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.permission_queue.len(),
|
||||
0,
|
||||
"YOLO must auto-approve in place of queueing"
|
||||
);
|
||||
let response = rx
|
||||
.blocking_recv()
|
||||
.expect("YOLO must have sent a response on response_tx");
|
||||
let resp = response.expect("YOLO response must be Ok");
|
||||
match resp.outcome {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome {
|
||||
option_id,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(option_id.0.as_ref(), "allow-once");
|
||||
}
|
||||
other => panic!("expected Selected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_for_unknown_session_id_is_cancelled() {
|
||||
// No agent owns the session and the active agent already has a
|
||||
// session_id (so the race-window fallback does not fire). The
|
||||
// permission must be cancelled rather than queued anywhere.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let (msg, rx) = make_permission_message("sess-unknown");
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(!affected);
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
assert_eq!(
|
||||
app.agents.get(&id).unwrap().permission_queue.len(),
|
||||
0,
|
||||
"no agent should have queued the unknown-session permission",
|
||||
);
|
||||
}
|
||||
let response = rx
|
||||
.blocking_recv()
|
||||
.expect("cancel_permission must have sent a response");
|
||||
let resp = response.expect("response must be Ok");
|
||||
assert!(
|
||||
matches!(resp.outcome, acp::RequestPermissionOutcome::Cancelled),
|
||||
"unknown session_id permissions must be cancelled, got {:?}",
|
||||
resp.outcome,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan approval persistence tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_viewer_preserves_plan_approval_state() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-persist".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.plan_approval_view.is_some(), "approval should be set");
|
||||
|
||||
// Close the viewer (simulates Esc / close button).
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// Approval state must survive the close.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must persist after viewer close"
|
||||
);
|
||||
assert!(agent.line_viewer.is_none(), "viewer should be closed");
|
||||
|
||||
// Response must NOT have been sent (still waiting for user).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must not be sent on viewer close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_viewer_restores_approval_buttons() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
// Seed a CreatePlan tool so the source is Inline (plan content
|
||||
// is carried in the ext_method params, not read from disk).
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "tc-reopen", "CreatePlan");
|
||||
}
|
||||
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-reopen".into(),
|
||||
plan_content: Some("# Plan\nStep 1".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
assert!(agent.line_viewer.is_none());
|
||||
|
||||
// Reopen plan preview — inline content is in plan_approval_view.plan_content.
|
||||
agent.show_plan_preview();
|
||||
|
||||
assert!(agent.line_viewer.is_some(), "viewer should reopen");
|
||||
assert!(
|
||||
agent.line_viewer.as_ref().unwrap().feedback_active(),
|
||||
"feedback_active must be true after reopen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_after_reopen_does_not_overwrite_prompt() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "tc-prompt", "CreatePlan");
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-prompt".into(),
|
||||
plan_content: Some("# Plan\nDo things".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
handle(
|
||||
AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// User types new text in the prompt while viewer is closed.
|
||||
agent.prompt.set_text("my new prompt text");
|
||||
|
||||
agent.reopen_plan_approval();
|
||||
agent.approve_plan();
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.prompt.text(),
|
||||
"my new prompt text",
|
||||
"stashed prompt should be restored after reopen + approve"
|
||||
);
|
||||
|
||||
// Response should be approved.
|
||||
let response = rx.blocking_recv().expect("should have sent response");
|
||||
let raw = response.expect("should be Ok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).unwrap();
|
||||
assert_eq!(parsed["outcome"], "approved");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a shared-queue interjection renders only via the broadcast,
|
||||
/// and the shell emits the queue-emptying `x.ai/queue/changed` right after
|
||||
/// it — which used to fire the withheld parked marker BELOW the just-
|
||||
/// rendered user message ("Worked for …" under the follow-up, flipped
|
||||
/// transcript order). The broadcast must consume the marker slot instead.
|
||||
#[test]
|
||||
fn interjection_broadcast_mid_park_forgoes_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_task_output_wait(agent, "bg-1");
|
||||
assert!(agent.is_parked_on_sendable_wait());
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "queued follow-up"),
|
||||
&mut app,
|
||||
));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.parked_wait_marker_for,
|
||||
Some(crate::app::agent_view::ParkedMarkerSlot::Forgone(
|
||||
"p1".into()
|
||||
)),
|
||||
"broadcast render must consume the parked-marker slot as Forgone"
|
||||
);
|
||||
// The queue-changed following the broadcast must not fire it late.
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no late 'Worked for …' marker under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a Forgone slot (interjection continued
|
||||
/// the parked turn, no marker on screen) must also silence the countdown
|
||||
/// — a full "Worked for …" tick under the interjected message would
|
||||
/// recreate the flipped transcript. Rendered slots keep ticking.
|
||||
#[test]
|
||||
fn forgone_slot_suppresses_countdown_ticks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
// The parked drain interjected a queued row before the marker
|
||||
// became eligible: slot consumed WITHOUT a marker.
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
assert!(agent.renders_parked(), "forgone slot keeps parked chrome");
|
||||
assert_eq!(count_parked(agent), 0, "no marker on screen");
|
||||
}
|
||||
|
||||
// A task completing in the still-parked window must stay silent.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no 'Worked for …' tick under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Feature: "sleep 10, 15, 20 in the background" — while the turn is
|
||||
/// parked, each task completion appends a fresh FULL marker with the
|
||||
/// remaining count, so the user watches it tick down (3 → 2 → 1), each
|
||||
/// line a complete "Worked for X. N commands still running.".
|
||||
/// The last completion pushes nothing (0/0): the wait returns and the
|
||||
/// real completion marker narrates the end. (Elapsed renders as "0.0s":
|
||||
/// `turn_started_at` is unset in this fixture.)
|
||||
#[test]
|
||||
fn parked_countdown_ticks_down_as_tasks_complete() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
insert_running_task(agent, "t20", "sleep 20");
|
||||
simulate_task_output_wait(agent, "t20");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
|
||||
// sleep 10 exits → full marker with "2 commands still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// Duplicate completion for the same task: not a Running→Done edge.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 15 exits → full marker with "1 command still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t15", "sleep 15", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 20 exits → nothing left; no "0 commands" line.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t20", "sleep 20", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 3 commands still running.".to_string(),
|
||||
"Worked for 0.0s. 2 commands still running.".to_string(),
|
||||
"Worked for 0.0s. 1 command still running.".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_subagent_finishes_refresh_one_uncommitted_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
|
||||
// Re-delivered finish for an already-finished subagent: not an edge.
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-3")),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_text_thought_and_tool_output_start_new_subagent_segments() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call;
|
||||
|
||||
crate::appearance::cache::set_show_thinking_blocks(true);
|
||||
for output_kind in ["text", "thought", "tool"] {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2", "child-3"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
if output_kind == "tool" {
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
"read_file",
|
||||
)
|
||||
.kind(acp::ToolKind::Read)
|
||||
.status(acp::ToolCallStatus::InProgress)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
}
|
||||
simulate_task_output_wait_call(agent, "wait-1", "not-ours", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let output = match output_kind {
|
||||
"text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent text")),
|
||||
)),
|
||||
"thought" => acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent thought")),
|
||||
)),
|
||||
"tool" => acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.status(Some(acp::ToolCallStatus::Completed)),
|
||||
)),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
output,
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000);
|
||||
}
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 2 subagents still running.".to_string(),
|
||||
"Worked for 0.0s. 1 subagent still running.".to_string(),
|
||||
],
|
||||
"{output_kind} output must start a new segment",
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_subagent_marker_appends_fallback() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
let marker_index = (0..agent.scrollback.len())
|
||||
.find(|&index| agent.scrollback.get(index).is_some_and(|entry| entry.id == marker_id))
|
||||
.unwrap();
|
||||
agent.scrollback.mark_committed(marker_index);
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec![
|
||||
"Worked for 0.0s. 2 subagents still running.".to_string(),
|
||||
"Worked for 0.0s. 1 subagent still running.".to_string(),
|
||||
],
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_subagent_marker_handle_appends_fallback() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let old_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2"])
|
||||
};
|
||||
assert!(app
|
||||
.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.remove_entry(old_marker_id));
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 1);
|
||||
assert_ne!(marker_ids[0], old_marker_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_suppresses_later_subagent_refresh() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "continue differently"),
|
||||
&mut app,
|
||||
));
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_subagent_finish_does_not_refresh_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
agent.session.loading_replay = true;
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_subagent_wait_does_not_refresh_marker() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
simulate_task_output_wait(agent, "child-1");
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 2 subagents still running.".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
/// Synthetic completions from cold-load reconciliation (`session_restart`
|
||||
/// signal) finalize quietly — no countdown line, mirroring the suppressed
|
||||
/// "Task failed" block: nothing happened in THIS session.
|
||||
#[test]
|
||||
fn stale_on_load_completion_pushes_no_countdown() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif_with_signal(
|
||||
"sess-park",
|
||||
"t10",
|
||||
"sleep 10",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
// Only the initial parked marker — no countdown re-push.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(parked_marker_messages(agent).len(), 1);
|
||||
}
|
||||
|
||||
/// Task completions with no parked look (running turn chrome is up, or
|
||||
/// the turn already ended) must not emit countdown lines — the Tasks
|
||||
/// pane and completion blocks already narrate those states.
|
||||
#[test]
|
||||
fn task_completion_without_parked_look_pushes_no_countdown() {
|
||||
let mut app = make_app_with_agent("sess-live");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
// No wait, no parked marker: chrome is the live turn.
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-live", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(parked_marker_messages(agent).is_empty());
|
||||
}
|
||||
|
||||
// -- imminent waits do not park (awaited work already finished) ----------
|
||||
|
||||
/// Waiting on a task that already completed: no marker, slot stays free.
|
||||
#[test]
|
||||
fn wait_on_already_completed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "imminent wait must not park");
|
||||
assert!(
|
||||
agent.parked_wait_marker_for.is_none(),
|
||||
"slot must stay free for a later genuine park"
|
||||
);
|
||||
assert!(!agent.renders_parked());
|
||||
}
|
||||
|
||||
/// A skipped wait leaves the slot free: a later wait on running work in
|
||||
/// the same turn still parks.
|
||||
#[test]
|
||||
fn later_genuine_wait_still_parks_after_imminent_wait_skip() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "done", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0);
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
insert_running_task(agent, "live", "sleep 99");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "live", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "genuine park still renders");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed` is terminal for imminence, not just `Done`.
|
||||
#[test]
|
||||
fn wait_on_failed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Failed;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "failed task wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// Finished-subagent waits do not park — resolved by subagent id, then by
|
||||
/// child session id.
|
||||
#[test]
|
||||
fn wait_on_finished_subagent_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.finished = true;
|
||||
agent.subagent_sessions.insert("child-1".into(), info);
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "sa-child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "finished subagent wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "child-session-id wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// One unresolvable id among terminal ones keeps the park.
|
||||
#[test]
|
||||
fn wait_including_unknown_id_still_parks() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::count_parked;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
let meta = NotificationMeta::default();
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
"get_command_or_subagent_output",
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({
|
||||
"task_ids": ["done", "not-ours"],
|
||||
"timeout_ms": 30_000,
|
||||
}))),
|
||||
)),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "unresolvable id keeps the park");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_zero_work_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_running_work_still_parks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "wait-all on live work parks");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `SubagentSpawned` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn subagent_spawn_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle(
|
||||
make_ext_session_notification(
|
||||
"sess-park",
|
||||
test_subagent_spawned("sess-park", "child-1"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 subagent still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `x.ai/task_backgrounded` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn task_backgrounded_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle_ext_notification(
|
||||
&make_task_backgrounded_notif("sess-park", "tc-late", "t-late", "sleep 99"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
1,
|
||||
"task registration re-evaluates the skipped park"
|
||||
);
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s. 1 command still running.".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_pushes_block_to_matching_session() {
|
||||
// Multi-client fix: an interjection typed in one pane is broadcast by
|
||||
// the shell as x.ai/session/interjection; EVERY attached pane (incl.
|
||||
// the originator, which no longer pushes a local block) renders it.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "also add tests"), &mut app);
|
||||
assert!(affected, "rendering into the active agent should redraw");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("also add tests"),
|
||||
"the interjection block must be pushed from the broadcast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_for_unknown_session_is_ignored() {
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected = handle_ext_notification(&interjection_ext("sess-other", "stray"), &mut app);
|
||||
assert!(!affected, "an unmatched session must be a no-op");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
last_interjection_text(&agent.scrollback).is_none(),
|
||||
"no interjection block must be pushed for an unknown session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_renders_for_a_viewer() {
|
||||
// A viewer (attached_as_viewer) watching another client's session must
|
||||
// also render interjections broadcast for that session.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "viewer sees this"), &mut app);
|
||||
assert!(affected);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("viewer sees this"),
|
||||
"a viewer must render interjections broadcast for its session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_dedups_originators_own_echo() {
|
||||
// The originator rendered an optimistic block in dispatch_interject and
|
||||
// recorded the id; its own broadcast echo must be dropped (no dup) and
|
||||
// the id forgotten.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.self_interjection_ids
|
||||
.insert("ij-1".to_string());
|
||||
|
||||
let affected = handle_ext_notification(
|
||||
&interjection_ext_with_id("sess-view", "my own", Some("ij-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"an originator's own echo must be a no-op (already rendered locally)"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
last_interjection_text(&agent.scrollback).is_none(),
|
||||
"the echo must not push a duplicate block"
|
||||
);
|
||||
assert!(
|
||||
!agent.self_interjection_ids.contains("ij-1"),
|
||||
"the id must be forgotten after dedup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_with_foreign_id_renders() {
|
||||
// A broadcast carrying an id this client did NOT mint (another pane's
|
||||
// interjection) must render — only the originator dedups by its own id.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected = handle_ext_notification(
|
||||
&interjection_ext_with_id("sess-view", "from another pane", Some("other-id")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("from another pane"),
|
||||
"an interjection from another pane must render"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_updates_seeded_progress_in_place() {
|
||||
// When a session is seeded with mcp_init_progress{0,0}, a
|
||||
// subsequent init_progress notification must update total and
|
||||
// connected IN PLACE — preserving started_at for timer accuracy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
let started_at = agent.mcp_init_progress.as_ref().unwrap().started_at;
|
||||
|
||||
let notif = make_mcp_init_progress_notif(5, 0);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let progress = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.total, 5, "total must be updated from shell");
|
||||
assert_eq!(progress.connected, 0);
|
||||
assert_eq!(
|
||||
progress.started_at, started_at,
|
||||
"started_at must be preserved (timer anchoring)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_status_routes_to_owning_agent() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
|
||||
// Agent 0 owns sess-owner; Agent 1 is foregrounded.
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Give the active agent its own modal so we can prove it's untouched.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(vec![
|
||||
crate::views::mcps_modal::McpServerInfo {
|
||||
name: "alpha".into(),
|
||||
display_name: None,
|
||||
status: McpServerDisplayStatus::Initializing,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
let tools = Some(serde_json::json!([
|
||||
{ "name": "t1", "description": "one", "enabled": true },
|
||||
{ "name": "t2", "enabled": true },
|
||||
]));
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, tools);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
!redraw,
|
||||
"owner is background — mutation must not request a redraw"
|
||||
);
|
||||
|
||||
// Owner mutated.
|
||||
let owner_modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref owner_servers) = owner_modal.mcps_data else {
|
||||
panic!("owner modal must still be in Loaded state");
|
||||
};
|
||||
assert_eq!(owner_servers[0].status, McpServerDisplayStatus::Ready);
|
||||
assert_eq!(owner_servers[0].tool_count, 2);
|
||||
assert_eq!(owner_servers[0].tools.len(), 2);
|
||||
|
||||
// Active agent's modal must be untouched.
|
||||
let active_modal = app
|
||||
.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref active_servers) = active_modal.mcps_data else {
|
||||
panic!("active modal must still be in Loaded state");
|
||||
};
|
||||
assert_eq!(
|
||||
active_servers[0].status,
|
||||
McpServerDisplayStatus::Initializing,
|
||||
"active-view agent must not absorb the owning agent's push"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_creates_when_none() {
|
||||
// When mcp_init_progress is None (no seed), init_progress
|
||||
// creates a fresh McpInitProgress.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
assert!(app.agents[&AgentId(0)].mcp_init_progress.is_none());
|
||||
|
||||
let notif = make_mcp_init_progress_notif(3, 1);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let progress = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.total, 3);
|
||||
assert_eq!(progress.connected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_clears_progress() {
|
||||
// x.ai/mcp_initialized must set mcp_init_progress to None.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 3,
|
||||
connected: 3,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
let notif = make_mcp_initialized_notif("sess-1");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"mcp_initialized must clear mcp_init_progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_full_lifecycle_seed_to_clear() {
|
||||
// Full N-server lifecycle:
|
||||
// seed(0/0) → init_progress(0/3) → init_progress(2/3)
|
||||
// → init_progress(3/3) → mcp_initialized → None
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell reports real count.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 0));
|
||||
|
||||
// Incremental progress.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 2), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 2));
|
||||
|
||||
// All connected.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 3), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 3));
|
||||
|
||||
// mcp_initialized clears everything.
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"mcp_initialized must clear progress after full lifecycle",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_zero_server_lifecycle() {
|
||||
// 0-server lifecycle (the bug scenario):
|
||||
// seed(0/0) → init_progress(0/0) → mcp_initialized → None
|
||||
// Previously mcp_initialized was never sent for 0 servers,
|
||||
// leaving a stuck progress indicator.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell sends 0/0 for the 0-server case.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(0, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (0, 0));
|
||||
|
||||
// Shell now sends mcp_initialized (root-cause fix).
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"0-server mcp_initialized must clear progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_routes_to_background_session() {
|
||||
// init_progress carrying a background session's sessionId must update
|
||||
// *that* agent's indicator, not the foregrounded one, and must not
|
||||
// force a redraw (the background spinner isn't visible).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
|
||||
let notif = make_mcp_init_progress_notif_for(4, 1, "sess-B");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"background-session progress must not force a redraw"
|
||||
);
|
||||
|
||||
let bg = app.agents[&AgentId(1)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((bg.total, bg.connected), (4, 1));
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
"foreground agent must be untouched by a background session's progress",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_routes_to_background_session() {
|
||||
// mcp_initialized for a background session must clear *that* agent's
|
||||
// indicator while leaving the foreground agent's intact. Previously
|
||||
// the clear was applied to whichever agent was active, so a
|
||||
// background agent's spinner could stick forever.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
app.agents.get_mut(&id).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 2,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-B");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"clearing a background spinner must not force a redraw",
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(1)].mcp_init_progress.is_none(),
|
||||
"background session's spinner must be cleared",
|
||||
);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"foreground agent's spinner must NOT be cleared by another session",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_unknown_session_is_dropped() {
|
||||
// An mcp_initialized for a session that matches no agent must not
|
||||
// clear anyone's indicator (no misrouting to the active agent).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 1,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-unknown");
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"unknown-session mcp_initialized must not clear the active agent",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_lifecycle_notif_for_subagent_session_is_dropped() {
|
||||
// A subagent runs its own MCP init, emitting init_progress /
|
||||
// mcp_initialized under the *child* session id. Those must NOT write to
|
||||
// or clear the parent agent's mcp_init_progress — it's a per-root-agent
|
||||
// indicator with no subagent slot, so a subagent's init must not
|
||||
// clobber the parent's spinner.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
total: 2,
|
||||
connected: 1,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
// Register a subagent child view keyed by the child session id.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.subagent_views
|
||||
.insert(
|
||||
"child-sess".to_string(),
|
||||
Box::new(make_agent(Some("child-sess"))),
|
||||
);
|
||||
|
||||
// init_progress for the child session must leave the parent untouched.
|
||||
let changed = handle_ext_notification(
|
||||
&make_mcp_init_progress_notif_for(5, 0, "child-sess"),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!changed,
|
||||
"subagent init_progress must not redraw the parent"
|
||||
);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(p.total, p.connected),
|
||||
(2, 1),
|
||||
"parent spinner must be untouched by a subagent's init",
|
||||
);
|
||||
|
||||
// mcp_initialized for the child session must not clear the parent.
|
||||
let changed =
|
||||
handle_ext_notification(&make_mcp_initialized_notif_for("child-sess"), &mut app);
|
||||
assert!(!changed);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_some(),
|
||||
"subagent mcp_initialized must not clear the parent's spinner",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_closed_background() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Owner is background and has NO modal open. server_status
|
||||
// must be a silent no-op (no Effect scheduling, no redraw).
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "closed-modal cheap path must not request a redraw");
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"closed-modal cheap path must not schedule any effects"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.is_none(),
|
||||
"owner modal must remain closed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the cheap-path semantics for the FOREGROUND case too.
|
||||
/// Without this, the background case passes trivially regardless
|
||||
/// of how the cheap path is gated on `is_active`.
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_closed_foreground() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Foreground = agent 1 (sess-active). Send a push targeting
|
||||
// the foregrounded agent, no modal open.
|
||||
let notif = make_server_status_notif("sess-active", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
!redraw,
|
||||
"foreground + closed-modal must still be a no-op (no row → no redraw)"
|
||||
);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"server_status NEVER schedules an effect (push is per-row, not list-refetch)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin the cheap-path semantics when the modal is open but the
|
||||
/// data is still loading. The `TabDataState::Loaded`
|
||||
/// gate must early-return without panicking.
|
||||
#[test]
|
||||
fn server_status_handler_noop_when_modal_data_still_loading() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let mut modal = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
modal.mcps_data = TabDataState::Loading;
|
||||
owner.extensions_modal = Some(modal);
|
||||
}
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "Loading state must skip the patch");
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
/// A malformed `status` field must NOT silently coerce to
|
||||
/// `Unavailable`. The whole payload must fail to parse and
|
||||
/// warn-log instead.
|
||||
#[test]
|
||||
fn server_status_malformed_status_does_not_silently_repaint() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Send a payload with the `status` field missing entirely —
|
||||
// a defaulting decoder would silently coerce this to Unavailable.
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-owner",
|
||||
"name": "alpha",
|
||||
"source": "local",
|
||||
"reason": "initialized",
|
||||
// NB: no `status`.
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/mcp/server_status", raw.into());
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "malformed payload must not request a redraw");
|
||||
|
||||
let modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref servers) = modal.mcps_data else {
|
||||
panic!("modal still Loaded");
|
||||
};
|
||||
assert_eq!(
|
||||
servers[0].status,
|
||||
McpServerDisplayStatus::Initializing,
|
||||
"malformed status must NOT silently map to Unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
/// A present-but-non-array `tools` field must drop ONLY the tools
|
||||
/// update. The `status` field still applies — we do not drop the
|
||||
/// whole push.
|
||||
#[test]
|
||||
fn server_status_lenient_tools_decoding_still_applies_status() {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::McpServerDisplayStatus;
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
|
||||
// tools = arbitrary non-array shape (a future shell might emit
|
||||
// something like `{ added: [], removed: [] }`). Must NOT take
|
||||
// down the status update.
|
||||
let bad_tools = Some(serde_json::json!({"added": [], "removed": []}));
|
||||
let notif =
|
||||
make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, bad_tools);
|
||||
let _ = handle_mcp_server_status(¬if, &mut app);
|
||||
|
||||
let modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let TabDataState::Loaded(ref servers) = modal.mcps_data else {
|
||||
panic!("modal still Loaded");
|
||||
};
|
||||
assert_eq!(
|
||||
servers[0].status,
|
||||
McpServerDisplayStatus::Ready,
|
||||
"malformed tools must not take down the status update"
|
||||
);
|
||||
// tool_count / tools were preserved (we dropped the tools
|
||||
// update, not overwrote with empty).
|
||||
assert_eq!(servers[0].tool_count, 0);
|
||||
assert!(servers[0].tools.is_empty());
|
||||
}
|
||||
|
||||
/// Pin that the pager deserializes against the *shell's*
|
||||
/// `McpServerStatus` enum, so a future new variant doesn't need a
|
||||
/// pager change to be recognized. Round-trip through
|
||||
/// `serde_json::to_string` of the shell type itself.
|
||||
#[test]
|
||||
fn server_status_round_trips_shell_canonical_type() {
|
||||
use kigi_shell::extensions::mcp::{
|
||||
McpServerSource, McpServerStatus, McpServerStatusPayload, McpServerStatusReason,
|
||||
};
|
||||
let payload = McpServerStatusPayload {
|
||||
session_id: "s".into(),
|
||||
name: "alpha".into(),
|
||||
source: McpServerSource::Local,
|
||||
status: McpServerStatus::NeedsAuth,
|
||||
reason: McpServerStatusReason::AuthExpired,
|
||||
detail: Some("token expired".into()),
|
||||
tools: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let roundtripped: McpServerStatusPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(payload, roundtripped);
|
||||
// `needsAuth` must be on the wire as the lowercase form, not
|
||||
// mixed-case — verifies the rename_all = lowercase contract.
|
||||
assert!(
|
||||
json.contains("\"needsauth\""),
|
||||
"wire form must be lowercase 'needsauth'; got {json}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `servers_updated` has NO `sessionId` on the wire. The handler
|
||||
/// must broadcast to every agent with an open modal, NOT fall
|
||||
/// through to `active_view`.
|
||||
#[test]
|
||||
fn servers_updated_broadcasts_to_every_agent_with_open_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Open modals on BOTH agents — broadcast must hit both.
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(vec![
|
||||
crate::views::mcps_modal::McpServerInfo {
|
||||
name: "beta".into(),
|
||||
display_name: None,
|
||||
status: crate::views::mcps_modal::McpServerDisplayStatus::Initializing,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
let notif = make_servers_updated_notif();
|
||||
let redraw = handle_mcp_servers_updated(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
redraw,
|
||||
"active agent had its modal open — redraw must be requested"
|
||||
);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
2,
|
||||
"servers_updated must broadcast: one FetchMcpsList per agent with an open modal"
|
||||
);
|
||||
let mut targets: Vec<usize> = app
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::FetchMcpsList {
|
||||
agent_id, cache, ..
|
||||
} => {
|
||||
assert!(*cache, "broadcast must use the debounced cache=true path");
|
||||
Some(agent_id.0)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// Agents without an open modal must NOT receive a refetch (cheap
|
||||
/// path) even though they are eligible to receive the broadcast in
|
||||
/// principle.
|
||||
#[test]
|
||||
fn servers_updated_skips_agents_with_closed_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Only agent 1 (foregrounded) has a modal.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let notif = make_servers_updated_notif();
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"agent 0 has no modal open — must NOT receive a refetch"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } = app.pending_effects.first().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(1),
|
||||
"only the agent with an open modal gets the refetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second `servers_updated` push that lands before agent A's
|
||||
/// pending fetch drains must coalesce for agent A but still
|
||||
/// schedule fresh for agent B if B's first push was never seen.
|
||||
#[test]
|
||||
fn servers_updated_per_agent_coalescing() {
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
let notif = make_servers_updated_notif();
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(app.pending_effects.len(), 1);
|
||||
|
||||
// Second push: owner is now coalesced (agent 0 has pending);
|
||||
// but we add a modal to agent 1 — that agent's push must NOT
|
||||
// be dropped just because agent 0 has a pending fetch.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let _ = handle_mcp_servers_updated(¬if, &mut app);
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
2,
|
||||
"agent 1's first push must schedule a fetch despite agent 0 having a pending one"
|
||||
);
|
||||
let mut targets: Vec<usize> = app
|
||||
.pending_effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::FetchMcpsList { agent_id, .. } => Some(agent_id.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// `mcp_initialized` wire shape `{ sessionId, mcpToolCount, elapsedMs }`
|
||||
/// — sessionId routing applies; the matched agent's
|
||||
/// `mcp_init_progress` overlay must be cleared.
|
||||
#[test]
|
||||
fn mcp_initialized_clears_init_progress_on_owner() {
|
||||
use crate::app::agent_view::McpInitProgress;
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed init progress on the OWNER (agent 0) and on the
|
||||
// active view (agent 1). The push must clear only the
|
||||
// owner's overlay.
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
owner.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 5,
|
||||
connected: 3,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 5,
|
||||
connected: 3,
|
||||
started_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let notif = make_mcp_initialized_notif_for("sess-owner");
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.mcp_init_progress
|
||||
.is_none(),
|
||||
"owner's init progress overlay must be cleared"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.mcp_init_progress
|
||||
.is_some(),
|
||||
"active-view agent's init progress must NOT be cleared by a background-agent init"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_changed_post_h2_routes_to_owning_agent_not_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
// Active agent has NO modal — a route-by-active-view path
|
||||
// would no-op. Routing by sessionId must schedule a fetch
|
||||
// against the OWNER agent (agent 0).
|
||||
let notif = make_tools_changed_notif_post_h2("sess-owner");
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"tools_changed with sessionId must route to the owner"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } =
|
||||
app.pending_effects.first().expect("effect scheduled")
|
||||
else {
|
||||
panic!("expected FetchMcpsList");
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(0),
|
||||
"tools_changed with sessionId must route to the owner, not the active view"
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward/backward-compat guard: older shells emit
|
||||
/// `{ serverName, tools }` with NO sessionId. The pager must
|
||||
/// gracefully fall back to `active_view`.
|
||||
|
||||
#[test]
|
||||
fn tools_changed_pre_h2_falls_back_to_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Active agent (agent 1) gets a modal; owner (agent 0) does not.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
}
|
||||
let notif = make_tools_changed_notif_pre_h2();
|
||||
let _ = handle_mcp_tools_changed(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.pending_effects.len(),
|
||||
1,
|
||||
"legacy payload without sessionId falls back to active view"
|
||||
);
|
||||
let Effect::FetchMcpsList { agent_id, .. } = app.pending_effects.first().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
*agent_id,
|
||||
AgentId(1),
|
||||
"legacy fallback targets the foregrounded agent"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a machine-wide `x.ai/models/update` broadcast
|
||||
/// carries each model's static catalog-default effort (`high`), not the
|
||||
/// session's chosen `xhigh`, and must not clobber the per-session choice.
|
||||
#[test]
|
||||
fn models_update_preserves_user_reasoning_effort() {
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id = acp::ModelId::new(std::sync::Arc::from("reason-model"));
|
||||
let mut info = make_model_info("reason-model");
|
||||
info.meta = serde_json::json!({
|
||||
"supportsReasoningEffort": true,
|
||||
"reasoningEffort": "high",
|
||||
})
|
||||
.as_object()
|
||||
.cloned();
|
||||
agent.session.models.available.insert(id.clone(), info);
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.set_current(id, Some(ReasoningEffort::Xhigh));
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::Xhigh)
|
||||
);
|
||||
|
||||
let notif = make_reasoning_models_update_notif("reason-model", "high");
|
||||
assert!(handle_models_update(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::Xhigh),
|
||||
"models/update broadcast must not clobber a user-set per-session effort"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_preserves_active_agent_model() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent.session.models.current = Some(id_3);
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"app.models.current must preserve active agent's model, not remote settings default"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"agent's per-session model must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_uses_shell_default_when_agent_model_removed() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent.session.models.current = Some(id_3);
|
||||
|
||||
// grok-3 removed from catalog.
|
||||
let notif = make_models_update_notif("grok-4.3", &["grok-4.3", "grok-4.5"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4.3"),
|
||||
"app.models.current must use shell default when agent model removed"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4.3"),
|
||||
"agent must fall back to shell default when its model is removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_without_active_agent_uses_shell_default() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"without an active agent, shell default must be used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_noop_when_agent_matches_shell_default() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_4 = acp::ModelId::new(std::sync::Arc::from("grok-4"));
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_4.clone(), make_model_info("grok-4"));
|
||||
agent.session.models.current = Some(id_4);
|
||||
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"app.models.current must be grok-4 when agent and shell agree"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"agent model must remain grok-4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_update_non_active_agent_uses_shell_fallback_not_active_model() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let id_3 = acp::ModelId::new(std::sync::Arc::from("grok-3"));
|
||||
agent_a
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_3.clone(), make_model_info("grok-3"));
|
||||
agent_a.session.models.current = Some(id_3);
|
||||
}
|
||||
|
||||
{
|
||||
let agent_b = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
let id_5 = acp::ModelId::new(std::sync::Arc::from("grok-4.5"));
|
||||
agent_b
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(id_5.clone(), make_model_info("grok-4.5"));
|
||||
agent_b.session.models.current = Some(id_5);
|
||||
}
|
||||
|
||||
// grok-5 removed from catalog.
|
||||
let notif = make_models_update_notif("grok-4", &["grok-3", "grok-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
);
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"agent A's model must be preserved"
|
||||
);
|
||||
|
||||
// B's grok-5 was removed — must fall back to shell's grok-4, not A's grok-3.
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"inactive agent must fall back to shell default, not active agent's model"
|
||||
);
|
||||
}
|
||||
|
||||
/// A follower client (no in-flight switch of its own) receives the
|
||||
/// leader's `ModelChanged` broadcast and silently mirrors the new model
|
||||
/// into its local state — no scrollback entry, no toast, just enough
|
||||
/// state for the status bar / `/model` dropdown to render correctly.
|
||||
#[test]
|
||||
fn model_changed_updates_state_silently_on_follower() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
// Follower: no local switch in flight.
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
changed,
|
||||
"follower's state changed → handler must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-4"),
|
||||
"follower must mirror the remote switch into its local model state",
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
scrollback_before,
|
||||
"follower must NOT push a 'Switched to' scrollback entry — that is \
|
||||
the invoking client's job (SwitchModelComplete owns the system message)"
|
||||
);
|
||||
assert!(
|
||||
!agent.session.model_switch_pending,
|
||||
"follower's pending flag must stay false (no local switch was issued)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A live remote `ModelChanged` (leader-mode fan-out from another client)
|
||||
/// must apply even when this client already has a local
|
||||
/// `user_model_preference` — otherwise the status bar desyncs from the
|
||||
/// gateway session. Preference is updated to track the new live model.
|
||||
/// (History-replay silent-revert is suppressed on the shell side via
|
||||
/// `ReconnectState::user_selected_model`, not by permanently blocking
|
||||
/// remote ModelChanged here.)
|
||||
#[test]
|
||||
fn model_changed_applies_and_updates_user_model_preference() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "heavy", &["auto", "heavy"]);
|
||||
agent.session.user_model_preference =
|
||||
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "auto", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
changed,
|
||||
"remote live ModelChanged must apply despite prior local preference"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("auto"),
|
||||
"selector must mirror the remote switch"
|
||||
);
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.user_model_preference
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("auto"),
|
||||
"preference must track the applied remote switch"
|
||||
);
|
||||
}
|
||||
|
||||
/// The invoking client is also a subscriber to its own session and so
|
||||
/// receives the broadcast it triggered. Its in-flight
|
||||
/// `SetSessionModelResponse` is the authority for its local state +
|
||||
/// the single "Switched to X" scrollback entry, so the broadcast handler
|
||||
/// must be a no-op here — gated on `model_switch_pending == true`.
|
||||
///
|
||||
/// Concretely we verify the broadcast does NOT touch
|
||||
/// `models.current` (preserving the pre-response snapshot) — that
|
||||
/// snapshot is what `SwitchModelComplete`'s `unchanged` check compares
|
||||
/// against to decide whether to render the "Switched to X" message. If
|
||||
/// the broadcast optimistically updated state here, the response
|
||||
/// handler would see `prev == new`, mark it unchanged, and suppress the
|
||||
/// user-facing message entirely.
|
||||
#[test]
|
||||
fn model_changed_skipped_when_local_switch_in_flight() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
// Invoker: a local switch is in flight (set by Action::SwitchModel /
|
||||
// set_default_model before the SetSessionModelRequest is sent).
|
||||
agent.session.model_switch_pending = true;
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"broadcast must be a no-op while local switch is pending"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"models.current must stay at the pre-response snapshot — \
|
||||
SwitchModelComplete owns the final apply + system message"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
scrollback_before,
|
||||
"broadcast must not push any scrollback entry on the invoker"
|
||||
);
|
||||
assert!(
|
||||
agent.session.model_switch_pending,
|
||||
"pending flag must remain set until SwitchModelComplete arrives"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `ModelChanged` broadcast carrying a model id the local catalog
|
||||
/// doesn't know about must be dropped — applying it would render an
|
||||
/// unresolvable id in the status bar and desync the `/model` dropdown.
|
||||
/// This can happen when leader and a follower client briefly disagree
|
||||
/// on the model catalog (etag drift, custom-model config skew).
|
||||
#[test]
|
||||
fn model_changed_dropped_when_model_unknown_to_catalog() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-99-unknown", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(
|
||||
!changed,
|
||||
"unknown model must NOT trigger a redraw — no state changed"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"models.current must stay on the previously-known model"
|
||||
);
|
||||
}
|
||||
|
||||
/// `reasoning_effort` round-trips through the broadcast: the follower
|
||||
/// applies it alongside the model id so the prompt header / status bar
|
||||
/// show the right effort without waiting for a subsequent
|
||||
/// `x.ai/models/update`.
|
||||
#[test]
|
||||
fn model_changed_applies_reasoning_effort_on_follower() {
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "grok-4", Some("high"));
|
||||
assert!(handle_ext_notification(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.session.models.reasoning_effort,
|
||||
Some(ReasoningEffort::High),
|
||||
"follower must mirror the broadcast's reasoning_effort"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ModelChanged` for a session this client doesn't own / hasn't loaded
|
||||
/// must be dropped — `find_session_match` returns `None`. The bug-flavored
|
||||
/// version of this would be: leader-mode A switches model on session X
|
||||
/// (which this client never opened) and we accidentally apply the change
|
||||
/// to the active agent.
|
||||
#[test]
|
||||
fn model_changed_dropped_for_unknown_session_id() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "grok-3", &["grok-3", "grok-4"]);
|
||||
|
||||
let notif = model_changed_ext("sess-OTHER", "grok-4", None);
|
||||
let changed = handle_ext_notification(¬if, &mut app);
|
||||
assert!(!changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|id| id.0.as_ref()),
|
||||
Some("grok-3"),
|
||||
"unrelated-session broadcast must not touch this agent's model"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// The permission prompt must surface the payload an MCP call would
|
||||
/// send — both `UseTool` (meta-dispatch) and `MCPTool` (natively
|
||||
/// registered) raw_input shapes.
|
||||
#[test]
|
||||
fn mcp_args_lines_extracts_planned_tool_input() {
|
||||
for variant in ["UseTool", "MCPTool"] {
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": variant,
|
||||
"tool_name": "jira__AddjiraComment",
|
||||
"tool_input": {"issue": "ABC-123", "body": "hello"},
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
let joined = lines.join("\n");
|
||||
assert!(
|
||||
joined.contains("\"issue\": \"ABC-123\""),
|
||||
"{variant}: {joined}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains("\"body\": \"hello\""),
|
||||
"{variant}: {joined}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-MCP raw_input (bash, edit, gateway `{command}` shapes) must not
|
||||
/// grow a JSON dump — those prompts have dedicated displays.
|
||||
#[test]
|
||||
fn mcp_args_lines_empty_for_non_mcp_shapes() {
|
||||
for raw in [
|
||||
None,
|
||||
Some(serde_json::json!({"variant": "Bash", "command": "ls", "description": "d"})),
|
||||
Some(serde_json::json!({"command": "rm -rf /"})),
|
||||
Some(serde_json::json!({"file_path": "/tmp/x"})),
|
||||
Some(serde_json::json!("not-an-object")),
|
||||
] {
|
||||
let req = permission_req_with_raw_input(raw.clone());
|
||||
assert!(
|
||||
mcp_args_lines(&req).is_empty(),
|
||||
"expected empty for {raw:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tool_input` that is missing or JSON null renders nothing rather
|
||||
/// than a misleading `null`.
|
||||
#[test]
|
||||
fn mcp_args_lines_empty_for_missing_or_null_input() {
|
||||
for raw in [
|
||||
serde_json::json!({"variant": "UseTool", "tool_name": "t"}),
|
||||
serde_json::json!({"variant": "UseTool", "tool_name": "t", "tool_input": null}),
|
||||
] {
|
||||
let req = permission_req_with_raw_input(Some(raw));
|
||||
assert!(mcp_args_lines(&req).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A pathological single-line value (e.g. an embedded base64 blob) is
|
||||
/// elided at `MCP_ARGS_MAX_LINE_CHARS` so per-frame wrap cost stays
|
||||
/// bounded. Uses a multi-byte char to pin char (not byte) slicing.
|
||||
#[test]
|
||||
fn mcp_args_lines_caps_line_length() {
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": "UseTool",
|
||||
"tool_name": "t",
|
||||
"tool_input": {"blob": "é".repeat(MCP_ARGS_MAX_LINE_CHARS * 2)},
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
let long = lines
|
||||
.iter()
|
||||
.find(|l| l.contains("é"))
|
||||
.expect("blob line present");
|
||||
assert_eq!(long.chars().count(), MCP_ARGS_MAX_LINE_CHARS + 1);
|
||||
assert!(long.ends_with('…'));
|
||||
}
|
||||
|
||||
/// Pathologically large payloads are capped in storage with an explicit
|
||||
/// hidden-line count (the overlay clips further at render time).
|
||||
#[test]
|
||||
fn mcp_args_lines_caps_stored_lines() {
|
||||
let big: serde_json::Map<String, serde_json::Value> = (0..MCP_ARGS_MAX_LINES + 50)
|
||||
.map(|i| (format!("k{i:04}"), serde_json::Value::from(i)))
|
||||
.collect();
|
||||
let req = permission_req_with_raw_input(Some(serde_json::json!({
|
||||
"variant": "UseTool",
|
||||
"tool_name": "t",
|
||||
"tool_input": big,
|
||||
})));
|
||||
let lines = mcp_args_lines(&req);
|
||||
assert_eq!(lines.len(), MCP_ARGS_MAX_LINES + 1);
|
||||
let last = lines.last().unwrap();
|
||||
assert!(
|
||||
last.starts_with("… (+") && last.ends_with(" more lines)"),
|
||||
"unexpected tail: {last}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Manual recap with an uncommitted in-flight spinner: filled in place
|
||||
/// (no second block), animation stopped.
|
||||
#[test]
|
||||
fn recap_fills_uncommitted_spinner_in_place() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
|
||||
apply_recap_block(&mut agent, false, recap_block("THE RECAP"));
|
||||
|
||||
assert_eq!(agent.scrollback.len(), 1, "filled in place, not appended");
|
||||
let entry = agent.scrollback.get_by_id(spinner).expect("entry kept");
|
||||
assert!(!entry.is_running, "spinner animation stopped");
|
||||
assert!(agent.pending_recap_entry.is_none());
|
||||
}
|
||||
|
||||
/// Regression (minimal mode): the spinner was already committed into
|
||||
/// native scrollback (print-once) — an in-place fill would never reach the
|
||||
/// terminal. The stale committed entry is dropped from state and the recap
|
||||
/// appended as a fresh (uncommitted) block so the commit pass prints it.
|
||||
#[test]
|
||||
fn recap_reprints_fresh_block_when_spinner_already_committed() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
// The minimal idle commit pass consumed the spinner.
|
||||
agent.scrollback.finish_running(spinner);
|
||||
agent.scrollback.mark_committed(0);
|
||||
agent.scrollback.set_commit_scan_cursor(1);
|
||||
assert!(agent.scrollback.is_committed(spinner));
|
||||
|
||||
apply_recap_block(&mut agent, false, recap_block("THE RECAP"));
|
||||
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
1,
|
||||
"stale committed spinner dropped, fresh block appended"
|
||||
);
|
||||
let fresh = agent.scrollback.get(0).expect("fresh block");
|
||||
assert_ne!(fresh.id, spinner, "a NEW entry, not the committed one");
|
||||
assert!(
|
||||
!agent.scrollback.is_committed(fresh.id),
|
||||
"fresh block is uncommitted so the commit pass will print it"
|
||||
);
|
||||
}
|
||||
|
||||
/// An automatic recap never consumes the manual loading slot — it always
|
||||
/// appends its own block and leaves the pending spinner alone.
|
||||
#[test]
|
||||
fn auto_recap_appends_and_leaves_manual_spinner_pending() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let spinner = agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
recap_block(""),
|
||||
));
|
||||
agent.pending_recap_entry = Some(spinner);
|
||||
|
||||
apply_recap_block(&mut agent, true, recap_block("AUTO RECAP"));
|
||||
|
||||
assert_eq!(agent.scrollback.len(), 2, "auto recap appended");
|
||||
assert_eq!(agent.pending_recap_entry, Some(spinner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_auto_recap_dropped_when_agent_not_idle() {
|
||||
assert!(should_drop_late_auto_recap(true, false, false));
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(true, false, true),
|
||||
"idle agent: show auto recap"
|
||||
);
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(false, false, false),
|
||||
"manual /recap always shown"
|
||||
);
|
||||
assert!(
|
||||
!should_drop_late_auto_recap(true, true, false),
|
||||
"history replay rebuilds scrollback even mid-turn"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_auto_opens_inline_cursor_plan_preview() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# Cursor Plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_keeps_inline_plan_preview_available() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# First Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|s| s.source),
|
||||
Some(crate::views::plan_approval_view::PlanReviewSource::Inline)
|
||||
);
|
||||
agent.line_viewer = None;
|
||||
agent.show_plan_preview();
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# First Plan")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_without_inline_content_uses_file_backed_source() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext(Some("# File Plan"));
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(agent.latest_inline_plan_content.is_none());
|
||||
|
||||
assert_eq!(
|
||||
agent.plan_approval_view.as_ref().map(|s| s.source),
|
||||
Some(crate::views::plan_approval_view::PlanReviewSource::FileBacked)
|
||||
);
|
||||
// File-backed bodies still open via request plan_content even when
|
||||
// plan.md is not on disk under the agent's cwd.
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some("# File Plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_empty_opens_placeholder_preview() {
|
||||
// Empty plan.md must still surface a decision UI — otherwise the user
|
||||
// only sees "Waiting on plan approval" with a dead Tab:plan and thinks
|
||||
// the session is stuck.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let (ext, _rx) = make_exit_plan_ext(None);
|
||||
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
||||
let pav = agent
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
.expect("plan_approval_view must be set");
|
||||
assert!(!pav.has_plan);
|
||||
assert_eq!(
|
||||
pav.focus,
|
||||
crate::views::plan_approval_view::PlanApprovalFocus::Preview,
|
||||
"empty approval must keep Preview focus once the placeholder opens"
|
||||
);
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some(crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_dismisses_open_modal() {
|
||||
// Regression: if the user has Ctrl+P command palette open when the
|
||||
// agent calls exit_plan_mode, the modal must be dismissed so the
|
||||
// plan preview is visible and input routes correctly. Otherwise the
|
||||
// modal hides the line viewer in draw order while input gets
|
||||
// routed to the invisible line viewer, leaving the user stuck.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette {
|
||||
entries: crate::views::modal::default_palette_entries(agent.sharing_enabled),
|
||||
state: crate::views::picker::PickerState::input_active(),
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.active_modal.is_none(),
|
||||
"exit_plan_mode must dismiss the open modal so the plan preview is visible"
|
||||
);
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert!(agent.line_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_dismisses_open_block_viewer() {
|
||||
// Regression: if the user has an Edit/tool block_viewer open when
|
||||
// exit_plan_mode opens, dismiss it so wheel scroll reaches the plan
|
||||
// line_viewer. Draw returns on line_viewer (plan visible) but
|
||||
// handle_scroll prefers block_viewer while it remains in state.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
|
||||
"edit",
|
||||
"diff content",
|
||||
));
|
||||
}
|
||||
|
||||
let (ext, _rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# Cursor Plan"));
|
||||
assert!(handle_exit_plan_mode(ext, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.block_viewer.is_none(),
|
||||
"exit_plan_mode must dismiss open block_viewer so the plan can scroll"
|
||||
);
|
||||
assert!(agent.plan_approval_view.is_some());
|
||||
assert!(agent.line_viewer.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_empty_exit_plan_request_clears_stale_inline_plan() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_pending_tool(agent, "create-plan-call", "CreatePlan");
|
||||
}
|
||||
let (first, _first_rx) =
|
||||
make_exit_plan_ext_with_tool_call_id("create-plan-call", Some("# First Plan"));
|
||||
let (second, _second_rx) = make_exit_plan_ext(None);
|
||||
|
||||
assert!(handle_exit_plan_mode(first, &mut app));
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.latest_inline_plan_content.as_deref(),
|
||||
Some("# First Plan")
|
||||
);
|
||||
}
|
||||
assert!(handle_exit_plan_mode(second, &mut app));
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(agent.latest_inline_plan_content.is_none());
|
||||
// Empty approval still opens the placeholder decision surface (not a
|
||||
// silent "no plan" toast) so the user always sees a way to proceed.
|
||||
assert_eq!(
|
||||
agent
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_test()),
|
||||
Some(crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_shows_overlay() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
assert!(!app.agents.get(&AgentId(0)).unwrap().session.is_yolo());
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-normal".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(affected, "opening the overlay should need a redraw");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must be set for interactive approval"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT have been sent yet (waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_shows_overlay_even_in_yolo() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-A".into(),
|
||||
tool_call_id: "tc-yolo".into(),
|
||||
plan_content: Some("# Plan\nDo stuff".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(affected, "overlay should open even in yolo mode");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
"plan_approval_view must be set even in always-approve mode"
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must NOT have been sent yet (waiting for user)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_plan_mode_routes_to_background_session_not_active_view() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
let ext_req = crate::views::plan_approval_view::ExitPlanModeExtRequest {
|
||||
session_id: "sess-B".into(),
|
||||
tool_call_id: "tc-bg-plan".into(),
|
||||
plan_content: Some("# Plan".into()),
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(&ext_req).unwrap();
|
||||
let msg = AcpClientMessage::ExtMethod(kigi_acp_lib::AcpArgs {
|
||||
request: acp::ExtRequest::new("x.ai/exit_plan_mode", raw.into()),
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"a background-session plan approval must not redraw the active view"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(1))
|
||||
.unwrap()
|
||||
.plan_approval_view
|
||||
.is_some(),
|
||||
"plan approval must be parked on the session that asked (background agent B)"
|
||||
);
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.plan_approval_view
|
||||
.is_none(),
|
||||
"plan approval must NOT land on the unrelated active agent A"
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "response must NOT be sent yet");
|
||||
}
|
||||
|
||||
/// Regression: tool-call titles containing `"enter_plan_mode"` must not
|
||||
/// flip plan mode (the substring matcher used to brick sessions on any
|
||||
/// tool mentioning the phrase, e.g. a Grep with that pattern).
|
||||
#[test]
|
||||
fn tool_call_with_enter_plan_mode_substring_does_not_activate_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
assert!(!agent.plan_mode_active);
|
||||
|
||||
let updates = [
|
||||
make_tool_call("enter_plan_mode"),
|
||||
make_tool_call_update("enter_plan_mode"),
|
||||
make_tool_call("Execute `rg enter_plan_mode`"),
|
||||
make_tool_call_update("Execute `rg enter_plan_mode`"),
|
||||
make_tool_call_update("Plan mode entered"),
|
||||
make_tool_call("mcp__foo__enter_plan_mode"),
|
||||
];
|
||||
for update in &updates {
|
||||
let refresh_needed = detect_plan_mode_change(update, &mut agent);
|
||||
assert!(
|
||||
!refresh_needed,
|
||||
"tool-call title (not a CurrentModeUpdate) must not request refresh"
|
||||
);
|
||||
assert!(
|
||||
!agent.plan_mode_active,
|
||||
"tool-call title must not flip plan mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric: tool-call titles containing `"exit_plan_mode"` must not
|
||||
/// deactivate plan mode either. Exit is signaled by `CurrentModeUpdate`.
|
||||
#[test]
|
||||
fn tool_call_with_exit_plan_mode_substring_does_not_deactivate_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
|
||||
let updates = [
|
||||
make_tool_call("exit_plan_mode"),
|
||||
make_tool_call_update("exit_plan_mode"),
|
||||
make_tool_call_update("Plan mode exited"),
|
||||
make_tool_call("Execute `rg exit_plan_mode`"),
|
||||
];
|
||||
for update in &updates {
|
||||
let refresh_needed = detect_plan_mode_change(update, &mut agent);
|
||||
assert!(!refresh_needed);
|
||||
assert!(
|
||||
agent.plan_mode_active,
|
||||
"tool-call title must not flip plan mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_mode_update_plan_activates_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
assert!(!agent.plan_mode_active);
|
||||
|
||||
let refresh_needed = detect_plan_mode_change(&make_current_mode_update("plan"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_mode_update_default_deactivates_plan_mode() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
agent.plan_mode_pending = Some(true);
|
||||
|
||||
let refresh_needed =
|
||||
detect_plan_mode_change(&make_current_mode_update("default"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(!agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
/// Unknown mode ids (e.g. a custom agent definition name like
|
||||
/// `"browser_use"`) parse to `SessionMode::Default` and deactivate
|
||||
/// plan mode.
|
||||
#[test]
|
||||
fn current_mode_update_unknown_id_treated_as_default() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
|
||||
let refresh_needed =
|
||||
detect_plan_mode_change(&make_current_mode_update("browser_use"), &mut agent);
|
||||
assert!(refresh_needed);
|
||||
assert!(!agent.plan_mode_active);
|
||||
}
|
||||
|
||||
/// Idempotent CurrentModeUpdate still signals refresh because
|
||||
/// `plan_mode_pending` was cleared (affects effective state).
|
||||
#[test]
|
||||
fn current_mode_update_signals_refresh_even_on_no_op_active_change() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.plan_mode_active = true;
|
||||
agent.plan_mode_pending = Some(true);
|
||||
|
||||
let refresh_needed = detect_plan_mode_change(&make_current_mode_update("plan"), &mut agent);
|
||||
assert!(
|
||||
refresh_needed,
|
||||
"CurrentModeUpdate must always signal refresh — pending was cleared"
|
||||
);
|
||||
assert!(agent.plan_mode_active);
|
||||
assert!(agent.plan_mode_pending.is_none());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugins_changed_update_refreshes_data_without_reseeding_collapse_state() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
|
||||
let mut app = make_app_with_agent("sess-plugins");
|
||||
let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
|
||||
modal.plugins_data =
|
||||
TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins: vec![] });
|
||||
modal.plugins_groups_seeded = true;
|
||||
modal
|
||||
.plugins_collapsed_groups
|
||||
.insert("origin:user-claude".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().extensions_modal = Some(modal);
|
||||
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
)],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => {
|
||||
assert_eq!(response.plugins.len(), 1);
|
||||
assert_eq!(response.plugins[0].name, "user-tool");
|
||||
}
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from(["origin:user-claude".to_string()]),
|
||||
"live PluginsChanged refresh must not touch collapse state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_changed_seeds_collapse_when_it_wins_the_first_load_race() {
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab, TabDataState};
|
||||
|
||||
// Fresh modal: the push lands before the initial list fetch returns.
|
||||
let mut app = make_app_with_agent("sess-plugins");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().extensions_modal =
|
||||
Some(ExtensionsModalState::new(ExtensionsTab::Plugins));
|
||||
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![
|
||||
crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
),
|
||||
crate::views::extensions_modal::test_plugin_info(
|
||||
"claude-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserClaude),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from([
|
||||
"origin:user".to_string(),
|
||||
"origin:user-claude".to_string()
|
||||
]),
|
||||
"push winning the first-load race must seed the collapsed default"
|
||||
);
|
||||
match &modal.plugins_data {
|
||||
TabDataState::Loaded(response) => assert_eq!(response.plugins.len(), 2),
|
||||
other => panic!("expected Loaded plugins data, got {other:?}"),
|
||||
}
|
||||
|
||||
// The push counts as the one seeding: expand a group, deliver again,
|
||||
// and the expansion must survive.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.extensions_modal
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.plugins_collapsed_groups
|
||||
.remove("origin:user");
|
||||
let handled = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-plugins",
|
||||
XaiSessionUpdate::PluginsChanged {
|
||||
plugins: vec![crate::views::extensions_modal::test_plugin_info(
|
||||
"user-tool",
|
||||
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
|
||||
)],
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handled);
|
||||
let modal = app.agents[&AgentId(0)].extensions_modal.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
modal.plugins_collapsed_groups,
|
||||
std::collections::HashSet::from(["origin:user-claude".to_string()]),
|
||||
"deliveries after the push-seed must preserve expand state"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,492 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_happy_path_enqueues_and_drains() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "task-42",
|
||||
"prompt": "/pr-babysit check",
|
||||
"humanSchedule": "every 5m",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Agent should now be in TurnRunning (drain happened, prompt was sent).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// Scrollback should have a cron prompt block.
|
||||
assert!(!agent.scrollback.is_empty());
|
||||
|
||||
// pending_effects should contain a SendPromptBlocks with system-reminder framing,
|
||||
// displayText/displayAsCron meta, and a scheduler-fired- prompt_id prefix.
|
||||
match &app.pending_effects[0] {
|
||||
Effect::SendPromptBlocks {
|
||||
blocks, prompt_id, ..
|
||||
} => {
|
||||
let text = match &blocks[0] {
|
||||
acp::ContentBlock::Text(t) => &t.text,
|
||||
_ => panic!("expected Text block"),
|
||||
};
|
||||
assert!(
|
||||
text.contains("<system-reminder>"),
|
||||
"missing system-reminder framing"
|
||||
);
|
||||
assert!(text.contains("task-42"), "missing task_id");
|
||||
assert!(text.contains("every 5m"), "missing schedule");
|
||||
assert!(text.contains("/pr-babysit check"), "missing prompt");
|
||||
assert!(
|
||||
prompt_id.starts_with("scheduler-fired-"),
|
||||
"cron prompt_id must start with 'scheduler-fired-' for data pipeline tagging, got: {prompt_id}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SendPromptBlocks, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_drives_even_when_attached_as_viewer() {
|
||||
// The leader routes `x.ai/scheduled_task_inject_prompt` to the SINGLE
|
||||
// session driver, so any client that receives it IS the driver and must
|
||||
// enqueue + run it — even one that attached via `session/load`
|
||||
// (`attached_as_viewer == true`). Previously this handler latched on
|
||||
// `attached_as_viewer` and skipped, which stranded the cron loop with no
|
||||
// output whenever the designated driver was an attacher (the sticky-flag
|
||||
// bug). Pin the corrected behavior: the inject drives the turn.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "task-42",
|
||||
"prompt": "echo hello",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
let result = handle_scheduled_task_inject_prompt(&make_inject_notif(&payload), &mut app);
|
||||
assert!(result);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.state.is_turn_running(),
|
||||
"the designated driver must start the cron turn even when it attached as a viewer"
|
||||
);
|
||||
assert!(
|
||||
agent.session.pending_prompts.is_empty(),
|
||||
"the cron prompt must drain (not linger queued) on the driver"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
app.pending_effects.first(),
|
||||
Some(Effect::SendPromptBlocks { .. })
|
||||
),
|
||||
"the driver must emit a SendPromptBlocks effect for the cron turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_malformed_json_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let raw = serde_json::value::to_raw_value(&"not a json object").unwrap();
|
||||
let notif = acp::ExtNotification::new("x.ai/scheduled_task_inject_prompt", raw.into());
|
||||
|
||||
// The JSON is valid (a string), but sessionId/prompt fields won't exist.
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_empty_prompt_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"prompt": "",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Nothing should be enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_no_matching_agent_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-other",
|
||||
"prompt": "do something",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Agent should still be idle, nothing enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_busy_agent_enqueues_without_draining() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Make the agent busy.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"prompt": "/pr-babysit check",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Prompt should be queued but not drained (agent was busy).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||||
assert_eq!(
|
||||
agent.session.pending_prompts[0].kind,
|
||||
crate::app::agent::QueueEntryKind::Cron
|
||||
);
|
||||
// No effects produced (drain was a no-op since agent was busy).
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_skips_duplicate_while_queued() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Busy with an unrelated turn so the cron prompt queues instead of draining.
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "loop-7",
|
||||
"prompt": "check deploy",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// A re-fire of the same task while it is still queued must not pile up.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.len(),
|
||||
1,
|
||||
"a re-fire of the same loop must dedupe in the queue, not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_skips_duplicate_while_running() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": "sess-1",
|
||||
"taskId": "loop-7",
|
||||
"prompt": "check deploy",
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
// First fire on an idle agent drains into a running cron turn.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// A re-fire while that same loop turn is running must be skipped, not queued.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
));
|
||||
assert!(
|
||||
app.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.pending_prompts
|
||||
.is_empty(),
|
||||
"a re-fire while the loop turn runs must not enqueue a duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_prompt_missing_session_id_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let payload = serde_json::json!({
|
||||
"prompt": "do something",
|
||||
});
|
||||
let notif = make_inject_notif(&payload);
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_known_task_updates_next_fire_at_only() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let original_created_at = Instant::now() - std::time::Duration::from_secs(60);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-1".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-1".into(),
|
||||
prompt: "original prompt".into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
created_at: original_created_at,
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-1",
|
||||
// Different field values to verify they are NOT copied over.
|
||||
"DIFFERENT",
|
||||
"every 1 hour",
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-1").unwrap();
|
||||
assert_eq!(info.prompt, "original prompt", "prompt must not change");
|
||||
assert_eq!(
|
||||
info.human_schedule, "every 5 minutes",
|
||||
"human_schedule must not change"
|
||||
);
|
||||
assert_eq!(
|
||||
info.created_at, original_created_at,
|
||||
"created_at must not change"
|
||||
);
|
||||
assert_eq!(
|
||||
info.next_fire_at.as_deref(),
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
"next_fire_at must be updated"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.session.scheduled_tasks.len(),
|
||||
1,
|
||||
"no extra entry should be inserted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_unknown_task_inserts_entry_from_payload() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let before = Instant::now();
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-new",
|
||||
"scheduled prompt",
|
||||
"every 10 minutes",
|
||||
Some("2026-03-03T03:03:03Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.scheduled_tasks.len(), 1);
|
||||
let info = agent.session.scheduled_tasks.get("task-new").unwrap();
|
||||
assert_eq!(info.task_id, "task-new");
|
||||
assert_eq!(info.prompt, "scheduled prompt");
|
||||
assert_eq!(info.human_schedule, "every 10 minutes");
|
||||
assert_eq!(info.next_fire_at.as_deref(), Some("2026-03-03T03:03:03Z"));
|
||||
assert!(
|
||||
info.created_at >= before && info.created_at <= Instant::now(),
|
||||
"created_at should be set to roughly now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_unknown_task_with_none_next_fire_skips_insert() {
|
||||
// Mirrors handle_missed_tasks output: a missed one-shot fires with
|
||||
// next_fire_at: None and is immediately removed. The pane should not
|
||||
// flicker an entry that the Removed will instantly drop.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"missed-1",
|
||||
"missed prompt",
|
||||
"every 1 minute",
|
||||
None,
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.session.scheduled_tasks.is_empty(),
|
||||
"no entry should be inserted for a Vacant + None fire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_known_task_with_none_next_fire_clears_field() {
|
||||
// The Vacant short-circuit on next_fire_at: None must NOT apply to
|
||||
// Occupied — clearing an existing countdown is correct behaviour.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-1".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-1".into(),
|
||||
prompt: "p".into(),
|
||||
human_schedule: "every 1 minute".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let notif = make_fired_notif("sess-1", "task-1", "p", "every 1 minute", None);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-1").unwrap();
|
||||
assert!(info.next_fire_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed a known task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
"task-owner".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-owner".into(),
|
||||
prompt: "check PR".into(),
|
||||
human_schedule: "every 5m".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Fire notification targets agent 0's session, but active_view
|
||||
// points to agent 1. Return value is "needs redraw" — false is
|
||||
// correct when the mutated agent is not the active view.
|
||||
let notif = make_fired_notif(
|
||||
"sess-owner",
|
||||
"task-owner",
|
||||
"check PR",
|
||||
"every 5m",
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
let needs_redraw = handle_scheduled_task_fired(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
// Agent 0's next_fire_at must be updated.
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent0.session.scheduled_tasks.get("task-owner").unwrap();
|
||||
assert_eq!(
|
||||
info.next_fire_at.as_deref(),
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
"next_fire_at must update on the owning agent, not the active one"
|
||||
);
|
||||
|
||||
// Agent 1 must be completely untouched.
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
"non-owning agent must not receive the update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
let notif = make_created_ext_notif(
|
||||
"sess-owner",
|
||||
"task-new",
|
||||
"check PR",
|
||||
"every 5m",
|
||||
Some("2026-06-01T12:00:00Z"),
|
||||
);
|
||||
let needs_redraw = handle_scheduled_task_created(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent0.session.scheduled_tasks.contains_key("task-new"),
|
||||
"task must be created on the owning agent"
|
||||
);
|
||||
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
"non-owning agent must not receive the task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_removes_from_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
"task-rm".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-rm".into(),
|
||||
prompt: "check PR".into(),
|
||||
human_schedule: "every 5m".into(),
|
||||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_deleted_ext_notif("sess-owner", "task-rm");
|
||||
let needs_redraw = handle_scheduled_task_deleted(¬if, &mut app);
|
||||
assert!(
|
||||
!needs_redraw,
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent0.session.scheduled_tasks.is_empty(),
|
||||
"task must be removed from the owning agent"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── apply_session_event ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_started_sets_activity() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "hi".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(1),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
percentage: 85,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"compaction start implies server activity — cancel must not rewind prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ImageDropped` joins notes with `\n` and pushes a system block.
|
||||
/// Pin the `\n` separator so a `notes.join(" ")` regression is caught.
|
||||
#[test]
|
||||
fn apply_image_dropped_pushes_scrollback_block() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let before = scrollback.len();
|
||||
let notes = vec![
|
||||
"Image 1 was dropped: corrupt.".to_string(),
|
||||
"Image 2 was dropped: too small (4×3).".to_string(),
|
||||
];
|
||||
let update = XaiSessionUpdate::ImageDropped {
|
||||
notes: notes.clone(),
|
||||
};
|
||||
let changed = apply_session_event(&update, &mut session, &mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(changed);
|
||||
assert_eq!(scrollback.len(), before + 1);
|
||||
let entry = scrollback.entries_mut().last().expect("entry pushed");
|
||||
match &entry.block {
|
||||
RenderBlock::System(b) => {
|
||||
assert!(b.text.contains(¬es[0]));
|
||||
assert!(b.text.contains(¬es[1]));
|
||||
assert!(
|
||||
b.text.contains('\n'),
|
||||
"expected \\n separator between dropped notes, got: {:?}",
|
||||
b.text
|
||||
);
|
||||
}
|
||||
other => panic!("expected System block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A successful compression needs no user action: log-only — no toast,
|
||||
/// no scrollback block, no redraw. Same live and on session replay.
|
||||
#[test]
|
||||
fn image_compressed_is_invisible_in_tui() {
|
||||
for replay in [false, true] {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent.session.loading_replay = replay;
|
||||
assert!(!apply_image_compressed(
|
||||
&mut agent,
|
||||
&[compressed_entry(1), compressed_entry(2)],
|
||||
"Compressed Image 1: 4.2 MB (3024x1964) \u{2192} 780 KB (1568x1018)",
|
||||
));
|
||||
assert!(agent.toast.is_none(), "no toast (replay={replay})");
|
||||
assert_eq!(agent.scrollback.len(), 0, "no block (replay={replay})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The re-encode fallback (empty `images`) means the oversized original
|
||||
/// was kept — a persistent warning line, not a transient toast.
|
||||
#[test]
|
||||
fn image_compressed_fallback_warning_stays_in_scrollback() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
let msg = "Image 1 could not be re-encoded under the 1.5 MB limit; the original attachment was kept.";
|
||||
assert!(apply_image_compressed(&mut agent, &[], msg));
|
||||
assert!(agent.toast.is_none(), "warning must not be transient");
|
||||
let entry = agent.scrollback.entries_mut().last().expect("block pushed");
|
||||
match &entry.block {
|
||||
RenderBlock::System(b) => assert_eq!(b.text, msg),
|
||||
other => panic!("expected System block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_retrying_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "retry me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
let retry = RetryState::Retrying {
|
||||
attempt: 1,
|
||||
max_retries: 3,
|
||||
reason: "rate limited".into(),
|
||||
};
|
||||
apply_retry_state(&retry, &mut session, &mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"RetryState bypasses session/update in_flight hook"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_rate_limited_sets_flag() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
assert!(!session.rate_limited);
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "rate limited".into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"rate_limited flag must be set when is_rate_limited is true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_rate_limited_message_is_auth_aware() {
|
||||
use kigi_shell::sampling::error::{
|
||||
RATE_LIMITED_USER_MESSAGE_API_KEY, RATE_LIMITED_USER_MESSAGE_OAUTH,
|
||||
};
|
||||
|
||||
let exhausted = RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "rate limited".into(),
|
||||
is_rate_limited: true,
|
||||
};
|
||||
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, false);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH);
|
||||
}
|
||||
other => panic!("expected OAuth rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(&exhausted, &mut session, &mut scrollback, true);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::RetryFailed { error, .. }) => {
|
||||
assert_eq!(error, RATE_LIMITED_USER_MESSAGE_API_KEY);
|
||||
}
|
||||
other => panic!("expected API-key rate-limit RetryFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhausted_non_rate_limited_does_not_set_flag() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "server error".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.rate_limited,
|
||||
"rate_limited flag must not be set when is_rate_limited is false"
|
||||
);
|
||||
}
|
||||
|
||||
/// A rate-limit exhaustion whose flattened reason carries the
|
||||
/// free-usage code sets both flags and pushes NO generic block (the
|
||||
/// driver shows the paywall modal on PromptResponse; viewers keep no
|
||||
/// marker).
|
||||
#[test]
|
||||
fn retry_exhausted_free_usage_sets_paywall_flag_without_marker() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "try me again".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 0,
|
||||
reason: "API error (status 429 Too Many Requests): \
|
||||
subscription:free-usage-exhausted: You have used all your free usage."
|
||||
.into(),
|
||||
is_rate_limited: true,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.rate_limited,
|
||||
"free-usage keeps rate_limited (TurnFailed/toast suppression)"
|
||||
);
|
||||
assert!(session.free_usage_blocked);
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"no RetryFailed marker — the paywall modal shows instead"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"free-usage exhaustion clears in_flight_prompt like other failures"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_exhausted_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(2),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Exhausted {
|
||||
attempts: 3,
|
||||
reason: "status 403: run out of credits".into(),
|
||||
is_rate_limited: false,
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_credit_limit_failed_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "stash me too".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(3),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message: "status 403: run out of credits".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for credit-limit 403"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved so PromptResponse handler can stash it"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "stash me too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_pool_402_sets_credit_limit_blocked() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "pool blocked".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "proxy_error".into(),
|
||||
message:
|
||||
"API error (status 402 Payment Required): Grok Build usage balance exhausted"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.credit_limit_blocked,
|
||||
"credit_limit_blocked must be set for pool 402 balance exhausted"
|
||||
);
|
||||
assert!(session.in_flight_prompt.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_retry_state_non_credit_limit_failed_clears_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "gone".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(4),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "server_error".into(),
|
||||
message: "internal server error".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.credit_limit_blocked,
|
||||
"credit_limit_blocked must NOT be set for non-credit-limit errors"
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_none(),
|
||||
"in_flight_prompt must be cleared for non-credit-limit errors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_reauthable_failure_matrix() {
|
||||
assert!(is_reauthable_failure(Some("auth"), "Unauthorized (401)"));
|
||||
assert!(is_reauthable_failure(
|
||||
Some("api"),
|
||||
"Unauthorized (401) from https://proxy/v1/responses"
|
||||
));
|
||||
assert!(is_reauthable_failure(None, "Unauthorized (401)"));
|
||||
// legacy_auth carries its own migration guidance — excluded.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("legacy_auth"),
|
||||
"Unauthorized (401) ... deprecated authentication method"
|
||||
));
|
||||
// Unrelated failures must not be treated as re-authable.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("server_error"),
|
||||
"internal server error"
|
||||
));
|
||||
assert!(!is_reauthable_failure(Some("api"), "model not found"));
|
||||
}
|
||||
|
||||
/// A 401 with `error_type == "auth"` surfaces the actionable re-auth
|
||||
/// prompt instead of the raw "Retry failed: Unauthorized (401) …" dump.
|
||||
#[test]
|
||||
fn apply_retry_state_auth_failure_pushes_reauth_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "auth".into(),
|
||||
message: "Unauthorized (401) from https://cli-chat-proxy.kigi.com/v1/messages: \
|
||||
no auth context"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ReAuthRequired)
|
||||
),
|
||||
"auth 401 must surface the actionable re-auth prompt"
|
||||
);
|
||||
assert!(!session.credit_limit_blocked);
|
||||
}
|
||||
|
||||
/// A recoverable auth failure preserves `in_flight_prompt` so the
|
||||
/// PromptResponse handler can stash it for auto-resubmit after re-auth.
|
||||
#[test]
|
||||
fn apply_retry_state_auth_failure_preserves_in_flight_prompt() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.in_flight_prompt = Some(InFlightPrompt {
|
||||
text: "retry after login".into(),
|
||||
images: Vec::new(),
|
||||
scrollback_entry: EntryId::new(5),
|
||||
chip_elements: Vec::new(),
|
||||
});
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "auth".into(),
|
||||
message: "Unauthorized (401) from https://proxy/v1/messages".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.in_flight_prompt.is_some(),
|
||||
"in_flight_prompt must be preserved on a recoverable auth failure"
|
||||
);
|
||||
assert_eq!(session.in_flight_prompt.unwrap().text, "retry after login");
|
||||
}
|
||||
|
||||
/// A 401 reported with a non-auth `error_type` but an "Unauthorized
|
||||
/// (401)" message (the `SamplingErrorKind::Api` path) also prompts.
|
||||
#[test]
|
||||
fn apply_retry_state_401_message_without_auth_type_prompts_reauth() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "api".into(),
|
||||
message: "Unauthorized (401) from https://proxy/v1/responses: invalid credentials"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ReAuthRequired)
|
||||
));
|
||||
}
|
||||
|
||||
/// Legacy WebLogin auth keeps its verbose message (with `grok logout` /
|
||||
/// `grok login` guidance), not the generic re-auth prompt.
|
||||
#[test]
|
||||
fn apply_retry_state_legacy_auth_keeps_detailed_message() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "legacy_auth".into(),
|
||||
message: "Unauthorized (401) ... deprecated authentication method (WebLogin) ... \
|
||||
run `grok logout` then `grok login`"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::RetryFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Non-auth terminal failures still render the standard RetryFailed.
|
||||
#[test]
|
||||
fn apply_retry_state_generic_failure_still_shows_retry_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "server_error".into(),
|
||||
message: "internal server error".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::RetryFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// A context overflow surfaces the actionable `ContextTooLarge` prompt (not the
|
||||
/// raw `RetryFailed`); `PromptResponse` then suppresses the redundant `TurnFailed`.
|
||||
#[test]
|
||||
fn apply_retry_state_context_length_shows_context_too_large() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "context_length".into(),
|
||||
message: "API error (status 500): the prompt is too long for this model's \
|
||||
context window"
|
||||
.into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::ContextTooLarge)
|
||||
),
|
||||
"context overflow must surface the actionable ContextTooLarge prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the compaction handler already showed its "too large to compact" message,
|
||||
/// the overflow path does NOT stack a second `ContextTooLarge` prompt on top.
|
||||
#[test]
|
||||
fn apply_retry_state_context_length_does_not_duplicate_compaction_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
scrollback.push_block(RenderBlock::session_event(SessionEvent::CompactionFailed {
|
||||
error: "this conversation is too large to compact.".into(),
|
||||
}));
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "context_length".into(),
|
||||
message: "the prompt is too long for this model's context window".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&scrollback),
|
||||
Some(SessionEvent::CompactionFailed { .. })
|
||||
),
|
||||
"must not push a duplicate prompt on top of CompactionFailed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_defers_message_until_turn_end() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
session.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(858_000),
|
||||
tokens_after: 66_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
0,
|
||||
"live compaction completion must be deferred, not pushed immediately"
|
||||
);
|
||||
|
||||
session.note_context_used(43_000);
|
||||
|
||||
session.finish_turn(&mut scrollback);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted {
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(tokens_before, Some(858_000));
|
||||
assert_eq!(
|
||||
tokens_after, 43_000,
|
||||
"must flush the model-confirmed count, not the 66k estimate"
|
||||
);
|
||||
}
|
||||
other => panic!("expected deferred CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_falls_back_to_estimate_without_confirmation() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 20_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
session.finish_turn(&mut scrollback);
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => {
|
||||
assert_eq!(
|
||||
tokens_after, 20_000,
|
||||
"fallback to estimate when unconfirmed"
|
||||
);
|
||||
}
|
||||
other => panic!("expected fallback CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_completed_renders_immediately_during_replay() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
session.loading_replay = true;
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 20_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
match last_session_event(&scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => {
|
||||
assert_eq!(
|
||||
tokens_after, 20_000,
|
||||
"replay renders the recorded count immediately"
|
||||
);
|
||||
}
|
||||
other => panic!("expected immediate CompactionCompleted on replay, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_compaction_flushes_confirmed_count_over_estimate_refresh() {
|
||||
let mut agent = make_agent(Some("s1"));
|
||||
agent
|
||||
.session
|
||||
.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(858_000),
|
||||
tokens_after: 66_000,
|
||||
elapsed_ms: Some(500),
|
||||
summary_preview: None,
|
||||
};
|
||||
assert!(apply_session_event(
|
||||
&update,
|
||||
&mut agent.session,
|
||||
&mut agent.scrollback,
|
||||
false,
|
||||
));
|
||||
|
||||
refresh_context_used(&mut agent, 66_000);
|
||||
confirm_context_used(&mut agent, 43_000);
|
||||
|
||||
agent.session.finish_turn(&mut agent.scrollback);
|
||||
match last_session_event(&agent.scrollback) {
|
||||
Some(SessionEvent::CompactionCompleted {
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(tokens_before, Some(858_000));
|
||||
assert_eq!(
|
||||
tokens_after, 43_000,
|
||||
"deferred line must flush the confirmed 43k, not the 66k \
|
||||
estimate refresh that updated the bar first"
|
||||
);
|
||||
}
|
||||
other => panic!("expected deferred CompactionCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_unhandled_event_returns_false() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
let update = XaiSessionUpdate::MemoryFlushStarted;
|
||||
assert!(!apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
}
|
||||
|
||||
// ── handle_child_session_notification ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_updates_subagent_info() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-1";
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
let child_view = make_agent(Some(child_sid));
|
||||
agent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(child_view));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90000),
|
||||
tokens_after: 25000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
};
|
||||
let changed = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
assert!(changed);
|
||||
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.tokens_used, Some(25000));
|
||||
// 25000 / 131072 * 100 ~= 19
|
||||
assert_eq!(info.context_usage_pct, Some(19));
|
||||
|
||||
// The child view's context_state.used (context-bar numerator) must
|
||||
// also be reset — see the comment in handle_child_session_notification.
|
||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.context_state.as_ref().map(|c| c.used),
|
||||
Some(25000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_compact_started_does_not_reset_context_used() {
|
||||
// Sibling variants in the same outer arm must not touch the numerator;
|
||||
// guards against accidental widening of the AutoCompactCompleted gate.
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-3";
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
let mut child_view = make_agent(Some(child_sid));
|
||||
child_view.context_state = Some(kigi_shell::session::ContextInfo::from_notification(
|
||||
90_000, 131_072,
|
||||
));
|
||||
agent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(child_view));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 95_000,
|
||||
context_window: 131_072,
|
||||
percentage: 72,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
let _ = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
|
||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert_eq!(
|
||||
child_view.context_state.as_ref().map(|c| c.used),
|
||||
Some(90_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_notification_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
// No child view registered.
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
percentage: 85,
|
||||
reason: "threshold".into(),
|
||||
};
|
||||
let changed = handle_child_session_notification(update, "unknown-child", &mut agent, false);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let child_sid = "child-sess-2";
|
||||
// SubagentInfo exists but no child view (race between notification and spawn).
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
|
||||
let update = XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90000),
|
||||
tokens_after: 25000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
};
|
||||
let changed = handle_child_session_notification(update, child_sid, &mut agent, false);
|
||||
// No child_view means nothing visible changed — must not trigger redraw.
|
||||
assert!(!changed);
|
||||
// SubagentInfo should still be updated (data correctness).
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.tokens_used, Some(25000));
|
||||
assert_eq!(info.context_usage_pct, Some(19));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_unknown_event_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
let update = XaiSessionUpdate::MemoryFlushStarted;
|
||||
let changed = handle_child_session_notification(update, "child-1", &mut agent, false);
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
// ── apply_retry_state ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn retry_failed_encrypted_content_sets_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
assert!(!session.model_incompatible);
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "encrypted_content_mismatch".into(),
|
||||
message: "incompatible history".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
session.model_incompatible,
|
||||
"encrypted_content_mismatch should set model_incompatible flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_failed_other_type_does_not_set_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
let mut session = make_session(Some("s1"));
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
apply_retry_state(
|
||||
&RetryState::Failed {
|
||||
error_type: "api_400".into(),
|
||||
message: "bad request".into(),
|
||||
},
|
||||
&mut session,
|
||||
&mut scrollback,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!session.model_incompatible,
|
||||
"non-encrypted_content error types must not set model_incompatible"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_inactive_agent_lands_in_its_scrollback() {
|
||||
// Regression: switching away from a streaming agent must not
|
||||
// discard chunks bound for that agent. Before this fix, only
|
||||
// `TaskResult::PromptResponse` survived, so the user saw a bare
|
||||
// "Worked for X.Xs" with no body text.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(make_agent_chunk_message("sess-A", "hello from A"), &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_message_text(agent_a),
|
||||
"hello from A",
|
||||
"chunk for inactive agent A must land in A's scrollback"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"chunk routed to a non-active agent must not request a redraw"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent_b.scrollback.is_empty(),
|
||||
"active agent B's scrollback must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_active_agent_returns_affected_true() {
|
||||
// Baseline: chunk for the visible agent triggers a redraw.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(make_agent_chunk_message("sess-B", "hello from B"), &mut app);
|
||||
|
||||
assert!(affected, "chunk for active agent must request a redraw");
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(agent_message_text(agent_b), "hello from B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_subagent_routes_through_parent() {
|
||||
// Subagent (child) chunk must land in the parent's
|
||||
// `subagent_views[child_sid]` even when a different agent is
|
||||
// currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let child_sid = "sess-A-child";
|
||||
{
|
||||
let parent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
parent
|
||||
.subagent_sessions
|
||||
.insert(child_sid.into(), make_subagent_info(child_sid));
|
||||
parent
|
||||
.subagent_views
|
||||
.insert(child_sid.into(), Box::new(make_agent(Some(child_sid))));
|
||||
}
|
||||
|
||||
let affected = handle(
|
||||
make_agent_chunk_message(child_sid, "hello from subagent"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let parent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let child_view = parent
|
||||
.subagent_views
|
||||
.get(child_sid)
|
||||
.expect("child view must still exist");
|
||||
assert_eq!(
|
||||
agent_message_text(child_view),
|
||||
"hello from subagent",
|
||||
"subagent chunk must land in subagent_views[child_sid]"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"subagent chunk for non-active parent must not request a redraw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_with_unknown_session_id_is_dropped_and_no_redraw() {
|
||||
// No agent owns the session_id and the active agent already has a
|
||||
// session_id assigned (so the race-window fallback does not fire).
|
||||
// The notification must be dropped silently.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let affected = handle(
|
||||
make_agent_chunk_message("sess-unknown", "stray text"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!affected, "unknown session_id must not request a redraw");
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"agent A must not have absorbed a notification for sess-unknown"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().scrollback.is_empty(),
|
||||
"agent B must not have absorbed a notification for sess-unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_none_race_window_routes_to_active_agent() {
|
||||
// Pin the existing race-window semantics: notifications that arrive
|
||||
// before `TaskResult::SessionCreated` (active agent has no session_id
|
||||
// yet) must still land on the active agent.
|
||||
|
||||
// Case 1: active agent A has session_id == None; everyone else has
|
||||
// a real id. Stray notification routes to A.
|
||||
{
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// make_app_with_agent already activated AgentId(0); no switch needed.
|
||||
|
||||
let _ = handle(
|
||||
make_agent_chunk_message("not-yet-assigned", "racing chunk"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(0)).unwrap()),
|
||||
"racing chunk",
|
||||
"race-window fallback should land on active agent A"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(1)).unwrap().scrollback.is_empty(),
|
||||
"non-active agent B must not absorb the race chunk"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: both A and B have session_id == None; the active one wins.
|
||||
{
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id = None;
|
||||
insert_agent(&mut app, AgentId(1), None);
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let _ = handle(
|
||||
make_agent_chunk_message("not-yet-assigned", "racing chunk"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"non-active agent A must not absorb the race chunk"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(1)).unwrap()),
|
||||
"racing chunk",
|
||||
"race-window fallback must prefer the active agent (B)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_for_inactive_agent_lands_in_its_todo() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
// Sanity: A's todo starts empty.
|
||||
assert_eq!(
|
||||
app.agents.get(&AgentId(0)).unwrap().todo.counts().total(),
|
||||
0,
|
||||
);
|
||||
|
||||
let _ = handle(make_plan_message("sess-A", &["task1", "task2"]), &mut app);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.todo.counts().total(),
|
||||
2,
|
||||
"Plan update must mutate A's todo even when B is active"
|
||||
);
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b.todo.counts().total(),
|
||||
0,
|
||||
"active agent B's todo must not absorb A's plan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_update_for_inactive_agent_bumps_its_generation() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
let initial_gen_a = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.available_commands_generation;
|
||||
|
||||
let _ = handle(
|
||||
make_commands_update_message("sess-A", &["compact", "fork"]),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.session.available_commands.len(),
|
||||
2,
|
||||
"AvailableCommandsUpdate must replace A's commands list"
|
||||
);
|
||||
assert_eq!(
|
||||
agent_a.session.available_commands_generation,
|
||||
initial_gen_a + 1,
|
||||
"AvailableCommandsUpdate must bump A's generation counter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_task_stdout_for_inactive_agent_lands_in_its_bg_tasks() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
// Pre-register a bg task on A so route_bg_task_stdout has a target.
|
||||
let task_id = "task-A-1";
|
||||
let tool_call_id = "call-A-1";
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent_a.session.bg_tasks.insert(
|
||||
task_id.into(),
|
||||
BgTaskState {
|
||||
task_id: task_id.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
command: "sleep 5".into(),
|
||||
description: None,
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/out".into(),
|
||||
status: BgTaskStatus::Running,
|
||||
start_time: std::time::SystemTime::now(),
|
||||
end_time: None,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdout: String::new(),
|
||||
stdout_line_count: 0,
|
||||
truncated: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
is_monitor: false,
|
||||
restored_from_replay: false,
|
||||
},
|
||||
);
|
||||
agent_a
|
||||
.session
|
||||
.bg_tool_call_to_task
|
||||
.insert(tool_call_id.into(), task_id.into());
|
||||
}
|
||||
|
||||
let _ = handle(
|
||||
make_bash_stdout_message("sess-A", tool_call_id, "stdout-from-A"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.session.bg_tasks.get(task_id).unwrap().stdout,
|
||||
"stdout-from-A",
|
||||
"Bash stdout must land in A's bg_tasks even when B is active"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_chunks_for_two_agents_dont_cross_contaminate() {
|
||||
// Send chunks to both A and B in sequence; each landing in its own
|
||||
// scrollback proves the demux works in both directions regardless
|
||||
// of which agent is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let _ = handle(make_agent_chunk_message("sess-A", "A only"), &mut app);
|
||||
let _ = handle(make_agent_chunk_message("sess-B", "B only"), &mut app);
|
||||
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(0)).unwrap()),
|
||||
"A only",
|
||||
);
|
||||
assert_eq!(
|
||||
agent_message_text(app.agents.get(&AgentId(1)).unwrap()),
|
||||
"B only",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_non_api_key_tier_clears_stale_api_key_flag() {
|
||||
let mut app = make_app_with_agent("sess-stale-key");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(app.is_api_key_auth);
|
||||
assert!(!app.usage_visible);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
|
||||
// Later personal Free stamp must not keep the API-key bypass.
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("Free"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.usage_visible);
|
||||
assert!(!app.tier_restricted_commands.is_empty());
|
||||
|
||||
// A paid tier after API Key clears the api-key flag and tier limits.
|
||||
let mut app = make_app_with_agent("sess-paid-tier");
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("API Key"),
|
||||
&mut app
|
||||
));
|
||||
assert!(handle_ext_notification(
|
||||
&tier_settings_update("SuperGrok"),
|
||||
&mut app
|
||||
));
|
||||
assert!(!app.is_api_key_auth);
|
||||
assert!(app.tier_restricted_commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_group_tool_verbs_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
// test holds regardless of host config/env (a local `[ui]` or env
|
||||
// override legitimately beats the remote tier on both legs).
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
let managed_config = kigi_shell::config::load_managed_config().ok();
|
||||
let resolve = |remote_val: Option<bool>| {
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
group_tool_verbs: remote_val,
|
||||
..Default::default()
|
||||
};
|
||||
kigi_shell::util::config::resolve_group_tool_verbs(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value
|
||||
};
|
||||
let expect_on = resolve(Some(true));
|
||||
let expect_cleared = resolve(None);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// Remote enable arrives (redundant with the on-default, still latched).
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_group_tool_verbs(),
|
||||
expect_on,
|
||||
"remote Some(true) must re-resolve into the cache"
|
||||
);
|
||||
|
||||
// remote settings clears the remote tier (field absent → None). Seed the
|
||||
// cache opposite to the expected outcome — the latched remote enable —
|
||||
// so only a real re-resolve can pass; the update must revert it to the
|
||||
// local/default resolution instead of skipping the field. An old
|
||||
// payload without the field takes this same path.
|
||||
crate::appearance::cache::set_group_tool_verbs(!expect_cleared);
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(None),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_group_tool_verbs(),
|
||||
expect_cleared,
|
||||
"cleared remote tier must re-resolve the full chain, not stay latched"
|
||||
);
|
||||
// Restore default (on) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_collapsed_edit_blocks_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
// test holds regardless of host config/env (a local `[ui]` or env
|
||||
// override legitimately beats the remote tier on both legs).
|
||||
let requirements = kigi_shell::config::load_merged_requirements();
|
||||
let user_config = kigi_shell::config::load_from_disk().ok();
|
||||
let managed_config = kigi_shell::config::load_managed_config().ok();
|
||||
let resolve = |remote_val: Option<bool>| {
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
collapsed_edit_blocks: remote_val,
|
||||
..Default::default()
|
||||
};
|
||||
kigi_shell::util::config::resolve_collapsed_edit_blocks(
|
||||
requirements.as_ref(),
|
||||
user_config.as_ref(),
|
||||
managed_config.as_ref(),
|
||||
Some(&remote),
|
||||
)
|
||||
.value
|
||||
};
|
||||
let expect_on = resolve(Some(true));
|
||||
let expect_cleared = resolve(None);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// remote settings enable arrives (the team rollout path).
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_collapsed_edit_blocks(),
|
||||
expect_on,
|
||||
"remote Some(true) must re-resolve into the cache"
|
||||
);
|
||||
|
||||
// remote settings clears the remote tier (field absent → None). Seed the
|
||||
// cache opposite to the expected outcome — the latched remote enable —
|
||||
// so only a real re-resolve can pass; the update must revert it to the
|
||||
// local/default resolution instead of skipping the field. An old
|
||||
// payload without the field takes this same path.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(!expect_cleared);
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(None),
|
||||
&mut app
|
||||
));
|
||||
assert_eq!(
|
||||
crate::appearance::cache::load_collapsed_edit_blocks(),
|
||||
expect_cleared,
|
||||
"cleared remote tier must re-resolve the full chain, not stay latched"
|
||||
);
|
||||
// Restore default (off) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
}
|
||||
|
||||
/// A remote collapsed_edit_blocks flip re-materializes on-default Edit
|
||||
/// rows in the live transcript (the same policy the settings toggle
|
||||
/// applies via `apply_collapsed_edit_blocks_flip`).
|
||||
#[test]
|
||||
fn settings_update_collapsed_edit_blocks_flip_refolds_live_edits() {
|
||||
use crate::scrollback::types::DisplayMode;
|
||||
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = {
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
sb.push_block(crate::scrollback::block::RenderBlock::ToolCall(
|
||||
crate::scrollback::blocks::tool::ToolCallBlock::Edit(
|
||||
crate::scrollback::blocks::tool::EditToolCallBlock::new("f.rs", vec![]),
|
||||
),
|
||||
))
|
||||
};
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.get_by_id(id).unwrap().display_mode,
|
||||
DisplayMode::Expanded,
|
||||
"flag off materializes expanded"
|
||||
);
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&collapsed_edit_blocks_settings_update(Some(true)),
|
||||
&mut app
|
||||
));
|
||||
if !crate::appearance::cache::load_collapsed_edit_blocks() {
|
||||
// A host-level env/config override outranked the remote value, so
|
||||
// no real flip occurred and the re-fold didn't run — nothing to
|
||||
// assert on this machine (CI runs with clean layers).
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.get_by_id(id).unwrap().display_mode,
|
||||
DisplayMode::Collapsed,
|
||||
"remote enable must collapse the on-default Edit row"
|
||||
);
|
||||
// Restore default (off) for other tests that share the process cache.
|
||||
crate::appearance::cache::set_collapsed_edit_blocks(false);
|
||||
}
|
||||
|
||||
/// The live-refresh flip mirrors `set_group_tool_verbs_inner`'s stale
|
||||
/// group-expansion cleanup: a previously expanded verb slot must not
|
||||
/// survive a remote flip as an expanded header.
|
||||
#[test]
|
||||
fn settings_update_flip_resets_stale_group_expansion() {
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
{
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
for i in 0..3 {
|
||||
sb.push_block(crate::scrollback::block::RenderBlock::read(
|
||||
format!("f{i}.rs"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
sb.prepare_layout(80, 40);
|
||||
sb.set_selected(Some(0));
|
||||
assert!(sb.toggle_group_expansion());
|
||||
sb.prepare_layout(80, 40);
|
||||
let info = sb.get_cached_entry_layouts().unwrap()[0];
|
||||
assert!(info.group_collapse_header, "expanded verb slot armed");
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&group_tool_verbs_settings_update(Some(false)),
|
||||
&mut app
|
||||
));
|
||||
if crate::appearance::cache::load_group_tool_verbs() {
|
||||
// A host-level env/config override outranked the remote value, so
|
||||
// no real flip occurred and the cleanup path didn't run — nothing
|
||||
// to assert on this machine (CI runs with clean layers).
|
||||
return;
|
||||
}
|
||||
let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
|
||||
sb.prepare_layout(80, 40);
|
||||
let info = sb.get_cached_entry_layouts().unwrap()[0];
|
||||
assert!(
|
||||
!info.group_collapse_header,
|
||||
"remote flip must drop the stale expansion"
|
||||
);
|
||||
assert!(
|
||||
sb.get_cached_entry_height(1).unwrap_or(0) > 0,
|
||||
"rows render individually after the flip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_gate_killswitch_clears_all_agents_regardless_of_active_mirror() {
|
||||
// Two agents both in auto; the active tab's global mirror reads "ask"
|
||||
// (a tab switch / Shift+Tab re-anchored it away from auto). A
|
||||
// mid-session gate kill-switch (`auto_permission_mode_enabled=false`)
|
||||
// must clear the per-session auto flag on BOTH agents. The old code
|
||||
// gated this fan-out on `current_ui.permission_mode == "auto"`, so it
|
||||
// skipped background agents and left stale `auto_mode` that
|
||||
// `switch_to_agent` could re-anchor back to "auto" on return.
|
||||
let mut app = make_app_two_agents();
|
||||
app.auto_mode_gate = true;
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.session.auto_mode = true;
|
||||
}
|
||||
// Active tab's mirror is NOT "auto" — the old bug's skip condition.
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "auto_permission_mode_enabled": false }),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
for (id, agent) in &app.agents {
|
||||
assert!(
|
||||
!agent.session.auto_mode,
|
||||
"agent {id:?} auto_mode must be cleared by the kill-switch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_gate_killswitch_notifies_agents_to_leave_auto() {
|
||||
// The kill-switch must tell live sessions to leave Auto, else the agent
|
||||
// keeps classifier-approving while the UI shows "Ask". The notification is
|
||||
// CLIENT-scoped, so exactly ONE fires regardless of how many tabs were in
|
||||
// auto; it omits `yolo_mode` so a sibling always-approve tab is preserved.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
||||
// Two auto agents + one always-approve sibling, all with live sessions.
|
||||
app.agents.insert(AgentId(0), make_agent(Some("sess-0")));
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-1")));
|
||||
app.agents.insert(AgentId(2), make_agent(Some("sess-yolo")));
|
||||
app.auto_mode_gate = true;
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.auto_mode = true;
|
||||
app.agents.get_mut(&AgentId(1)).unwrap().session.auto_mode = true;
|
||||
app.agents.get_mut(&AgentId(2)).unwrap().session.yolo_mode = true;
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "auto_permission_mode_enabled": false }),
|
||||
)
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
// Sibling always-approve is untouched — the kill-switch clears only auto.
|
||||
assert!(
|
||||
app.agents[&AgentId(2)].session.is_yolo(),
|
||||
"sibling always-approve must stay yolo after the auto kill-switch"
|
||||
);
|
||||
|
||||
let mut leave_auto_notifs = 0;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
if let kigi_acp_lib::AcpAgentMessage::ExtNotification(args) = msg {
|
||||
if args.request.method.as_ref() != "x.ai/yolo_mode_changed" {
|
||||
continue;
|
||||
}
|
||||
let params: serde_json::Value =
|
||||
serde_json::from_str(args.request.params.get()).unwrap();
|
||||
assert_eq!(params["auto_mode"], serde_json::json!(false));
|
||||
assert_eq!(params["permission_mode"], serde_json::json!("ask"));
|
||||
assert!(
|
||||
params.get("yolo_mode").is_none(),
|
||||
"yolo_mode must be omitted so a sibling always-approve session is preserved"
|
||||
);
|
||||
leave_auto_notifs += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
leave_auto_notifs, 1,
|
||||
"exactly one client-scoped leave-auto notification, regardless of agent count"
|
||||
);
|
||||
}
|
||||
|
||||
/// User-owned mode must not re-arm default_yolo or rewrite UI from remote.
|
||||
#[test]
|
||||
fn permission_mode_user_claim_blocks_default_yolo_rearm() {
|
||||
let mut app = make_app_with_agent("sess-user-claim");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = false;
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
app.default_yolo = false;
|
||||
|
||||
let apply_yolo = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&apply_yolo, &mut app);
|
||||
assert!(
|
||||
!app.default_yolo,
|
||||
"user-claimed mode must not re-arm default_yolo from remote always-approve"
|
||||
);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"user-claimed UI must not be rewritten by remote soft-default"
|
||||
);
|
||||
assert!(
|
||||
!app.permission_mode_from_soft_default,
|
||||
"user claim origin stays false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_mode_omitted_does_not_clear_soft_default() {
|
||||
let mut app = make_app_with_agent("sess-omit-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = Some("auto".into());
|
||||
app.default_yolo = false;
|
||||
app.auto_mode_gate = true;
|
||||
|
||||
let unrelated = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"show_resolved_model": true,
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&unrelated, &mut app);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("auto"),
|
||||
"omitted permission_mode must not clear soft-applied UI mode"
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"origin must stay SoftDefault when field is omitted"
|
||||
);
|
||||
assert!(
|
||||
!app.default_yolo,
|
||||
"omitted permission_mode must not recompute default_yolo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Positive wiring: a permission_mode-bearing push with the latch held
|
||||
/// must reach the applier through the real handler. The handler's ambient
|
||||
/// effective-config read decides WHICH mode wins (exact outcomes are
|
||||
/// pinned on the applier with injected TOML), so this asserts the
|
||||
/// applier's host-independent signature instead: the non-canonical
|
||||
/// sentinel display is rewritten to a canonical mode, latch preserved.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_push_reaches_applier() {
|
||||
let mut app = make_app_with_agent("sess-wire-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
// Outside the applier's output alphabet — only the applier rewrites it.
|
||||
app.current_ui.permission_mode = Some("sentinel-not-a-mode".into());
|
||||
|
||||
let push = acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
let _ = handle_ext_notification(&push, &mut app);
|
||||
let display = app
|
||||
.current_ui
|
||||
.permission_mode
|
||||
.as_deref()
|
||||
.expect("applier always writes a display mode");
|
||||
assert!(
|
||||
matches!(display, "ask" | "auto" | "always-approve" | "default"),
|
||||
"soft push must rewrite the sentinel display via the applier, got {display:?}"
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"a soft re-arm must keep SoftDefault origin"
|
||||
);
|
||||
}
|
||||
|
||||
/// Soft-origin recompute with injected TOML (deterministic — no host
|
||||
/// config): remote always-approve arms default_yolo + UI, keeps the soft
|
||||
/// latch, and persists nothing.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_applies_remote_always_approve() {
|
||||
let mut app = make_app_with_agent("sess-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = None;
|
||||
app.default_yolo = false;
|
||||
|
||||
super::super::settings::apply_soft_default_permission_mode(
|
||||
&mut app,
|
||||
None,
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(app.default_yolo, "remote always-approve must arm default_yolo");
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(
|
||||
app.permission_mode_from_soft_default,
|
||||
"a soft re-arm must keep SoftDefault origin"
|
||||
);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"a soft default must never be persisted to disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Explicit `null` recomputes with remote=None (unlike field omission):
|
||||
/// with no TOML permission key the soft always-approve drops back to Ask.
|
||||
#[test]
|
||||
fn permission_mode_explicit_null_clears_soft_always_approve() {
|
||||
let mut app = make_app_with_agent("sess-null-pm");
|
||||
app.auto_mode_gate = true;
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.current_ui.permission_mode = Some("always-approve".into());
|
||||
app.default_yolo = true;
|
||||
|
||||
super::super::settings::apply_soft_default_permission_mode(&mut app, None, None);
|
||||
assert!(!app.default_yolo, "remote null must disarm a soft always-approve");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
assert!(app.permission_mode_from_soft_default);
|
||||
assert!(
|
||||
app.pending_effects.is_empty(),
|
||||
"a soft default must never be persisted to disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Policy pin and auto gate clamp a soft re-arm to Ask enforcement/display.
|
||||
#[test]
|
||||
fn permission_mode_soft_default_respects_pin_and_gate() {
|
||||
let mut app = make_app_with_agent("sess-pin-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.yolo_policy_block = Some("pinned");
|
||||
app.default_yolo = false;
|
||||
super::super::settings::apply_soft_default_permission_mode(
|
||||
&mut app,
|
||||
None,
|
||||
Some("always-approve"),
|
||||
);
|
||||
assert!(!app.default_yolo, "policy pin must block a remote always-approve");
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
|
||||
let mut app = make_app_with_agent("sess-gate-pm");
|
||||
app.permission_mode_from_soft_default = true;
|
||||
app.auto_mode_gate = false;
|
||||
super::super::settings::apply_soft_default_permission_mode(&mut app, None, Some("auto"));
|
||||
assert!(!app.default_yolo);
|
||||
assert_eq!(
|
||||
app.current_ui.permission_mode.as_deref(),
|
||||
Some("ask"),
|
||||
"gated-off Auto must display as Ask"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// On resume, a replayed spawn+finish pair leaves the subagent terminal.
|
||||
#[test]
|
||||
fn replayed_subagent_finished_marks_orphan_terminal() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
|
||||
let finished = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_finished",
|
||||
"subagent_id": "sa-1",
|
||||
"child_session_id": "child-1",
|
||||
"status": "cancelled",
|
||||
"error": "interrupted by process restart",
|
||||
"tool_calls": 0,
|
||||
"turns": 0,
|
||||
"duration_ms": 1000,
|
||||
"tokens_used": 0,
|
||||
}),
|
||||
"sess-1-2",
|
||||
);
|
||||
handle_ext_notification(&finished, &mut app);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent
|
||||
.subagent_sessions
|
||||
.get("child-1")
|
||||
.expect("subagent present after replay");
|
||||
assert!(
|
||||
info.finished,
|
||||
"orphan must be terminal after replayed subagent_finished"
|
||||
);
|
||||
assert_eq!(info.status.as_deref(), Some("cancelled"));
|
||||
}
|
||||
|
||||
/// `cancelled = false` must finalize the row, not revert "killing" to "running".
|
||||
#[test]
|
||||
fn kill_finalizes_orphan_when_shell_reports_not_cancelled() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
|
||||
// User clicks kill after load.
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.loading_replay = false;
|
||||
let info = agent.subagent_sessions.get_mut("child-1").unwrap();
|
||||
assert!(!info.finished);
|
||||
info.pending_kill = true;
|
||||
info.kill_requested_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
// Shell: cancelled=false (nothing live), no real status → "cancelled".
|
||||
let finalized = finalize_killed_subagent(
|
||||
&mut app,
|
||||
&acp::SessionId::new("sess-1".to_owned()),
|
||||
"sa-1",
|
||||
"cancelled",
|
||||
);
|
||||
assert!(finalized, "row should have been finalized");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get("child-1").unwrap();
|
||||
assert!(info.finished, "kill must finalize the stuck orphan row");
|
||||
assert_eq!(info.status.as_deref(), Some("cancelled"));
|
||||
assert!(
|
||||
!info.pending_kill,
|
||||
"pending_kill must clear so it can't revert"
|
||||
);
|
||||
assert!(info.kill_requested_at.is_none());
|
||||
}
|
||||
|
||||
/// An already-finished subagent killed → finalize stamps the REAL terminal
|
||||
/// status (e.g. "completed"), not a forced "cancelled".
|
||||
#[test]
|
||||
fn kill_finalizes_orphan_with_real_status_when_already_finished() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let spawned = subagent_ext_replay(
|
||||
"sess-1",
|
||||
serde_json::json!({
|
||||
"sessionUpdate": "subagent_spawned",
|
||||
"subagent_id": "sa-1",
|
||||
"parent_session_id": "sess-1",
|
||||
"child_session_id": "child-1",
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "orphan review",
|
||||
}),
|
||||
"sess-1-1",
|
||||
);
|
||||
handle_ext_notification(&spawned, &mut app);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.loading_replay = false;
|
||||
let info = agent.subagent_sessions.get_mut("child-1").unwrap();
|
||||
info.pending_kill = true;
|
||||
}
|
||||
|
||||
let finalized = finalize_killed_subagent(
|
||||
&mut app,
|
||||
&acp::SessionId::new("sess-1".to_owned()),
|
||||
"sa-1",
|
||||
"completed",
|
||||
);
|
||||
assert!(finalized, "row should have been finalized");
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get("child-1").unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(
|
||||
info.status.as_deref(),
|
||||
Some("completed"),
|
||||
"already-finished kill must stamp the real terminal status"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: replay from `updates.jsonl` emits `x.ai/session/update` (not
|
||||
/// `session_notification`). Subagent lifecycle events must still populate
|
||||
/// `subagent_sessions` and the parent scrollback `SubagentBlock`.
|
||||
#[test]
|
||||
fn ext_session_update_replay_handles_subagent_spawned_and_finished() {
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
let child_sid = "child-sess-replay";
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
affected,
|
||||
"SubagentSpawned on the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.expect("SubagentSpawned must register subagent_sessions");
|
||||
assert_eq!(info.description.as_ref(), "scan src/");
|
||||
assert_eq!(info.subagent_type.as_ref(), "explore");
|
||||
assert!(
|
||||
agent.subagent_views.contains_key(child_sid),
|
||||
"SubagentSpawned must create subagent_views eagerly"
|
||||
);
|
||||
let entry_id = info
|
||||
.scrollback_entry_id
|
||||
.expect("spawn must stash scrollback_entry_id on SubagentInfo");
|
||||
assert_eq!(agent.scrollback.len(), 1);
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("SubagentSpawned must push a SubagentBlock to parent scrollback");
|
||||
};
|
||||
assert_eq!(sb.child_session_id, child_sid);
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Started));
|
||||
assert!(agent.scrollback.needs_animation());
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
affected,
|
||||
"SubagentFinished on the active agent must request a redraw"
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(info.status.as_deref(), Some("completed"));
|
||||
assert_eq!(info.tool_calls, Some(2));
|
||||
assert_eq!(info.turns, Some(1));
|
||||
assert_eq!(info.duration_ms, Some(500));
|
||||
assert_eq!(info.scrollback_entry_id, Some(entry_id));
|
||||
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("finished subagent must keep the started scrollback entry");
|
||||
};
|
||||
match &sb.kind {
|
||||
SubagentBlockKind::Completed { elapsed } => {
|
||||
assert_eq!(*elapsed, std::time::Duration::from_millis(500));
|
||||
}
|
||||
other => {
|
||||
panic!("blocking subagent must mutate started block to Completed, got {other:?}")
|
||||
}
|
||||
}
|
||||
assert!(!entry.is_running, "finish_running must clear running flag");
|
||||
assert!(
|
||||
!agent.scrollback.needs_animation(),
|
||||
"finished subagent entry must not keep scrollback animation"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live activity label fans out to `SubagentInfo` (tasks pane /
|
||||
/// dashboard rows) alongside the scrollback block — from both the child
|
||||
/// session/update path and the `SubagentProgress` path — and
|
||||
/// `SubagentFinished` clears both surfaces.
|
||||
#[test]
|
||||
fn subagent_activity_label_stamps_info_and_clears_on_finish() {
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
let child_sid = "child-activity";
|
||||
let _ = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-parent",
|
||||
test_subagent_spawned("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// A live child message chunk resolves "Responding" and stamps both
|
||||
// the block and the info.
|
||||
let _ = handle(
|
||||
make_agent_chunk_with_event(child_sid, "child text", "p-child", None),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert_eq!(info.activity_label.as_deref(), Some("Responding"));
|
||||
let entry_id = info.scrollback_entry_id.unwrap();
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("expected Subagent block");
|
||||
};
|
||||
assert_eq!(sb.activity_label, info.activity_label);
|
||||
|
||||
// SubagentProgress recomputes from the child tracker and restamps.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.subagent_sessions
|
||||
.get_mut(child_sid)
|
||||
.unwrap()
|
||||
.activity_label = None;
|
||||
let _ = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-parent",
|
||||
test_subagent_progress("sess-parent", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.unwrap()
|
||||
.activity_label
|
||||
.as_deref(),
|
||||
Some("Responding")
|
||||
);
|
||||
|
||||
let _ = handle(
|
||||
make_ext_session_notification("sess-parent", test_subagent_finished(child_sid)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(
|
||||
info.activity_label.is_none(),
|
||||
"finish must clear the info label"
|
||||
);
|
||||
let entry = agent.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("expected Subagent block");
|
||||
};
|
||||
assert!(
|
||||
sb.activity_label.is_none(),
|
||||
"finish must clear the block label"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: replayed SubagentSpawned (resumed_from unset) must load child
|
||||
/// updates.jsonl so fullscreen scrollback is not prompt-only.
|
||||
#[test]
|
||||
fn subagent_spawned_replays_child_updates_without_resumed_from() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-with-updates";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"spawn must replay exactly one tool call"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed),
|
||||
"spawn must set child_updates_replayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Resume: a `SubagentSpawned` during `loading_replay` must defer the child
|
||||
/// transcript load (the dominant large-session resume cost) to first open.
|
||||
#[test]
|
||||
fn subagent_spawned_during_resume_defers_child_replay_until_open() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-resume-defer";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
// Simulate resume: the parent agent is replaying its own session.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
0,
|
||||
"resume spawn must NOT eagerly replay the child transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| !i.child_updates_replayed),
|
||||
"resume spawn must leave child_updates_replayed unset for lazy load"
|
||||
);
|
||||
|
||||
// Opening the subagent later triggers the deferred (lazy) replay.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"opening the subagent after resume must lazily replay its transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed),
|
||||
"lazy open must set child_updates_replayed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression (resume): a subagent that already finished must still show its
|
||||
/// full transcript on open. The finished handler's `TurnCompleted` push is
|
||||
/// suppressed during replay — otherwise it vetoes the deferred load
|
||||
/// (`subagent_child_needs_replay`), leaving a permanently empty transcript.
|
||||
#[test]
|
||||
fn subagent_resume_finished_then_open_shows_full_transcript() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-resume-finished";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
let _ = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-parent",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
0,
|
||||
"resume must not eagerly load the finished subagent transcript"
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| !i.child_updates_replayed),
|
||||
"finished-during-resume must leave child_updates_replayed unset"
|
||||
);
|
||||
// Even deferred, a finished subagent must not show a running spinner.
|
||||
assert!(
|
||||
matches!(
|
||||
agent.subagent_views.get(child_sid).unwrap().session.state,
|
||||
AgentState::Idle
|
||||
),
|
||||
"finished subagent must be Idle after resume, not TurnRunning"
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"opening a finished subagent after resume must show its transcript"
|
||||
);
|
||||
// The lazy load reapplies the "Worked for" footer (live parity).
|
||||
let child = agent.subagent_views.get(child_sid).unwrap();
|
||||
assert!(
|
||||
(0..child.scrollback.len()).any(|i| child
|
||||
.scrollback
|
||||
.entry(i)
|
||||
.is_some_and(|e| matches!(e.block, RenderBlock::SessionEvent(_)))),
|
||||
"opened finished subagent must show a TurnCompleted footer"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression (resume): with a meta.json task prompt AND a persisted child
|
||||
/// transcript that echoes that prompt, opening after resume shows the task
|
||||
/// exactly once — the deferred open must dedup the replayed prompt echo.
|
||||
#[test]
|
||||
fn subagent_resume_with_meta_prompt_shows_task_once_after_open() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-resume-meta";
|
||||
let task = "scan src/ for auth";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
.session
|
||||
.loading_replay = true;
|
||||
|
||||
let updates = format!(
|
||||
"{}\n{}",
|
||||
child_user_message_line(child_sid, task),
|
||||
child_tool_line(child_sid)
|
||||
);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, Some(&updates));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"task prompt must appear exactly once after resume + open"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression: replayed user_message_chunk + meta prompt must not duplicate via injection.
|
||||
#[test]
|
||||
fn subagent_spawn_replay_and_meta_prompt_shows_task_once() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-prompt-once";
|
||||
let task = "scan src/ for auth";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
let updates = format!(
|
||||
"{}\n{}",
|
||||
child_user_message_line(child_sid, task),
|
||||
child_tool_line(child_sid)
|
||||
);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, Some(&updates));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"task prompt must appear exactly once in child scrollback"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
assert!(
|
||||
!child_tracker_expects_user_echo(agent, child_sid),
|
||||
"replay path must not set expect_user_echo when injection is skipped"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Live spawn: meta prompt without updates.jsonl still injects the task once.
|
||||
#[test]
|
||||
fn subagent_spawn_live_injects_meta_prompt_once_without_updates() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-live-prompt";
|
||||
let task = "explore handlers only";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, task);
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, task),
|
||||
1,
|
||||
"live spawn must inject meta prompt when updates.jsonl is absent"
|
||||
);
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 0);
|
||||
assert!(
|
||||
child_tracker_expects_user_echo(agent, child_sid),
|
||||
"live spawn must set expect_user_echo after injecting meta prompt"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_skips_injection_for_whitespace_only_meta_prompt() {
|
||||
with_replay_disk_home(|home| {
|
||||
let parent_sid = "sess-parent";
|
||||
let child_sid = "child-empty-meta";
|
||||
write_subagent_meta_json(home, parent_sid, child_sid, " ");
|
||||
|
||||
let mut app = make_app_with_agent(parent_sid);
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
child_scrollback_matching_prompt_count(agent, child_sid, " "),
|
||||
0,
|
||||
"whitespace-only meta prompt must not inject a user block"
|
||||
);
|
||||
assert!(
|
||||
!child_tracker_expects_user_echo(agent, child_sid),
|
||||
"whitespace-only meta prompt must not set expect_user_echo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_without_updates_jsonl_is_noop() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-no-updates";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(&mut app, child_sid, None);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 0);
|
||||
assert_eq!(
|
||||
agent
|
||||
.subagent_views
|
||||
.get(child_sid)
|
||||
.unwrap()
|
||||
.scrollback
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawn_and_open_replay_is_idempotent() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-idempotent";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
assert_eq!(
|
||||
child_scrollback_tool_call_count(agent, child_sid),
|
||||
1,
|
||||
"open must not duplicate spawn replay when child_updates_replayed is set"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_subagent_fullscreen_replays_when_flag_false_and_prompt_only() {
|
||||
with_replay_disk_home(|_| {
|
||||
let child_sid = "child-open-replay";
|
||||
let mut app = make_app_with_agent("sess-parent");
|
||||
spawn_subagent_with_optional_updates(
|
||||
&mut app,
|
||||
child_sid,
|
||||
Some(&(child_tool_line(child_sid) + "\n")),
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
if let Some(child) = agent.subagent_views.get_mut(child_sid) {
|
||||
child.scrollback.clear();
|
||||
child
|
||||
.scrollback
|
||||
.push_block(RenderBlock::user_prompt("task only"));
|
||||
}
|
||||
if let Some(info) = agent.subagent_sessions.get_mut(child_sid) {
|
||||
info.child_updates_replayed = false;
|
||||
}
|
||||
|
||||
agent.open_subagent_fullscreen(child_sid.to_string());
|
||||
|
||||
assert_eq!(child_scrollback_tool_call_count(agent, child_sid), 1);
|
||||
assert!(
|
||||
agent
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.is_some_and(|i| i.child_updates_replayed)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_notification_and_update_equivalent_for_subagent_spawned() {
|
||||
let child_sid = "child-equiv";
|
||||
let (spawn_notif, finish_notif) =
|
||||
run_subagent_lifecycle_via_method("x.ai/session_notification", child_sid);
|
||||
let (spawn_update, finish_update) =
|
||||
run_subagent_lifecycle_via_method("x.ai/session/update", child_sid);
|
||||
|
||||
assert_eq!(spawn_notif.description, spawn_update.description);
|
||||
assert_eq!(spawn_notif.subagent_type, spawn_update.subagent_type);
|
||||
assert_eq!(spawn_notif.has_child_view, spawn_update.has_child_view);
|
||||
assert_eq!(spawn_notif.scrollback_len, spawn_update.scrollback_len);
|
||||
assert_eq!(spawn_notif.child_session_id, child_sid);
|
||||
assert_eq!(spawn_update.child_session_id, child_sid);
|
||||
assert!(matches!(spawn_notif.block_kind, SubagentBlockKind::Started));
|
||||
assert!(matches!(
|
||||
spawn_update.block_kind,
|
||||
SubagentBlockKind::Started
|
||||
));
|
||||
assert_eq!(
|
||||
spawn_notif.scrollback_entry_id,
|
||||
spawn_update.scrollback_entry_id
|
||||
);
|
||||
assert!(spawn_notif.scrollback_entry_id.is_some());
|
||||
|
||||
assert!(finish_notif.finished);
|
||||
assert!(finish_update.finished);
|
||||
assert_eq!(finish_notif.status.as_deref(), Some("completed"));
|
||||
assert_eq!(finish_update.status.as_deref(), Some("completed"));
|
||||
assert_eq!(finish_notif.tool_calls, Some(2));
|
||||
assert_eq!(finish_update.tool_calls, Some(2));
|
||||
assert_eq!(finish_notif.turns, Some(1));
|
||||
assert_eq!(finish_update.turns, Some(1));
|
||||
assert_eq!(finish_notif.duration_ms, Some(500));
|
||||
assert_eq!(finish_update.duration_ms, Some(500));
|
||||
assert!(matches!(
|
||||
finish_notif.block_kind,
|
||||
SubagentBlockKind::Completed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
finish_update.block_kind,
|
||||
SubagentBlockKind::Completed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_for_inactive_agent_registers_subagent_without_redraw() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let child_sid = "child-inactive";
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-A",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-A", child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent_a
|
||||
.subagent_sessions
|
||||
.get(child_sid)
|
||||
.expect("SubagentSpawned must register on inactive agent A");
|
||||
assert!(
|
||||
agent_a.subagent_views.contains_key(child_sid),
|
||||
"SubagentSpawned must create subagent_views on inactive agent A"
|
||||
);
|
||||
assert_eq!(agent_a.scrollback.len(), 1);
|
||||
let entry_id = info
|
||||
.scrollback_entry_id
|
||||
.expect("inactive spawn must stash scrollback_entry_id");
|
||||
let entry = agent_a.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("inactive spawn must push SubagentBlock");
|
||||
};
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Started));
|
||||
assert!(
|
||||
!affected,
|
||||
"SubagentSpawned on inactive agent must not request a redraw"
|
||||
);
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-A",
|
||||
"x.ai/session/update",
|
||||
test_subagent_finished(child_sid),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"SubagentFinished on inactive agent must not request a redraw"
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent_a.subagent_sessions.get(child_sid).unwrap();
|
||||
assert!(info.finished);
|
||||
assert_eq!(info.status.as_deref(), Some("completed"));
|
||||
let entry = agent_a.scrollback.get_by_id(entry_id).unwrap();
|
||||
let RenderBlock::Subagent(sb) = &entry.block else {
|
||||
panic!("inactive finish must keep SubagentBlock");
|
||||
};
|
||||
assert!(matches!(sb.kind, SubagentBlockKind::Completed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_unknown_session_subagent_spawned_no_op() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let affected = handle(
|
||||
make_ext_session_notification_with_method(
|
||||
"sess-unknown",
|
||||
"x.ai/session/update",
|
||||
test_subagent_spawned("sess-unknown", "child-unknown"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(!affected, "unknown session_id must not request a redraw");
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.subagent_sessions.is_empty(),
|
||||
"SubagentSpawned for unknown session must not register subagent_sessions"
|
||||
);
|
||||
assert!(
|
||||
agent.scrollback.is_empty(),
|
||||
"SubagentSpawned for unknown session must not push scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_update_malformed_params_returns_false() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
// Valid JSON but not a SessionNotification — parse must fail quietly.
|
||||
let raw =
|
||||
serde_json::value::to_raw_value(&serde_json::json!({"unexpected": true})).unwrap();
|
||||
let request = acp::ExtNotification::new("x.ai/session/update", raw.into());
|
||||
let msg = AcpClientMessage::ExtNotification(kigi_acp_lib::AcpArgs {
|
||||
request,
|
||||
response_tx: tx,
|
||||
});
|
||||
|
||||
let affected = handle(msg, &mut app);
|
||||
|
||||
assert!(
|
||||
!affected,
|
||||
"malformed x.ai/session/update params must not redraw"
|
||||
);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.is_empty(),
|
||||
"malformed notification must not mutate scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_session_notification_for_inactive_agent_updates_its_context_used() {
|
||||
// AutoCompactCompleted on the xAI ext path resets the context bar
|
||||
// numerator via refresh_context_used. That side effect must run on
|
||||
// the matched agent regardless of which view is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
// Seed A with a stale context-used reading so we can prove the
|
||||
// notification reset it.
|
||||
{
|
||||
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent_a.apply_context_used(90_000, 131_072);
|
||||
}
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
let affected = handle(
|
||||
make_ext_session_notification(
|
||||
"sess-A",
|
||||
XaiSessionUpdate::AutoCompactCompleted {
|
||||
tokens_before: Some(90_000),
|
||||
tokens_after: 25_000,
|
||||
elapsed_ms: Some(300),
|
||||
summary_preview: None,
|
||||
},
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent_a.context_state.as_ref().map(|c| c.used),
|
||||
Some(25_000),
|
||||
"AutoCompactCompleted must reset A's context_used even when B is active"
|
||||
);
|
||||
assert!(
|
||||
!affected,
|
||||
"ext notification routed to a non-active agent must not request a redraw"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user