Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/acp_handler/session_notification.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

1328 lines
54 KiB
Rust

use super::*;
use kigi_shell::sampling::error::rate_limited_user_message;
/// Stash a live stop/stop_failure batch under `stash_pid` for the turn marker
/// to fold. `merge_same_name` merges a same-name repeat instead of standalone.
pub(super) fn stash_live_stop_batch(
agent: &mut AgentView,
stash_pid: Option<String>,
event_name: String,
hook_entries: Vec<crate::scrollback::blocks::tool::HookRunEntry>,
merge_same_name: bool,
) {
if let Some(stale) = agent
.pending_stop_hooks
.take_if(|p| p.prompt_id != stash_pid)
{
for (name, runs) in stale.groups {
agent.scrollback.push_lifecycle_hooks(name, runs);
}
}
let pending = agent.pending_stop_hooks.get_or_insert_with(|| {
super::super::agent_view::PendingStopHooks {
prompt_id: stash_pid,
groups: Vec::new(),
}
});
match pending
.groups
.iter()
.position(|(name, _)| *name == event_name)
{
Some(idx) if merge_same_name => {
pending.groups[idx].1.extend(hook_entries);
}
Some(_) => {
agent
.scrollback
.push_lifecycle_hooks(event_name, hook_entries);
}
None => {
pending.groups.push((event_name, hook_entries));
}
}
}
pub(super) fn refresh_context_used(view: &mut AgentView, used: u64) {
let total = view.session.models.get_context_window().unwrap_or(0);
view.apply_context_used(used, total);
}
/// Refresh the bar and record `used` as the confirmed count for a pending
/// compaction message; call only from the `meta.totalTokens` path.
pub(super) fn confirm_context_used(view: &mut AgentView, used: u64) {
refresh_context_used(view, used);
view.session.note_context_used(used);
}
/// Replay gate shared by the ACP and xAI session-update paths. Returns `true`
/// when the update must be dropped.
///
/// Replay is only expected while a `session/load` is in flight for this agent
/// (fresh-view load or reconnect reload window). Anything else is misrouted —
/// e.g. a leader falling through to broadcast another client's replay, or a
/// replay landing after its reload already timed out — and applying it would
/// append duplicated history below the live transcript. An expected replay is
/// recorded on the open reload window instead (see
/// [`AgentView::mark_reload_replay_seen`]). One `warn!` per incident; the rest
/// of the burst (one line per replayed event) logs at `debug!`.
pub(super) fn drop_unexpected_replay(
agent: &mut AgentView,
meta: &NotificationMeta,
session_id: &str,
source: &'static str,
) -> bool {
if !meta.is_replay {
return false;
}
if agent.session.loading_replay {
agent.mark_reload_replay_seen();
return false;
}
if agent.unexpected_replay_drops == 0 {
tracing::warn!(
session_id,
source,
event_id = meta.event_id.as_deref(),
"Dropping unexpected replay update (no session load in flight); further drops logged at debug"
);
} else {
tracing::debug!(
session_id,
source,
event_id = meta.event_id.as_deref(),
"Dropping unexpected replay update"
);
}
agent.unexpected_replay_drops = agent.unexpected_replay_drops.saturating_add(1);
true
}
/// Advance the reconnect cursor to an APPLIED update's eventId. Called from
/// every applied arm (Plan, bg-stdout, tracker) — dropped updates (dedup,
/// promptId gate, unexpected replay) deliberately don't move it.
pub(super) fn advance_reconnect_cursor(agent: &mut AgentView, meta: &mut NotificationMeta) {
if let Some(id) = meta.event_id.take() {
agent.last_seen_event_id = Some(id);
}
}
/// Handle `x.ai/session_notification` and replay-path `x.ai/session/update`.
///
/// Routes by `session_id` so events for an inactive agent still mutate that
/// agent's state. The redraw decision is gated on whether the matched agent
/// is the currently visible one.
pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
tracing::warn!("Failed to parse {}", notif.method.as_ref());
return false;
};
match &session_notif.update {
XaiSessionUpdate::TaskBackgrounded { .. } => {
return handle_task_backgrounded(notif, app);
}
XaiSessionUpdate::TaskCompleted { .. } => {
return handle_task_completed(notif, app);
}
XaiSessionUpdate::ScheduledTaskCreated { .. } => {
return handle_scheduled_task_created(notif, app);
}
XaiSessionUpdate::ScheduledTaskDeleted { .. } => {
return handle_scheduled_task_deleted(notif, app);
}
_ => {}
}
let is_api_key_auth = app.is_api_key_auth;
let matched = match find_session_match(app, &session_notif.session_id) {
Some(m) => m,
None => {
tracing::debug!(
session_id = session_notif.session_id.0.as_ref(),
method = notif.method.as_ref(),
"load-race: x.ai/session_notification DROPPED — no agent matches session_id"
);
return false;
}
};
let parent_id = matched.agent_id();
let is_active = is_matched_agent_active(app, parent_id);
let agent = app
.agents
.get_mut(&parent_id)
.expect("find_session_match returned an existing AgentId");
if matches!(matched, SessionMatch::Child(_)) {
let child_sid: &str = session_notif.session_id.0.as_ref();
let changed = handle_child_session_notification(
session_notif.update,
child_sid,
agent,
is_api_key_auth,
);
return changed && is_active;
}
let meta = NotificationMeta::from_json(session_notif.meta.as_ref().and_then(|v| v.as_object()));
if drop_unexpected_replay(
agent,
&meta,
session_notif.session_id.0.as_ref(),
"x.ai/session/update",
) {
return false;
}
if !meta.is_replay
&& meta.event_seq.is_some_and(|seq| {
agent
.last_applied_xai_event_seq
.is_some_and(|last| seq <= last)
})
{
tracing::debug!(
session_id = session_notif.session_id.0.as_ref(),
event_seq = meta.event_seq,
last_applied = agent.last_applied_xai_event_seq,
"x.ai/session update DROPPED by dedup highwater (event_seq <= last_applied)"
);
return false;
}
let mut plugins_changed_needs_skills_refetch = false;
let mut terminal_outcome: Option<super::super::turn_completion::TerminalApply> = None;
let root_session_id: &str = session_notif.session_id.0.as_ref();
let changed = match session_notif.update {
ref update @ (XaiSessionUpdate::AutoCompactStarted { .. }
| XaiSessionUpdate::AutoCompactCompleted { .. }
| XaiSessionUpdate::AutoCompactFailed { .. }
| XaiSessionUpdate::AutoCompactCancelled { .. }
| XaiSessionUpdate::RetryState(_)
| XaiSessionUpdate::ImageDropped { .. }
| XaiSessionUpdate::MemoryFlushCompleted { .. }
| XaiSessionUpdate::MemoryDreamCompleted { .. }
| XaiSessionUpdate::MemorySessionSaved { .. }) => {
let changed = apply_session_event(
update,
&mut agent.session,
&mut agent.scrollback,
is_api_key_auth,
);
if let XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } = update {
refresh_context_used(agent, *tokens_after);
agent.todo.update_todos(Vec::new());
}
changed
}
XaiSessionUpdate::ImageCompressed {
ref images,
ref message,
} => apply_image_compressed(agent, images, message),
XaiSessionUpdate::TurnCompleted {
prompt_id,
stop_reason,
agent_result,
..
} => {
if agent.session.loading_replay {
agent.replayed_terminal_prompts.insert(prompt_id);
false
} else if is_wake_prompt(&prompt_id) {
if agent.session.state.is_busy() {
false
} else if matches!(stop_reason.as_str(), "cancelled" | "error" | "rate_limit") {
agent.maybe_push_work_status()
} else {
let elapsed = wake_turn_elapsed(agent, &prompt_id, meta.agent_timestamp_ms);
push_wake_end_marker(agent, &prompt_id, elapsed);
true
}
} else {
let cancel_trigger = session_notif
.meta
.as_ref()
.and_then(|v| v.get("cancelTrigger"))
.and_then(|v| v.as_str());
terminal_outcome =
Some(super::super::turn_completion::finalize_turn_from_terminal(
agent,
root_session_id,
Some(&prompt_id),
Some(&stop_reason),
agent_result.as_deref(),
cancel_trigger,
));
false
}
}
XaiSessionUpdate::SubagentSpawned {
subagent_id,
child_session_id,
subagent_type,
description,
persona,
role,
model,
effective_context_source,
resumed_from,
capability_mode,
context_normalized,
parent_prompt_id,
..
} => {
tracing::info!(
child_session_id = % child_session_id, subagent_type = % subagent_type,
"Subagent spawned"
);
let is_background = agent
.session
.tracker
.task_tool_background
.remove(&subagent_id)
.unwrap_or(false);
let persona_display = persona.clone();
let role_display = role.clone();
let model_display = model.clone();
agent.subagent_sessions.insert(
child_session_id.clone(),
SubagentInfo {
subagent_id: Arc::from(subagent_id),
child_session_id: Arc::from(child_session_id.clone()),
description: Arc::from(description.clone()),
subagent_type: Arc::from(subagent_type.clone()),
persona: persona.map(Arc::from),
role: role.map(Arc::from),
model: model.map(Arc::from),
context_source: effective_context_source.map(Arc::from),
resumed_from: resumed_from.map(Arc::from),
capability_mode: capability_mode.map(Arc::from),
context_normalized,
parent_prompt_id: parent_prompt_id.map(Arc::from),
started_at: std::time::Instant::now(),
last_progress_at: std::time::Instant::now(),
finished: false,
status: None,
error: None,
duration_ms: None,
tool_calls: None,
turns: None,
turn_count: None,
tool_call_count: None,
tokens_used: None,
context_window_tokens: None,
context_usage_pct: None,
tools_used: Vec::new(),
error_count: None,
activity_label: None,
is_background,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
prompt: None,
child_cwd: None,
worktree_path: None,
child_updates_replayed: false,
},
);
if let Some(ref sid) = agent.session.session_id
&& let Some(info) = agent.subagent_sessions.get_mut(&child_session_id)
{
crate::app::subagent::enrich_from_meta(info, &agent.session.cwd, sid.0.as_ref());
}
let (effective_child_cwd, effective_is_worktree) = derive_child_cwd(
&agent.session.cwd,
agent.subagent_sessions.get(&child_session_id),
);
let child_session = AgentSession {
id: AgentId(0),
acp_tx: agent.session.acp_tx.clone(),
session_id: Some(acp::SessionId::new(child_session_id.clone())),
models: agent.session.models.clone(),
state: AgentState::TurnRunning,
tracker: AcpUpdateTracker::new(),
cwd: effective_child_cwd,
is_worktree: effective_is_worktree,
forked_from: None,
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: true,
auto_mode: false,
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: None,
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
};
let mut child_scrollback = crate::scrollback::state::ScrollbackState::new();
child_scrollback.set_appearance(agent.scrollback.appearance().clone());
let mut child_view = AgentView::new(child_session, child_scrollback);
child_view.set_input_mode(InputMode::Vim);
child_view.is_subagent_view = true;
child_view.active_pane = crate::views::agent::ActivePane::Scrollback;
child_view.set_sharing_enabled(agent.sharing_enabled);
let usage_visible = agent
.prompt
.slash_controller
.registry()
.get("usage")
.is_some();
child_view.set_usage_visible(usage_visible);
let dashboard_visible = agent
.prompt
.slash_controller
.registry()
.get("dashboard")
.is_some();
child_view.set_dashboard_visible(dashboard_visible);
child_view
.prompt
.set_screen_mode(agent.prompt.slash_controller.screen_mode());
child_view.app_chat_mode = agent.app_chat_mode;
let recap_visible = agent
.prompt
.slash_controller
.registry()
.get("recap")
.is_some();
child_view.set_session_recap_available(recap_visible);
let restricted = agent
.prompt
.slash_controller
.registry()
.restricted_commands();
child_view.set_restricted_commands(&restricted);
agent
.subagent_views
.insert(child_session_id.clone(), Box::new(child_view));
if !agent.session.loading_replay {
if let Some(child_view) = agent.subagent_views.get_mut(&child_session_id) {
crate::app::subagent::replay_inherited_updates(child_view, &child_session_id);
}
if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {
info.child_updates_replayed = true;
}
}
let prompt_to_inject = agent
.subagent_sessions
.get(&child_session_id)
.and_then(|info| info.prompt.as_deref())
.filter(|p| !p.trim().is_empty())
.filter(|p| {
agent
.subagent_views
.get(&child_session_id)
.is_some_and(|cv| {
!crate::app::subagent::child_scrollback_already_shows_prompt(
&cv.scrollback,
p,
)
})
})
.map(str::to_owned);
if let (Some(prompt), Some(child_view)) = (
prompt_to_inject,
agent.subagent_views.get_mut(&child_session_id),
) {
child_view
.scrollback
.push_block(RenderBlock::user_prompt(prompt));
child_view.session.tracker.expect_user_echo();
}
let block = crate::scrollback::blocks::SubagentBlock::started(
&description,
&child_session_id,
&subagent_type,
persona_display,
role_display,
model_display,
is_background,
);
let entry_id = agent.scrollback.push_block(RenderBlock::Subagent(block));
agent.scrollback.set_last_running(true);
if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {
info.scrollback_entry_id = Some(entry_id);
info.is_background = is_background;
}
agent.maybe_push_parked_marker();
true
}
XaiSessionUpdate::SubagentProgress {
child_session_id,
duration_ms,
turn_count,
tool_call_count,
tokens_used,
context_window_tokens,
context_usage_pct,
tools_used,
error_count,
..
} => {
if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {
info.duration_ms = Some(duration_ms);
info.turn_count = Some(turn_count);
info.tool_call_count = Some(tool_call_count);
info.tokens_used = Some(tokens_used);
info.context_window_tokens = Some(context_window_tokens);
info.context_usage_pct = Some(context_usage_pct);
info.tools_used = tools_used.into_iter().map(Arc::from).collect();
info.error_count = Some(error_count);
info.last_progress_at = std::time::Instant::now();
}
if let Some(child_view) = agent.subagent_views.get_mut(&child_session_id)
&& context_window_tokens > 0
{
child_view
.session
.models
.override_context_window(context_window_tokens);
}
let activity_label = agent
.subagent_views
.get(&child_session_id)
.and_then(|cv| subagent_activity_label(cv));
sync_subagent_activity(agent, &child_session_id, activity_label);
true
}
XaiSessionUpdate::SubagentFinished {
child_session_id,
status,
error,
tool_calls,
turns,
duration_ms,
tokens_used,
will_wake,
..
} => {
tracing::info!(
child_session_id = % child_session_id, status = % status, tool_calls =
tool_calls, turns = turns, duration_ms = duration_ms, "Subagent finished"
);
let elapsed_dur = std::time::Duration::from_millis(duration_ms);
let info_ref = agent.subagent_sessions.get(&child_session_id);
let entry_id = info_ref.and_then(|s| s.scrollback_entry_id);
let is_background = info_ref.is_some_and(|s| s.is_background);
let description = info_ref.map(|s| s.description.clone()).unwrap_or_default();
if let Some(eid) = entry_id {
agent.scrollback.finish_running(eid);
}
sync_subagent_activity(agent, &child_session_id, None);
if is_background {
let block = match status.as_str() {
"completed" => {
RenderBlock::Subagent(crate::scrollback::blocks::SubagentBlock::completed(
description.as_ref(),
child_session_id.as_str(),
elapsed_dur,
))
}
"cancelled" => {
RenderBlock::Subagent(crate::scrollback::blocks::SubagentBlock::cancelled(
description.as_ref(),
child_session_id.as_str(),
elapsed_dur,
))
}
_ => RenderBlock::Subagent(crate::scrollback::blocks::SubagentBlock::failed(
description.as_ref(),
child_session_id.as_str(),
elapsed_dur,
error.clone(),
)),
};
agent.scrollback.push_block(block);
} else if let Some(eid) = entry_id
&& let Some(entry) = agent.scrollback.get_by_id_mut(eid)
{
if let RenderBlock::Subagent(ref mut sb) = entry.block {
match status.as_str() {
"completed" => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Completed {
elapsed: elapsed_dur,
};
}
"cancelled" => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Cancelled {
elapsed: elapsed_dur,
};
}
_ => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Failed {
elapsed: elapsed_dur,
error: error.clone(),
};
}
}
}
entry.invalidate_cache();
}
let mut was_running = false;
if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {
was_running = info.is_running();
info.finished = true;
info.status = Some(Arc::from(status));
info.error = error.map(Arc::from);
info.duration_ms = Some(duration_ms);
info.tool_calls = Some(tool_calls);
info.turns = Some(turns);
if tokens_used > 0 {
info.tokens_used = Some(tokens_used);
}
info.pending_kill = false;
info.kill_requested_at = None;
info.last_progress_at = std::time::Instant::now();
}
let resuming = agent.session.loading_replay;
if let Some(child_view) = agent.subagent_views.get_mut(&child_session_id) {
child_view.session.state = AgentState::Idle;
if !resuming {
crate::app::subagent::finalize_finished_child_view(child_view, elapsed_dur);
}
}
if was_running && !resuming {
agent.maybe_refresh_parked_subagent_marker();
}
if !resuming && !will_wake {
agent.maybe_push_work_status();
}
true
}
XaiSessionUpdate::HookAnnotation { message } => {
if app.appearance.disable_plugins {
return false;
}
tracing::debug!("Hook annotation: {message}");
agent
.scrollback
.push_block(RenderBlock::session_event(SessionEvent::HookAnnotation {
message,
}));
true
}
XaiSessionUpdate::HookExecution {
event_name,
tool_name: _tool_name,
prompt_id: batch_prompt_id,
runs,
} => {
use crate::scrollback::blocks::tool::{HookPhase, HookRunEntry, HookRunStatus};
let hook_entries: Vec<HookRunEntry> = runs
.into_iter()
.map(|r| {
let status = match r.status {
kigi_shell::extensions::notification::HookRunStatusDto::Success {
elapsed_ms,
} => HookRunStatus::Success {
elapsed: std::time::Duration::from_millis(elapsed_ms),
},
kigi_shell::extensions::notification::HookRunStatusDto::Skipped => {
HookRunStatus::Skipped
}
kigi_shell::extensions::notification::HookRunStatusDto::Failed {
error,
elapsed_ms,
} => HookRunStatus::Failed {
error,
elapsed: std::time::Duration::from_millis(elapsed_ms),
},
};
HookRunEntry {
name: r.name,
status,
output: r.output,
}
})
.collect();
let is_tool_hook = event_name == "pre_tool_use" || event_name == "post_tool_use";
let is_stop_hook = event_name == "stop" || event_name == "stop_failure";
if is_tool_hook {
let phase = if event_name == "pre_tool_use" {
HookPhase::Pre
} else {
HookPhase::Post
};
if let Some(entry_id) = agent.scrollback.last_tool_call_entry_id() {
agent.scrollback.attach_hooks(entry_id, phase, hook_entries);
}
} else if is_stop_hook && !meta.is_replay && !agent.session.loading_replay {
let local_turn_active =
agent.session.state.is_turn_running() || agent.session.state.is_cancelling();
let batch_is_wake = batch_prompt_id.as_deref().is_some_and(is_wake_prompt);
let foreign_batch = batch_prompt_id.is_some()
&& agent.session.current_prompt_id.is_some()
&& batch_prompt_id != agent.session.current_prompt_id
&& !batch_is_wake;
if foreign_batch {
agent
.scrollback
.push_lifecycle_hooks(event_name, hook_entries);
} else if !batch_is_wake && local_turn_active {
let stash_pid = batch_prompt_id
.clone()
.or_else(|| agent.session.current_prompt_id.clone());
stash_live_stop_batch(
agent,
stash_pid,
event_name,
hook_entries,
batch_prompt_id.is_some(),
);
} else if let Some(entry_id) = agent
.scrollback
.latest_turn_marker_accepting(&event_name, batch_prompt_id.as_deref())
{
agent.scrollback.attach_stop_hooks_to_marker(
entry_id,
event_name,
hook_entries,
batch_prompt_id.as_deref(),
);
} else if batch_is_wake
&& !batch_prompt_id
.as_deref()
.is_some_and(|pid| agent.scrollback.has_turn_terminal_marker_with_pid(pid))
{
stash_live_stop_batch(
agent,
batch_prompt_id.clone(),
event_name,
hook_entries,
true,
);
} else {
agent
.scrollback
.push_lifecycle_hooks(event_name, hook_entries);
}
} else {
agent
.scrollback
.push_lifecycle_hooks(event_name, hook_entries);
}
true
}
XaiSessionUpdate::HooksChanged {
hooks,
project_trusted,
load_errors,
} => {
if let Some(ref mut modal) = agent.extensions_modal {
use crate::views::extensions_modal::TabDataState;
modal.hooks_data =
TabDataState::Loaded(kigi_hooks_plugins_types::HooksListResponse {
hooks,
project_trusted,
load_errors,
});
true
} else {
false
}
}
XaiSessionUpdate::PluginsChanged { plugins } => {
if let Some(ref mut modal) = agent.extensions_modal {
use crate::views::extensions_modal::TabDataState;
modal.seed_plugin_groups_once(&plugins);
modal.plugins_data =
TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins });
if !matches!(modal.skills_data, TabDataState::Loading) {
modal.skills_data = TabDataState::Loading;
plugins_changed_needs_skills_refetch = true;
}
true
} else {
false
}
}
XaiSessionUpdate::SessionSummaryGenerated { session_summary } => {
agent.generated_session_title =
Some(crate::util::decode_html_entities(&session_summary).into_owned());
true
}
XaiSessionUpdate::SessionRecap { summary, auto } => {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
if should_drop_late_auto_recap(auto, meta.is_replay, agent.session.state.is_idle()) {
tracing::debug!(
"dropping late auto SessionRecap; agent busy (turn or command in flight)"
);
false
} else {
app.notification_service.focus_tracker.mark_recap_shown();
let recap_block = RenderBlock::session_event(SessionEvent::Recap { summary, auto });
apply_recap_block(agent, auto, recap_block);
true
}
}
XaiSessionUpdate::SessionRecapUnavailable => {
if meta.is_replay {
false
} else if let Some(pending_id) = agent.pending_recap_entry.take() {
agent.scrollback.remove_entry(pending_id);
agent.show_toast(crate::app::dispatch::recap_unavailable_toast(
crate::app::dispatch::scrollback_has_user_messages(&agent.scrollback),
));
true
} else {
false
}
}
XaiSessionUpdate::ModelAutoSwitched {
previous_model_id,
new_model_id,
reason,
} => {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
let available_count = agent.session.models.available.len();
let available_keys: Vec<&str> = agent
.session
.models
.available
.keys()
.take(10)
.map(|m| m.0.as_ref())
.collect();
tracing::warn!(
session_id = session_notif.session_id.0.as_ref(), previous = %
previous_model_id, new = % new_model_id, available_count, available_keys
= ? available_keys,
"Model auto-switched: previous model no longer available"
);
crate::unified_log::warn(
"model auto-switched: previous model unavailable",
Some(session_notif.session_id.0.as_ref()),
Some(serde_json::json!(
{ "previous_model" : previous_model_id.as_str(), "new_model" :
new_model_id.as_str(), "available_count" : available_count,
"available_keys" : available_keys, }
)),
);
agent.scrollback.push_block(RenderBlock::session_event(
SessionEvent::ModelUnavailable {
previous_model_id,
new_model_id,
reason,
},
));
true
}
XaiSessionUpdate::ModelChanged {
model_id,
reasoning_effort,
} => {
if agent.session.model_switch_pending {
tracing::debug!(
session_id = session_notif.session_id.0.as_ref(), model_id = %
model_id,
"ignoring ModelChanged broadcast — local switch is in flight"
);
return false;
}
use kigi_shell::sampling::types::ReasoningEffort;
let new_model_id = acp::ModelId::new(model_id.clone());
if !agent.session.models.available.contains_key(&new_model_id) {
if kigi_shell::agent::chat_modes::process_chat_mode_enabled() {
agent.session.models.available.insert(
new_model_id.clone(),
acp::ModelInfo::new(new_model_id.clone(), model_id.clone()),
);
} else {
tracing::warn!(
session_id = session_notif.session_id.0.as_ref(), model_id = %
model_id,
"ignoring ModelChanged broadcast — model not in local catalog"
);
return false;
}
}
let effort = reasoning_effort
.as_deref()
.and_then(|s| s.parse::<ReasoningEffort>().ok());
let prev_model = agent.session.models.current.clone();
let prev_effort = agent.session.models.reasoning_effort;
agent
.session
.models
.set_current(new_model_id.clone(), effort);
agent.session.user_model_preference = Some(new_model_id.clone());
let resolved_effort = agent.session.models.reasoning_effort;
let actually_changed =
prev_model.as_ref() != Some(&new_model_id) || prev_effort != resolved_effort;
if actually_changed {
tracing::info!(
session_id = session_notif.session_id.0.as_ref(), model_id = %
model_id, effort = ? resolved_effort,
"ModelChanged broadcast applied (remote switch)"
);
}
actually_changed
}
XaiSessionUpdate::MemoryFiles { files } => {
let entries = crate::views::memory_modal::build_entries(files);
let modal_state = crate::views::memory_modal::MemoryModalState::new(entries);
agent.active_modal = Some(crate::views::modal::ActiveModal::MemoryBrowser {
state: Box::new(modal_state),
});
true
}
XaiSessionUpdate::GoalUpdated {
goal_id,
objective,
status,
phase,
token_budget,
tokens_used,
elapsed_ms,
total_deliverables,
completed_deliverables,
current_deliverable_id,
current_deliverable_title,
current_subagent_role,
total_worker_rounds,
total_verify_rounds,
token_baseline,
finished_subagent_tokens,
live_subagent_tokens,
live_tokens_by_model,
live_context_pct,
live_turn_count,
live_tool_call_count,
last_event,
last_event_detail,
last_event_timestamp,
pause_message,
classifier_runs_attempted,
classifier_max_runs,
last_classifier_verdict,
last_classifier_details_path,
verifying_completion,
planning,
..
} => {
let new_status = GoalDisplayStatus::parse(&status);
let just_completed = new_status == GoalDisplayStatus::Complete
&& agent
.goal_state
.as_ref()
.is_none_or(|g| g.status != GoalDisplayStatus::Complete);
if status == "cleared" {
if let Some(g) = agent.goal_state.take() {
agent.last_cleared_goal_id = Some(g.goal_id);
}
agent.show_goal_detail = false;
true
} else if agent.last_cleared_goal_id.as_deref() == Some(goal_id.as_str()) {
false
} else {
let elapsed_floor_ms = agent
.goal_state
.as_ref()
.filter(|g| g.goal_id == goal_id)
.map(|g| g.live_elapsed_ms())
.unwrap_or(0)
.max(elapsed_ms);
if just_completed {
agent.scrollback.push_block(RenderBlock::session_event(
SessionEvent::GoalCompleted {
elapsed: std::time::Duration::from_millis(elapsed_floor_ms),
},
));
}
let last_classifier_details_exists = last_classifier_details_path
.as_deref()
.is_some_and(|p| std::path::Path::new(p).exists());
agent.goal_state = Some(GoalDisplayState {
goal_id,
objective,
status: new_status,
phase: GoalDisplayPhase::parse(&phase),
token_budget,
tokens_used,
elapsed_ms,
total_deliverables,
completed_deliverables,
current_deliverable_id,
current_deliverable_title,
current_subagent_role,
total_worker_rounds,
total_verify_rounds,
live_subagent_tokens,
live_tokens_by_model,
live_context_pct,
live_turn_count,
live_tool_call_count,
last_event,
last_event_detail,
last_event_timestamp,
token_baseline,
finished_subagent_tokens,
deliverables: Vec::new(),
pause_message,
classifier_runs_attempted,
classifier_max_runs,
last_classifier_verdict,
last_classifier_details_path,
last_classifier_details_exists,
verifying_completion: verifying_completion.unwrap_or(false),
planning: planning.unwrap_or(false),
received_at: std::time::Instant::now(),
elapsed_floor_ms,
});
true
}
}
XaiSessionUpdate::InteractionResolved { tool_call_id } => {
agent.dismiss_resolved_interaction(&tool_call_id)
}
_ => {
tracing::trace!(
"Ignoring {}: {:?}",
notif.method.as_ref(),
std::mem::discriminant(&session_notif.update)
);
return false;
}
};
if plugins_changed_needs_skills_refetch {
if let Some(agent) = app.agents.get(&parent_id)
&& let Some(session_id) = agent.session.session_id.clone()
{
app.pending_effects.push(Effect::FetchSkillsList {
agent_id: parent_id,
session_id,
});
} else if let Some(agent) = app.agents.get_mut(&parent_id)
&& let Some(ref mut modal) = agent.extensions_modal
{
modal.skills_data =
crate::views::extensions_modal::TabDataState::Error("No active session".into());
} else {
tracing::warn!("PluginsChanged: agent or modal disappeared before skills re-fetch");
}
}
if let Some(agent) = app.agents.get_mut(&parent_id) {
if let Some(seq) = meta.event_seq
&& !meta.is_replay
{
agent.last_applied_xai_event_seq = Some(seq);
}
if let Some(id) = meta.event_id {
agent.last_seen_event_id = Some(id);
}
}
if let Some(outcome) = terminal_outcome {
return super::super::turn_completion::apply_terminal_outcome(
outcome, app, parent_id, is_active,
);
}
changed && is_active
}
/// Handle an xAI session notification that targets a child (subagent) session.
///
/// Events like compaction, retry, and memory flush are emitted by the child's
/// `acp_session` with the *child's* `session_id`. This routes them to the
/// correct child view and updates `SubagentInfo` where appropriate.
pub(super) fn handle_child_session_notification(
update: XaiSessionUpdate,
child_sid: &str,
agent: &mut AgentView,
is_api_key_auth: bool,
) -> bool {
match update {
XaiSessionUpdate::AutoCompactStarted { .. }
| XaiSessionUpdate::AutoCompactCompleted { .. }
| XaiSessionUpdate::AutoCompactFailed { .. }
| XaiSessionUpdate::AutoCompactCancelled { .. }
| XaiSessionUpdate::RetryState(_) => {
let compact_tokens = match &update {
XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } => Some(*tokens_after),
_ => None,
};
let mut changed = false;
if let Some(child_view) = agent.subagent_views.get_mut(child_sid) {
changed = apply_session_event(
&update,
&mut child_view.session,
&mut child_view.scrollback,
is_api_key_auth,
);
if let Some(tokens_after) = compact_tokens {
refresh_context_used(child_view, tokens_after);
}
}
if let Some(tokens_after) = compact_tokens
&& let Some(info) = agent.subagent_sessions.get_mut(child_sid)
{
info.tokens_used = Some(tokens_after);
if let Some(cw) = info.context_window_tokens.filter(|&cw| cw > 0) {
info.context_usage_pct =
Some(kigi_token_estimation::usage_percentage_u8(tokens_after, cw));
}
}
changed
}
ref update @ (XaiSessionUpdate::MemoryFlushCompleted { .. }
| XaiSessionUpdate::MemoryDreamCompleted { .. }
| XaiSessionUpdate::MemorySessionSaved { .. }) => {
if let Some(child_view) = agent.subagent_views.get_mut(child_sid) {
apply_session_event(
update,
&mut child_view.session,
&mut child_view.scrollback,
is_api_key_auth,
)
} else {
false
}
}
_ => false,
}
}
/// Apply a compaction or retry event to a session's activity state and scrollback.
///
/// Shared between the root agent and child (subagent) notification paths.
/// Test-only shim so dispatch-level tests can replay real notification
/// sequences (e.g. `RetryState::Retrying` → `Exhausted`) through the
/// production handler — the Retrying arm clears the `in_flight_prompt`
/// rewind stash, which a fixture setting fields directly would miss.
#[cfg(test)]
pub(crate) fn apply_session_event_for_test(
update: &XaiSessionUpdate,
session: &mut AgentSession,
scrollback: &mut crate::scrollback::state::ScrollbackState,
) -> bool {
apply_session_event(update, session, scrollback, false)
}
pub(super) fn apply_session_event(
update: &XaiSessionUpdate,
session: &mut AgentSession,
scrollback: &mut crate::scrollback::state::ScrollbackState,
is_api_key_auth: bool,
) -> bool {
match update {
XaiSessionUpdate::AutoCompactStarted { percentage, .. } => {
tracing::info!("Auto-compact started: {percentage}% context used");
session.in_flight_prompt = None;
session.set_compaction_activity(Some(TurnActivity::AutoCompacting));
scrollback.push_block(RenderBlock::session_event(
SessionEvent::CompactionStarted {
percentage: *percentage,
},
));
true
}
XaiSessionUpdate::AutoCompactCompleted {
tokens_before,
tokens_after,
elapsed_ms,
..
} => {
tracing::info!("Auto-compact completed: {tokens_after} tokens after");
session.set_compaction_activity(None);
if session.loading_replay {
scrollback.push_block(RenderBlock::session_event(
SessionEvent::CompactionCompleted {
tokens_before: *tokens_before,
tokens_after: *tokens_after,
elapsed_ms: *elapsed_ms,
},
));
} else {
session.defer_compaction(*tokens_before, *tokens_after, *elapsed_ms);
}
true
}
XaiSessionUpdate::AutoCompactFailed { error } => {
tracing::error!(error = % error, "Auto-compaction failed");
session.set_compaction_activity(None);
scrollback.push_block(RenderBlock::session_event(SessionEvent::CompactionFailed {
error: error.clone(),
}));
true
}
XaiSessionUpdate::AutoCompactCancelled { .. } => {
tracing::info!("Auto-compact cancelled");
session.set_compaction_activity(None);
scrollback.push_block(RenderBlock::session_event(
SessionEvent::CompactionCancelled,
));
true
}
XaiSessionUpdate::RetryState(retry) => {
tracing::debug!("Retry state: {retry:?}");
apply_retry_state(retry, session, scrollback, is_api_key_auth);
true
}
XaiSessionUpdate::ImageDropped { notes } => {
let message = notes.join("\n");
tracing::info!("Image dropped: {message}");
scrollback.push_block(RenderBlock::system(message));
true
}
_ => false,
}
}
/// True if the trailing run of session/system blocks contains a
/// [`SessionEvent::CompactionFailed`]. Used so we don't stack a [`SessionEvent::ContextTooLarge`]
/// prompt on top of the compaction handler's "too large to compact" message.
pub(super) fn scrollback_has_recent_compaction_failed(
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
use crate::scrollback::block::RenderBlock;
for idx in (0..scrollback.len()).rev() {
match scrollback.entry(idx).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(ev)) => {
if matches!(ev.event, SessionEvent::CompactionFailed { .. }) {
return true;
}
}
Some(RenderBlock::System(_)) => {}
_ => break,
}
}
false
}
/// Handle an `ImageCompressed` notification. A successful compression is
/// deliberately invisible in the TUI (log-only): it needs no user action,
/// and the model-facing `<image_compression_notice>` reminder is attached
/// to the prompt independently. Only the re-encode *fallback* — the
/// oversized original was KEPT — surfaces, as a persistent scrollback
/// warning (and is re-materialized on session replay).
pub(super) fn apply_image_compressed(
agent: &mut AgentView,
images: &[kigi_shell::extensions::notification::ImageCompressedEntry],
message: &str,
) -> bool {
if images.is_empty() {
tracing::warn!("Image re-encode fallback: {message}");
agent
.scrollback
.push_block(RenderBlock::system(message.to_owned()));
return true;
}
tracing::info!("Image compressed: {message}");
false
}
pub(super) fn apply_retry_state(
retry: &kigi_shell::extensions::notification::RetryState,
session: &mut AgentSession,
scrollback: &mut crate::scrollback::state::ScrollbackState,
is_api_key_auth: bool,
) {
let mut is_credit_limit = false;
let mut is_reauth = false;
use kigi_shell::extensions::notification::RetryState;
match retry {
RetryState::Retrying {
attempt,
max_retries,
reason,
} => {
session.set_retry_activity(Some(TurnActivity::Retrying {
attempt: *attempt,
max_retries: *max_retries,
reason: reason.clone(),
}));
}
RetryState::Exhausted {
attempts,
reason,
is_rate_limited: rate_limited,
} => {
session.set_retry_activity(None);
session.rate_limited = *rate_limited;
is_credit_limit = super::super::dispatch::is_credit_limit_error(None, reason);
let is_free_usage =
*rate_limited && super::super::dispatch::is_free_usage_exhausted_error(reason);
if is_credit_limit {
session.credit_limit_blocked = true;
} else if is_free_usage {
session.free_usage_blocked = true;
} else if !*rate_limited && is_reauthable_failure(None, reason) {
is_reauth = true;
scrollback.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
} else {
let error = if *rate_limited {
rate_limited_user_message(is_api_key_auth).into()
} else {
format!("failed after {attempts} retries: {reason}")
};
scrollback.push_block(RenderBlock::session_event(SessionEvent::RetryFailed {
error,
error_type: None,
}));
}
}
RetryState::Failed {
error_type,
message,
} => {
session.set_retry_activity(None);
if error_type == "encrypted_content_mismatch" {
session.model_incompatible = true;
}
is_credit_limit = super::super::dispatch::is_credit_limit_error(None, message);
if is_credit_limit {
session.credit_limit_blocked = true;
} else if is_reauthable_failure(Some(error_type.as_str()), message) {
is_reauth = true;
scrollback.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
} else if error_type == "context_length" {
if !scrollback_has_recent_compaction_failed(scrollback) {
scrollback
.push_block(RenderBlock::session_event(SessionEvent::ContextTooLarge));
}
} else {
scrollback.push_block(RenderBlock::session_event(SessionEvent::RetryFailed {
error: message.clone(),
error_type: Some(error_type.clone()),
}));
}
}
}
if is_credit_limit {
} else if !is_reauth {
session.in_flight_prompt = None;
}
}
/// Single source of truth for plan-mode state on the pager side.
///
/// The agent emits `CurrentModeUpdate` on every entry and exit — both for
/// user-driven mode switches (Shift+Tab → `session/set_mode`) and for
/// agent-driven `EnterPlanMode` / `ExitPlanMode` tool calls (mapped by the
/// notification bridge).
///
/// Do not be tempted to infer mode from tool-call titles: titles incorporate
/// raw model/user input (Grep pattern, Bash command, search query, ...), so
/// a substring match silently bricks sessions whenever any tool happens to
/// mention `enter_plan_mode`.
///
/// Returns `true` when a `CurrentModeUpdate` was processed so the
/// caller can refresh open settings modals after the per-agent borrow
/// releases.
pub(super) fn detect_plan_mode_change(update: &acp::SessionUpdate, agent: &mut AgentView) -> bool {
use kigi_tools::types::SessionMode;
let acp::SessionUpdate::CurrentModeUpdate(cmu) = update else {
return false;
};
let mode = SessionMode::from_id(cmu.current_mode_id.0.as_ref());
let was_active = agent.plan_mode_active;
let now_active = mode.is_plan();
agent.plan_mode_active = now_active;
agent.plan_mode_pending = None;
if was_active != now_active {
tracing::info!(
mode_id = % cmu.current_mode_id.0, plan_active = now_active,
"Plan mode state updated (from CurrentModeUpdate)"
);
}
true
}