//! 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 { 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, subject: Option, /// 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, } /// `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 ` when set /// (renders as `{desc}…`). description: Option, }, /// 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, pub estimate_after: u64, pub elapsed_ms: Option, pub last_used: Option, } /// 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, /// Entry currently receiving AgentThoughtChunk deltas. /// None when agent isn't thinking. current_thinking: Option, /// Tool calls in flight, keyed by ACP tool call ID string. /// Stores the base ToolCall for field merging with ToolCallUpdate. pending_tools: HashMap, /// ToolCallUpdates that arrived before their ToolCall (race condition). /// When the ToolCall arrives, we merge and create the entry immediately /// as completed. orphan_updates: HashMap, /// 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, /// 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, /// 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, /// 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, /// 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>, /// 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, /// 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, /// Compaction-related activity override. /// Set by `set_compaction_activity()` from ExtNotification events, /// cleared by `finish_turn()`. compaction_activity: Option, pending_compaction: Option, /// 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, /// 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>, /// 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>, /// Live Edit completions awaiting full-file HL (drained via [`Self::take_pending_edit_hl`]). pending_edit_hl: Vec, } /// 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, 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, } /// 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, /// 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) { 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 &&` / `;` that only restates the session working directory. pub fn activity(&self) -> Option { 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 { 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 { 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) { 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 { 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) { self.compaction_activity = activity; } pub fn defer_compaction( &mut self, tokens_before: Option, estimate_after: u64, elapsed_ms: Option, ) { 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) { 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> { 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> { 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 { 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("") { 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("") { 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> { 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 { 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("") || t.starts_with("` cron framing. /// /// Matches the format produced by `format_scheduled_task_prompt`: /// `"\nThis is a scheduled task execution...\n\n\n"` /// /// 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 { if !text.starts_with("") { return None; } let end_tag = ""; 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 ` was stripped, else None. fn peeled_if_changed(command: &str, session_cwd: Option<&Path>) -> Option { 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 { 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 ` 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::(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::(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 = 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::(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::(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::(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 { match serde_json::from_value::(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::(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::>() .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 { 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 { let mut out = Vec::new(); let mut seen = std::collections::HashSet::::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 { 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::(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 { 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) -> Option { let val = raw.as_ref()?; match serde_json::from_value::(val.clone()) { Ok(ToolOutput::Bash(bash)) => Some(bash), _ => None, } } /// Extracted grep search results. #[derive(Default)] struct GrepResult { match_count: usize, file_matches: Vec, /// File paths only (for files_with_matches output mode). file_paths: Vec, } /// Extract grep search results from raw_output. fn extract_grep_output(raw: &Option) -> Option { let val = raw.as_ref()?; match serde_json::from_value::(val.clone()) { Ok(ToolOutput::GrepSearch(grep)) => { let file_matches: Vec = 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 /// /// Found N files /// /path/to/file1.rs /// /path/to/file2.rs /// /// ``` fn parse_file_paths_from_stdout(stdout: &str) -> Vec { 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) -> Option { let val = raw.as_ref()?; match serde_json::from_value::(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> { 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`, 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 { let Ok(val) = serde_json::from_str::(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) -> Option { let val = raw.as_ref()?; if let Ok(output) = serde_json::from_value::(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::(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 = (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 = "implement\n\ /implement\n\ fix the rendering bug"; 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 = "deploy\n\ /deploy"; 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 = "implement\n\ /implement\n\ fix bug"; 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 = "commit\n\ /commit"; 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::(...). #[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 = 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) -> 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 = 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 = 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 = 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 { 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 = (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| { 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 = "implement\n\ /implement\n\ fix bug"; 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 = "commit\n\ /commit\n\ fix typo"; 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 = "\ \n\ [CI] IN_PROGRESS=5 SUCCESS=38\n\ "; 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("\nBackground task done.\n"), &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\n[1] tick-1\n[2] tick-2\n"; 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 ); } }