//! Prompt-queue dispatch: the server-authoritative immediate-send routing //! helpers, optimistic queue echoes, the local drip-feed drain //! ([`maybe_drain_queue`]), the turn-start shim, and the queue-interject //! action arm. use super::ctx::{active_agent_session_id, with_active_agent}; use super::interject::record_interject_prompt_history; use crate::acp::meta::user_prompt_meta; use crate::app::actions::Effect; use crate::app::agent::{AgentCommand, AgentId}; use crate::app::agent_view::{AgentView, PromptMode}; use crate::app::app_view::{ActiveView, AppView}; use crate::scrollback::block::RenderBlock; use agent_client_protocol as acp; use std::time::Instant; /// Whether a prompt/command submitted right now should take the /// server-authoritative immediate-send path: the **server is busy** /// (running a turn or still holding queued prompts), the session exists, the /// local drip-feed queue is empty, and we're not mid-edit / model-switch / /// replay. Kind-specific extras (e.g. the plain-prompt "no images" rule) are /// checked by the caller. /// /// **Server-busy — `is_turn_running() || !shared_queue.is_empty()`:** the /// immediate-send path is for prompts that must queue server-side rather than /// start a turn locally. It is NOT enough to check `is_turn_running()`: in /// leader mode there is a turn-end window where this client has processed the /// turn-end (so it is locally `Idle`, `current_prompt_id` cleared) but has not /// yet adopted the leader's broadcast that the next prompt was promoted. In /// that window `is_turn_running()` is false, yet the agent is busy and its /// queue is non-empty — which this client sees as a non-empty `shared_queue` /// mirror. Without the queue check, a prompt sent then takes the local /// drip-feed path and is optimistically promoted to a running turn on THIS /// client, while the leader appends it BEHIND the existing queue — it shows as /// running here but queued on every other client (confirmed via qtrace: /// `send_route_plain immediate=false is_turn_running=false shared_queue_len=5` /// followed by `local_drain`). Treating a non-empty `shared_queue` as /// server-busy routes it to the server queue, where the broadcast then drives /// adoption consistently for all clients. /// /// **FIFO guard — `pending_prompts.is_empty()`:** a prompt may only jump onto /// the server queue when there is nothing ahead of it in the local drip-feed /// queue. The two queues are merged for display/drain as *server rows first, /// then local rows* ([`QueuePane::sync_from_merged`]), which is only correct /// while every server-queued prompt is older than every local one. That /// invariant breaks during the startup race: prompts typed while the session is /// still "Starting…" go local (no session/turn yet); once the first one drains /// and the turn starts, a newly-typed prompt would immediate-send onto the /// server queue and render *ahead* of the still-pending older local prompt /// (e.g. `[2, 3]` shown/run as `[3, 2]`). Requiring an empty local queue keeps /// later prompts behind the older ones (they join the local queue and drain in /// order), preserving FIFO. pub(super) fn immediate_server_send_eligible(agent: &AgentView) -> bool { let server_busy = agent.session.state.is_turn_running() || !agent.shared_queue.is_empty(); server_busy && agent.session.session_id.is_some() && agent.session.pending_prompts.is_empty() && !matches!(agent.prompt_mode, PromptMode::EditingQueued { .. }) && !agent.session.model_switch_pending && !agent.session.loading_replay } /// Push the optimistic shared-queue echo for an immediate server-authoritative /// send and mirror it into the owning agent so the queue pane renders it /// immediately, before the confirming `kigi/queue/changed` broadcast. pub(super) fn push_server_queue_echo( app: &mut AppView, agent_id: AgentId, session_id: &str, prompt_id: &str, text: &str, kind: &str, ) { app.push_optimistic_prompt_echo(session_id, prompt_id, text, kind); let snapshot = app .shared_prompt_queue(session_id) .cloned() .unwrap_or_default(); if let Some(agent) = app.agents.get_mut(&agent_id) { agent.shared_queue = snapshot; // Track the unconfirmed echo so a queue-row send-now against it is // parked until the confirming broadcast (see // `AgentView::send_now_awaiting_confirm`). agent.optimistic_queue_ids.insert(prompt_id.to_string()); } } /// Retire the optimistic placeholder for a prompt that has definitively left /// the server-authoritative queue (restored on cancel, removed, drained, or /// otherwise resolved without becoming the running turn). /// /// The agent's `pending_inputs` is the single source of truth for queue /// contents and order; the only client-side queue state is the optimistic echo /// that bridges the round-trip before the confirming `kigi/queue/changed` /// broadcast. Once a prompt's RPC resolves (or we pull it back into the input /// on cancel) it will never reappear in a future broadcast, so its echo must be /// dropped — otherwise the reconcile in [`AppView::apply_queue_changed`] keeps /// re-pinning the stale row onto the END of every subsequent broadcast, which /// both resurrects the removed prompt and scrambles the queue order. /// /// Takes the two backing maps by `&mut` (rather than `&mut AppView`) so callers /// can invoke it while holding a disjoint borrow of `app.agents`. pub(super) fn retire_optimistic_echo( optimistic: &mut std::collections::HashMap< String, Vec, >, shared: &mut std::collections::HashMap>, session_id: &str, prompt_id: &str, ) { if let Some(opt) = optimistic.get_mut(session_id) { opt.retain(|e| e.id != prompt_id); if opt.is_empty() { optimistic.remove(session_id); } } if let Some(q) = shared.get_mut(session_id) { q.retain(|e| e.id != prompt_id); if q.is_empty() { shared.remove(session_id); } } } /// Drain prompt-side images and snapshot all chip elements (paste blocks, /// @-file refs, image chips) into the most recently enqueued `QueuedPrompt`. /// /// Must be called after `enqueue_prompt` / `push_back` and before /// `prompt.set_text("")` (which clears element and image state). If the /// last queued entry already has `wire_blocks` (skill injection), images /// are dropped with a toast instead of merged. pub(super) fn drain_prompt_state_to_last_queued(agent: &mut AgentView) { let prompt_state = agent.prompt.stash(); let (_, images, chip_elements) = prompt_state.into_submission(); let Some(entry) = agent.session.pending_prompts.back_mut() else { return; }; entry.chip_elements = chip_elements; if images.is_empty() { return; } // wire_blocks policy: skill-injected prompts do not carry prompt images. if entry.wire_blocks.is_some() { agent.show_toast("Images removed (skill prompt)"); return; } entry.images = images; } /// Prepend `` framing to a cron prompt for the model. /// /// The UI shows the raw `prompt` text via `RenderBlock::cron_prompt`; this /// wrapped version is only sent to the model via `Effect::SendPrompt` so /// the model knows the message is a scheduled task execution, not a human. fn format_cron_prompt(prompt: &str, task_id: &str, human_schedule: &str) -> String { kigi_tools::reminders::format_scheduled_task_prompt(prompt, task_id, human_schedule) } /// `/compact ` text from a queued command; a queue edit may leave leading whitespace. pub(super) fn compact_user_context(text: &str) -> Option { let inv = crate::slash::parse_invocation(text.trim_start())?; let args = inv.args.trim(); (inv.token == "compact" && !args.is_empty()).then(|| args.to_string()) } /// Try to send the next queued entry (prompt, command, bash, or cron) if the agent is idle. /// /// Called after enqueue operations and task completions to advance the queue. pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { use crate::app::agent::QueueEntryKind; use crate::unified_log as ulog; let sid = agent.session.session_id.as_ref().map(|s| s.0.as_ref()); let queue_depth = agent.session.pending_prompts.len(); let log_blocked = |reason: &str, sid: Option<&str>| { if queue_depth > 0 { ulog::debug( "prompt.drain_blocked", sid, Some(serde_json::json!({"reason": reason, "queue_depth": queue_depth})), ); } }; if !agent.session.state.is_idle() { log_blocked("turn_running", sid); return vec![]; } // Hold the drain during an in-flight model switch. See the // `model_switch_pending` field doc for why a reconnect must clear it. if agent.session.model_switch_pending { log_blocked("model_switch_pending", sid); return vec![]; } if agent.session.loading_replay { log_blocked("loading_replay", sid); return vec![]; } // Server-owned next turn: a non-running server row (including this // client's own in-flight send-now echo) drains shell-side — the // `queue/changed(running_prompt_id)` adoption starts it. Draining a LOCAL // row now would optimistically promote it as the running turn while the // shell runs the server row, whose deltas then fail the prompt-id gate // and render nothing (the FIFO invariant documented on // `immediate_server_send_eligible`). let running = agent.session.current_prompt_id.as_deref(); if agent .shared_queue .iter() .any(|e| Some(e.id.as_str()) != running) { log_blocked("server_queue_owns_next_turn", sid); return vec![]; } let Some(session_id) = agent.session.session_id.clone() else { log_blocked("no_session_id", None); return vec![]; }; if let PromptMode::EditingQueued { id, .. } = &agent.prompt_mode && agent .session .pending_prompts .front() .is_some_and(|p| p.id == *id) { // The prompt being edited is next to send — don't drain it // from under the user. The turn status line will show a // "waiting on your edit" indicator. log_blocked("user_editing_front", Some(&session_id.0)); return vec![]; } let queued = match agent.session.dequeue_prompt() { Some(q) => q, None => return vec![], }; // A new turn is starting: follow-up chips belong to the previous // response and must not linger into it. agent.clear_follow_ups(); // This client is now sending its own prompt — it "takes the wheel" and is // no longer a passive viewer. Clearing this restores strict prompt-id gate // semantics (so stale chunks from a later rewind/cancel of THIS turn are // dropped, not adopted). See `AgentView::attached_as_viewer`. agent.attached_as_viewer = false; ulog::info( "prompt.drain", Some(&session_id.0), Some(serde_json::json!({ "kind": queued.kind.as_label(), "remaining_in_queue": agent.session.pending_prompts.len(), "prompt_len": queued.text.len(), })), ); // qtrace: a LOCAL drip-feed drain promotes this prompt to the running turn // client-side (renders a scrollback block + sets current_prompt_id). In // leader mode this is the suspected divergence point — the server may queue // the prompt behind others instead of running it. tracing::debug!( target: "qtrace", pid = std::process::id(), event = "local_drain", kind = queued.kind.as_label(), remaining = agent.session.pending_prompts.len(), shared_queue_len = agent.shared_queue.len(), session = session_id.0.as_ref(), text = %queued.text.chars().take(48).collect::(), "draining prompt LOCALLY as a new running turn", ); let agent_id = agent.session.id; // Track whether this turn is a bash-mode command for post-turn focus. agent.bash_turn = queued.kind == QueueEntryKind::BashCommand; agent.cron_task_id = if queued.kind == QueueEntryKind::Cron { queued.task_id.clone() } else { None }; // Generate a fresh prompt_id for every outgoing prompt/command. This is // threaded through PromptRequest._meta to the agent and echoed on every // SessionNotification + the PromptResponse, letting us correlate // notifications back to the originating prompt for cancel/rewind. let prompt_id = uuid::Uuid::new_v4().to_string(); // Record it as self-originated so the ACP gate treats this turn's deltas as // ours (drive it; drop a stale post-rewind chunk on a mismatch) rather than // adopting them as another client's turn. The `Cron` arm overrides // `prompt_id` with a `scheduler-fired-` prefix and records that id itself. if queued.kind != QueueEntryKind::Cron { agent.note_self_originated_prompt(&prompt_id); } match queued.kind { QueueEntryKind::Prompt => { agent.start_turn_boundary(Some(&prompt_id)); agent.session.current_prompt_id = Some(prompt_id.clone()); // Scrollback always shows the display text (e.g. "/commit fix bug"), // never the raw skill XML. Skill prompts use a distinct accent color; // plain prompts with composer-recognized tokens style just those. let is_skill = queued.display_as_skill; let block = if is_skill { RenderBlock::skill_prompt(&queued.text) } else if !queued.skill_token_ranges.is_empty() { RenderBlock::user_prompt_with_skill_tokens( &queued.text, queued.skill_token_ranges.clone(), ) } else { RenderBlock::user_prompt(&queued.text) }; let prompt_entry_id = agent.scrollback.push_block(block); // Stash for cancel-with-restore. Only plain (non-skill) prompts // can be reversed back into the input box. if queued.wire_blocks.is_none() { agent.session.in_flight_prompt = Some(crate::app::agent::InFlightPrompt { text: queued.text.clone(), images: queued.images.clone(), scrollback_entry: prompt_entry_id, chip_elements: queued.chip_elements.clone(), }); } agent.turn_started_at = Some(Instant::now()); let prompt_idx = agent.scrollback.len().saturating_sub(1); agent.scrollback.set_selected(Some(prompt_idx)); agent.scrollback.scroll_to_entry_top(prompt_idx); agent.scrollback.enable_follow_with_preserve(); if let Some(mut blocks) = queued.wire_blocks { // Annotate the first text block's meta with the display text // so the pager can reconstruct the clean prompt on session // restore (replay). Without this, replay shows the raw skill // instructions instead of the user-facing display text. if let Some(acp::ContentBlock::Text(tb)) = blocks.first_mut() { let map = tb.meta.get_or_insert_with(acp::Meta::new); map.insert( user_prompt_meta::DISPLAY_TEXT.into(), serde_json::Value::String(queued.text), ); if is_skill { map.insert( user_prompt_meta::DISPLAY_AS_SKILL.into(), serde_json::Value::Bool(true), ); } } else { tracing::debug!( "wire_blocks[0] is not TextContent — displayText annotation skipped" ); } vec![Effect::SendPromptBlocks { agent_id, session_id, blocks, prompt_id, }] } else if !queued.images.is_empty() { // Pass the session cwd so orphan `[Image #N: ]` // placeholders (paste from a previous session, etc.) // can be recovered from disk via the shared helper. // Token ranges are NOT stamped here: the builder rewrites the // text (placeholder stripping), which would shift byte offsets. let blocks = crate::prompt_images::build_content_blocks_with_workspace( queued.text, queued.images, Some(std::path::Path::new(&agent.session.cwd)), ); vec![Effect::SendPromptBlocks { agent_id, session_id, blocks, prompt_id, }] } else { vec![Effect::SendPrompt { agent_id, session_id, text: queued.text, prompt_id, skill_token_ranges: queued.skill_token_ranges, }] } } QueueEntryKind::Command => { // Currently only `/compact` — future slash commands will branch here. // `start_command` occupies the turn slot without `start_turn`, so // it closes the between-turns status window itself. agent.end_work_announced = false; agent.session.start_command(AgentCommand::Compact); agent.turn_started_at = Some(Instant::now()); let user_context = compact_user_context(&queued.text); vec![Effect::Compact { agent_id, session_id, user_context, }] } QueueEntryKind::BashCommand => { // Start turn but do NOT push a user prompt block. // The execute block from the shell IS the visual entry. agent.start_turn_boundary(Some(&prompt_id)); agent.session.current_prompt_id = Some(prompt_id.clone()); agent.turn_started_at = Some(Instant::now()); agent.scrollback.enable_follow_with_preserve(); vec![Effect::SendBashCommand { agent_id, session_id, command: queued.text, prompt_id, }] } QueueEntryKind::Cron => { let prompt_id = format!("scheduler-fired-{prompt_id}"); agent.note_self_originated_prompt(&prompt_id); agent.start_turn_boundary(Some(&prompt_id)); agent.session.current_prompt_id = Some(prompt_id.clone()); agent .scrollback .push_block(RenderBlock::cron_prompt(&queued.text)); agent.turn_started_at = Some(Instant::now()); let prompt_idx = agent.scrollback.len().saturating_sub(1); agent.scrollback.set_selected(Some(prompt_idx)); agent.scrollback.scroll_to_entry_top(prompt_idx); agent.scrollback.enable_follow_with_preserve(); let framed_text = format_cron_prompt( &queued.text, queued.task_id.as_deref().unwrap_or("unknown"), queued.human_schedule.as_deref().unwrap_or("unknown"), ); let mut meta_map = serde_json::Map::new(); meta_map.insert( user_prompt_meta::DISPLAY_TEXT.into(), serde_json::Value::String(queued.text), ); meta_map.insert( user_prompt_meta::DISPLAY_AS_CRON.into(), serde_json::Value::Bool(true), ); let blocks = vec![acp::ContentBlock::Text( acp::TextContent::new(framed_text).meta(Some(meta_map)), )]; vec![Effect::SendPromptBlocks { agent_id, session_id, blocks, prompt_id, }] } } } /// Whether [`apply_turn_start_shim`] renders its own user block (i.e. /// `display_block` is `Some`). When true the pager owns the block and must /// swallow the leader's user-echo; when false (bash, or a viewer with no local /// text) the echo is the only source and must render. Kept in sync with the /// shim's match via a `debug_assert!` there. pub(crate) fn shim_renders_own_user_block(kind: &str, text: Option<&str>) -> bool { match kind { "bash" => false, _ => text.is_some(), } } /// Trailing turn-starting `UserPrompt` matching `text`, scanning back past /// turn-boundary chrome (`SessionEvent`/`System`); any content block ends the /// scan. Interjection bubbles are never claimable. fn trailing_user_prompt_matching( agent: &AgentView, text: &str, ) -> Option<(usize, crate::scrollback::EntryId)> { for idx in (0..agent.scrollback.len()).rev() { let entry = agent.scrollback.entry(idx)?; match &entry.block { RenderBlock::UserPrompt(ub) if ub.text == text && !ub.is_interjection => { return Some((idx, entry.id)); } RenderBlock::SessionEvent(_) | RenderBlock::System(_) => continue, _ => return None, } } None } /// Paint the user block for a send-now'd prompt at dispatch: arming hides the /// queue row, and the shell echo is swallowed at adoption (`expect_user_echo`) /// or swept as a duplicate — so without this the message has no visible /// representation until the turn-start adoption (which reuses the block via /// `send_now_painted_blocks`). Call wherever the /// expectation is armed, only for rows whose adoption paints its own block; /// `kind` must build the same block the shim would. `edited` marks an /// edit-interject override (fresher than the mirror text the adoption sees). pub(super) fn push_send_now_user_block( agent: &mut AgentView, prompt_id: &str, kind: &str, text: &str, edited: bool, ) { // Viewers never run the shim and keep their echo (their only block // source) — painting here would double-render against it. if agent.attached_as_viewer { return; } // Repeat arm: same text is already painted; new text (edit re-arm) // replaces the block. if let Some((existing, _)) = agent.send_now_painted_blocks.get(prompt_id).copied() && let Some(idx) = agent.scrollback.index_of_id(existing) { let same = matches!( agent.scrollback.entry(idx).map(|e| &e.block), Some(RenderBlock::UserPrompt(ub)) if ub.text == text ); if same { return; } agent.scrollback.remove_entry(existing); } // Always a fresh block: at dispatch this prompt's echo cannot have landed // yet, so a same-text trailing match is a stale earlier bubble — claiming // it would leave the send-now with no new visible message. let block = match kind { "cron" => RenderBlock::cron_prompt(text.to_string()), _ => RenderBlock::user_prompt(text.to_string()), }; let entry_id = agent.scrollback.push_block(block); // Send-now keeps the viewport where it is (no entry-top jump). agent.scrollback.enable_follow_mode(); agent .send_now_painted_blocks .insert(prompt_id.to_string(), (entry_id, edited)); } /// Arm the send-now cancel expectation for queue row `id` and paint its user /// block — the arm hides the row, so the paint must accompany it. No-op when /// the shell won't cancel-and-send (idle / goal turn); no paint for kinds the /// adoption renders no block for (bash). `new_text` = edit-interject override. pub(crate) fn arm_send_now_and_paint(agent: &mut AgentView, id: &str, new_text: Option<&str>) { if !agent.expects_send_now_cancel() { return; } agent.arm_send_now_expectation(id.to_string()); let row = agent .shared_queue .iter() .find(|e| e.id == id) .map(|e| (e.kind.clone(), e.text.clone())); if let Some((kind, text)) = row && shim_renders_own_user_block(&kind, Some(&text)) { let edited = new_text.is_some_and(|t| t != text); push_send_now_user_block(agent, id, &kind, new_text.unwrap_or(&text), edited); } } /// Turn-start shim for a server-authoritative prompt the leader just drained /// into the running slot. /// /// Mirrors the matching arm of [`maybe_drain_queue`] EXCEPT it does NOT mint a /// `prompt_id` (it adopts the one the leader reported) and does NOT emit a send /// `Effect` (the prompt was already sent at enqueue time). The scrollback block /// and focus flag are branched on the adopted entry's `kind`: /// /// - `"bash"` — no user block (the shell's execute block IS the entry); set /// `agent.bash_turn = true` for post-turn focus + TurnComplete suppression. /// - `"cron"` — render the cron text via `cron_prompt`. /// - otherwise (plain `"prompt"`) — render the user-prompt block + stash an /// `in_flight_prompt` for Ctrl+C rewind. /// /// `start_turn` calls `expect_user_echo`, so the optimistic block here and the /// server's user-echo `session/update` are de-duplicated (not double-rendered). pub(crate) fn apply_turn_start_shim( agent: &mut AgentView, prompt_id: String, text: Option, kind: &str, ) { // Re-derive the per-turn viewer flag (see the ACP gate). This shim adopts a // turn the leader drained into the running slot: if THIS client originated // it (its own queued/immediate prompt), it drives it; otherwise it is // viewing a turn another client drives, so `attached_as_viewer` must flip // back to true even if this pane has sent prompts before (the flag is // not a one-way latch) — that drives `handle_prompt_complete` + the // viewer chrome correctly. let adopted_from_other_client = !agent.is_self_originated_prompt(&prompt_id); // Sticky pin + still-armed send-now expect (not cleared on adopt — the // cancel rail may still need it). Either covers adopt-before-cancel. let skip_entry_top = agent .follow_without_jump_prompt_id .as_ref() .is_some_and(|id| id == &prompt_id) || agent .expect_send_now_cancel .as_ref() .is_some_and(|id| id == &prompt_id); // Always drop pin (hit or miss) so a stale queue-row id cannot skip later. agent.follow_without_jump_prompt_id = None; tracing::debug!( target: "qtrace", pid = std::process::id(), event = "turn_start_shim", prompt_id = %prompt_id, kind, adopted_from_other_client, skip_entry_top, prev_current_prompt_id = agent.session.current_prompt_id.as_deref().unwrap_or(""), shared_queue_len = agent.shared_queue.len(), text = %text.as_deref().unwrap_or("").chars().take(48).collect::(), "adopting server-driven running turn (turn-start shim)", ); agent.start_turn_boundary(Some(&prompt_id)); agent.session.current_prompt_id = Some(prompt_id.clone()); agent.attached_as_viewer = adopted_from_other_client; // A new (adopted) turn is starting: drop the prior turn's chips but KEEP the // seen ring, so a buffer-replayed `kigi/follow_ups` for an older response // stays rejected (no stale revival). This is correct for BOTH passive-viewer // and self-driven adoption: the adopted turn's OWN follow_ups still // re-render via the stamped `promptId` match in `apply_follow_ups` (the // current_prompt_id set just above), so no seen-ring un-recording is needed. agent.clear_follow_ups(); // The adopted turn's follow_ups may have arrived on the ext channel BEFORE // this turn-start adoption (separate channels) and been buffered — render // them now that the turn is current. agent.flush_pending_follow_ups(&prompt_id); // The display block to render (if any), and whether to stash an // `in_flight_prompt` (only plain prompts are Ctrl+C-rewindable). let (display_block, rewindable): (Option, bool) = match kind { "bash" => { // The shell's execute block is the visual entry; no user block. agent.bash_turn = true; (None, false) } "cron" => (text.as_deref().map(RenderBlock::cron_prompt), false), // Plain prompt (default). _ => (text.as_deref().map(RenderBlock::user_prompt), true), }; debug_assert_eq!( display_block.is_some(), shim_renders_own_user_block(kind, text.as_deref()), "shim_renders_own_user_block must mirror apply_turn_start_shim's display_block" ); if let Some(block) = display_block { // The block may already be painted: consume the send-now paint's // id-keyed entry, else reuse a trailing echo block by text — never // double-push the user-prompt row. let map_painted = agent.send_now_painted_blocks.remove(&prompt_id).and_then( |(id, edited)| -> Option<(usize, crate::scrollback::EntryId)> { let idx = agent.scrollback.index_of_id(id)?; // Text drift resolves by freshness: an `edited` paint is // newer than the adoption's captured mirror text — keep it; // otherwise the adoption is fresher — swap the stale block. let RenderBlock::UserPrompt(ub) = &agent.scrollback.entry(idx)?.block else { return None; }; if text.as_deref() != Some(ub.text.as_str()) && !edited { agent.scrollback.remove_entry(id); return None; } // Drop an unarmed echo's duplicate copy of this prompt; // blocks claimed by other pending send-nows are excluded // (identical stacked texts). if let Some((_, dup)) = text .as_deref() .and_then(|t| trailing_user_prompt_matching(agent, t)) && dup != id && !agent .send_now_painted_blocks .values() .any(|(v, _)| *v == dup) { agent.scrollback.remove_entry(dup); } Some((agent.scrollback.index_of_id(id)?, id)) }, ); let already_painted = map_painted.or_else(|| { text.as_deref() .and_then(|t| trailing_user_prompt_matching(agent, t)) // Never claim a block owned by another pending send-now. .filter(|(_, id)| !agent.send_now_painted_blocks.values().any(|(v, _)| v == id)) }); let (prompt_idx, prompt_entry_id) = if let Some(found) = already_painted { found } else { let id = agent.scrollback.push_block(block); (agent.scrollback.len().saturating_sub(1), id) }; if rewindable && let Some(text) = text { // The rewind restore must match the on-screen (possibly edited) // block text, not the adoption's stale mirror text. let restore_text = match agent.scrollback.entry(prompt_idx).map(|e| &e.block) { Some(RenderBlock::UserPrompt(ub)) if ub.text != text => ub.text.clone(), _ => text, }; agent.session.in_flight_prompt = Some(crate::app::agent::InFlightPrompt { text: restore_text, images: Vec::new(), scrollback_entry: prompt_entry_id, chip_elements: Vec::new(), }); } agent.scrollback.set_selected(Some(prompt_idx)); if skip_entry_top { agent.scrollback.enable_follow_mode(); } else { agent.scrollback.scroll_to_entry_top(prompt_idx); agent.scrollback.enable_follow_with_preserve(); } } else { // No local block to render — this is a synthetic/cron/bash adoption with // no shared-queue text. `start_turn` above called `expect_user_echo`, // which would swallow the agent's live user-message broadcast — but for // these turns that broadcast is the ONLY source of the user block (e.g. // the cron `↻ echo hello` header, rendered from `_meta.displayAsCron` / // `displayText`). Clear the skip so `handle_user_message` renders it // instead of dropping it (the cause of viewers missing the cron header). agent.session.tracker.clear_user_echo_skip(); agent.scrollback.enable_follow_with_preserve(); } agent.turn_started_at = Some(Instant::now()); // After the echo-skip above; mirror the live-arm transitions the flush bypasses. agent.flush_pending_adoption_updates(&prompt_id); if agent.session.tracker.activity().is_some() { agent.session.in_flight_prompt = None; } if let Some(commands) = agent.session.tracker.take_pending_acp_commands() { agent.session.available_commands = commands; agent.session.available_commands_generation += 1; } if let Some(tools) = agent.session.tracker.take_pending_acp_tools() { agent.session.available_tools = Some(tools.into_iter().collect()); } } /// Try to drain the next queued prompt (triggered after editing completes). pub(super) fn dispatch_drain_queue(app: &mut AppView) -> Vec { if app.reconnect_pending { return vec![]; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; maybe_drain_queue(agent) } /// `Action::QueueInterjectShared` arm: map the (possibly edited) queue /// interject to a fire-and-forget effect scoped to the active agent's /// session. pub(super) fn dispatch_queue_interject_shared( app: &mut AppView, id: String, expected_version: u64, new_text: Option, ) -> Vec { match active_agent_session_id(app) { Some(session_id) => { with_active_agent(app, |agent| { // Edited override is user-typed text — keep it Ctrl+R recallable. if let Some(text) = &new_text { record_interject_prompt_history(agent, text); } arm_send_now_and_paint(agent, &id, new_text.as_deref()); }); vec![Effect::QueueInterject { session_id, id, expected_version, new_text, }] } None => vec![], } } #[cfg(test)] mod tests { use super::*; use crate::app::actions::Action; use crate::app::agent::AgentState; use crate::app::agent_view::test_fixtures::{ complete_task_output_wait_call, count_parked, running_subagent_info, simulate_subagent_wait, simulate_task_output_wait, simulate_task_output_wait_call, }; use crate::app::dispatch::router::dispatch; use crate::app::dispatch::tests::{end_turn, enqueue_local, test_app_with_agent}; /// A running background bash task for the work-count fixtures. fn running_bg_task(task_id: &str) -> crate::app::agent::BgTaskState { crate::app::agent::BgTaskState { task_id: task_id.into(), tool_call_id: format!("call-{task_id}"), command: "sleep 5".into(), description: None, cwd: "/tmp".into(), output_file: "/tmp/out".into(), status: crate::app::agent::BgTaskStatus::Running, start_time: std::time::SystemTime::now(), end_time: None, exit_code: None, signal: None, stdout: String::new(), stdout_line_count: 0, truncated: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, is_monitor: false, restored_from_replay: false, } } #[test] fn format_cron_prompt_includes_framing() { let out = super::format_cron_prompt("do stuff", "task-1", "every 5m"); assert!(out.starts_with("")); assert!(out.contains("task task-1")); assert!(out.contains("every 5m")); assert!(out.contains("do stuff")); assert!( !out.contains(""), "must not add — shell does that" ); assert!(out.ends_with("do stuff")); } #[test] fn drain_blocked_when_editing_front_prompt() { let mut app = test_app_with_agent(); let id = AgentId(0); // Queue 3 prompts, first one drains immediately (turn starts); the // follow-ups populate the local queue directly (see `enqueue_local`). dispatch(Action::SendPrompt("first".into()), &mut app); enqueue_local(&mut app, id, "second"); enqueue_local(&mut app, id, "third"); assert!(app.agents[&id].session.state.is_turn_running()); assert_eq!(app.agents[&id].session.queue_len(), 2); // Simulate user editing "second" (which becomes front after "first" ends). let second_id = app.agents[&id].session.pending_prompts[0].id; app.agents.get_mut(&id).unwrap().prompt_mode = PromptMode::EditingQueued { id: second_id, original: "second".into(), server_id: None, kind: crate::app::agent::QueueEntryKind::Prompt, }; // Turn ends → should NOT drain "second" (user is editing it). let effects = dispatch(end_turn(), &mut app); assert!(effects.is_empty(), "drain should be blocked: {effects:?}"); assert!(app.agents[&id].session.state.is_idle()); // "second" should still be in the queue. assert_eq!(app.agents[&id].session.queue_len(), 2); assert_eq!(app.agents[&id].session.pending_prompts[0].text, "second"); } #[test] fn drain_not_blocked_when_editing_non_front_prompt() { let mut app = test_app_with_agent(); let id = AgentId(0); // Queue 3 prompts, first drains. dispatch(Action::SendPrompt("first".into()), &mut app); enqueue_local(&mut app, id, "second"); enqueue_local(&mut app, id, "third"); // Simulate user editing "third" (NOT the front). let third_id = app.agents[&id].session.pending_prompts[1].id; app.agents.get_mut(&id).unwrap().prompt_mode = PromptMode::EditingQueued { id: third_id, original: "third".into(), server_id: None, kind: crate::app::agent::QueueEntryKind::Prompt, }; // Turn ends → should drain "second" (front, not being edited). let effects = dispatch(end_turn(), &mut app); assert_eq!(effects.len(), 1); assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second")); // "third" should still be in queue. assert_eq!(app.agents[&id].session.queue_len(), 1); assert_eq!(app.agents[&id].session.pending_prompts[0].text, "third"); } #[test] fn drain_queue_action_sends_front_prompt() { let mut app = test_app_with_agent(); let id = AgentId(0); // Queue a prompt but don't drain (set turn running first). app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; enqueue_local(&mut app, id, "queued"); assert_eq!(app.agents[&id].session.queue_len(), 1); // Set idle to simulate turn end (without going through PromptResponse). app.agents.get_mut(&id).unwrap().session.state = AgentState::Idle; // DrainQueue should pop and send. let effects = dispatch(Action::DrainQueue, &mut app); assert_eq!(effects.len(), 1); assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued")); assert_eq!(app.agents[&id].session.queue_len(), 0); } #[test] fn drain_queue_when_empty_does_nothing() { let mut app = test_app_with_agent(); let effects = dispatch(Action::DrainQueue, &mut app); assert!(effects.is_empty()); } /// Server-queue edit Actions map to fire-and-forget Effects scoped to /// the active agent's session. #[test] fn queue_edit_actions_map_to_scoped_effects() { let mut app = test_app_with_agent(); let effects = dispatch( Action::QueueRemoveShared { id: "p1".into(), expected_version: 4, }, &mut app, ); match effects.as_slice() { [ Effect::QueueRemove { session_id, id, expected_version, }, ] => { assert_eq!(session_id.0.as_ref(), "test-session"); assert_eq!(id, "p1"); assert_eq!(*expected_version, 4); } other => panic!("expected QueueRemove, got {other:?}"), } let effects = dispatch( Action::QueueReorderShared { ordered_ids: vec!["p2".into(), "p1".into()], }, &mut app, ); match effects.as_slice() { [ Effect::QueueReorder { session_id, ordered_ids, }, ] => { assert_eq!(session_id.0.as_ref(), "test-session"); assert_eq!(ordered_ids, &vec!["p2".to_string(), "p1".to_string()]); } other => panic!("expected QueueReorder, got {other:?}"), } let effects = dispatch(Action::QueueClearShared, &mut app); match effects.as_slice() { [Effect::QueueClear { session_id }] => { assert_eq!(session_id.0.as_ref(), "test-session"); } other => panic!("expected QueueClear, got {other:?}"), } let effects = dispatch( Action::QueueEditShared { id: "p1".into(), new_text: "new body".into(), }, &mut app, ); match effects.as_slice() { [ Effect::QueueEdit { session_id, id, new_text, }, ] => { assert_eq!(session_id.0.as_ref(), "test-session"); assert_eq!(id, "p1"); assert_eq!(new_text, "new body"); } other => panic!("expected QueueEdit, got {other:?}"), } let effects = dispatch( Action::QueueInterjectShared { id: "p1".into(), expected_version: 5, new_text: None, }, &mut app, ); match effects.as_slice() { [ Effect::QueueInterject { session_id, id, expected_version, new_text, }, ] => { assert_eq!(session_id.0.as_ref(), "test-session"); assert_eq!(id, "p1"); assert_eq!(*expected_version, 5); assert_eq!(*new_text, None, "plain interject carries no override"); } other => panic!("expected QueueInterject, got {other:?}"), } // Plain interjects re-send an existing queue row: no history insert. assert!(app.agents[&AgentId(0)].session.prompt_history.is_empty()); // Edited interject: the same arm carrying the edited text as the // newText override. let effects = dispatch( Action::QueueInterjectShared { id: "p1".into(), expected_version: 5, new_text: Some("edited body".into()), }, &mut app, ); match effects.as_slice() { [ Effect::QueueInterject { session_id, id, expected_version, new_text, }, ] => { assert_eq!(session_id.0.as_ref(), "test-session"); assert_eq!(id, "p1"); assert_eq!(*expected_version, 5); assert_eq!(new_text.as_deref(), Some("edited body")); } other => panic!("expected single QueueInterject with newText, got {other:?}"), } // The user typed the edited text — it must be Ctrl+R recallable. assert_eq!( app.agents[&AgentId(0)] .session .prompt_history .first() .map(String::as_str), Some("edited body") ); } /// The turn-start shim sets `bash_turn` and pushes NO user block for /// an adopted `bash` entry. #[test] fn shim_bash_kind_sets_bash_turn_and_no_user_block() { let mut app = test_app_with_agent(); let id = AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); let before = agent.scrollback.len(); apply_turn_start_shim(agent, "p1".to_string(), Some("ls -la".to_string()), "bash"); assert!(agent.bash_turn, "bash adoption must set bash_turn"); assert!(agent.session.state.is_turn_running()); assert_eq!(agent.session.current_prompt_id.as_deref(), Some("p1")); // No user/display block is pushed (the shell's execute block IS the entry). assert_eq!(agent.scrollback.len(), before); assert!(agent.session.in_flight_prompt.is_none()); } /// Turn-start path: the leader/viewer adoption shim /// clears the previous response's follow-up chips. #[test] fn shim_clears_follow_up_chips() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.apply_follow_ups("resp-1".into(), vec!["a".into()]); apply_turn_start_shim(agent, "p9".to_string(), Some("hi".to_string()), "prompt"); assert!(agent.follow_ups.is_none(), "turn adoption must clear chips"); } /// FIX 4 (a) via the `queue/changed` shim: after the shim adopts a turn /// (setting `current_prompt_id`), a re-delivery of THAT turn's follow_ups /// re-renders — the stamped `promptId` matches the adopted turn, so chips /// that were applied then cleared reappear (no un-recording needed). #[test] fn shim_adopted_turn_redelivery_rerenders() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); // Follow-ups for the (to-be-)adopted turn p9 already applied + shown. assert!(agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p9"), vec!["a".into()])); // The shim adopts turn p9 (viewer: "p9" is not self-originated): it // clears the shown chips but KEEPS the seen ring and sets // current_prompt_id = "p9". apply_turn_start_shim(agent, "p9".to_string(), Some("hi".to_string()), "prompt"); assert!(agent.attached_as_viewer, "p9 is a viewer-adopted turn"); assert!(agent.follow_ups.is_none(), "turn adoption clears chips"); assert!( agent.follow_up_seen.contains_key("resp-1"), "adoption keeps the seen ring" ); // A re-delivery of the adopted turn's follow_ups re-renders (promptId // p9 == current_prompt_id). let changed = agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p9"), vec!["a".into()]); assert!(changed, "re-delivery of the adopted turn must re-render"); assert_eq!( agent .follow_ups .as_ref() .expect("chips re-populated") .suggestions, vec!["a"] ); } /// FIX 4 (b) via the shim: after starting a NEW turn, a buffer-replayed /// `kigi/follow_ups` for a PRIOR turn's response stays rejected (its /// `promptId` is not the active turn and it is already seen) — no stale /// revival. Covers the self-driven turn start (`p-self`). #[test] fn shim_new_turn_rejects_prior_turn_replay() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); // A prior turn (p-prev) produced resp-1's chips. agent.session.current_prompt_id = Some("p-prev".into()); assert!(agent.apply_follow_ups_with_prompt( "resp-1".into(), Some("p-prev"), vec!["a".into()] )); // This client starts a new self-driven turn (p-self). agent.note_self_originated_prompt("p-self"); apply_turn_start_shim( agent, "p-self".to_string(), Some("hi".to_string()), "prompt", ); assert!( !agent.attached_as_viewer, "a self-originated turn is driver, not viewer" ); assert!(agent.follow_ups.is_none(), "turn start hides chips"); assert!( agent.follow_up_seen.contains_key("resp-1"), "the seen ring entry for the prior response is kept" ); // A replayed PRIOR turn (p-prev/resp-1) must NOT revive on the new turn. let changed = agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p-prev"), vec!["a".into()]); assert!( !changed, "a replayed prior-turn follow_ups must stay rejected on the new turn" ); assert!( agent.follow_ups.is_none(), "no stale chips may resurface from the replay" ); } /// Regression: a viewer adopting a synthetic/cron turn (no shared-queue /// text → no local block) must CLEAR the `expect_user_echo` flag that /// `start_turn` set, so the agent's live user-message broadcast — the only /// source of the cron `↻` header — renders instead of being swallowed. /// When the shim DOES render a block (queue text present), it keeps the /// skip so the broadcast isn't duplicated. #[test] fn shim_clears_echo_skip_only_when_it_renders_no_block() { // No queue text → no local block → clear the skip (let broadcast render). let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); apply_turn_start_shim(agent, "cron-1".to_string(), None, "prompt"); assert!( !agent.session.tracker.expects_user_echo(), "no-block adoption must clear the echo skip so the cron broadcast renders" ); // Queue text present → local block rendered → keep the skip (no dup). let mut app2 = test_app_with_agent(); let agent2 = app2.agents.get_mut(&AgentId(0)).unwrap(); apply_turn_start_shim( agent2, "p2".to_string(), Some("hello world".to_string()), "prompt", ); assert!( agent2.session.tracker.expects_user_echo(), "rendering the user block locally must keep the skip to avoid duplicating the broadcast" ); } #[test] fn send_now_shim_skips_scroll_to_entry_top() { fn seed_tall_scrollback(agent: &mut crate::app::agent_view::AgentView) -> usize { for i in 0..40 { agent .scrollback .push_block(RenderBlock::agent_message(format!( "seeded assistant line {i}: filler so entry-top and bottom offsets diverge" ))); } agent.scrollback.prepare_layout(80, 8); agent.scrollback.goto_bottom(); agent.scrollback.scroll_offset() } let mut app_normal = test_app_with_agent(); let agent_n = app_normal.agents.get_mut(&AgentId(0)).unwrap(); let bottom_n = seed_tall_scrollback(agent_n); agent_n.note_self_originated_prompt("p-normal"); apply_turn_start_shim( agent_n, "p-normal".into(), Some("normal next".into()), "prompt", ); let normal_offset = agent_n.scrollback.scroll_offset(); assert!(agent_n.scrollback.is_follow_mode()); assert_ne!( normal_offset, bottom_n, "normal adoption should leave bottom via scroll_to_entry_top" ); let mut app_send = test_app_with_agent(); let agent_s = app_send.agents.get_mut(&AgentId(0)).unwrap(); seed_tall_scrollback(agent_s); agent_s.note_self_originated_prompt("p-send-now"); agent_s.arm_send_now_expectation("p-send-now".into()); assert!(agent_s.follow_without_jump_prompt_id.is_some()); // Cancel-rail take: only sticky pin remains. let _ = agent_s.expect_send_now_cancel.take(); apply_turn_start_shim( agent_s, "p-send-now".into(), Some("hurry up".into()), "prompt", ); assert!(agent_s.follow_without_jump_prompt_id.is_none()); assert!(agent_s.scrollback.is_follow_mode()); let send_now_offset = agent_s.scrollback.scroll_offset(); assert_ne!( send_now_offset, normal_offset, "send-now must not use scroll_to_entry_top" ); // Miss: armed for A, adopt B — entry-top path, pin still dropped. let mut app_miss = test_app_with_agent(); let agent_m = app_miss.agents.get_mut(&AgentId(0)).unwrap(); let bottom_m = seed_tall_scrollback(agent_m); agent_m.note_self_originated_prompt("p-b"); agent_m.arm_send_now_expectation("p-a".into()); apply_turn_start_shim(agent_m, "p-b".into(), Some("other".into()), "prompt"); assert!(agent_m.follow_without_jump_prompt_id.is_none()); assert_ne!( agent_m.scrollback.scroll_offset(), bottom_m, "mismatched arm must still scroll_to_entry_top" ); } /// Shell user-echo can paint the prompt before the deferred turn-start /// shim runs. Reuse that trailing block — do not push a second copy. #[test] fn shim_reuses_already_painted_user_prompt() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.note_self_originated_prompt("p-dup"); // Simulate the shell echo winning the race. agent .scrollback .push_block(RenderBlock::user_prompt("/deslop")); let before = agent.scrollback.len(); apply_turn_start_shim(agent, "p-dup".into(), Some("/deslop".into()), "prompt"); assert_eq!( agent.scrollback.len(), before, "must not push a second user-prompt block when echo already painted" ); let last = agent.scrollback.entry(before - 1).expect("trailing entry"); match &last.block { RenderBlock::UserPrompt(ub) => assert_eq!(ub.text, "/deslop"), other => panic!("expected user prompt, got {other:?}"), } } /// Number of `UserPrompt` blocks with exactly `text`. fn user_prompt_count(agent: &crate::app::agent_view::AgentView, text: &str) -> usize { (0..agent.scrollback.len()) .filter_map(|i| agent.scrollback.entry(i)) .filter(|e| matches!(&e.block, RenderBlock::UserPrompt(ub) if ub.text == text)) .count() } /// Queue-row send-now paints at dispatch; the adoption reuses the block. #[test] fn queue_interject_shared_paints_user_block_at_arm() { let mut app = test_app_with_agent(); let id = AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "p-ty".into(), version: 1, owner: None, last_editor: None, kind: "prompt".into(), text: "ty".into(), position: 0, }]; } let effects = dispatch( Action::QueueInterjectShared { id: "p-ty".into(), expected_version: 1, new_text: None, }, &mut app, ); assert!(matches!( effects.as_slice(), [Effect::QueueInterject { .. }] )); let agent = app.agents.get_mut(&id).unwrap(); assert_eq!( user_prompt_count(agent, "ty"), 1, "send-now must paint the user block at dispatch" ); // The adoption shim reuses the painted block instead of double-pushing. agent.note_self_originated_prompt("p-ty"); apply_turn_start_shim(agent, "p-ty".into(), Some("ty".into()), "prompt"); assert_eq!( user_prompt_count(agent, "ty"), 1, "turn-start adoption must reuse the dispatch-painted block" ); } /// No paint when idle (adoption renders the drain) or for bash rows. #[test] fn queue_interject_shared_skips_paint_when_not_arming_or_bash() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "p-idle".into(), version: 1, owner: None, last_editor: None, kind: "prompt".into(), text: "idle row".into(), position: 0, }]; // Idle (no running turn): expects_send_now_cancel is false — no arm, no paint. let _ = dispatch( Action::QueueInterjectShared { id: "p-idle".into(), expected_version: 1, new_text: None, }, &mut app, ); assert_eq!(user_prompt_count(&app.agents[&id], "idle row"), 0); // Bash row mid-turn: armed, but its adoption paints no user block. { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "p-bash".into(), version: 1, owner: None, last_editor: None, kind: "bash".into(), text: "ls -la".into(), position: 0, }]; } let _ = dispatch( Action::QueueInterjectShared { id: "p-bash".into(), expected_version: 1, new_text: None, }, &mut app, ); assert_eq!(user_prompt_count(&app.agents[&id], "ls -la"), 0); } /// Composer/local-row send-now paints at dispatch too. #[test] fn send_prompt_now_paints_user_block_at_arm() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let effects = dispatch( Action::SendPromptNow { text: "hurry".into(), images: vec![], }, &mut app, ); assert!(matches!(effects.as_slice(), [Effect::SendPromptNow { .. }])); assert_eq!(user_prompt_count(&app.agents[&id], "hurry"), 1); } /// The reuse scan looks past turn-boundary chrome landing between the /// paint and the adoption (interject no-op, natural drain). #[test] fn shim_reuses_painted_block_past_turn_chrome() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.note_self_originated_prompt("p-late"); push_send_now_user_block(agent, "p-late", "prompt", "ty", false); agent.scrollback.push_block(RenderBlock::session_event( crate::scrollback::blocks::SessionEvent::TurnCompleted { elapsed: Some(std::time::Duration::from_secs(2)), }, )); agent .scrollback .push_block(RenderBlock::system("1 command still running")); apply_turn_start_shim(agent, "p-late".into(), Some("ty".into()), "prompt"); assert_eq!( user_prompt_count(agent, "ty"), 1, "reuse must look past trailing SessionEvent/System chrome" ); // A content block ends the scan. agent .scrollback .push_block(RenderBlock::agent_message("done")); agent.note_self_originated_prompt("p-again"); apply_turn_start_shim(agent, "p-again".into(), Some("ty".into()), "prompt"); assert_eq!( user_prompt_count(agent, "ty"), 2, "content between ends the reuse scan" ); } /// Idempotent per prompt id; new-text re-arm replaces; blank rows paint /// (their adoption renders a blank bubble too). #[test] fn push_send_now_user_block_dedupes_and_replaces_on_new_text() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); push_send_now_user_block(agent, "p-1", "prompt", "ty", false); push_send_now_user_block(agent, "p-1", "prompt", "ty", false); assert_eq!(user_prompt_count(agent, "ty"), 1); push_send_now_user_block(agent, "p-1", "prompt", "ty edited", true); assert_eq!(user_prompt_count(agent, "ty"), 0); assert_eq!(user_prompt_count(agent, "ty edited"), 1); let before = agent.scrollback.len(); push_send_now_user_block(agent, "p-2", "prompt", " ", false); assert_eq!(agent.scrollback.len(), before + 1, "blank rows paint too"); } /// Stacked send-nows: each adoption reuses its own id-keyed block. #[test] fn stacked_send_nows_each_reuse_their_own_block() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.note_self_originated_prompt("p-thx"); agent.note_self_originated_prompt("p-ty"); push_send_now_user_block(agent, "p-thx", "prompt", "thx", false); push_send_now_user_block(agent, "p-ty", "prompt", "ty", false); apply_turn_start_shim(agent, "p-thx".into(), Some("thx".into()), "prompt"); assert_eq!(user_prompt_count(agent, "thx"), 1); agent .scrollback .push_block(RenderBlock::agent_message("You're welcome.")); apply_turn_start_shim(agent, "p-ty".into(), Some("ty".into()), "prompt"); assert_eq!(user_prompt_count(agent, "ty"), 1); } /// Identical-text stacked send-nows get one block each (no aliasing). #[test] fn stacked_identical_text_send_nows_get_distinct_blocks() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); push_send_now_user_block(agent, "p-1", "prompt", "go", false); push_send_now_user_block(agent, "p-2", "prompt", "go", false); assert_eq!(user_prompt_count(agent, "go"), 2); assert_ne!( agent.send_now_painted_blocks["p-1"].0, agent.send_now_painted_blocks["p-2"].0 ); // The sibling's block must survive the first adoption's dup sweep. agent.note_self_originated_prompt("p-1"); agent.note_self_originated_prompt("p-2"); apply_turn_start_shim(agent, "p-1".into(), Some("go".into()), "prompt"); assert_eq!(user_prompt_count(agent, "go"), 2); apply_turn_start_shim(agent, "p-2".into(), Some("go".into()), "prompt"); assert_eq!(user_prompt_count(agent, "go"), 2); assert!(agent.send_now_painted_blocks.is_empty()); } /// Viewers never paint (their echo is the block source). #[test] fn viewer_send_now_does_not_paint() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.attached_as_viewer = true; push_send_now_user_block(agent, "p-v", "prompt", "hi", false); assert_eq!(user_prompt_count(agent, "hi"), 0); assert!(agent.send_now_painted_blocks.is_empty()); } /// Drifted non-edited paint is swapped for the adoption's text. #[test] fn shim_swaps_stale_painted_text_for_adoption_text() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.note_self_originated_prompt("p-edit"); push_send_now_user_block(agent, "p-edit", "prompt", "old text", false); apply_turn_start_shim(agent, "p-edit".into(), Some("new text".into()), "prompt"); assert_eq!(user_prompt_count(agent, "old text"), 0); assert_eq!(user_prompt_count(agent, "new text"), 1); } /// Edited paint outranks the adoption's stale mirror text. #[test] fn shim_keeps_edited_paint_over_stale_adoption_text() { let mut app = test_app_with_agent(); let id = AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "p-ed".into(), version: 1, owner: None, last_editor: None, kind: "prompt".into(), text: "original".into(), position: 0, }]; } let _ = dispatch( Action::QueueInterjectShared { id: "p-ed".into(), expected_version: 1, new_text: Some("edited body".into()), }, &mut app, ); let agent = app.agents.get_mut(&id).unwrap(); assert_eq!( user_prompt_count(agent, "edited body"), 1, "the paint must show the edited text the shell will run" ); // Adoption captures the pre-edit mirror text; the edited paint wins. agent.note_self_originated_prompt("p-ed"); apply_turn_start_shim(agent, "p-ed".into(), Some("original".into()), "prompt"); assert_eq!(user_prompt_count(agent, "edited body"), 1); assert_eq!(user_prompt_count(agent, "original"), 0); // The Ctrl+C rewind restore must match the on-screen (edited) text. assert_eq!( agent .session .in_flight_prompt .as_ref() .map(|p| p.text.as_str()), Some("edited body") ); } /// An early unarmed echo's duplicate is swept at adoption. #[test] fn shim_drops_echo_duplicate_of_painted_block() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.note_self_originated_prompt("p-echo"); push_send_now_user_block(agent, "p-echo", "prompt", "ty", false); // Echo slips in before the adoption (no promptId, skip not armed yet). agent.scrollback.push_block(RenderBlock::user_prompt("ty")); apply_turn_start_shim(agent, "p-echo".into(), Some("ty".into()), "prompt"); assert_eq!(user_prompt_count(agent, "ty"), 1); } /// A painted-pending row stays hidden after the arm drops; the pair /// resolves at adoption or retire, never by the arm's lifetime. #[test] fn painted_pending_row_stays_hidden_after_arm_drop() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.session.state = AgentState::TurnRunning; agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "p-drop".into(), version: 1, owner: None, last_editor: None, kind: "prompt".into(), text: "held".into(), position: 0, }]; arm_send_now_and_paint(agent, "p-drop", None); assert!(agent.visible_queue_is_empty(), "armed row is hidden"); // Interactive cancel drops the arm; the block still owns the message. agent.clear_send_now_expectation(); assert!( agent.visible_queue_is_empty(), "painted-pending row must stay hidden after the arm drops" ); assert_eq!(user_prompt_count(agent, "held"), 1); // Retiring resolves both: block gone, row visible again. agent.retire_send_now_painted_block("p-drop"); assert!(!agent.visible_queue_is_empty()); assert_eq!(user_prompt_count(agent, "held"), 0); } /// The adoption's text fallback must not claim a block owned by another /// pending send-now (map-less adoption racing a painted sibling). #[test] fn shim_fallback_skips_blocks_claimed_by_pending_send_nows() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); push_send_now_user_block(agent, "p-owned", "prompt", "go", false); // p-other adopts with the same text but no map entry of its own. agent.note_self_originated_prompt("p-other"); apply_turn_start_shim(agent, "p-other".into(), Some("go".into()), "prompt"); assert_eq!( user_prompt_count(agent, "go"), 2, "the fallback must push fresh instead of claiming p-owned's block" ); assert!(agent.send_now_painted_blocks.contains_key("p-owned")); } /// A never-run send-now takes its block with it (no requeue duplicate). #[test] fn retire_send_now_painted_block_removes_ghost() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); push_send_now_user_block(agent, "p-fail", "prompt", "lost?", false); assert_eq!(user_prompt_count(agent, "lost?"), 1); agent.retire_send_now_painted_block("p-fail"); assert_eq!(user_prompt_count(agent, "lost?"), 0); assert!(agent.send_now_painted_blocks.is_empty()); } /// The cancel-and-send arm survives the *matching* prompt's turn start (so /// the cancel rail can still suppress the marker when it races after adopt), /// but a non-matching (stale) arm is dropped. #[test] fn start_turn_boundary_keeps_send_now_cancel_expectation() { let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.arm_send_now_expectation("p-send-now".into()); agent.start_turn_boundary(Some("p-send-now")); assert_eq!( agent.expect_send_now_cancel.as_deref(), Some("p-send-now"), "adopt must not clear the cancel-marker arm before the cancel PromptResponse" ); assert!( agent.follow_without_jump_prompt_id.is_some(), "sticky pin is independent of start_turn_boundary" ); // A different prompt starting the turn means the arm is stale — drop it // so it cannot suppress that turn's later real cancel marker. agent.start_turn_boundary(Some("p-other")); assert!( agent.expect_send_now_cancel.is_none(), "a non-matching turn start clears the stale send-now arm" ); } #[test] fn drain_queue_when_running_does_nothing() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; enqueue_local(&mut app, id, "queued"); // DrainQueue while running → no effect. let effects = dispatch(Action::DrainQueue, &mut app); assert!(effects.is_empty()); assert_eq!(app.agents[&id].session.queue_len(), 1); } #[test] fn drain_queue_blocked_during_loading_replay() { let mut app = test_app_with_agent(); let id = AgentId(0); // Simulate session-resume state: Idle but still replaying. let agent = app.agents.get_mut(&id).unwrap(); agent.session.loading_replay = true; agent.session.enqueue_cron_prompt( "check status".into(), "task-1".into(), "every 5m".into(), ); // Drain while loading_replay is true → must be blocked. let effects = maybe_drain_queue(app.agents.get_mut(&id).unwrap()); assert!( effects.is_empty(), "drain must be blocked during loading_replay" ); assert_eq!( app.agents[&id].session.queue_len(), 1, "cron prompt must stay queued" ); // Clear loading_replay (simulates SessionLoaded completing). app.agents.get_mut(&id).unwrap().session.loading_replay = false; // Drain again → should succeed now. let effects = maybe_drain_queue(app.agents.get_mut(&id).unwrap()); assert_eq!(effects.len(), 1); assert!( matches!(&effects[0], Effect::SendPromptBlocks { .. }), "expected SendPromptBlocks effect, got: {:?}", effects[0] ); assert_eq!( app.agents[&id].session.queue_len(), 0, "queue must be empty after drain" ); assert_eq!( app.agents[&id].cron_task_id.as_deref(), Some("task-1"), "cron_task_id must track the running cron task" ); } #[test] fn drain_after_editing_sends_correct_prompt() { // Regression: editing #3, prompts #1 and #2 drain, #3 becomes front. // User presses Enter (save) → DrainQueue should send #3's updated text, // NOT #4 or the old text. let mut app = test_app_with_agent(); let id = AgentId(0); // Queue 4 prompts, first drains. dispatch(Action::SendPrompt("p1".into()), &mut app); enqueue_local(&mut app, id, "p2"); enqueue_local(&mut app, id, "p3"); enqueue_local(&mut app, id, "p4"); assert_eq!(app.agents[&id].session.queue_len(), 3); // End turn for p1 → sets Idle → maybe_drain_queue pops p2 → Running again. // Queue is now: p3, p4. dispatch(end_turn(), &mut app); assert_eq!(app.agents[&id].session.queue_len(), 2); // Start editing p3 (now front). let p3_id = app.agents[&id].session.pending_prompts[0].id; app.agents.get_mut(&id).unwrap().prompt_mode = PromptMode::EditingQueued { id: p3_id, original: "p3".into(), server_id: None, kind: crate::app::agent::QueueEntryKind::Prompt, }; // End turn for p2 → should NOT drain p3 (being edited). let effects = dispatch(end_turn(), &mut app); assert!(effects.is_empty(), "drain should be blocked: {effects:?}"); assert_eq!(app.agents[&id].session.queue_len(), 2); // Simulate user saving edited text. app.agents .get_mut(&id) .unwrap() .session .pending_prompts .iter_mut() .find(|p| p.id == p3_id) .unwrap() .text = "p3-edited".into(); app.agents.get_mut(&id).unwrap().prompt_mode = PromptMode::Normal; // DrainQueue after edit → should send "p3-edited", not "p4". let effects = dispatch(Action::DrainQueue, &mut app); assert_eq!(effects.len(), 1); assert!( matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "p3-edited"), "should send the edited prompt, got: {:?}", effects[0] ); // p4 should still be in queue. assert_eq!(app.agents[&id].session.queue_len(), 1); assert_eq!(app.agents[&id].session.pending_prompts[0].text, "p4"); } #[test] fn drain_queue_blocked_during_reconnect() { let mut app = test_app_with_agent(); let id = AgentId(0); // Enqueue a prompt while not reconnecting so it's queued. app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; enqueue_local(&mut app, id, "queued"); assert_eq!(app.agents[&id].session.queue_len(), 1); // Set idle + reconnect_pending: DrainQueue should be blocked. app.agents.get_mut(&id).unwrap().session.state = AgentState::Idle; app.reconnect_pending = true; let effects = dispatch(Action::DrainQueue, &mut app); assert!(effects.is_empty()); assert_eq!(app.agents[&id].session.queue_len(), 1); } /// Regression (leader mode): a prompt queued during a turn must drain once /// the leader connection reconnects. Every normal drain trigger /// (PromptResponse / DrainQueue / send-prompt / session-created) /// early-returns while `reconnect_pending` is set, deferring the drain to /// the event loop's reconnect-complete arm. That arm clears /// `reconnect_pending`, force-idles the agent, then dispatches /// `Action::DrainQueue`. This test exercises that final drain step and /// guards against the queue silently stalling after a reconnect. #[test] fn drain_queue_after_reconnect_sends_queued_prompt() { let mut app = test_app_with_agent(); let id = AgentId(0); // A prompt was queued behind a running turn before the outage. app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; enqueue_local(&mut app, id, "queued"); assert_eq!(app.agents[&id].session.queue_len(), 1); // During the outage every drain trigger is suppressed. app.reconnect_pending = true; let blocked = dispatch(Action::DrainQueue, &mut app); assert!(blocked.is_empty(), "drain must stay blocked mid-reconnect"); assert_eq!(app.agents[&id].session.queue_len(), 1); // Reconnect completes: the event loop clears `reconnect_pending` and // force-idles the agent, then dispatches DrainQueue. Mirror that here. app.reconnect_pending = false; app.agents.get_mut(&id).unwrap().session.state = AgentState::Idle; let effects = dispatch(Action::DrainQueue, &mut app); assert_eq!( effects.len(), 1, "queued prompt must drain once reconnect clears, got: {effects:?}" ); assert!(matches!(&effects[0], Effect::SendPrompt { .. })); assert!(app.agents[&id].session.state.is_turn_running()); assert_eq!( app.agents[&id].session.queue_len(), 0, "queue must be empty after the post-reconnect drain" ); } #[test] fn parked_marker_fires_once_on_empty_queue_park() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); assert!(agent.renders_parked(), "marker + live wait = parked look"); // Idempotent within the same park. agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1, "one marker per park"); } /// A re-park re-pushes the marker when intervening content buried the /// previous one — otherwise the second park renders as a dead session. #[test] fn parked_marker_repushes_on_repark_after_buried_marker() { use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1, "current tail must dedupe"); complete_task_output_wait_call(agent, "wait-1"); assert!(!agent.renders_parked(), "no parked look between parks"); agent .scrollback .push_block(RenderBlock::agent_message("between-parks content")); simulate_task_output_wait_call(agent, "wait-2", "bg-1", 600_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 2, "buried marker re-pushes on re-park"); assert!(agent.renders_parked()); } /// Content landing during a park makes the tail stale — the marker /// restates, then the restated tail dedupes again. #[test] fn parked_marker_restates_when_tail_goes_stale_mid_park() { use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); agent .scrollback .push_block(RenderBlock::agent_message("mid-park chip")); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 2, "stale tail restates the marker"); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 2, "restated tail dedupes again"); } /// A re-park whose previous marker is still the transcript tail pushes /// nothing (poll loop: wait expiry → immediate re-issue). #[test] fn parked_marker_not_repushed_when_marker_still_tail() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 15_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); // Wait tools render no blocks, so the marker stays the tail. complete_task_output_wait_call(agent, "wait-1"); simulate_task_output_wait_call(agent, "wait-2", "bg-1", 15_000); agent.maybe_push_parked_marker(); assert_eq!( count_parked(agent), 1, "marker still at the tail: a re-push would be a duplicate line" ); assert!( agent.renders_parked(), "the park itself still renders parked" ); } /// An interjection below an already-pushed marker must not trigger a /// restate beneath the user's message (the queue-emptying re-evaluation /// fires before the wait-abort lands). #[test] fn rendered_slot_stays_quiet_under_tail_interjection() { use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); agent .scrollback .push_block(RenderBlock::interjection_prompt("hurry up")); agent.suppress_parked_marker_on_interject(); agent.maybe_push_parked_marker(); assert_eq!( count_parked(agent), 1, "no marker may render beneath the interjected message" ); } /// `Forgone` is final for the turn: even a genuine re-park with buried /// content must not resurrect the marker. #[test] fn forgone_slot_blocks_repark_repush() { use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); agent.suppress_parked_marker_on_interject(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "forgone park renders no marker"); complete_task_output_wait_call(agent, "wait-1"); agent .scrollback .push_block(RenderBlock::agent_message("continued below interject")); simulate_task_output_wait_call(agent, "wait-2", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "forgone stays silent all turn"); } /// A work-count change makes the tail's marker stale — it restates, and /// the restatement dedupes an immediate re-park. #[test] fn count_change_restates_marker_and_dedupes_repark() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); agent .session .bg_tasks .insert("bg-1".into(), running_bg_task("bg-1")); agent .session .bg_tasks .insert("bg-2".into(), running_bg_task("bg-2")); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); agent.session.bg_tasks.remove("bg-2"); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 2, "count change restates the marker"); complete_task_output_wait_call(agent, "wait-1"); simulate_task_output_wait_call(agent, "wait-2", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!( count_parked(agent), 2, "the restated tail dedupes the immediate re-park" ); } #[test] fn parked_marker_not_pushed_while_send_now_echo_is_only_row() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); enqueue_local(&mut app, id, "held then send-now'd"); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "held row withholds the marker"); agent.session.pending_prompts.clear(); agent.expect_send_now_cancel = Some("send-now-echo".into()); agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "send-now-echo".into(), version: 0, owner: None, last_editor: None, kind: "prompt".into(), text: "send now payload".into(), position: 0, }]; assert!(agent.visible_queue_is_empty()); assert!(agent.has_held_user_queue()); agent.maybe_push_parked_marker(); assert_eq!( count_parked(agent), 0, "send-now occupancy must block the parked marker" ); agent.suppress_parked_marker_on_interject(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "forgone slot stays silent"); } /// Queued rows HOLD during a parked/blocking wait; nothing drains on its own. #[test] fn parked_wait_holds_queue_and_explains_itself() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); enqueue_local(&mut app, id, "queued row"); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "queued row must hold the marker"); assert!(!agent.renders_parked()); assert_eq!( agent.held_queue_count(), 1, "held row feeds the inline status hint" ); assert_eq!(agent.session.pending_prompts.len(), 1); } /// A foreground-subagent wait is sendable but never parks (parent blocked, not completed). #[test] fn subagent_wait_holds_queue_but_never_parks() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); enqueue_local(&mut app, id, "queued row"); simulate_subagent_wait(app.agents.get_mut(&id).unwrap()); let agent = app.agents.get_mut(&id).unwrap(); assert_eq!( agent.held_queue_count(), 1, "held row feeds the inline status hint" ); agent.maybe_push_parked_marker(); assert_eq!( count_parked(agent), 0, "queued row holds the (excluded) marker" ); assert!(!agent.renders_parked()); // Even with an empty queue + live subagent, a subagent wait never parks. agent .subagent_sessions .insert("child-1".into(), running_subagent_info("child-1")); agent.session.pending_prompts.clear(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "subagent wait must never park"); assert!( !agent.renders_parked(), "subagent wait keeps running chrome" ); } /// T1 regression: once the model resumes streaming in the SAME turn, the /// parked/stopped look must flip off (the running chrome returns) even if /// the wait tool's terminal ToolCallUpdate never reached this client — /// a live chunk proves the turn is no longer parked in the wait. #[test] fn parked_look_clears_when_model_resumes_streaming() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert!(agent.renders_parked(), "parked look active during the wait"); // The model resumes with a message chunk (no Completed for the wait). let meta = crate::acp::meta::NotificationMeta::default(); agent.session.handle_update( agent_client_protocol::SessionUpdate::AgentMessageChunk( agent_client_protocol::ContentChunk::new( agent_client_protocol::ContentBlock::Text( agent_client_protocol::TextContent::new("resuming".to_string()), ), ), ), &meta, &mut agent.scrollback, ); assert!( !agent.renders_parked(), "a live stream must un-park the chrome (spinner returns)" ); assert!( !agent.is_parked_on_sendable_wait(), "the stale wait must not survive a resumed stream" ); // A new-stream thought also un-parks; same-stream thoughts must not. // Establish the wait under stream_start=1, then thought under 9001. { let wait_meta = crate::acp::meta::NotificationMeta { stream_start_ms: Some(1), ..Default::default() }; agent.session.handle_update( acp::SessionUpdate::ToolCall( acp::ToolCall::new( acp::ToolCallId::new(std::sync::Arc::from("wait-2")), "get_command_or_subagent_output", ) .kind(acp::ToolKind::Other) .status(acp::ToolCallStatus::Pending) .content(vec![]) .locations(vec![]), ), &wait_meta, &mut agent.scrollback, ); agent.session.handle_update( acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( acp::ToolCallId::new(std::sync::Arc::from("wait-2")), acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({ "task_ids": ["bg-2"], "timeout_ms": 30_000, }))), )), &wait_meta, &mut agent.scrollback, ); } assert!(agent.is_parked_on_sendable_wait()); let thought_meta = crate::acp::meta::NotificationMeta { stream_start_ms: Some(9_001), ..Default::default() }; agent.session.handle_update( agent_client_protocol::SessionUpdate::AgentThoughtChunk( agent_client_protocol::ContentChunk::new( agent_client_protocol::ContentBlock::Text( agent_client_protocol::TextContent::new("thinking again".to_string()), ), ), ), &thought_meta, &mut agent.scrollback, ); assert!( !agent.is_parked_on_sendable_wait(), "a new-stream thought must clear the stale wait" ); } /// T4 regression: the inline hint only advertises "Enter to send now" /// when the TOP held row would actually send (server rows always; local /// rows only when prompt-like — bash rows refuse with a toast). #[test] fn held_hint_advertises_send_now_only_for_sendable_top() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); // Local bash row on top: counted, but Enter would no-op. let agent = app.agents.get_mut(&id).unwrap(); agent.session.enqueue_bash_command("git status".into()); assert_eq!(agent.held_queue_count(), 1); assert!( !agent.held_queue_top_sendable(), "a bash top row must not advertise Enter-send-now" ); // A plain local prompt on top instead: sendable. agent.session.pending_prompts.clear(); agent.session.enqueue_prompt("plain follow-up".into()); assert!(agent.held_queue_top_sendable()); // A server row (renders first in the merge) is always sendable. agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "srv-1".into(), version: 0, owner: None, last_editor: None, kind: "bash".into(), text: "server bash".into(), position: 0, }]; agent.session.pending_prompts.clear(); agent.session.enqueue_bash_command("still bash".into()); assert!( agent.held_queue_top_sendable(), "a server top row sends now regardless of kind" ); } #[test] fn held_queue_count_matches_pane_with_send_now_echo() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); let running = agent .session .current_prompt_id .clone() .unwrap_or_else(|| "running".into()); agent.expect_send_now_cancel = Some("send-now-echo".into()); agent.shared_queue = vec![ crate::app::prompt_queue::QueueEntryWire { id: "send-now-echo".into(), version: 0, owner: None, last_editor: None, kind: "prompt".into(), text: "send now payload".into(), position: 0, }, crate::app::prompt_queue::QueueEntryWire { id: "held-1".into(), version: 0, owner: None, last_editor: None, kind: "prompt".into(), text: "genuine held".into(), position: 1, }, ]; agent.session.current_prompt_id = Some(running); agent.session.pending_prompts.clear(); assert_eq!( agent.held_queue_count(), 1, "hint counts only the genuine held row" ); agent.sync_queue_pane(); let pane_len = agent.queue.entry_ids().len(); assert_eq!(pane_len, 1, "pane must drop the send-now echo too"); assert_eq!( agent.held_queue_count(), pane_len, "hint and pane share one visible-held filter" ); assert_eq!( agent.visible_held_queue_len(), pane_len, "ungated visible length matches pane rows" ); assert!(agent.held_queue_top_sendable()); assert_eq!( format!(" · {} queued — Enter to send now", agent.held_queue_count()), " · 1 queued — Enter to send now" ); agent .shared_queue .push(crate::app::prompt_queue::QueueEntryWire { id: "held-2".into(), version: 0, owner: None, last_editor: None, kind: "prompt".into(), text: "second held".into(), position: 2, }); assert_eq!(agent.held_queue_count(), 2); agent.sync_queue_pane(); let pane_len = agent.queue.entry_ids().len(); assert_eq!(pane_len, 2); assert_eq!(agent.held_queue_count(), pane_len); assert_eq!( format!(" · {} queued — Enter to send now", agent.held_queue_count()), " · 2 queued — Enter to send now" ); } #[test] fn has_held_user_queue_includes_armed_send_now_echo() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.session.pending_prompts.clear(); agent.shared_queue.clear(); assert!( !agent.has_held_user_queue(), "empty held occupancy while parked" ); agent.expect_send_now_cancel = Some("send-now-echo".into()); agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { id: "send-now-echo".into(), version: 0, owner: None, last_editor: None, kind: "prompt".into(), text: "send now payload".into(), position: 0, }]; assert!( agent.has_held_user_queue(), "armed send-now occupies hold even when pane-visible count is 0" ); assert_eq!( agent.visible_held_queue_len(), 0, "pane still hides the echo" ); assert_eq!(agent.held_queue_count(), 0); } /// The armed send-now cancel does NOT count as held occupancy once it is /// the running turn (arm id == current_prompt_id) — otherwise the parked /// marker is suppressed and a new prompt is wrongly held behind an empty /// queue after a send-now adopts. #[test] fn has_held_user_queue_excludes_arm_that_is_running() { let mut app = test_app_with_agent(); let id = AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.pending_prompts.clear(); agent.shared_queue.clear(); // Matching send-now adopt: the armed id became the running turn. agent.expect_send_now_cancel = Some("p-run".into()); agent.session.current_prompt_id = Some("p-run".into()); assert!( !agent.has_held_user_queue(), "an arm for the running turn is not held occupancy" ); // A stale arm for a different (not-running) prompt still occupies hold. agent.session.current_prompt_id = Some("p-other".into()); assert!( agent.has_held_user_queue(), "an arm for a non-running prompt still occupies hold" ); } /// T2 regression: deleting the LAST held local row re-evaluates the /// parked look immediately (no waiting for an unrelated notification). #[test] fn local_delete_of_last_held_row_flips_parked_look_on() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); enqueue_local(&mut app, id, "held row"); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 0, "held row holds the marker"); assert!(!agent.renders_parked()); // Delete the row through the queue-pane key path. agent.queue.sync_from_merged( &agent.session.pending_prompts, &agent.shared_queue, agent.session.current_prompt_id.as_deref(), agent.expect_send_now_cancel.as_deref(), &agent.send_now_painted_blocks, ); let ids = agent.queue.entry_ids(); assert_eq!(ids.len(), 1); agent.queue.list_state.select_by_id(ids[0]); let registry = crate::actions::ActionRegistry::defaults(); let _ = agent.handle_queue_key( &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ®istry, ); assert!(agent.session.pending_prompts.is_empty()); assert_eq!( count_parked(agent), 1, "deleting the last held row must push the parked marker now" ); assert!( agent.renders_parked(), "the stopped look must flip on immediately after the local delete" ); } /// T3 regression: a task-tool refinement that OMITS `run_in_background` /// means background (the shell's serde default is true) — the provisional /// foreground Subagent wait must clear, not stick as "Waiting on subagent…". #[test] fn task_refinement_without_background_field_defaults_to_background() { use std::sync::Arc; use crate::acp::tracker::{TurnActivity, WaitingReason}; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); let meta = crate::acp::meta::NotificationMeta::default(); // Meta-less task ToolCall (old shell): provisional Subagent wait. agent.session.handle_update( agent_client_protocol::SessionUpdate::ToolCall( agent_client_protocol::ToolCall::new( agent_client_protocol::ToolCallId::new(Arc::from("task-old-shell")), "task", ) .kind(agent_client_protocol::ToolKind::Other) .status(agent_client_protocol::ToolCallStatus::Pending) .content(vec![]) .locations(vec![]), ), &meta, &mut agent.scrollback, ); assert!( matches!( agent.resolve_turn_activity(), Some(TurnActivity::Waiting(WaitingReason::Subagent)) ), "the meta-less spawn starts with a provisional foreground wait" ); // Refinement with raw_input that omits run_in_background/background. agent.session.handle_update( agent_client_protocol::SessionUpdate::ToolCallUpdate( agent_client_protocol::ToolCallUpdate::new( agent_client_protocol::ToolCallId::new(Arc::from("task-old-shell")), agent_client_protocol::ToolCallUpdateFields::new().raw_input(Some( serde_json::json!({ "variant": "Task", "task_id": "t-1", "prompt": "explore" }), )), ), ), &meta, &mut agent.scrollback, ); assert!( !matches!( agent.resolve_turn_activity(), Some(TurnActivity::Waiting(WaitingReason::Subagent)) ), "an omitted run_in_background field means background: the provisional \ foreground wait must clear" ); } /// A plain mid-turn interjection (no wait) must NOT consume the marker /// slot: a later park in the same turn still deserves its marker. #[test] fn non_parked_interjection_keeps_later_park_marker() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); // Mid-turn interjection while streaming (no wait advertised). let _ = dispatch( Action::Interject { text: "heads up".into(), images: Vec::new(), }, &mut app, ); assert!( app.agents[&id].parked_wait_marker_for.is_none(), "no wait → slot must stay free" ); // The turn later parks: the marker still fires. simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1, "later park keeps its marker"); } /// Parked chrome must clear OSC 9;4 (and treat the tab title as idle) so /// Ghostty/WezTerm drop the progress bar while the session looks stopped. /// The turn is still `TurnRunning` server-side — only `renders_parked` /// flips the notification busy bit. #[test] fn parked_wait_clears_progress_bar_notification() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); // Running wait, no parked marker yet → still busy chrome / progress on. app.update_notifications(); assert!( app.notification_service.is_progress_active(), "live turn must keep the OSC 9;4 progress indicator active" ); let agent = app.agents.get_mut(&id).unwrap(); agent.maybe_push_parked_marker(); assert!(agent.renders_parked()); assert!( agent.session.state.is_busy(), "server-side turn remains running while parked" ); app.pending_notification_escapes = None; app.update_notifications(); assert!( !app.notification_service.is_progress_active(), "parked look must clear OSC 9;4 so the terminal progress bar stops" ); } /// The parked push is the unified marker: a static `TurnCompleted` event /// block flagged `parked`, stamped with the turn's pid and carrying a /// snapshot of the wait's work counts. The real final marker later /// pushes separately (two static lines — main's park shape). #[test] fn parked_marker_is_static_completed_snapshot_with_counts() { use crate::scrollback::block::RenderBlock; use crate::scrollback::blocks::SessionEvent; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); agent .session .bg_tasks .insert("bg-1".into(), running_bg_task("bg-1")); simulate_task_output_wait(agent, "bg-1"); agent.maybe_push_parked_marker(); let block = (0..agent.scrollback.len()) .rev() .find_map(|i| match agent.scrollback.get(i).map(|e| &e.block) { Some(RenderBlock::SessionEvent(b)) => Some(b), _ => None, }) .expect("the park must push a marker block"); assert!(matches!(block.event, SessionEvent::TurnCompleted { .. })); assert!(block.parked); assert_eq!( block.prompt_id, agent.session.current_prompt_id, "the park stamps the marker with its turn's pid" ); assert!( block.stop_hooks.is_empty(), "a parked marker carries no hooks" ); let work = block.end_work.expect("park snapshots the work counts"); assert_eq!(work.running_commands, 1); assert_eq!(work.running_monitors, 0); assert_eq!(work.running_subagents, 0); } }