Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1289 lines
52 KiB
Rust
1289 lines
52 KiB
Rust
//! Session lifecycle: bind/reload/replay bookkeeping, turn activity
|
|
//! resolution, context updates, and app-scoped gates.
|
|
#[cfg(test)]
|
|
use super::test_agent_view;
|
|
use super::{
|
|
ActivePane, AgentView, InlineMediaHitAreas, InputMode, PaneAreas, PromptInputMode, PromptMode,
|
|
SELF_ORIGINATED_PROMPT_CAP, SessionReload,
|
|
};
|
|
use crate::app::agent::AgentSession;
|
|
use crate::app::app_view::InputOutcome;
|
|
use crate::scrollback::state::ScrollbackState;
|
|
use crate::scrollback::text_selection::ResolvedSelectionModel;
|
|
use crate::views::prompt_widget::PromptWidget;
|
|
use crate::views::queue_pane::QueuePane;
|
|
use crate::views::subagent_catalog_pane::SubagentCatalogPane;
|
|
use crate::views::tasks_pane::TasksPane;
|
|
use crate::views::todo_pane::TodoPane;
|
|
use ratatui::layout::Rect;
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::time::Instant;
|
|
impl AgentView {
|
|
/// Bind this view to a root session id, resetting the per-session
|
|
/// reconnect cursor and both dedup highwaters (ACP + xAI) when the id
|
|
/// actually changes — all three are meaningless against another session's
|
|
/// event-id history (a stale cursor relies on exact-match failure for
|
|
/// safety; a stale highwater could dedup-drop the new session's events
|
|
/// outright).
|
|
pub(crate) fn bind_session_id(&mut self, session_id: agent_client_protocol::SessionId) {
|
|
if self.session.session_id.as_ref() != Some(&session_id) {
|
|
self.last_seen_event_id = None;
|
|
self.last_applied_event_seq = None;
|
|
self.last_applied_xai_event_seq = None;
|
|
}
|
|
self.session.session_id = Some(session_id);
|
|
}
|
|
/// Record a prompt id this client originated (sent to the agent as the turn
|
|
/// driver). Used by the ACP gate to keep `attached_as_viewer` per-turn
|
|
/// accurate. Bounded FIFO; a no-op for ids already tracked.
|
|
pub fn note_self_originated_prompt(&mut self, prompt_id: &str) {
|
|
if self.is_self_originated_prompt(prompt_id) {
|
|
return;
|
|
}
|
|
self.self_originated_prompt_ids
|
|
.push_back(prompt_id.to_string());
|
|
while self.self_originated_prompt_ids.len() > SELF_ORIGINATED_PROMPT_CAP {
|
|
self.self_originated_prompt_ids.pop_front();
|
|
}
|
|
}
|
|
/// Whether `prompt_id` is a turn THIS client originated (vs. one another
|
|
/// client drives, or a server-initiated turn).
|
|
pub fn is_self_originated_prompt(&self, prompt_id: &str) -> bool {
|
|
self.self_originated_prompt_ids
|
|
.iter()
|
|
.any(|p| p == prompt_id)
|
|
}
|
|
pub fn new(session: AgentSession, scrollback: ScrollbackState) -> Self {
|
|
let prompt = PromptWidget::new_with_cwd(&session.cwd);
|
|
let mut view = Self {
|
|
session,
|
|
scrollback,
|
|
prompt,
|
|
tip_typing_dismissed: false,
|
|
todo: TodoPane::new(),
|
|
tasks: TasksPane::new(),
|
|
catalog: SubagentCatalogPane::new(),
|
|
queue: QueuePane::new(),
|
|
shared_queue: Vec::new(),
|
|
attached_as_viewer: false,
|
|
self_originated_prompt_ids: VecDeque::new(),
|
|
last_applied_event_seq: None,
|
|
last_applied_xai_event_seq: None,
|
|
last_seen_event_id: None,
|
|
session_reload: None,
|
|
unexpected_replay_drops: 0,
|
|
replayed_terminal_prompts: HashSet::new(),
|
|
active_pane: ActivePane::Prompt,
|
|
prompt_mode: PromptMode::Normal,
|
|
prompt_input_mode: PromptInputMode::Normal,
|
|
multiline_mode: false,
|
|
vim_mode: crate::appearance::cache::load_vim_mode(),
|
|
input_mode: InputMode::Vim,
|
|
bash_turn: false,
|
|
cron_task_id: None,
|
|
stashed_prompt: None,
|
|
reauth_stashed_prompt: None,
|
|
active_modal: None,
|
|
modal_buttons: Vec::new(),
|
|
modal_hovered_key: None,
|
|
context_state: None,
|
|
chat_kind: false,
|
|
app_chat_mode: false,
|
|
goal_state: None,
|
|
graph_state: None,
|
|
parked_wait_marker_for: None,
|
|
end_work_announced: false,
|
|
pending_stop_hooks: None,
|
|
last_cleared_goal_id: None,
|
|
show_goal_detail: false,
|
|
turn_start_ms: None,
|
|
wake_turn_start: None,
|
|
turn_started_at: None,
|
|
first_activity_logged_for: None,
|
|
turn_paused_duration: std::time::Duration::ZERO,
|
|
self_interjection_ids: std::collections::HashSet::new(),
|
|
last_active_at: Some(Instant::now()),
|
|
current_branch: None,
|
|
is_worktree: false,
|
|
main_repo: None,
|
|
worktree_label: None,
|
|
activity_started_at: None,
|
|
last_activity: None,
|
|
pane_areas: PaneAreas::default(),
|
|
hovered_entry: None,
|
|
pending_text_drag: None,
|
|
drag_selection: None,
|
|
pending_block_drag: None,
|
|
block_drag_selection: None,
|
|
deferred_text_press: None,
|
|
persistent_text_selection: None,
|
|
table_selection_geometry: None,
|
|
selection_created_at: None,
|
|
last_drag_mouse: None,
|
|
drag_autoscroll: None,
|
|
left_mouse_down: false,
|
|
plan_prompt_mouse_drag: false,
|
|
last_scrollback_selection_model: ResolvedSelectionModel::default(),
|
|
last_scrollback_selection_boundaries: Default::default(),
|
|
last_link_overlay: Default::default(),
|
|
frame_occluder_rects: Vec::new(),
|
|
visible_link_map: Default::default(),
|
|
scrollback_visible_link_count: 0,
|
|
highlighted_link_idx: None,
|
|
hovered_link_idx: None,
|
|
last_pointer_on_link: false,
|
|
last_btw_selection_model: ResolvedSelectionModel::default(),
|
|
last_btw_area: Rect::default(),
|
|
pending_scrollback_click: None,
|
|
pending_link_click: None,
|
|
media_link_paths: Vec::new(),
|
|
media_link_paths_gen: None,
|
|
last_mouse_pos: (0, 0),
|
|
last_mouse_moved_at: None,
|
|
last_click: None,
|
|
last_text_click: None,
|
|
last_clipboard_toast_at: None,
|
|
last_context_click_at: None,
|
|
hovered_prompt: false,
|
|
hit_badge: Default::default(),
|
|
hit_context: Default::default(),
|
|
hit_todo_close: Default::default(),
|
|
hit_bg_close: Default::default(),
|
|
hit_subagent_close: Default::default(),
|
|
hit_catalog_close: Default::default(),
|
|
hit_bg_status: Default::default(),
|
|
hit_goal_status: Default::default(),
|
|
hit_goal_close: Default::default(),
|
|
hit_bg_button: Default::default(),
|
|
last_bg_click: None,
|
|
hit_queue_close: Default::default(),
|
|
hit_queue_badge: Default::default(),
|
|
hit_plan_button: Default::default(),
|
|
hit_plan_approval_status: Default::default(),
|
|
hit_follow_indicator: Default::default(),
|
|
hit_cwd: Default::default(),
|
|
hit_cancel_button: Default::default(),
|
|
hit_scrollbar: Default::default(),
|
|
scrollbar_dragging: false,
|
|
dropdown_items_area: None,
|
|
slash_dropdown_items_area: None,
|
|
slash_dropdown_hit: Default::default(),
|
|
completion_dropdown_items_area: None,
|
|
history_dropdown_area: None,
|
|
last_prompt_click_ms: None,
|
|
line_viewer: None,
|
|
image_viewer: None,
|
|
image_load_rx: None,
|
|
video_viewer: None,
|
|
gboom: None,
|
|
inline_media_cache: std::collections::HashMap::new(),
|
|
inline_media_ids: std::collections::HashMap::new(),
|
|
inline_media_iterm_emitted: std::collections::HashMap::new(),
|
|
next_inline_media_id: 2,
|
|
inline_video: None,
|
|
video_load_rx: None,
|
|
mermaid: None,
|
|
edit_hl: None,
|
|
inline_media_active: false,
|
|
last_placed_ids: HashSet::new(),
|
|
last_terminal_size: (0, 0),
|
|
terminal_size_stale: false,
|
|
inline_media_hits: InlineMediaHitAreas::default(),
|
|
extensions_modal: None,
|
|
agents_modal: None,
|
|
persona_detail: None,
|
|
btw_state: None,
|
|
btw_focused: false,
|
|
hit_btw_close: Default::default(),
|
|
toast: None,
|
|
ephemeral_tip: Default::default(),
|
|
word_select_tip_prompt_snapshot: None,
|
|
last_word_select_probe: None,
|
|
sticky_toast: None,
|
|
mode_switch_banner: None,
|
|
block_viewer: None,
|
|
scrollback_search: None,
|
|
hit_sb_copy: Default::default(),
|
|
hit_sb_view: Default::default(),
|
|
question_view: None,
|
|
hit_question_scrollbar: Default::default(),
|
|
hovered_question_item: None,
|
|
question_scrollbar_dragging: false,
|
|
last_question_click: None,
|
|
inline_prompt_area: None,
|
|
question_nav_buttons: Vec::new(),
|
|
hovered_question_button: None,
|
|
question_scroll_region: None,
|
|
plan_mode_active: false,
|
|
plan_mode_pending: None,
|
|
deferred_session_mode: None,
|
|
pending_extensions_fetch: false,
|
|
in_dashboard_overlay: false,
|
|
mcp_init_progress: None,
|
|
acp_synced_generation: 0,
|
|
hovered_permission_item: None,
|
|
last_permission_click: None,
|
|
permission_queue: VecDeque::new(),
|
|
next_perm_req_id: 0,
|
|
permission_stashed_prompt: None,
|
|
plan_approval_view: None,
|
|
latest_inline_plan_content: None,
|
|
plan_comments: Vec::new(),
|
|
plan_next_comment_id: 0,
|
|
casual_commenting_range: None,
|
|
casual_editing_comment_id: None,
|
|
casual_stashed_prompt: None,
|
|
cancel_turn_view: None,
|
|
cancel_turn_buttons: Vec::new(),
|
|
cancel_subagents_preference: None,
|
|
cancel_trigger_hint: None,
|
|
rewind_state: None,
|
|
rewind_points: None,
|
|
inline_edit: None,
|
|
pending_inline_resubmit: None,
|
|
jump_state: None,
|
|
timeline_rail: None,
|
|
timeline_hover: None,
|
|
timeline_hover_preview: None,
|
|
session_agent_name: None,
|
|
subagent_sessions: HashMap::new(),
|
|
subagent_views: HashMap::new(),
|
|
active_subagent: None,
|
|
is_subagent_view: false,
|
|
hit_subagent_frame_close: Default::default(),
|
|
input_log: crate::input_log::InputRingBuffer::new(),
|
|
esc_pressed_at: None,
|
|
pending_first_prompt: None,
|
|
pending_fork_banner: None,
|
|
loading_placeholder_id: None,
|
|
pending_recap_entry: None,
|
|
display_name: None,
|
|
generated_session_title: None,
|
|
pending_effects: Vec::new(),
|
|
paste_probe_in_flight: 0,
|
|
deferred_send: None,
|
|
pending_turn_end_reconcile: None,
|
|
expect_send_now_cancel: None,
|
|
optimistic_queue_ids: std::collections::HashSet::new(),
|
|
send_now_awaiting_confirm: None,
|
|
send_now_painted_blocks: std::collections::HashMap::new(),
|
|
follow_without_jump_prompt_id: None,
|
|
follow_ups: None,
|
|
follow_up_shown_prompt_id: None,
|
|
follow_up_chips: Vec::new(),
|
|
hovered_follow_up_chip: None,
|
|
follow_up_seen: HashMap::new(),
|
|
follow_up_next_gen: 0,
|
|
follow_up_pending: HashMap::new(),
|
|
follow_up_pending_order: VecDeque::new(),
|
|
pending_adoption_updates: Vec::new(),
|
|
};
|
|
let mode = if crate::appearance::cache::load_simple_mode() {
|
|
InputMode::Simple
|
|
} else {
|
|
InputMode::Vim
|
|
};
|
|
view.set_input_mode(mode);
|
|
view
|
|
}
|
|
/// Clear `turn_started_at` and stamp `last_active_at` to "now".
|
|
///
|
|
/// Call this from every site that ends a turn (success, failure,
|
|
/// cancellation, reconnect cleanup). Centralised so the two
|
|
/// fields cannot drift apart at the ~10 termination call sites
|
|
/// across `dispatch.rs` and `event_loop.rs`.
|
|
pub fn mark_turn_finished(&mut self) {
|
|
self.turn_started_at = None;
|
|
self.turn_paused_duration = std::time::Duration::ZERO;
|
|
self.last_active_at = Some(Instant::now());
|
|
}
|
|
/// Enter a `session/load` replay window: flip `loading_replay` on and reset
|
|
/// every field coupled to that transition together, so no site can drift
|
|
/// (e.g. reset one coupled field but miss another). Called at every
|
|
/// replay-window entry: the fresh/restore load ctor paths and the
|
|
/// reconnect/fork reuse paths.
|
|
pub(crate) fn begin_replay_window(&mut self) {
|
|
self.session.loading_replay = true;
|
|
self.replayed_terminal_prompts.clear();
|
|
self.unexpected_replay_drops = 0;
|
|
self.pending_stop_hooks = None;
|
|
self.end_work_announced = false;
|
|
self.wake_turn_start = None;
|
|
self.clear_send_now_expectation();
|
|
self.optimistic_queue_ids.clear();
|
|
self.send_now_awaiting_confirm = None;
|
|
self.send_now_painted_blocks.clear();
|
|
}
|
|
/// Open a reconnect reload window: stash the current transcript/tracker
|
|
/// and point the live fields at fresh state for the incoming
|
|
/// `session/load` replay. The transcript is NOT cleared — it stays
|
|
/// recoverable until [`finish_session_reload`](Self::finish_session_reload)
|
|
/// decides the outcome.
|
|
pub(crate) fn begin_session_reload(&mut self, generation: u64) {
|
|
self.dismiss_jump_picker();
|
|
if let Some(prev) = self.session_reload.take() {
|
|
tracing::warn!(
|
|
generation,
|
|
prev_generation = prev.generation,
|
|
"session reload superseded without finalize; restoring previous stash first"
|
|
);
|
|
if self.apply_reload_outcome(prev, false) {
|
|
crate::memory_release::release_retained_memory_with("reload-supersede");
|
|
}
|
|
}
|
|
while self.scrollback.in_batch() {
|
|
self.scrollback.end_batch();
|
|
}
|
|
if let Some(pid) = self.loading_placeholder_id.take() {
|
|
self.scrollback.remove_entry(pid);
|
|
}
|
|
if let Some(rid) = self.pending_recap_entry.take() {
|
|
self.scrollback.remove_entry(rid);
|
|
}
|
|
self.session.model_switch_pending = false;
|
|
self.pending_adoption_updates.clear();
|
|
let fresh = self.scrollback.fresh_continuation();
|
|
self.session_reload = Some(SessionReload {
|
|
generation,
|
|
scrollback: std::mem::replace(&mut self.scrollback, fresh),
|
|
tracker: std::mem::replace(
|
|
&mut self.session.tracker,
|
|
crate::acp::tracker::AcpUpdateTracker::new(),
|
|
),
|
|
todo: std::mem::take(&mut self.todo),
|
|
last_seen_event_id: self.last_seen_event_id.clone(),
|
|
last_applied_event_seq: self.last_applied_event_seq,
|
|
last_applied_xai_event_seq: self.last_applied_xai_event_seq,
|
|
saw_replay: false,
|
|
saw_todo_update: false,
|
|
});
|
|
self.loading_placeholder_id = Some(self.scrollback.push_block(
|
|
crate::scrollback::block::RenderBlock::system("Reloading session after reconnect..."),
|
|
));
|
|
self.scrollback.begin_batch();
|
|
self.begin_replay_window();
|
|
}
|
|
/// Record that an `isReplay` update applied while a reload window is open.
|
|
/// No-op otherwise.
|
|
pub(crate) fn mark_reload_replay_seen(&mut self) {
|
|
if let Some(reload) = self.session_reload.as_mut() {
|
|
reload.saw_replay = true;
|
|
}
|
|
}
|
|
/// Record that a Plan update applied while a reload window is open.
|
|
/// No-op otherwise.
|
|
pub(crate) fn mark_reload_todo_update(&mut self) {
|
|
if let Some(reload) = self.session_reload.as_mut() {
|
|
reload.saw_todo_update = true;
|
|
}
|
|
}
|
|
/// Start a locally-tracked turn: close the between-turns status window
|
|
/// (completions inside a turn push chips only), then enter TurnRunning.
|
|
/// Every real turn start must route through here so no caller can miss
|
|
/// the close. Deliberately NOT used by server-initiated synthetic turns
|
|
/// (auto-wake / actor runs): they never call `start_turn`, and their
|
|
/// completions still deserve status lines.
|
|
pub(crate) fn start_turn_boundary(&mut self, starting_prompt_id: Option<&str>) {
|
|
self.end_work_announced = false;
|
|
if self
|
|
.expect_send_now_cancel
|
|
.as_deref()
|
|
.is_some_and(|id| Some(id) != starting_prompt_id)
|
|
{
|
|
self.expect_send_now_cancel = None;
|
|
}
|
|
self.session.start_turn(&mut self.scrollback);
|
|
}
|
|
/// Adopt the in-flight turn another client is driving, conveyed by the
|
|
/// `session/load` response meta (`kigi/runningPromptId`): enter
|
|
/// TurnRunning and match subsequent live deltas. No user-prompt block is
|
|
/// pushed — the turn's prompt and prior chunks arrived via the replay.
|
|
pub(crate) fn adopt_running_prompt(&mut self, prompt_id: String) {
|
|
self.start_turn_boundary(Some(&prompt_id));
|
|
self.session.tracker.clear_user_echo_skip();
|
|
self.session.current_prompt_id = Some(prompt_id.clone());
|
|
self.turn_started_at = Some(Instant::now());
|
|
self.scrollback.enable_follow_with_preserve();
|
|
self.flush_pending_follow_ups(&prompt_id);
|
|
}
|
|
/// Finalize any open reload window as FAILED, regardless of generation.
|
|
///
|
|
/// For load initiations that take over the agent (fork/worktree/restore
|
|
/// binding a new session): the stash belongs to the superseded
|
|
/// pre-reconnect state, and an open window would corrupt the incoming
|
|
/// load's batch/replay bookkeeping — and defer its results. The window's
|
|
/// pending re-init completion later no-ops (generation gone).
|
|
pub(crate) fn abort_session_reload(&mut self) {
|
|
if let Some(reload) = self.session_reload.take()
|
|
&& self.apply_reload_outcome(reload, false)
|
|
{
|
|
crate::memory_release::release_retained_memory_with("reload-abort");
|
|
}
|
|
}
|
|
/// Finalize the reload window opened for `generation`.
|
|
///
|
|
/// Returns `false` (untouched state) when no window with that generation
|
|
/// is open — the agent was never reloading, or a newer reconnect already
|
|
/// superseded it.
|
|
pub(crate) fn finish_session_reload(&mut self, generation: u64, success: bool) -> bool {
|
|
match self.session_reload.take() {
|
|
Some(reload) if reload.generation == generation => {
|
|
if self.apply_reload_outcome(reload, success) {
|
|
crate::memory_release::release_retained_memory_with("reload-finalize");
|
|
}
|
|
true
|
|
}
|
|
Some(other) => {
|
|
tracing::warn!(
|
|
generation,
|
|
open_generation = other.generation,
|
|
"ignoring session reload finalize for a superseded generation"
|
|
);
|
|
self.session_reload = Some(other);
|
|
false
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
/// Whether a running prompt reported on a `session/load` (resume /
|
|
/// reconnect) is adoptable by THIS agent: the pure synthetic-turn guard
|
|
/// ([`acp_handler::should_adopt_running_prompt`]) AND not terminal-in-replay.
|
|
/// A turn whose durable `TurnCompleted` already arrived in this load's replay
|
|
/// (recorded in [`Self::replayed_terminal_prompts`]) has ended; adopting it
|
|
/// would re-strand the viewer on "Waiting…".
|
|
///
|
|
/// [`acp_handler::should_adopt_running_prompt`]: crate::app::acp_handler::should_adopt_running_prompt
|
|
pub(crate) fn should_adopt_running_prompt(&self, prompt_id: &str) -> bool {
|
|
crate::app::acp_handler::should_adopt_running_prompt(prompt_id)
|
|
&& !self.replayed_terminal_prompts.contains(prompt_id)
|
|
}
|
|
/// Finalize a reconnect-reload window and, iff the running prompt is
|
|
/// adoptable, adopt it. Returns whether the window finalized.
|
|
///
|
|
/// Adoption is gated by [`Self::should_adopt_running_prompt`] and ordered
|
|
/// AFTER finalize so the finalize side effect (force-idle + window resolve)
|
|
/// always runs even when adoption is skipped for a synthetic / non-adoptable
|
|
/// / terminal-in-replay running id. The reconnect loop in `event_loop.rs`
|
|
/// calls this per agent.
|
|
pub(crate) fn finalize_reload_and_maybe_adopt(
|
|
&mut self,
|
|
generation: u64,
|
|
ok: bool,
|
|
running_prompt_id: Option<String>,
|
|
) -> bool {
|
|
let finalized = self.finish_session_reload(generation, ok);
|
|
if finalized
|
|
&& let Some(pid) = running_prompt_id
|
|
&& self.should_adopt_running_prompt(&pid)
|
|
{
|
|
self.adopt_running_prompt(pid);
|
|
}
|
|
finalized
|
|
}
|
|
/// Resolve a closed window per the [`SessionReload`] outcome trichotomy.
|
|
///
|
|
/// Returns whether a heavy transient was dropped — the stashed pre-reload
|
|
/// scrollback (success + full replay) or the staged partial replay
|
|
/// (failure). The success+cursor branch *reuses* the stash and moves the
|
|
/// tail entries into it: nothing multi-MB drops, so callers must NOT
|
|
/// purge for it (a full-arena purge there would madvise away warm pages
|
|
/// on the most common reconnect outcome, once per open tab).
|
|
#[must_use = "purge retained memory iff a heavy transient dropped"]
|
|
fn apply_reload_outcome(&mut self, reload: SessionReload, success: bool) -> bool {
|
|
if let Some(pid) = self.loading_placeholder_id.take() {
|
|
self.scrollback.remove_entry(pid);
|
|
}
|
|
let dropped_heavy;
|
|
if success && reload.saw_replay {
|
|
self.scrollback.end_batch();
|
|
dropped_heavy = true;
|
|
} else if success {
|
|
let tail = std::mem::replace(&mut self.scrollback, reload.scrollback);
|
|
self.scrollback.append_entries_from(tail);
|
|
if !reload.saw_todo_update {
|
|
self.todo = reload.todo;
|
|
}
|
|
dropped_heavy = false;
|
|
} else {
|
|
let floor = self.scrollback.id_floor();
|
|
let staging_generations = self.scrollback.invalidation_generations();
|
|
self.scrollback = reload.scrollback;
|
|
self.scrollback.raise_id_floor(floor);
|
|
self.scrollback
|
|
.raise_invalidation_floor(staging_generations);
|
|
self.session.tracker = reload.tracker;
|
|
self.todo = reload.todo;
|
|
self.last_seen_event_id = reload.last_seen_event_id;
|
|
self.last_applied_event_seq = reload.last_applied_event_seq;
|
|
self.last_applied_xai_event_seq = reload.last_applied_xai_event_seq;
|
|
dropped_heavy = true;
|
|
}
|
|
self.session.loading_replay = false;
|
|
self.session.prompt_history_loading = false;
|
|
self.session.tracker.clear_user_echo_skip();
|
|
self.session.finish_turn(&mut self.scrollback);
|
|
self.scrollback.finish_all_running();
|
|
if let Some(id) = self.pending_recap_entry.take() {
|
|
self.scrollback.remove_entry(id);
|
|
}
|
|
self.mark_turn_finished();
|
|
self.activity_started_at = None;
|
|
self.last_activity = None;
|
|
self.reset_follow_ups_for_reload();
|
|
dropped_heavy
|
|
}
|
|
/// Effective turn elapsed time, excluding time spent in question views.
|
|
///
|
|
/// Subtracts both the accumulated `turn_paused_duration` (from question
|
|
/// views closed earlier in the turn) and the time elapsed since the current question
|
|
/// view opened (if one is active).
|
|
pub fn turn_elapsed(&self) -> Option<std::time::Duration> {
|
|
let raw = self.turn_started_at?.elapsed();
|
|
let mut paused = self.turn_paused_duration;
|
|
if let Some(qv) = &self.question_view {
|
|
paused += qv.opened_at.elapsed();
|
|
}
|
|
Some(raw.saturating_sub(paused))
|
|
}
|
|
/// Turn activity for the status spinner, with the implicit "no activity"
|
|
/// gap during a running inference turn resolved into an explicit
|
|
/// [`WaitingReason`] so the spinner names *what* we're waiting on.
|
|
///
|
|
/// The tracker already returns `Waiting(TaskOutput/TasksComplete/Sleep)`,
|
|
/// and `Waiting(Subagent)` for a foreground `task` call from the moment it's
|
|
/// issued. This fills in the remaining gap: if no tracker activity but a
|
|
/// foreground subagent is registered as running, it's still `Subagent`
|
|
/// (covers any window where the task tool call has cleared but the child is
|
|
/// live); otherwise the model itself (`Model`). Bash turns keep `None` so
|
|
/// the status line renders its own "Running…".
|
|
///
|
|
/// For `Waiting(TaskOutput { task_ids, .. })`, also resolves a display
|
|
/// `subject` from live bg-task / subagent state (description preferred,
|
|
/// else command) so the spinner can read `{description}…`.
|
|
pub(crate) fn resolve_turn_activity(&self) -> Option<crate::acp::tracker::TurnActivity> {
|
|
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
|
use crate::app::agent::AgentState;
|
|
if let Some(activity) = self.session.turn_activity() {
|
|
return Some(self.enrich_waiting_activity(activity));
|
|
}
|
|
if !matches!(self.session.state, AgentState::TurnRunning) {
|
|
return None;
|
|
}
|
|
if self.bash_turn {
|
|
return None;
|
|
}
|
|
let reason = if self.has_running_foreground_subagent() {
|
|
WaitingReason::Subagent
|
|
} else {
|
|
WaitingReason::Model
|
|
};
|
|
Some(TurnActivity::Waiting(reason))
|
|
}
|
|
/// Fill in a `TaskOutput` wait's display subject from live task state.
|
|
fn enrich_waiting_activity(
|
|
&self,
|
|
activity: crate::acp::tracker::TurnActivity,
|
|
) -> crate::acp::tracker::TurnActivity {
|
|
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
|
match activity {
|
|
TurnActivity::Waiting(WaitingReason::TaskOutput {
|
|
task_ids, waits, ..
|
|
}) => {
|
|
let subject = self.subject_for_wait_tasks(&task_ids);
|
|
TurnActivity::Waiting(WaitingReason::TaskOutput {
|
|
task_ids,
|
|
subject,
|
|
waits,
|
|
})
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
/// Best user-facing name for the tasks being waited on.
|
|
///
|
|
/// Uses the first resolvable subject. Multi-id waits always reflect the
|
|
/// full `task_ids` length (`"first + N more"` with `N = task_ids.len()-1`)
|
|
/// so partial resolution still reads as multi-task. Unknown ids → `None`
|
|
/// (spinner falls back to the generic label).
|
|
fn subject_for_wait_tasks(&self, task_ids: &[String]) -> Option<String> {
|
|
use crate::acp::tracker::{MAX_ACTIVITY_SUBJECT_CHARS, clamp_activity_subject};
|
|
if task_ids.is_empty() {
|
|
return None;
|
|
}
|
|
let first = task_ids
|
|
.iter()
|
|
.find_map(|id| self.lookup_task_subject(id))?;
|
|
if task_ids.len() == 1 {
|
|
let first = clamp_activity_subject(&first);
|
|
return (!first.is_empty()).then_some(first);
|
|
}
|
|
let n = task_ids.len() - 1;
|
|
let suffix = format!(" + {n} more");
|
|
let budget = MAX_ACTIVITY_SUBJECT_CHARS
|
|
.saturating_sub(suffix.chars().count())
|
|
.max(8);
|
|
let base: String = first
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|l| !l.is_empty())
|
|
.unwrap_or(first.trim())
|
|
.chars()
|
|
.take(budget)
|
|
.collect();
|
|
if base.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!("{base}{suffix}"))
|
|
}
|
|
}
|
|
/// Resolve one task id to a display subject (description preferred, else
|
|
/// a *short* command / subagent description).
|
|
///
|
|
/// Long bare commands are intentionally not used as subjects — the spinner
|
|
/// falls back to the generic `"Waiting on task output…"` instead of
|
|
/// stuffing a wall of shell into the status line. Descriptions are kept
|
|
/// but clamped by the caller via [`clamp_activity_subject`].
|
|
fn lookup_task_subject(&self, task_id: &str) -> Option<String> {
|
|
use crate::acp::tracker::MAX_ACTIVITY_SUBJECT_CHARS;
|
|
fn first_nonempty_line(s: &str) -> &str {
|
|
s.lines()
|
|
.map(str::trim)
|
|
.find(|line| !line.is_empty())
|
|
.unwrap_or(s)
|
|
}
|
|
if let Some(task) = self.session.bg_tasks.get(task_id) {
|
|
if let Some(desc) = task
|
|
.description
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
return Some(first_nonempty_line(desc).to_string());
|
|
}
|
|
let cmd = first_nonempty_line(task.command.trim());
|
|
if !cmd.is_empty() && cmd.chars().count() <= MAX_ACTIVITY_SUBJECT_CHARS {
|
|
return Some(cmd.to_string());
|
|
}
|
|
}
|
|
if let Some(info) = self.subagent_sessions.get(task_id) {
|
|
let desc = info.description.trim();
|
|
if !desc.is_empty() {
|
|
return Some(first_nonempty_line(desc).to_string());
|
|
}
|
|
}
|
|
self.subagent_sessions
|
|
.values()
|
|
.find(|info| info.subagent_id.as_ref() == task_id)
|
|
.and_then(|info| {
|
|
let desc = info.description.trim();
|
|
if desc.is_empty() {
|
|
None
|
|
} else {
|
|
Some(first_nonempty_line(desc).to_string())
|
|
}
|
|
})
|
|
}
|
|
/// Whether a foreground subagent (`task`/`spawn_subagent`, not
|
|
/// `run_in_background`) is currently running. The parent turn is blocked on
|
|
/// it, so the spinner should read "Waiting on subagent…".
|
|
fn has_running_foreground_subagent(&self) -> bool {
|
|
self.subagent_sessions
|
|
.values()
|
|
.any(|s| s.is_running() && !s.is_background)
|
|
}
|
|
/// Update context state with a full snapshot from live callers.
|
|
///
|
|
/// No-op for gateway/chat-kind sessions — local GetSessionInfo / sampler
|
|
/// breakdowns must not populate the context bar (remote owns context).
|
|
pub fn apply_full_context_info(&mut self, next: kigi_shell::session::ContextInfo) {
|
|
if self.chat_kind {
|
|
self.context_state = None;
|
|
return;
|
|
}
|
|
self.context_state = Some(next);
|
|
}
|
|
/// Update context state from a streaming notification carrying only
|
|
/// `used` and `total` fields.
|
|
///
|
|
/// No-op for gateway/chat-kind sessions (same policy as
|
|
/// [`Self::apply_full_context_info`]).
|
|
pub fn apply_context_used(&mut self, used: u64, total: u64) {
|
|
if self.chat_kind {
|
|
self.context_state = None;
|
|
return;
|
|
}
|
|
let total = if total > 0 {
|
|
total
|
|
} else {
|
|
self.context_state.as_ref().map(|s| s.total).unwrap_or(0)
|
|
};
|
|
match self.context_state.as_mut() {
|
|
Some(snap) => {
|
|
snap.used = used;
|
|
if total > 0 {
|
|
snap.total = total;
|
|
}
|
|
snap.usage_pct = kigi_token_estimation::usage_percentage_u8(used, snap.total);
|
|
snap.free_tokens = kigi_token_estimation::free_tokens(snap.total, used);
|
|
}
|
|
None => {
|
|
self.context_state = Some(kigi_shell::session::ContextInfo::from_notification(
|
|
used, total,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
/// Record a key event to the input flight recorder.
|
|
///
|
|
/// Zero heap allocations — stores raw `Copy` types in the ring buffer.
|
|
/// Formatting into strings happens only during dump (`snapshot_entries`).
|
|
pub(crate) fn record_input(
|
|
&mut self,
|
|
key: &crossterm::event::KeyEvent,
|
|
outcome: &InputOutcome,
|
|
) {
|
|
use crate::input_log::{ActivePaneSnapshot, OutcomeSnapshot, RawInputEntry};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let delta = std::mem::take(&mut self.prompt.last_input_delta);
|
|
let pane = match self.active_pane {
|
|
ActivePane::Scrollback => ActivePaneSnapshot::Scrollback,
|
|
ActivePane::Todo => ActivePaneSnapshot::Todo,
|
|
ActivePane::Queue => ActivePaneSnapshot::Queue,
|
|
ActivePane::Prompt => ActivePaneSnapshot::Prompt,
|
|
ActivePane::Tasks => ActivePaneSnapshot::Tasks,
|
|
ActivePane::Catalog => ActivePaneSnapshot::Catalog,
|
|
};
|
|
let outcome_snap = match outcome {
|
|
InputOutcome::Changed | InputOutcome::ArmPending { .. } => OutcomeSnapshot::Changed,
|
|
InputOutcome::Unchanged => OutcomeSnapshot::Unchanged,
|
|
InputOutcome::Action(_)
|
|
| InputOutcome::ActionThenForward(_)
|
|
| InputOutcome::ActionPair(_, _) => OutcomeSnapshot::Action,
|
|
};
|
|
self.input_log.push(RawInputEntry {
|
|
wall_ts: SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64,
|
|
key_code: key.code,
|
|
key_modifiers: key.modifiers,
|
|
key_kind: key.kind,
|
|
active_pane: pane,
|
|
outcome: outcome_snap,
|
|
cursor_before: delta.cursor_before,
|
|
cursor_after: delta.cursor_after,
|
|
text_len_before: delta.text_len_before,
|
|
text_len_after: delta.text_len_after,
|
|
sel_before: delta.had_selection_before,
|
|
sel_after: delta.had_selection_after,
|
|
textarea_changed: delta.textarea_changed,
|
|
});
|
|
}
|
|
/// Show or hide the `/usage` slash command in this agent's registry.
|
|
pub fn set_usage_visible(&mut self, visible: bool) {
|
|
self.prompt
|
|
.slash_controller
|
|
.registry_mut()
|
|
.set_usage_visible(visible);
|
|
}
|
|
/// Replace the restricted slash-command deny list in this agent's
|
|
/// registry (e.g. `/usage` denied on the free / X Basic tiers). Deny
|
|
/// wins over every `set_*_visible` gate.
|
|
pub fn set_restricted_commands(&mut self, names: &[String]) {
|
|
self.prompt.set_restricted_commands(names);
|
|
}
|
|
/// Show or hide the `/dashboard` slash command in this agent's registry.
|
|
/// Driven by the dashboard feature flag
|
|
/// (`crate::views::dashboard::dashboard_enabled()`) at agent-creation
|
|
/// time — independent of leader mode.
|
|
pub fn set_dashboard_visible(&mut self, visible: bool) {
|
|
self.prompt
|
|
.slash_controller
|
|
.registry_mut()
|
|
.set_dashboard_visible(visible);
|
|
}
|
|
/// One place for the app-scoped gates a new/adopted session inherits so the session-creation sites cannot drift.
|
|
pub(crate) fn apply_app_scoped_gates(
|
|
&mut self,
|
|
usage_visible: bool,
|
|
chat_mode: bool,
|
|
screen_mode: crate::app::ScreenMode,
|
|
restricted_commands: &[String],
|
|
) {
|
|
self.set_usage_visible(usage_visible);
|
|
self.app_chat_mode = chat_mode;
|
|
self.prompt.set_screen_mode(screen_mode);
|
|
self.set_dashboard_visible(crate::views::dashboard::dashboard_enabled());
|
|
self.set_restricted_commands(restricted_commands);
|
|
}
|
|
/// Show or hide the `/recap` slash command in this agent's registry.
|
|
pub fn set_session_recap_available(&mut self, available: bool) {
|
|
self.prompt.set_recap_visible(available);
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod resolve_turn_activity_tests {
|
|
use super::*;
|
|
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
|
use crate::app::agent::AgentState;
|
|
fn running_view() -> AgentView {
|
|
let mut view = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
|
|
view.session.state = AgentState::TurnRunning;
|
|
view
|
|
}
|
|
#[test]
|
|
fn idle_turn_has_no_activity() {
|
|
let view = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
|
|
assert_eq!(view.resolve_turn_activity(), None);
|
|
}
|
|
#[test]
|
|
fn running_with_no_stream_waits_on_model() {
|
|
let view = running_view();
|
|
assert_eq!(
|
|
view.resolve_turn_activity(),
|
|
Some(TurnActivity::Waiting(WaitingReason::Model))
|
|
);
|
|
}
|
|
#[test]
|
|
fn bash_turn_stays_none() {
|
|
let mut view = running_view();
|
|
view.bash_turn = true;
|
|
assert_eq!(view.resolve_turn_activity(), None);
|
|
}
|
|
#[test]
|
|
fn real_activity_passes_through() {
|
|
let mut view = running_view();
|
|
view.session
|
|
.set_compaction_activity(Some(TurnActivity::AutoCompacting));
|
|
assert_eq!(
|
|
view.resolve_turn_activity(),
|
|
Some(TurnActivity::AutoCompacting)
|
|
);
|
|
}
|
|
#[test]
|
|
fn task_output_wait_uses_bg_task_description() {
|
|
use crate::acp::meta::NotificationMeta;
|
|
use crate::app::agent::{BgTaskState, BgTaskStatus};
|
|
use agent_client_protocol as acp;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
let mut view = running_view();
|
|
view.session.bg_tasks.insert(
|
|
"bg-1".into(),
|
|
BgTaskState {
|
|
task_id: "bg-1".into(),
|
|
tool_call_id: "tc-1".into(),
|
|
command: "cargo test --release".into(),
|
|
description: Some("run release tests".into()),
|
|
cwd: String::new(),
|
|
output_file: String::new(),
|
|
status: BgTaskStatus::Running,
|
|
start_time: SystemTime::now(),
|
|
end_time: None,
|
|
exit_code: None,
|
|
signal: None,
|
|
stdout: String::new(),
|
|
stdout_line_count: 0,
|
|
truncated: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
is_monitor: false,
|
|
restored_from_replay: false,
|
|
},
|
|
);
|
|
let meta = NotificationMeta::default();
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCall(
|
|
acp::ToolCall::new(
|
|
acp::ToolCallId::new(Arc::from("wait-1")),
|
|
"get_command_or_subagent_output",
|
|
)
|
|
.kind(acp::ToolKind::Other)
|
|
.status(acp::ToolCallStatus::Pending)
|
|
.content(vec![])
|
|
.locations(vec![]),
|
|
),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
|
acp::ToolCallId::new(Arc::from("wait-1")),
|
|
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
|
|
{ "task_ids" : ["bg-1"], "timeout_ms" : 30_000, }
|
|
))),
|
|
)),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
let activity = view.resolve_turn_activity();
|
|
assert_eq!(
|
|
activity,
|
|
Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
|
|
task_ids: vec!["bg-1".into()],
|
|
subject: Some("run release tests".into()),
|
|
waits: true,
|
|
}))
|
|
);
|
|
assert_eq!(activity.as_ref().unwrap().as_label(), "waiting_task_output");
|
|
let TurnActivity::Waiting(reason) = activity.unwrap() else {
|
|
panic!("expected waiting activity");
|
|
};
|
|
assert_eq!(reason.label(), "run release tests…");
|
|
}
|
|
#[test]
|
|
fn task_output_wait_falls_back_to_short_command() {
|
|
use crate::acp::meta::NotificationMeta;
|
|
use crate::app::agent::{BgTaskState, BgTaskStatus};
|
|
use agent_client_protocol as acp;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
let mut view = running_view();
|
|
view.session.bg_tasks.insert(
|
|
"bg-2".into(),
|
|
BgTaskState {
|
|
task_id: "bg-2".into(),
|
|
tool_call_id: "tc-2".into(),
|
|
command: "sleep 30".into(),
|
|
description: None,
|
|
cwd: String::new(),
|
|
output_file: String::new(),
|
|
status: BgTaskStatus::Running,
|
|
start_time: SystemTime::now(),
|
|
end_time: None,
|
|
exit_code: None,
|
|
signal: None,
|
|
stdout: String::new(),
|
|
stdout_line_count: 0,
|
|
truncated: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
is_monitor: false,
|
|
restored_from_replay: false,
|
|
},
|
|
);
|
|
let meta = NotificationMeta::default();
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCall(
|
|
acp::ToolCall::new(acp::ToolCallId::new(Arc::from("wait-2")), "get_task_output")
|
|
.kind(acp::ToolKind::Other)
|
|
.status(acp::ToolCallStatus::Pending)
|
|
.content(vec![])
|
|
.raw_input(Some(serde_json::json!(
|
|
{ "task_ids" : ["bg-2"], "timeout_ms" : 5_000, }
|
|
)))
|
|
.locations(vec![]),
|
|
),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
let activity = view.resolve_turn_activity().expect("activity");
|
|
let TurnActivity::Waiting(reason) = activity else {
|
|
panic!("expected waiting: {activity:?}");
|
|
};
|
|
assert_eq!(reason.label(), "sleep 30…");
|
|
}
|
|
#[test]
|
|
fn task_output_wait_multi_id_uses_full_task_count() {
|
|
use crate::acp::meta::NotificationMeta;
|
|
use crate::app::agent::{BgTaskState, BgTaskStatus};
|
|
use agent_client_protocol as acp;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
let mut view = running_view();
|
|
view.session.bg_tasks.insert(
|
|
"bg-a".into(),
|
|
BgTaskState {
|
|
task_id: "bg-a".into(),
|
|
tool_call_id: "tc-a".into(),
|
|
command: "echo a".into(),
|
|
description: Some("alpha task".into()),
|
|
cwd: String::new(),
|
|
output_file: String::new(),
|
|
status: BgTaskStatus::Running,
|
|
start_time: SystemTime::now(),
|
|
end_time: None,
|
|
exit_code: None,
|
|
signal: None,
|
|
stdout: String::new(),
|
|
stdout_line_count: 0,
|
|
truncated: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
is_monitor: false,
|
|
restored_from_replay: false,
|
|
},
|
|
);
|
|
let meta = NotificationMeta::default();
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCall(
|
|
acp::ToolCall::new(
|
|
acp::ToolCallId::new(Arc::from("wait-multi")),
|
|
"get_task_output",
|
|
)
|
|
.kind(acp::ToolKind::Other)
|
|
.status(acp::ToolCallStatus::Pending)
|
|
.content(vec![])
|
|
.raw_input(Some(serde_json::json!(
|
|
{ "task_ids" : ["bg-a", "missing-b", "missing-c"],
|
|
"timeout_ms" : 5_000, }
|
|
)))
|
|
.locations(vec![]),
|
|
),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
let activity = view.resolve_turn_activity().expect("activity");
|
|
let TurnActivity::Waiting(reason) = activity else {
|
|
panic!("expected waiting: {activity:?}");
|
|
};
|
|
assert_eq!(
|
|
reason.label(),
|
|
"alpha task + 2 more…",
|
|
"N more is based on full task_ids length, not resolved count"
|
|
);
|
|
}
|
|
#[test]
|
|
fn task_output_wait_multi_id_preserves_suffix_when_first_is_long() {
|
|
use crate::acp::meta::NotificationMeta;
|
|
use crate::acp::tracker::MAX_ACTIVITY_SUBJECT_CHARS;
|
|
use crate::app::agent::{BgTaskState, BgTaskStatus};
|
|
use agent_client_protocol as acp;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
let long_desc = "L".repeat(80);
|
|
let mut view = running_view();
|
|
view.session.bg_tasks.insert(
|
|
"bg-long".into(),
|
|
BgTaskState {
|
|
task_id: "bg-long".into(),
|
|
tool_call_id: "tc-long".into(),
|
|
command: "echo long".into(),
|
|
description: Some(long_desc),
|
|
cwd: String::new(),
|
|
output_file: String::new(),
|
|
status: BgTaskStatus::Running,
|
|
start_time: SystemTime::now(),
|
|
end_time: None,
|
|
exit_code: None,
|
|
signal: None,
|
|
stdout: String::new(),
|
|
stdout_line_count: 0,
|
|
truncated: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
is_monitor: false,
|
|
restored_from_replay: false,
|
|
},
|
|
);
|
|
let meta = NotificationMeta::default();
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCall(
|
|
acp::ToolCall::new(
|
|
acp::ToolCallId::new(Arc::from("wait-long-multi")),
|
|
"get_task_output",
|
|
)
|
|
.kind(acp::ToolKind::Other)
|
|
.status(acp::ToolCallStatus::Pending)
|
|
.content(vec![])
|
|
.raw_input(Some(serde_json::json!(
|
|
{ "task_ids" : ["bg-long", "missing-b"], "timeout_ms" :
|
|
5_000, }
|
|
)))
|
|
.locations(vec![]),
|
|
),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
let activity = view.resolve_turn_activity().expect("activity");
|
|
let TurnActivity::Waiting(reason) = activity else {
|
|
panic!("expected waiting: {activity:?}");
|
|
};
|
|
let label = reason.label();
|
|
assert!(
|
|
label.contains(" + 1 more"),
|
|
"multi-task suffix must survive clamp: {label}"
|
|
);
|
|
assert!(label.ends_with('…'));
|
|
let body = label.strip_suffix('…').unwrap();
|
|
assert!(
|
|
body.chars().count() <= MAX_ACTIVITY_SUBJECT_CHARS + 20,
|
|
"unexpectedly long body: {body}"
|
|
);
|
|
}
|
|
/// get_task_output often passes subagent_id, not the child_session_id map key.
|
|
#[test]
|
|
fn task_output_wait_resolves_subagent_by_subagent_id() {
|
|
use crate::acp::meta::NotificationMeta;
|
|
use crate::app::subagent::SubagentInfo;
|
|
use agent_client_protocol as acp;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
let mut view = running_view();
|
|
let now = Instant::now();
|
|
view.subagent_sessions.insert(
|
|
"child-session-xyz".into(),
|
|
SubagentInfo {
|
|
subagent_id: Arc::from("sub-id-42"),
|
|
child_session_id: Arc::from("child-session-xyz"),
|
|
description: Arc::from("explore the auth module"),
|
|
subagent_type: Arc::from("explore"),
|
|
persona: None,
|
|
role: None,
|
|
model: None,
|
|
context_source: None,
|
|
resumed_from: None,
|
|
capability_mode: None,
|
|
context_normalized: false,
|
|
parent_prompt_id: None,
|
|
started_at: now,
|
|
last_progress_at: now,
|
|
finished: false,
|
|
status: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
tool_calls: None,
|
|
turns: None,
|
|
turn_count: None,
|
|
tool_call_count: None,
|
|
tokens_used: None,
|
|
context_window_tokens: None,
|
|
context_usage_pct: None,
|
|
tools_used: vec![],
|
|
error_count: None,
|
|
activity_label: None,
|
|
is_background: true,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
prompt: None,
|
|
child_cwd: None,
|
|
worktree_path: None,
|
|
child_updates_replayed: false,
|
|
},
|
|
);
|
|
let meta = NotificationMeta::default();
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCall(
|
|
acp::ToolCall::new(
|
|
acp::ToolCallId::new(Arc::from("wait-sub")),
|
|
"get_command_or_subagent_output",
|
|
)
|
|
.kind(acp::ToolKind::Other)
|
|
.status(acp::ToolCallStatus::Pending)
|
|
.content(vec![])
|
|
.raw_input(Some(serde_json::json!(
|
|
{ "task_ids" : ["sub-id-42"], "timeout_ms" : 10_000, }
|
|
)))
|
|
.locations(vec![]),
|
|
),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
let activity = view.resolve_turn_activity().expect("activity");
|
|
let TurnActivity::Waiting(reason) = activity else {
|
|
panic!("expected waiting: {activity:?}");
|
|
};
|
|
assert_eq!(reason.label(), "explore the auth module…");
|
|
}
|
|
#[test]
|
|
fn task_output_wait_long_command_keeps_generic_label() {
|
|
use crate::acp::meta::NotificationMeta;
|
|
use crate::app::agent::{BgTaskState, BgTaskStatus};
|
|
use agent_client_protocol as acp;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
let long_cmd = "cargo test --release --workspace --all-features -- --nocapture".to_string();
|
|
assert!(
|
|
long_cmd.chars().count() > 40,
|
|
"fixture must exceed the short-command threshold"
|
|
);
|
|
let mut view = running_view();
|
|
view.session.bg_tasks.insert(
|
|
"bg-3".into(),
|
|
BgTaskState {
|
|
task_id: "bg-3".into(),
|
|
tool_call_id: "tc-3".into(),
|
|
command: long_cmd,
|
|
description: None,
|
|
cwd: String::new(),
|
|
output_file: String::new(),
|
|
status: BgTaskStatus::Running,
|
|
start_time: SystemTime::now(),
|
|
end_time: None,
|
|
exit_code: None,
|
|
signal: None,
|
|
stdout: String::new(),
|
|
stdout_line_count: 0,
|
|
truncated: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
is_monitor: false,
|
|
restored_from_replay: false,
|
|
},
|
|
);
|
|
let meta = NotificationMeta::default();
|
|
view.session.handle_update(
|
|
acp::SessionUpdate::ToolCall(
|
|
acp::ToolCall::new(acp::ToolCallId::new(Arc::from("wait-3")), "get_task_output")
|
|
.kind(acp::ToolKind::Other)
|
|
.status(acp::ToolCallStatus::Pending)
|
|
.content(vec![])
|
|
.raw_input(Some(serde_json::json!(
|
|
{ "task_ids" : ["bg-3"], "timeout_ms" : 5_000, }
|
|
)))
|
|
.locations(vec![]),
|
|
),
|
|
&meta,
|
|
&mut view.scrollback,
|
|
);
|
|
let activity = view.resolve_turn_activity().expect("activity");
|
|
let TurnActivity::Waiting(reason) = activity else {
|
|
panic!("expected waiting: {activity:?}");
|
|
};
|
|
assert_eq!(
|
|
reason.label(),
|
|
"Waiting on task output…",
|
|
"long command without description must not become the spinner subject"
|
|
);
|
|
assert_eq!(
|
|
reason,
|
|
WaitingReason::TaskOutput {
|
|
task_ids: vec!["bg-3".into()],
|
|
subject: None,
|
|
waits: true,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod status_window_tests {
|
|
use super::super::test_agent_view;
|
|
#[test]
|
|
fn start_turn_boundary_closes_status_window() {
|
|
let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
|
|
agent.end_work_announced = true;
|
|
agent.start_turn_boundary(None);
|
|
assert!(
|
|
!agent.end_work_announced,
|
|
"a real turn start closes the between-turns status window"
|
|
);
|
|
assert!(agent.session.state.is_turn_running());
|
|
}
|
|
#[test]
|
|
fn begin_replay_window_closes_status_window() {
|
|
let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
|
|
agent.end_work_announced = true;
|
|
agent.begin_replay_window();
|
|
assert!(
|
|
!agent.end_work_announced,
|
|
"a replay window closes the between-turns status window"
|
|
);
|
|
}
|
|
}
|