Files
Kigi-CLI/crates/codegen/kigi-tui/src/acp/tracker.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

6533 lines
271 KiB
Rust

//! AcpUpdateTracker — converts ACP SessionUpdate events into scrollback mutations.
//!
//! This is a stateful streaming machine: it tracks which entries are currently
//! being streamed to (agent message, thinking) and which tool calls are pending.
//! Each `handle_update()` call processes one event and mutates the scrollback.
use crate::acp::meta::{NotificationMeta, user_message_chunk_meta, user_prompt_meta};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
use crate::scrollback::blocks::tool::list_dir::ListDirToolCallBlock;
use crate::scrollback::blocks::tool::search::{
SearchFileMatch, SearchInputMeta, SearchLineMatch, SearchOutputMode, SearchToolCallBlock,
};
use crate::scrollback::blocks::tool::{
DiscoveredTool, EditHighlightPhase, EditToolCallBlock, ExecuteToolCallBlock,
IntegrationSearchToolCallBlock, LineRange, MemorySearchToolCallBlock, OtherToolCallBlock,
ReadMediaKind, ReadToolCallBlock, ToolCallBlock, UseToolCallBlock, WebFetchToolCallBlock,
WebSearchToolCallBlock,
};
use crate::scrollback::entry::{EntryId, ScrollbackEntry};
use crate::scrollback::state::ScrollbackState;
use crate::scrollback::state::verb_group::verb_group_kind_changed;
use agent_client_protocol as acp;
use chrono::{DateTime, Local, TimeZone};
use kigi_tools::types::output::{BashOutput, ToolOutput};
use kigi_tools::types::output::{ReadFileOutput, SearchToolOutput, WebFetchOutput};
use kigi_tools::util::strip_redundant_session_cd;
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;
/// Convert a UTC millisecond timestamp to local time.
fn utc_ms_to_local(ms: i64) -> DateTime<Local> {
chrono::Utc
.timestamp_millis_opt(ms)
.single()
.map(|utc| utc.with_timezone(&Local))
.unwrap_or_else(Local::now)
}
/// What the agent is currently doing within a turn.
///
/// Derived from the tracker's internal state by [`AcpUpdateTracker::activity()`].
/// Used by the turn status line widget to show context-appropriate indicators.
///
/// Note: `Idle` here means "the tracker has no in-flight work". The caller
/// should check `TurnState` to distinguish true idle (no turn) from waiting
/// (turn started, but no chunks received yet).
/// Why a turn is open but nothing is streaming right now.
///
/// Replaces the old single, opaque "Waiting…" placeholder: instead of treating
/// the absence of activity as one undifferentiated state, the turn-status line
/// names *what* the agent is blocked on. Resolved partly by the tracker (the
/// blocking tool waits it suppresses — see [`AcpUpdateTracker::activity`]) and
/// partly at the view boundary (`Model`/`Subagent`, which need turn-state and
/// the subagent registry the tracker doesn't own).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WaitingReason {
/// Waiting for the model to (re)start streaming — the first token after the
/// prompt is sent, or the gap after a tool completes before the next
/// inference step begins.
Model,
/// Blocked on a running foreground subagent (`task` / `spawn_subagent`).
Subagent,
/// Blocked polling/awaiting a background task's output
/// (`get_command_or_subagent_output` / `get_task_output`).
///
/// `task_ids` come from the tool's `raw_input` (empty until it arrives).
/// `subject` is an optional display name (description preferred, else
/// command) filled in by the view from live task state — the tracker
/// itself always leaves it `None`.
TaskOutput {
task_ids: Vec<String>,
subject: Option<String>,
/// True when the call blocks (`timeout_ms > 0` in raw_input); an
/// instant poll (0/missing) can't be shortened by interjecting.
/// Defaults to false until raw_input arrives.
waits: bool,
},
/// Blocked until one or more background tasks finish
/// (`wait_commands_or_subagents` / `wait_tasks`).
TasksComplete,
/// Explicit sleep / await (`Await` / `Sleep …`).
Sleep,
}
/// Max chars for wait/tool *description* subjects in status UI (matches
/// tool-title truncation in `format_activity_label`).
pub const MAX_ACTIVITY_SUBJECT_CHARS: usize = 40;
/// First non-empty trimmed line, clamped to [`MAX_ACTIVITY_SUBJECT_CHARS`].
pub fn clamp_activity_subject(s: &str) -> String {
let line = s
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or_else(|| s.trim());
if line.chars().count() <= MAX_ACTIVITY_SUBJECT_CHARS {
line.to_string()
} else {
line.chars().take(MAX_ACTIVITY_SUBJECT_CHARS).collect()
}
}
/// Shared in-progress subject label (clamped description/command) used by
/// turn-status, title bar, and dashboard/subagent activity columns.
///
/// Renders as `{subject}…` — no "Waiting for" prefix or quotes — so a
/// description like `Wait 5 seconds` reads cleanly next to the spinner.
pub fn format_waiting_for_subject(subject: &str) -> String {
let clamped = clamp_activity_subject(subject);
if clamped.is_empty() {
"Waiting on task output…".to_string()
} else {
format!("{clamped}…")
}
}
impl WaitingReason {
/// Unit constructor for a task-output wait with no known ids/subject yet.
/// A known-blocking task-output wait (the only kind `activity()` shows).
pub fn task_output() -> Self {
Self::TaskOutput {
task_ids: Vec::new(),
subject: None,
waits: true,
}
}
/// User-facing spinner label.
pub fn label(&self) -> String {
match self {
Self::Model => "Waiting for response…".to_string(),
Self::Subagent => "Waiting on subagent…".to_string(),
Self::TaskOutput {
subject: Some(subject),
..
} => format_waiting_for_subject(subject),
Self::TaskOutput { .. } => "Waiting on task output…".to_string(),
Self::TasksComplete => "Waiting on tasks…".to_string(),
Self::Sleep => "Sleeping…".to_string(),
}
}
/// Short, stable snake_case label for telemetry / phase-transition logs.
pub fn as_telemetry_label(&self) -> &'static str {
match self {
Self::Model => "waiting_model",
Self::Subagent => "waiting_subagent",
Self::TaskOutput { .. } => "waiting_task_output",
Self::TasksComplete => "waiting_tasks_complete",
Self::Sleep => "waiting_sleep",
}
}
}
/// A suppressed blocking tool's wait, tagged with the stream it was registered
/// under (drives `drop_stale_blocking_waits`).
#[derive(Debug, Clone)]
struct BlockingWait {
reason: WaitingReason,
stream_start_ms: Option<i64>,
}
/// `strings`-greppable marker proving a binary carries this fix (kept by `#[used]`).
#[used]
static PAGER_IMPL_WAIT_STATUS_MIDTURN: &str = "PAGER_IMPL_wait_status_midturn";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnActivity {
/// Agent is streaming thinking/chain-of-thought content.
Thinking,
/// Agent is streaming response text.
Responding,
/// A tool is executing.
ToolRunning {
/// Tool title (e.g., command name, file path). Used for `Run …`
/// when no human description is available.
title: String,
/// Optional human description from tool input (e.g. bash
/// `description`). Prefer this over `Run <command>` when set
/// (renders as `{desc}…`).
description: Option<String>,
},
/// Auto-compaction in progress (mid-turn, agent-initiated).
AutoCompacting,
/// A retry is in progress (transient error, empty response, etc.).
Retrying {
/// Current retry attempt number (1-indexed).
attempt: u32,
/// Maximum number of retries allowed.
max_retries: u32,
/// Human-readable reason for the retry.
reason: String,
},
/// Turn is open but nothing is streaming; `reason` says what we're waiting
/// on. Replaces the implicit "no activity == generic Waiting…" placeholder.
Waiting(WaitingReason),
}
impl TurnActivity {
/// Short, stable label for telemetry / profiling logs.
pub fn as_label(&self) -> &'static str {
match self {
Self::Thinking => "thinking",
Self::Responding => "responding",
Self::ToolRunning { .. } => "tool_running",
Self::AutoCompacting => "compacting",
Self::Retrying { .. } => "retrying",
Self::Waiting(reason) => reason.as_telemetry_label(),
}
}
}
#[derive(Debug, Clone)]
pub struct PendingCompaction {
pub tokens_before: Option<u64>,
pub estimate_after: u64,
pub elapsed_ms: Option<i64>,
pub last_used: Option<u64>,
}
/// Tracks in-flight streaming state for one agent's turn.
///
/// Converts ACP `SessionUpdate` variants into scrollback entry mutations.
/// Does nothing else — no UI, no networking, just data transformation.
#[derive(Debug, Default)]
pub struct AcpUpdateTracker {
/// Entry currently receiving AgentMessageChunk deltas.
/// None between turns or before first message chunk.
current_agent_msg: Option<EntryId>,
/// Entry currently receiving AgentThoughtChunk deltas.
/// None when agent isn't thinking.
current_thinking: Option<EntryId>,
/// Tool calls in flight, keyed by ACP tool call ID string.
/// Stores the base ToolCall for field merging with ToolCallUpdate.
pending_tools: HashMap<String, PendingTool>,
/// ToolCallUpdates that arrived before their ToolCall (race condition).
/// When the ToolCall arrives, we merge and create the entry immediately
/// as completed.
orphan_updates: HashMap<String, acp::ToolCallUpdate>,
/// Last computed thinking elapsed (ms) from server timestamps.
/// Updated on every thought chunk as `agentTimestampMs - streamStartMs`.
/// Frozen when thinking ends (passed to `finish_running_with_time`).
last_thinking_elapsed_ms: Option<i64>,
/// When true, the next UserMessageChunk will be silently ignored
/// because we already pushed the user prompt entry directly from
/// `dispatch_send_prompt`. Reset after one skip.
skip_next_user_echo: bool,
/// When true, the next UserMessageChunk is a skill body that follows
/// a skill metadata chunk. It should be silently absorbed so the
/// raw skill instructions don't appear in scrollback.
skip_next_skill_body: bool,
/// Tool call IDs suppressed from scrollback (e.g. TodoWrite).
/// Their ToolCallUpdate counterparts are silently dropped too.
suppressed_tools: std::collections::HashSet<String>,
/// Suppressed-but-blocking tool calls, keyed by tool-call ID → the reason
/// the turn is waiting. These tools (`get_command_or_subagent_output`,
/// `wait_tasks`, `Sleep`, …) are kept out of `pending_tools` (so they never
/// hit scrollback) but the turn *is* blocked on them — without this the
/// spinner falls back to a generic "Waiting…". Populated in
/// `handle_tool_call`, cleared on the suppressed tool's completion update
/// and in `finish_turn`.
blocking_waits: std::collections::HashMap<String, BlockingWait>,
/// Task tool `run_in_background` flags, keyed by `task_id` (subagent_id).
/// Populated when a task tool call is detected (variant == "Task"),
/// consumed by the acp_handler when `SubagentSpawned` arrives.
pub(crate) task_tool_background: std::collections::HashMap<String, bool>,
/// Tool call IDs marked as background (`is_background=true`).
///
/// First-detection (no scrollback entry yet): defers entry creation until
/// `x.ai/task_backgrounded` creates a `BgTask` block.
/// Late-detection (Execute block already exists): suppresses further output
/// streaming; the existing block is demoted by `handle_task_backgrounded`.
///
/// Value is the optional description from `raw_input.description`.
pub(crate) bg_deferred_tools: std::collections::HashMap<String, Option<String>>,
/// Last seen `stream_start_ms` from notification meta.
/// When this changes, a new LLM streaming response has started — we
/// finish any in-flight thinking/agent-message entries so the next
/// chunks create fresh ones instead of appending to stale entries.
last_stream_start_ms: Option<i64>,
/// Monotonic count of live parent-agent updates that changed scrollback.
agent_output_epoch: u64,
/// Session project cwd for display-only redundant-`cd` stripping.
/// Set from [`AgentSession::cwd`]; not used for execution.
session_cwd: Option<PathBuf>,
/// Compaction-related activity override.
/// Set by `set_compaction_activity()` from ExtNotification events,
/// cleared by `finish_turn()`.
compaction_activity: Option<TurnActivity>,
pending_compaction: Option<PendingCompaction>,
/// Retry-related activity override.
/// Set by `set_retry_activity()` from ExtNotification `RetryState::Retrying`,
/// auto-cleared when normal streaming data resumes (in `handle_update`)
/// and on `finish_turn()`.
retry_activity: Option<TurnActivity>,
/// Pending ACP commands from the most recent `AvailableCommandsUpdate`.
/// Consumed by the caller via `take_pending_acp_commands()`. The caller
/// is responsible for copying to `AgentSession.available_commands` and
/// bumping `available_commands_generation`.
pending_acp_commands: Option<Vec<acp::AvailableCommand>>,
/// Pending agent toolset from the most recent `AvailableCommandsUpdate.meta`.
/// Format on the wire: `{"tools": ["read_file", ...]}`.
/// `Some(_)` only if the shell included a tools list this round.
/// Consumed by the caller via `take_pending_acp_tools()`.
///
/// Invariant: drained synchronously by
/// `acp_handler::handle_session_notification` immediately after each
/// `handle_update` call -- so this field never accumulates across
/// notifications. A meta-less follow-up update intentionally
/// preserves the previous `Some` (see the assignment in
/// `handle_update`) so a partial replay can't silently regress the
/// registry to the unknown-toolset state.
pending_acp_tools: Option<Vec<String>>,
/// Live Edit completions awaiting full-file HL (drained via [`Self::take_pending_edit_hl`]).
pending_edit_hl: Vec<EntryId>,
}
/// A tool call that's been started but not yet completed.
#[derive(Debug)]
struct PendingTool {
/// Scrollback entry ID, or None if the entry hasn't been created yet.
/// The entry is deferred until we receive the real tool kind from the
/// first in-progress update. The initial ToolCall message often has
/// kind=Other with no useful metadata — creating an entry from it
/// would show a wrong block type briefly before the real kind arrives.
entry_id: Option<EntryId>,
base: acp::ToolCall,
/// Streaming UTF-8 decoder for incremental bash output deltas.
utf8_decoder: Utf8Decoder,
/// Stashed `started_at` from eager creation. The eagerly-created block
/// is `ToolCallBlock::Other`; when the refinement arrives with the real
/// kind, `transfer_timing_from` can't cross variant boundaries
/// (Other → Search, etc.) and would silently drop the timing. This
/// field preserves the instant so `set_started_at` can apply it to
/// whatever variant the refined block becomes.
started_at: Option<std::time::Instant>,
}
/// Streaming UTF-8 decoder for incremental byte deltas.
///
/// When output is split at arbitrary byte offsets, a multi-byte UTF-8
/// character can land across two deltas. Without buffering, both halves
/// would be replaced with U+FFFD by `from_utf8_lossy`, permanently
/// corrupting the character.
///
/// This decoder buffers trailing incomplete bytes from each delta and
/// prepends them to the next one. Only genuinely invalid sequences
/// (not just incomplete ones at the end) produce U+FFFD.
#[derive(Debug, Default)]
struct Utf8Decoder {
/// Trailing bytes from the last delta that didn't form a complete
/// UTF-8 character. At most 3 bytes (max continuation length).
buffer: Vec<u8>,
/// Reusable output buffer — avoids allocating a new String per delta.
/// Cleared on each `decode()` call, grows to high-water mark and stays.
decoded: String,
}
impl Utf8Decoder {
/// Feed raw bytes and return the decoded string slice.
///
/// Any trailing incomplete UTF-8 sequence is held back in the internal
/// buffer and will be prepended to the next `decode()` call. Genuinely
/// invalid byte sequences produce U+FFFD.
///
/// The returned `&str` is valid until the next `decode()` call.
fn decode(&mut self, piece: &[u8]) -> &str {
self.decoded.clear();
self.buffer.extend_from_slice(piece);
let mut last_invalid_len = None;
for chunk in self.buffer.utf8_chunks() {
if let Some(prev) = last_invalid_len.replace(chunk.invalid().len())
&& prev > 0
{
self.decoded.push(char::REPLACEMENT_CHARACTER);
}
self.decoded.push_str(chunk.valid());
}
match last_invalid_len {
Some(0) => self.buffer.clear(),
Some(n) => {
let keep_from = self.buffer.len() - n;
self.buffer.drain(..keep_from);
}
None => self.buffer.clear(),
}
&self.decoded
}
}
impl AcpUpdateTracker {
pub fn new() -> Self {
Self::default()
}
/// Current boundary for visible live parent-agent output.
pub(crate) fn agent_output_epoch(&self) -> u64 {
self.agent_output_epoch
}
fn bump_agent_output_epoch(&mut self) {
self.agent_output_epoch = self.agent_output_epoch.wrapping_add(1);
}
/// Record session cwd used when stripping redundant `cd` prefixes in chrome.
/// No-op when the path is already stored (avoids cloning on every update).
pub fn set_session_cwd(&mut self, cwd: impl AsRef<Path>) {
let cwd = cwd.as_ref();
if self.session_cwd.as_deref() != Some(cwd) {
self.session_cwd = Some(cwd.to_path_buf());
}
}
/// Current activity within the turn, derived from in-flight state.
///
/// Priority order (highest first):
/// 1. External overrides: Retrying, AutoCompacting (from ExtNotification)
/// 2. Known-blocking wait (task output / wait / sleep / foreground
/// subagent) — outranks Thinking, ToolRunning, and Responding.
/// 3. Thinking (agent is in chain-of-thought)
/// 4. ToolRunning (a tool call is pending / executing)
/// 5. Responding (agent is streaming text)
/// 6. None (nothing in-flight; the view turns this into Waiting(Model) or
/// Waiting(Subagent) while a turn is running)
///
/// Retry and compaction states are set externally via
/// `set_retry_activity()` / `set_compaction_activity()` since they
/// come from ExtNotification, not from standard ACP SessionUpdate messages.
///
/// When [`Self::session_cwd`] is set, execute activity titles omit a leading
/// `cd <cwd> &&` / `;` that only restates the session working directory.
pub fn activity(&self) -> Option<TurnActivity> {
if self.retry_activity.is_some() {
return self.retry_activity.clone();
}
if self.compaction_activity.is_some() {
return self.compaction_activity.clone();
}
if let Some(waiting) = self.activity_known_blocking_wait() {
return Some(waiting);
}
if self.current_thinking.is_some() {
return Some(TurnActivity::Thinking);
}
if let Some(tool) = self.pending_tools.values().next() {
let description = tool
.base
.raw_input
.as_ref()
.and_then(|v| v.get("description"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(clamp_activity_subject);
let title = tool
.base
.raw_input
.as_ref()
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| tool.base.title.clone());
let title = peeled_if_changed(&title, self.session_cwd.as_deref()).unwrap_or(title);
return Some(TurnActivity::ToolRunning { title, description });
}
if self.current_agent_msg.is_some() {
return Some(TurnActivity::Responding);
}
None
}
/// Spinner activity for a suppressed blocking tool, or `None`. Instant
/// task-output polls (`timeout_ms` 0/missing) are excluded.
fn activity_known_blocking_wait(&self) -> Option<TurnActivity> {
let reason = self.blocking_wait()?;
if matches!(reason, WaitingReason::TaskOutput { waits: false, .. }) {
return None;
}
Some(TurnActivity::Waiting(reason))
}
/// Highest-priority blocking-tool wait currently in flight, if any.
///
/// `blocking_waits` is a map (non-deterministic iteration order), so
/// collapse it to a single reason by a fixed priority. In practice at most
/// one blocking tool runs at a time; the ordering only matters for the
/// degenerate multi-tool case.
fn blocking_wait(&self) -> Option<WaitingReason> {
self.blocking_waits
.values()
.min_by_key(|w| match &w.reason {
WaitingReason::TaskOutput { .. } => 0,
WaitingReason::TasksComplete => 1,
WaitingReason::Sleep => 2,
WaitingReason::Subagent => 3,
WaitingReason::Model => 4,
})
.map(|w| w.reason.clone())
}
/// Drop waits not registered under `current_stream` (stale earlier rounds,
/// or an unknown `None` stream); co-batched same-stream waits survive.
fn drop_stale_blocking_waits(&mut self, current_stream: Option<i64>) {
self.blocking_waits
.retain(|_, w| current_stream.is_some() && w.stream_start_ms == current_stream);
}
pub fn tool_title(&self, tool_call_id: &str) -> Option<&str> {
self.pending_tools
.get(tool_call_id)
.map(|pending| pending.base.title.as_str())
}
/// Get the scrollback entry_id for a pending tool by tool_call_id.
///
/// Used by demotion to find the execute block to swap.
pub fn pending_tool_entry_id(&self, tool_call_id: &str) -> Option<EntryId> {
self.pending_tools
.get(tool_call_id)
.and_then(|t| t.entry_id)
}
/// Remove a tool from pending_tools (for demotion swap).
///
/// Called when an execute block is being swapped to a BgTask block.
pub fn remove_pending_tool(&mut self, tool_call_id: &str) {
self.pending_tools.remove(tool_call_id);
}
/// Get the tool_call_id of the currently running Execute tool, if any.
///
/// Used by demotion (Ctrl-G) to know which tool to background.
/// Returns None if no Execute tool is currently pending.
pub fn running_execute_tool_call_id(&self) -> Option<&str> {
self.pending_tools
.iter()
.find(|(_, tool)| tool.base.kind == acp::ToolKind::Execute && tool.entry_id.is_some())
.map(|(id, _)| id.as_str())
}
/// Set a compaction-related activity override.
///
/// Called by the ACP handler when `ExtNotification` compaction events
/// arrive. Cleared automatically by `finish_turn()`.
pub fn set_compaction_activity(&mut self, activity: Option<TurnActivity>) {
self.compaction_activity = activity;
}
pub fn defer_compaction(
&mut self,
tokens_before: Option<u64>,
estimate_after: u64,
elapsed_ms: Option<i64>,
) {
self.pending_compaction = Some(PendingCompaction {
tokens_before,
estimate_after,
elapsed_ms,
last_used: None,
});
}
pub fn note_context_used(&mut self, used: u64) {
if let Some(pending) = self.pending_compaction.as_mut() {
pending.last_used = Some(used);
}
}
/// Set a retry-related activity override.
///
/// Called by the ACP handler when `ExtNotification` `RetryState::Retrying`
/// arrives. Auto-cleared when normal streaming data resumes (in
/// `handle_update`) and on `finish_turn()`.
pub fn set_retry_activity(&mut self, activity: Option<TurnActivity>) {
self.retry_activity = activity;
}
/// Take pending ACP commands, if any. Returns `None` if no update arrived
/// since the last drain.
///
/// The caller is the single drain site: it copies the commands to
/// `AgentSession.available_commands` and bumps the generation counter.
pub fn take_pending_acp_commands(&mut self) -> Option<Vec<acp::AvailableCommand>> {
self.pending_acp_commands.take()
}
/// Take the agent's most recently advertised tool list, if any.
///
/// Drained alongside `take_pending_acp_commands()` -- the same
/// `AvailableCommandsUpdate` carries both. `None` means the shell
/// didn't include a `meta.tools` field (older shell, or no update
/// since last drain).
pub fn take_pending_acp_tools(&mut self) -> Option<Vec<String>> {
self.pending_acp_tools.take()
}
/// Drain Edit entry ids that need a background full-file HL job.
pub fn take_pending_edit_hl(&mut self) -> Vec<EntryId> {
std::mem::take(&mut self.pending_edit_hl)
}
/// Whether `block` is a successful Edit with hunks (worth a full-file HL job).
fn edit_wants_file_hl(block: &RenderBlock) -> bool {
matches!(
block, RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) if edit.error
.is_none() && ! edit.hunks.is_empty()
)
}
/// Stash `entry_id` for live successful Edits with hunks. Skips replay
/// because a resume replays every historical edit at once — queueing them
/// would thundering-herd N full-file jobs — and replayed edits' files may
/// have changed on disk since, so the styles would not match the hunks.
fn queue_edit_hl_if_needed(&mut self, entry_id: EntryId, block: &RenderBlock, is_replay: bool) {
if !is_replay && Self::edit_wants_file_hl(block) {
self.pending_edit_hl.push(entry_id);
}
}
/// Push a completed tool block, queue its edit-HL upgrade if warranted, and
/// clear the running state — the shared tail of every completed-tool path.
/// Evaluates the predicate before `push_block` consumes the block, so the
/// entry needs no re-fetch.
///
/// The returned id may no longer be in the scrollback: a completed Edit
/// can coalesce into an adjacent earlier Edit of the same file.
fn finish_completed_tool(
&mut self,
block: RenderBlock,
scrollback: &mut ScrollbackState,
is_replay: bool,
) -> EntryId {
let wants_hl = Self::edit_wants_file_hl(&block);
let id = scrollback.push_block(block);
if !is_replay && wants_hl {
self.pending_edit_hl.push(id);
}
scrollback.finish_running(id);
self.try_coalesce_edit(id, scrollback, is_replay);
id
}
/// The Edit block of `entry` if it qualifies for coalescing with an
/// adjacent same-file Edit: completed successfully with hunks, a
/// trustworthy one-liner summary, and free of per-entry attachments a
/// merge would misplace.
fn coalescable_edit(entry: &ScrollbackEntry) -> Option<&EditToolCallBlock> {
if entry.is_running || entry.is_pending_user_input || entry.hook_data.is_some() {
return None;
}
let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block else {
return None;
};
(edit.error.is_none() && !edit.hunks.is_empty() && !edit.summary_untrusted).then_some(edit)
}
/// Whether the completed Edit entries `earlier` and `later` target the
/// same file and may merge into one block.
fn edits_can_merge(
&self,
scrollback: &ScrollbackState,
earlier: EntryId,
later: EntryId,
) -> bool {
if scrollback.is_committed(earlier) || scrollback.is_committed(later) {
return false;
}
let (Some(a), Some(b)) = (
scrollback
.get_by_id(earlier)
.and_then(Self::coalescable_edit),
scrollback.get_by_id(later).and_then(Self::coalescable_edit),
) else {
return false;
};
if a.prefix != b.prefix {
return false;
}
let cwd = self.session_cwd.as_deref();
let resolve = |p: &str| crate::render::tool_paths::resolve_tool_path_target(p, cwd);
match (resolve(&a.path), resolve(&b.path)) {
(Some(pa), Some(pb)) => pa == pb,
(None, None) => a.path == b.path,
_ => false,
}
}
/// Coalesce the just-completed Edit at `entry_id` with strictly adjacent
/// completed Edits of the same file, so back-to-back edits render as one
/// block with a summed diffstat. The earlier entry always survives.
///
/// Checks the previous neighbor (sequential completions) and the next one
/// (parallel calls can complete out of push order, so the pair only
/// becomes mergeable when the earlier call lands). Loops so runs of 3+
/// collapse pairwise.
///
/// Ingestion-time only: a later `collapsed_edit_blocks` flip never
/// merges or unmerges rows that already landed.
fn try_coalesce_edit(
&mut self,
entry_id: EntryId,
scrollback: &mut ScrollbackState,
is_replay: bool,
) {
if !crate::appearance::cache::load_collapsed_edit_blocks() {
return;
}
if scrollback
.get_by_id(entry_id)
.and_then(Self::coalescable_edit)
.is_none()
{
return;
}
let mut survivor = entry_id;
loop {
let Some(idx) = scrollback.index_of_id(survivor) else {
return;
};
let prev_id = idx
.checked_sub(1)
.and_then(|i| scrollback.get(i))
.map(|e| e.id);
if let Some(prev_id) = prev_id
&& self.edits_can_merge(scrollback, prev_id, survivor)
{
self.merge_edit_entries(prev_id, survivor, scrollback, is_replay);
survivor = prev_id;
continue;
}
let next_id = scrollback.get(idx + 1).map(|e| e.id);
if let Some(next_id) = next_id
&& self.edits_can_merge(scrollback, survivor, next_id)
{
self.merge_edit_entries(survivor, next_id, scrollback, is_replay);
continue;
}
return;
}
}
/// Append `removed`'s hunks onto `survivor` (the earlier entry) —
/// stitching overlapping/adjacent ones into unified hunks — and drop
/// `removed` from the scrollback and the edit-HL queue.
fn merge_edit_entries(
&mut self,
survivor: EntryId,
removed: EntryId,
scrollback: &mut ScrollbackState,
is_replay: bool,
) {
let (removed_hunks, removed_edit_count) =
match scrollback.get_by_id(removed).map(|e| &e.block) {
Some(RenderBlock::ToolCall(ToolCallBlock::Edit(edit))) => {
(edit.hunks.clone(), edit.edit_count)
}
_ => return,
};
if let Some(entry) = scrollback.get_by_id_mut(survivor) {
if let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &mut entry.block {
let merged_edit_count = edit.edit_count + removed_edit_count;
let mut hunks = std::mem::take(&mut edit.hunks);
hunks.extend(removed_hunks);
edit.set_hunks(crate::diff::stitch_overlapping_hunks(hunks));
edit.edit_count = merged_edit_count;
edit.highlight = EditHighlightPhase::HunkOnly;
}
entry.invalidate_cache();
}
scrollback.mark_structurally_dirty(survivor);
scrollback.remove_entry(removed);
self.pending_edit_hl.retain(|id| *id != removed);
if !is_replay && !self.pending_edit_hl.contains(&survivor) {
self.pending_edit_hl.push(survivor);
}
}
/// Process a single SessionUpdate, mutating the scrollback.
///
/// The `meta` carries server-side timestamps used for thinking elapsed time.
/// Returns true if the scrollback was modified (needs redraw).
pub fn handle_update(
&mut self,
update: acp::SessionUpdate,
meta: &NotificationMeta,
scrollback: &mut ScrollbackState,
) -> bool {
if !meta.is_replay {
debug!(
target : crate ::tracing::ACP_UPDATE_TARGET, "[acp] {} | {}",
update_summary(& update), meta_summary(meta),
);
}
if self.retry_activity.is_some() {
self.retry_activity = None;
}
if let Some(new_start) = meta.stream_start_ms {
if self
.last_stream_start_ms
.is_some_and(|prev| prev != new_start)
{
let thinking_has_content = self
.current_thinking
.and_then(|id| scrollback.get_by_id(id))
.is_some_and(|e| {
if let RenderBlock::Thinking(t) = &e.block {
!t.text().is_empty()
} else {
false
}
});
if thinking_has_content {
self.finish_thinking(scrollback);
}
if let Some(agent_id) = self.current_agent_msg.take() {
scrollback.finish_running(agent_id);
}
if !meta.is_replay
&& self.current_thinking.is_none()
&& self.activity_known_blocking_wait().is_none()
{
self.pre_create_thinking(scrollback);
}
}
self.last_stream_start_ms = Some(new_start);
}
let is_agent_output = matches!(
&update,
acp::SessionUpdate::AgentMessageChunk(_)
| acp::SessionUpdate::AgentThoughtChunk(_)
| acp::SessionUpdate::ToolCall(_)
| acp::SessionUpdate::ToolCallUpdate(_)
);
let changed = match update {
acp::SessionUpdate::AgentMessageChunk(chunk) => {
self.blocking_waits.clear();
self.handle_agent_chunk(chunk, meta, scrollback)
}
acp::SessionUpdate::AgentThoughtChunk(thought) => {
self.drop_stale_blocking_waits(meta.stream_start_ms);
self.handle_thought_chunk(thought, meta, scrollback)
}
acp::SessionUpdate::ToolCall(tc) => {
self.handle_tool_call(tc, scrollback, meta.is_replay)
}
acp::SessionUpdate::ToolCallUpdate(tcu) => {
self.handle_tool_call_update(tcu, scrollback, meta.is_replay)
}
acp::SessionUpdate::UserMessageChunk(chunk) => {
self.handle_user_message(chunk, meta, scrollback)
}
acp::SessionUpdate::AvailableCommandsUpdate(update) => {
if let Some(t) = parse_tools_meta(update.meta.as_ref()) {
self.pending_acp_tools = Some(t);
}
self.pending_acp_commands = Some(update.available_commands);
true
}
acp::SessionUpdate::Plan(_) | acp::SessionUpdate::CurrentModeUpdate(_) => false,
_ => false,
};
if is_agent_output && changed && !meta.is_replay {
self.bump_agent_output_epoch();
}
changed
}
/// Called when PromptResponse is received (turn complete).
pub fn finish_turn(&mut self, scrollback: &mut ScrollbackState) {
self.finish_thinking(scrollback);
if let Some(agent_id) = self.current_agent_msg.take() {
scrollback.finish_running(agent_id);
}
for (_, pending) in self.pending_tools.drain() {
if let Some(entry_id) = pending.entry_id {
scrollback.finish_running(entry_id);
}
}
if let Some(pending) = self.pending_compaction.take() {
scrollback.push_block(RenderBlock::session_event(
SessionEvent::CompactionCompleted {
tokens_before: pending.tokens_before,
tokens_after: pending.last_used.unwrap_or(pending.estimate_after),
elapsed_ms: pending.elapsed_ms,
},
));
}
self.last_thinking_elapsed_ms = None;
self.last_stream_start_ms = None;
self.compaction_activity = None;
self.retry_activity = None;
self.suppressed_tools.clear();
self.blocking_waits.clear();
self.skip_next_skill_body = false;
}
/// Finish the current thinking block, passing elapsed time to the entry.
///
/// Empty thinking blocks (pre-created but never received content) are
/// removed from scrollback — they'd show a misleading "Thought for 0.0s".
/// Only blocks that received actual thinking tokens are kept.
fn finish_thinking(&mut self, scrollback: &mut ScrollbackState) {
if let Some(thinking_id) = self.current_thinking.take() {
let is_empty = scrollback.get_by_id(thinking_id).is_some_and(
|e| matches!(& e.block, RenderBlock::Thinking(t) if t.text().is_empty()),
);
if is_empty {
scrollback.remove_entry(thinking_id);
} else {
scrollback.finish_running_with_time(thinking_id, self.last_thinking_elapsed_ms);
}
self.last_thinking_elapsed_ms = None;
}
}
/// Pre-create a thinking block so "Thinking…" appears immediately
/// when the turn starts, before the first ThinkingDelta arrives.
///
/// The tracker's `current_thinking` is set so subsequent ThinkingDelta
/// chunks append to this entry instead of creating a new one.
/// No-op when `show_thinking_blocks` is off.
pub fn pre_create_thinking(&mut self, scrollback: &mut ScrollbackState) {
if !crate::appearance::cache::load_show_thinking_blocks() {
return;
}
if self.current_thinking.is_none() {
let block = RenderBlock::thinking_streaming();
let entry_id = scrollback.push_block(block);
scrollback.set_last_running(true);
self.current_thinking = Some(entry_id);
}
}
/// Mark that the next UserMessageChunk should be silently dropped.
///
/// Call this from `dispatch_send_prompt` after pushing the user entry
/// directly, so the ACP echo doesn't produce a duplicate.
pub fn expect_user_echo(&mut self) {
self.skip_next_user_echo = true;
}
/// Reset stale skip state when no local user block was rendered, so the
/// agent's user-message broadcast is the one source of the user echo
/// (e.g. the synthetic cron/bash adoption path) instead of being dropped.
pub fn clear_user_echo_skip(&mut self) {
self.skip_next_user_echo = false;
self.skip_next_skill_body = false;
}
/// Whether [`expect_user_echo`] is pending (subagent replay tests).
#[cfg(test)]
pub fn expects_user_echo(&self) -> bool {
self.skip_next_user_echo
}
/// Handle an agent message chunk (streaming text).
fn handle_agent_chunk(
&mut self,
chunk: acp::ContentChunk,
meta: &NotificationMeta,
scrollback: &mut ScrollbackState,
) -> bool {
self.finish_thinking(scrollback);
let text = extract_text_from_content(&chunk.content);
if text.is_empty() {
return false;
}
if self.current_agent_msg.is_none() && text.trim().is_empty() {
tracing::warn!(
text = % text.escape_debug(),
"ignoring whitespace-only agent message chunk (no prior content)"
);
return false;
}
let is_new = self.current_agent_msg.is_none();
let id = *self.current_agent_msg.get_or_insert_with(|| {
let entry_id = scrollback.start_streaming_agent();
scrollback.set_last_running(true);
entry_id
});
if is_new
&& let Some(ts_ms) = meta.agent_timestamp_ms
&& let Some(entry) = scrollback.get_by_id_mut(id)
{
entry.created_at = Some(utc_ms_to_local(ts_ms));
}
if meta.is_replay {
scrollback.push_chunk_to_agent_deferred(id, &text)
} else {
scrollback.push_chunk_to_agent(id, &text)
}
}
/// Handle an agent thought chunk (streaming thinking).
fn handle_thought_chunk(
&mut self,
thought: acp::ContentChunk,
meta: &NotificationMeta,
scrollback: &mut ScrollbackState,
) -> bool {
if !crate::appearance::cache::load_show_thinking_blocks() {
return false;
}
let text = match &thought.content {
acp::ContentBlock::Text(t) => &t.text,
_ => return false,
};
if text.is_empty() {
return false;
}
let is_replay = meta.is_replay;
let id = *self.current_thinking.get_or_insert_with(|| {
let block = if is_replay {
RenderBlock::thinking_streaming_replay()
} else {
RenderBlock::thinking_streaming()
};
let entry_id = scrollback.push_block(block);
scrollback.set_last_running(true);
entry_id
});
if let (Some(agent_ts), Some(stream_start)) =
(meta.agent_timestamp_ms, meta.stream_start_ms)
{
self.last_thinking_elapsed_ms = Some(agent_ts - stream_start);
}
if meta.is_replay {
scrollback.push_chunk_to_thinking_deferred(id, text)
} else {
scrollback.push_chunk_to_thinking(id, text)
}
}
/// Handle a tool call start.
fn handle_tool_call(
&mut self,
tc: acp::ToolCall,
scrollback: &mut ScrollbackState,
is_replay: bool,
) -> bool {
self.finish_thinking(scrollback);
self.current_agent_msg = None;
if is_todo_tool(&tc)
|| is_goal_tool(&tc)
|| is_bg_plumbing_tool(&tc)
|| is_task_tool(&tc)
|| is_scheduler_tool(&tc)
{
if is_task_tool(&tc) {
let is_background = tc
.meta
.as_ref()
.and_then(|m| m.get("subagentBackground"))
.and_then(serde_json::Value::as_bool);
if is_background != Some(true) {
self.blocking_waits.insert(
tc.tool_call_id.0.to_string(),
BlockingWait {
reason: WaitingReason::Subagent,
stream_start_ms: self.last_stream_start_ms,
},
);
}
} else if let Some(reason) = blocking_wait_reason(&tc) {
self.blocking_waits.insert(
tc.tool_call_id.0.to_string(),
BlockingWait {
reason,
stream_start_ms: self.last_stream_start_ms,
},
);
}
self.suppressed_tools.insert(tc.tool_call_id.0.to_string());
return false;
}
let tc_id = tc.tool_call_id.0.to_string();
if let Some(orphan) = self.orphan_updates.remove(&tc_id) {
let merged = merge_tool_call_update(tc, orphan);
let block = tool_call_to_block(&merged, self.session_cwd.as_deref());
self.finish_completed_tool(block, scrollback, is_replay);
return true;
}
let is_completed = matches!(
tc.status,
acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed
);
if is_completed {
let block = tool_call_to_block(&tc, self.session_cwd.as_deref());
self.finish_completed_tool(block, scrollback, is_replay);
} else {
let block = tool_call_to_block(&tc, self.session_cwd.as_deref());
let id = scrollback.push_block(block);
scrollback.set_last_running(true);
let started_at = Some(std::time::Instant::now());
self.pending_tools.insert(
tc_id,
PendingTool {
entry_id: Some(id),
base: tc,
utf8_decoder: Utf8Decoder::default(),
started_at,
},
);
}
true
}
/// Handle a tool call update (streaming output or completion).
fn handle_tool_call_update(
&mut self,
tcu: acp::ToolCallUpdate,
scrollback: &mut ScrollbackState,
is_replay: bool,
) -> bool {
let tc_id_str = tcu.tool_call_id.0.to_string();
if self.bg_deferred_tools.contains_key(&tc_id_str) {
return false;
}
if self.suppressed_tools.contains(&tc_id_str) {
if let Some(ref raw_input) = tcu.fields.raw_input {
let variant = raw_input.get("variant").and_then(|v| v.as_str());
if is_task_variant(variant) {
let run_in_bg = raw_input
.get("run_in_background")
.or_else(|| raw_input.get("background"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
if variant == Some("Task")
&& let Some(task_id) = raw_input.get("task_id").and_then(|v| v.as_str())
{
self.task_tool_background
.insert(task_id.to_string(), run_in_bg);
}
if run_in_bg {
self.blocking_waits.remove(&tc_id_str);
}
}
if let Some(WaitingReason::TaskOutput {
task_ids, waits, ..
}) = self
.blocking_waits
.get_mut(&tc_id_str)
.map(|w| &mut w.reason)
{
let extracted = task_ids_from_raw_input(raw_input);
if !extracted.is_empty() {
*task_ids = extracted;
}
if raw_input.get("timeout_ms").is_some() {
*waits = timeout_waits(Some(raw_input));
}
}
}
let status = tcu.fields.status.unwrap_or_default();
if matches!(
status,
acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed
) {
self.suppressed_tools.remove(&tc_id_str);
self.blocking_waits.remove(&tc_id_str);
}
return false;
}
let status = tcu.fields.status.unwrap_or_default();
let is_completed = matches!(
status,
acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed
);
let tc_id = tcu.tool_call_id.0.to_string();
if !is_completed {
let mut deferred_visible_change = false;
let defer_as_bg = if let Some(pending) = self.pending_tools.get_mut(&tc_id) {
let bash_output = extract_bash_output_from_value(&tcu.fields.raw_output);
pending.base.update(tcu.fields);
if pending.entry_id.is_none() && is_bg_tool(&pending.base) {
let desc = extract_raw_field(&pending.base, "description");
Some((tc_id.clone(), desc, false))
} else if pending.entry_id.is_some() && is_bg_tool(&pending.base) {
let eid = pending.entry_id;
let has_real_command = raw_input_command(&pending.base).is_some();
let entry_placeholder = eid
.and_then(|id| scrollback.get_by_id(id))
.is_some_and(entry_is_execute_placeholder);
let drop_placeholder = entry_placeholder && !has_real_command;
let desc = extract_raw_field(&pending.base, "description");
if drop_placeholder {
deferred_visible_change = pending
.entry_id
.take()
.is_some_and(|id| scrollback.remove_entry(id));
Some((tc_id.clone(), desc, false))
} else {
if let Some(entry_id) = pending.entry_id {
let mut block =
tool_call_to_block(&pending.base, self.session_cwd.as_deref());
let mut kind_changed = false;
if let Some(entry) = scrollback.get_by_id_mut(entry_id) {
if let RenderBlock::ToolCall(new_tc) = &mut block
&& let Some(t) = pending.started_at
{
new_tc.set_started_at(t);
}
kind_changed = verb_group_kind_changed(&entry.block, &block);
entry.block = block;
entry.invalidate_cache();
deferred_visible_change = true;
}
if kind_changed {
scrollback.mark_structurally_dirty(entry_id);
}
}
Some((tc_id.clone(), desc, true))
}
} else {
let entry_id = if let Some(entry_id) = pending.entry_id {
let block = tool_call_to_block(&pending.base, self.session_cwd.as_deref());
scrollback.replace_tool_block(entry_id, block, pending.started_at);
entry_id
} else {
let block = tool_call_to_block(&pending.base, self.session_cwd.as_deref());
let id = scrollback.push_block(block);
scrollback.set_last_running(true);
pending.entry_id = Some(id);
id
};
if let Some(bash_output) = bash_output {
if let Some(delta) = &bash_output.output_delta {
let text = pending.utf8_decoder.decode(delta);
return scrollback.append_execute_output(entry_id, text);
}
let output_str = String::from_utf8_lossy(&bash_output.output);
return scrollback.set_execute_output(entry_id, &output_str);
}
return true;
}
} else {
return false;
};
if let Some((deferred_id, description, keep_in_pending)) = defer_as_bg {
tracing::debug!(
tool_call_id = % deferred_id, keep_in_pending,
"Deferring is_background=true tool to bg_deferred_tools"
);
if !keep_in_pending {
self.pending_tools.remove(&deferred_id);
}
self.bg_deferred_tools.insert(deferred_id, description);
if deferred_visible_change && !is_replay {
self.bump_agent_output_epoch();
}
return false;
}
unreachable!("both branches above return");
}
if let Some(pending) = self.pending_tools.remove(&tc_id) {
let merged = merge_tool_call_update(pending.base, tcu);
let block = tool_call_to_block(&merged, self.session_cwd.as_deref());
if let Some(entry_id) = pending.entry_id {
if scrollback.replace_tool_block(entry_id, block, pending.started_at)
&& let Some(entry) = scrollback.get_by_id(entry_id)
{
self.queue_edit_hl_if_needed(entry_id, &entry.block, is_replay);
}
scrollback.finish_running(entry_id);
self.try_coalesce_edit(entry_id, scrollback, is_replay);
} else {
self.finish_completed_tool(block, scrollback, is_replay);
}
true
} else {
self.orphan_updates.insert(tc_id, tcu);
false
}
}
/// Handle a user message chunk (session replay or live followup).
///
/// If `skip_next_user_echo` is set, this is the ACP echo of a prompt
/// we already added to scrollback — drop it but still reset tracking
/// state so the agent's response creates fresh entries.
fn handle_user_message(
&mut self,
chunk: acp::ContentChunk,
meta: &NotificationMeta,
scrollback: &mut ScrollbackState,
) -> bool {
let text = extract_text_from_content(&chunk.content);
if self.skip_next_skill_body {
self.skip_next_skill_body = false;
return false;
}
if text.is_empty() {
return false;
}
self.finish_thinking(scrollback);
if let Some(agent_id) = self.current_agent_msg.take() {
scrollback.finish_running(agent_id);
}
for (_, pending) in self.pending_tools.drain() {
if let Some(entry_id) = pending.entry_id {
scrollback.finish_running(entry_id);
}
}
if self.skip_next_user_echo {
self.skip_next_user_echo = false;
if text.contains("<command-name>") {
self.skip_next_skill_body = true;
}
let prompt_index = chunk
.meta
.as_ref()
.and_then(|m| m.get(user_message_chunk_meta::PROMPT_INDEX))
.and_then(|v| v.as_u64())
.map(|v| v as usize);
if let Some(pi) = prompt_index {
for idx in (0..scrollback.len()).rev() {
if let Some(entry) = scrollback.get_mut(idx)
&& let RenderBlock::UserPrompt(ref mut block) = entry.block
{
if block.is_interjection {
continue;
}
if block.prompt_index.is_none() {
block.prompt_index = Some(pi);
}
break;
}
}
}
return false;
}
let prompt_index = chunk
.meta
.as_ref()
.and_then(|m| m.get(user_message_chunk_meta::PROMPT_INDEX))
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let display_override = match &chunk.content {
acp::ContentBlock::Text(t) => t
.meta
.as_ref()
.and_then(|m| m.get(user_prompt_meta::DISPLAY_TEXT))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
_ => None,
};
let skill_token_ranges = match &chunk.content {
acp::ContentBlock::Text(t) => t
.meta
.as_ref()
.and_then(|m| m.get(user_prompt_meta::SKILL_TOKEN_RANGES))
.map(parse_skill_token_ranges)
.unwrap_or_default(),
_ => Vec::new(),
};
let mut block = if let Some(dt) = display_override {
if text.contains("<command-name>") {
self.skip_next_skill_body = true;
}
let (as_skill, as_cron) = match &chunk.content {
acp::ContentBlock::Text(t) => {
let m = t.meta.as_ref();
let skill = m
.and_then(|m| m.get(user_prompt_meta::DISPLAY_AS_SKILL))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let cron = m
.and_then(|m| m.get(user_prompt_meta::DISPLAY_AS_CRON))
.and_then(|v| v.as_bool())
.unwrap_or(false);
(skill, cron)
}
_ => (false, false),
};
if as_cron {
crate::scrollback::blocks::UserPromptBlock::cron(dt)
} else if as_skill {
crate::scrollback::blocks::UserPromptBlock::skill(dt)
} else {
crate::scrollback::blocks::UserPromptBlock::new(dt)
}
} else if !skill_token_ranges.is_empty() {
crate::scrollback::blocks::UserPromptBlock::with_skill_tokens(text, skill_token_ranges)
} else {
let skill_display =
kigi_tools::implementations::skills::skill::extract_skill_display_text(&text);
if let Some(display_text) = skill_display {
self.skip_next_skill_body = true;
crate::scrollback::blocks::UserPromptBlock::skill(display_text)
} else if text.starts_with('/') && !text.starts_with("//") {
crate::scrollback::blocks::UserPromptBlock::skill(text)
} else if let Some(cmd) = extract_skill_header_command(&text) {
crate::scrollback::blocks::UserPromptBlock::new(cmd)
} else if let Some(prompt) = extract_cron_prompt_body(&text) {
crate::scrollback::blocks::UserPromptBlock::cron(prompt)
} else if user_message_hidden_from_scrollback(&chunk, meta, &text) {
return false;
} else {
crate::scrollback::blocks::UserPromptBlock::new(text)
}
};
block.prompt_index = prompt_index;
let entry_id = scrollback.push_block(RenderBlock::UserPrompt(block));
let ts_ms = meta.turn_start_ms.or(meta.agent_timestamp_ms);
if let Some(ms) = ts_ms
&& let Some(entry) = scrollback.get_by_id_mut(entry_id)
{
entry.created_at = Some(utc_ms_to_local(ms));
}
true
}
}
/// Parse `skillTokenRanges` content-block meta (`[[start, end], …]`) into
/// byte ranges. Malformed entries are skipped; bounds/boundary validation
/// happens in `UserPromptBlock::with_skill_tokens`.
fn parse_skill_token_ranges(v: &serde_json::Value) -> Vec<std::ops::Range<usize>> {
v.as_array()
.map(|arr| {
arr.iter()
.filter_map(|pair| {
let pair = pair.as_array()?;
let start = pair.first()?.as_u64()? as usize;
let end = pair.get(1)?.as_u64()? as usize;
Some(start..end)
})
.collect()
})
.unwrap_or_default()
}
/// Extract a slash command name from a skill instruction markdown header.
///
/// Matches text starting with `# /command -- ` (the format used by
/// `InjectSkill`). Returns the `## Input` section's content if present,
/// prefixed with the command name. Falls back to just the command name.
///
/// Example: `"# /loop -- schedule a recurring prompt\n\n...\n## Input\n5m check deploy"`
/// → `"/loop 5m check deploy"`
fn extract_skill_header_command(text: &str) -> Option<String> {
let text = text.strip_prefix("# ")?;
if !text.starts_with('/') {
return None;
}
let cmd_name = text.split(&[' ', '\n'][..]).next()?;
if let Some(input_idx) = text.find("## Input\n") {
let args = text[input_idx + "## Input\n".len()..].trim();
if !args.is_empty() {
return Some(format!("{cmd_name} {args}"));
}
}
Some(cmd_name.to_string())
}
/// Whether a `UserMessageChunk` must stay out of scrollback.
///
/// Type-driven (preferred):
/// 1. `ContentChunk._meta.hideFromScrollback` stamped by the shell from
/// [`PromptOrigin::hide_user_echo_from_scrollback`]
/// 2. `SessionNotification._meta.promptId` classified via
/// [`PromptOrigin::from_prompt_id`]
///
/// Legacy fallback (pre-meta sessions only): bare auto-wake text that used to
/// be gated by the system-reminder prefix. Cron is handled earlier by
/// [`extract_cron_prompt_body`].
fn user_message_hidden_from_scrollback(
chunk: &acp::ContentChunk,
meta: &NotificationMeta,
text: &str,
) -> bool {
if chunk
.meta
.as_ref()
.and_then(|m| m.get(user_message_chunk_meta::HIDE_FROM_SCROLLBACK))
.and_then(|v| v.as_bool())
== Some(true)
{
return true;
}
if let Some(pid) = meta.prompt_id.as_deref()
&& kigi_shell::session::PromptOrigin::from_prompt_id(pid).hide_user_echo_from_scrollback()
{
return true;
}
let t = text.trim_start();
t.starts_with("<system-reminder>")
|| t.starts_with("<monitor-event")
|| t.trim() == "---"
|| t.lines().next().is_some_and(|first| {
first.starts_with(|c: char| c.is_ascii_digit())
&& first.contains(" monitor events from ")
&& first.contains(" (use ")
})
}
/// Extract the user's prompt from `<system-reminder>` cron framing.
///
/// Matches the format produced by `format_scheduled_task_prompt`:
/// `"<system-reminder>\nThis is a scheduled task execution...\n</system-reminder>\n\n<prompt>"`
///
/// Returns the prompt text after the closing tag, or `None` if the text
/// doesn't match the cron framing pattern.
fn extract_cron_prompt_body(text: &str) -> Option<String> {
if !text.starts_with("<system-reminder>") {
return None;
}
let end_tag = "</system-reminder>";
let close = text.find(end_tag)?;
let header = &text[..close];
if !header.contains("scheduled task execution") {
return None;
}
let body = text[close + end_tag.len()..].trim();
if body.is_empty() {
return None;
}
Some(body.to_string())
}
/// Merge ToolCallUpdate fields with the base ToolCall.
/// Update fields take precedence when present.
fn merge_tool_call_update(base: acp::ToolCall, update: acp::ToolCallUpdate) -> acp::ToolCall {
acp::ToolCall::new(
update.tool_call_id,
update.fields.title.unwrap_or(base.title),
)
.kind(update.fields.kind.unwrap_or(base.kind))
.status(update.fields.status.unwrap_or(base.status))
.content(update.fields.content.unwrap_or(base.content))
.raw_input(update.fields.raw_input.or(base.raw_input))
.raw_output(update.fields.raw_output.or(base.raw_output))
.locations(update.fields.locations.unwrap_or(base.locations))
.meta(base.meta)
}
/// Peeled display form when a redundant leading `cd <cwd>` was stripped, else None.
fn peeled_if_changed(command: &str, session_cwd: Option<&Path>) -> Option<String> {
let cwd = session_cwd?;
let stripped = strip_redundant_session_cd(command, cwd);
(stripped.as_ref() != command).then(|| stripped.into_owned())
}
/// True when `s` is an ACP/function tool id rather than a shell command.
///
/// Eager ToolCall messages often set `title` to the function name
/// (`run_terminal_command`) before `raw_input.command` arrives — using that as
/// the execute header flashes the internal tool name in the TUI.
fn is_execute_tool_function_name(s: &str) -> bool {
matches!(
s.to_ascii_lowercase().as_str(),
"run_terminal_command"
| "run_terminal_cmd"
| "bash"
| "shell"
| "execute"
| "run_command"
| "terminal"
)
}
/// Eager execute-related placeholder that should not be shown to the user.
///
/// Only **empty** execute commands count as placeholders. A real shell
/// invocation of `bash` / `shell` / etc. must not be dropped on late
/// `is_background` (would lose demotion + stdout). Other blocks still
/// matching the tool function name are placeholders.
fn entry_is_execute_placeholder(entry: &crate::scrollback::entry::ScrollbackEntry) -> bool {
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(ex)) => ex.command.trim().is_empty(),
RenderBlock::ToolCall(ToolCallBlock::Other(o)) => is_execute_tool_function_name(&o.name),
_ => false,
}
}
/// Non-empty `raw_input.command` if present (empty / whitespace treated as missing).
fn raw_input_command(tc: &acp::ToolCall) -> Option<String> {
extract_raw_field(tc, "command").and_then(|c| {
let t = c.trim();
if t.is_empty() { None } else { Some(c) }
})
}
/// Resolve the shell command for an execute tool call.
///
/// Prefer `raw_input.command`. Do **not** fall back to a title that is only the
/// tool function name (that produces the "Run run_terminal_command" flash).
fn execute_command_from_tool_call(tc: &acp::ToolCall) -> String {
if let Some(cmd) = raw_input_command(tc) {
return cmd;
}
if !tc.title.is_empty() && !is_execute_tool_function_name(&tc.title) {
return tc.title.clone();
}
String::new()
}
/// Convert an ACP ToolCall to a RenderBlock.
///
/// Parses `tool_call.kind` to create the appropriate block type,
/// extracting fields from `raw_input` JSON when available. `session_cwd` sets
/// execute `header_display` when a leading `cd <cwd>` is redundant.
fn tool_call_to_block(tc: &acp::ToolCall, session_cwd: Option<&Path>) -> RenderBlock {
let success = !matches!(tc.status, acp::ToolCallStatus::Failed);
match tc.kind {
acp::ToolKind::Execute => {
let command = execute_command_from_tool_call(tc);
let header_display = peeled_if_changed(&command, session_cwd);
let description = extract_raw_field(tc, "description");
let is_bash_mode = tc
.meta
.as_ref()
.and_then(|m| m.get("bash_mode"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some(bash) = extract_bash_output_from_value(&tc.raw_output) {
let output_str = String::from_utf8_lossy(&bash.output);
let mut block = ExecuteToolCallBlock::new(command).with_output(output_str.as_ref());
block.bash_mode = is_bash_mode;
block.header_display = header_display;
if let Some(desc) = description {
block = block.with_description(desc);
}
if !success || bash.exit_code != 0 {
let error_msg = if let Some(sig) = &bash.signal {
sig.clone()
} else if bash.exit_code != 0 {
format!("exit code {}", bash.exit_code)
} else {
"Command failed".into()
};
block = block.with_error(error_msg);
}
RenderBlock::ToolCall(ToolCallBlock::Execute(block))
} else {
let mut block = ExecuteToolCallBlock::new(command);
block.bash_mode = is_bash_mode;
block.header_display = header_display;
if let Some(desc) = description {
block = block.with_description(desc);
}
if !success {
let text = content_text(tc);
let error_msg = if text.is_empty() {
"Command failed".to_string()
} else {
text
};
block = block.with_error(error_msg);
}
RenderBlock::ToolCall(ToolCallBlock::Execute(block))
}
}
acp::ToolKind::Read => {
let path = extract_raw_field(tc, "file_path")
.or_else(|| extract_raw_field(tc, "target_file"))
.or_else(|| extract_raw_field(tc, "path"))
.unwrap_or_else(|| tc.title.clone());
let mut block = ReadToolCallBlock::new(&path);
if let Some(ref raw) = tc.raw_output
&& let Ok(ToolOutput::ReadFile(read_output)) =
serde_json::from_value::<ToolOutput>(raw.clone())
{
match read_output {
ReadFileOutput::FileContent(fc) => {
if fc.offset.is_some() || fc.limit.is_some() {
let off = fc.offset.unwrap_or(0);
let start = off + 1;
let end = fc
.limit
.map_or(fc.total_lines, |lim| (off + lim).min(fc.total_lines));
block = block.with_line_range(LineRange::new(start, end));
}
block = block.with_content(fc.raw_output, fc.total_lines);
}
ReadFileOutput::FileNotFound(msg)
| ReadFileOutput::IsADirectory(msg)
| ReadFileOutput::PermissionDenied(msg)
| ReadFileOutput::FileTooLarge(msg)
| ReadFileOutput::FileReadError(msg)
| ReadFileOutput::ImageSizeError(msg) => {
block = block.with_error(msg);
}
ReadFileOutput::ImageContent(_) => {
block.media_kind = Some(ReadMediaKind::Image);
block.image_ref =
crate::prompt_images::ScrollbackImageRef::from_path(&path);
}
ReadFileOutput::PdfPageImages(pdf) => {
block.media_kind = Some(ReadMediaKind::Pdf {
pages: pdf.total_pages,
});
}
}
} else if !success {
let text = content_text(tc);
block = block.with_error(if text.is_empty() {
"Read failed".to_string()
} else {
text
});
}
RenderBlock::ToolCall(ToolCallBlock::Read(block))
}
acp::ToolKind::Edit => {
let raw_path = extract_raw_field(tc, "file_path")
.or_else(|| extract_raw_field(tc, "filePath"))
.or_else(|| extract_raw_field(tc, "target_file"))
.or_else(|| extract_raw_field(tc, "path"));
let path_from_title = raw_path.is_none();
let path = raw_path.unwrap_or_else(|| tc.title.clone());
let untrusted_summary = path_from_title
|| tc
.content
.iter()
.filter(|c| matches!(c, acp::ToolCallContent::Diff(_)))
.count()
> 1;
let is_write = is_write_tool(tc);
let mut block = if success {
let (hunks, _count) = crate::diff::extract_edit_hunks(tc);
EditToolCallBlock::new(path, hunks)
} else {
let error_msg = extract_edit_error(tc);
EditToolCallBlock::new(path, vec![]).with_error(error_msg)
};
if untrusted_summary {
block = block.with_untrusted_summary();
}
if is_write {
block = block.with_prefix("Creating ");
}
RenderBlock::ToolCall(ToolCallBlock::Edit(block))
}
acp::ToolKind::Search
if matches!(
extract_raw_field(tc, "variant").as_deref(),
Some("WebSearch") | Some("XSearch")
) || tc.title.starts_with("Web search:")
|| tc.title.starts_with("X search:") =>
{
let is_backend = extract_raw_field(tc, "backend").as_deref() == Some("true")
|| tc
.meta
.as_ref()
.and_then(|m| m.get("backend"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let query = extract_raw_field(tc, "query")
.or_else(|| {
tc.title
.strip_prefix("Web search: ")
.map(|q| q.trim_matches('"').to_owned())
})
.unwrap_or_else(|| {
if is_backend {
String::new()
} else {
tc.title.clone()
}
});
let mut block = WebSearchToolCallBlock::new(query);
if is_backend {
let variant = extract_raw_field(tc, "variant").unwrap_or_default();
if variant == "XSearch" {
block.label = Some("X Search ".to_string());
block.is_x_search = true;
}
if let Some(ref raw) = tc.raw_output {
if variant == "XSearch" && raw.get("name").is_some() {
let tool_name = raw
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("x_search");
let short_type = match tool_name {
"x_keyword_search" => "keyword",
"x_semantic_search" => "semantic",
"x_user_search" => "users",
"x_thread_fetch" => "thread",
other => other,
};
let input_str = raw.get("input").and_then(|v| v.as_str()).unwrap_or("{}");
let query_text = serde_json::from_str::<serde_json::Value>(input_str)
.ok()
.and_then(|v| {
v.get("query").and_then(|q| q.as_str()).map(String::from)
});
if let Some(ref q) = query_text {
block.query = format!("{short_type}({q})");
} else {
block.query = short_type.to_string();
}
} else if variant == "WebSearch" && raw.pointer("/action/type").is_some() {
let action_type = raw
.pointer("/action/type")
.and_then(|v| v.as_str())
.unwrap_or("");
match action_type {
"search" => {
if let Some(q) =
raw.pointer("/action/query").and_then(|v| v.as_str())
{
block.query = q.to_string();
}
if let Some(arr) =
raw.pointer("/action/sources").and_then(|v| v.as_array())
{
block.citations = arr
.iter()
.filter_map(|s| s.get("url").and_then(|u| u.as_str()))
.map(|u| u.to_string())
.collect();
}
}
"open_page" => {
if let Some(url) =
raw.pointer("/action/url").and_then(|v| v.as_str())
{
block.query = format!("open {url}");
block.citations = vec![url.to_string()];
}
}
"find" | "find_in_page" => {
let pattern = raw
.pointer("/action/pattern")
.and_then(|v| v.as_str())
.unwrap_or("?");
let url = raw
.pointer("/action/url")
.and_then(|v| v.as_str())
.unwrap_or("");
block.query = format!("find \"{pattern}\"");
if !url.is_empty() {
block.citations = vec![url.to_string()];
}
}
_ => {}
}
}
}
if !block.citations.is_empty() {
let sources_list: Vec<String> = block
.citations
.iter()
.enumerate()
.map(|(i, url)| format!("{}. {}", i + 1, url))
.collect();
block.content = Some(sources_list.join("\n"));
}
} else {
if let Some(ref raw) = tc.raw_output
&& let Ok(ToolOutput::WebSearch(ws)) =
serde_json::from_value::<ToolOutput>(raw.clone())
{
if !ws.content.is_empty() {
block.content = Some(ws.content);
}
block.citations = ws.citations;
}
if block.content.is_none() {
let text = content_text(tc);
if !text.is_empty() {
block.content = Some(text);
}
}
}
if !success {
block = block.with_error("Web search failed");
}
RenderBlock::ToolCall(ToolCallBlock::WebSearch(block))
}
acp::ToolKind::Search => {
let pattern = extract_raw_field(tc, "pattern")
.or_else(|| extract_raw_field(tc, "glob_pattern"))
.unwrap_or_else(|| tc.title.clone());
let meta = extract_search_meta(tc);
let grep = extract_grep_output(&tc.raw_output).unwrap_or_default();
let mut block = SearchToolCallBlock::new(pattern);
block.meta = meta;
block.match_count = grep.match_count;
block.file_matches = grep.file_matches;
block.file_paths = grep.file_paths;
if !success {
block.error = Some("Search failed".into());
}
RenderBlock::ToolCall(ToolCallBlock::Search(block))
}
acp::ToolKind::Fetch => {
let url = extract_raw_field(tc, "url")
.or_else(|| tc.title.strip_prefix("Fetch: ").map(str::to_owned))
.unwrap_or_else(|| tc.title.clone());
let mut block = WebFetchToolCallBlock::new(url);
if let Some(ref raw) = tc.raw_output
&& let Ok(ToolOutput::WebFetch(WebFetchOutput::Content(content))) =
serde_json::from_value::<ToolOutput>(raw.clone())
{
block.status_code = Some(content.status_code);
block.content_type = Some(content.content_type);
block.bytes = Some(content.bytes);
}
let text = content_text(tc);
if !text.is_empty() {
block.output = Some(text);
}
if !success {
block = block.with_error("Fetch failed");
}
RenderBlock::ToolCall(ToolCallBlock::WebFetch(block))
}
_ if extract_raw_field(tc, "target_directory").is_some() => {
let path = extract_raw_field(tc, "target_directory").unwrap();
let mut block = ListDirToolCallBlock::new(make_relative_path(&path));
if let Some(content) = extract_listdir_content(&tc.raw_output) {
block = block.with_output(content);
}
if !success {
block = block.with_error("List directory failed");
}
RenderBlock::ToolCall(ToolCallBlock::ListDir(block))
}
_ if extract_raw_field(tc, "variant").as_deref() == Some("SearchTool") => {
let query = extract_raw_field(tc, "query").unwrap_or_default();
let mut block = IntegrationSearchToolCallBlock::new(query);
block.limit = tc
.raw_input
.as_ref()
.and_then(|v| v.get("limit"))
.and_then(|v| v.as_u64())
.map(|n| n as u8);
if let Some(ref raw) = tc.raw_output
&& let Ok(ToolOutput::SearchTool(SearchToolOutput {
result_count,
content,
})) = serde_json::from_value::<ToolOutput>(raw.clone())
{
block.result_count = result_count;
block.results = parse_search_tool_results(&content);
block.content = Some(content);
}
if !success {
block = block.with_error("Search failed");
}
RenderBlock::ToolCall(ToolCallBlock::IntegrationSearch(block))
}
_ if extract_raw_field(tc, "variant").as_deref() == Some("UseTool") => {
let tool_name = extract_raw_field(tc, "tool_name").unwrap_or_else(|| tc.title.clone());
let mut block = UseToolCallBlock::new(tool_name);
block.input_args = extract_use_tool_args(tc);
let text = content_text(tc);
if !text.is_empty() {
block.output = Some(text);
} else if let Some(extracted) = extract_use_tool_output(&tc.raw_output) {
block.output = Some(extracted);
}
if !success {
block.error = Some(
block
.output
.take()
.unwrap_or_else(|| "Tool call failed".into()),
);
}
RenderBlock::ToolCall(ToolCallBlock::UseTool(block))
}
_ if matches!(
extract_raw_field(tc, "variant").as_deref(),
Some("ImageGen") | Some("ImageToVideo") | Some("ReferenceToVideo") | Some("ImageEdit")
) =>
{
media_gen_block(tc, success)
}
_ if tc.title.starts_with("Memory search:") => {
let query = tc
.title
.strip_prefix("Memory search: ")
.map(|q| q.trim_matches('"').to_owned())
.unwrap_or_else(|| tc.title.clone());
let mut block = MemorySearchToolCallBlock::new(query);
let text = content_text(tc);
if !text.is_empty() {
block.results =
crate::scrollback::blocks::tool::memory_search::parse_memory_results(&text);
}
if !success {
block.error = Some("Memory search failed".into());
}
RenderBlock::ToolCall(ToolCallBlock::MemorySearch(block))
}
_ => {
if is_execute_tool_function_name(&tc.title) {
let command = execute_command_from_tool_call(tc);
let header_display = peeled_if_changed(&command, session_cwd);
if let Some(bash) = extract_bash_output_from_value(&tc.raw_output) {
let output_str = String::from_utf8_lossy(&bash.output);
let mut block =
ExecuteToolCallBlock::new(command).with_output(output_str.as_ref());
block.header_display = header_display;
if let Some(desc) = extract_raw_field(tc, "description") {
block = block.with_description(desc);
}
if !success || bash.exit_code != 0 {
let error_msg = if let Some(sig) = &bash.signal {
sig.clone()
} else if bash.exit_code != 0 {
format!("exit code {}", bash.exit_code)
} else {
"Command failed".into()
};
block = block.with_error(error_msg);
}
return RenderBlock::ToolCall(ToolCallBlock::Execute(block));
}
let mut block = ExecuteToolCallBlock::new(command);
block.header_display = header_display;
if let Some(desc) = extract_raw_field(tc, "description") {
block = block.with_description(desc);
}
if !success {
let text = content_text(tc);
block = block.with_error(if text.is_empty() {
"Command failed".to_string()
} else {
text
});
}
return RenderBlock::ToolCall(ToolCallBlock::Execute(block));
}
let name = tool_call_title(tc);
let summary = if tc.title.is_empty() {
extract_raw_field(tc, "path")
.or_else(|| extract_raw_field(tc, "url"))
.or_else(|| extract_raw_field(tc, "query"))
.unwrap_or_default()
} else if tc.kind == acp::ToolKind::Other {
String::new()
} else {
format!("{:?}", tc.kind).to_lowercase()
};
let (label, ctor): (String, fn(OtherToolCallBlock) -> ToolCallBlock) = if name
.eq_ignore_ascii_case("skill")
|| name.to_ascii_lowercase().starts_with("skill:")
{
let label = match name.find(':') {
Some(i) => format!("Skill{}", &name[i..]),
None => "Skill".into(),
};
(label, ToolCallBlock::Skill)
} else {
(name.into_owned(), ToolCallBlock::Other)
};
let mut block = OtherToolCallBlock::new(label, summary);
let ct = content_text(tc);
if !success {
block.error = Some(if ct.is_empty() {
"Failed".into()
} else {
ct.clone()
});
}
if !ct.is_empty() {
block.set_output_text(ct);
}
RenderBlock::ToolCall(ctor(block))
}
}
}
/// Display title for a tool call: its title, or the kind name when empty.
fn tool_call_title(tc: &acp::ToolCall) -> Cow<'_, str> {
if tc.title.is_empty() {
Cow::Owned(format!("{:?}", tc.kind))
} else {
Cow::Borrowed(&tc.title)
}
}
/// Build the media block from the typed `raw_output` path.
fn media_gen_block(tc: &acp::ToolCall, success: bool) -> RenderBlock {
let mut block = OtherToolCallBlock::new(tool_call_title(tc), String::new());
if !success {
let err = content_text(tc);
block.error = Some(if err.is_empty() { "Failed".into() } else { err });
} else if let Some((path, is_video)) = media_gen_ref(tc) {
block = block.with_media_ref(path, is_video);
} else if let Some(text) = media_gen_text(tc) {
block.set_output_text(text);
}
RenderBlock::ToolCall(ToolCallBlock::Other(block))
}
/// Plain-text body of a media-variant tool that returned `ToolOutput::Text`
/// rather than a media file (the free / X Basic SuperGrok-upsell short-circuit).
/// `None` for real media outputs — including ZDR upload-only results — so their
/// typed rendering is untouched.
fn media_gen_text(tc: &acp::ToolCall) -> Option<String> {
match serde_json::from_value::<ToolOutput>(tc.raw_output.clone()?).ok()? {
ToolOutput::Text(t) => (!t.text.is_empty()).then_some(t.text),
_ => None,
}
}
/// Local `(path, is_video)` from typed `raw_output`.
///
/// Returns `None` when `raw_output` is missing/unparseable, not a media
/// variant, or has no openable local file (ZDR `uploaded_url` / empty path).
fn media_gen_ref(tc: &acp::ToolCall) -> Option<(std::path::PathBuf, bool)> {
let (media, is_video) =
match serde_json::from_value::<ToolOutput>(tc.raw_output.clone()?).ok()? {
ToolOutput::ImageGen(m) | ToolOutput::ImageEdit(m) => (m, false),
ToolOutput::ImageToVideo(m) | ToolOutput::ReferenceToVideo(m) => (m, true),
_ => return None,
};
if media.uploaded_url.is_some() || media.path.as_os_str().is_empty() {
return None;
}
Some((media.path, is_video))
}
/// Extract text content from a ContentBlock.
fn extract_text_from_content(content: &acp::ContentBlock) -> String {
match content {
acp::ContentBlock::Text(t) => t.text.clone(),
_ => String::new(),
}
}
/// Extract text from tool call content blocks.
fn content_text(tc: &acp::ToolCall) -> String {
tc.content
.iter()
.filter_map(|c| match c {
acp::ToolCallContent::Content(acp::Content {
content: acp::ContentBlock::Text(t),
..
}) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
/// Check if a tool call is bg-task internal plumbing
/// (get_command_or_subagent_output, kill_command_or_subagent,
/// wait_commands_or_subagents, and the external background-await tool).
///
/// These are suppressed from scrollback because the bg task pane provides
/// visibility into task status and output.
fn is_bg_plumbing_tool(tc: &acp::ToolCall) -> bool {
matches!(
tc.title.as_str(),
"get_command_or_subagent_output"
| "kill_command_or_subagent"
| "wait_commands_or_subagents"
| "get_task_output"
| "kill_task"
| "wait_tasks"
| "get_task_or_subagent_output"
| "kill_task_or_subagent"
| "wait_tasks_or_subagents"
| "AwaitShell"
| "Await"
) || tc.title.starts_with("Await:")
|| tc.title.starts_with("Sleep ")
|| tc.title.starts_with("Wait tasks:")
|| tc.title.starts_with("Kill task:")
|| tc
.raw_input
.as_ref()
.and_then(|v| v.get("variant"))
.and_then(|v| v.as_str())
.is_some_and(|v| matches!(v, "TaskOutput" | "KillTask" | "WaitTasks"))
}
/// Classify a *blocking* suppressed tool into the [`WaitingReason`] the turn is
/// waiting on, or `None` for suppressed tools that don't block the turn (e.g.
/// `kill_*`, todo/goal/scheduler). Mirrors the title/variant matches in
/// [`is_bg_plumbing_tool`] so the spinner can name the wait instead of falling
/// back to a generic "Waiting…".
fn blocking_wait_reason(tc: &acp::ToolCall) -> Option<WaitingReason> {
let title = tc.title.as_str();
let variant = tc
.raw_input
.as_ref()
.and_then(|v| v.get("variant"))
.and_then(|v| v.as_str());
if matches!(
title,
"get_command_or_subagent_output" | "get_task_output" | "get_task_or_subagent_output"
) || variant == Some("TaskOutput")
{
let task_ids = tc
.raw_input
.as_ref()
.map(task_ids_from_raw_input)
.unwrap_or_default();
return Some(WaitingReason::TaskOutput {
task_ids,
subject: None,
waits: timeout_waits(tc.raw_input.as_ref()),
});
}
if matches!(
title,
"wait_commands_or_subagents" | "wait_tasks" | "wait_tasks_or_subagents"
) || title.starts_with("Wait tasks:")
|| variant == Some("WaitTasks")
{
return Some(WaitingReason::TasksComplete);
}
if matches!(title, "Await" | "AwaitShell")
|| title.starts_with("Await:")
|| title.starts_with("Sleep ")
{
return Some(WaitingReason::Sleep);
}
None
}
/// Whether the wait tool call actually blocks: `timeout_ms > 0` in raw_input.
/// Missing input / missing field / 0 all mean an instant poll.
fn timeout_waits(raw: Option<&serde_json::Value>) -> bool {
raw.and_then(|v| v.get("timeout_ms"))
.and_then(|v| v.as_u64())
.is_some_and(|t| t > 0)
}
/// Extract `task_ids` from a `get_task_output` / wait tool's raw_input JSON.
fn task_ids_from_raw_input(raw: &serde_json::Value) -> Vec<String> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::<String>::new();
if let Some(arr) = raw.get("task_ids").and_then(|v| v.as_array()) {
for v in arr {
if let Some(id) = v.as_str() {
let id = id.trim();
if !id.is_empty() && seen.insert(id.to_string()) {
out.push(id.to_string());
}
}
}
}
if out.is_empty()
&& let Some(id) = raw.get("task_id").and_then(|v| v.as_str())
{
let id = id.trim();
if !id.is_empty() {
out.push(id.to_string());
}
}
out
}
/// Check if a tool call is a background execute (`is_background=true`).
///
/// These are deferred from scrollback — the `x.ai/task_backgrounded`
/// notification creates a `BgTask` block instead of an `Execute` block.
///
/// Eager ACP messages often use `kind=Other` with `title=run_terminal_command`
/// before the kind is refined to Execute — still treat those as execute tools
/// when `raw_input` requests background so we don't flash the function name.
fn is_bg_tool(tc: &acp::ToolCall) -> bool {
let looks_like_execute =
tc.kind == acp::ToolKind::Execute || is_execute_tool_function_name(&tc.title);
looks_like_execute
&& tc
.raw_input
.as_ref()
.and_then(|v| v.get("is_background").or_else(|| v.get("background")))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Check if an Edit-kind tool call is a whole-file write (write)
/// rather than a targeted replacement (search_replace / edit).
///
/// Detection: a Write-family `rawInput.variant` tag.
fn is_write_tool(tc: &acp::ToolCall) -> bool {
is_write_variant(
tc.raw_input
.as_ref()
.and_then(|v| v.get("variant"))
.and_then(|v| v.as_str()),
)
}
/// Extract the serde variant tag from a tool call's `raw_input.variant`.
///
/// Shared helper for all `is_*_tool` suppression checks — avoids
/// duplicating the `.as_ref()?.get("variant")?.as_str()` chain.
fn extract_variant(tc: &acp::ToolCall) -> Option<&str> {
tc.raw_input.as_ref()?.get("variant")?.as_str()
}
/// Twin without the optional-toolset spelling.
fn is_task_variant(variant: Option<&str>) -> bool {
matches!(variant, Some("Task"))
}
/// Twin without the optional-toolset spelling.
fn is_write_variant(variant: Option<&str>) -> bool {
matches!(variant, Some("Write"))
}
/// Twin without the optional-toolset spelling.
fn is_todo_variant(variant: Option<&str>) -> bool {
matches!(variant, Some("TodoWrite"))
}
/// Check if a tool call is a todo-related tool.
///
/// Suppressed from scrollback because the dedicated todo pane provides
/// better visibility. Covers the `todo_write` / `TodoWrite` ids, the
/// `Updating plan` title, and TodoWrite-family variant tags.
fn is_todo_tool(tc: &acp::ToolCall) -> bool {
matches!(
tc.title.as_str(),
"todo_write" | "TodoWrite" | "Updating plan"
) || is_todo_variant(extract_variant(tc))
}
/// Check if a tool call is a goal-update tool (update_goal).
///
/// Suppressed from scrollback because the goal dashboard provides visibility.
fn is_goal_tool(tc: &acp::ToolCall) -> bool {
tc.title == "update_goal" || matches!(extract_variant(tc), Some("UpdateGoal"))
}
/// Check if a tool call is a task tool (subagent spawn).
///
/// Suppressed from scrollback because the SubagentBlock (created from
/// SubagentSpawned notification) provides better visibility. Covers the
/// `task` / `Task` / `spawn_subagent` ids and Task-family variant tags.
fn is_task_tool(tc: &acp::ToolCall) -> bool {
matches!(tc.title.as_str(), "task" | "Task" | "spawn_subagent")
|| is_task_variant(extract_variant(tc))
}
/// Check if a tool call is a scheduler tool (scheduler_create/delete/list).
///
/// Suppressed from scrollback because the tasks pane provides visibility.
/// Uses convention-based prefixes rather than exhaustive names.
fn is_scheduler_tool(tc: &acp::ToolCall) -> bool {
tc.title.starts_with("scheduler_")
|| extract_variant(tc).is_some_and(|v| v.starts_with("Scheduler"))
}
/// Extract a string field from raw_input JSON.
fn extract_raw_field(tc: &acp::ToolCall, field: &str) -> Option<String> {
tc.raw_input
.as_ref()
.and_then(|v| v.get(field))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Extract a short, user-friendly error label from a failed Edit tool call.
fn extract_edit_error(tc: &acp::ToolCall) -> String {
use kigi_tools::types::output::SearchReplaceOutput;
if let Some(ref raw) = tc.raw_output
&& let Ok(ToolOutput::SearchReplace(sr)) = serde_json::from_value::<ToolOutput>(raw.clone())
{
return match sr {
SearchReplaceOutput::InvalidInput(_) => "Invalid input".to_owned(),
SearchReplaceOutput::FileNotFound(_) => "File not found".to_owned(),
SearchReplaceOutput::MultipleMatchesFound(_) => "Multiple matches found".to_owned(),
SearchReplaceOutput::FileAlreadyExists(_) => "File already exists".to_owned(),
SearchReplaceOutput::FilenameTooLong(_) => "Filename too long".to_owned(),
SearchReplaceOutput::NoMatchesFound(_) => "No matches found".to_owned(),
SearchReplaceOutput::EditsApplied(_) => "Edit failed".to_owned(),
};
}
"Edit failed".to_owned()
}
/// Extract search input metadata from a tool call's rawInput.
fn extract_search_meta(tc: &acp::ToolCall) -> SearchInputMeta {
let raw = match tc.raw_input.as_ref() {
Some(v) => v,
None => return SearchInputMeta::default(),
};
let str_field = |name: &str| -> Option<String> {
raw.get(name)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
};
let bool_field =
|name: &str| -> bool { raw.get(name).and_then(|v| v.as_bool()).unwrap_or(false) };
let output_mode_str = raw.get("output_mode").and_then(|v| v.as_str());
let path = str_field("path")
.or_else(|| str_field("target_directory"))
.map(|p| make_relative_path(&p))
.filter(|p| p != ".");
SearchInputMeta {
path,
glob: str_field("glob"),
output_mode: SearchOutputMode::from_str_opt(output_mode_str),
case_insensitive: bool_field("-i"),
file_type: str_field("type"),
multiline: bool_field("multiline"),
}
}
/// Extract BashOutput from a serde_json::Value containing ToolOutput::Bash.
fn extract_bash_output_from_value(raw: &Option<serde_json::Value>) -> Option<BashOutput> {
let val = raw.as_ref()?;
match serde_json::from_value::<ToolOutput>(val.clone()) {
Ok(ToolOutput::Bash(bash)) => Some(bash),
_ => None,
}
}
/// Extracted grep search results.
#[derive(Default)]
struct GrepResult {
match_count: usize,
file_matches: Vec<SearchFileMatch>,
/// File paths only (for files_with_matches output mode).
file_paths: Vec<String>,
}
/// Extract grep search results from raw_output.
fn extract_grep_output(raw: &Option<serde_json::Value>) -> Option<GrepResult> {
let val = raw.as_ref()?;
match serde_json::from_value::<ToolOutput>(val.clone()) {
Ok(ToolOutput::GrepSearch(grep)) => {
let file_matches: Vec<SearchFileMatch> = grep
.file_matches
.into_iter()
.map(|fm| SearchFileMatch {
path: make_relative_path(&fm.path),
matches: fm
.matches
.into_iter()
.map(|m| SearchLineMatch {
line_number: m.line_number,
content: m.content,
})
.collect(),
})
.collect();
let file_paths = if file_matches.is_empty() && grep.match_count > 0 {
let stdout_str = String::from_utf8_lossy(&grep.stdout);
parse_file_paths_from_stdout(&stdout_str)
} else {
vec![]
};
Some(GrepResult {
match_count: grep.match_count,
file_matches,
file_paths,
})
}
_ => None,
}
}
/// Parse file paths from grep stdout in workspace_result XML format.
///
/// The stdout format is:
/// ```text
/// <workspace_result workspace_path="/path">
/// Found N files
/// /path/to/file1.rs
/// /path/to/file2.rs
/// </workspace_result>
/// ```
fn parse_file_paths_from_stdout(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter(|line| !line.is_empty() && !line.starts_with('<') && !line.starts_with("Found "))
.map(make_relative_path)
.collect()
}
/// Extract directory listing content from rawOutput.
fn extract_listdir_content(raw: &Option<serde_json::Value>) -> Option<String> {
let val = raw.as_ref()?;
match serde_json::from_value::<ToolOutput>(val.clone()) {
Ok(ToolOutput::ListDir(kigi_tools::types::output::ListDirOutput::Content(c))) => {
Some(c.content)
}
_ => None,
}
}
/// Extract the agent's advertised toolset from
/// `AvailableCommandsUpdate.meta`.
///
/// Wire format set by the shell: `{"tools": ["read_file", ...]}`.
/// Returns `None` if `meta` is absent, has no `tools` array, or the
/// array contains no string entries (defensive against future shape
/// drift). An empty `Vec` would mean "the shell told us there are zero
/// tools" -- pager `CommandRegistry::set_available_tools(empty)` then
/// hides every tool-gated command.
fn parse_tools_meta(meta: Option<&acp::Meta>) -> Option<Vec<String>> {
let arr = meta?.get("tools")?.as_array()?;
Some(
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
)
}
/// Compact one-line description of a `SessionUpdate` for the always-on
/// `acp_update` log target.
///
/// Deliberately avoids serializing payloads: emits variant names, ids,
/// statuses, and *sizes* only, so the line stays O(100B) no matter how large
/// the update is. Full payloads go to the opt-in `acp_update_payload` target.
fn update_summary(update: &acp::SessionUpdate) -> String {
match update {
acp::SessionUpdate::UserMessageChunk(chunk) => {
format!(
"user_message_chunk {}",
content_block_summary(&chunk.content)
)
}
acp::SessionUpdate::AgentMessageChunk(chunk) => {
format!(
"agent_message_chunk {}",
content_block_summary(&chunk.content)
)
}
acp::SessionUpdate::AgentThoughtChunk(chunk) => {
format!(
"agent_thought_chunk {}",
content_block_summary(&chunk.content)
)
}
acp::SessionUpdate::ToolCall(tc) => {
format!(
"tool_call id={} kind={:?} status={:?} title={:?} content={} raw_input={}",
tc.tool_call_id.0,
tc.kind,
tc.status,
tc.title,
tc.content.len(),
tc.raw_input
.as_ref()
.map_or_else(|| "none".to_string(), json_size_hint),
)
}
acp::SessionUpdate::ToolCallUpdate(tcu) => {
let f = &tcu.fields;
format!(
"tool_call_update id={} status={:?} title={:?} content={} raw_output={}",
tcu.tool_call_id.0,
f.status,
f.title,
f.content
.as_ref()
.map_or_else(|| "none".to_string(), |c| c.len().to_string()),
f.raw_output
.as_ref()
.map_or_else(|| "none".to_string(), json_size_hint),
)
}
acp::SessionUpdate::Plan(plan) => format!("plan entries={}", plan.entries.len()),
acp::SessionUpdate::AvailableCommandsUpdate(u) => {
format!(
"available_commands_update commands={}",
u.available_commands.len()
)
}
acp::SessionUpdate::CurrentModeUpdate(u) => {
format!("current_mode_update mode={}", u.current_mode_id.0)
}
_ => "unknown_update".to_string(),
}
}
/// Compact description of a `ContentBlock`: type plus payload size in bytes.
fn content_block_summary(content: &acp::ContentBlock) -> String {
match content {
acp::ContentBlock::Text(t) => format!("text={}B", t.text.len()),
acp::ContentBlock::Image(i) => {
format!("image={}B mime={}", i.data.len(), i.mime_type)
}
acp::ContentBlock::Audio(a) => {
format!("audio={}B mime={}", a.data.len(), a.mime_type)
}
acp::ContentBlock::ResourceLink(r) => format!("resource_link={}", r.uri),
acp::ContentBlock::Resource(_) => "resource".to_string(),
_ => "unknown_content".to_string(),
}
}
/// Cheap size descriptor for a `serde_json::Value` without serializing it.
///
/// Strings report byte length; arrays report element count (bash raw_output
/// is a `Vec<u8>`, so element count == output bytes); objects report key
/// count plus the summed size of direct string/array members (one level, no
/// recursion). This keeps the cost O(top-level members), never O(payload).
fn json_size_hint(v: &serde_json::Value) -> String {
use serde_json::Value;
match v {
Value::Null => "null".to_string(),
Value::Bool(_) => "bool".to_string(),
Value::Number(_) => "number".to_string(),
Value::String(s) => format!("str({}B)", s.len()),
Value::Array(a) => format!("arr({})", a.len()),
Value::Object(o) => {
let inner: usize = o
.values()
.map(|m| match m {
Value::String(s) => s.len(),
Value::Array(a) => a.len(),
_ => 0,
})
.sum();
format!("obj({} keys, ~{}B)", o.len(), inner)
}
}
}
/// Compact rendering of the interesting `NotificationMeta` fields.
fn meta_summary(meta: &NotificationMeta) -> String {
format!(
"seq={} tokens={} prompt={} stream_start={}",
meta.event_seq
.map_or_else(|| "-".to_string(), |v| v.to_string()),
meta.total_tokens
.map_or_else(|| "-".to_string(), |v| v.to_string()),
meta.prompt_id.as_deref().unwrap_or("-"),
meta.stream_start_ms
.map_or_else(|| "-".to_string(), |v| v.to_string()),
)
}
/// Parse the JSON content from a SearchToolOutput into DiscoveredTool entries.
///
/// Results are grouped by server: `{"results": [{"server": "...", "tools": [...]}]}`.
/// Each tool has `tool_name`, `description`, `score`, and `input_schema`.
fn parse_search_tool_results(content: &str) -> Vec<DiscoveredTool> {
let Ok(val) = serde_json::from_str::<serde_json::Value>(content) else {
return Vec::new();
};
let Some(groups) = val.get("results").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out = Vec::new();
for group in groups {
let server = group
.get("server")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let Some(tools) = group.get("tools").and_then(|v| v.as_array()) else {
continue;
};
for r in tools {
let Some(name) = r.get("tool_name").and_then(|v| v.as_str()) else {
continue;
};
let description = r
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let score = r.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
out.push(DiscoveredTool {
name: name.to_owned(),
server: server.clone(),
description,
score,
});
}
}
out
}
/// Extract output text from a use_tool's raw_output.
///
/// MCP tools don't put content in ACP content blocks — they only set raw_output.
/// This extracts the text from ToolOutput::MCP, ToolOutput::Text, or
/// ToolOutput::Dynamic variants.
fn extract_use_tool_output(raw: &Option<serde_json::Value>) -> Option<String> {
let val = raw.as_ref()?;
if let Ok(output) = serde_json::from_value::<ToolOutput>(val.clone()) {
let text = match output {
ToolOutput::MCP(mcp) => {
use kigi_tools::types::output::MCPOutputDetails;
match mcp.output() {
MCPOutputDetails::OkayOutput(s) | MCPOutputDetails::Error(s) => s.clone(),
}
}
ToolOutput::Text(text) => text.text,
ToolOutput::Dynamic(v) => {
return Some(serde_json::to_string_pretty(&v).unwrap_or_default());
}
_ => return None,
};
return Some(maybe_pretty_json(&text));
}
val.as_str().map(maybe_pretty_json)
}
/// If the string is valid JSON, pretty-print it. Otherwise return as-is.
fn maybe_pretty_json(s: &str) -> String {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(s) {
serde_json::to_string_pretty(&v).unwrap_or_else(|_| s.to_owned())
} else {
s.to_owned()
}
}
/// Extract input arguments from a use_tool call's raw_input.tool_input.
///
/// Flattens the top-level JSON object into key-value string pairs for display.
/// Nested objects/arrays are rendered as compact JSON strings.
fn extract_use_tool_args(tc: &acp::ToolCall) -> Vec<(String, String)> {
let Some(raw) = tc.raw_input.as_ref() else {
return Vec::new();
};
let Some(tool_input) = raw.get("tool_input") else {
return Vec::new();
};
let Some(obj) = tool_input.as_object() else {
return Vec::new();
};
obj.iter()
.map(|(k, v)| {
let display = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => "null".to_owned(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
other => serde_json::to_string(other).unwrap_or_default(),
};
(k.clone(), display)
})
.collect()
}
/// Convert an absolute path to relative by stripping the current working directory.
fn make_relative_path(path: &str) -> String {
if let Ok(cwd) = std::env::current_dir() {
let cwd_str = cwd.to_string_lossy();
if let Some(rel) = path.strip_prefix(cwd_str.as_ref()) {
let rel = rel.strip_prefix('/').unwrap_or(rel);
return if rel.is_empty() {
".".to_string()
} else {
rel.to_string()
};
}
}
path.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Default meta with no timestamps (simulates old grok-shell or tests that
/// don't care about timing).
fn meta() -> NotificationMeta {
NotificationMeta::default()
}
fn agent_chunk(text: &str) -> acp::SessionUpdate {
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(text.to_string()),
)))
}
fn thought_chunk(text: &str) -> acp::SessionUpdate {
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(text.to_string()),
)))
}
fn tool_call(id: &str, kind: acp::ToolKind, title: &str) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(acp::ToolCallId::new(Arc::from(id)), title.to_string())
.kind(kind)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.locations(vec![]),
)
}
fn tool_call_completed(id: &str, kind: acp::ToolKind, title: &str) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(acp::ToolCallId::new(Arc::from(id)), title.to_string())
.kind(kind)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.locations(vec![]),
)
}
fn tool_update_completed(id: &str) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(id)),
acp::ToolCallUpdateFields::new().status(Some(acp::ToolCallStatus::Completed)),
))
}
fn user_message(text: &str) -> acp::SessionUpdate {
acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(text.to_string()),
)))
}
#[test]
fn streaming_agent_message() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(tracker.handle_update(agent_chunk("Hello "), &meta(), &mut sb));
assert!(tracker.handle_update(agent_chunk("world!"), &meta(), &mut sb));
assert_eq!(sb.len(), 1);
assert!(tracker.current_agent_msg.is_some());
}
#[test]
fn agent_output_epoch_tracks_visible_live_output() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(tracker.handle_update(user_message("prompt"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 0);
assert!(tracker.handle_update(agent_chunk("response"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 1);
let replay = NotificationMeta {
is_replay: true,
..Default::default()
};
assert!(tracker.handle_update(agent_chunk(" replay"), &replay, &mut sb));
assert_eq!(tracker.agent_output_epoch(), 1);
assert!(tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 2);
assert!(tracker.handle_update(
tool_call("read-1", acp::ToolKind::Read, "read_file"),
&meta(),
&mut sb,
));
assert_eq!(tracker.agent_output_epoch(), 3);
assert!(tracker.handle_update(tool_update_completed("read-1"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 4);
assert!(!tracker.handle_update(
tool_call("todo-1", acp::ToolKind::Other, "TodoWrite"),
&meta(),
&mut sb,
));
assert_eq!(tracker.agent_output_epoch(), 4);
}
#[test]
fn streaming_thinking() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(tracker.handle_update(thought_chunk("Let me think"), &meta(), &mut sb));
assert!(tracker.handle_update(thought_chunk("..."), &meta(), &mut sb));
assert_eq!(sb.len(), 1);
assert!(tracker.current_thinking.is_some());
}
#[test]
fn pre_create_thinking_no_op_when_flag_off() {
crate::appearance::cache::set_show_thinking_blocks(false);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.pre_create_thinking(&mut sb);
assert_eq!(sb.len(), 0);
assert!(tracker.current_thinking.is_none());
crate::appearance::cache::set_show_thinking_blocks(true);
}
#[test]
fn thought_chunk_dropped_when_flag_off() {
crate::appearance::cache::set_show_thinking_blocks(false);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(!tracker.handle_update(thought_chunk("secret reasoning"), &meta(), &mut sb));
assert_eq!(sb.len(), 0);
assert!(tracker.current_thinking.is_none());
crate::appearance::cache::set_show_thinking_blocks(true);
assert!(tracker.handle_update(thought_chunk("visible now"), &meta(), &mut sb));
assert_eq!(sb.len(), 1);
assert!(tracker.current_thinking.is_some());
}
#[test]
fn pre_create_thinking_creates_when_flag_on() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.pre_create_thinking(&mut sb);
assert_eq!(sb.len(), 1);
assert!(tracker.current_thinking.is_some());
}
#[test]
fn thinking_then_agent_message() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("thinking..."), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
tracker.handle_update(agent_chunk("Here's my answer"), &meta(), &mut sb);
assert_eq!(sb.len(), 2);
assert!(tracker.current_thinking.is_none());
}
#[test]
fn replayed_thinking_uses_server_elapsed_not_local_zero() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let replay_meta = NotificationMeta {
is_replay: true,
stream_start_ms: Some(1_000_000),
agent_timestamp_ms: Some(1_002_000),
..NotificationMeta::default()
};
tracker.handle_update(thought_chunk("pondering deeply"), &replay_meta, &mut sb);
tracker.handle_update(agent_chunk("done"), &replay_meta, &mut sb);
let entries = sb.entries_in_range(0..sb.len());
let thinking = entries
.iter()
.find_map(|e| match &e.block {
RenderBlock::Thinking(t) => Some(t),
_ => None,
})
.expect("a thinking block should survive replay (non-empty content)");
assert_eq!(
thinking.elapsed_time_ms(),
Some(2000),
"replayed thinking must use server elapsed, not a ~0ms local-timer freeze"
);
}
#[test]
fn live_thinking_keeps_local_elapsed_timer() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("thinking live"), &meta(), &mut sb);
let entries = sb.entries_in_range(0..sb.len());
let thinking = entries
.iter()
.find_map(|e| match &e.block {
RenderBlock::Thinking(t) => Some(t),
_ => None,
})
.expect("a live thinking block should exist");
assert!(
thinking.elapsed_time_ms().is_some(),
"live thinking must keep a local elapsed timer (started_at armed)"
);
}
#[test]
fn tool_call_lifecycle() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Read, "src/main.rs"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
tracker.handle_update(tool_update_completed("tc1"), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 0);
}
#[test]
fn tool_call_already_completed() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call_completed("tc1", acp::ToolKind::Read, "src/main.rs"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 0);
}
#[test]
fn agent_msg_resets_after_tool() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("Before tool"), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
tracker.handle_update(
tool_call_completed("tc1", acp::ToolKind::Read, "file.rs"),
&meta(),
&mut sb,
);
assert!(tracker.current_agent_msg.is_none());
tracker.handle_update(agent_chunk("After tool"), &meta(), &mut sb);
assert_eq!(sb.len(), 3);
}
#[test]
fn finish_turn_clears_state() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("text"), &meta(), &mut sb);
tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb);
assert!(tracker.current_agent_msg.is_some());
assert!(tracker.current_thinking.is_some());
tracker.finish_turn(&mut sb);
assert!(tracker.current_agent_msg.is_none());
assert!(tracker.current_thinking.is_none());
assert!(tracker.pending_tools.is_empty());
assert!(
!sb.needs_animation(),
"no entries should be running after finish_turn"
);
}
#[test]
fn user_message_replay() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(user_message("What is Rust?"), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
}
#[test]
fn empty_chunks_ignored() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(!tracker.handle_update(agent_chunk(""), &meta(), &mut sb));
assert!(!tracker.handle_update(thought_chunk(""), &meta(), &mut sb));
assert_eq!(sb.len(), 0);
}
/// Regression test: two turns should create separate agent message entries.
///
/// Previously, handle_user_message() didn't reset current_agent_msg,
/// so the second turn's agent message chunks got appended to the first
/// turn's entry, producing concatenated text.
#[test]
fn two_turns_separate_agent_messages() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(user_message("whats the current date"), &meta(), &mut sb);
tracker.handle_update(thought_chunk("thinking about date..."), &meta(), &mut sb);
tracker.handle_update(
agent_chunk("The current date is February 8, 2026."),
&meta(),
&mut sb,
);
tracker.handle_update(
user_message("whats the weather in london"),
&meta(),
&mut sb,
);
tracker.handle_update(thought_chunk("thinking about weather..."), &meta(), &mut sb);
tracker.handle_update(
agent_chunk("I don't have access to weather data."),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 6, "Each turn should have its own entries");
let entry2 = sb.get(2).expect("entry 2");
let entry5 = sb.get(5).expect("entry 5");
assert_ne!(
entry2.id, entry5.id,
"Agent messages from different turns must be separate entries"
);
}
/// Regression test: user_message should reset tracking state.
#[test]
fn user_message_resets_tracking() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("hello"), &meta(), &mut sb);
assert!(tracker.current_agent_msg.is_some());
tracker.handle_update(user_message("new question"), &meta(), &mut sb);
assert!(
tracker.current_agent_msg.is_none(),
"user_message should reset current_agent_msg"
);
assert!(
tracker.current_thinking.is_none(),
"user_message should reset current_thinking"
);
}
/// Regression test: exact real-world flow where send_prompt adds user entry
/// directly to scrollback (bypassing tracker), then tracker receives echo + response.
///
/// This matches what actually happens in the app:
/// 1. send_prompt() pushes user entry + calls expect_user_echo()
/// 2. ACP echoes user_message_chunk → tracker skips it (no duplicate)
/// 3. ACP streams thought_chunk, agent_message_chunk
/// 4. User sends second prompt via send_prompt
/// 5. ACP echoes + streams second turn
///
/// The critical invariant: exactly 1 user entry per turn, 2 separate agent messages.
#[test]
fn real_flow_two_turns_via_send_prompt() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
sb.push_block(RenderBlock::user_prompt("whats the date"));
tracker.expect_user_echo();
let modified = tracker.handle_update(user_message("whats the date"), &meta(), &mut sb);
assert!(!modified, "echo should be skipped, not modify scrollback");
assert_eq!(sb.len(), 1, "still just 1 entry (direct push only)");
tracker.handle_update(thought_chunk("thinking about date..."), &meta(), &mut sb);
tracker.handle_update(
agent_chunk("Today's date is February 8, 2026."),
&meta(),
&mut sb,
);
assert!(
tracker.current_agent_msg.is_some(),
"turn 1 agent msg should be tracked"
);
tracker.finish_turn(&mut sb);
sb.push_block(RenderBlock::user_prompt("whats the current weather"));
tracker.expect_user_echo();
let modified =
tracker.handle_update(user_message("whats the current weather"), &meta(), &mut sb);
assert!(!modified, "second echo should also be skipped");
assert!(
tracker.current_agent_msg.is_none(),
"echo should have reset current_agent_msg"
);
tracker.handle_update(thought_chunk("thinking about weather..."), &meta(), &mut sb);
tracker.handle_update(
agent_chunk("I don't have access to weather data."),
&meta(),
&mut sb,
);
let agent_msg_indices: Vec<usize> = (0..sb.len())
.filter(|&i| matches!(sb.get(i).unwrap().block, RenderBlock::AgentMessage(_)))
.collect();
assert_eq!(
agent_msg_indices.len(),
2,
"Should have exactly 2 separate agent message entries, got {}. Total entries: {}",
agent_msg_indices.len(),
sb.len(),
);
let user_count = (0..sb.len())
.filter(|&i| matches!(sb.get(i).unwrap().block, RenderBlock::UserPrompt(_)))
.count();
assert_eq!(
user_count, 2,
"exactly 2 user entries (no duplicates from echo)"
);
}
/// Test: two turns where finish_turn() is called between them
/// (simulating send_prompt calling finish_turn before new turn).
/// No echo user_message_chunk — just direct scrollback manipulation + tracker.
#[test]
fn two_turns_with_finish_turn_between() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
sb.push_block(RenderBlock::user_prompt("whats the date"));
tracker.handle_update(thought_chunk("thinking..."), &meta(), &mut sb);
tracker.handle_update(agent_chunk("Today is February 8, 2026."), &meta(), &mut sb);
assert!(tracker.current_agent_msg.is_some());
tracker.finish_turn(&mut sb);
assert!(tracker.current_agent_msg.is_none());
sb.push_block(RenderBlock::user_prompt("whats the weather"));
tracker.handle_update(thought_chunk("thinking about weather..."), &meta(), &mut sb);
tracker.handle_update(agent_chunk("I can't check weather."), &meta(), &mut sb);
let agent_msg_count = (0..sb.len())
.filter(|&i| matches!(sb.get(i).unwrap().block, RenderBlock::AgentMessage(_)))
.count();
assert_eq!(
agent_msg_count, 2,
"Must have 2 separate agent messages, got {}",
agent_msg_count,
);
}
/// Test: expect_user_echo skips exactly one echo, then allows normal flow.
#[test]
fn expect_user_echo_skips_one() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
sb.push_block(RenderBlock::user_prompt("hello"));
tracker.expect_user_echo();
assert!(!tracker.handle_update(user_message("hello"), &meta(), &mut sb));
assert_eq!(sb.len(), 1, "echo should not add a duplicate");
assert!(tracker.handle_update(user_message("world"), &meta(), &mut sb));
assert_eq!(sb.len(), 2, "second message should be added normally");
}
/// The echoed promptIndex belongs to the turn-starting prompt: an
/// interjection that lands between the local push and the echo (laggy
/// link) must not steal the backfilled index — the shell never numbers
/// interjections.
#[test]
fn echo_prompt_index_backfill_skips_interjections() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let prompt_id = sb.push_block(RenderBlock::user_prompt("real prompt"));
tracker.expect_user_echo();
let ij_id = sb.push_block(RenderBlock::interjection_prompt("steer"));
let echo = acp::SessionUpdate::UserMessageChunk(
acp::ContentChunk::new(acp::ContentBlock::Text(acp::TextContent::new(
"real prompt".to_string(),
)))
.meta(
serde_json::json!({ "promptIndex" : 3 })
.as_object()
.cloned(),
),
);
assert!(
!tracker.handle_update(echo, &meta(), &mut sb),
"echo is skipped"
);
let prompt_idx = sb.index_of_id(prompt_id).unwrap();
match &sb.get(prompt_idx).unwrap().block {
RenderBlock::UserPrompt(b) => assert_eq!(b.prompt_index, Some(3)),
other => panic!("expected UserPrompt, got {other:?}"),
}
let ij_idx = sb.index_of_id(ij_id).unwrap();
match &sb.get(ij_idx).unwrap().block {
RenderBlock::UserPrompt(b) => {
assert!(b.is_interjection);
assert_eq!(
b.prompt_index, None,
"interjection must not steal the echoed index"
);
}
other => panic!("expected UserPrompt, got {other:?}"),
}
}
/// Test: session replay (no expect_user_echo) still creates user entries.
#[test]
fn session_replay_creates_user_entries() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(tracker.handle_update(user_message("old question"), &meta(), &mut sb));
assert_eq!(sb.len(), 1);
tracker.handle_update(agent_chunk("old answer"), &meta(), &mut sb);
assert!(tracker.handle_update(user_message("second question"), &meta(), &mut sb));
assert_eq!(sb.len(), 3);
}
/// Skill replay: XML metadata becomes a clean skill block, body is absorbed.
#[test]
fn skill_replay_creates_clean_block() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let xml = "<command-name>implement</command-name>\n\
<command-message>/implement</command-message>\n\
<command-args>fix the rendering bug</command-args>";
assert!(tracker.handle_update(user_message(xml), &meta(), &mut sb));
assert_eq!(sb.len(), 1);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::UserPrompt(block) => {
assert_eq!(
block.skill_token_ranges,
vec![0..10],
"leading /implement token styled as skill"
);
assert_eq!(block.text, "/implement fix the rendering bug");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
assert!(
!tracker.handle_update(user_message("You are an orchestrator..."), &meta(), &mut sb,),
"skill body should be absorbed",
);
assert_eq!(sb.len(), 1, "no new entry for skill body");
}
/// Skill replay without args still creates a clean block.
#[test]
fn skill_replay_no_args() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let xml = "<command-name>deploy</command-name>\n\
<command-message>/deploy</command-message>";
assert!(tracker.handle_update(user_message(xml), &meta(), &mut sb));
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.skill_token_ranges, vec![0..7]);
assert_eq!(block.text, "/deploy");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
assert!(!tracker.handle_update(user_message("Deploy instructions"), &meta(), &mut sb,));
assert_eq!(sb.len(), 1);
}
/// Live execution: echo-skip + skill body skip work together.
#[test]
fn skill_echo_skips_both_chunks() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
sb.push_block(RenderBlock::skill_prompt("/implement fix bug"));
tracker.expect_user_echo();
let xml = "<command-name>implement</command-name>\n\
<command-message>/implement</command-message>\n\
<command-args>fix bug</command-args>";
assert!(!tracker.handle_update(user_message(xml), &meta(), &mut sb));
assert_eq!(sb.len(), 1, "echo should not add a duplicate");
assert!(!tracker.handle_update(
user_message("You are an orchestrator..."),
&meta(),
&mut sb,
));
assert_eq!(sb.len(), 1, "skill body echo should be absorbed");
assert!(tracker.handle_update(user_message("follow-up question"), &meta(), &mut sb,));
assert_eq!(sb.len(), 2);
}
/// finish_turn clears stale skip_next_skill_body.
#[test]
fn finish_turn_clears_skill_body_skip() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let xml = "<command-name>commit</command-name>\n\
<command-message>/commit</command-message>";
tracker.handle_update(user_message(xml), &meta(), &mut sb);
assert!(tracker.skip_next_skill_body);
tracker.finish_turn(&mut sb);
assert!(!tracker.skip_next_skill_body);
assert!(tracker.handle_update(user_message("new question"), &meta(), &mut sb,));
assert_eq!(sb.len(), 2);
}
#[test]
fn tool_update_before_tool_call_race() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(!tracker.handle_update(tool_update_completed("tc1"), &meta(), &mut sb));
assert_eq!(sb.len(), 0);
assert_eq!(tracker.orphan_updates.len(), 1);
assert!(tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `echo hi`"),
&meta(),
&mut sb,
));
assert_eq!(sb.len(), 1);
assert_eq!(tracker.orphan_updates.len(), 0);
assert_eq!(tracker.pending_tools.len(), 0);
}
#[test]
fn tool_update_before_tool_call_preserves_kind() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(tool_update_completed("tc1"), &meta(), &mut sb);
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `ls`"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(_)) => {}
other => panic!("Expected Execute block, got {:?}", other),
}
}
#[test]
fn tool_normal_order_still_works() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Read, "src/lib.rs"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
assert_eq!(tracker.orphan_updates.len(), 0);
tracker.handle_update(tool_update_completed("tc1"), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 0);
}
/// Test thinking elapsed time computed from server timestamps.
#[test]
fn thinking_elapsed_from_server_timestamps() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let stream_start = 1700000000000i64;
let make_meta = |agent_ts: i64| NotificationMeta {
agent_timestamp_ms: Some(agent_ts),
stream_start_ms: Some(stream_start),
..Default::default()
};
tracker.handle_update(
thought_chunk("Let me think"),
&make_meta(stream_start + 500),
&mut sb,
);
assert_eq!(tracker.last_thinking_elapsed_ms, Some(500));
tracker.handle_update(
thought_chunk("...still thinking"),
&make_meta(stream_start + 3200),
&mut sb,
);
assert_eq!(tracker.last_thinking_elapsed_ms, Some(3200));
tracker.handle_update(agent_chunk("Here's my answer"), &meta(), &mut sb);
assert!(tracker.current_thinking.is_none());
assert_eq!(tracker.last_thinking_elapsed_ms, None);
}
/// Test thinking elapsed is None when server doesn't send timestamps.
#[test]
fn thinking_elapsed_none_without_timestamps() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb);
assert_eq!(tracker.last_thinking_elapsed_ms, None);
tracker.handle_update(agent_chunk("done"), &meta(), &mut sb);
assert_eq!(tracker.last_thinking_elapsed_ms, None);
}
#[test]
fn agent_message_uses_server_timestamp() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let ts_ms = 1700000000000i64;
let replay_meta = NotificationMeta {
agent_timestamp_ms: Some(ts_ms),
is_replay: true,
..Default::default()
};
tracker.handle_update(agent_chunk("Hello"), &replay_meta, &mut sb);
let entry = sb.get(0).unwrap();
let created = entry.created_at.expect("entry should have created_at");
let expected = utc_ms_to_local(ts_ms);
assert_eq!(
created.timestamp(),
expected.timestamp(),
"Agent message should use server timestamp, not Local::now()"
);
}
#[test]
fn user_message_uses_server_timestamp() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let ts_ms = 1700000000000i64;
let replay_meta = NotificationMeta {
turn_start_ms: Some(ts_ms),
is_replay: true,
..Default::default()
};
tracker.handle_update(user_message("Hello user"), &replay_meta, &mut sb);
let entry = sb.get(0).unwrap();
let created = entry.created_at.expect("entry should have created_at");
let expected = utc_ms_to_local(ts_ms);
assert_eq!(
created.timestamp(),
expected.timestamp(),
"User message should use server turn_start_ms timestamp"
);
}
#[test]
fn entry_falls_back_to_now_without_server_timestamp() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let before = chrono::Local::now();
tracker.handle_update(agent_chunk("live message"), &meta(), &mut sb);
let after = chrono::Local::now();
let entry = sb.get(0).unwrap();
let created = entry.created_at.expect("entry should have created_at");
assert!(
created >= before && created <= after,
"Without server timestamp, should fall back to Local::now()"
);
}
/// Helper: create a ToolCallUpdate with InProgress status and BashOutput raw_output.
fn tool_update_in_progress(id: &str, output_bytes: &[u8]) -> acp::SessionUpdate {
use kigi_tools::types::output::{BashOutput, ToolOutput};
let bash = BashOutput {
output: output_bytes.to_vec(),
output_for_prompt: String::new(),
exit_code: 0,
command: String::new(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: String::new(),
output_file: String::new(),
total_bytes: output_bytes.len(),
output_delta: None,
was_bare_echo: false,
};
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(id)),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.raw_output(serde_json::to_value(ToolOutput::Bash(bash)).ok()),
))
}
/// Helper: create a completed ToolCallUpdate with BashOutput.
fn tool_update_completed_bash(
id: &str,
output_bytes: &[u8],
exit_code: i32,
) -> acp::SessionUpdate {
use kigi_tools::types::output::{BashOutput, ToolOutput};
let status = if exit_code == 0 {
acp::ToolCallStatus::Completed
} else {
acp::ToolCallStatus::Failed
};
let bash = BashOutput {
output: output_bytes.to_vec(),
output_for_prompt: String::new(),
exit_code,
command: "test".to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: output_bytes.len(),
output_delta: None,
was_bare_echo: false,
};
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(id)),
acp::ToolCallUpdateFields::new()
.status(Some(status))
.raw_output(serde_json::to_value(ToolOutput::Bash(bash)).ok()),
))
}
/// Helper: create a ToolCall with raw_input containing command + description.
fn tool_call_execute_with_desc(
id: &str,
command: &str,
description: &str,
) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from(id)),
format!("Execute `{}`", command),
)
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.raw_input(Some(serde_json::json!(
{ "command" : command, "description" : description, }
)))
.locations(vec![]),
)
}
/// Streaming execute: InProgress updates push output to the block.
#[test]
fn streaming_execute_in_progress() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `cargo build`"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
let modified = tracker.handle_update(
tool_update_in_progress("tc1", b"Compiling"),
&meta(),
&mut sb,
);
assert!(modified, "InProgress update should modify scrollback");
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(exec.output.as_deref(), Some("Compiling"));
}
other => panic!("Expected Execute block, got {:?}", other),
}
tracker.handle_update(
tool_update_in_progress("tc1", b"Compiling crate v0.1.0\n Finished"),
&meta(),
&mut sb,
);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(
exec.output.as_deref(),
Some("Compiling crate v0.1.0\n Finished")
);
}
other => panic!("Expected Execute block, got {:?}", other),
}
assert_eq!(tracker.pending_tools.len(), 1);
assert!(sb.get(0).unwrap().is_running);
}
/// Streaming execute: completed update replaces block with final output.
#[test]
fn streaming_execute_completion() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `echo hello`"),
&meta(),
&mut sb,
);
tracker.handle_update(tool_update_in_progress("tc1", b"hello\n"), &meta(), &mut sb);
tracker.handle_update(
tool_update_completed_bash("tc1", b"hello\n", 0),
&meta(),
&mut sb,
);
assert_eq!(tracker.pending_tools.len(), 0);
assert!(!sb.get(0).unwrap().is_running);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(exec.output.as_deref(), Some("hello\n"));
assert!(exec.error.is_none(), "exit code 0 = no error");
}
other => panic!("Expected Execute block, got {:?}", other),
}
}
/// Streaming execute: failed command shows error.
#[test]
fn streaming_execute_failure() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `false`"),
&meta(),
&mut sb,
);
tracker.handle_update(tool_update_completed_bash("tc1", b"", 1), &meta(), &mut sb);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert!(exec.error.is_some(), "non-zero exit should set error");
assert!(
exec.error.as_deref().unwrap().contains("exit code 1"),
"error should mention exit code"
);
}
other => panic!("Expected Execute block, got {:?}", other),
}
}
/// Execute with description from raw_input.
#[test]
fn execute_with_description() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call_execute_with_desc("tc1", "cargo test", "Run the test suite"),
&meta(),
&mut sb,
);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(exec.command, "cargo test");
assert_eq!(exec.description.as_deref(), Some("Run the test suite"));
}
other => panic!("Expected Execute block, got {:?}", other),
}
}
/// InProgress update for non-execute tool is ignored.
#[test]
fn in_progress_update_ignored_for_non_execute() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Read, "src/main.rs"),
&meta(),
&mut sb,
);
let modified = tracker.handle_update(
tool_update_in_progress("tc1", b"file content"),
&meta(),
&mut sb,
);
assert!(
!modified,
"InProgress with bash output should be ignored for Read blocks"
);
}
/// Output is passed through without modification (no-color mode means
/// the shell sends clean output without ANSI codes).
#[test]
fn streaming_execute_passes_output_through() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `ls`"),
&meta(),
&mut sb,
);
tracker.handle_update(
tool_update_in_progress("tc1", b"green text"),
&meta(),
&mut sb,
);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(
exec.output.as_deref(),
Some("green text"),
"Output should be passed through as-is"
);
}
other => panic!("Expected Execute block, got {:?}", other),
}
}
/// Verify ToolOutput::Bash round-trips through serde_json::Value correctly.
/// This mimics the exact path: streaming_local_terminal serializes with
/// serde_json::to_value(ToolOutput::Bash(...)), and tracker deserializes with
/// serde_json::from_value::<ToolOutput>(...).
#[test]
fn tool_output_bash_serde_roundtrip() {
use kigi_tools::types::output::{BashOutput, ToolOutput};
let bash = BashOutput {
output: b"hello world\n".to_vec(),
output_for_prompt: String::new(),
exit_code: 0,
command: "echo hello".to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: 12,
output_delta: None,
was_bare_echo: false,
};
let value = serde_json::to_value(ToolOutput::Bash(bash)).unwrap();
assert_eq!(
value.get("type").and_then(|v| v.as_str()),
Some("Bash"),
"ToolOutput should serialize with type tag"
);
assert!(value.get("output").is_some(), "Should have output field");
let deserialized: ToolOutput = serde_json::from_value(value).unwrap();
match deserialized {
ToolOutput::Bash(bash) => {
assert_eq!(bash.output, b"hello world\n");
assert_eq!(bash.command, "echo hello");
}
_ => panic!("Expected ToolOutput::Bash"),
}
}
/// End-to-end test mimicking the exact production notification sequence:
/// 1. ToolCall (Pending) with raw_input containing BashTool
/// 2. InProgress ToolCallUpdate with raw_output containing ToolOutput::Bash
/// (sent by notification_bridge from LocalTerminalBackend)
/// 3. Completed ToolCallUpdate with raw_output containing final ToolOutput::Bash
/// 4. Second Completed ToolCallUpdate (from acp_session completion handler)
#[test]
fn production_execute_sequence() {
use kigi_tools::types::output::{BashOutput, ToolOutput};
use serde_json::json;
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let tc_id = "call_abc123";
let tc = acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from(tc_id)),
"Execute `python tmp/test.py`".to_string(),
)
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Pending)
.content(vec![acp::ToolCallContent::from(acp::ContentBlock::Text(
acp::TextContent::new("Running Python script".to_string()),
))])
.raw_input(Some(json!(
{ "command" : "python tmp/test.py", "description" :
"Running Python script" }
)))
.locations(vec![]),
);
tracker.handle_update(tc, &meta(), &mut sb);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
let bash_output = ToolOutput::Bash(BashOutput {
output_for_prompt: String::new(),
output: b"Step 1: loading...\n".to_vec(),
exit_code: 0,
command: "python tmp/test.py".to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: 19,
output_delta: None,
was_bare_echo: false,
});
let in_progress = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(tc_id)),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.content(Some(vec![acp::ToolCallContent::from(
acp::ContentBlock::Text(acp::TextContent::new(
"Step 1: loading...\n".to_string(),
)),
)]))
.raw_output(serde_json::to_value(&bash_output).ok()),
));
let modified = tracker.handle_update(in_progress, &meta(), &mut sb);
assert!(modified, "InProgress should trigger redraw");
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(
exec.output.as_deref(),
Some("Step 1: loading...\n"),
"Streaming output should be set"
);
}
other => panic!("Expected Execute block, got {:?}", other),
}
let bash_output2 = ToolOutput::Bash(BashOutput {
output_for_prompt: String::new(),
output: b"Step 1: loading...\nStep 2: processing...\n".to_vec(),
exit_code: 0,
command: "python tmp/test.py".to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: 40,
output_delta: None,
was_bare_echo: false,
});
let in_progress2 = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(tc_id)),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.raw_output(serde_json::to_value(&bash_output2).ok()),
));
tracker.handle_update(in_progress2, &meta(), &mut sb);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(
exec.output.as_deref(),
Some("Step 1: loading...\nStep 2: processing...\n"),
"Output should be replaced with full buffer"
);
}
other => panic!("Expected Execute block, got {:?}", other),
}
let final_bash = ToolOutput::Bash(BashOutput {
output_for_prompt: String::new(),
output: b"Step 1: loading...\nStep 2: processing...\nDone!\n".to_vec(),
exit_code: 0,
command: "python tmp/test.py".to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: 46,
output_delta: None,
was_bare_echo: false,
});
let completed = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(tc_id)),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::Completed))
.raw_output(serde_json::to_value(&final_bash).ok()),
));
tracker.handle_update(completed, &meta(), &mut sb);
assert_eq!(tracker.pending_tools.len(), 0);
let entry = sb.get(0).unwrap();
assert!(!entry.is_running, "Should be marked as not running");
match &entry.block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert!(exec.output.is_some(), "Final block should have output");
let output = exec.output.as_deref().unwrap();
assert!(
output.contains("Done!"),
"Should contain final output, got: {output}"
);
}
other => panic!("Expected Execute block, got {:?}", other),
}
}
#[test]
fn utf8_decoder_ascii_passthrough() {
let mut dec = Utf8Decoder::default();
assert_eq!(dec.decode(b"hello"), "hello");
assert!(dec.buffer.is_empty());
}
#[test]
fn utf8_decoder_complete_multibyte() {
let mut dec = Utf8Decoder::default();
assert_eq!(dec.decode("café".as_bytes()), "café");
assert!(dec.buffer.is_empty());
}
#[test]
fn utf8_decoder_split_2byte_char() {
let mut dec = Utf8Decoder::default();
assert_eq!(dec.decode(&[b'c', b'a', b'f', 0xC3]), "caf");
assert_eq!(dec.buffer, &[0xC3]);
assert_eq!(dec.decode(&[0xA9]), "é");
assert!(dec.buffer.is_empty());
}
#[test]
fn utf8_decoder_split_3byte_char() {
let mut dec = Utf8Decoder::default();
assert_eq!(dec.decode(&[0xE2]), "");
assert_eq!(dec.buffer, &[0xE2]);
assert_eq!(dec.decode(&[0x9C]), "");
assert_eq!(dec.buffer, &[0xE2, 0x9C]);
assert_eq!(dec.decode(&[0x93, b'!']), "✓!");
assert!(dec.buffer.is_empty());
}
#[test]
fn utf8_decoder_split_4byte_char() {
let mut dec = Utf8Decoder::default();
assert_eq!(dec.decode(&[0xF0, 0x9F]), "");
assert_eq!(dec.buffer, &[0xF0, 0x9F]);
assert_eq!(dec.decode(&[0xA6, 0x80]), "🦀");
assert!(dec.buffer.is_empty());
}
#[test]
fn utf8_decoder_genuinely_invalid_byte() {
let mut dec = Utf8Decoder::default();
let result = dec.decode(&[b'a', 0xFF, b'b']);
assert_eq!(result, "a\u{FFFD}b");
assert!(dec.buffer.is_empty());
}
#[test]
fn utf8_decoder_multiple_feeds() {
let mut dec = Utf8Decoder::default();
assert_eq!(dec.decode(b"line1\n"), "line1\n");
assert_eq!(dec.decode(b"line2\n"), "line2\n");
assert_eq!(dec.decode("héllo\n".as_bytes()), "héllo\n");
assert!(dec.buffer.is_empty());
}
/// Reproduce the exact ACP message flow for a grep search tool call:
/// 1. ToolCall with kind=Other, title="grep" (initial, no metadata)
/// 2. ToolCallUpdate in-progress with kind=search, title="fn main", rawInput
/// 3. ToolCallUpdate completed with rawOutput containing GrepSearchOutput
///
/// This was broken: kind from in-progress update was lost, so the completed
/// block rendered as "Other" with no search results.
#[test]
fn test_search_tool_call_flow() {
use kigi_tools::types::output::{GrepFileMatch, GrepLineMatch, GrepSearchOutput};
let mut tracker = AcpUpdateTracker::new();
let mut scrollback = ScrollbackState::new();
let tc_id: Arc<str> = Arc::from("toolu_search_001");
let tool_call = acp::SessionUpdate::ToolCall(
acp::ToolCall::new(acp::ToolCallId::new(tc_id.clone()), "grep".to_string())
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending),
);
tracker.handle_update(tool_call, &meta(), &mut scrollback);
assert_eq!(scrollback.len(), 1);
let in_progress = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(tc_id.clone()),
acp::ToolCallUpdateFields::new()
.kind(Some(acp::ToolKind::Search))
.title(Some("fn main".to_string()))
.raw_input(Some(serde_json::json!(
{ "variant" : "Grep", "pattern" : "fn main", "path" :
"src/", }
))),
));
tracker.handle_update(in_progress, &meta(), &mut scrollback);
assert_eq!(scrollback.len(), 1, "should still be 1 entry");
let entry = scrollback.get(0).expect("entry exists");
assert!(
matches!(
&entry.block,
RenderBlock::ToolCall(ToolCallBlock::Search(_))
),
"block should be Search after in-progress update, got: {:?}",
std::mem::discriminant(&entry.block)
);
let grep_output = GrepSearchOutput {
stdout: vec![],
stderr: vec![],
exit_code: 0,
match_count: 1,
file_matches: vec![GrepFileMatch {
path: "/Users/alice/dev/rust/foo/src/main.rs".to_string(),
matches: vec![GrepLineMatch {
line_number: 54,
content: "fn main() -> Result<()> {".to_string(),
}],
}],
};
let completed = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(tc_id.clone()),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::Completed))
.content(Some(vec![acp::ToolCallContent::Content(
acp::Content::new(acp::ContentBlock::Text(acp::TextContent::new(
"found 1 matches".to_string(),
))),
)]))
.raw_output(serde_json::to_value(ToolOutput::GrepSearch(grep_output)).ok()),
));
tracker.handle_update(completed, &meta(), &mut scrollback);
assert_eq!(scrollback.len(), 1, "should still be 1 entry");
let entry = scrollback.get(0).expect("entry exists");
if let RenderBlock::ToolCall(ToolCallBlock::Search(search)) = &entry.block {
assert_eq!(search.pattern, "fn main");
assert_eq!(search.match_count, 1);
assert_eq!(search.file_matches.len(), 1);
assert_eq!(
search.file_matches[0].path,
"/Users/alice/dev/rust/foo/src/main.rs"
);
assert_eq!(search.file_matches[0].matches.len(), 1);
assert_eq!(search.file_matches[0].matches[0].line_number, 54);
assert_eq!(
search.file_matches[0].matches[0].content,
"fn main() -> Result<()> {"
);
} else {
panic!(
"Expected Search block after completion, got: {:?}",
std::mem::discriminant(&entry.block)
);
}
}
/// ScrollbackState with an explicit `expanded_by_default` shape override
/// (flag-independent: the `Some` beats the `collapsed_edit_blocks` cache).
fn edit_config_scrollback(expanded_by_default: bool) -> ScrollbackState {
use crate::appearance::AppearanceConfig;
let mut sb = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.edit.expanded_by_default = Some(expanded_by_default);
sb.set_appearance(appearance);
sb
}
/// ToolCall(Pending) with kind=Other (shell currently sends this).
fn pending_other_tool_call(tc_id: &Arc<str>) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(tc_id.clone()),
"search_replace".to_string(),
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.locations(vec![]),
)
}
/// Regression test for #199720 follow-up: when an Other(Pending) entry is
/// upgraded in-place to an Edit block, the entry's `display_mode` must be
/// reset to the materialize policy's default — Collapsed by default,
/// Expanded when `expanded_by_default` is set — rather than left at
/// Other's default.
///
/// Also covers the fast-path Pending→Completed (no in-progress refinement)
/// where Edit's `finished_display_mode()` returns `None` and `finish_running`
/// would otherwise leave a stale mode in place.
#[test]
fn edit_tool_upgrade_resets_display_mode_to_default() {
use crate::scrollback::types::DisplayMode;
/// Drive Pending(Other) → InProgress(Edit) → Completed and return the
/// display mode observed after the InProgress upgrade and after
/// completion.
fn upgrade_path(tc: &str, expanded_by_default: bool) -> (DisplayMode, DisplayMode) {
let mut tracker = AcpUpdateTracker::new();
let mut sb = edit_config_scrollback(expanded_by_default);
let tc_id: Arc<str> = Arc::from(tc);
tracker.handle_update(pending_other_tool_call(&tc_id), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
assert_eq!(sb.get(0).unwrap().display_mode, DisplayMode::Collapsed);
let in_progress = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(tc_id.clone()),
acp::ToolCallUpdateFields::new()
.kind(Some(acp::ToolKind::Edit))
.title(Some("foo.rs".to_string()))
.raw_input(Some(serde_json::json!({ "file_path" : "foo.rs" }))),
));
tracker.handle_update(in_progress, &meta(), &mut sb);
let entry = sb.get(0).expect("entry exists");
assert!(
matches!(&entry.block, RenderBlock::ToolCall(ToolCallBlock::Edit(_))),
"block should be upgraded to Edit after in-progress refinement"
);
let after_upgrade = entry.display_mode;
tracker.handle_update(tool_update_completed(&tc_id), &meta(), &mut sb);
(after_upgrade, sb.get(0).unwrap().display_mode)
}
/// Drive the fast path Pending(Other) → Completed(Edit) with no
/// in-progress refinement and return the final display mode.
fn fast_path(tc: &str, expanded_by_default: bool) -> DisplayMode {
let mut tracker = AcpUpdateTracker::new();
let mut sb = edit_config_scrollback(expanded_by_default);
let tc_id: Arc<str> = Arc::from(tc);
tracker.handle_update(pending_other_tool_call(&tc_id), &meta(), &mut sb);
assert_eq!(sb.get(0).unwrap().display_mode, DisplayMode::Collapsed);
let completed = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(tc_id.clone()),
acp::ToolCallUpdateFields::new()
.kind(Some(acp::ToolKind::Edit))
.title(Some("foo.rs".to_string()))
.raw_input(Some(serde_json::json!({ "file_path" : "foo.rs" })))
.status(Some(acp::ToolCallStatus::Completed)),
));
tracker.handle_update(completed, &meta(), &mut sb);
let entry = sb.get(0).expect("entry exists");
assert!(
matches!(&entry.block, RenderBlock::ToolCall(ToolCallBlock::Edit(_))),
"block should be Edit after fast Pending→Completed"
);
entry.display_mode
}
let (upgraded, completed) = upgrade_path("toolu_edit_001", false);
assert_eq!(
upgraded,
DisplayMode::Collapsed,
"collapse shape: Edit upgrade stays Collapsed"
);
assert_eq!(
completed,
DisplayMode::Collapsed,
"collapse shape: successful Edit remains Collapsed after completion"
);
assert_eq!(
fast_path("toolu_edit_002", false),
DisplayMode::Collapsed,
"collapse shape: fast Pending→Completed Edit ends up Collapsed"
);
let (upgraded, completed) = upgrade_path("toolu_edit_003", true);
assert_eq!(
upgraded,
DisplayMode::Expanded,
"config on: display_mode must be reset to Expanded on upgrade, \
not left at Other's default (Collapsed)"
);
assert_eq!(
completed,
DisplayMode::Expanded,
"config on: successful Edit remains Expanded after completion"
);
assert_eq!(
fast_path("toolu_edit_004", true),
DisplayMode::Expanded,
"config on: fast Pending→Completed Edit must end up Expanded"
);
}
/// A manual expand of the collapsed one-liner must survive completion:
/// once the entry is an Edit, the Edit-to-Edit completion swap preserves
/// the current mode instead of snapping back to the configured default
/// (no `respect_manual_folds` pinning required).
#[test]
fn edit_manual_expand_survives_completion() {
use crate::scrollback::types::DisplayMode;
let mut tracker = AcpUpdateTracker::new();
let mut sb = edit_config_scrollback(false);
let tc_id: Arc<str> = Arc::from("toolu_edit_gesture");
tracker.handle_update(pending_other_tool_call(&tc_id), &meta(), &mut sb);
let in_progress = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(tc_id.clone()),
acp::ToolCallUpdateFields::new()
.kind(Some(acp::ToolKind::Edit))
.title(Some("foo.rs".to_string()))
.raw_input(Some(serde_json::json!({ "file_path" : "foo.rs" })))
.content(Some(vec![acp::ToolCallContent::Diff(
acp::Diff::new("foo.rs", "let x = 2;\n".to_string())
.old_text(Some("let x = 1;\n".to_string())),
)])),
));
tracker.handle_update(in_progress, &meta(), &mut sb);
assert_eq!(sb.get(0).unwrap().display_mode, DisplayMode::Collapsed);
sb.get_by_id_mut(sb.get(0).unwrap().id)
.unwrap()
.set_display_mode(DisplayMode::Expanded);
tracker.handle_update(tool_update_completed(&tc_id), &meta(), &mut sb);
assert_eq!(
sb.get(0).unwrap().display_mode,
DisplayMode::Expanded,
"completion must not snap a user-expanded Edit back to Collapsed"
);
}
/// Multi-file (apply_patch shape: several Diff items) and title-fallback
/// Edits can't be summarized by the one-liner: they materialize Expanded
/// with the summary marked untrusted, config-independent. Each case
/// isolates one untrusted signal.
#[test]
fn multi_diff_and_title_fallback_edits_default_expanded() {
use crate::scrollback::types::DisplayMode;
let diff = |path: &str, old: &str, new: &str| {
acp::ToolCallContent::Diff(
acp::Diff::new(path, new.to_string()).old_text(Some(old.to_string())),
)
};
let assert_untrusted_expanded = |tc: acp::ToolCall, label: &str| {
let mut tracker = AcpUpdateTracker::new();
let mut sb = edit_config_scrollback(false);
tracker.handle_update(acp::SessionUpdate::ToolCall(tc), &meta(), &mut sb);
let entry = sb.get(0).expect("entry exists");
let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block else {
panic!("{label}: expected Edit block, got {:?}", entry.block);
};
assert!(edit.summary_untrusted, "{label}: summary must be untrusted");
assert_eq!(
entry.display_mode,
DisplayMode::Expanded,
"{label}: untrusted summaries must not collapse to the one-liner"
);
};
assert_untrusted_expanded(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("toolu_multi_diff")),
"Apply patch".to_string(),
)
.kind(acp::ToolKind::Edit)
.status(acp::ToolCallStatus::Completed)
.raw_input(Some(serde_json::json!({ "file_path" : "a.rs" })))
.content(vec![
diff("a.rs", "a1\n", "a2\n"),
diff("b.rs", "b1\n", "b2\n"),
])
.locations(vec![]),
"multi_diff",
);
assert_untrusted_expanded(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("toolu_title_fallback")),
"Apply patch".to_string(),
)
.kind(acp::ToolKind::Edit)
.status(acp::ToolCallStatus::Completed)
.content(vec![diff("a.rs", "a1\n", "a2\n")])
.locations(vec![]),
"title_fallback",
);
}
/// ToolCall(Pending) start for a search_replace edit.
fn edit_tool_start(id: &str) -> acp::SessionUpdate {
tool_call(id, acp::ToolKind::Edit, "search_replace")
}
/// Diff content replacing one line at `line`, so each scripted edit
/// yields exactly one `+1/-1` hunk at a distinct position.
fn edit_diff_content(path: &str, line: usize) -> acp::ToolCallContent {
acp::ToolCallContent::Diff(
acp::Diff::new(path, format!("new_{line}"))
.old_text(Some(format!("old_{line}")))
.meta(
serde_json::json!({ "old_line" : line, "new_line" : line })
.as_object()
.cloned(),
),
)
}
/// Completed update carrying the edit's file_path and one-hunk diff.
fn edit_tool_complete(id: &str, path: &str, line: usize) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(id)),
acp::ToolCallUpdateFields::new()
.kind(Some(acp::ToolKind::Edit))
.title(Some(path.to_string()))
.raw_input(Some(serde_json::json!({ "file_path" : path })))
.content(Some(vec![edit_diff_content(path, line)]))
.status(Some(acp::ToolCallStatus::Completed)),
))
}
/// Full Pending → Completed lifecycle for one scripted edit.
fn run_edit(
tracker: &mut AcpUpdateTracker,
sb: &mut ScrollbackState,
id: &str,
path: &str,
line: usize,
) {
tracker.handle_update(edit_tool_start(id), &meta(), sb);
tracker.handle_update(edit_tool_complete(id, path, line), &meta(), sb);
}
/// Pre-completed ToolCall (replay / session-load shape) with the same
/// one-hunk diff as [`edit_tool_complete`].
fn edit_tool_precompleted(id: &str, path: &str, line: usize) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(acp::ToolCallId::new(Arc::from(id)), path.to_string())
.kind(acp::ToolKind::Edit)
.status(acp::ToolCallStatus::Completed)
.raw_input(Some(serde_json::json!({ "file_path" : path })))
.content(vec![edit_diff_content(path, line)])
.locations(vec![]),
)
}
fn edit_block_at(sb: &ScrollbackState, idx: usize) -> &EditToolCallBlock {
match &sb.get(idx).expect("entry at index").block {
RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) => edit,
other => panic!("expected Edit block at {idx}, got {other:?}"),
}
}
/// Positions of the edited lines, one per hunk, in hunk order.
fn hunk_lines(edit: &EditToolCallBlock) -> Vec<usize> {
edit.hunks
.iter()
.map(|h| {
h.iter()
.find(|l| l.tag == similar::ChangeTag::Insert)
.expect("insert line")
.ln
})
.collect()
}
#[test]
fn adjacent_same_file_edits_coalesce() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
run_edit(&mut tracker, &mut sb, "e2", "foo.rs", 40);
assert_eq!(sb.len(), 1, "two adjacent edits must merge into one entry");
let edit = edit_block_at(&sb, 0);
assert_eq!(edit.hunks.len(), 2);
assert_eq!(edit.edit_count, 2);
assert_eq!(hunk_lines(edit), vec![5, 40], "hunks keep scrollback order");
let inserts: usize = edit
.hunks
.iter()
.flatten()
.filter(|l| l.tag == similar::ChangeTag::Insert)
.count();
assert_eq!(inserts, 2);
})
.join()
.unwrap();
}
#[test]
fn overlapping_adjacent_edits_stitch_into_single_hunk() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
for (i, line) in (5..=9).enumerate() {
run_edit(&mut tracker, &mut sb, &format!("e{i}"), "foo.rs", line);
}
assert_eq!(sb.len(), 1);
let edit = edit_block_at(&sb, 0);
assert_eq!(edit.hunks.len(), 1, "contiguous hunks stitch into one");
assert_eq!(
edit.edit_count, 5,
"the (N edits) fallback counts merged calls, not stitched hunks"
);
let rows: Vec<(similar::ChangeTag, usize)> =
edit.hunks[0].iter().map(|l| (l.tag, l.ln)).collect();
let expected: Vec<(similar::ChangeTag, usize)> = (5..=9)
.flat_map(|ln| {
[
(similar::ChangeTag::Delete, ln),
(similar::ChangeTag::Insert, ln),
]
})
.collect();
assert_eq!(rows, expected);
})
.join()
.unwrap();
}
#[test]
fn coalesce_disabled_when_collapsed_edit_blocks_off() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(false);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
run_edit(&mut tracker, &mut sb, "e2", "foo.rs", 40);
assert_eq!(
sb.len(),
2,
"flag off keeps the legacy one-row-per-call transcript"
);
assert_eq!(edit_block_at(&sb, 0).hunks.len(), 1);
assert_eq!(edit_block_at(&sb, 1).hunks.len(), 1);
})
.join()
.unwrap();
}
#[test]
fn three_sequential_edits_chain_into_one() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
run_edit(&mut tracker, &mut sb, "e2", "foo.rs", 20);
run_edit(&mut tracker, &mut sb, "e3", "foo.rs", 40);
assert_eq!(sb.len(), 1);
let edit = edit_block_at(&sb, 0);
assert_eq!(edit.hunks.len(), 3);
assert_eq!(hunk_lines(edit), vec![5, 20, 40]);
})
.join()
.unwrap();
}
#[test]
fn different_files_do_not_coalesce() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
run_edit(&mut tracker, &mut sb, "e2", "bar.rs", 5);
assert_eq!(sb.len(), 2, "edits to different files stay separate");
})
.join()
.unwrap();
}
#[test]
fn intervening_entry_breaks_coalesce_run() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
tracker.handle_update(agent_chunk("first edit done"), &meta(), &mut sb);
run_edit(&mut tracker, &mut sb, "e2", "foo.rs", 40);
assert_eq!(
sb.len(),
3,
"a visible entry between edits blocks the merge"
);
assert_eq!(edit_block_at(&sb, 0).hunks.len(), 1);
assert_eq!(edit_block_at(&sb, 2).hunks.len(), 1);
})
.join()
.unwrap();
}
#[test]
fn parallel_out_of_order_completion_coalesces() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(edit_tool_start("e1"), &meta(), &mut sb);
tracker.handle_update(edit_tool_start("e2"), &meta(), &mut sb);
tracker.handle_update(edit_tool_complete("e2", "foo.rs", 40), &meta(), &mut sb);
assert_eq!(sb.len(), 2, "no merge while the earlier call still runs");
tracker.handle_update(edit_tool_complete("e1", "foo.rs", 5), &meta(), &mut sb);
assert_eq!(sb.len(), 1, "forward check merges once the earlier lands");
let edit = edit_block_at(&sb, 0);
assert_eq!(
hunk_lines(edit),
vec![5, 40],
"push order, not completion order"
);
})
.join()
.unwrap();
}
#[test]
fn errored_edit_does_not_coalesce() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
tracker.handle_update(edit_tool_start("e2"), &meta(), &mut sb);
tracker.handle_update(
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("e2")),
acp::ToolCallUpdateFields::new()
.kind(Some(acp::ToolKind::Edit))
.raw_input(Some(serde_json::json!({ "file_path" : "foo.rs" })))
.status(Some(acp::ToolCallStatus::Failed)),
)),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 2, "a failed edit never merges");
assert!(edit_block_at(&sb, 1).error.is_some());
})
.join()
.unwrap();
}
#[test]
fn committed_edit_does_not_coalesce() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
sb.mark_committed(0);
run_edit(&mut tracker, &mut sb, "e2", "foo.rs", 40);
assert_eq!(sb.len(), 2, "a committed row never merges");
assert_eq!(edit_block_at(&sb, 0).hunks.len(), 1);
assert_eq!(edit_block_at(&sb, 1).hunks.len(), 1);
})
.join()
.unwrap();
}
#[test]
fn untrusted_summary_edit_does_not_coalesce() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
let multi_diff =
acp::ToolCall::new(acp::ToolCallId::new(Arc::from("e2")), "foo.rs".to_string())
.kind(acp::ToolKind::Edit)
.status(acp::ToolCallStatus::Completed)
.raw_input(Some(serde_json::json!({ "file_path" : "foo.rs" })))
.content(vec![
edit_diff_content("foo.rs", 40),
edit_diff_content("bar.rs", 7),
])
.locations(vec![]);
tracker.handle_update(acp::SessionUpdate::ToolCall(multi_diff), &meta(), &mut sb);
assert_eq!(sb.len(), 2, "an untrusted summary never merges");
assert!(edit_block_at(&sb, 1).summary_untrusted);
})
.join()
.unwrap();
}
#[test]
fn replay_precompleted_edits_coalesce_without_hl_queue() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let replay = NotificationMeta {
is_replay: true,
..Default::default()
};
tracker.handle_update(edit_tool_precompleted("e1", "foo.rs", 5), &replay, &mut sb);
tracker.handle_update(edit_tool_precompleted("e2", "foo.rs", 40), &replay, &mut sb);
assert_eq!(sb.len(), 1, "replayed adjacent edits merge like live ones");
assert_eq!(hunk_lines(edit_block_at(&sb, 0)), vec![5, 40]);
assert!(
tracker.take_pending_edit_hl().is_empty(),
"replay never queues full-file HL"
);
})
.join()
.unwrap();
}
#[test]
fn coalesce_repoints_pending_edit_hl_to_survivor() {
std::thread::spawn(|| {
crate::appearance::cache::set_collapsed_edit_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
run_edit(&mut tracker, &mut sb, "e1", "foo.rs", 5);
run_edit(&mut tracker, &mut sb, "e2", "foo.rs", 40);
let survivor = sb.get(0).unwrap().id;
assert_eq!(
tracker.take_pending_edit_hl(),
vec![survivor],
"HL queue holds the survivor exactly once, never the removed id"
);
})
.join()
.unwrap();
}
fn scrollback_with_respect_manual_folds() -> ScrollbackState {
use crate::appearance::AppearanceConfig;
let mut sb = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.scroll.respect_manual_folds = true;
sb.set_appearance(appearance);
sb
}
#[test]
fn pinned_thinking_keeps_user_mode_across_finish_triggers() {
use crate::scrollback::types::DisplayMode;
crate::appearance::cache::set_show_thinking_blocks(true);
let setup = || {
let mut sb = scrollback_with_respect_manual_folds();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("deep thought"), &meta(), &mut sb);
sb.prepare_layout(80, 40);
sb.set_selected(Some(0));
sb.expand_selected();
let entry = sb.get(0).unwrap();
assert!(entry.display_mode_pinned, "manual expand pins the entry");
assert_eq!(entry.display_mode, DisplayMode::Expanded);
(tracker, sb)
};
let assert_kept = |sb: &ScrollbackState, trigger: &str| {
let entry = sb.get(0).unwrap();
assert!(!entry.is_running, "{trigger}: thinking finished");
assert_eq!(
entry.display_mode,
DisplayMode::Expanded,
"{trigger}: pinned thinking must keep the user's mode"
);
};
let (mut tracker, mut sb) = setup();
tracker.handle_update(agent_chunk("answer"), &meta(), &mut sb);
assert_kept(&sb, "agent chunk");
let (mut tracker, mut sb) = setup();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Read, "src/main.rs"),
&meta(),
&mut sb,
);
assert_kept(&sb, "tool call");
let (mut tracker, mut sb) = setup();
tracker.finish_turn(&mut sb);
assert_kept(&sb, "finish_turn");
let mut sb = scrollback_with_respect_manual_folds();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("deep thought"), &meta_stream(1000), &mut sb);
sb.prepare_layout(80, 40);
sb.set_selected(Some(0));
sb.expand_selected();
tracker.handle_update(thought_chunk("new stream"), &meta_stream(2000), &mut sb);
assert_kept(&sb, "stream restart");
let mut sb = scrollback_with_respect_manual_folds();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("deep thought"), &meta(), &mut sb);
tracker.handle_update(agent_chunk("answer"), &meta(), &mut sb);
let entry = sb.get(0).unwrap();
assert!(!entry.is_running);
assert_eq!(
entry.display_mode,
DisplayMode::Collapsed,
"unpinned thinking still auto-collapses"
);
}
#[test]
fn pinned_execute_keeps_user_mode_across_block_upgrades() {
use crate::scrollback::types::DisplayMode;
let mut sb = scrollback_with_respect_manual_folds();
let mut tracker = AcpUpdateTracker::new();
let tc_id = "call_pinned_exec";
tracker.handle_update(
tool_call(tc_id, acp::ToolKind::Execute, "Execute `sleep 5`"),
&meta(),
&mut sb,
);
tracker.handle_update(
tool_update_in_progress(tc_id, b"tick 1\n"),
&meta(),
&mut sb,
);
sb.prepare_layout(80, 40);
sb.set_selected(Some(0));
sb.expand_selected();
let entry = sb.get(0).unwrap();
assert!(
entry.display_mode_pinned,
"expand after the first output tick (which makes Execute foldable) pins the entry"
);
assert_eq!(entry.display_mode, DisplayMode::Expanded);
tracker.handle_update(
tool_update_in_progress(tc_id, b"tick 1\ntick 2\n"),
&meta(),
&mut sb,
);
let entry = sb.get(0).unwrap();
assert_eq!(
entry.display_mode,
DisplayMode::Expanded,
"InProgress block upgrade must not reset a pinned entry (agent Execute default is Collapsed)"
);
assert!(entry.display_mode_pinned, "pin survives the block swap");
tracker.handle_update(tool_update_completed(tc_id), &meta(), &mut sb);
let entry = sb.get(0).unwrap();
assert!(!entry.is_running);
assert_eq!(
entry.display_mode,
DisplayMode::Expanded,
"Completed upgrade + finish must not reset a pinned entry"
);
}
#[test]
fn respect_manual_folds_off_bypasses_finish_pin_guard() {
use crate::appearance::AppearanceConfig;
use crate::scrollback::types::DisplayMode;
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = scrollback_with_respect_manual_folds();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("deep thought"), &meta(), &mut sb);
sb.prepare_layout(80, 40);
sb.set_selected(Some(0));
sb.expand_selected();
sb.collapse_selected();
let entry = sb.get(0).unwrap();
assert!(entry.display_mode_pinned);
assert_eq!(entry.display_mode, DisplayMode::Truncated);
let mut appearance = AppearanceConfig::default();
appearance.scrollback.scroll.respect_manual_folds = false;
sb.set_appearance(appearance);
tracker.handle_update(agent_chunk("answer"), &meta(), &mut sb);
assert_eq!(
sb.get(0).unwrap().display_mode,
DisplayMode::Collapsed,
"flag off: finish applies the sticky mode to a pinned non-Expanded entry"
);
}
/// Helper: build a NotificationMeta with a specific stream_start_ms.
fn meta_stream(stream_start: i64) -> NotificationMeta {
NotificationMeta {
stream_start_ms: Some(stream_start),
agent_timestamp_ms: Some(stream_start + 100),
..Default::default()
}
}
/// Regression test: agent message (stream A) → thinking (stream B) → agent message (stream B).
///
/// Without stream_start_ms boundary detection, stream B's agent message
/// chunks were appended to stream A's entry because handle_thought_chunk
/// never resets current_agent_msg.
#[test]
fn stream_start_breaks_agent_msg_across_streams() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let stream_a = meta_stream(1000);
let stream_b = meta_stream(2000);
tracker.handle_update(thought_chunk("thinking A"), &stream_a, &mut sb);
tracker.handle_update(agent_chunk("answer A"), &stream_a, &mut sb);
assert_eq!(sb.len(), 2);
tracker.handle_update(thought_chunk("thinking B"), &stream_b, &mut sb);
tracker.handle_update(agent_chunk("answer B"), &stream_b, &mut sb);
assert_eq!(sb.len(), 4, "Each stream should produce separate entries");
let agent_indices: Vec<usize> = (0..sb.len())
.filter(|&i| matches!(sb.get(i).unwrap().block, RenderBlock::AgentMessage(_)))
.collect();
assert_eq!(
agent_indices.len(),
2,
"Should have 2 separate agent message entries"
);
assert_ne!(
sb.get(agent_indices[0]).unwrap().id,
sb.get(agent_indices[1]).unwrap().id,
);
}
/// Same stream_start_ms should NOT break messages — chunks append normally.
#[test]
fn same_stream_start_appends_normally() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let stream = meta_stream(1000);
tracker.handle_update(agent_chunk("Hello "), &stream, &mut sb);
tracker.handle_update(agent_chunk("world!"), &stream, &mut sb);
assert_eq!(sb.len(), 1, "Same stream should append to one entry");
}
/// stream_start_ms change breaks thinking entries too.
#[test]
fn stream_start_breaks_thinking_across_streams() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let stream_a = meta_stream(1000);
let stream_b = meta_stream(2000);
tracker.handle_update(thought_chunk("thinking A"), &stream_a, &mut sb);
assert!(tracker.current_thinking.is_some());
tracker.handle_update(thought_chunk("thinking B"), &stream_b, &mut sb);
assert_eq!(sb.len(), 2, "Each stream should get its own thinking entry");
assert!(
!sb.get(0).unwrap().is_running,
"stream A thinking should be finished"
);
}
/// Agent message in stream A, then agent message in stream B (no thinking between).
#[test]
fn stream_start_breaks_agent_msg_to_agent_msg() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let stream_a = meta_stream(1000);
let stream_b = meta_stream(2000);
tracker.handle_update(agent_chunk("message A"), &stream_a, &mut sb);
tracker.handle_update(agent_chunk("message B"), &stream_b, &mut sb);
assert_eq!(
sb.len(),
2,
"Different streams should create separate agent messages"
);
assert!(
!sb.get(0).unwrap().is_running,
"stream A message should be finished"
);
}
/// No stream_start_ms (old grok-shell) should not break anything.
#[test]
fn no_stream_start_ms_preserves_existing_behavior() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("Hello "), &meta(), &mut sb);
tracker.handle_update(agent_chunk("world!"), &meta(), &mut sb);
assert_eq!(
sb.len(),
1,
"Without stream_start_ms, chunks should append normally"
);
}
/// finish_turn resets last_stream_start_ms so the next turn starts fresh.
#[test]
fn finish_turn_resets_stream_start() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let stream = meta_stream(1000);
tracker.handle_update(agent_chunk("turn 1"), &stream, &mut sb);
assert_eq!(tracker.last_stream_start_ms, Some(1000));
tracker.finish_turn(&mut sb);
assert_eq!(
tracker.last_stream_start_ms, None,
"finish_turn should reset last_stream_start_ms"
);
tracker.handle_update(agent_chunk("turn 2"), &stream, &mut sb);
assert_eq!(sb.len(), 2);
}
#[test]
fn activity_none_by_default() {
let tracker = AcpUpdateTracker::new();
assert_eq!(tracker.activity(), None);
}
#[test]
fn activity_thinking_when_thought_chunks_arrive() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("hmm..."), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Thinking));
}
#[test]
fn activity_responding_when_agent_chunks_arrive() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("Here's my answer"), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Responding));
}
#[test]
fn activity_thinking_to_responding_transition() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("thinking..."), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Thinking));
tracker.handle_update(agent_chunk("answer"), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Responding));
}
#[test]
fn activity_tool_running_when_tool_pending() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "cargo test"),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::ToolRunning {
title: "cargo test".into(),
description: None,
})
);
}
/// Foreground execute tools often carry a human `description` in raw_input
/// (e.g. sleep with "Wait 5 seconds…"). Surface it for the spinner.
#[test]
fn activity_tool_running_prefers_description_from_raw_input() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("tc1")),
"run_terminal_command",
)
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.raw_input(Some(serde_json::json!(
{ "command" : "sleep 5 && echo done", "description" :
"Wait 5 seconds then print done", }
)))
.locations(vec![]),
),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::ToolRunning {
title: "sleep 5 && echo done".into(),
description: Some("Wait 5 seconds then print done".into()),
})
);
}
/// The initial ToolCall registers with kind=Other and title=tool_id
/// (e.g. "Shell"). When raw_input carries a `command` field, activity()
/// should show the command instead of the bare tool name.
#[test]
fn activity_extracts_command_from_raw_input_regardless_of_kind() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let tc = acp::SessionUpdate::ToolCall(
acp::ToolCall::new(acp::ToolCallId::new(Arc::from("tc1")), "Shell".to_string())
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.raw_input(Some(
serde_json::json!({ "command" : "gt stack submit --no-edit" }),
))
.locations(vec![]),
);
tracker.handle_update(tc, &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::ToolRunning {
title: "gt stack submit --no-edit".into(),
description: None
}),
);
}
#[test]
fn activity_strips_redundant_session_cd_prefix() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let cwd = std::path::PathBuf::from("/proj");
tracker.set_session_cwd(cwd.clone());
let command = format!("cd {} && echo hi", cwd.display());
let tc = acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("tc-cd")),
"Shell".to_string(),
)
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.raw_input(Some(serde_json::json!({ "command" : command })))
.locations(vec![]),
);
tracker.handle_update(tc, &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::ToolRunning {
title: "echo hi".into(),
description: None
}),
);
}
#[test]
fn activity_strips_windows_shaped_session_cd_on_unix_host() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let cwd = std::path::PathBuf::from(r"C:\Users\a\proj");
tracker.set_session_cwd(cwd.clone());
let command = r"cd C:\Users\a\proj && cargo test";
let tc = acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("tc-win")),
"Shell".to_string(),
)
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.raw_input(Some(serde_json::json!({ "command" : command })))
.locations(vec![]),
);
tracker.handle_update(tc, &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::ToolRunning {
title: "cargo test".into(),
description: None
}),
);
}
#[test]
fn execute_block_keeps_full_command_sets_header_display_when_peeled() {
let tc = acp::ToolCall::new(acp::ToolCallId::new(Arc::from("tc-exec")), "Execute")
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.raw_input(Some(
serde_json::json!({ "command" : "cd /proj && echo hi" }),
))
.locations(vec![]);
let block = tool_call_to_block(&tc, Some(Path::new("/proj")));
match &block {
RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) => {
assert_eq!(exec.command, "cd /proj && echo hi");
assert_eq!(exec.header_display.as_deref(), Some("echo hi"));
}
other => panic!("expected Execute block, got {other:?}"),
}
assert_eq!(block.copy_meta().as_deref(), Some("cd /proj && echo hi"));
let searchable = block.searchable_text().expect("searchable");
assert!(
searchable.contains("cd /proj && echo hi"),
"searchable_text must retain full command: {searchable}"
);
}
#[test]
fn activity_none_after_finish_turn() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("text"), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Responding));
tracker.finish_turn(&mut sb);
assert_eq!(tracker.activity(), None);
}
#[test]
fn activity_compaction_overrides_other_state() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(agent_chunk("text"), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Responding));
tracker.set_compaction_activity(Some(TurnActivity::AutoCompacting));
assert_eq!(tracker.activity(), Some(TurnActivity::AutoCompacting));
tracker.finish_turn(&mut sb);
assert_eq!(tracker.activity(), None);
}
/// The blocking bg-plumbing tools are kept out of scrollback but the turn
/// IS blocked on them — `activity()` must name the wait instead of the old
/// generic `None` (→ "Waiting…"). Task-output tools only advertise once
/// raw_input proves them blocking (`timeout_ms > 0`); before that the
/// wait is not shown (display mirrors interject eligibility).
#[test]
fn activity_waiting_for_blocking_bg_plumbing_tools() {
let cases = [
("wait_commands_or_subagents", WaitingReason::TasksComplete),
("wait_tasks", WaitingReason::TasksComplete),
("Await", WaitingReason::Sleep),
("Sleep 5s", WaitingReason::Sleep),
];
for (title, expected) in cases {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, title),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(expected.clone())),
"title {title:?} should produce {expected:?}"
);
}
for title in ["get_command_or_subagent_output", "get_task_output"] {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, title),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
None,
"{title:?}: unknown blocking-ness must not advertise a wait"
);
tracker.handle_update(timeout_update("t1", 30_000), &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output())),
"{title:?}: known-blocking wait must be named"
);
}
}
/// A known-blocking wait must beat an open (residual/pre-created) thought entry.
#[test]
fn activity_known_blocking_wait_outranks_thinking() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(thought_chunk("planning the wait…"), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Thinking));
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&meta(),
&mut sb,
);
tracker.pre_create_thinking(&mut sb);
assert!(
tracker.current_thinking.is_some(),
"precondition: residual/pre-created thinking is live"
);
tracker.handle_update(timeout_update("t1", 60_000), &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output())),
"known-blocking wait must beat Thinking for the status spinner"
);
}
/// Thought chunks on the same stream must not erase an in-flight wait.
#[test]
fn thought_chunk_does_not_clear_active_blocking_wait() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let m = meta_stream(42);
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&m,
&mut sb,
);
tracker.handle_update(timeout_update("t1", 60_000), &m, &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output()))
);
tracker.handle_update(thought_chunk("still waiting…"), &m, &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output())),
"active wait must survive same-stream thought chunks"
);
}
/// stream_start rollover must not pre-create a thought block during a wait.
#[test]
fn stream_start_does_not_pre_create_thinking_during_blocking_wait() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let m1 = meta_stream(1_000);
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&m1,
&mut sb,
);
tracker.handle_update(timeout_update("t1", 30_000), &m1, &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output()))
);
let m2 = meta_stream(9_999);
tracker.handle_update(timeout_update("t1", 30_000), &m2, &mut sb);
assert!(
tracker.current_thinking.is_none(),
"must not pre-create thinking while a known-blocking wait is live"
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output()))
);
}
/// Regression: a resumed thought with no `stream_start_ms` must clear a
/// stale wait (show Thinking, not a stuck wait spinner).
#[test]
fn resumed_thought_without_stream_start_clears_stale_wait() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let m = meta_stream(100);
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&m,
&mut sb,
);
tracker.handle_update(timeout_update("t1", 60_000), &m, &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output())),
"precondition: sendable wait is live"
);
tracker.handle_update(
thought_chunk("resuming, let me check the output…"),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Thinking),
"resumed-round thought (no stream_start) must clear the stale wait"
);
}
/// ToolCallUpdate carrying a `timeout_ms` raw_input (the shape the shell
/// sends on the first InProgress update).
fn timeout_update(id: &str, timeout_ms: u64) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(id)),
acp::ToolCallUpdateFields::new()
.raw_input(Some(serde_json::json!({ "timeout_ms" : timeout_ms }))),
))
}
/// A blocking-wait reason is dropped when the suppressed tool completes, so
/// the spinner stops showing it.
#[test]
fn blocking_wait_cleared_on_tool_completion() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&meta(),
&mut sb,
);
tracker.handle_update(timeout_update("t1", 30_000), &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output()))
);
tracker.handle_update(tool_update_completed("t1"), &meta(), &mut sb);
assert_eq!(tracker.activity(), None);
}
/// `finish_turn` clears any lingering blocking-wait state.
#[test]
fn blocking_wait_cleared_by_finish_turn() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "wait_tasks"),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::TasksComplete))
);
tracker.finish_turn(&mut sb);
assert_eq!(tracker.activity(), None);
}
/// `kill_*` is suppressed but doesn't block the turn — no waiting reason.
#[test]
fn kill_tool_is_not_a_blocking_wait() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "kill_command_or_subagent"),
&meta(),
&mut sb,
);
assert_eq!(tracker.activity(), None);
}
/// A response stream outranks a still-open blocking wait: show Responding.
#[test]
fn streaming_overrides_blocking_wait() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_task_output"),
&meta(),
&mut sb,
);
tracker.handle_update(timeout_update("t1", 30_000), &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::task_output()))
);
tracker.handle_update(agent_chunk("partial"), &meta(), &mut sb);
assert_eq!(tracker.activity(), Some(TurnActivity::Responding));
}
/// A same-stream (co-batched) thought must not clear an active wait.
#[test]
fn same_stream_thought_after_wait_tool_keeps_blocking_wait() {
crate::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let m = meta_stream(100);
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&m,
&mut sb,
);
tracker.handle_update(
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("t1")),
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
{ "task_ids" : ["bg-1"], "timeout_ms" : 180_000, }
))),
)),
&m,
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
task_ids: vec!["bg-1".into()],
subject: None,
waits: true,
}))
);
tracker.handle_update(thought_chunk("planning next…"), &m, &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
task_ids: vec!["bg-1".into()],
subject: None,
waits: true,
})),
"same-stream thought must not clear an active task-output wait"
);
}
/// raw_input with task_ids on the first update populates the wait reason so
/// the view can resolve a display subject from live bg task state.
#[test]
fn task_output_wait_captures_task_ids_from_raw_input_update() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "get_command_or_subagent_output"),
&meta(),
&mut sb,
);
assert_eq!(tracker.activity(), None);
tracker.handle_update(
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("t1")),
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
{ "task_ids" : ["bg-123", "bg-456"], "timeout_ms" : 30_000,
}
))),
)),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
task_ids: vec!["bg-123".into(), "bg-456".into()],
subject: None,
waits: true,
}))
);
}
/// `waits` derives from raw_input `timeout_ms`: 0/missing are instant
/// polls (not interject-eligible); only >0 marks a blocking wait.
#[test]
fn task_output_waits_tracks_timeout_ms() {
let tc = |raw: Option<serde_json::Value>| {
acp::ToolCall::new(
acp::ToolCallId::new(std::sync::Arc::from("w")),
"get_task_output",
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.raw_input(raw)
.locations(vec![])
};
let waits = |raw| match blocking_wait_reason(&tc(raw)) {
Some(WaitingReason::TaskOutput { waits, .. }) => waits,
other => panic!("expected TaskOutput, got {other:?}"),
};
assert!(!waits(None), "missing raw_input defaults to instant poll");
assert!(!waits(Some(serde_json::json!({ "task_ids" : ["a"] }))));
assert!(!waits(Some(
serde_json::json!({ "task_ids" : ["a"], "timeout_ms" : 0 })
)));
assert!(waits(Some(
serde_json::json!({ "task_ids" : ["a"], "timeout_ms" : 1 })
)));
}
#[test]
fn waiting_reason_label_uses_subject_when_present() {
assert_eq!(
WaitingReason::TaskOutput {
task_ids: vec!["t1".into()],
subject: Some("compile release".into()),
waits: false,
}
.label(),
"compile release…"
);
assert_eq!(
WaitingReason::task_output().label(),
"Waiting on task output…"
);
assert_eq!(
WaitingReason::TaskOutput {
task_ids: vec![],
subject: Some("\n first line \nsecond".into()),
waits: false,
}
.label(),
"first line…"
);
let long = "x".repeat(80);
let label = WaitingReason::TaskOutput {
task_ids: vec![],
subject: Some(long),
waits: false,
}
.label();
assert!(label.ends_with('…'));
let inner = label.strip_suffix('…').unwrap();
assert_eq!(inner.chars().count(), MAX_ACTIVITY_SUBJECT_CHARS);
}
#[test]
fn format_waiting_for_subject_matches_label_shape() {
assert_eq!(format_waiting_for_subject("run tests"), "run tests…");
assert_eq!(format_waiting_for_subject(" "), "Waiting on task output…");
}
/// A `task` ToolCall carrying the shell's `_meta.subagentBackground` flag.
fn task_call_with_bg(id: &str, background: bool) -> acp::SessionUpdate {
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(acp::ToolCallId::new(Arc::from(id)), "task".to_string())
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.locations(vec![])
.meta(Some(
[(
"subagentBackground".to_string(),
serde_json::Value::Bool(background),
)]
.into_iter()
.collect(),
)),
)
}
/// Shell-stamped foreground (`subagentBackground=false`): the subagent wait
/// surfaces from frame 1 — no "Waiting for response…" flash.
#[test]
fn foreground_stamp_waits_on_subagent_from_frame_one() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(task_call_with_bg("t1", false), &meta(), &mut sb);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::Subagent)),
"a foreground-stamped subagent spawn surfaces the wait immediately"
);
}
/// Shell-stamped background (`subagentBackground=true`, the default): the
/// model keeps working, so no subagent wait surfaces — not even a one-frame
/// flash.
#[test]
fn background_stamp_never_surfaces_subagent_wait() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(task_call_with_bg("t1", true), &meta(), &mut sb);
assert_eq!(
tracker.activity(),
None,
"a background-stamped subagent spawn must not surface any wait"
);
}
/// Older shell with no `subagentBackground` stamp: fall back to the
/// provisional foreground assumption (the refinement update still drops it
/// for a background spawn).
#[test]
fn foreground_task_waits_on_subagent_immediately() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "task"),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::Subagent))
);
tracker.finish_turn(&mut sb);
assert_eq!(tracker.activity(), None);
}
/// A background subagent doesn't block the parent: once an update reveals
/// `run_in_background`, the provisional subagent wait is dropped.
#[test]
fn background_task_clears_subagent_wait() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("t1", acp::ToolKind::Other, "task"),
&meta(),
&mut sb,
);
assert_eq!(
tracker.activity(),
Some(TurnActivity::Waiting(WaitingReason::Subagent))
);
let bg_update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("t1")),
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
{ "variant" : "Task", "task_id" : "sa1", "run_in_background"
: true }
))),
));
tracker.handle_update(bg_update, &meta(), &mut sb);
assert_eq!(tracker.activity(), None);
}
fn available_commands_update(names: &[&str]) -> acp::SessionUpdate {
acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate::new(
names
.iter()
.map(|n| acp::AvailableCommand::new(n.to_string(), format!("{n} command")))
.collect(),
))
}
#[test]
fn tracker_captures_available_commands_update() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
assert!(tracker.take_pending_acp_commands().is_none());
let changed = tracker.handle_update(
available_commands_update(&["flush", "compact"]),
&meta(),
&mut sb,
);
assert!(changed, "AvailableCommandsUpdate should signal redraw");
let cmds = tracker
.take_pending_acp_commands()
.expect("should have pending");
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].name, "flush");
assert_eq!(cmds[1].name, "compact");
}
#[test]
fn tracker_single_drain_clears_pending() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
tracker.handle_update(available_commands_update(&["flush"]), &meta(), &mut sb);
assert!(tracker.take_pending_acp_commands().is_some());
assert!(tracker.take_pending_acp_commands().is_none());
}
#[test]
fn tracker_latest_update_replaces_pending() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
tracker.handle_update(available_commands_update(&["old"]), &meta(), &mut sb);
tracker.handle_update(
available_commands_update(&["new_a", "new_b"]),
&meta(),
&mut sb,
);
let cmds = tracker
.take_pending_acp_commands()
.expect("should have pending");
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].name, "new_a");
assert_eq!(cmds[1].name, "new_b");
}
#[test]
fn parse_search_tool_results_grouped_format() {
let json = serde_json::json!(
{ "results" : [{ "server" : "linear", "tools" : [{ "tool_name" :
"linear__save_issue", "description" : "Create an issue", "score" : 0.8,
"parameters" : ["stale_param_a", "stale_param_b"], "input_schema" : { "type"
: "object", "properties" : { "title" : { "type" : "string" }, "team" : {
"type" : "string" } }, "required" : ["title"] } }, { "tool_name" :
"linear__list_issues", "description" : "List issues", "score" : 0.5,
"parameters" : ["stale_query"], "input_schema" : { "type" : "object",
"properties" : { "query" : { "type" : "string" } } } }] }, { "server" :
"slack", "tools" : [{ "tool_name" : "slack__send_message", "description" :
"Send a message", "score" : 0.3, "input_schema" : {} }] }],
"total_hidden_tools" : 10, "status" : "ready" }
);
let content = serde_json::to_string_pretty(&json).unwrap();
let results = parse_search_tool_results(&content);
assert_eq!(results.len(), 3);
assert_eq!(results[0].name, "linear__save_issue");
assert_eq!(results[0].server, "linear");
assert_eq!(results[0].description, "Create an issue");
assert!((results[0].score - 0.8).abs() < f64::EPSILON);
assert_eq!(results[1].name, "linear__list_issues");
assert_eq!(results[1].server, "linear");
assert_eq!(results[2].name, "slack__send_message");
assert_eq!(results[2].server, "slack");
}
#[test]
fn parse_search_tool_results_old_flat_format_returns_empty() {
let json = serde_json::json!(
{ "results" : [{ "tool_name" : "linear__save_issue", "server_name" :
"linear", "description" : "Create an issue", "score" : 0.8 }] }
);
let content = serde_json::to_string_pretty(&json).unwrap();
let results = parse_search_tool_results(&content);
assert!(
results.is_empty(),
"old flat format should not parse: {results:?}"
);
}
#[test]
fn tracker_extracts_tools_meta_from_available_commands_update() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
let update = acp::SessionUpdate::AvailableCommandsUpdate(
acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
"loop".to_string(),
"loop".to_string(),
)])
.meta(
serde_json::json!({ "tools" : ["scheduler_create", "read_file"] })
.as_object()
.cloned(),
),
);
tracker.handle_update(update, &meta(), &mut sb);
let tools = tracker
.take_pending_acp_tools()
.expect("tools list should be present");
assert_eq!(tools, vec!["scheduler_create", "read_file"]);
assert!(tracker.take_pending_acp_tools().is_none());
}
#[test]
fn tracker_tools_meta_absent_when_meta_missing() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
tracker.handle_update(available_commands_update(&["loop"]), &meta(), &mut sb);
assert!(tracker.take_pending_acp_tools().is_none());
assert!(tracker.take_pending_acp_commands().is_some());
}
#[test]
fn parse_tools_meta_handles_shape_variants() {
assert_eq!(
parse_tools_meta(serde_json::json!({ "tools" : ["a", "b"] }).as_object()),
Some(vec!["a".to_string(), "b".to_string()]),
);
assert_eq!(parse_tools_meta(None), None);
assert_eq!(
parse_tools_meta(serde_json::json!({ "other" : 1 }).as_object()),
None,
);
assert_eq!(
parse_tools_meta(serde_json::json!({ "tools" : "nope" }).as_object()),
None,
);
assert_eq!(
parse_tools_meta(serde_json::json!({ "tools" : ["a", 1, true, "b"] }).as_object()),
Some(vec!["a".to_string(), "b".to_string()]),
);
}
#[test]
fn update_summary_is_compact_for_huge_tool_output() {
let big = serde_json::to_value(vec![0u8; 100_000]).unwrap();
let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("call-1")),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.raw_output(Some(big)),
));
let summary = update_summary(&update);
assert!(
summary.len() < 200,
"summary must not scale with payload: {} bytes",
summary.len()
);
assert!(summary.contains("tool_call_update"), "{summary}");
assert!(summary.contains("id=call-1"), "{summary}");
assert!(summary.contains("arr(100000)"), "{summary}");
}
#[test]
fn update_summary_reports_chunk_text_size() {
let summary = update_summary(&agent_chunk(&"x".repeat(5000)));
assert!(summary.contains("agent_message_chunk"), "{summary}");
assert!(summary.contains("text=5000B"), "{summary}");
assert!(summary.len() < 100, "{summary}");
}
#[test]
fn json_size_hint_shapes() {
assert_eq!(json_size_hint(&serde_json::json!(null)), "null");
assert_eq!(json_size_hint(&serde_json::json!("abcd")), "str(4B)");
assert_eq!(json_size_hint(&serde_json::json!([1, 2, 3])), "arr(3)");
assert_eq!(
json_size_hint(&serde_json::json!({ "output" : [1, 2], "cmd" : "ls" })),
"obj(2 keys, ~4B)"
);
}
#[test]
fn meta_summary_handles_missing_fields() {
assert_eq!(
meta_summary(&NotificationMeta::default()),
"seq=- tokens=- prompt=- stream_start=-"
);
let m = NotificationMeta {
event_seq: Some(42),
total_tokens: Some(1234),
..Default::default()
};
assert_eq!(
meta_summary(&m),
"seq=42 tokens=1234 prompt=- stream_start=-"
);
}
#[test]
fn build_and_parse_tools_meta_round_trip() {
let names = vec!["scheduler_create".to_string(), "image_gen".to_string()];
let wire = serde_json::json!({ "tools" : names });
assert_eq!(parse_tools_meta(wire.as_object()), Some(names));
}
#[test]
fn tracker_finish_turn_does_not_clear_pending_acp_commands() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
tracker.handle_update(available_commands_update(&["persist"]), &meta(), &mut sb);
tracker.finish_turn(&mut sb);
assert!(tracker.take_pending_acp_commands().is_some());
}
#[test]
fn tracker_finish_turn_does_not_clear_pending_acp_tools() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
let update = acp::SessionUpdate::AvailableCommandsUpdate(
acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
"loop".to_string(),
"loop".to_string(),
)])
.meta(
serde_json::json!({ "tools" : ["scheduler_create"] })
.as_object()
.cloned(),
),
);
tracker.handle_update(update, &meta(), &mut sb);
tracker.finish_turn(&mut sb);
let tools = tracker
.take_pending_acp_tools()
.expect("pending_acp_tools should survive finish_turn");
assert_eq!(tools, vec!["scheduler_create"]);
}
#[test]
fn tracker_meta_less_update_preserves_prior_pending_acp_tools() {
let mut tracker = AcpUpdateTracker::new();
let mut sb = ScrollbackState::new();
let with_tools = acp::SessionUpdate::AvailableCommandsUpdate(
acp::AvailableCommandsUpdate::new(vec![]).meta(
serde_json::json!({ "tools" : ["scheduler_create"] })
.as_object()
.cloned(),
),
);
tracker.handle_update(with_tools, &meta(), &mut sb);
tracker.handle_update(available_commands_update(&["loop"]), &meta(), &mut sb);
let tools = tracker
.take_pending_acp_tools()
.expect("prior pending tools should be preserved");
assert_eq!(tools, vec!["scheduler_create"]);
}
/// Build a `ToolCall` that mimics the initial ACP register-early payload
/// emitted by `acp_session.rs`: title comes from the model's function
/// name, raw_input is None.
fn initial_tool_call(id: &str, function_name: &str) -> acp::ToolCall {
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from(id)),
function_name.to_string(),
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
}
#[test]
fn is_task_tool_recognizes_grok_build_variant() {
assert!(is_task_tool(&initial_tool_call("tc1", "task")));
let mut with_variant = initial_tool_call("tc2", "anything");
with_variant.raw_input = Some(serde_json::json!({ "variant" : "Task" }));
assert!(is_task_tool(&with_variant));
}
#[test]
fn is_task_tool_rejects_unrelated_tools() {
assert!(!is_task_tool(&initial_tool_call("tc1", "read_file")));
assert!(!is_task_tool(&initial_tool_call("tc2", "Read")));
assert!(!is_task_tool(&initial_tool_call("tc3", "todo_write")));
let mut with_variant = initial_tool_call("tc4", "anything");
with_variant.raw_input = Some(serde_json::json!({ "variant" : "Bash" }));
assert!(!is_task_tool(&with_variant));
}
#[test]
fn is_bg_plumbing_tool_recognizes_all_name_generations() {
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t1",
"get_command_or_subagent_output"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t2",
"kill_command_or_subagent"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t3",
"wait_commands_or_subagents"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t4",
"get_task_output"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call("t5", "kill_task")));
assert!(is_bg_plumbing_tool(&initial_tool_call("t6", "wait_tasks")));
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t7",
"get_task_or_subagent_output"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t8",
"kill_task_or_subagent"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call(
"t9",
"wait_tasks_or_subagents"
)));
assert!(is_bg_plumbing_tool(&initial_tool_call("t10", "AwaitShell")));
assert!(is_bg_plumbing_tool(&initial_tool_call("t10b", "Await")));
let mut with_variant = initial_tool_call("t11", "anything");
with_variant.raw_input = Some(serde_json::json!({ "variant" : "WaitTasks" }));
assert!(is_bg_plumbing_tool(&with_variant));
assert!(!is_bg_plumbing_tool(&initial_tool_call("t12", "read_file")));
assert!(!is_bg_plumbing_tool(&initial_tool_call(
"t13",
"spawn_subagent"
)));
}
#[test]
fn pascal_case_task_tool_call_is_suppressed_from_scrollback() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Other, "Task"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 0, "PascalCase Task tool must be suppressed");
assert!(tracker.suppressed_tools.contains("tc1"));
tracker.handle_update(tool_update_completed("tc1"), &meta(), &mut sb);
assert_eq!(
sb.len(),
0,
"PascalCase Task updates must also be suppressed"
);
}
#[test]
fn task_tool_surfaces_as_subagent_wait_not_run_task() {
for title in ["task", "Task"] {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Other, title),
&meta(),
&mut sb,
);
let activity = tracker.activity();
assert!(
!matches!(activity, Some(TurnActivity::ToolRunning { .. })),
"suppressed task tool with title={title:?} must not surface as ToolRunning \
(would render as 'Run {title}' in the bottom turn-status spinner)"
);
assert_eq!(
activity,
Some(TurnActivity::Waiting(WaitingReason::Subagent)),
"suppressed task tool with title={title:?} should surface as the subagent wait"
);
}
}
/// Helper: create an InProgress ToolCallUpdate with raw_input containing is_background.
fn tool_update_in_progress_bg(id: &str, output_bytes: &[u8]) -> acp::SessionUpdate {
use kigi_tools::types::output::{BashOutput, ToolOutput};
let bash = BashOutput {
output: output_bytes.to_vec(),
output_for_prompt: String::new(),
exit_code: 0,
command: String::new(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: String::new(),
output_file: String::new(),
total_bytes: output_bytes.len(),
output_delta: None,
was_bare_echo: false,
};
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from(id)),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.raw_output(serde_json::to_value(ToolOutput::Bash(bash)).ok())
.raw_input(Some(serde_json::json!(
{ "command" : "sleep 9999", "is_background" : true,
"description" : "long running task" }
))),
))
}
/// Regression: is_bg_tool() detected on first InProgress defers the tool
/// before any scrollback entry is created.
#[test]
fn bg_tool_detected_at_first_update_defers_to_bg() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `sleep 9999`"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
let output_epoch = tracker.agent_output_epoch();
let modified = tracker.handle_update(
tool_update_in_progress_bg("tc1", b"started"),
&meta(),
&mut sb,
);
assert!(
!modified,
"bg tool deferral should suppress further output streaming"
);
assert_eq!(tracker.agent_output_epoch(), output_epoch + 1);
assert_eq!(sb.len(), 1, "real execute entry kept for demotion");
assert!(
!tracker.pending_tools.is_empty(),
"tool stays in pending_tools for demotion entry_id"
);
assert!(
tracker.bg_deferred_tools.contains_key("tc1"),
"tool should be added to bg_deferred_tools"
);
assert_eq!(
tracker.bg_deferred_tools.get("tc1").unwrap().as_deref(),
Some("long running task"),
"description should be extracted from raw_input"
);
}
/// Eager kind=Other title=`run_terminal_command` must not flash in the TUI.
#[test]
fn eager_execute_function_name_is_loading_placeholder_not_label() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Other, "run_terminal_command"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
match &sb.get(0).unwrap().block {
RenderBlock::ToolCall(ToolCallBlock::Execute(ex)) => {
assert!(
ex.command.is_empty(),
"must not use function name as command: {:?}",
ex.command
);
}
other => panic!("expected loading Execute, got {other:?}"),
}
let _ = tracker.handle_update(tool_update_in_progress_bg("tc1", b""), &meta(), &mut sb);
assert_eq!(sb.len(), 1, "real command keeps entry for demotion");
match &sb.get(0).unwrap().block {
RenderBlock::ToolCall(ToolCallBlock::Execute(ex)) => {
assert_eq!(ex.command, "sleep 9999");
assert_eq!(ex.description.as_deref(), Some("long running task"));
}
other => panic!("expected refined Execute, got {other:?}"),
}
assert!(tracker.bg_deferred_tools.contains_key("tc1"));
assert!(tracker.pending_tools.contains_key("tc1"));
}
/// `raw_input.command: ""` must still map Other+function-name to loading Execute
/// (not leave a bold `run_terminal_command` Other label).
#[test]
fn empty_command_key_still_maps_function_name_to_loading_execute() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Other, "run_terminal_command"),
&meta(),
&mut sb,
);
let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc1")),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.raw_input(Some(serde_json::json!(
{ "command" : "", "description" : "still loading" }
))),
));
tracker.handle_update(update, &meta(), &mut sb);
assert_eq!(sb.len(), 1);
match &sb.get(0).unwrap().block {
RenderBlock::ToolCall(ToolCallBlock::Execute(ex)) => {
assert!(ex.command.is_empty(), "empty command stays placeholder");
assert_eq!(ex.description.as_deref(), Some("still loading"));
}
other => panic!("expected loading Execute, got {other:?}"),
}
}
/// Real backgrounded `bash` command must not be treated as a placeholder.
#[test]
fn real_bash_command_is_not_dropped_on_bg_deferral() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "bash"),
&meta(),
&mut sb,
);
let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc1")),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::InProgress))
.kind(Some(acp::ToolKind::Execute))
.raw_input(Some(serde_json::json!(
{ "command" : "bash", "is_background" : true, "description"
: "start a shell" }
))),
));
tracker.handle_update(update, &meta(), &mut sb);
assert_eq!(
sb.len(),
1,
"real command=bash must be kept for demotion, not dropped as placeholder"
);
assert!(tracker.pending_tools.contains_key("tc1"));
assert!(tracker.bg_deferred_tools.contains_key("tc1"));
}
/// A completed function-name `Other` tool call that still carries BashOutput
/// must preserve the command output + exit-code error (mirror of the Execute
/// arm), not drop it when the kind was never refined to Execute.
#[test]
fn completed_other_function_name_preserves_bash_output() {
use kigi_tools::types::output::{BashOutput, ToolOutput};
let bash = BashOutput {
output: b"hello from bg\n".to_vec(),
output_for_prompt: String::new(),
exit_code: 3,
command: "echo hi".to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: 14,
output_delta: None,
was_bare_echo: false,
};
let tc = acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("tc1")),
"run_terminal_command".to_string(),
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.raw_input(Some(serde_json::json!({ "command" : "echo hi" })))
.raw_output(serde_json::to_value(ToolOutput::Bash(bash)).ok())
.locations(vec![]);
match tool_call_to_block(&tc, None) {
RenderBlock::ToolCall(ToolCallBlock::Execute(ex)) => {
assert_eq!(ex.command, "echo hi");
assert_eq!(ex.output.as_deref(), Some("hello from bg\n"));
assert_eq!(ex.error.as_deref(), Some("exit code 3"));
}
other => panic!("expected Execute with output, got {other:?}"),
}
}
/// Regression: when raw_input with is_background=true arrives after the
/// Execute block was already created (late detection), the tool must still
/// be moved to bg_deferred_tools so the task_backgrounded handler can
/// demote the existing entry.
#[test]
fn bg_tool_late_detection_defers_existing_entry() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `sleep 9999`"),
&meta(),
&mut sb,
);
assert_eq!(tracker.pending_tools.len(), 1);
tracker.handle_update(
tool_update_in_progress("tc1", b"early output"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1, "Execute block should be created");
assert!(
tracker.pending_tools.get("tc1").unwrap().entry_id.is_some(),
"entry_id should be set"
);
let modified = tracker.handle_update(
tool_update_in_progress_bg("tc1", b"more output"),
&meta(),
&mut sb,
);
assert!(!modified, "late bg detection should suppress the update");
assert!(
tracker.pending_tools.contains_key("tc1"),
"tool must stay in pending_tools so demotion handler can find entry_id"
);
assert!(
tracker.bg_deferred_tools.contains_key("tc1"),
"tool should also be in bg_deferred_tools to suppress future updates"
);
assert_eq!(sb.len(), 1);
}
/// Non-background Execute tools are unaffected by the late-detection path.
#[test]
fn non_bg_execute_unaffected_by_late_detection() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `ls`"),
&meta(),
&mut sb,
);
tracker.handle_update(
tool_update_in_progress("tc1", b"file1.rs"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
assert!(tracker.bg_deferred_tools.is_empty());
tracker.handle_update(
tool_update_in_progress("tc1", b"file1.rs\nfile2.rs"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1, "still one entry");
assert_eq!(tracker.pending_tools.len(), 1, "still pending");
assert!(
tracker.bg_deferred_tools.is_empty(),
"should not defer non-bg tool"
);
}
/// Regression: handle_user_message must finish_running on pending tool entries
/// before clearing them, otherwise Execute blocks are orphaned as "running".
#[test]
fn handle_user_message_finishes_pending_tool_entries() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `sleep 999`"),
&meta(),
&mut sb,
);
tracker.handle_update(
tool_update_in_progress("tc1", b"waiting..."),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert!(sb.get(0).unwrap().is_running, "tool should be running");
assert_eq!(tracker.pending_tools.len(), 1);
tracker.handle_update(user_message("cancel that"), &meta(), &mut sb);
assert!(
!sb.get(0).unwrap().is_running,
"Execute block should be finished by handle_user_message",
);
assert!(
tracker.pending_tools.is_empty(),
"pending_tools should be drained",
);
assert!(
!sb.needs_animation(),
"no entries should be animating after user message",
);
}
/// Regression: finish_turn must call finish_running even for tools that are
/// in bg_deferred_tools. The turn is over — the original Execute block must
/// not stay orphaned as "running".
#[test]
fn finish_turn_finishes_bg_deferred_tool_entries() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
tool_call("tc1", acp::ToolKind::Execute, "Execute `long cmd`"),
&meta(),
&mut sb,
);
tracker.handle_update(
tool_update_in_progress("tc1", b"early output"),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
assert!(sb.get(0).unwrap().is_running);
tracker.handle_update(
tool_update_in_progress_bg("tc1", b"more output"),
&meta(),
&mut sb,
);
assert!(
tracker.bg_deferred_tools.contains_key("tc1"),
"tool should be in bg_deferred_tools",
);
assert!(
tracker.pending_tools.contains_key("tc1"),
"tool should still be in pending_tools",
);
tracker.finish_turn(&mut sb);
assert!(
!sb.get(0).unwrap().is_running,
"Execute block must be finished even when in bg_deferred_tools",
);
assert!(
tracker.pending_tools.is_empty(),
"pending_tools should be drained",
);
assert!(
!sb.needs_animation(),
"no entries should be animating after finish_turn",
);
assert!(
tracker.bg_deferred_tools.contains_key("tc1"),
"bg_deferred_tools must survive finish_turn",
);
}
/// Helper: create a UserMessageChunk with displayText in content block meta.
fn user_message_with_display_text(
raw_text: &str,
display_text: &str,
display_as_skill: bool,
) -> acp::SessionUpdate {
let mut meta_map = serde_json::Map::new();
meta_map.insert(
"displayText".into(),
serde_json::Value::String(display_text.into()),
);
if display_as_skill {
meta_map.insert("displayAsSkill".into(), serde_json::Value::Bool(true));
}
acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(raw_text.to_string())
.meta(serde_json::Value::Object(meta_map).as_object().cloned()),
)))
}
/// Replay with displayText in content meta shows clean display text
/// instead of raw skill instructions.
#[test]
fn replay_display_text_override() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let raw = "# /loop -- schedule a recurring prompt\n\nParse the input below...";
tracker.handle_update(
user_message_with_display_text(raw, "/loop 1m print current time", true),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::UserPrompt(block) => {
assert_eq!(
block.skill_token_ranges,
vec![0..5],
"leading /loop token styled as skill"
);
assert_eq!(block.text, "/loop 1m print current time");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
}
/// displayText with displayAsSkill=false creates a regular prompt block.
#[test]
fn replay_display_text_non_skill() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let raw = "some raw wire content";
tracker.handle_update(
user_message_with_display_text(raw, "clean display text", false),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::UserPrompt(block) => {
assert!(
block.skill_token_ranges.is_empty(),
"should NOT be styled as skill"
);
assert_eq!(block.text, "clean display text");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
}
/// displayText with legacy XML raw text still skips the body block.
#[test]
fn replay_display_text_with_legacy_xml_skips_body() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let xml = "<command-name>implement</command-name>\n\
<command-message>/implement</command-message>\n\
<command-args>fix bug</command-args>";
tracker.handle_update(
user_message_with_display_text(xml, "/implement fix bug", true),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.skill_token_ranges, vec![0..10]);
assert_eq!(block.text, "/implement fix bug");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
assert!(
!tracker.handle_update(user_message("You are an orchestrator..."), &meta(), &mut sb),
"skill body should be absorbed",
);
assert_eq!(sb.len(), 1, "no new entry for skill body");
}
/// Sessions without displayText still work via legacy fallback.
#[test]
fn replay_without_display_text_uses_legacy_detection() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let xml = "<command-name>commit</command-name>\n\
<command-message>/commit</command-message>\n\
<command-args>fix typo</command-args>";
tracker.handle_update(user_message(xml), &meta(), &mut sb);
assert_eq!(sb.len(), 1);
let entry = sb.get(0).unwrap();
match &entry.block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.skill_token_ranges, vec![0..7]);
assert_eq!(block.text, "/commit fix typo");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
let mut sb2 = ScrollbackState::new();
let mut tracker2 = AcpUpdateTracker::new();
tracker2.handle_update(user_message("/help"), &meta(), &mut sb2);
let entry2 = sb2.get(0).unwrap();
match &entry2.block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.skill_token_ranges, vec![0..5]);
assert_eq!(block.text, "/help");
}
other => panic!("expected UserPrompt, got {:?}", other),
}
}
fn user_message_with_chunk_meta(text: &str, chunk_meta: acp::Meta) -> acp::SessionUpdate {
acp::SessionUpdate::UserMessageChunk(
acp::ContentChunk::new(acp::ContentBlock::Text(acp::TextContent::new(
text.to_string(),
)))
.meta(Some(chunk_meta)),
)
}
fn meta_with_prompt_id(prompt_id: &str) -> NotificationMeta {
let mut m = meta();
m.prompt_id = Some(prompt_id.to_string());
m
}
/// Scrollback hide is type-driven: chunk meta `hideFromScrollback` or
/// notification `promptId` → [`PromptOrigin::hide_user_echo_from_scrollback`].
#[test]
fn replay_hides_user_echo_by_origin_type() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let monitor_xml = "\
<monitor-event task_id=\"019e0000-0000-7000-8000-000000000001\">\n\
[CI] IN_PROGRESS=5 SUCCESS=38\n\
</monitor-event>";
let mut hide_meta = acp::Meta::new();
hide_meta.insert("hideFromScrollback".into(), serde_json::json!(true));
assert!(
!tracker.handle_update(
user_message_with_chunk_meta(monitor_xml, hide_meta),
&meta(),
&mut sb,
),
"hideFromScrollback meta must suppress regardless of text shape"
);
assert!(
!tracker.handle_update(
user_message("arbitrary model-only body"),
&meta_with_prompt_id("task-completed-bg-1"),
&mut sb,
),
"task-completed origin must suppress via promptId"
);
assert!(
!tracker.handle_update(
user_message("drain body"),
&meta_with_prompt_id("notifications-019e0000"),
&mut sb,
),
"notification-drain origin must suppress via promptId"
);
assert!(
tracker.handle_update(
user_message("please check the CI status"),
&meta_with_prompt_id("scheduler-fired-abc"),
&mut sb,
),
"scheduler-fired must still render (cron path is separate)"
);
assert!(
tracker.handle_update(user_message("please check the CI status"), &meta(), &mut sb),
"real user text must still render"
);
assert_eq!(sb.len(), 2);
assert!(
!tracker.handle_update(user_message(monitor_xml), &meta(), &mut sb),
"legacy untyped monitor XML still suppressed"
);
assert!(
!tracker.handle_update(
user_message("<system-reminder>\nBackground task done.\n</system-reminder>"),
&meta(),
&mut sb,
),
"legacy system-reminder still suppressed"
);
let batched = "2 monitor events from 1 monitor (use get_command_or_subagent_output \
to identify each monitor):\n\n<monitor description=\"ticks\" \
task_id=\"t-1\">\n[1] tick-1\n[2] tick-2\n</monitor>";
assert!(
!tracker.handle_update(user_message(batched), &meta(), &mut sb),
"legacy batched drain preamble still suppressed"
);
assert!(
!tracker.handle_update(user_message("---"), &meta(), &mut sb),
"legacy drain section separator still suppressed"
);
assert!(
tracker.handle_update(
user_message("what do these monitor events from my run mean (use plain words)?"),
&meta(),
&mut sb,
),
"digit anchor: user text with both phrases but no leading count still renders"
);
assert_eq!(sb.len(), 3);
}
/// Helper: UserMessageChunk with `skillTokenRanges` in content-block meta.
fn user_message_with_token_ranges(text: &str, ranges: serde_json::Value) -> acp::SessionUpdate {
let mut meta_map = acp::Meta::new();
meta_map.insert("skillTokenRanges".into(), ranges);
acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(text.to_string()).meta(Some(meta_map)),
)))
}
/// `skillTokenRanges` meta round-trips into a token-styled block: same
/// text, same ranges.
#[test]
fn replay_skill_token_ranges_styles_block() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
user_message_with_token_ranges(
"great /pr-workflow all good now",
serde_json::json!([[6, 18]]),
),
&meta(),
&mut sb,
);
assert_eq!(sb.len(), 1);
match &sb.get(0).unwrap().block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.text, "great /pr-workflow all good now");
assert_eq!(block.skill_token_ranges, vec![6..18]);
}
other => panic!("expected UserPrompt, got {:?}", other),
}
}
/// `skillTokenRanges` index the wire text, so a `displayText` override (a
/// different coordinate space) IGNORES them — `displayAsSkill` keeps
/// owning that branch. No first-party producer stamps both.
#[test]
fn replay_display_text_ignores_skill_token_ranges() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
let mut meta_map = serde_json::Map::new();
meta_map.insert(
"displayText".into(),
serde_json::Value::String("/commit now".into()),
);
meta_map.insert("displayAsSkill".into(), serde_json::Value::Bool(true));
meta_map.insert("skillTokenRanges".into(), serde_json::json!([[3, 10]]));
tracker.handle_update(
acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new("raw wire text".to_string()).meta(Some(meta_map)),
))),
&meta(),
&mut sb,
);
match &sb.get(0).unwrap().block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.text, "/commit now", "displayText still applies");
assert_eq!(
block.skill_token_ranges,
vec![0..7],
"displayAsSkill styling (leading token), not the wire-space ranges"
);
}
other => panic!("expected UserPrompt, got {:?}", other),
}
}
/// Malformed/out-of-bounds ranges never panic; the block degrades to a
/// plain prompt (missing meta keeps the legacy fallbacks — pinned above).
#[test]
fn replay_malformed_skill_token_ranges_degrade_to_plain() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.handle_update(
user_message_with_token_ranges(
"short text",
serde_json::json!([[100, 200], "bogus", [3]]),
),
&meta(),
&mut sb,
);
match &sb.get(0).unwrap().block {
RenderBlock::UserPrompt(block) => {
assert_eq!(block.text, "short text");
assert!(
block.skill_token_ranges.is_empty(),
"invalid ranges dropped"
);
}
other => panic!("expected UserPrompt, got {:?}", other),
}
}
#[test]
fn call_mcp_tool_coerced_to_use_tool_renders_block() {
let tc = acp::ToolCall::new(acp::ToolCallId::new(Arc::from("mcp1")), "grafana__search")
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.raw_input(Some(serde_json::json!(
{ "variant" : "UseTool", "tool_name" : "grafana__search",
"tool_input" : { "query" : "alerts" } }
)))
.locations(vec![]);
let block = tool_call_to_block(&tc, None);
let RenderBlock::ToolCall(ToolCallBlock::UseTool(ut)) = block else {
panic!("expected UseTool block, got {block:?}");
};
assert_eq!(ut.tool_name, "grafana__search");
}
#[test]
fn call_mcp_tool_no_raw_input_does_not_panic() {
let tc = acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("mcp2")),
"linear__save_issue",
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.locations(vec![]);
let _block = tool_call_to_block(&tc, None);
}
#[test]
fn cursor_todo_write_suppressed_by_title() {
assert!(is_todo_tool(&initial_tool_call("tc1", "TodoWrite")));
assert!(is_todo_tool(&initial_tool_call("tc2", "Updating plan")));
assert!(is_todo_tool(&initial_tool_call("tc3", "todo_write")));
}
#[test]
fn todo_write_suppressed_by_variant() {
let mut tc = initial_tool_call("tc1", "anything");
tc.raw_input = Some(serde_json::json!({ "variant" : "TodoWrite" }));
assert!(is_todo_tool(&tc));
}
#[test]
fn pascal_case_todo_write_suppressed_from_scrollback() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
for (i, title) in ["TodoWrite", "Updating plan"].iter().enumerate() {
let id = format!("tc-todo-{i}");
tracker.handle_update(
tool_call(&id, acp::ToolKind::Think, title),
&meta(),
&mut sb,
);
assert_eq!(
sb.len(),
0,
"todo tool with title={title:?} must be suppressed"
);
}
}
/// Every video ToolInput variant must route through `media_gen_block` so
/// `[Open Video]` uses the typed `MediaGenOutput.path` (not a regex scrape
/// of the JSON prompt text — fragile on Windows with %-encoded session dirs).
#[test]
fn video_tool_variants_use_typed_path_not_generic_scrape() {
use crate::scrollback::block::BlockContent;
let dir = tempfile::tempdir().unwrap();
let video_path = dir.path().join("1.mp4");
std::fs::write(&video_path, b"fake-mp4").unwrap();
let cases: &[(&str, ToolOutput)] = &[
(
"ImageToVideo",
ToolOutput::ImageToVideo(kigi_tools::types::output::MediaGenOutput::new(
video_path.clone(),
)),
),
(
"ReferenceToVideo",
ToolOutput::ReferenceToVideo(kigi_tools::types::output::MediaGenOutput::new(
video_path.clone(),
)),
),
];
for (variant, output) in cases {
let tc = acp::ToolCall::new(
acp::ToolCallId::new(Arc::from(format!("media-{variant}"))),
variant.to_string(),
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.raw_input(Some(serde_json::json!({ "variant" : variant })))
.raw_output(serde_json::to_value(output).ok())
.locations(vec![]);
let block = tool_call_to_block(&tc, None);
let open_path = block
.inline_open_button()
.map(|(p, is_video)| {
assert!(is_video, "{variant}: expected video open button");
p
})
.or_else(|| block.video_references().first().map(|r| r.path.clone()))
.unwrap_or_else(|| panic!("{variant}: missing media ref / open button"));
assert_eq!(
open_path, video_path,
"{variant}: open path must be the typed MediaGenOutput.path"
);
}
}
#[test]
fn media_gen_ref_skips_uploaded_only_video() {
let output = ToolOutput::ImageToVideo(kigi_tools::types::output::MediaGenOutput::uploaded(
"https://bucket.example/videos/x.mp4".into(),
));
let tc = acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("zdr-upload")),
"image_to_video",
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Completed)
.content(vec![])
.raw_input(Some(serde_json::json!({ "variant" : "ImageToVideo" })))
.raw_output(serde_json::to_value(output).ok())
.locations(vec![]);
assert!(
media_gen_ref(&tc).is_none(),
"uploaded_url-only media must not claim a local open path"
);
}
/// A tier-restricted (free / X Basic) imagine call short-circuits with the
/// SuperGrok upsell as `ToolOutput::Text` on a `Completed` status. The media
/// renderer has no file to open, so it must surface the upsell text in the
/// card body (not a bare title) and must NOT mark the card as an error.
#[test]
fn tier_restricted_media_shows_upsell_text_not_error() {
let upsell = "Image generation is a SuperGrok feature. Upgrade at \
https://grok.com/supergrok?referrer=grok-build";
let output = ToolOutput::Text(kigi_tools::types::output::TextOutput::from(upsell));
let tc = acp::ToolCall::new(
acp::ToolCallId::new(Arc::from("tier-restricted-img")),
"image_gen",
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Completed)
.content(vec![acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::Text(acp::TextContent::new(upsell)),
))])
.raw_input(Some(serde_json::json!({ "variant" : "ImageGen" })))
.raw_output(serde_json::to_value(output).ok())
.locations(vec![]);
let RenderBlock::ToolCall(ToolCallBlock::Other(block)) = tool_call_to_block(&tc, None)
else {
panic!("expected an Other tool-call block");
};
assert!(
block.is_success(),
"the upsell is a successful result, not an error"
);
assert!(
block
.output
.as_deref()
.unwrap_or_default()
.contains("SuperGrok"),
"upsell text must be shown in the card body, got: {:?}",
block.output
);
}
}