Synced from monorepo
Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "xai-grok-pager"
|
||||
version = "0.2.111"
|
||||
version = "0.2.112"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
|
|
|
|||
|
|
@ -51,8 +51,11 @@ Hooks are discovered from several places (all are merged):
|
|||
| Global | `~/.claude/settings.json` | Always | Claude Code compatibility |
|
||||
| Project | `<project>/.grok/hooks/*.json` | Requires trust | Per-repo automation |
|
||||
| Project | `<project>/.claude/settings.json` | Requires trust | Claude compatibility |
|
||||
| Config | `config.toml`, `managed_config.toml`, `requirements.toml` | Always | Hooks shipped in your (or your organization's) config |
|
||||
| Plugin | Bundled inside installed plugins | Per-plugin | Shared team hooks |
|
||||
|
||||
Config-file hooks use the same schema in TOML form; see the [Hooks user guide](user-guide/10-hooks.md#hooks-in-config-files) for details.
|
||||
|
||||
**Trusting a project**: Open the hooks modal (`Ctrl+L` on non–VS Code family, or `/hooks` on any terminal including VS Code family) or run `/hooks-trust` (the same folder-trust gate as `--trust`, recorded in `~/.grok/trusted_folders.toml`) the first time you open a project with hooks. This prevents untrusted repos from running arbitrary code.
|
||||
|
||||
## The Hook JSON Format
|
||||
|
|
|
|||
|
|
@ -68,9 +68,12 @@ Hooks are discovered from several places (all are merged):
|
|||
| Project | `<project>/.grok/hooks/*.json` | Requires trust | Per-repo automation |
|
||||
| Project | `<project>/.claude/settings.json` (and `settings.local.json`) | Requires trust | Claude compatibility (configurable) |
|
||||
| Project | `<project>/.cursor/hooks.json` | Requires trust | Cursor compatibility (configurable) |
|
||||
| Config | `~/.grok/config.toml` | Always | Your hooks alongside the rest of your config |
|
||||
| Config | `managed_config.toml` (`$GROK_HOME` and `/etc/grok`) | Always | Organization-distributed hooks (server-synced and on-device) |
|
||||
| Config | `requirements.toml` (user and system) | Always | Organization-distributed hooks in the requirements layer |
|
||||
| Plugin | Bundled inside installed plugins | Per-plugin | Shared team hooks |
|
||||
|
||||
The Claude and Cursor hook sources are scanned by default. To disable scanning for a specific vendor, set `[compat.<vendor>] hooks = false` in `~/.grok/config.toml` or the corresponding environment variable. See [Configuration](05-configuration.md#harness-compatibility) for details.
|
||||
Config-file hooks live in the same TOML your organization already controls; see [Hooks in Config Files](#hooks-in-config-files) for the format. The compatible vendor hook sources are scanned by default. To disable scanning for a specific vendor, set `[compat.<vendor>] hooks = false` in `~/.grok/config.toml` or the corresponding environment variable. See [Configuration](05-configuration.md#harness-compatibility) for details.
|
||||
|
||||
**Trusting a project**: The first time you open a project with hooks, you must trust it before its project hooks will run -- until then they are silently skipped. Grant trust by running `/hooks-trust` (or launching with `--trust`); the decision is recorded in the unified folder-trust store (`~/.grok/trusted_folders.toml`), the same gate that governs repo-local MCP/LSP servers. Global hooks in `~/.grok/hooks/` are always trusted and need no entry. This prevents untrusted repos from running arbitrary code.
|
||||
|
||||
|
|
@ -168,6 +171,47 @@ A matcher keeps its original name too, so `Bash` matches both `Bash` and `run_te
|
|||
|
||||
---
|
||||
|
||||
## Hooks in Config Files
|
||||
|
||||
Hooks can also live directly in your Grok config, so a team can distribute them with the rest of their configuration instead of shipping separate JSON files. The same `hooks` object is read from three TOML files:
|
||||
|
||||
| File | Tier | Who sets it |
|
||||
|------|------|-------------|
|
||||
| `~/.grok/config.toml` | User | You |
|
||||
| `managed_config.toml` (`$GROK_HOME`, `/etc/grok`) | Managed / system | Your organization |
|
||||
| `requirements.toml` (user and system) | Requirements | Your organization |
|
||||
|
||||
The TOML is structurally identical to the JSON hook object, so an existing hook transliterates directly:
|
||||
|
||||
```toml
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash|Write|Edit"
|
||||
hooks = [
|
||||
{ type = "command", command = "/opt/guard/pretooluse.sh", timeout = 10 },
|
||||
]
|
||||
```
|
||||
|
||||
Each matcher group is a `[[hooks.<Event>]]` entry with an optional `matcher` and an inner `hooks` array of handlers. The handler fields (`type`, `command`, `url`, `timeout`, `env`) and event names are exactly the same as the [JSON format](#the-hook-json-format).
|
||||
|
||||
TOML offers two equivalent notations for the inner handlers, and both parse to the identical structure. The inline-table array shown above is recommended: it reads best for the common single-handler case. The nested array-of-tables form is also accepted:
|
||||
|
||||
```toml
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash|Write|Edit"
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "/opt/guard/pretooluse.sh"
|
||||
timeout = 10
|
||||
```
|
||||
|
||||
Prefer the inline form to avoid repeating the `[[hooks.<Event>.hooks]]` header for each handler.
|
||||
|
||||
- **Additive across layers.** Every layer's hooks run; a lower-priority layer adds hooks but never replaces another layer's block. A hook defined identically in more than one layer is deduplicated, keeping the highest-authority copy.
|
||||
- **Provenance labels.** Config hooks appear in `/hooks` tagged by origin (`managed:`, `requirements/user:`, `user:`, and so on) so you can see which layer contributed each one.
|
||||
- **No read-time expansion.** A literal `${VAR}` in a `command` or `url` reaches the hook runner unchanged, matching JSON hook-file semantics; the runner performs the single expansion.
|
||||
|
||||
---
|
||||
|
||||
## Writing Hook Scripts
|
||||
|
||||
### Input
|
||||
|
|
|
|||
|
|
@ -196,6 +196,14 @@ Whenever background work is still running while the agent looks idle — between
|
|||
|
||||
It counts running background commands, monitors, scheduled `/loop` tasks, and background subagents, and updates live as each finishes. Any of them can wake the agent for a new turn (commands and subagents on completion, monitors on events, loops on their timer), so the cue stays up until nothing is left. The running counts live only on this status line: completions land in the transcript as a single "Task completed" chip, and "Worked for" markers stay plain — the transcript never repeats or restates the running counts.
|
||||
|
||||
While a turn is waiting on background work (blocked in a `get_task_output` or `wait_tasks` call), the status line adds a hint that typing takes over immediately:
|
||||
|
||||
```
|
||||
◎ 1 command still running · send a message to interrupt
|
||||
```
|
||||
|
||||
The same hint appears as `◎ waiting · send a message to interrupt` when the agent is waiting on something with no live counter (a sleep, or work that already finished). Sending a message interrupts the wait and runs your message right away. The transcript keeps its usual shape throughout: one "Worked for" marker when the turn ends. When a completion wakes the agent and it replies, that reply gets its own "Worked for" marker; a wake the agent answers silently leaves no trace in the transcript — unless it fails, in which case a "Turn failed" line appears even for a silent wake, so a standing instruction never stops executing invisibly.
|
||||
|
||||
---
|
||||
|
||||
## Use Cases and Patterns
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ pub struct AcpConnection {
|
|||
pub auth_methods: Vec<acp::AuthMethod>,
|
||||
/// Cancellation token to stop the agent.
|
||||
pub cancel: CancellationToken,
|
||||
/// In-process agent worker thread (`connect` only). Join after cancel so
|
||||
/// session actors can flush SessionEnd hooks. `None` in leader mode.
|
||||
pub agent_thread: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
|
||||
/// ACP-advertised slash commands parsed from `InitializeResponse.meta.availableCommands`.
|
||||
/// Seeded into every new `AgentSession` so autocomplete has shell builtins
|
||||
/// and skills immediately, before any `AvailableCommandsUpdate` arrives.
|
||||
|
|
@ -227,6 +230,7 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
|
|||
is_grok_shell,
|
||||
auth_methods,
|
||||
cancel: spawned.cancel,
|
||||
agent_thread: Some(spawned.thread_handle),
|
||||
available_commands,
|
||||
needs_login,
|
||||
login_label,
|
||||
|
|
@ -353,6 +357,7 @@ pub async fn connect_via_leader(
|
|||
is_grok_shell,
|
||||
auth_methods,
|
||||
cancel: bridge.cancel,
|
||||
agent_thread: None,
|
||||
available_commands,
|
||||
needs_login,
|
||||
login_label,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
//! Simplified to only support GrokShell (in-process) mode.
|
||||
//! Subprocess and remote modes can be added later if needed.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::rc::Rc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -14,15 +16,28 @@ use xai_acp_lib::{
|
|||
acp_channels,
|
||||
};
|
||||
use xai_grok_shell::{
|
||||
agent::{MvpAgent, config::Config as AgentConfig, models::RefreshStrategy},
|
||||
agent::{
|
||||
MvpAgent, activity::SESSION_FLUSH_GRACE, config::Config as AgentConfig,
|
||||
models::RefreshStrategy,
|
||||
},
|
||||
auth::AuthManager,
|
||||
util::grok_home::grok_home,
|
||||
};
|
||||
|
||||
/// Extra slack when joining the agent OS thread after cancel so the flush
|
||||
/// can finish and the thread can unwind.
|
||||
const AGENT_JOIN_SLACK: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How long the join stays silent before telling an interactive user why exit
|
||||
/// is taking a moment. Short joins (the common case) print nothing.
|
||||
const JOIN_NOTICE_AFTER: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Result of spawning a child agent.
|
||||
pub struct SpawnedAgent {
|
||||
/// Kept alive so the thread isn't detached. Will be used for graceful shutdown.
|
||||
pub _thread_handle: thread::JoinHandle<Result<()>>,
|
||||
/// Agent worker OS thread. Hand to [`AgentShutdownGuard`] so the worker is
|
||||
/// cancelled and joined — letting session actors flush SessionEnd hooks —
|
||||
/// on every exit path.
|
||||
pub thread_handle: thread::JoinHandle<Result<()>>,
|
||||
pub channel: AcpClientChannel,
|
||||
pub cancel: CancellationToken,
|
||||
/// The agent's `AuthManager`, shared so pager-side consumers (e.g. the voice
|
||||
|
|
@ -30,6 +45,128 @@ pub struct SpawnedAgent {
|
|||
pub auth_manager: std::sync::Arc<AuthManager>,
|
||||
}
|
||||
|
||||
/// The single teardown mechanism for an in-process agent: cancels the worker
|
||||
/// and joins it on drop, so session actors always get
|
||||
/// `SessionCommand::Shutdown` (SessionEnd hooks, memory save) before the
|
||||
/// process exits — on normal return, `?` bail, or panic unwind alike.
|
||||
///
|
||||
/// Hold one from every site that calls [`spawn_grok_shell`] (headless, the TUI,
|
||||
/// `models`, `worktree`, `share`). Scope-end drop is the default; the TUI is the
|
||||
/// one caller that drops it explicitly, because the join has to happen before
|
||||
/// background processes are reaped (see `app::run`).
|
||||
pub struct AgentShutdownGuard {
|
||||
cancel: CancellationToken,
|
||||
thread: Option<thread::JoinHandle<Result<()>>>,
|
||||
}
|
||||
|
||||
impl AgentShutdownGuard {
|
||||
/// Guard an in-process agent worker. A `None` thread makes the guard a
|
||||
/// no-op cancel (leader mode has no in-process worker to join).
|
||||
pub fn new(cancel: CancellationToken, thread: Option<thread::JoinHandle<Result<()>>>) -> Self {
|
||||
Self { cancel, thread }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgentShutdownGuard {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
let Some(handle) = self.thread.take() else {
|
||||
return;
|
||||
};
|
||||
let timeout = SESSION_FLUSH_GRACE + AGENT_JOIN_SLACK;
|
||||
match join_agent_thread(handle, timeout) {
|
||||
JoinOutcome::Joined => {}
|
||||
JoinOutcome::Failed(error) => {
|
||||
tracing::warn!(%error, "agent worker exited with error after cancel");
|
||||
}
|
||||
JoinOutcome::Panicked(panic) => {
|
||||
tracing::warn!(%panic, "agent worker panicked after cancel");
|
||||
}
|
||||
JoinOutcome::TimedOut => {
|
||||
tracing::warn!(
|
||||
timeout_ms = timeout.as_millis() as u64,
|
||||
"agent worker did not exit within grace after cancel; \
|
||||
session hooks may be incomplete"
|
||||
);
|
||||
}
|
||||
JoinOutcome::HelperLost => {
|
||||
tracing::warn!("agent worker join helper disappeared; proceeding");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the join ended, so each case is explicit at the call site (and callers
|
||||
/// can tell a completed flush from an abandoned one).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum JoinOutcome {
|
||||
/// Worker returned cleanly: session actors flushed within the grace.
|
||||
Joined,
|
||||
/// Worker returned an error; the flush may be incomplete.
|
||||
Failed(String),
|
||||
/// Worker panicked, with the payload rendered as text.
|
||||
Panicked(String),
|
||||
/// Worker was still running when the budget elapsed.
|
||||
TimedOut,
|
||||
/// The join helper vanished without reporting (helper thread itself died).
|
||||
HelperLost,
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for a cancelled agent worker to exit.
|
||||
///
|
||||
/// The blocking `join` runs on a helper thread so this stays callable from
|
||||
/// `Drop` — which cannot await — while every caller sits on the async runtime.
|
||||
/// On timeout that helper is abandoned rather than joined; this is safe **only
|
||||
/// because every caller is on its way out of the process**, so the OS reaps the
|
||||
/// thread at exit. Do not reuse this outside teardown.
|
||||
fn join_agent_thread(handle: thread::JoinHandle<Result<()>>, timeout: Duration) -> JoinOutcome {
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let _ = tx.send(handle.join());
|
||||
});
|
||||
|
||||
// Two-phase wait: silent for a short join (overwhelmingly the common case),
|
||||
// then a one-line notice so a slow SessionEnd hook does not look like a
|
||||
// frozen exit. Only for a terminal — piped/JSON consumers stay clean.
|
||||
let quiet = timeout.min(JOIN_NOTICE_AFTER);
|
||||
match rx.recv_timeout(quiet) {
|
||||
Ok(result) => return classify_join(result),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if std::io::stderr().is_terminal() {
|
||||
eprintln!("Finishing session hooks…");
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return JoinOutcome::HelperLost,
|
||||
}
|
||||
match rx.recv_timeout(timeout.saturating_sub(quiet)) {
|
||||
Ok(result) => classify_join(result),
|
||||
Err(RecvTimeoutError::Timeout) => JoinOutcome::TimedOut,
|
||||
Err(RecvTimeoutError::Disconnected) => JoinOutcome::HelperLost,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_join(result: thread::Result<Result<()>>) -> JoinOutcome {
|
||||
match result {
|
||||
Ok(Ok(())) => JoinOutcome::Joined,
|
||||
Ok(Err(e)) => JoinOutcome::Failed(e.to_string()),
|
||||
Err(payload) => JoinOutcome::Panicked(panic_message(payload)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a panic payload as text — `panic!` payloads are `&str` or `String`,
|
||||
/// so the log shows the message instead of an opaque `Any`.
|
||||
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
if let Some(s) = payload.downcast_ref::<&'static str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"non-string panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a GrokShell agent in a background thread.
|
||||
///
|
||||
/// Returns the ACP client channel for communication and a cancellation token.
|
||||
|
|
@ -91,7 +228,7 @@ pub async fn spawn_grok_shell(
|
|||
spawn_agent_thread_direct(spawn_fn, acp_agent, agent_cancel.clone(), skills_paths)?;
|
||||
|
||||
Ok(SpawnedAgent {
|
||||
_thread_handle: handle,
|
||||
thread_handle: handle,
|
||||
channel: acp_client,
|
||||
cancel: agent_cancel,
|
||||
auth_manager: auth_manager_for_pager,
|
||||
|
|
@ -161,9 +298,74 @@ fn spawn_agent_thread_direct(
|
|||
};
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
// Keep running until cancelled
|
||||
// Keep running until cancelled, then flush every live session
|
||||
// actor (SessionEnd hooks + memory save) before the LocalSet /
|
||||
// agent drop. Session actors live on dedicated OS threads and
|
||||
// only exit cleanly on SessionCommand::Shutdown; without this
|
||||
// flush, /exit and headless quit race process death and skip
|
||||
// SessionEnd. Mirrors leader auto-update / relaunch.
|
||||
cancel.cancelled().await;
|
||||
agent_rc.flush_all_sessions(SESSION_FLUSH_GRACE).await;
|
||||
anyhow::Result::Ok(())
|
||||
})
|
||||
})?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn join_reports_clean_worker_exit() {
|
||||
let handle = thread::spawn(|| Ok(()));
|
||||
assert_eq!(
|
||||
join_agent_thread(handle, Duration::from_secs(5)),
|
||||
JoinOutcome::Joined
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_reports_worker_error() {
|
||||
let handle = thread::spawn(|| Err(anyhow::anyhow!("flush failed")));
|
||||
assert_eq!(
|
||||
join_agent_thread(handle, Duration::from_secs(5)),
|
||||
JoinOutcome::Failed("flush failed".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// The timeout branch the built-binary e2e cannot reach: a wedged worker
|
||||
/// (e.g. a hung SessionEnd hook) is abandoned once the budget elapses
|
||||
/// instead of holding the process open indefinitely.
|
||||
#[test]
|
||||
fn join_abandons_wedged_worker_at_budget() {
|
||||
let handle = thread::spawn(|| {
|
||||
thread::sleep(Duration::from_secs(30));
|
||||
Ok(())
|
||||
});
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(
|
||||
join_agent_thread(handle, Duration::from_millis(50)),
|
||||
JoinOutcome::TimedOut
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"join must return at its budget, not wait out the worker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_payloads_render_as_text() {
|
||||
assert_eq!(
|
||||
classify_join(Err(Box::new("boom"))),
|
||||
JoinOutcome::Panicked("boom".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_join(Err(Box::new("boom".to_string()))),
|
||||
JoinOutcome::Panicked("boom".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_join(Err(Box::new(7u32))),
|
||||
JoinOutcome::Panicked("non-string panic payload".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ pub struct AcpUpdateTracker {
|
|||
last_stream_start_ms: Option<i64>,
|
||||
/// Monotonic count of live parent-agent updates that changed scrollback.
|
||||
agent_output_epoch: u64,
|
||||
epoch_at_last_finish: u64,
|
||||
/// Session project cwd for display-only redundant-`cd` stripping.
|
||||
/// Set from [`AgentSession::cwd`]; not used for execution.
|
||||
session_cwd: Option<PathBuf>,
|
||||
|
|
@ -376,9 +377,15 @@ impl AcpUpdateTracker {
|
|||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// Current boundary for visible live parent-agent output.
|
||||
pub(crate) fn agent_output_epoch(&self) -> u64 {
|
||||
self.agent_output_epoch
|
||||
pub(crate) fn output_since_last_finish(&self) -> bool {
|
||||
self.agent_output_epoch != self.epoch_at_last_finish
|
||||
}
|
||||
/// Mark all output so far as accounted for without finishing the turn —
|
||||
/// for terminals that must be skipped while a client command owns the
|
||||
/// screen (a full `finish_turn` would flush mid-command state such as
|
||||
/// `pending_compaction`).
|
||||
pub(crate) fn snapshot_output_epoch(&mut self) {
|
||||
self.epoch_at_last_finish = self.agent_output_epoch;
|
||||
}
|
||||
fn bump_agent_output_epoch(&mut self) {
|
||||
self.agent_output_epoch = self.agent_output_epoch.wrapping_add(1);
|
||||
|
|
@ -827,6 +834,7 @@ impl AcpUpdateTracker {
|
|||
}
|
||||
/// Called when PromptResponse is received (turn complete).
|
||||
pub fn finish_turn(&mut self, scrollback: &mut ScrollbackState) {
|
||||
self.epoch_at_last_finish = self.agent_output_epoch;
|
||||
self.finish_thinking(scrollback);
|
||||
if let Some(agent_id) = self.current_agent_msg.take() {
|
||||
scrollback.finish_running(agent_id);
|
||||
|
|
@ -2782,31 +2790,51 @@ mod tests {
|
|||
let mut sb = ScrollbackState::new();
|
||||
let mut tracker = AcpUpdateTracker::new();
|
||||
assert!(tracker.handle_update(user_message("prompt"), &meta(), &mut sb));
|
||||
assert_eq!(tracker.agent_output_epoch(), 0);
|
||||
assert_eq!(tracker.agent_output_epoch, 0);
|
||||
assert!(tracker.handle_update(agent_chunk("response"), &meta(), &mut sb));
|
||||
assert_eq!(tracker.agent_output_epoch(), 1);
|
||||
assert_eq!(tracker.agent_output_epoch, 1);
|
||||
let replay = NotificationMeta {
|
||||
is_replay: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(tracker.handle_update(agent_chunk(" replay"), &replay, &mut sb));
|
||||
assert_eq!(tracker.agent_output_epoch(), 1);
|
||||
assert_eq!(tracker.agent_output_epoch, 1);
|
||||
assert!(tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb));
|
||||
assert_eq!(tracker.agent_output_epoch(), 2);
|
||||
assert_eq!(tracker.agent_output_epoch, 2);
|
||||
assert!(tracker.handle_update(
|
||||
tool_call("read-1", acp::ToolKind::Read, "read_file"),
|
||||
&meta(),
|
||||
&mut sb,
|
||||
));
|
||||
assert_eq!(tracker.agent_output_epoch(), 3);
|
||||
assert_eq!(tracker.agent_output_epoch, 3);
|
||||
assert!(tracker.handle_update(tool_update_completed("read-1"), &meta(), &mut sb));
|
||||
assert_eq!(tracker.agent_output_epoch(), 4);
|
||||
assert_eq!(tracker.agent_output_epoch, 4);
|
||||
assert!(!tracker.handle_update(
|
||||
tool_call("todo-1", acp::ToolKind::Other, "TodoWrite"),
|
||||
&meta(),
|
||||
&mut sb,
|
||||
));
|
||||
assert_eq!(tracker.agent_output_epoch(), 4);
|
||||
assert_eq!(tracker.agent_output_epoch, 4);
|
||||
}
|
||||
#[test]
|
||||
fn output_since_last_finish_flips_per_turn() {
|
||||
let mut sb = ScrollbackState::new();
|
||||
let mut tracker = AcpUpdateTracker::new();
|
||||
tracker.finish_turn(&mut sb);
|
||||
assert!(
|
||||
!tracker.output_since_last_finish(),
|
||||
"no output right after a finish"
|
||||
);
|
||||
assert!(tracker.handle_update(agent_chunk("wake reply"), &meta(), &mut sb));
|
||||
assert!(
|
||||
tracker.output_since_last_finish(),
|
||||
"an agent message chunk flips the flag"
|
||||
);
|
||||
tracker.finish_turn(&mut sb);
|
||||
assert!(
|
||||
!tracker.output_since_last_finish(),
|
||||
"the next finish snapshots the epoch again"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn streaming_thinking() {
|
||||
|
|
@ -5858,7 +5886,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(sb.len(), 1);
|
||||
assert_eq!(tracker.pending_tools.len(), 1);
|
||||
let output_epoch = tracker.agent_output_epoch();
|
||||
let output_epoch = tracker.agent_output_epoch;
|
||||
let modified = tracker.handle_update(
|
||||
tool_update_in_progress_bg("tc1", b"started"),
|
||||
&meta(),
|
||||
|
|
@ -5869,9 +5897,8 @@ mod tests {
|
|||
"bg tool deferral should suppress further output streaming"
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.agent_output_epoch(),
|
||||
output_epoch,
|
||||
"deferral must not bump the epoch (re-pushes the parked marker)"
|
||||
tracker.agent_output_epoch, output_epoch,
|
||||
"deferral must not bump the epoch (it is not visible agent output)"
|
||||
);
|
||||
assert_eq!(sb.len(), 1, "real execute entry kept for demotion");
|
||||
assert!(
|
||||
|
|
@ -5889,7 +5916,7 @@ mod tests {
|
|||
);
|
||||
}
|
||||
/// Regression: a bg-tool deferral (here dropping the placeholder row) must
|
||||
/// not bump `agent_output_epoch` — bumping re-pushed the parked marker.
|
||||
/// not bump `agent_output_epoch` — it is not visible agent output.
|
||||
#[test]
|
||||
fn bg_tool_deferral_does_not_bump_agent_output_epoch() {
|
||||
let mut sb = ScrollbackState::new();
|
||||
|
|
@ -5900,7 +5927,7 @@ mod tests {
|
|||
&mut sb,
|
||||
);
|
||||
assert_eq!(sb.len(), 1);
|
||||
let epoch = tracker.agent_output_epoch();
|
||||
let epoch = tracker.agent_output_epoch;
|
||||
let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(Arc::from("tc1")),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
|
|
@ -5914,9 +5941,8 @@ mod tests {
|
|||
assert_eq!(sb.len(), 0, "placeholder dropped on deferral");
|
||||
assert!(tracker.bg_deferred_tools.contains_key("tc1"));
|
||||
assert_eq!(
|
||||
tracker.agent_output_epoch(),
|
||||
epoch,
|
||||
"deferral must not bump the epoch (re-pushes the parked marker)"
|
||||
tracker.agent_output_epoch, epoch,
|
||||
"deferral must not bump the epoch (it is not visible agent output)"
|
||||
);
|
||||
}
|
||||
/// Eager kind=Other title=`run_terminal_command` must not flash in the TUI.
|
||||
|
|
|
|||
|
|
@ -229,15 +229,6 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
|
|||
bg.scrollback_entry_id = Some(entry_id);
|
||||
}
|
||||
|
||||
// Ext notifications reorder vs session updates: work registering after
|
||||
// its awaiting wait must re-evaluate the skipped park. Root only — child
|
||||
// tasks never enter root `bg_tasks`.
|
||||
if !matches!(matched, SessionMatch::Child(_))
|
||||
&& let Some((_, _, agent)) = resolve_notif_agent(app, &session_notif.session_id)
|
||||
{
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
|
||||
is_active
|
||||
}
|
||||
|
||||
|
|
@ -608,9 +599,8 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
|
|||
// Prefer the human description for "Task completed/failed: …" labels
|
||||
// (same as "Task started"), falling back to the raw command only when
|
||||
// no description was supplied.
|
||||
let (command, elapsed, mut description, scrollback_entry_id, was_running) =
|
||||
let (command, elapsed, mut description, scrollback_entry_id) =
|
||||
if let Some(bg_task) = session.bg_tasks.get_mut(task_id) {
|
||||
let was_running = bg_task.status == BgTaskStatus::Running;
|
||||
bg_task.status = if success {
|
||||
BgTaskStatus::Done
|
||||
} else {
|
||||
|
|
@ -626,7 +616,6 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
|
|||
bg_task.elapsed(),
|
||||
bg_task.description.clone(),
|
||||
bg_task.scrollback_entry_id,
|
||||
was_running,
|
||||
)
|
||||
} else {
|
||||
// Task we didn't know about — use snapshot data. Prefer
|
||||
|
|
@ -652,9 +641,7 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
|
|||
Some(d)
|
||||
}
|
||||
});
|
||||
// Unknown task: it never counted toward the parked marker's
|
||||
// running total, so its completion is not a countdown edge.
|
||||
(command, elapsed, description, None, false)
|
||||
(command, elapsed, description, None)
|
||||
};
|
||||
|
||||
// Finish the "Task started" scrollback entry (stops bullet animation).
|
||||
|
|
@ -707,14 +694,5 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
|
|||
};
|
||||
scrollback.push_block(block);
|
||||
|
||||
// Re-eval a withheld park; the slot self-dedupes. Root sessions only.
|
||||
// (Re-borrow: `resolve_target_view` consumed the earlier `&mut`.)
|
||||
if was_running
|
||||
&& !matches!(matched, SessionMatch::Child(_))
|
||||
&& let Some(agent) = app.agents.get_mut(&matched.agent_id())
|
||||
{
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
|
||||
is_active
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ use routing::{
|
|||
|
||||
use prompt_origin::{finish_wake_turn, viewer_turn_anchor};
|
||||
pub(crate) use prompt_origin::{
|
||||
is_server_initiated_prompt, is_wake_prompt, should_adopt_running_prompt,
|
||||
is_scheduler_fired_prompt, is_server_initiated_prompt, is_wake_prompt,
|
||||
should_adopt_running_prompt,
|
||||
};
|
||||
|
||||
pub(crate) use subagent_activity::finalize_killed_subagent;
|
||||
|
|
@ -145,13 +146,9 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
|
|||
AcpClientMessage::SessionNotification(notif) => {
|
||||
let mut meta = NotificationMeta::from_json(notif.request.meta.as_ref());
|
||||
|
||||
// Wait-state bookkeeping after the agent borrow ends (parked marker).
|
||||
let mut wait_state_agent: Option<AgentId> = None;
|
||||
|
||||
let affected = match find_session_match(app, ¬if.request.session_id) {
|
||||
Some(SessionMatch::Root(id)) => {
|
||||
let is_active = is_matched_agent_active(app, id);
|
||||
wait_state_agent = Some(id);
|
||||
// Read before the agent borrow below.
|
||||
let stashed_adoption_pid = app
|
||||
.pending_running_adoptions
|
||||
|
|
@ -250,6 +247,7 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
|
|||
}
|
||||
if let Some(ts) = meta.turn_start_ms {
|
||||
agent.turn_start_ms = Some(ts);
|
||||
agent.turn_start_ms_prompt = meta.prompt_id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -565,12 +563,6 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
|
|||
false
|
||||
}
|
||||
};
|
||||
if let Some(aid) = wait_state_agent {
|
||||
// Parked marker (any tab — the update that created the wait state stamps the park time).
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
}
|
||||
notif.response_tx.send(Ok(())).ok();
|
||||
affected
|
||||
}
|
||||
|
|
@ -748,11 +740,6 @@ fn handle_interjection(notif: &acp::ExtNotification, app: &mut AppView) -> bool
|
|||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::interjection_prompt(text));
|
||||
// Interjecting into a parked wait continues the turn below this block —
|
||||
// the withheld "Worked for …" marker must not fire late beneath it
|
||||
// (shared-queue interjects render only via this broadcast, and the shell
|
||||
// emits the queue-emptying `x.ai/queue/changed` right after it).
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
is_active
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -303,11 +303,30 @@ fn build_permission_display(
|
|||
}
|
||||
};
|
||||
|
||||
let description = mcp_args_lines(req);
|
||||
let description = permission_description_lines(req);
|
||||
let bash_cmd = if is_execute { raw_command } else { None };
|
||||
(title, description, bash_cmd)
|
||||
}
|
||||
|
||||
/// Lines shown under the permission title: protected-edit note (if any), then
|
||||
/// MCP planned-argument lines (empty for bash/edit).
|
||||
fn permission_description_lines(req: &acp::RequestPermissionRequest) -> Vec<String> {
|
||||
let mut lines = mcp_args_lines(req);
|
||||
if is_edit_permission(req)
|
||||
&& let Some(desc) = protected_edit_description(req)
|
||||
{
|
||||
lines.insert(0, desc);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn protected_edit_description(req: &acp::RequestPermissionRequest) -> Option<String> {
|
||||
let meta = req.meta.as_ref()?;
|
||||
let protected: xai_grok_workspace::permission::ProtectedEditPermission =
|
||||
serde_json::from_value(serde_json::Value::Object(meta.clone())).ok()?;
|
||||
protected.description.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Maximum stored lines for the MCP planned-arguments display. The overlay
|
||||
/// clips further (options always stay visible); this only bounds memory for
|
||||
/// pathologically large inputs.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ pub(crate) fn is_scheduler_fired_prompt(prompt_id: &str) -> bool {
|
|||
/// `subagent-completed-…`, `notifications-…`). These run non-adopted — no
|
||||
/// `PromptResponse`, no viewer finalize — so their durable `TurnCompleted` is
|
||||
/// the only signal marking the back-to-idle point (see [`finish_wake_turn`];
|
||||
/// wake turns close markerless). Deliberately narrower than "non-adopted
|
||||
/// a chatty wake closes with a marker, a silent one stays markerless).
|
||||
/// Deliberately narrower than "non-adopted
|
||||
/// synthetic": goal turns render through the goal chip/loop chrome and
|
||||
/// `plan-resume-…` keeps its own markerless shape.
|
||||
pub(crate) fn is_wake_prompt(prompt_id: &str) -> bool {
|
||||
|
|
@ -86,10 +87,64 @@ pub(super) fn viewer_turn_anchor(turn_start_ms: Option<i64>) -> std::time::Insta
|
|||
.unwrap_or(now)
|
||||
}
|
||||
|
||||
/// Close out a wake turn: markerless, but the stream must be finished here —
|
||||
/// wake turns skip `PromptResponse`, so this is the only flush site for an
|
||||
/// in-flight streamed entry (dead wakes included). Leaves a real turn's
|
||||
/// stop-hook stash pending for its own marker rail.
|
||||
pub(super) fn finish_wake_turn(agent: &mut AgentView) {
|
||||
/// Close out a wake turn — the only flush site for its in-flight streamed
|
||||
/// entries (wake turns skip `PromptResponse`). Markers: visible output closes
|
||||
/// with one; silence closes with none — except failures, which surface even
|
||||
/// when silent (the user's standing instruction stopped executing invisibly).
|
||||
/// Silent rate limits defer to the retry notifications, like the real-turn
|
||||
/// rails.
|
||||
pub(super) fn finish_wake_turn(
|
||||
agent: &mut AgentView,
|
||||
prompt_id: &str,
|
||||
stop_reason: &str,
|
||||
agent_result: Option<&str>,
|
||||
) {
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
let had_output = agent.session.tracker.output_since_last_finish();
|
||||
agent.session.tracker.finish_turn(&mut agent.scrollback);
|
||||
// The stored `turn_start_ms` may belong to an earlier turn (a silent wake
|
||||
// streamed no deltas of its own; interleaved deltas can re-stamp it) —
|
||||
// claim an elapsed only when the anchor is provably this wake's.
|
||||
let anchor_is_ours = agent.turn_start_ms_prompt.as_deref() == Some(prompt_id);
|
||||
let elapsed = if had_output && anchor_is_ours {
|
||||
agent.turn_start_ms.and_then(|start_ms| {
|
||||
let ms = chrono::Utc::now()
|
||||
.timestamp_millis()
|
||||
.saturating_sub(start_ms);
|
||||
(ms >= 0).then(|| std::time::Duration::from_millis(ms as u64))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let already_failed = agent.failed_wake_marker_for.as_deref() == Some(prompt_id);
|
||||
let event = match stop_reason {
|
||||
"error" | "rate_limit"
|
||||
if already_failed || (stop_reason == "rate_limit" && !had_output) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
"error" | "rate_limit" => {
|
||||
agent.failed_wake_marker_for = Some(prompt_id.to_string());
|
||||
Some(SessionEvent::TurnFailed {
|
||||
error: agent_result.map(str::to_string).unwrap_or_else(|| {
|
||||
if stop_reason == "error" {
|
||||
"unknown error".to_string()
|
||||
} else {
|
||||
"rate limited".to_string()
|
||||
}
|
||||
}),
|
||||
elapsed,
|
||||
})
|
||||
}
|
||||
"cancelled" if !had_output => None,
|
||||
"cancelled" => Some(SessionEvent::TurnCancelled {
|
||||
elapsed: elapsed.unwrap_or_default(),
|
||||
}),
|
||||
_ if !had_output => None,
|
||||
_ => Some(SessionEvent::TurnCompleted { elapsed }),
|
||||
};
|
||||
if event.is_some() {
|
||||
crate::app::turn_completion::push_turn_terminal_marker(agent, event, Some(prompt_id));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,11 +220,6 @@ pub(super) fn handle_queue_changed(notif: &acp::ExtNotification, app: &mut AppVi
|
|||
new_text: None,
|
||||
});
|
||||
}
|
||||
// A queue change can empty the visible queue mid-wait — the marker
|
||||
// may become eligible now (see `maybe_push_parked_marker`).
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
}
|
||||
|
||||
// Adoption / turn-start correlation.
|
||||
|
|
|
|||
|
|
@ -223,9 +223,40 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||
false
|
||||
} else if is_wake_prompt(&prompt_id) {
|
||||
if agent.session.state.is_busy() {
|
||||
if agent.session.state.command_in_flight().is_some() {
|
||||
agent.session.tracker.snapshot_output_epoch();
|
||||
}
|
||||
let errored = matches!(stop_reason.as_str(), "error" | "rate_limit");
|
||||
if errored && agent.failed_wake_marker_for.as_deref() != Some(&*prompt_id) {
|
||||
agent.failed_wake_marker_for = Some(prompt_id.clone());
|
||||
agent.push_end_marker_block(
|
||||
crate::scrollback::blocks::SessionEvent::TurnFailed {
|
||||
error: agent_result
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown error".to_string()),
|
||||
elapsed: None,
|
||||
},
|
||||
Vec::new(),
|
||||
Some(prompt_id.clone()),
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
finish_wake_turn(agent, &prompt_id, &stop_reason, agent_result.as_deref());
|
||||
true
|
||||
}
|
||||
} else if is_server_initiated_prompt(&prompt_id)
|
||||
&& !is_scheduler_fired_prompt(&prompt_id)
|
||||
{
|
||||
if agent.session.state.is_busy() {
|
||||
if agent.session.state.command_in_flight().is_some() {
|
||||
agent.session.tracker.snapshot_output_epoch();
|
||||
}
|
||||
false
|
||||
} else {
|
||||
finish_wake_turn(agent);
|
||||
agent.session.tracker.finish_turn(&mut agent.scrollback);
|
||||
true
|
||||
}
|
||||
} else {
|
||||
|
|
@ -455,7 +486,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||
info.scrollback_entry_id = Some(entry_id);
|
||||
info.is_background = is_background;
|
||||
}
|
||||
agent.maybe_push_parked_marker();
|
||||
} else if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {
|
||||
info.is_background = is_background;
|
||||
}
|
||||
|
|
@ -596,9 +626,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||
crate::app::subagent::finalize_finished_child_view(child_view, elapsed_dur);
|
||||
}
|
||||
}
|
||||
if !resuming {
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
true
|
||||
}
|
||||
XaiSessionUpdate::HookAnnotation { message } => {
|
||||
|
|
|
|||
|
|
@ -1,53 +1,9 @@
|
|||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
/// Regression: a shared-queue interjection renders only via the broadcast,
|
||||
/// and the shell emits the queue-emptying `x.ai/queue/changed` right after
|
||||
/// it — which used to fire the withheld parked marker BELOW the just-
|
||||
/// rendered user message ("Worked for …" under the follow-up, flipped
|
||||
/// transcript order). The broadcast must consume the marker slot instead.
|
||||
#[test]
|
||||
fn interjection_broadcast_mid_park_forgoes_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_task_output_wait(agent, "bg-1");
|
||||
assert!(agent.is_parked_on_sendable_wait());
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "queued follow-up"),
|
||||
&mut app,
|
||||
));
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.parked_wait_marker_for,
|
||||
Some(crate::app::agent_view::ParkedMarkerSlot::Forgone(
|
||||
"p1".into()
|
||||
)),
|
||||
"broadcast render must consume the parked-marker slot as Forgone"
|
||||
);
|
||||
// The queue-changed following the broadcast must not fire it late.
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
0,
|
||||
"no late 'Worked for …' marker under the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a Forgone slot (interjection continued
|
||||
/// the parked turn, no marker on screen) must silence later marker pushes
|
||||
/// — a full "Worked for …" line under the interjected message would
|
||||
/// recreate the flipped transcript.
|
||||
#[test]
|
||||
fn forgone_slot_suppresses_later_marker_pushes() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
fn interjection_broadcast_mid_park_adds_no_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
|
|
@ -57,32 +13,44 @@
|
|||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
// The parked drain interjected a queued row before the marker
|
||||
// became eligible: slot consumed WITHOUT a marker.
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
assert!(agent.renders_parked(), "forgone slot keeps parked chrome");
|
||||
assert_eq!(count_parked(agent), 0, "no marker on screen");
|
||||
assert!(agent.is_parked_on_sendable_wait());
|
||||
assert_eq!(count_turn_markers(agent), 0, "the park writes no row");
|
||||
}
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "queued follow-up"),
|
||||
&mut app,
|
||||
));
|
||||
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
last_interjection_text(&agent.scrollback).as_deref(),
|
||||
Some("queued follow-up"),
|
||||
);
|
||||
assert_eq!(
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"no 'Worked for …' marker around the interjection"
|
||||
);
|
||||
}
|
||||
|
||||
// A task completing in the still-parked window must stay silent.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"no 'Worked for …' tick under the interjection"
|
||||
);
|
||||
assert!(agent.renders_parked(), "the parked chrome stays on");
|
||||
}
|
||||
|
||||
/// "sleep 10, 15, 20 in the background": completions within one park
|
||||
/// episode push chips only — the marker never re-pushes. (Elapsed
|
||||
/// renders as "0.0s": `turn_started_at` is unset in this fixture.)
|
||||
#[test]
|
||||
fn parked_completions_push_chips_without_marker_repush() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
fn parked_completions_push_chips_without_markers() {
|
||||
use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
|
|
@ -93,12 +61,9 @@
|
|||
insert_running_task(agent, "t15", "sleep 15");
|
||||
insert_running_task(agent, "t20", "sleep 20");
|
||||
simulate_task_output_wait(agent, "t20");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
|
||||
// Each completion lands as a chip; no marker re-push, no "N commands
|
||||
// still running." lines.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
|
|
@ -119,9 +84,9 @@
|
|||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
"one plain marker per park episode — completions never re-push"
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"completions during a park never write a marker"
|
||||
);
|
||||
assert!(
|
||||
work_status_lines(&agent.scrollback).is_empty(),
|
||||
|
|
@ -129,58 +94,16 @@
|
|||
);
|
||||
}
|
||||
|
||||
/// Parity with the bg-command completion rail: a park withheld at park
|
||||
/// time (held queue) gets re-evaluated by a subagent completion once the
|
||||
/// blocker cleared, so the boundary marker isn't deferred to whenever the
|
||||
/// next unrelated notification happens to arrive.
|
||||
#[test]
|
||||
fn subagent_finish_reevaluates_withheld_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
fn consecutive_subagent_finishes_stay_markerless() {
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
simulate_wait_all(agent);
|
||||
// Held queue at park time: the marker is withheld.
|
||||
agent.session.enqueue_prompt("queued follow-up".into());
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "held queue withholds the marker");
|
||||
// The queue drains; nothing has re-evaluated the marker yet.
|
||||
agent.session.pending_prompts.clear();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"]);
|
||||
}
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
1,
|
||||
"the completion re-evaluates the withheld park"
|
||||
);
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_subagent_finishes_leave_single_parked_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
};
|
||||
|
||||
for child in ["child-1", "child-1", "child-2", "child-3"] {
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished(child)),
|
||||
|
|
@ -189,489 +112,41 @@
|
|||
}
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
"subagent finishes never re-push or mutate the park marker"
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"subagent finishes never write a marker mid-park"
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
/// A re-park after new parent output (text / thought / tool) is a new
|
||||
/// park episode: the wait-state update that creates the second wait
|
||||
/// pushes a fresh marker (epoch mismatch), while completions within one
|
||||
/// episode never re-push.
|
||||
#[test]
|
||||
fn parent_text_thought_and_tool_output_start_new_park_episodes() {
|
||||
fn repark_after_parent_output_stays_markerless() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call;
|
||||
|
||||
crate::appearance::cache::set_show_thinking_blocks(true);
|
||||
for output_kind in ["text", "thought", "tool"] {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let first_marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2", "child-3"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
if output_kind == "tool" {
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
"read_file",
|
||||
)
|
||||
.kind(acp::ToolKind::Read)
|
||||
.status(acp::ToolCallStatus::InProgress)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
}
|
||||
simulate_task_output_wait_call(agent, "wait-1", "not-ours", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
// Same episode: a repeated push attempt (e.g. another wait
|
||||
// update restating the same wait) is deduped by epoch.
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(parked_marker_ids(agent).len(), 1);
|
||||
|
||||
let output = match output_kind {
|
||||
"text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent text")),
|
||||
)),
|
||||
"thought" => acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("parent thought")),
|
||||
)),
|
||||
"tool" => acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.status(Some(acp::ToolCallStatus::Completed)),
|
||||
)),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
output,
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000);
|
||||
// The wait-state notification path re-evaluates the marker on
|
||||
// every wait update (`maybe_push_parked_marker` from the ACP
|
||||
// handler); mirror it for the fixture-driven second wait.
|
||||
agent.maybe_push_parked_marker();
|
||||
}
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string(), "Worked for 0.0s".to_string()],
|
||||
"{output_kind} output must start a new park episode",
|
||||
);
|
||||
let marker_ids = parked_marker_ids(agent);
|
||||
assert_eq!(marker_ids.len(), 2);
|
||||
assert_eq!(marker_ids[0], first_marker_id);
|
||||
assert_ne!(marker_ids[0], marker_ids[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_suppresses_later_marker_push() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_turn_markers, simulate_task_output_wait_call,
|
||||
};
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
assert!(handle_ext_notification(
|
||||
&interjection_broadcast("sess-park", "continue differently"),
|
||||
&mut app,
|
||||
));
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_subagent_finish_does_not_touch_marker() {
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
|
||||
agent.session.loading_replay = true;
|
||||
marker_id
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_subagent_wait_keeps_single_marker() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let marker_id = {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
for child_id in ["child-1", "child-2"] {
|
||||
agent
|
||||
.subagent_sessions
|
||||
.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
simulate_task_output_wait(agent, "child-1");
|
||||
agent.maybe_push_parked_marker();
|
||||
parked_marker_ids(agent)[0]
|
||||
};
|
||||
|
||||
handle(
|
||||
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
|
||||
}
|
||||
|
||||
/// Synthetic completions from cold-load reconciliation (`session_restart`
|
||||
/// signal) finalize quietly — no countdown line, mirroring the suppressed
|
||||
/// "Task failed" block: nothing happened in THIS session.
|
||||
#[test]
|
||||
fn stale_on_load_completion_pushes_no_countdown() {
|
||||
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
simulate_task_output_wait(agent, "t15");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif_with_signal(
|
||||
"sess-park",
|
||||
"t10",
|
||||
"sleep 10",
|
||||
None,
|
||||
Some("session_restart"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
// Only the initial parked marker — no countdown re-push.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(parked_marker_messages(agent).len(), 1);
|
||||
}
|
||||
|
||||
/// Task completions with no parked look (running turn chrome is up, or
|
||||
/// the turn already ended) must not emit countdown lines — the Tasks
|
||||
/// pane and completion blocks already narrate those states.
|
||||
#[test]
|
||||
fn task_completion_without_parked_look_pushes_no_countdown() {
|
||||
let mut app = make_app_with_agent("sess-live");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
insert_running_task(agent, "t15", "sleep 15");
|
||||
// No wait, no parked marker: chrome is the live turn.
|
||||
}
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-live", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert!(parked_marker_messages(agent).is_empty());
|
||||
}
|
||||
|
||||
// -- imminent waits do not park (awaited work already finished) ----------
|
||||
|
||||
/// Waiting on a task that already completed: no marker, slot stays free.
|
||||
#[test]
|
||||
fn wait_on_already_completed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "imminent wait must not park");
|
||||
assert!(
|
||||
agent.parked_wait_marker_for.is_none(),
|
||||
"slot must stay free for a later genuine park"
|
||||
);
|
||||
assert!(!agent.renders_parked());
|
||||
}
|
||||
|
||||
/// A skipped wait leaves the slot free: a later wait on running work in
|
||||
/// the same turn still parks.
|
||||
#[test]
|
||||
fn later_genuine_wait_still_parks_after_imminent_wait_skip() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "done", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0);
|
||||
simulate_task_output_wait_call(agent, "wait-1", "t10", 30_000);
|
||||
assert!(agent.renders_parked());
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
insert_running_task(agent, "live", "sleep 99");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "live", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "genuine park still renders");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed` is terminal for imminence, not just `Done`.
|
||||
#[test]
|
||||
fn wait_on_failed_task_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Failed;
|
||||
|
||||
simulate_task_output_wait(agent, "t10");
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "failed task wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// Finished-subagent waits do not park — resolved by subagent id, then by
|
||||
/// child session id.
|
||||
#[test]
|
||||
fn wait_on_finished_subagent_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{
|
||||
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
let mut info = make_subagent_info("child-1");
|
||||
info.finished = true;
|
||||
agent.subagent_sessions.insert("child-1".into(), info);
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-1", "sa-child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "finished subagent wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
simulate_task_output_wait_call(agent, "wait-2", "child-1", 30_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "child-session-id wait must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
/// One unresolvable id among terminal ones keeps the park.
|
||||
#[test]
|
||||
fn wait_including_unknown_id_still_parks() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use crate::app::agent_view::test_fixtures::count_parked;
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "done", "sleep 1");
|
||||
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
|
||||
|
||||
let meta = NotificationMeta::default();
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
"get_command_or_subagent_output",
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.content(vec![])
|
||||
.locations(vec![]),
|
||||
),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
|
||||
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({
|
||||
"task_ids": ["done", "not-ours"],
|
||||
"timeout_ms": 30_000,
|
||||
}))),
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new("between-parks content")),
|
||||
)),
|
||||
&meta,
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
agent.maybe_push_parked_marker();
|
||||
));
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "unresolvable id keeps the park");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_zero_work_pushes_no_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all must not park");
|
||||
assert!(agent.parked_wait_marker_for.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_all_with_running_work_still_parks() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
insert_running_task(agent, "t10", "sleep 10");
|
||||
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
|
||||
assert_eq!(count_parked(agent), 1, "wait-all on live work parks");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `SubagentSpawned` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn subagent_spawn_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle(
|
||||
make_ext_session_notification(
|
||||
"sess-park",
|
||||
test_subagent_spawned("sess-park", "child-1"),
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park");
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
/// `x.ai/task_backgrounded` arriving after the skipped zero-work wait
|
||||
/// re-evaluates and restores the park.
|
||||
#[test]
|
||||
fn task_backgrounded_after_zero_work_wait_all_restores_park() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
|
||||
|
||||
let mut app = make_app_with_agent("sess-park");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
|
||||
}
|
||||
|
||||
handle_ext_notification(
|
||||
&make_task_backgrounded_notif("sess-park", "tc-late", "t-late", "sleep 99"),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
1,
|
||||
"task registration re-evaluates the skipped park"
|
||||
);
|
||||
assert_eq!(
|
||||
parked_marker_messages(agent),
|
||||
vec!["Worked for 0.0s".to_string()],
|
||||
);
|
||||
simulate_task_output_wait_call(agent, "wait-2", "t10", 30_000);
|
||||
assert!(agent.renders_parked(), "the re-park renders parked again");
|
||||
assert_eq!(count_turn_markers(agent), 0, "and still writes no marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -770,4 +245,3 @@
|
|||
"an interjection from another pane must render"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -239,26 +239,7 @@ pub(super) fn insert_running_task(agent: &mut AgentView, task_id: &str, command:
|
|||
},
|
||||
);
|
||||
}
|
||||
/// Marker texts of all parked blocks in scrollback, in order — one per
|
||||
/// park episode (re-pushed only after new parent output, i.e. a re-park).
|
||||
pub(super) fn parked_marker_messages(agent: &AgentView) -> Vec<String> {
|
||||
(0..agent.scrollback.len())
|
||||
.filter_map(|i| match agent.scrollback.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) if b.parked => Some(b.event.message()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn parked_marker_ids(agent: &AgentView) -> Vec<EntryId> {
|
||||
(0..agent.scrollback.len())
|
||||
.filter_map(|i| {
|
||||
let entry = agent.scrollback.get(i)?;
|
||||
matches!(&entry.block, RenderBlock::SessionEvent(b) if b.parked)
|
||||
.then_some(entry.id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) -> EntryId {
|
||||
pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) {
|
||||
use crate::app::agent_view::test_fixtures::simulate_wait_all;
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.session.current_prompt_id = Some("p1".into());
|
||||
|
|
@ -266,9 +247,7 @@ pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) -> En
|
|||
agent.subagent_sessions.insert(child_id.into(), make_subagent_info(child_id));
|
||||
}
|
||||
simulate_wait_all(agent);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked());
|
||||
parked_marker_ids(agent)[0]
|
||||
}
|
||||
pub(super) fn follow_ups_ext(
|
||||
response_id: &str,
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn wake_turn_completed_is_markerless() {
|
||||
fn silent_wake_turn_completed_is_markerless() {
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
seed_two_bg_tasks(&mut app, "sess-wake");
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
|
@ -254,7 +254,7 @@
|
|||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
len_before,
|
||||
"a completed wake turn pushes no marker"
|
||||
"a silent wake turn pushes no marker"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.watchers().commands,
|
||||
|
|
@ -263,12 +263,65 @@
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_wake_turn_completed_pushes_one_marker() {
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(count_turn_markers(&app.agents[&AgentId(0)]), 0);
|
||||
|
||||
let affected = handle_ext_notification(
|
||||
&xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None),
|
||||
&mut app,
|
||||
);
|
||||
assert!(affected);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
count_turn_markers(agent),
|
||||
1,
|
||||
"a chatty wake closes with exactly one marker"
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCompleted { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_wake_terminal_pushes_no_second_marker() {
|
||||
// `finish_wake_turn` snapshots the output epoch, so a duplicate sees no new output.
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
let _ = handle_ext_notification(
|
||||
&xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(count_turn_markers(&app.agents[&AgentId(0)]), 1);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(
|
||||
count_turn_markers(&app.agents[&AgentId(0)]),
|
||||
1,
|
||||
"a duplicate wake terminal must not push a second marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_terminal_finishes_in_flight_streamed_entry() {
|
||||
// A wake turn streams its response, then its terminal lands: the
|
||||
// terminal is the ONLY flush site (wake turns skip PromptResponse),
|
||||
// so the streamed entry must be finished — not left spinning until
|
||||
// the next turn's stream start. Dead wakes take the same path.
|
||||
// The terminal is a wake's ONLY flush site (wakes skip PromptResponse).
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
|
|
@ -291,9 +344,7 @@
|
|||
|
||||
#[test]
|
||||
fn wake_turn_completed_in_replay_only_records_pid() {
|
||||
// The replay arm is untouched: a wake pid seen during a load's replay
|
||||
// records adoption state and pushes nothing (markers are client-local
|
||||
// and never replayed).
|
||||
// Markers are client-local and never replayed.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
|
|
@ -324,9 +375,7 @@
|
|||
|
||||
#[test]
|
||||
fn scheduler_fired_turn_completed_keeps_adopted_path() {
|
||||
// `/loop` turns are synthetic but CLIENT-driven with a real finalize
|
||||
// path — they must not take the wake-marker shortcut. Idle driver +
|
||||
// scheduler pid → the shared finalize ignores it, no marker.
|
||||
// `/loop` turns are client-driven with a real finalize path — never the wake shortcut.
|
||||
let mut app = make_app_with_agent("sess-cron");
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
|
|
@ -344,14 +393,209 @@
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn failed_wake_turn_keeps_markerless_shape() {
|
||||
// "Worked for" would lie about an errored/cancelled wake turn, and
|
||||
// the cancel/failure UX is driver-side context this signal lacks —
|
||||
// those stop reasons keep today's markerless shape.
|
||||
fn silent_errored_wake_pushes_failure_marker() {
|
||||
// Failures surface even when invisible: the standing instruction silently stopped.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
for stop_reason in ["error", "cancelled", "rate_limit"] {
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), len_before + 1);
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_errored_wake_ignores_stale_turn_start_ms() {
|
||||
// A silent wake streamed no deltas, so the stored `turn_start_ms` is an earlier turn's.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().turn_start_ms =
|
||||
Some(chrono::Utc::now().timestamp_millis() - 600_000);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { elapsed: None, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_terminal_snapshots_epoch_so_next_silent_wake_stays_markerless() {
|
||||
// A dirty output epoch made the NEXT silent wake inherit the goal turn's output.
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "goal-summary-g1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "goal-summary-g1", "end_turn", false),
|
||||
&mut app,
|
||||
);
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "end_turn", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
len_before,
|
||||
"a silent wake after a goal turn must not inherit its output"
|
||||
);
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errored_wake_terminal_during_local_turn_still_pushes_failure() {
|
||||
// Failure visibility survives the busy skip: no tracker finish, no
|
||||
// elapsed (the anchor is the local turn's), but the row must land.
|
||||
use crate::app::agent::AgentState;
|
||||
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning;
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
for _ in 0..2 {
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
|
||||
&mut app,
|
||||
);
|
||||
}
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.scrollback.len(), len_before + 1, "one row, deduped");
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { elapsed: None, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_terminal_during_command_snapshots_epoch_for_next_silent_wake() {
|
||||
// A client command (e.g. /compact) skips the wake finish but must not
|
||||
// leave the epoch dirty: the next silent wake would claim the skipped
|
||||
// wake's output.
|
||||
use crate::app::agent::{AgentCommand, AgentState};
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::CommandRunning {
|
||||
command: AgentCommand::Compact,
|
||||
started_at: std::time::Instant::now(),
|
||||
};
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "end_turn", false),
|
||||
&mut app,
|
||||
);
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::Idle;
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg2", "end_turn", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
len_before,
|
||||
"silent wake after a command-skipped terminal must stay markerless"
|
||||
);
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_wake_with_foreign_turn_start_anchor_omits_elapsed() {
|
||||
// `turn_start_ms` stamped by another prompt's deltas must not become
|
||||
// this wake's elapsed.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 600_000),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg2", "end_turn", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCompleted { elapsed: None })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_errored_wake_after_goal_turn_has_no_elapsed() {
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "goal-summary-g1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "goal-summary-g1", "end_turn", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { elapsed: None, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_errored_wake_terminal_pushes_one_failure_marker() {
|
||||
// Failures bypass the output-epoch dedupe, so duplicates are deduped by prompt id.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
for _ in 0..2 {
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
|
||||
&mut app,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.len(),
|
||||
len_before + 1,
|
||||
"one failure marker for the wake, duplicates dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_cancelled_or_rate_limited_wake_stays_markerless() {
|
||||
// Rate limits ride the retry notifications instead, matching the real-turn rails.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let len_before = app.agents[&AgentId(0)].scrollback.len();
|
||||
|
||||
for stop_reason in ["cancelled", "rate_limit"] {
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", stop_reason, false),
|
||||
&mut app,
|
||||
|
|
@ -361,10 +605,50 @@
|
|||
assert_eq!(
|
||||
app.agents[&AgentId(0)].scrollback.len(),
|
||||
len_before,
|
||||
"non-completion wake terminals push nothing"
|
||||
"cancelled/rate-limited silent wake terminals push nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_rate_limited_wake_closes_with_failure_marker() {
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "rate_limit", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatty_errored_wake_pushes_failure_marker_not_worked_for() {
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
let _ = handle(
|
||||
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_wake_pushes_no_status_line() {
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
|
|
@ -395,11 +679,8 @@
|
|||
|
||||
#[test]
|
||||
fn wake_terminal_during_local_turn_pushes_nothing() {
|
||||
// Wire interleave: wake turn W streams (pager idle), the user sends a
|
||||
// prompt locally (TurnRunning), then FIFO delivers W's terminal
|
||||
// before the new turn's deltas. A foreign "Worked for" under the
|
||||
// fresh prompt would misattribute — the local turn pushes its own
|
||||
// marker when it ends.
|
||||
// FIFO can deliver a wake's terminal after a fresh local prompt starts; a
|
||||
// foreign "Worked for" under that prompt would misattribute.
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
seed_two_bg_tasks(&mut app, "sess-wake");
|
||||
{
|
||||
|
|
@ -429,10 +710,6 @@
|
|||
|
||||
#[test]
|
||||
fn wake_terminal_leaves_real_turn_stash_pending() {
|
||||
// Stop-hook stash semantics belong to real turns: a stash stamped
|
||||
// with a REAL turn's pid must survive a wake turn's (markerless)
|
||||
// terminal untouched — no fold, no standalone flush — and wait for
|
||||
// its own marker rail.
|
||||
use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
|
||||
let mut app = make_app_with_agent("sess-wake");
|
||||
{
|
||||
|
|
@ -900,9 +1177,7 @@
|
|||
|
||||
#[test]
|
||||
fn will_wake_flag_is_ignored_wire_compat_pin() {
|
||||
// `will_wake` is a wire-compat field the TUI no longer reads: a
|
||||
// stamped completion must behave exactly like an unstamped one
|
||||
// (chip-only). Pins the "ignored, not load-bearing" contract.
|
||||
// `will_wake` is a wire-compat field the TUI no longer reads.
|
||||
let mut app = make_app_with_agent("sess-wake-skip");
|
||||
seed_two_bg_tasks(&mut app, "sess-wake-skip");
|
||||
|
||||
|
|
@ -1096,10 +1371,7 @@
|
|||
|
||||
#[test]
|
||||
fn wake_stop_hooks_render_standalone_at_arrival() {
|
||||
// Wake turns close markerless, so a wake-pid stop batch has no marker
|
||||
// to fold into — it renders standalone the moment it arrives, whether
|
||||
// it beats or trails its wake TurnCompleted. Never stashed: a stash
|
||||
// keyed to a wake pid would wait for a marker that never comes.
|
||||
// Never stashed: a stash keyed to a wake pid could wait for a marker that never comes.
|
||||
let mut app = make_app_with_agent("sess-wake-idle");
|
||||
|
||||
// Hook beats the wake terminal.
|
||||
|
|
@ -1141,9 +1413,6 @@
|
|||
|
||||
#[test]
|
||||
fn wake_stop_hooks_never_stash_under_local_turn() {
|
||||
// A wake batch landing while a LOCAL turn runs must not stash under
|
||||
// (or fold onto) the unrelated local turn — it renders standalone,
|
||||
// and the local turn's marker rail stays clean.
|
||||
let mut app = make_app_with_agent("sess-wake-local");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ impl AgentView {
|
|||
}
|
||||
}
|
||||
qv.focus = QuestionFocus::Navigation;
|
||||
self.last_prompt_click_ms = None;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
|
@ -276,6 +277,7 @@ impl AgentView {
|
|||
}
|
||||
if key!('c', CONTROL).matches(key) {
|
||||
qv.focus = QuestionFocus::Navigation;
|
||||
self.last_prompt_click_ms = None;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
match self.prompt.route_enter(key) {
|
||||
|
|
@ -306,6 +308,7 @@ impl AgentView {
|
|||
}
|
||||
}
|
||||
qv.focus = QuestionFocus::Navigation;
|
||||
self.last_prompt_click_ms = None;
|
||||
let last = qv.questions.len().saturating_sub(1);
|
||||
if qv.active_tab < last {
|
||||
self.swap_question_freeform();
|
||||
|
|
@ -349,7 +352,7 @@ impl AgentView {
|
|||
&& matches!(key.code, KeyCode::Char(c) if c != ' ')
|
||||
{
|
||||
let text = qv.activate_freeform_input();
|
||||
self.prompt.set_text(&text);
|
||||
self.prompt.set_text_preserving(&text);
|
||||
let _ = self.prompt.handle_key(key);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
|
@ -407,7 +410,7 @@ impl AgentView {
|
|||
KeyCode::Char(' ') => {
|
||||
if qv.is_on_freeform_row() {
|
||||
let text = qv.activate_freeform_input();
|
||||
self.prompt.set_text(&text);
|
||||
self.prompt.set_text_preserving(&text);
|
||||
} else {
|
||||
let active = qv.active_tab;
|
||||
let cursor = qv.cursor();
|
||||
|
|
@ -427,7 +430,7 @@ impl AgentView {
|
|||
KeyCode::Enter => {
|
||||
if qv.is_on_freeform_row() {
|
||||
let text = qv.activate_freeform_input();
|
||||
self.prompt.set_text(&text);
|
||||
self.prompt.set_text_preserving(&text);
|
||||
} else {
|
||||
let cursor = qv.cursor();
|
||||
let active = qv.active_tab;
|
||||
|
|
@ -448,7 +451,7 @@ impl AgentView {
|
|||
let freeform_idx = qv.total_items(qv.active_tab).saturating_sub(1);
|
||||
qv.set_cursor(freeform_idx);
|
||||
let text = qv.activate_freeform_input();
|
||||
self.prompt.set_text(&text);
|
||||
self.prompt.set_text_preserving(&text);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Char(']') | KeyCode::Right
|
||||
|
|
@ -590,6 +593,7 @@ impl AgentView {
|
|||
*sel = None;
|
||||
}
|
||||
qv.focus = crate::views::question_view::QuestionFocus::Navigation;
|
||||
self.last_prompt_click_ms = None;
|
||||
}
|
||||
let key_event = KeyEvent::new(
|
||||
if key_ch == '\n' {
|
||||
|
|
@ -669,6 +673,11 @@ impl AgentView {
|
|||
.contains((mouse.column, mouse.row).into())
|
||||
{
|
||||
let _ = self.prompt.handle_mouse(mouse);
|
||||
if self.prompt_click_is_double()
|
||||
&& self.prompt.expand_paste_element_at_cursor()
|
||||
{
|
||||
self.prompt.refresh_slash(&self.session.models);
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let idx = qv.active_tab;
|
||||
|
|
@ -687,6 +696,7 @@ impl AgentView {
|
|||
*sel = None;
|
||||
}
|
||||
qv.focus = crate::views::question_view::QuestionFocus::Navigation;
|
||||
self.last_prompt_click_ms = None;
|
||||
}
|
||||
let prompt_area = self.pane_areas.prompt;
|
||||
let footer_h = 3u16;
|
||||
|
|
@ -729,7 +739,7 @@ impl AgentView {
|
|||
.get(active_tab)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
self.prompt.set_text(&text);
|
||||
self.prompt.set_text_preserving(&text);
|
||||
qv.focus = crate::views::question_view::QuestionFocus::InputMode;
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
|
|
@ -812,7 +822,7 @@ impl AgentView {
|
|||
.get(tab)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
self.prompt.set_text(&text);
|
||||
self.prompt.set_text_preserving(&text);
|
||||
qv.focus = QuestionFocus::InputMode;
|
||||
}
|
||||
}
|
||||
|
|
@ -1028,7 +1038,7 @@ impl AgentView {
|
|||
.get(qv.active_tab)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
self.prompt.set_text(new_text);
|
||||
self.prompt.set_text_preserving(new_text);
|
||||
}
|
||||
/// Dismiss (hide) the question view without submitting answers.
|
||||
///
|
||||
|
|
@ -1195,6 +1205,7 @@ impl AgentView {
|
|||
self.hit_question_scrollbar.clear();
|
||||
self.inline_prompt_area = None;
|
||||
self.last_question_click = None;
|
||||
self.last_prompt_click_ms = None;
|
||||
}
|
||||
/// Answer the ACTIVE question of this agent's pending
|
||||
/// `AskUserQuestion` from the dashboard peek panel.
|
||||
|
|
@ -1684,7 +1695,7 @@ mod question_no_freeform_tests {
|
|||
id: None,
|
||||
}
|
||||
}
|
||||
fn open_question(agent: &mut AgentView, no_freeform: bool) {
|
||||
pub(super) fn open_question(agent: &mut AgentView, no_freeform: bool) {
|
||||
let state = QuestionViewState::new(
|
||||
"tc-upsell".into(),
|
||||
vec![upsell_question()],
|
||||
|
|
@ -1698,7 +1709,7 @@ mod question_no_freeform_tests {
|
|||
}
|
||||
/// Draw one 80x30 frame so `pane_areas` and `question_scroll_region`
|
||||
/// hold the real rendered layout the mouse handler hit-tests against.
|
||||
fn draw_frame(agent: &mut AgentView) {
|
||||
pub(super) fn draw_frame(agent: &mut AgentView) {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let reg = ActionRegistry::defaults();
|
||||
let bundle = crate::app::bundle::BundleState::default();
|
||||
|
|
@ -1719,7 +1730,7 @@ mod question_no_freeform_tests {
|
|||
crate::app::agent_view::AppRenderParams::default(),
|
||||
);
|
||||
}
|
||||
fn down(col: u16, row: u16) -> MouseEvent {
|
||||
pub(super) fn down(col: u16, row: u16) -> MouseEvent {
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: col,
|
||||
|
|
@ -1735,7 +1746,7 @@ mod question_no_freeform_tests {
|
|||
modifiers: KeyModifiers::empty(),
|
||||
}
|
||||
}
|
||||
fn qv(agent: &AgentView) -> &QuestionViewState {
|
||||
pub(super) fn qv(agent: &AgentView) -> &QuestionViewState {
|
||||
agent.question_view.as_ref().expect("question view open")
|
||||
}
|
||||
/// Clicking the empty rows under the last option (option gap, footer)
|
||||
|
|
@ -1847,3 +1858,129 @@ mod question_no_freeform_tests {
|
|||
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod question_freeform_chip_tests {
|
||||
//! Paste-chip round trip through the question freeform input:
|
||||
//! re-entering input mode used to reload the unchanged draft with a
|
||||
//! wholesale `set_text`, expanding every chip into raw text.
|
||||
use super::super::test_fixtures::make_agent;
|
||||
use super::question_no_freeform_tests::{down, draw_frame, open_question, qv};
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::views::prompt_widget::KIND_PASTE;
|
||||
use crate::views::question_view::QuestionFocus;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
const PASTE: &str = "line 1\nline 2\nline 3\nline 4\nline 5";
|
||||
fn paste_chip_count(agent: &AgentView) -> usize {
|
||||
agent
|
||||
.prompt
|
||||
.textarea()
|
||||
.elements()
|
||||
.iter()
|
||||
.filter(|e| e.kind == KIND_PASTE)
|
||||
.count()
|
||||
}
|
||||
/// Multi-line paste folds into a chip; Esc out and Enter back in must
|
||||
/// keep the chip folded (not raw expanded text), and the string slot
|
||||
/// keeps the full paste for the submit payload.
|
||||
#[test]
|
||||
fn paste_chip_survives_input_mode_round_trip() {
|
||||
let mut agent = make_agent();
|
||||
open_question(&mut agent, false);
|
||||
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&z);
|
||||
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
|
||||
let _ = agent.prompt.handle_paste(PASTE);
|
||||
assert_eq!(paste_chip_count(&agent), 1, "paste must fold into a chip");
|
||||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&esc);
|
||||
assert_eq!(qv(&agent).focus, QuestionFocus::Navigation);
|
||||
assert_eq!(qv(&agent).per_question_freeform[0], PASTE);
|
||||
assert!(qv(&agent).per_question_freeform_selected[0]);
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&enter);
|
||||
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
|
||||
assert_eq!(
|
||||
paste_chip_count(&agent),
|
||||
1,
|
||||
"re-entering input mode must keep the folded chip"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), PASTE, "buffer text must round-trip");
|
||||
}
|
||||
/// A slot rewritten by another surface (e.g. the dashboard peek answer
|
||||
/// path) no longer matches the live draft, so re-entry must take the
|
||||
/// normal `set_text` path and show the rewritten slot.
|
||||
#[test]
|
||||
fn rewritten_slot_replaces_stale_draft() {
|
||||
let mut agent = make_agent();
|
||||
open_question(&mut agent, false);
|
||||
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&z);
|
||||
let _ = agent.prompt.handle_paste(PASTE);
|
||||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&esc);
|
||||
agent.question_view.as_mut().unwrap().per_question_freeform[0] = "peek answer".to_string();
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&enter);
|
||||
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
|
||||
assert_eq!(
|
||||
agent.prompt.text(),
|
||||
"peek answer",
|
||||
"a stale draft must not shadow the rewritten slot"
|
||||
);
|
||||
assert_eq!(paste_chip_count(&agent), 0);
|
||||
}
|
||||
/// Double-click on the chip inside the question freeform input expands
|
||||
/// it, exactly like the main prompt; a single click must not.
|
||||
#[test]
|
||||
fn double_click_expands_chip_in_question_input() {
|
||||
let mut agent = make_agent();
|
||||
open_question(&mut agent, false);
|
||||
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&z);
|
||||
let _ = agent.prompt.handle_paste(PASTE);
|
||||
assert_eq!(paste_chip_count(&agent), 1);
|
||||
draw_frame(&mut agent);
|
||||
let ta = agent.prompt.textarea_area();
|
||||
assert!(ta.area() > 0, "inline textarea must have rendered");
|
||||
let (col, row) = (ta.x + 2, ta.y);
|
||||
let _ = agent.handle_question_mouse(&down(col, row));
|
||||
assert_eq!(
|
||||
paste_chip_count(&agent),
|
||||
1,
|
||||
"a single click must not expand the chip"
|
||||
);
|
||||
let _ = agent.handle_question_mouse(&down(col, row));
|
||||
assert_eq!(
|
||||
paste_chip_count(&agent),
|
||||
0,
|
||||
"double-click must expand the chip"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), PASTE, "content inlined as plain text");
|
||||
assert_eq!(
|
||||
qv(&agent).focus,
|
||||
QuestionFocus::InputMode,
|
||||
"expanding must not leave input mode"
|
||||
);
|
||||
}
|
||||
/// A textarea click from before leaving InputMode must not pair with
|
||||
/// the first click after re-entry as a double-click (exits clear the
|
||||
/// pairing timer).
|
||||
#[test]
|
||||
fn click_before_exit_does_not_pair_with_click_after_reentry() {
|
||||
let mut agent = make_agent();
|
||||
open_question(&mut agent, false);
|
||||
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&z);
|
||||
let _ = agent.prompt.handle_paste(PASTE);
|
||||
draw_frame(&mut agent);
|
||||
let ta = agent.prompt.textarea_area();
|
||||
let (col, row) = (ta.x + 2, ta.y);
|
||||
let _ = agent.handle_question_mouse(&down(col, row));
|
||||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&esc);
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
let _ = agent.handle_question_key(&enter);
|
||||
let _ = agent.handle_question_mouse(&down(col, row));
|
||||
assert_eq!(paste_chip_count(&agent), 1, "chip must stay folded");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -737,35 +737,6 @@ pub(crate) enum AgentDeferredSend {
|
|||
/// Ctrl+Enter — a mid-turn interjection.
|
||||
Interject,
|
||||
}
|
||||
/// How the parked-marker slot was consumed. Both variants carry the turn's
|
||||
/// prompt id and both keep the parked (idle) chrome. `Rendered` markers are
|
||||
/// one-per-park-episode — a re-park after new parent output (epoch bump)
|
||||
/// pushes a fresh one (see `maybe_push_parked_marker`); `Forgone` (an
|
||||
/// interjection continued the parked turn) is final — a later "Worked for"
|
||||
/// line would land below the interjected message, flipping the transcript.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ParkedMarkerSlot {
|
||||
/// A "Worked for X" marker block was pushed.
|
||||
Rendered {
|
||||
prompt_id: String,
|
||||
/// The parent-output boundary at push time: chips/completions landing
|
||||
/// under the marker don't bump it, so a matching epoch means "same
|
||||
/// park episode — don't re-push".
|
||||
agent_output_epoch: u64,
|
||||
},
|
||||
/// The marker was forgone: an interjection continued the parked turn.
|
||||
Forgone(String),
|
||||
}
|
||||
impl ParkedMarkerSlot {
|
||||
/// The prompt id the slot was consumed for, regardless of variant.
|
||||
pub(crate) fn prompt_id(&self) -> &str {
|
||||
match self {
|
||||
ParkedMarkerSlot::Rendered { prompt_id, .. } | ParkedMarkerSlot::Forgone(prompt_id) => {
|
||||
prompt_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct AgentView {
|
||||
pub session: AgentSession,
|
||||
pub(crate) session_binding_epoch: u32,
|
||||
|
|
@ -848,6 +819,10 @@ pub struct AgentView {
|
|||
/// turn that already ended (otherwise the viewer re-strands on "Waiting…").
|
||||
/// Reset at the start of every load so it never leaks across loads.
|
||||
pub(crate) replayed_terminal_prompts: HashSet<String>,
|
||||
/// Wake prompt id whose failure marker already rendered — a re-delivered
|
||||
/// errored wake terminal must not stack a second "Turn failed" row (the
|
||||
/// output-epoch dedupe only covers chatty closes; failures bypass it).
|
||||
pub(crate) failed_wake_marker_for: Option<String>,
|
||||
pub active_pane: AgentPane,
|
||||
/// Current mode of the prompt widget (normal vs editing a queued prompt).
|
||||
pub prompt_mode: PromptMode,
|
||||
|
|
@ -916,10 +891,6 @@ pub struct AgentView {
|
|||
pub cleared_workflow_runs: std::collections::HashSet<String>,
|
||||
pub show_workflows: bool,
|
||||
pub workflows_view: crate::views::workflows::WorkflowsViewState,
|
||||
/// The consumed parked-wait marker slot for the current turn, if any.
|
||||
/// Keyed by prompt id: a new turn naturally invalidates the slot with no
|
||||
/// explicit clear site. See [`ParkedMarkerSlot`].
|
||||
pub(crate) parked_wait_marker_for: Option<ParkedMarkerSlot>,
|
||||
/// Live `stop`/`stop_failure` hook runs held for the turn's terminal
|
||||
/// marker (driver order: the hooks arrive before the `PromptResponse`
|
||||
/// that pushes it). Consumed or flushed by `push_turn_terminal_marker`;
|
||||
|
|
@ -938,6 +909,10 @@ pub struct AgentView {
|
|||
/// UTC ms when the current turn started (`turnStartMs` from notification meta).
|
||||
/// Used for turn elapsed display.
|
||||
pub turn_start_ms: Option<i64>,
|
||||
/// Prompt id the stored `turn_start_ms` belongs to (stamped together from
|
||||
/// the same delta meta): wake markers may only claim an elapsed whose
|
||||
/// anchor is provably their own turn's.
|
||||
pub turn_start_ms_prompt: Option<String>,
|
||||
/// Local wall-clock time when the current turn started.
|
||||
/// Set by `maybe_drain_queue` when a prompt is sent. Used to compute
|
||||
/// elapsed time for "Worked for Xm Ys" system messages.
|
||||
|
|
@ -2422,15 +2397,17 @@ pub(crate) mod test_fixtures {
|
|||
child_updates_replayed: false,
|
||||
}
|
||||
}
|
||||
/// Count of parked ("Worked for X") marker blocks in the agent's
|
||||
/// scrollback.
|
||||
pub fn count_parked(agent: &AgentView) -> usize {
|
||||
/// Count of "Worked for X" (`TurnCompleted`) marker blocks in the
|
||||
/// agent's scrollback.
|
||||
pub fn count_turn_markers(agent: &AgentView) -> usize {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
(0..agent.scrollback.len())
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
agent.scrollback.get(*i).map(|e| &e.block),
|
||||
Some(RenderBlock::SessionEvent(b)) if b.parked
|
||||
Some(RenderBlock::SessionEvent(b))
|
||||
if matches!(b.event, SessionEvent::TurnCompleted { .. })
|
||||
)
|
||||
})
|
||||
.count()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
#[cfg(test)]
|
||||
use super::test_fixtures;
|
||||
use super::{AgentPane, AgentView, ParkedMarkerSlot, PromptMode, overlay_action_to_outcome};
|
||||
use super::{AgentPane, AgentView, PromptMode, overlay_action_to_outcome};
|
||||
use crate::actions::ActionRegistry;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
|
|
@ -76,8 +76,8 @@ impl AgentView {
|
|||
/// the wait as user-interruptible would lie there).
|
||||
///
|
||||
/// Gates Enter interjecting instead of queueing and the parked queue
|
||||
/// drain. The stopped-session *rendering* additionally requires the
|
||||
/// parked-marker slot to be consumed — see [`Self::renders_parked`].
|
||||
/// drain. The stopped-session *rendering* additionally excludes subagent
|
||||
/// waits — see [`Self::renders_parked`].
|
||||
/// Purely view-derived — reading it has no turn-lifecycle side effects.
|
||||
pub(crate) fn is_parked_on_sendable_wait(&self) -> bool {
|
||||
crate::views::turn_status::is_sendable_wait(&self.resolve_turn_activity())
|
||||
|
|
@ -114,7 +114,7 @@ impl AgentView {
|
|||
}
|
||||
|
||||
/// The current wait is a foreground subagent await — sendable, but excluded
|
||||
/// from the parked marker (the parent is blocked, not completed; the
|
||||
/// from the parked look (the parent is blocked, not completed; the
|
||||
/// subagent reports its own progress).
|
||||
pub(crate) fn is_waiting_on_subagent(&self) -> bool {
|
||||
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
||||
|
|
@ -124,166 +124,6 @@ impl AgentView {
|
|||
)
|
||||
}
|
||||
|
||||
/// The wait can only return imminently: every awaited id is already
|
||||
/// terminal, or a wait-all sees zero running work. Unknown ids and Sleep
|
||||
/// are never imminent. Callers must pre-gate on
|
||||
/// `is_parked_on_sendable_wait` — this predicate ignores `waits`.
|
||||
fn parked_wait_resolves_imminently(&self) -> bool {
|
||||
use crate::acp::tracker::{TurnActivity, WaitingReason};
|
||||
match self.resolve_turn_activity() {
|
||||
Some(TurnActivity::Waiting(WaitingReason::TaskOutput { task_ids, .. })) => {
|
||||
!task_ids.is_empty() && task_ids.iter().all(|id| self.awaited_id_is_terminal(id))
|
||||
}
|
||||
// The tracker drops wait_commands_or_subagents' explicit task_ids;
|
||||
// zero visible work is the only signal available here.
|
||||
Some(TurnActivity::Waiting(WaitingReason::TasksComplete)) => {
|
||||
self.watchers().awaitable_work() == 0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal work behind an awaited id: bg task by task id, else subagent
|
||||
/// by child session id or subagent id. Unknown → `false`.
|
||||
fn awaited_id_is_terminal(&self, id: &str) -> bool {
|
||||
if let Some(task) = self.session.bg_tasks.get(id) {
|
||||
return task.status != crate::app::agent::BgTaskStatus::Running;
|
||||
}
|
||||
self.subagent_sessions
|
||||
.get(id)
|
||||
.or_else(|| {
|
||||
self.subagent_sessions
|
||||
.values()
|
||||
.find(|info| info.subagent_id.as_ref() == id)
|
||||
})
|
||||
.is_some_and(|info| !info.is_running())
|
||||
}
|
||||
|
||||
/// Push a "Worked for X" marker when the turn parks on a sendable wait —
|
||||
/// the transcript boundary explaining the idle-looking chrome. One marker
|
||||
/// per park episode: same agent-output epoch as the rendered slot means
|
||||
/// no re-push (chips/completions don't bump it); an epoch bump means the
|
||||
/// wait resumed and re-parked, which pushes a fresh marker. Completion
|
||||
/// rails also call this to re-eval a park withheld at park time (e.g.
|
||||
/// held queue since drained).
|
||||
///
|
||||
/// Called from the ACP notification path — not the draw path — so
|
||||
/// background tabs and minimal mode stamp the park at its true moment. A
|
||||
/// [`ParkedMarkerSlot::Forgone`] slot stays silent for the rest of the
|
||||
/// turn (see [`Self::suppress_parked_marker_on_interject`]). UI-only: no
|
||||
/// turn-lifecycle event, no stop hooks; the completion folds into an
|
||||
/// uncommitted tail parked marker, else prints fresh (minimal-mode
|
||||
/// commits are print-once).
|
||||
pub(crate) fn maybe_push_parked_marker(&mut self) {
|
||||
if !self.is_parked_on_sendable_wait()
|
||||
|| self.is_waiting_on_subagent()
|
||||
|| self.has_held_user_queue()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(prompt_id) = self.session.current_prompt_id.clone() else {
|
||||
return;
|
||||
};
|
||||
match &self.parked_wait_marker_for {
|
||||
// Interjection ordering: forgone is final for the turn.
|
||||
Some(ParkedMarkerSlot::Forgone(pid)) if *pid == prompt_id => return,
|
||||
// Same park episode (no parent output since the marker): the one
|
||||
// marker already explains this park — chips landing below it
|
||||
// must not re-push.
|
||||
Some(ParkedMarkerSlot::Rendered {
|
||||
prompt_id: pid,
|
||||
agent_output_epoch,
|
||||
..
|
||||
}) if *pid == prompt_id
|
||||
&& *agent_output_epoch == self.session.tracker.agent_output_epoch() =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A tail user prompt after a rendered marker is an interjection:
|
||||
// a marker line beneath it would flip the transcript.
|
||||
Some(ParkedMarkerSlot::Rendered { prompt_id: pid, .. })
|
||||
if *pid == prompt_id && self.tail_is_user_prompt() =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Below the slot dedupe: a rendered park would otherwise log a false
|
||||
// "skipped" on every subsequent update.
|
||||
if self.parked_wait_resolves_imminently() {
|
||||
tracing::debug!(
|
||||
target: "prompt.parked_marker",
|
||||
"parked marker skipped: awaited work already finished, wait resolves imminently"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.push_parked_marker_block(prompt_id);
|
||||
}
|
||||
|
||||
/// The transcript tail is a user-authored prompt row.
|
||||
fn tail_is_user_prompt(&self) -> bool {
|
||||
matches!(
|
||||
self.scrollback.last().map(|entry| &entry.block),
|
||||
Some(crate::scrollback::block::RenderBlock::UserPrompt(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// The parked marker block shape: a `TurnCompleted` marker flagged
|
||||
/// `parked` (renders mid-turn, never accepts stop hooks).
|
||||
fn push_parked_marker_block(&mut self, prompt_id: String) {
|
||||
let agent_output_epoch = self.session.tracker.agent_output_epoch();
|
||||
let mut block = crate::scrollback::blocks::SessionEventBlock::new(
|
||||
crate::scrollback::blocks::SessionEvent::TurnCompleted {
|
||||
// Unknown elapsed renders as "Worked for 0.0s" rather than
|
||||
// falling back to `None`'s bare "Turn completed." — the park
|
||||
// boundary should read like every other turn marker.
|
||||
elapsed: Some(self.turn_elapsed().unwrap_or_default()),
|
||||
},
|
||||
);
|
||||
block.parked = true;
|
||||
block.prompt_id = Some(prompt_id.clone());
|
||||
self.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::SessionEvent(block));
|
||||
self.parked_wait_marker_for = Some(ParkedMarkerSlot::Rendered {
|
||||
prompt_id,
|
||||
agent_output_epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// Consume the parked-marker slot as forgone when an interjection lands
|
||||
/// while the turn is parked on a sendable wait: the turn visibly continues
|
||||
/// below the user's message, so the withheld "Worked for … still
|
||||
/// running." marker must never render under it (it would read as the turn
|
||||
/// completing *after* the user's follow-up — flipped ordering). A no-op
|
||||
/// when the marker already rendered (slot already stamped) or the turn is
|
||||
/// not parked (a plain mid-turn interjection keeps a later park's marker).
|
||||
///
|
||||
/// Accepted edge: if the interject send later FAILS while the wait is
|
||||
/// still parked (`TaskResult::InterjectFailed` requeues the payload), the
|
||||
/// slot stays consumed — idle chrome without a marker until the wait
|
||||
/// ends. Un-consuming would recreate the flipped ordering under the
|
||||
/// already-rendered optimistic block.
|
||||
pub(crate) fn suppress_parked_marker_on_interject(&mut self) {
|
||||
if self.is_parked_on_sendable_wait()
|
||||
&& let Some(prompt_id) = self.session.current_prompt_id.clone()
|
||||
{
|
||||
// Never downgrade a Rendered slot: with the marker on screen the
|
||||
// ordering is already correct, and its countdown may keep ticking.
|
||||
if self
|
||||
.parked_wait_marker_for
|
||||
.as_ref()
|
||||
.is_some_and(|slot| slot.prompt_id() == prompt_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "prompt.auto_interject",
|
||||
"parked marker forgone: interjection continued the parked turn"
|
||||
);
|
||||
self.parked_wait_marker_for = Some(ParkedMarkerSlot::Forgone(prompt_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible held rows for the "N queued" hint. 0 outside sendable waits.
|
||||
pub(crate) fn held_queue_count(&self) -> usize {
|
||||
// Goal-gated via `is_parked_on_sendable_wait` (0 during a goal — shell exempts goal turns).
|
||||
|
|
@ -369,20 +209,13 @@ impl AgentView {
|
|||
);
|
||||
}
|
||||
|
||||
/// Whether the stopped-session look is active: the parked-marker slot for
|
||||
/// the current turn was consumed (marker pushed, or forgone because an
|
||||
/// interjection continued the parked turn) and the turn is still in its
|
||||
/// sendable wait. Drives hiding the turn-status row and the idle keybar;
|
||||
/// flips back off (the running chrome returns) the moment the wait ends
|
||||
/// and the turn resumes.
|
||||
/// Whether the stopped-session look is active: the turn is parked in a
|
||||
/// sendable wait that is not a foreground subagent await. Purely
|
||||
/// view-derived — no transcript row is written for a park. Drives the
|
||||
/// idle keybar and the parked turn-status cue; flips back off (the
|
||||
/// running chrome returns) the moment the wait ends and the turn resumes.
|
||||
pub(crate) fn renders_parked(&self) -> bool {
|
||||
self.parked_wait_marker_for
|
||||
.as_ref()
|
||||
.zip(self.session.current_prompt_id.as_deref())
|
||||
.is_some_and(|(slot, pid)| slot.prompt_id() == pid)
|
||||
&& self.is_parked_on_sendable_wait()
|
||||
// Subagent waits keep running chrome — exclude them from the stopped look.
|
||||
&& !self.is_waiting_on_subagent()
|
||||
self.is_parked_on_sendable_wait() && !self.is_waiting_on_subagent()
|
||||
}
|
||||
|
||||
/// Live counts for the turn-status watching cue; see
|
||||
|
|
@ -423,15 +256,6 @@ impl AgentView {
|
|||
stop_hooks: Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)>,
|
||||
prompt_id: Option<String>,
|
||||
) {
|
||||
// Park → work finished → turn ended with nothing in between: fold the
|
||||
// completion into the tail parked marker instead of stacking a dup row.
|
||||
if self.scrollback.fold_completion_into_tail_parked_marker(
|
||||
&event,
|
||||
&stop_hooks,
|
||||
prompt_id.as_deref(),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The marker keeps its turn's pid for the tail-merge attribution check.
|
||||
let block = crate::scrollback::blocks::SessionEventBlock::with_stop_hooks(
|
||||
event, stop_hooks, prompt_id,
|
||||
|
|
@ -678,10 +502,6 @@ impl AgentView {
|
|||
if self.visible_queue_is_empty() {
|
||||
self.hide_queue_pane();
|
||||
}
|
||||
// Deleting the last held row can flip the parked
|
||||
// look on now (the ACP rebroadcast re-checks too,
|
||||
// but the optimistic remove shouldn't lag).
|
||||
self.maybe_push_parked_marker();
|
||||
return InputOutcome::Action(Action::QueueRemoveShared {
|
||||
id: server_id,
|
||||
expected_version: row.version,
|
||||
|
|
@ -691,11 +511,6 @@ impl AgentView {
|
|||
}
|
||||
// No drain kick (cf. mouse [cancel]): queue focus is unreachable mid-edit.
|
||||
self.remove_local_queue_row(id);
|
||||
// A LOCAL delete has no server rebroadcast to re-evaluate
|
||||
// the parked look — deleting the last held row must flip
|
||||
// the stopped chrome on immediately, not on the next
|
||||
// unrelated notification.
|
||||
self.maybe_push_parked_marker();
|
||||
}
|
||||
QueueEvent::EditSelected { id } => {
|
||||
// Entry into editing mode lives in `queue_edit.rs`.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ use crate::theme::Theme;
|
|||
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
|
||||
use crate::views::modal;
|
||||
use crate::views::plan_approval_view::PlanApprovalFocus;
|
||||
use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptStyle};
|
||||
use crate::views::prompt_widget::{PromptBg, PromptFlag, PromptInfo, PromptStyle};
|
||||
use crate::views::question_view::QUESTION_VIEW_HPAD;
|
||||
use crate::views::shortcuts_bar::{HintItem, PendingHint, ShortcutsBar};
|
||||
use crate::views::{agent, turn_status};
|
||||
|
|
@ -802,7 +802,7 @@ impl AgentView {
|
|||
chrome: true,
|
||||
chrome_pad_left: layout_cfg.block_pad_left,
|
||||
chrome_pad_right: layout_cfg.block_pad_right,
|
||||
bg_override: None,
|
||||
bg: PromptBg::Default,
|
||||
accent_color_override: if let Some(c) = self.prompt_input_mode.accent_color(&theme) {
|
||||
Some(c)
|
||||
} else if effective_plan || casual_commenting {
|
||||
|
|
@ -944,7 +944,7 @@ impl AgentView {
|
|||
chrome: false,
|
||||
chrome_pad_left: 0,
|
||||
chrome_pad_right: 0,
|
||||
bg_override: Some(theme.bg_visual),
|
||||
bg: PromptBg::Panel(theme.bg_visual),
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
@ -979,7 +979,7 @@ impl AgentView {
|
|||
chrome: false,
|
||||
chrome_pad_left: 0,
|
||||
chrome_pad_right: 0,
|
||||
bg_override: Some(theme.bg_visual),
|
||||
bg: PromptBg::Panel(theme.bg_visual),
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
@ -2334,7 +2334,7 @@ impl AgentView {
|
|||
chrome: false,
|
||||
chrome_pad_left: 0,
|
||||
chrome_pad_right: 0,
|
||||
bg_override: Some(row_bg),
|
||||
bg: PromptBg::Panel(row_bg),
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ impl AgentView {
|
|||
session_reload: None,
|
||||
unexpected_replay_drops: 0,
|
||||
replayed_terminal_prompts: HashSet::new(),
|
||||
failed_wake_marker_for: None,
|
||||
active_pane: ActivePane::Prompt,
|
||||
prompt_mode: PromptMode::Normal,
|
||||
prompt_input_mode: PromptInputMode::Normal,
|
||||
|
|
@ -125,11 +126,11 @@ impl AgentView {
|
|||
cleared_workflow_runs: std::collections::HashSet::new(),
|
||||
show_workflows: false,
|
||||
workflows_view: crate::views::workflows::WorkflowsViewState::default(),
|
||||
parked_wait_marker_for: None,
|
||||
pending_stop_hooks: None,
|
||||
last_cleared_goal_id: None,
|
||||
show_goal_detail: false,
|
||||
turn_start_ms: None,
|
||||
turn_start_ms_prompt: None,
|
||||
turn_started_at: None,
|
||||
first_activity_logged_for: None,
|
||||
turn_paused_duration: std::time::Duration::ZERO,
|
||||
|
|
|
|||
|
|
@ -1229,6 +1229,17 @@ impl AppView {
|
|||
.as_deref()
|
||||
.is_some_and(|r| r.eq_ignore_ascii_case("admin"))
|
||||
}
|
||||
/// Why `coding_data_sharing` is locked for this user (`None` = editable).
|
||||
/// Mirrors the dispatch guards in `set_coding_data_sharing`.
|
||||
pub fn coding_data_sharing_lock(&self) -> Option<crate::settings::CodingDataSharingLock> {
|
||||
if self.is_zdr {
|
||||
Some(crate::settings::CodingDataSharingLock::Zdr)
|
||||
} else if self.is_team_non_admin() {
|
||||
Some(crate::settings::CodingDataSharingLock::TeamManaged)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Welcome privacy banner visibility gates.
|
||||
pub fn privacy_banner_should_show(&self) -> bool {
|
||||
if self.screen_mode.is_minimal() {
|
||||
|
|
|
|||
|
|
@ -1286,6 +1286,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
}
|
||||
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
|
|
@ -1390,6 +1391,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
|
||||
plan_mode_active: false,
|
||||
show_tips: show_tips_from_app,
|
||||
auto_update: auto_update_from_app,
|
||||
|
|
|
|||
|
|
@ -53,9 +53,6 @@ pub(super) fn dispatch_interject(
|
|||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::interjection_prompt(&text));
|
||||
// Interjecting into a parked wait continues the turn below this block —
|
||||
// the withheld "Worked for …" marker must not fire late beneath it.
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
|
||||
// The composer is NOT touched here: the producer that consumed composer
|
||||
// text (the InterjectPrompt registry arm) clears it at the call site;
|
||||
|
|
@ -145,7 +142,6 @@ pub(super) fn dispatch_send_prompt_now(
|
|||
// The arm hides the queue echo pushed below — paint the block now.
|
||||
super::queue::push_send_now_user_block(agent, &prompt_id, "prompt", &text, false);
|
||||
}
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
|
||||
let blocks = crate::prompt_images::build_content_blocks_with_workspace(
|
||||
text.clone(),
|
||||
|
|
|
|||
|
|
@ -467,6 +467,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
};
|
||||
// Capture app-level fields before the mut-borrow on `agent`.
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
|
|
@ -563,6 +564,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
|
||||
// Prefer optimistic pending over confirmed active.
|
||||
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
show_tips: show_tips_from_app,
|
||||
|
|
@ -845,7 +847,6 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
|
||||
if parked_sendable_wait && !hold_behind_existing_queue {
|
||||
agent.arm_send_now_expectation(prompt_id.clone());
|
||||
agent.suppress_parked_marker_on_interject();
|
||||
}
|
||||
|
||||
if consume_input {
|
||||
|
|
|
|||
|
|
@ -1016,7 +1016,7 @@ mod tests {
|
|||
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,
|
||||
complete_task_output_wait_call, count_turn_markers, running_subagent_info,
|
||||
simulate_subagent_wait, simulate_task_output_wait, simulate_task_output_wait_call,
|
||||
};
|
||||
use crate::app::dispatch::router::dispatch;
|
||||
|
|
@ -2299,64 +2299,59 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parked_marker_fires_once_on_empty_queue_park() {
|
||||
fn parked_wait_renders_parked_without_markers() {
|
||||
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");
|
||||
assert!(agent.renders_parked(), "live wait = parked look");
|
||||
assert_eq!(count_turn_markers(agent), 0, "a park writes no marker");
|
||||
}
|
||||
|
||||
/// A re-park after new PARENT OUTPUT (streamed through the tracker, so
|
||||
/// the agent-output epoch bumps) pushes a fresh marker for the new park
|
||||
/// episode — otherwise the second park renders as a dead session.
|
||||
#[test]
|
||||
fn parked_marker_repushes_on_repark_after_new_parent_output() {
|
||||
fn sibling_batch_park_writes_no_markers() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use std::sync::Arc;
|
||||
|
||||
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, "same episode must dedupe");
|
||||
// Blocking get_task_output registers first; anchor still running.
|
||||
simulate_task_output_wait_call(agent, "wait-1", "bg-anchor", 120_000);
|
||||
agent
|
||||
.session
|
||||
.bg_tasks
|
||||
.insert("bg-anchor".into(), running_bg_task("bg-anchor"));
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
assert!(!agent.renders_parked(), "no parked look between parks");
|
||||
// Between-parks content streams through the tracker (the production
|
||||
// path), bumping the agent-output epoch.
|
||||
assert!(agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
acp::TextContent::new("between-parks content")
|
||||
),)),
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
));
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-2", "bg-1", 600_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 2, "new episode pushes a fresh marker");
|
||||
assert!(agent.renders_parked());
|
||||
for i in 2..=5 {
|
||||
let tc_id = format!("wait-batch-tc{i}");
|
||||
agent.session.handle_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(
|
||||
acp::ToolCallId::new(Arc::from(tc_id.as_str())),
|
||||
"run_terminal_command",
|
||||
)
|
||||
.kind(acp::ToolKind::Execute)
|
||||
.status(acp::ToolCallStatus::Pending),
|
||||
),
|
||||
&NotificationMeta::default(),
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"a sibling-batch park must write zero markers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rows landing during a park WITHOUT parent output (chips and other
|
||||
/// direct scrollback pushes) stay in the same park episode — the marker
|
||||
/// is never re-pushed under them; the "… still running" status row carries
|
||||
/// the ongoing-work story instead.
|
||||
#[test]
|
||||
fn parked_marker_stays_single_when_rows_land_mid_park() {
|
||||
fn chips_and_completions_mid_park_add_no_markers() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
|
|
@ -2365,170 +2360,69 @@ mod tests {
|
|||
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);
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
|
||||
agent.scrollback.push_block(RenderBlock::bg_task_completed(
|
||||
"sleep 5",
|
||||
"bg-2",
|
||||
std::time::Duration::from_secs(5),
|
||||
));
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 1, "chips never re-push the marker");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 1, "still the same park episode");
|
||||
assert_eq!(count_turn_markers(agent), 0, "chips add no markers");
|
||||
assert!(agent.renders_parked(), "chips keep the parked look");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
fn wait_completion_clears_parked_look() {
|
||||
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);
|
||||
assert!(agent.renders_parked());
|
||||
|
||||
// Wait tools render no blocks, so the marker stays the tail.
|
||||
complete_task_output_wait_call(agent, "wait-1");
|
||||
assert!(!agent.renders_parked(), "no parked look between parks");
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
|
||||
simulate_task_output_wait_call(agent, "wait-2", "bg-1", 15_000);
|
||||
agent.maybe_push_parked_marker();
|
||||
assert!(agent.renders_parked(), "a re-park flips the look back on");
|
||||
assert_eq!(count_turn_markers(agent), 0, "re-parks stay markerless");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjection_during_park_adds_no_marker() {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
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");
|
||||
assert!(app.agents[&id].renders_parked());
|
||||
|
||||
let _ = dispatch(
|
||||
Action::Interject {
|
||||
text: "hurry up".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = &app.agents[&id];
|
||||
assert_eq!(
|
||||
count_parked(agent),
|
||||
1,
|
||||
"marker still at the tail: a re-push would be a duplicate line"
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"no marker around the interjection"
|
||||
);
|
||||
assert!(
|
||||
agent.renders_parked(),
|
||||
"the park itself still renders parked"
|
||||
matches!(
|
||||
agent.scrollback.last().map(|e| &e.block),
|
||||
Some(RenderBlock::UserPrompt(_))
|
||||
),
|
||||
"the user prompt row lands with nothing under it"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 never touches the marker — the counts live on the
|
||||
/// status row's "… still running" cue, so the transcript stays quiet while
|
||||
/// work finishes mid-park.
|
||||
#[test]
|
||||
fn count_change_never_restates_marker() {
|
||||
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);
|
||||
assert_eq!(agent.watchers().commands, 2);
|
||||
|
||||
agent.session.bg_tasks.remove("bg-2");
|
||||
agent.maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(agent), 1, "count changes never restate");
|
||||
assert_eq!(agent.watchers().commands, 1, "the cue counts down instead");
|
||||
}
|
||||
|
||||
#[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,
|
||||
combined_texts: None,
|
||||
}];
|
||||
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();
|
||||
|
|
@ -2538,9 +2432,11 @@ mod tests {
|
|||
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!(count_turn_markers(agent), 0);
|
||||
assert!(
|
||||
agent.renders_parked(),
|
||||
"the parked look is queue-occupancy-independent"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.held_queue_count(),
|
||||
1,
|
||||
|
|
@ -2565,12 +2461,7 @@ mod tests {
|
|||
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_eq!(count_turn_markers(agent), 0);
|
||||
assert!(!agent.renders_parked());
|
||||
|
||||
// Even with an empty queue + live subagent, a subagent wait never parks.
|
||||
|
|
@ -2578,18 +2469,19 @@ mod tests {
|
|||
.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_eq!(
|
||||
count_turn_markers(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.
|
||||
/// T1 regression: a live chunk must un-park even when the wait's terminal
|
||||
/// ToolCallUpdate never reached this client.
|
||||
#[test]
|
||||
fn parked_look_clears_when_model_resumes_streaming() {
|
||||
let mut app = test_app_with_agent();
|
||||
|
|
@ -2598,7 +2490,6 @@ mod tests {
|
|||
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).
|
||||
|
|
@ -2624,8 +2515,7 @@ mod tests {
|
|||
"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.
|
||||
// A new-stream thought (different stream_start_ms) also un-parks; same-stream must not.
|
||||
{
|
||||
let wait_meta = crate::acp::meta::NotificationMeta {
|
||||
stream_start_ms: Some(1),
|
||||
|
|
@ -2679,9 +2569,8 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// T4 regression: the hint must only advertise "Enter to send now" when
|
||||
/// the TOP held row would actually send (a bash top row no-ops).
|
||||
#[test]
|
||||
fn held_hint_advertises_send_now_only_for_sendable_top() {
|
||||
let mut app = test_app_with_agent();
|
||||
|
|
@ -2689,7 +2578,6 @@ mod tests {
|
|||
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);
|
||||
|
|
@ -2698,7 +2586,6 @@ mod tests {
|
|||
"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());
|
||||
|
|
@ -2846,10 +2733,8 @@ mod tests {
|
|||
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.
|
||||
/// An arm that became the running turn is not held occupancy — otherwise 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();
|
||||
|
|
@ -2858,7 +2743,6 @@ mod tests {
|
|||
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!(
|
||||
|
|
@ -2866,7 +2750,6 @@ mod tests {
|
|||
"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(),
|
||||
|
|
@ -2874,10 +2757,8 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
fn local_delete_of_last_held_row_adds_no_marker() {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
|
|
@ -2887,9 +2768,11 @@ mod tests {
|
|||
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());
|
||||
assert_eq!(count_turn_markers(agent), 0);
|
||||
assert!(
|
||||
agent.renders_parked(),
|
||||
"parked look on even with a held row"
|
||||
);
|
||||
|
||||
// Delete the row through the queue-pane key path.
|
||||
agent.queue.sync_from_merged(
|
||||
|
|
@ -2910,14 +2793,11 @@ mod tests {
|
|||
|
||||
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"
|
||||
count_turn_markers(agent),
|
||||
0,
|
||||
"deleting the last held row must not write a marker"
|
||||
);
|
||||
assert!(agent.renders_parked(), "the stopped look stays on");
|
||||
}
|
||||
|
||||
/// T3 regression: a task-tool refinement that OMITS `run_in_background`
|
||||
|
|
@ -2985,15 +2865,14 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// A plain mid-turn interjection (no wait) must NOT consume the marker
|
||||
/// slot: a later park in the same turn still deserves its marker.
|
||||
/// A plain mid-turn interjection needs no suppression state for a later
|
||||
/// park in the same turn.
|
||||
#[test]
|
||||
fn non_parked_interjection_keeps_later_park_marker() {
|
||||
fn interjection_then_later_park_still_renders_parked() {
|
||||
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(),
|
||||
|
|
@ -3002,37 +2881,32 @@ mod tests {
|
|||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.agents[&id].parked_wait_marker_for.is_none(),
|
||||
"no wait → slot must stay free"
|
||||
!app.agents[&id].renders_parked(),
|
||||
"no wait → no parked look"
|
||||
);
|
||||
|
||||
// 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");
|
||||
assert!(agent.renders_parked(), "later park renders parked");
|
||||
assert_eq!(count_turn_markers(agent), 0, "and stays markerless");
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
|
||||
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());
|
||||
assert!(
|
||||
agent.session.state.is_busy(),
|
||||
|
|
@ -3046,46 +2920,4 @@ mod tests {
|
|||
"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. It carries no
|
||||
/// work counts — the persistent "… still running" status row above the
|
||||
/// prompt tracks the still-running work. The real final marker later
|
||||
/// pushes separately (two static lines — main's park shape).
|
||||
#[test]
|
||||
fn parked_marker_is_static_completed_snapshot() {
|
||||
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"
|
||||
);
|
||||
// The running bg command shows in the watchers cue, not the marker.
|
||||
assert_eq!(agent.watchers().commands, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
|||
let ui_snapshot = app.current_ui.clone();
|
||||
// Capture app-level fields before the mut-borrow loop.
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
|
|
@ -80,6 +81,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
|||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
|
||||
// Prefer optimistic pending over confirmed active.
|
||||
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
show_tips: show_tips_from_app,
|
||||
|
|
@ -182,6 +184,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
|
|||
let ui_snapshot = app.current_ui.clone();
|
||||
// Capture app-level fields before the mut-borrow on the agent.
|
||||
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
|
||||
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
|
|
@ -224,6 +227,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
|
|||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||||
.collect(),
|
||||
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
|
||||
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
|
||||
// Prefer optimistic pending over confirmed active.
|
||||
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
|
||||
show_tips: show_tips_from_app,
|
||||
|
|
@ -702,6 +706,7 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal
|
|||
current_model_name: agent_current_model_name(app),
|
||||
available_models: agent_available_models(app),
|
||||
coding_data_sharing_opt_out: app.coding_data_retention_opt_out,
|
||||
coding_data_sharing_lock: app.coding_data_sharing_lock(),
|
||||
plan_mode_active: agent_plan_mode(app),
|
||||
show_tips: app.show_tips,
|
||||
auto_update: app.auto_update,
|
||||
|
|
|
|||
|
|
@ -3700,17 +3700,21 @@ fn interactive_cancel_supersedes_send_now_expectation() {
|
|||
);
|
||||
}
|
||||
|
||||
/// The parked "Worked for" marker stays the only marker across a send-now cancel.
|
||||
/// A send-now cancel out of a park leaves no markers at all: the park is
|
||||
/// markerless and the armed expectation suppresses the cancel marker.
|
||||
#[test]
|
||||
fn send_now_cancel_after_park_leaves_single_parked_marker() {
|
||||
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
|
||||
fn send_now_cancel_after_park_leaves_no_markers() {
|
||||
use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait};
|
||||
|
||||
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");
|
||||
app.agents.get_mut(&id).unwrap().maybe_push_parked_marker();
|
||||
assert_eq!(count_parked(&app.agents[&id]), 1);
|
||||
assert_eq!(
|
||||
count_turn_markers(&app.agents[&id]),
|
||||
0,
|
||||
"a park writes no marker"
|
||||
);
|
||||
|
||||
// Typing into the parked wait: plain send arms the expectation; cancel arrives meta-less.
|
||||
let _ = dispatch(Action::SendPrompt("next thing".into()), &mut app);
|
||||
|
|
@ -3719,14 +3723,8 @@ fn send_now_cancel_after_park_leaves_single_parked_marker() {
|
|||
assert_eq!(count_cancelled_markers(&app, id), 0);
|
||||
assert_eq!(
|
||||
count_completed_markers(&app, id),
|
||||
1,
|
||||
"the parked marker stays the only completed line (no duplicate)"
|
||||
);
|
||||
app.agents.get_mut(&id).unwrap().maybe_push_parked_marker();
|
||||
assert_eq!(
|
||||
count_parked(&app.agents[&id]),
|
||||
1,
|
||||
"no late parked marker after the send-now cancel"
|
||||
0,
|
||||
"no completed marker renders for the cancelled parked turn"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -641,7 +641,7 @@ pub async fn run(
|
|||
default_yolo_mode: launch_yolo.yolo,
|
||||
default_auto_mode: launch_auto && !launch_yolo.yolo,
|
||||
};
|
||||
let connection = if use_leader {
|
||||
let mut connection = if use_leader {
|
||||
let conn = crate::acp::connect_via_leader(&cancel, connect_flags, &raw_config).await?;
|
||||
tracing::info!(
|
||||
elapsed_ms = startup_start.elapsed().as_millis() as u64,
|
||||
|
|
@ -656,6 +656,8 @@ pub async fn run(
|
|||
);
|
||||
conn
|
||||
};
|
||||
let agent_guard =
|
||||
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), connection.agent_thread.take());
|
||||
let mut config_watcher = crate::appearance::ConfigWatcher::start().await?;
|
||||
let alt_screen_config_mode = config_watcher.current().alt_screen;
|
||||
let term_ctx = crate::terminal::terminal_context();
|
||||
|
|
@ -756,7 +758,7 @@ pub async fn run(
|
|||
.await;
|
||||
crate::unified_log::flush_blocking().await;
|
||||
let restore_result = restore_terminal(terminal, writer_thread, screen_mode);
|
||||
cancel.cancel();
|
||||
drop(agent_guard);
|
||||
xai_tty_utils::global_process_scope().kill_all();
|
||||
if let Err(cleanup_error) = restore_result {
|
||||
match &result {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ use crate::views::prompt_widget::PromptEvent;
|
|||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use std::time::Instant;
|
||||
impl AgentView {
|
||||
/// Time-paired multi-click check for the prompt textarea. Pairing is
|
||||
/// time-only (no coordinates); a mispaired action is one undo step.
|
||||
/// Records the click for the next pairing.
|
||||
pub(super) fn prompt_click_is_double(&mut self) -> bool {
|
||||
let now = std::time::Instant::now();
|
||||
let is_double = self
|
||||
.last_prompt_click_ms
|
||||
.is_some_and(|last| now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS);
|
||||
self.last_prompt_click_ms = Some(now);
|
||||
is_double
|
||||
}
|
||||
/// Handle mouse events: click-to-focus, forward to prompt textarea.
|
||||
///
|
||||
/// Scroll events are handled at app level (not here).
|
||||
|
|
@ -454,7 +465,6 @@ impl AgentView {
|
|||
if self.visible_queue_is_empty() {
|
||||
self.hide_queue_pane();
|
||||
}
|
||||
self.maybe_push_parked_marker();
|
||||
return InputOutcome::Action(Action::QueueRemoveShared {
|
||||
id: server_id,
|
||||
expected_version: row.version,
|
||||
|
|
@ -464,7 +474,6 @@ impl AgentView {
|
|||
}
|
||||
let was_drain_blocked = self.drain_blocked();
|
||||
self.remove_local_queue_row(id);
|
||||
self.maybe_push_parked_marker();
|
||||
if was_drain_blocked {
|
||||
return InputOutcome::Action(Action::DrainQueue);
|
||||
}
|
||||
|
|
@ -509,10 +518,7 @@ impl AgentView {
|
|||
self.pending_effects.push(eff);
|
||||
}
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(last) = self.last_prompt_click_ms
|
||||
&& now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS
|
||||
{
|
||||
if self.prompt_click_is_double() {
|
||||
if self.prompt.file_ref_near_cursor()
|
||||
&& let Some((path, initial_range)) =
|
||||
self.prompt.file_ref_element_at_cursor()
|
||||
|
|
@ -526,7 +532,6 @@ impl AgentView {
|
|||
self.prompt.refresh_slash(&self.session.models);
|
||||
}
|
||||
}
|
||||
self.last_prompt_click_ms = Some(now);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ use super::app_view::AppView;
|
|||
/// (right-justified) on the marker line instead of as a standalone block.
|
||||
///
|
||||
/// All three marker rails route through here: the driver's `PromptResponse`,
|
||||
/// the lost-RPC reconcile, and the viewer finalize. (Wake turns close
|
||||
/// markerless — see `finish_wake_turn` in acp_handler.) `event == None`
|
||||
/// the lost-RPC reconcile, and the viewer finalize. (Wake turns route through
|
||||
/// `finish_wake_turn` in acp_handler, which maps their stop reason and calls
|
||||
/// here only when a marker is due.) `event == None`
|
||||
/// (bash turns, rate-limit / re-auth UX that replaces the marker) flushes the
|
||||
/// held hooks as the legacy standalone lifecycle block so failures stay
|
||||
/// visible.
|
||||
|
|
|
|||
|
|
@ -418,9 +418,6 @@ fn last_marker_block(agent: &AgentView) -> &SessionEventBlock {
|
|||
|
||||
#[test]
|
||||
fn real_end_marker_stays_plain_with_running_work() {
|
||||
// Background work never rides the end marker as a "still running" suffix
|
||||
// — the persistent "… still running" status row carries it instead. The
|
||||
// running command shows up in the watchers count only.
|
||||
let mut agent = running_driver("p1");
|
||||
insert_bg_task(&mut agent, "bg-1", false);
|
||||
|
||||
|
|
@ -433,7 +430,6 @@ fn real_end_marker_stays_plain_with_running_work() {
|
|||
);
|
||||
|
||||
let block = last_marker_block(&agent);
|
||||
assert!(!block.parked);
|
||||
assert_eq!(block.prompt_id.as_deref(), Some("p1"));
|
||||
assert_eq!(block.event.message(), "Worked for 2.0s");
|
||||
assert_eq!(
|
||||
|
|
@ -533,108 +529,15 @@ fn driver_arm_records_cancel_trigger_for_reconcile() {
|
|||
);
|
||||
}
|
||||
|
||||
/// All `TurnCompleted` markers (parked and final) in scrollback order.
|
||||
fn completed_markers(sb: &ScrollbackState) -> Vec<SessionEventBlock> {
|
||||
(0..sb.len())
|
||||
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b))
|
||||
if matches!(b.event, SessionEvent::TurnCompleted { .. }) =>
|
||||
{
|
||||
Some(b.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The tail parked marker `maybe_push_parked_marker` leaves mid-turn (same
|
||||
/// shape as `push_parked_marker_block`).
|
||||
fn push_parked_tail(agent: &mut AgentView, prompt_id: &str, secs: u64) {
|
||||
let mut parked = SessionEventBlock::new(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(secs)),
|
||||
});
|
||||
parked.parked = true;
|
||||
parked.prompt_id = Some(prompt_id.into());
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::SessionEvent(parked));
|
||||
}
|
||||
|
||||
/// The turn-end marker takes no fold path — a park has no row to fold into.
|
||||
#[test]
|
||||
fn completion_folds_tail_parked_marker_instead_of_duplicating() {
|
||||
// Park → work finished → turn ended with nothing in between: the
|
||||
// completion folds into the parked marker, not a second identical row.
|
||||
let mut agent = running_driver("p1");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
fn turn_end_after_park_pushes_single_marker() {
|
||||
use crate::app::agent_view::test_fixtures::count_turn_markers;
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(5)),
|
||||
}),
|
||||
Some("p1"),
|
||||
);
|
||||
|
||||
let markers = completed_markers(&agent.scrollback);
|
||||
assert_eq!(
|
||||
markers.len(),
|
||||
1,
|
||||
"park + completion must render ONE marker, got {}",
|
||||
markers.len()
|
||||
);
|
||||
assert!(!markers[0].parked, "the folded marker is the real turn end");
|
||||
assert_eq!(
|
||||
markers[0].event.message(),
|
||||
"Worked for 5.0s",
|
||||
"the folded marker carries the final elapsed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_fold_attaches_stop_hooks_to_folded_marker() {
|
||||
let mut agent = running_driver("p1");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
|
||||
prompt_id: Some("p1".into()),
|
||||
groups: one_stop_group(),
|
||||
});
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(5)),
|
||||
}),
|
||||
Some("p1"),
|
||||
);
|
||||
|
||||
let markers = completed_markers(&agent.scrollback);
|
||||
assert_eq!(markers.len(), 1);
|
||||
assert_eq!(
|
||||
markers[0].stop_hooks.len(),
|
||||
1,
|
||||
"stop hooks must ride the folded marker"
|
||||
);
|
||||
// A hook-carrying marker rests Collapsed on the fresh-push and
|
||||
// attach_stop_hooks paths; the fold must match.
|
||||
let folded = agent.scrollback.last().expect("folded marker entry");
|
||||
assert_eq!(
|
||||
folded.display_mode,
|
||||
crate::scrollback::types::DisplayMode::Collapsed,
|
||||
"folded marker with stop hooks must rest collapsed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_folds_marker_pushed_by_real_park_path() {
|
||||
// Drive the real park — blocking wait through the tracker, then
|
||||
// `maybe_push_parked_marker` — so the fold's keys (parked + prompt_id)
|
||||
// stay pinned to the production marker shape, not the test helper's.
|
||||
let mut agent = running_driver("p1");
|
||||
super::super::agent_view::test_fixtures::simulate_task_output_wait(&mut agent, "bg-1");
|
||||
agent.maybe_push_parked_marker();
|
||||
let parked = completed_markers(&agent.scrollback);
|
||||
assert_eq!(parked.len(), 1, "real park path must push one marker");
|
||||
assert!(parked[0].parked);
|
||||
assert!(agent.renders_parked());
|
||||
assert_eq!(count_turn_markers(&agent), 0, "the park writes no marker");
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
|
|
@ -644,140 +547,10 @@ fn completion_folds_marker_pushed_by_real_park_path() {
|
|||
Some("p1"),
|
||||
);
|
||||
|
||||
let markers = completed_markers(&agent.scrollback);
|
||||
assert_eq!(
|
||||
markers.len(),
|
||||
count_turn_markers(&agent),
|
||||
1,
|
||||
"completion must fold into the marker the real park path pushed"
|
||||
);
|
||||
assert!(!markers[0].parked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_does_not_fold_across_bg_completion_chip() {
|
||||
// Park → bg task completes (chip lands under the marker) → turn ends:
|
||||
// folding would teleport the boundary above the chip, so this flow
|
||||
// intentionally keeps both markers.
|
||||
let mut agent = running_driver("p1");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
agent.scrollback.push_block(RenderBlock::bg_task_completed(
|
||||
"sleep 5",
|
||||
"task-1",
|
||||
std::time::Duration::from_secs(5),
|
||||
));
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(9)),
|
||||
}),
|
||||
Some("p1"),
|
||||
);
|
||||
|
||||
let markers = completed_markers(&agent.scrollback);
|
||||
assert_eq!(
|
||||
markers.len(),
|
||||
2,
|
||||
"a chip between park and completion keeps both markers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_does_not_fold_committed_parked_marker() {
|
||||
// Minimal mode already printed the parked row (print-once): an in-place
|
||||
// fold would never reach the terminal — a fresh marker must be pushed.
|
||||
let mut agent = running_driver("p1");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
let parked_idx = agent.scrollback.len() - 1;
|
||||
agent.scrollback.mark_committed(parked_idx);
|
||||
agent.scrollback.set_commit_scan_cursor(parked_idx + 1);
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(5)),
|
||||
}),
|
||||
Some("p1"),
|
||||
);
|
||||
|
||||
let markers = completed_markers(&agent.scrollback);
|
||||
assert_eq!(
|
||||
markers.len(),
|
||||
2,
|
||||
"committed tail must not fold: the completion appends a fresh marker"
|
||||
);
|
||||
assert!(markers[0].parked, "the committed parked row is untouched");
|
||||
assert!(!markers[1].parked, "the fresh marker is the real turn end");
|
||||
let fresh = agent.scrollback.last().expect("fresh marker entry");
|
||||
assert!(
|
||||
!agent.scrollback.is_committed(fresh.id),
|
||||
"fresh marker is uncommitted so the commit pass will print it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_does_not_fold_foreign_or_buried_parked_markers() {
|
||||
// Different prompt id at the tail: not this turn's park — push normally.
|
||||
let mut agent = running_driver("p2");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(5)),
|
||||
}),
|
||||
Some("p2"),
|
||||
);
|
||||
assert_eq!(
|
||||
completed_markers(&agent.scrollback).len(),
|
||||
2,
|
||||
"a foreign parked marker must not swallow another turn's completion"
|
||||
);
|
||||
|
||||
// Buried park (agent output rendered after it): the park no longer
|
||||
// explains the tail — the completion pushes its own marker.
|
||||
let mut agent = running_driver("p1");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new("resumed"),
|
||||
));
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(5)),
|
||||
}),
|
||||
Some("p1"),
|
||||
);
|
||||
assert_eq!(
|
||||
completed_markers(&agent.scrollback).len(),
|
||||
2,
|
||||
"a buried parked marker must not fold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_never_folds_into_a_parked_marker() {
|
||||
// A parked "Worked for" is a completion-shaped boundary; a failure is a
|
||||
// different outcome and must render as its own row beneath it.
|
||||
let mut agent = running_driver("p1");
|
||||
push_parked_tail(&mut agent, "p1", 3);
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnFailed {
|
||||
error: "boom".into(),
|
||||
elapsed: Some(std::time::Duration::from_secs(5)),
|
||||
}),
|
||||
Some("p1"),
|
||||
);
|
||||
assert_eq!(
|
||||
completed_markers(&agent.scrollback).len(),
|
||||
1,
|
||||
"the parked marker stays"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnFailed { .. })
|
||||
),
|
||||
"the failure renders as its own row"
|
||||
"the real turn end pushes exactly one marker"
|
||||
);
|
||||
assert_eq!(last_marker_block(&agent).event.message(), "Worked for 5.0s");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use xai_grok_shell::sampling::types::{
|
|||
use xai_grok_shell::util::config as cli_config;
|
||||
|
||||
use crate::acp::model_state::{EffortTokenError, ModelState};
|
||||
use crate::acp::spawn::spawn_grok_shell;
|
||||
use crate::acp::spawn::{AgentShutdownGuard, spawn_grok_shell};
|
||||
use crate::client_identity::{HEADLESS_CLIENT_TYPE, PAGER_CLIENT_VERSION};
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -928,6 +928,8 @@ pub async fn run_single_turn(
|
|||
anyhow::bail!("{msg}");
|
||||
}
|
||||
};
|
||||
// Cancel + join on every return path (success or bail).
|
||||
let _agent_guard = AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
|
||||
let (acp_tx, mut acp_rx) = (spawned.channel.tx, spawned.channel.rx);
|
||||
crate::unified_log::init(acp_tx.clone());
|
||||
crate::unified_log::info(
|
||||
|
|
@ -947,7 +949,6 @@ pub async fn run_single_turn(
|
|||
Err(e) => {
|
||||
let msg = format!("Couldn't initialize: {e}");
|
||||
emitter.on_error(&msg);
|
||||
cancel.cancel();
|
||||
anyhow::bail!("{msg}");
|
||||
}
|
||||
};
|
||||
|
|
@ -969,7 +970,6 @@ pub async fn run_single_turn(
|
|||
Ok(is_api_key) => is_api_key,
|
||||
Err(e) => {
|
||||
emitter.on_error(&e.to_string());
|
||||
cancel.cancel();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
|
@ -1041,7 +1041,6 @@ pub async fn run_single_turn(
|
|||
Err(e) => {
|
||||
let msg = format!("Couldn't create session: {e}");
|
||||
emitter.on_error(&msg);
|
||||
cancel.cancel();
|
||||
anyhow::bail!("{msg}");
|
||||
}
|
||||
};
|
||||
|
|
@ -1075,7 +1074,6 @@ pub async fn run_single_turn(
|
|||
{
|
||||
let msg = e.to_string();
|
||||
emitter.on_error(&msg);
|
||||
cancel.cancel();
|
||||
anyhow::bail!("{msg}");
|
||||
}
|
||||
|
||||
|
|
@ -1177,7 +1175,6 @@ pub async fn run_single_turn(
|
|||
msg = acp_rx.recv() => {
|
||||
let Some(msg) = msg else {
|
||||
emitter.on_error("Connection closed unexpectedly");
|
||||
cancel.cancel();
|
||||
anyhow::bail!("Connection closed unexpectedly");
|
||||
};
|
||||
handle_headless_acp_message(
|
||||
|
|
@ -1253,7 +1250,7 @@ pub async fn run_single_turn(
|
|||
// Non-blocking flock so a slow/network ~/.grok can't hang exit.
|
||||
let _ = xai_grok_shell::active_sessions::try_unregister(&session_id);
|
||||
}
|
||||
cancel.cancel();
|
||||
// Agent cancel + join (SessionEnd flush) runs in AgentShutdownGuard::drop.
|
||||
match prompt_result {
|
||||
Some(Ok(resp)) => {
|
||||
let stop_reason = format!("{:?}", resp.stop_reason);
|
||||
|
|
|
|||
|
|
@ -562,10 +562,7 @@ pub fn resolve_turn_activity(v: &AgentView) -> Option<TurnActivity> {
|
|||
v.resolve_turn_activity()
|
||||
}
|
||||
|
||||
/// [`AgentView::renders_parked`] — while the parked-wait marker's turn is
|
||||
/// parked, minimal renders the "… still running" cue (watchers running) or the
|
||||
/// idle hint (none), mirroring the full TUI. The marker itself is pushed by
|
||||
/// the shared ACP notification path, so minimal's scrollback carries it too.
|
||||
/// [`AgentView::renders_parked`].
|
||||
pub fn renders_parked(v: &AgentView) -> bool {
|
||||
v.renders_parked()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ pub async fn list_available_models(agent_config: &AgentConfig) -> Result<()> {
|
|||
|
||||
let cancel = CancellationToken::new();
|
||||
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
|
||||
// Cancel + join on every return path, including the `?` below.
|
||||
let _agent_guard =
|
||||
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
|
||||
|
||||
let state = list_models(&spawned.channel.tx, PAGER_CLIENT_TYPE, PAGER_CLIENT_VERSION).await?;
|
||||
|
||||
|
|
@ -35,6 +38,5 @@ pub async fn list_available_models(agent_config: &AgentConfig) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
cancel.cancel();
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,10 +271,7 @@ impl SessionEvent {
|
|||
|
||||
/// Whether this event marks the end of an agent turn (the "Turn
|
||||
/// completed/cancelled/failed" markers). These are the only events that
|
||||
/// can carry the turn's stop/stop_failure hook runs inline — but a
|
||||
/// parked marker renders mid-turn while the turn is still running
|
||||
/// shell-side, before any Stop hook fires, so hook eligibility is the
|
||||
/// block-level [`SessionEventBlock::accepts_stop_hooks`].
|
||||
/// can carry the turn's stop/stop_failure hook runs inline.
|
||||
pub fn is_turn_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
|
|
@ -312,12 +309,6 @@ pub struct SessionEventBlock {
|
|||
/// The prompt turn a terminal marker belongs to, when known. Gates
|
||||
/// which stop-hook batches may merge into it.
|
||||
pub prompt_id: Option<String>,
|
||||
/// The marker was pushed at park time (user-interruptible blocking
|
||||
/// wait): the turn is still running shell-side, so it must never accept
|
||||
/// stop hooks. Rendering is unchanged — a parked wait reads as stopped.
|
||||
/// Cleared when the completion folds into the uncommitted tail marker;
|
||||
/// a committed tail (minimal print-once) gets a fresh row instead.
|
||||
pub parked: bool,
|
||||
}
|
||||
|
||||
impl SessionEventBlock {
|
||||
|
|
@ -327,7 +318,6 @@ impl SessionEventBlock {
|
|||
event,
|
||||
stop_hooks: Vec::new(),
|
||||
prompt_id: None,
|
||||
parked: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,17 +332,9 @@ impl SessionEventBlock {
|
|||
event,
|
||||
stop_hooks,
|
||||
prompt_id,
|
||||
parked: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this marker may carry/accept stop-hook runs: a turn-terminal
|
||||
/// event that is not a parked line (which renders while the turn is
|
||||
/// still running shell-side, before any Stop hook fires).
|
||||
pub fn accepts_stop_hooks(&self) -> bool {
|
||||
self.event.is_turn_terminal() && !self.parked
|
||||
}
|
||||
|
||||
/// Whether any attached stop hook actually ran (non-skipped). Gates the
|
||||
/// fold/selection affordances and the inline summary, mirroring
|
||||
/// [`ToolCallHookData::has_content`](super::tool::ToolCallHookData::has_content).
|
||||
|
|
@ -1344,44 +1326,16 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// A parked marker block — the shape `maybe_push_parked_marker` pushes.
|
||||
fn parked_marker() -> SessionEventBlock {
|
||||
SessionEventBlock {
|
||||
event: SessionEvent::TurnCompleted {
|
||||
elapsed: Some(Duration::from_secs(24)),
|
||||
},
|
||||
stop_hooks: Vec::new(),
|
||||
prompt_id: None,
|
||||
parked: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_markers_never_accept_stop_hooks() {
|
||||
// A parked marker renders mid-turn, before any Stop hook fires.
|
||||
let block = parked_marker();
|
||||
assert!(!block.accepts_stop_hooks(), "parked marker refuses hooks");
|
||||
|
||||
// The real terminal marker accepts.
|
||||
fn only_turn_terminal_events_accept_stop_hooks() {
|
||||
let settled = SessionEventBlock::new(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(Duration::from_secs(24)),
|
||||
});
|
||||
assert!(settled.accepts_stop_hooks());
|
||||
// Non-terminal events never accept, parked or not.
|
||||
assert!(settled.event.is_turn_terminal());
|
||||
let recap = SessionEventBlock::new(SessionEvent::Recap {
|
||||
summary: "did stuff".into(),
|
||||
auto: false,
|
||||
});
|
||||
assert!(!recap.accepts_stop_hooks());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_marker_output_reads_as_plain_completed_marker() {
|
||||
// The parked marker renders the plain event text — still-running
|
||||
// background work is the status row's "… still running" cue, never a
|
||||
// transcript suffix.
|
||||
let block = parked_marker();
|
||||
let out = block.output(&ctx());
|
||||
assert_eq!(plain(&out.lines[0]), "Worked for 24s");
|
||||
assert!(!recap.event.is_turn_terminal());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -767,11 +767,7 @@ impl ScrollbackState {
|
|||
/// failed") that can accept a live `stop`/`stop_failure` batch arriving
|
||||
/// after the marker (viewer order). The walk skips blocks appended after
|
||||
/// the marker. A stamped batch needs the marker to carry the same prompt
|
||||
/// id — and treats parked markers as transparent: they never
|
||||
/// accept hooks themselves (their turn is still running), and pid-exact
|
||||
/// attribution cannot misattach, so a late prior-turn batch may cross
|
||||
/// the current turn's not-yet-settled boundary into its own turn's
|
||||
/// marker. An unstamped batch is positional (tail only) and stops at ANY
|
||||
/// id. An unstamped batch is positional (tail only) and stops at ANY
|
||||
/// terminal-event marker — without a pid there is no proof it belongs
|
||||
/// further back. A same-name repeat (e.g. the session-end `stop`) is
|
||||
/// always refused.
|
||||
|
|
@ -787,14 +783,6 @@ impl ScrollbackState {
|
|||
if !b.event.is_turn_terminal() {
|
||||
continue;
|
||||
}
|
||||
if !b.accepts_stop_hooks() {
|
||||
// Parked: transparent to a stamped batch, a hard stop for
|
||||
// a positional one.
|
||||
if batch_prompt_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if b.stop_hooks.iter().any(|(name, _)| name == event_name) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -812,9 +800,8 @@ impl ScrollbackState {
|
|||
/// entry and collapse it so the right-justified summary — not the
|
||||
/// fold-out detail — is the resting state. Returns `false` unless the
|
||||
/// entry is a turn-terminal session event the batch can be attributed to
|
||||
/// (see [`Self::latest_turn_marker_accepting`]) — never a parked
|
||||
/// marker; re-checked here so a stray caller can't attach hooks to the
|
||||
/// wrong entry.
|
||||
/// (see [`Self::latest_turn_marker_accepting`]); re-checked here so a
|
||||
/// stray caller can't attach hooks to the wrong entry.
|
||||
pub fn attach_stop_hooks_to_marker(
|
||||
&mut self,
|
||||
id: EntryId,
|
||||
|
|
@ -828,7 +815,7 @@ impl ScrollbackState {
|
|||
let RenderBlock::SessionEvent(ref mut block) = entry.block else {
|
||||
return false;
|
||||
};
|
||||
if !block.accepts_stop_hooks() {
|
||||
if !block.event.is_turn_terminal() {
|
||||
return false;
|
||||
}
|
||||
let attributable = match (batch_prompt_id, block.prompt_id.as_deref()) {
|
||||
|
|
@ -848,59 +835,6 @@ impl ScrollbackState {
|
|||
true
|
||||
}
|
||||
|
||||
/// Fold a turn completion into a tail-adjacent parked "Worked for X"
|
||||
/// marker from the same prompt turn: the parked row already IS the
|
||||
/// turn's boundary, so it takes the final elapsed + stop hooks in place
|
||||
/// (unparked) instead of an identical row stacking beneath it. Returns
|
||||
/// `false` (caller pushes a fresh marker) when the tail doesn't match,
|
||||
/// the event isn't a completion (a failure/cancel is a different
|
||||
/// outcome), or minimal mode already committed the row — print-once: an
|
||||
/// in-place mutation would never reach the terminal.
|
||||
pub fn fold_completion_into_tail_parked_marker(
|
||||
&mut self,
|
||||
event: &super::blocks::SessionEvent,
|
||||
stop_hooks: &[(String, Vec<super::blocks::tool::HookRunEntry>)],
|
||||
prompt_id: Option<&str>,
|
||||
) -> bool {
|
||||
use super::blocks::SessionEvent;
|
||||
// `is_none` also keeps `None` from matching a pid-less parked marker.
|
||||
if prompt_id.is_none() || !matches!(event, SessionEvent::TurnCompleted { .. }) {
|
||||
return false;
|
||||
}
|
||||
let tail_match = self.last().and_then(|entry| match &entry.block {
|
||||
RenderBlock::SessionEvent(b) if b.parked && b.prompt_id.as_deref() == prompt_id => {
|
||||
Some(entry.id)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let Some(id) = tail_match else {
|
||||
return false;
|
||||
};
|
||||
if self.is_committed(id) {
|
||||
return false;
|
||||
}
|
||||
let Some(entry) = self.entries.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
let RenderBlock::SessionEvent(ref mut b) = entry.block else {
|
||||
return false;
|
||||
};
|
||||
b.event = event.clone();
|
||||
b.parked = false;
|
||||
b.stop_hooks = stop_hooks.to_vec();
|
||||
// A hook-carrying marker rests Collapsed (the right-justified summary)
|
||||
// on every sibling path — fresh pushes via `default_display_mode` and
|
||||
// `attach_stop_hooks_to_marker` — so the fold matches.
|
||||
if b.has_stop_hook_content() && !entry.display_mode_pinned {
|
||||
entry.display_mode = DisplayMode::Collapsed;
|
||||
}
|
||||
entry.invalidate_cache();
|
||||
self.mark_structurally_dirty(id);
|
||||
// The marker's searchable text changed (parked elapsed → final).
|
||||
self.bump_content_generation();
|
||||
true
|
||||
}
|
||||
|
||||
/// Push a text chunk to an agent message entry.
|
||||
///
|
||||
/// This is the preferred way to append streaming content because it:
|
||||
|
|
@ -2264,17 +2198,6 @@ mod tests {
|
|||
};
|
||||
|
||||
let mut state = ScrollbackState::new();
|
||||
// A parked marker renders mid-turn — it must never accept hooks, at
|
||||
// the lookup and at the mutation site alike.
|
||||
let mut parked_block =
|
||||
crate::scrollback::blocks::SessionEventBlock::new(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(1)),
|
||||
});
|
||||
parked_block.parked = true;
|
||||
let parked = state.push_block(RenderBlock::SessionEvent(parked_block));
|
||||
assert_eq!(state.latest_turn_marker_accepting("stop", None), None);
|
||||
assert!(!state.attach_stop_hooks_to_marker(parked, "stop".into(), entries(), None));
|
||||
|
||||
let marker = state.push_block(RenderBlock::session_event(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}));
|
||||
|
|
@ -2412,68 +2335,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamped_stop_hooks_cross_parked_marker_to_their_turns_marker() {
|
||||
use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
|
||||
use crate::scrollback::blocks::{SessionEvent, SessionEventBlock};
|
||||
let entries = || {
|
||||
vec![HookRunEntry {
|
||||
name: "h".into(),
|
||||
status: HookRunStatus::Success {
|
||||
elapsed: std::time::Duration::from_millis(1),
|
||||
},
|
||||
output: None,
|
||||
}]
|
||||
};
|
||||
|
||||
// A prior turn's settled marker, then the current turn's parked
|
||||
// EndLine at the tail (viewer/reattach shape).
|
||||
let mut state = ScrollbackState::new();
|
||||
let prior = state.push_block(RenderBlock::SessionEvent(
|
||||
SessionEventBlock::with_stop_hooks(
|
||||
SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
},
|
||||
Vec::new(),
|
||||
Some("pid-a".into()),
|
||||
),
|
||||
));
|
||||
let mut parked_block = SessionEventBlock::new(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(1)),
|
||||
});
|
||||
parked_block.parked = true;
|
||||
parked_block.prompt_id = Some("pid-b".into());
|
||||
let parked = state.push_block(RenderBlock::SessionEvent(parked_block));
|
||||
|
||||
// A late batch stamped for the PRIOR turn crosses the parked marker
|
||||
// (pid-exact attribution cannot misattach) and merges into its own
|
||||
// turn's marker; the parked marker itself is untouched.
|
||||
assert_eq!(
|
||||
state.latest_turn_marker_accepting("stop", Some("pid-a")),
|
||||
Some(prior)
|
||||
);
|
||||
assert!(state.attach_stop_hooks_to_marker(prior, "stop".into(), entries(), Some("pid-a")));
|
||||
match &state.get_by_id(parked).unwrap().block {
|
||||
RenderBlock::SessionEvent(b) => {
|
||||
assert!(b.parked);
|
||||
assert!(b.stop_hooks.is_empty(), "the parked marker stays clean");
|
||||
}
|
||||
other => panic!("expected the parked marker, got {other:?}"),
|
||||
}
|
||||
|
||||
// The parked turn's own pid still never accepts (its Stop hooks
|
||||
// cannot have fired yet), and an unstamped positional batch stops at
|
||||
// the parked tail marker as before.
|
||||
assert_eq!(
|
||||
state.latest_turn_marker_accepting("stop_failure", Some("pid-b")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
state.latest_turn_marker_accepting("stop_failure", None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A finished user `!` command expands to its full output; a Collapsed
|
||||
/// entry keeps its fold (no snap-open at completion).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ pub mod defs;
|
|||
pub mod registry;
|
||||
|
||||
pub use registry::{
|
||||
DynamicEnumSource, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory,
|
||||
SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry,
|
||||
StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode,
|
||||
CodingDataSharingLock, DynamicEnumSource, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot,
|
||||
SettingCategory, SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue,
|
||||
SettingsRegistry, StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode,
|
||||
canonical_voice_capture_mode, canonical_voice_stt_language, current_value_for,
|
||||
default_value_for, dynamic_enum_choices,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -226,6 +226,23 @@ pub enum SettingValue {
|
|||
Int(i64),
|
||||
}
|
||||
|
||||
/// Why `coding_data_sharing` cannot be changed in the settings modal.
|
||||
/// Computed by `AppView::coding_data_sharing_lock`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodingDataSharingLock {
|
||||
Zdr,
|
||||
TeamManaged,
|
||||
}
|
||||
|
||||
impl CodingDataSharingLock {
|
||||
pub fn reason(self) -> &'static str {
|
||||
match self {
|
||||
Self::Zdr => "Your team has Zero Data Retention.",
|
||||
Self::TeamManaged => "Managed by your team admin.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of pager-local state captured when the modal opens.
|
||||
/// Used by `current_value_for` to render against LIVE state rather
|
||||
/// than the on-disk `UiConfig`. Refreshed by
|
||||
|
|
@ -252,6 +269,8 @@ pub struct PagerLocalSnapshot {
|
|||
/// `opt_out == false` → canonical "opt-in". Snapshot default is
|
||||
/// `true` (opted out) to match the safer consumer default.
|
||||
pub coding_data_sharing_opt_out: bool,
|
||||
/// Why `coding_data_sharing` cannot be changed here (`None` = editable).
|
||||
pub coding_data_sharing_lock: Option<CodingDataSharingLock>,
|
||||
/// Whether plan mode is active. Uses effective state
|
||||
/// (`pending.unwrap_or(active)`) so rapid toggles don't double-send.
|
||||
/// Refreshed on all mutation paths including ACP `CurrentModeUpdate`.
|
||||
|
|
@ -291,6 +310,7 @@ impl Default for PagerLocalSnapshot {
|
|||
current_model_name: None,
|
||||
available_models: Vec::new(),
|
||||
coding_data_sharing_opt_out: true,
|
||||
coding_data_sharing_lock: None,
|
||||
plan_mode_active: false,
|
||||
show_tips: None,
|
||||
auto_update: None,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ pub struct ShareArgs {
|
|||
pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> {
|
||||
let cancel = CancellationToken::new();
|
||||
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
|
||||
// Cancel + join on every return path, including the `?`s below.
|
||||
let _agent_guard =
|
||||
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
|
||||
|
||||
let _init: acp::InitializeResponse = acp_send(
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
||||
|
|
@ -44,6 +47,5 @@ pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> {
|
|||
let response: ShareSessionResponse = serde_json::from_str(ext_resp.0.get())?;
|
||||
|
||||
println!("{}", response.share_url);
|
||||
cancel.cancel();
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -571,7 +571,7 @@ pub fn render_peek_panel(
|
|||
live_tail: Option<PeekLiveTailArgs<'_>>,
|
||||
empty_hint: Option<&str>,
|
||||
) -> PeekRenderResult {
|
||||
use crate::views::prompt_widget::PromptStyle;
|
||||
use crate::views::prompt_widget::{PromptBg, PromptStyle};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
if area.area() == 0 || area.height < 3 || area.width < 20 {
|
||||
|
|
@ -724,7 +724,7 @@ pub fn render_peek_panel(
|
|||
show_prefix: false,
|
||||
vpad_top: 0,
|
||||
chrome: false,
|
||||
bg_override: Some(theme.bg_base),
|
||||
bg: PromptBg::Canvas(theme.bg_base),
|
||||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
};
|
||||
|
|
@ -863,7 +863,7 @@ pub fn render_peek_panel(
|
|||
show_prefix: false,
|
||||
vpad_top: 0,
|
||||
chrome: false,
|
||||
bg_override: Some(theme.bg_base),
|
||||
bg: PromptBg::Canvas(theme.bg_base),
|
||||
placeholder_override: Some("reply\u{2026}"),
|
||||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
|
|
|
|||
|
|
@ -2852,7 +2852,7 @@ fn render_dispatch(
|
|||
) -> Option<(u16, u16)> {
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
|
||||
use crate::views::prompt_widget::PromptStyle;
|
||||
use crate::views::prompt_widget::{PromptBg, PromptStyle};
|
||||
|
||||
if area.area() == 0 {
|
||||
return None;
|
||||
|
|
@ -3028,7 +3028,7 @@ fn render_dispatch(
|
|||
show_prefix: true,
|
||||
vpad_top: 0,
|
||||
chrome: false,
|
||||
bg_override: Some(theme.bg_base),
|
||||
bg: PromptBg::Canvas(theme.bg_base),
|
||||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -158,10 +158,8 @@ pub struct PromptStyle {
|
|||
/// Only used when `chrome` is true.
|
||||
pub chrome_pad_left: u16,
|
||||
pub chrome_pad_right: u16,
|
||||
/// Override the background color. When `Some`, the prompt uses this bg
|
||||
/// instead of computing one from focus state. Useful for rendering the
|
||||
/// prompt inline within another widget (e.g., question view).
|
||||
pub bg_override: Option<ratatui::style::Color>,
|
||||
/// Background surface for the prompt; see [`PromptBg`].
|
||||
pub bg: PromptBg,
|
||||
/// Override the accent line color. When `Some`, uses this color instead
|
||||
/// of the default `accent_user` / `gray_dim`. Used for plan mode (golden).
|
||||
pub accent_color_override: Option<ratatui::style::Color>,
|
||||
|
|
@ -194,6 +192,35 @@ pub struct PromptStyle {
|
|||
pub image_preview: bool,
|
||||
}
|
||||
|
||||
/// Background for the prompt widget.
|
||||
///
|
||||
/// Paste chips bake `theme.paste_bg` — a badge color tuned for the default
|
||||
/// canvas — into their display `Line` at paste time, so the background says
|
||||
/// what *kind* of surface the prompt sits on, not just its color.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PromptBg {
|
||||
/// The standalone prompt's default fill (`theme.bg_base`).
|
||||
#[default]
|
||||
Default,
|
||||
/// Explicit canvas color for prompts rendered inline within another
|
||||
/// widget whose surface matches the main prompt's (dashboard dispatch
|
||||
/// box, peek reply). Chips keep their badge background.
|
||||
Canvas(ratatui::style::Color),
|
||||
/// Inline panel color (question freeform input, permission follow-up).
|
||||
/// Chip cells are repainted to blend into the panel.
|
||||
Panel(ratatui::style::Color),
|
||||
}
|
||||
|
||||
impl PromptBg {
|
||||
/// Effective fill color; `default` is the standalone prompt's.
|
||||
fn color(self, default: ratatui::style::Color) -> ratatui::style::Color {
|
||||
match self {
|
||||
Self::Default => default,
|
||||
Self::Canvas(c) | Self::Panel(c) => c,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PromptStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -203,7 +230,7 @@ impl Default for PromptStyle {
|
|||
chrome: true,
|
||||
chrome_pad_left: 2,
|
||||
chrome_pad_right: 1,
|
||||
bg_override: None,
|
||||
bg: PromptBg::Default,
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
@ -237,7 +264,7 @@ impl PromptStyle {
|
|||
chrome: false,
|
||||
chrome_pad_left: 0,
|
||||
chrome_pad_right: 0,
|
||||
bg_override: Some(bg),
|
||||
bg: PromptBg::Panel(bg),
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
@ -986,6 +1013,17 @@ impl PromptWidget {
|
|||
self.update_file_search_context();
|
||||
}
|
||||
|
||||
/// [`Self::set_text`] unless the buffer already holds exactly `text`.
|
||||
///
|
||||
/// Skipping the no-op swap keeps chip elements, images, and undo history
|
||||
/// intact when a surface reloads an unchanged draft (the question view's
|
||||
/// freeform slots); any real content change takes the normal reset path.
|
||||
pub fn set_text_preserving(&mut self, text: &str) {
|
||||
if self.text() != text {
|
||||
self.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append plain text at the end without replacing existing chip elements.
|
||||
pub fn append_text(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
|
|
@ -2856,11 +2894,7 @@ impl PromptWidget {
|
|||
}
|
||||
|
||||
let theme = Theme::current();
|
||||
let bg = if let Some(override_bg) = style.bg_override {
|
||||
override_bg
|
||||
} else {
|
||||
theme.bg_base
|
||||
};
|
||||
let bg = style.bg.color(theme.bg_base);
|
||||
|
||||
let border_color = style.border_color_override.unwrap_or(if style.focused {
|
||||
theme.prompt_border_active
|
||||
|
|
@ -2987,6 +3021,21 @@ impl PromptWidget {
|
|||
|
||||
(&self.textarea).render_ref(ta_area, buf, &mut self.textarea_state);
|
||||
|
||||
// Chip bg remap (see `PromptBg::Panel`): chip `Line`s bake in
|
||||
// `paste_bg` at paste time and the same element can render on
|
||||
// multiple surfaces, so restyle at paint time.
|
||||
if matches!(style.bg, PromptBg::Panel(_)) && bg != theme.paste_bg {
|
||||
for y in ta_area.top()..ta_area.bottom() {
|
||||
for x in ta_area.left()..ta_area.right() {
|
||||
if let Some(cell) = buf.cell_mut((x, y))
|
||||
&& cell.bg == theme.paste_bg
|
||||
{
|
||||
cell.bg = bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slash overlays: teal command name + args ghost text. Both use the
|
||||
// same snapshot, so clone once. Capture flags for later ghost text
|
||||
// suppression to avoid a second clone.
|
||||
|
|
@ -3194,8 +3243,8 @@ impl PromptWidget {
|
|||
}
|
||||
|
||||
// Unfocused dimming: blend fg toward bg (bg already precomputed above).
|
||||
// Skip when bg_override is set — the prompt is inline in another widget.
|
||||
if !style.focused && style.bg_override.is_none() {
|
||||
// Skip when the bg is overridden — the prompt is inline in another widget.
|
||||
if !style.focused && style.bg == PromptBg::Default {
|
||||
// Dim only the content inside the box (skip all border chars).
|
||||
let dim_area = Rect {
|
||||
x: area.x + 1,
|
||||
|
|
|
|||
|
|
@ -4573,3 +4573,53 @@
|
|||
let buf = draw_bordered(11, &title_test_style(Some("my session")));
|
||||
assert_eq!(buf_text_at(&buf, 1, 10, 0), "\u{2500}".repeat(9));
|
||||
}
|
||||
|
||||
// ── PromptBg::Panel chip remap (inline surfaces) ────────────────
|
||||
|
||||
fn any_cell_with_bg(buf: &Buffer, bg: ratatui::style::Color) -> bool {
|
||||
let area = *buf.area();
|
||||
(area.top()..area.bottom())
|
||||
.any(|y| (area.left()..area.right()).any(|x| buf.cell((x, y)).is_some_and(|c| c.bg == bg)))
|
||||
}
|
||||
|
||||
/// Inline surfaces repaint the chip's baked-in `paste_bg` to the panel
|
||||
/// background; without the flag the chip keeps its own background. Uses
|
||||
/// a sentinel panel color so the test holds under terminal-default,
|
||||
/// where every palette entry quantizes to `Color::Reset`.
|
||||
#[test]
|
||||
fn panel_bg_repaints_paste_chip_to_panel_bg() {
|
||||
let theme = Theme::current();
|
||||
let panel = ratatui::style::Color::Rgb(12, 34, 56);
|
||||
assert_ne!(theme.paste_bg, panel, "fixture: sentinel must differ");
|
||||
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.handle_paste("a\nb\nc\nd\ne"); // 5 lines >= chip threshold (4)
|
||||
let area = Rect::new(0, 0, 40, 2);
|
||||
|
||||
let inline = PromptStyle::inline(panel);
|
||||
assert!(
|
||||
matches!(inline.bg, PromptBg::Panel(_)),
|
||||
"inline surfaces are panels"
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pw.draw(&mut buf, area, None, &inline, None, None);
|
||||
assert!(
|
||||
!any_cell_with_bg(&buf, theme.paste_bg),
|
||||
"chip cells must be repainted to the panel background"
|
||||
);
|
||||
assert!(
|
||||
any_cell_with_bg(&buf, panel),
|
||||
"the chip row renders on the panel background"
|
||||
);
|
||||
|
||||
let no_remap = PromptStyle {
|
||||
bg: PromptBg::Canvas(panel),
|
||||
..PromptStyle::inline(panel)
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
pw.draw(&mut buf, area, None, &no_remap, None, None);
|
||||
assert!(
|
||||
any_cell_with_bg(&buf, theme.paste_bg),
|
||||
"without the remap the chip keeps its own background"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ use super::state::{
|
|||
};
|
||||
use crate::render::line_utils::truncate_str;
|
||||
use crate::settings::{
|
||||
OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue, StringValidator,
|
||||
dynamic_enum_choices,
|
||||
CodingDataSharingLock, OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue,
|
||||
StringValidator, dynamic_enum_choices,
|
||||
};
|
||||
use crate::theme::Theme;
|
||||
use crate::views::modal_window::{
|
||||
|
|
@ -644,9 +644,9 @@ pub(super) fn render_rows(
|
|||
width: area.width,
|
||||
height: desc_height.min(8),
|
||||
};
|
||||
render_expanded_description(buf, desc_rect, meta, theme);
|
||||
render_expanded_description(buf, desc_rect, meta, None, theme);
|
||||
let consumed =
|
||||
wrapped_description_height(meta, area.width, desc_rect.height);
|
||||
wrapped_description_height(meta, None, area.width, desc_rect.height);
|
||||
y_cursor = y_cursor.saturating_add(consumed);
|
||||
}
|
||||
continue;
|
||||
|
|
@ -668,25 +668,10 @@ pub(super) fn render_rows(
|
|||
}
|
||||
};
|
||||
|
||||
let lock = state.row_lock(key);
|
||||
|
||||
// Decide 1 vs 2 line layout; fall back to 1 if viewport is tight.
|
||||
let value_display = match value {
|
||||
SettingValue::Bool(b) => {
|
||||
if *b {
|
||||
"on".to_string()
|
||||
} else {
|
||||
"off".to_string()
|
||||
}
|
||||
}
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)".to_string()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
|
||||
SettingValue::Int(i) => i.to_string(),
|
||||
};
|
||||
let value_display = value_display(meta, value, lock);
|
||||
let show_restart_pill_for_layout = meta.restart_required && is_expanded;
|
||||
let layout_decision = row_layout(
|
||||
area.width,
|
||||
|
|
@ -722,6 +707,7 @@ pub(super) fn render_rows(
|
|||
theme,
|
||||
is_expanded,
|
||||
is_hovered,
|
||||
lock,
|
||||
);
|
||||
state.value_hit_rects[row_idx] = value_rect;
|
||||
y_cursor = y_cursor.saturating_add(row_height);
|
||||
|
|
@ -734,10 +720,12 @@ pub(super) fn render_rows(
|
|||
width: area.width,
|
||||
height: desc_height.min(8), // cap at 8 lines per row to keep scroll sane
|
||||
};
|
||||
render_expanded_description(buf, desc_rect, meta, theme);
|
||||
let lock_reason = lock.map(CodingDataSharingLock::reason);
|
||||
render_expanded_description(buf, desc_rect, meta, lock_reason, theme);
|
||||
// Re-measure how many lines the wrapped description
|
||||
// actually consumed, so y_cursor advances precisely.
|
||||
let consumed = wrapped_description_height(meta, area.width, desc_rect.height);
|
||||
let consumed =
|
||||
wrapped_description_height(meta, lock_reason, area.width, desc_rect.height);
|
||||
y_cursor = y_cursor.saturating_add(consumed);
|
||||
}
|
||||
}
|
||||
|
|
@ -824,7 +812,7 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
if matches!(meta.kind, SettingKind::Group { .. }) {
|
||||
let mut h: u16 = 1;
|
||||
if state.expanded_keys.contains(key) {
|
||||
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
|
||||
h = h.saturating_add(wrapped_description_height(meta, None, area_width, 8));
|
||||
}
|
||||
heights.push(h);
|
||||
continue;
|
||||
|
|
@ -834,24 +822,8 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
continue;
|
||||
};
|
||||
let is_expanded = state.expanded_keys.contains(key);
|
||||
let value_display = match &value {
|
||||
SettingValue::Bool(b) => {
|
||||
if *b {
|
||||
"on".to_string()
|
||||
} else {
|
||||
"off".to_string()
|
||||
}
|
||||
}
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)".to_string()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
|
||||
SettingValue::Int(i) => i.to_string(),
|
||||
};
|
||||
let lock = state.row_lock(key);
|
||||
let value_display = value_display(meta, &value, lock);
|
||||
let show_restart_pill = meta.restart_required && is_expanded;
|
||||
let layout = row_layout(area_width, meta.label, &value_display, show_restart_pill);
|
||||
let mut h: u16 = match layout {
|
||||
|
|
@ -861,7 +833,12 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
if is_expanded {
|
||||
// Cap matches the forward render loop at line
|
||||
// 2040 (`desc_rect.height = ... .min(8)`).
|
||||
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
|
||||
h = h.saturating_add(wrapped_description_height(
|
||||
meta,
|
||||
lock.map(CodingDataSharingLock::reason),
|
||||
area_width,
|
||||
8,
|
||||
));
|
||||
}
|
||||
heights.push(h);
|
||||
}
|
||||
|
|
@ -871,13 +848,19 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
}
|
||||
|
||||
/// Wrapped description height for scroll math (mirrors render path).
|
||||
fn wrapped_description_height(meta: &SettingMeta, area_width: u16, cap: u16) -> u16 {
|
||||
fn wrapped_description_height(
|
||||
meta: &SettingMeta,
|
||||
lock_reason: Option<&'static str>,
|
||||
area_width: u16,
|
||||
cap: u16,
|
||||
) -> u16 {
|
||||
let indent = 4u16.min(area_width);
|
||||
let wrap_w = area_width.saturating_sub(indent);
|
||||
if wrap_w == 0 {
|
||||
return 0;
|
||||
}
|
||||
let line = Line::from(Span::raw(meta.description));
|
||||
let text = lock_reason.unwrap_or(meta.description);
|
||||
let line = Line::from(Span::raw(text));
|
||||
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
|
||||
(wrapped.len() as u16).min(cap)
|
||||
}
|
||||
|
|
@ -2230,6 +2213,37 @@ const ROW_CHEVRON_W: u16 = 2;
|
|||
/// Chevron column width — reserved for all rows for alignment.
|
||||
pub(super) const ROW_CHEVRON_COL_W: u16 = ROW_CHEVRON_W;
|
||||
const ROW_RESTART_PILL_W: u16 = 10; // " · restart" — used for layout budgeting only.
|
||||
/// Appended to the value column of a locked row (see `SettingsModalState::row_lock`).
|
||||
pub(super) const ROW_ADMIN_MANAGED_SUFFIX: &str = " \u{00B7} Admin Managed";
|
||||
/// Value column for ZDR-locked rows — replaces the opt-in/out value entirely.
|
||||
pub(super) const ROW_ZDR_VALUE: &str = "ZDR";
|
||||
|
||||
/// Value-column text, shared by layout, scroll math, and paint.
|
||||
pub(super) fn value_display(
|
||||
meta: &SettingMeta,
|
||||
value: &SettingValue,
|
||||
lock: Option<CodingDataSharingLock>,
|
||||
) -> String {
|
||||
if lock == Some(CodingDataSharingLock::Zdr) {
|
||||
return ROW_ZDR_VALUE.to_string();
|
||||
}
|
||||
let mut display = match value {
|
||||
SettingValue::Bool(b) => if *b { "on" } else { "off" }.to_string(),
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)".to_string()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
|
||||
SettingValue::Int(i) => i.to_string(),
|
||||
};
|
||||
if lock == Some(CodingDataSharingLock::TeamManaged) {
|
||||
display.push_str(ROW_ADMIN_MANAGED_SUFFIX);
|
||||
}
|
||||
display
|
||||
}
|
||||
|
||||
/// Per-row layout decision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -2309,6 +2323,7 @@ pub(super) fn render_setting_row(
|
|||
theme: &Theme,
|
||||
is_expanded: bool,
|
||||
is_hovered: bool,
|
||||
lock: Option<CodingDataSharingLock>,
|
||||
) -> Rect {
|
||||
let bg = settings_list_row_bg(theme, is_selected, is_hovered);
|
||||
// Paint the row bg across the full area (1 or 2 lines).
|
||||
|
|
@ -2327,43 +2342,24 @@ pub(super) fn render_setting_row(
|
|||
.add_modifier(Modifier::ITALIC);
|
||||
let desc_style = Style::default().fg(theme.gray).bg(bg);
|
||||
|
||||
// Enum rows display the user-friendly name, not the canonical.
|
||||
let value_text_owned;
|
||||
let value_text: &str = match value {
|
||||
SettingValue::Bool(b) => {
|
||||
if *b {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
}
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)"
|
||||
} else {
|
||||
s.as_str()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e),
|
||||
SettingValue::Int(i) => {
|
||||
value_text_owned = i.to_string();
|
||||
&value_text_owned
|
||||
}
|
||||
};
|
||||
let value_text = value_display(meta, value, lock);
|
||||
let value_text = value_text.as_str();
|
||||
|
||||
let value_style = if matches!(value, SettingValue::Bool(false)) {
|
||||
let value_style = if lock.is_some() || matches!(value, SettingValue::Bool(false)) {
|
||||
Style::default().fg(theme.gray).bg(bg)
|
||||
} else {
|
||||
value_style
|
||||
};
|
||||
|
||||
// Chevron for Enum/String/DynamicEnum (opens picker/editor).
|
||||
let show_chevron = matches!(
|
||||
(&meta.kind, value),
|
||||
(SettingKind::Enum { .. }, _)
|
||||
| (SettingKind::String { .. }, _)
|
||||
| (SettingKind::DynamicEnum { .. }, _)
|
||||
);
|
||||
// Locked rows can't be entered, so they drop the affordance.
|
||||
let show_chevron = lock.is_none()
|
||||
&& matches!(
|
||||
(&meta.kind, value),
|
||||
(SettingKind::Enum { .. }, _)
|
||||
| (SettingKind::String { .. }, _)
|
||||
| (SettingKind::DynamicEnum { .. }, _)
|
||||
);
|
||||
let chevron_str = format!(" {}", crate::glyphs::chevron()); // › → > on legacy ConHost
|
||||
let chevron_w = if show_chevron {
|
||||
chevron_str.width() as u16
|
||||
|
|
@ -2589,7 +2585,13 @@ pub(super) fn render_setting_row(
|
|||
}
|
||||
|
||||
/// Render the wrapped description for an expanded row.
|
||||
fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta, theme: &Theme) {
|
||||
fn render_expanded_description(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
meta: &SettingMeta,
|
||||
lock_reason: Option<&'static str>,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2597,14 +2599,14 @@ fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta,
|
|||
.fg(theme.gray)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
let desc_src: &str = meta.description;
|
||||
let desc_text = lock_reason.unwrap_or(meta.description);
|
||||
// Indent 4 cols to nest under the label.
|
||||
let indent = 4u16.min(area.width);
|
||||
let wrap_w = area.width.saturating_sub(indent);
|
||||
if wrap_w == 0 {
|
||||
return;
|
||||
}
|
||||
let line = Line::from(Span::styled(desc_src, desc_style));
|
||||
let line = Line::from(Span::styled(desc_text, desc_style));
|
||||
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
|
||||
for (i, wrapped_line) in wrapped.iter().enumerate() {
|
||||
if (i as u16) >= area.height {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ use ratatui::layout::Rect;
|
|||
use crate::app::actions::Action;
|
||||
use crate::input::line_editor::LineEditor;
|
||||
use crate::settings::{
|
||||
EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind,
|
||||
SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for,
|
||||
dynamic_enum_choices,
|
||||
CodingDataSharingLock, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory,
|
||||
SettingKey, SettingKind, SettingMeta, SettingValue, SettingsRegistry, StringValidator,
|
||||
current_value_for, dynamic_enum_choices,
|
||||
};
|
||||
use crate::views::modal_window::ModalWindowState;
|
||||
|
||||
|
|
@ -243,6 +243,16 @@ impl SettingsModalState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Why a Browse row cannot be edited (`None` = editable). Consulted by
|
||||
/// both render and input.
|
||||
pub fn row_lock(&self, key: SettingKey) -> Option<CodingDataSharingLock> {
|
||||
if key == "coding_data_sharing" {
|
||||
self.pager_snapshot.coding_data_sharing_lock
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently-focused setting row, if any.
|
||||
pub fn focused_setting(&self) -> Option<(SettingKey, &SettingMeta)> {
|
||||
match self.rows.get(self.selected)? {
|
||||
|
|
@ -551,6 +561,9 @@ impl SettingsModalState {
|
|||
let Some((key, meta)) = self.focused_setting() else {
|
||||
return false;
|
||||
};
|
||||
if self.row_lock(key).is_some() {
|
||||
return false;
|
||||
}
|
||||
// Handles both static `Enum` and `DynamicEnum` catalogs.
|
||||
let (supports_preview, resolved): (bool, Vec<OwnedEnumChoice>) = match &meta.kind {
|
||||
SettingKind::Enum {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use super::state::*;
|
|||
use crate::app::actions::Action;
|
||||
use crate::input::line_editor::LineEditor;
|
||||
use crate::settings::{
|
||||
EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind, SettingMeta,
|
||||
SettingOwner, SettingValue, SettingsRegistry, StringValidator,
|
||||
CodingDataSharingLock, EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey,
|
||||
SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry, StringValidator,
|
||||
};
|
||||
use crate::theme::Theme;
|
||||
use xai_grok_shell::agent::config::UiConfig;
|
||||
|
|
@ -542,6 +542,7 @@ fn render_setting_row_shows_full_label_when_one_line_fits() {
|
|||
&theme,
|
||||
false, // is_expanded
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -976,6 +977,7 @@ fn selected_browse_row_label_is_bold() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
|
|
@ -1433,6 +1435,7 @@ fn render_setting_row_emits_restart_pill_when_required() {
|
|||
&theme,
|
||||
true, // is_expanded — gate on
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -1457,6 +1460,7 @@ fn render_setting_row_emits_restart_pill_when_required() {
|
|||
&theme,
|
||||
false, // is_expanded — off
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -1505,6 +1509,7 @@ fn render_setting_row_hides_restart_pill_when_at_default_and_collapsed() {
|
|||
&theme,
|
||||
false, // is_expanded
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -4488,6 +4493,7 @@ fn narrow_terminal_drops_value_to_second_line() {
|
|||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
|
|
@ -4551,6 +4557,7 @@ fn wide_terminal_keeps_value_on_first_line() {
|
|||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
|
|
@ -4592,6 +4599,7 @@ fn pathologically_narrow_truncates_label_with_ellipsis() {
|
|||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
|
|
@ -5340,6 +5348,7 @@ fn bool_off_value_renders_in_dim_color() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// Use `find_text_col` so the
|
||||
// column index is the actual buffer position, not a byte
|
||||
|
|
@ -5372,6 +5381,7 @@ fn bool_off_value_renders_in_dim_color() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let on_col = find_text_col(&buf_on, 0, "on").expect("must find `on` substring");
|
||||
let on_cell = buf_on.cell((on_col, 0)).expect("on cell");
|
||||
|
|
@ -5443,6 +5453,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Enum row — chevron column contains the `›` glyph.
|
||||
|
|
@ -5457,6 +5468,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// The chevron column is a 2-cell block at
|
||||
|
|
@ -5528,6 +5540,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let _ = render_setting_row(
|
||||
&mut buf_multi,
|
||||
|
|
@ -5539,6 +5552,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// Bool row's `off` ends at column N; Enum row's `›` glyph
|
||||
// lands at column M. The contract: N == M's column
|
||||
|
|
@ -5595,6 +5609,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let area_one = Rect {
|
||||
x: 0,
|
||||
|
|
@ -5613,6 +5628,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// The column offset from the area's right edge is constant:
|
||||
// `area.right - ROW_RIGHT_PAD_W - 1` is the `›` glyph
|
||||
|
|
@ -7470,3 +7486,180 @@ fn preview_remains_clamped_when_pending_exceeds_widened_width() {
|
|||
"clamped note must render when pending > interior, even after widening",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locked coding_data_sharing row (ZDR / team non-admin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_locked_state(lock: CodingDataSharingLock) -> SettingsModalState {
|
||||
SettingsModalState::new(
|
||||
Arc::new(SettingsRegistry::defaults()),
|
||||
UiConfig::default(),
|
||||
PagerLocalSnapshot {
|
||||
coding_data_sharing_lock: Some(lock),
|
||||
..PagerLocalSnapshot::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn coding_data_sharing_row_idx(s: &SettingsModalState) -> usize {
|
||||
s.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
|
||||
.expect("coding_data_sharing must be registered")
|
||||
}
|
||||
|
||||
/// A locked `coding_data_sharing` row must NOT open the enum picker —
|
||||
/// neither via `try_enter_picking_enum` directly (the shared entry point
|
||||
/// for Enter, mouse value clicks, and the `focus_key` auto-open path) nor
|
||||
/// via the Browse Enter key. With no lock, the same row opens the picker.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_row_does_not_open_picker() {
|
||||
for lock in [
|
||||
CodingDataSharingLock::Zdr,
|
||||
CodingDataSharingLock::TeamManaged,
|
||||
] {
|
||||
let mut s = make_locked_state(lock);
|
||||
s.selected = coding_data_sharing_row_idx(&s);
|
||||
assert!(
|
||||
!s.try_enter_picking_enum(),
|
||||
"try_enter_picking_enum must return false for a locked row ({lock:?})"
|
||||
);
|
||||
assert!(
|
||||
matches!(s.mode(), SettingsModalMode::Browse),
|
||||
"mode must stay Browse for a locked row ({lock:?}), got {:?}",
|
||||
s.mode()
|
||||
);
|
||||
let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(
|
||||
matches!(out, SettingsKeyOutcome::Unchanged),
|
||||
"Enter on a locked row must be a no-op ({lock:?}), got {out:?}"
|
||||
);
|
||||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||||
}
|
||||
|
||||
// Control arm: no lock → the picker opens (existing behavior).
|
||||
let mut s = make_state();
|
||||
s.selected = coding_data_sharing_row_idx(&s);
|
||||
assert!(s.try_enter_picking_enum());
|
||||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||||
}
|
||||
|
||||
/// Locked rows drop the `›` enter-affordance and render a per-variant
|
||||
/// value: ZDR replaces opt-in/out with "ZDR"; team-managed keeps the
|
||||
/// value with an " · Admin Managed" suffix. Unlocked rows keep the plain
|
||||
/// value + chevron.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_row_renders_locked_value_without_chevron() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 60,
|
||||
};
|
||||
let theme = Theme::current();
|
||||
let chevron = crate::glyphs::chevron();
|
||||
|
||||
let mut s = make_locked_state(CodingDataSharingLock::Zdr);
|
||||
let idx = coding_data_sharing_row_idx(&s);
|
||||
s.selected = idx;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let rect = s.row_rects[idx];
|
||||
let line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
line.contains("ZDR") && !line.contains("Opt"),
|
||||
"ZDR lock must replace the opt-in/out value with `ZDR`: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
!line.contains(chevron),
|
||||
"locked row must not render the `{chevron}` enter affordance: {line:?}"
|
||||
);
|
||||
|
||||
let mut s = make_locked_state(CodingDataSharingLock::TeamManaged);
|
||||
s.selected = idx;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let rect = s.row_rects[idx];
|
||||
let line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
line.contains("Opt out \u{00B7} Admin Managed"),
|
||||
"team-managed lock must append ` · Admin Managed`: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
!line.contains(chevron),
|
||||
"locked row must not render the `{chevron}` enter affordance: {line:?}"
|
||||
);
|
||||
|
||||
// Control arm: unlocked row shows the plain value + chevron.
|
||||
let mut s = make_state();
|
||||
s.selected = idx;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let rect = s.row_rects[idx];
|
||||
let line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
line.contains("Opt out") && !line.contains("locked"),
|
||||
"unlocked row must show the plain value: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
line.contains(chevron),
|
||||
"unlocked row must keep the `{chevron}` enter affordance: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Expanding a locked row replaces the registry description with the lock
|
||||
/// reason; the unlocked expansion shows the description.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 60,
|
||||
};
|
||||
let theme = Theme::current();
|
||||
// Word-wrap may split the reason across lines; normalize the whole
|
||||
// buffer to a single whitespace-collapsed string before matching.
|
||||
let flatten = |buf: &Buffer| -> String {
|
||||
(0..area.height)
|
||||
.map(|y| buf_row_text(buf, y, area.x, area.width))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
|
||||
let mut s = make_locked_state(CodingDataSharingLock::TeamManaged);
|
||||
let idx = coding_data_sharing_row_idx(&s);
|
||||
s.selected = idx;
|
||||
s.expanded_keys.insert("coding_data_sharing");
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let text = flatten(&buf);
|
||||
assert!(
|
||||
text.contains("Managed by your team admin."),
|
||||
"expanded locked row must show the lock reason: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Controls whether"),
|
||||
"locked expansion must replace the description, not append to it: {text:?}"
|
||||
);
|
||||
|
||||
// Control arm: unlocked expansion shows the description only.
|
||||
let mut s = make_state();
|
||||
s.selected = idx;
|
||||
s.expanded_keys.insert("coding_data_sharing");
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let text = flatten(&buf);
|
||||
assert!(
|
||||
text.contains("Controls whether"),
|
||||
"expanded row must render the registry description: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Managed by your team admin."),
|
||||
"unlocked expansion must not mention the team-admin lock: {text:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,8 +212,7 @@ pub struct TurnStatusArgs<'a> {
|
|||
pub is_pending_user_input: bool,
|
||||
pub goal_verifying: bool,
|
||||
pub watchers: Watchers,
|
||||
/// Parked on a sendable wait (`AgentView::renders_parked`): suppress the
|
||||
/// running-turn chrome and render only the still-running cue.
|
||||
/// Parked on a sendable wait (`AgentView::renders_parked`).
|
||||
pub parked: bool,
|
||||
/// Transparent right-side background so the row blends with the
|
||||
/// terminal's own background (minimal mode).
|
||||
|
|
@ -295,39 +294,55 @@ pub fn render_turn_status(
|
|||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
// Idle or parked with watchers: persistent still-running cue (not
|
||||
// scrollback — it must never scroll away). Lower priority than the
|
||||
// starting-session and drain-blocked cues above.
|
||||
if (state.is_idle() || parked)
|
||||
&& let Some(cue) = still_running_label(watchers)
|
||||
{
|
||||
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
|
||||
// the agent is idle, so this breath runs slower than the active
|
||||
// turn spinner (see MONITOR_PULSE_DIVISOR).
|
||||
let frames = crate::glyphs::monitor_icon_frames();
|
||||
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
|
||||
let icon = format!("{} ", frames[frame_idx]);
|
||||
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
|
||||
theme.text_primary
|
||||
// Idle or parked: persistent cue (not scrollback — it must never scroll
|
||||
// away). Lower priority than the starting-session and drain-blocked cues
|
||||
// above. Parked never falls through to the running-turn chrome
|
||||
// (spinner/timers/[stop]) — the wait aborts the moment the user types,
|
||||
// so that chrome would lie.
|
||||
if state.is_idle() || parked {
|
||||
// Parked with held queued rows: the queued hint IS the input-semantics
|
||||
// story (Enter acts on the queue immediately), so it replaces the
|
||||
// generic interrupt copy.
|
||||
let parked_suffix = if held_queue > 0 && held_queue_top_sendable {
|
||||
format!(" \u{00b7} {held_queue} queued — Enter to send now")
|
||||
} else if held_queue > 0 {
|
||||
format!(" \u{00b7} {held_queue} queued")
|
||||
} else {
|
||||
theme.gray
|
||||
" \u{00b7} send a message to interrupt".to_string()
|
||||
};
|
||||
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
|
||||
let spans = vec![
|
||||
Span::styled(icon, Style::default().fg(theme.accent_system)),
|
||||
Span::styled(cue, Style::default().fg(label_fg)),
|
||||
];
|
||||
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
|
||||
return TurnStatusOutput {
|
||||
watching_cue: show_buttons.then(|| Rect::new(area.x, area.y, cue_width, 1)),
|
||||
..TurnStatusOutput::default()
|
||||
let cue = match (still_running_label(watchers), parked) {
|
||||
(Some(label), true) => Some(format!("{label}{parked_suffix}")),
|
||||
(Some(label), false) => Some(label),
|
||||
(None, true) => Some(format!("waiting{parked_suffix}")),
|
||||
(None, false) => None,
|
||||
};
|
||||
}
|
||||
|
||||
// Parked with no watchers left: render nothing. The stopped look must
|
||||
// never fall through to the running-turn chrome (spinner/timers/[stop])
|
||||
// — the wait aborts the moment the user types, so that chrome would lie.
|
||||
if parked {
|
||||
if let Some(cue) = cue {
|
||||
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
|
||||
// the agent is idle, so this breath runs slower than the active
|
||||
// turn spinner (see MONITOR_PULSE_DIVISOR).
|
||||
let frames = crate::glyphs::monitor_icon_frames();
|
||||
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
|
||||
let icon = format!("{} ", frames[frame_idx]);
|
||||
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
|
||||
theme.text_primary
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
|
||||
let spans = vec![
|
||||
Span::styled(icon, Style::default().fg(theme.accent_system)),
|
||||
Span::styled(cue, Style::default().fg(label_fg)),
|
||||
];
|
||||
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
|
||||
// The cue opens the tasks pane on click — only advertise the hit
|
||||
// area when there are tasks to show (a watcherless parked cue has
|
||||
// nothing behind it).
|
||||
return TurnStatusOutput {
|
||||
watching_cue: (show_buttons && watchers.total() > 0)
|
||||
.then(|| Rect::new(area.x, area.y, cue_width, 1)),
|
||||
..TurnStatusOutput::default()
|
||||
};
|
||||
}
|
||||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
|
|
@ -787,9 +802,7 @@ fn render_starting_session(
|
|||
/// completion/events, scheduled `/loop` tasks fire prompts, and background
|
||||
/// subagents inject a completion turn, any of which can start a new turn.
|
||||
///
|
||||
/// A parked turn (`parked` — the stopped look while blocked on a sendable
|
||||
/// wait) suppresses the running-turn chrome entirely: the row shows only when
|
||||
/// watchers exist, rendering the "… still running" cue.
|
||||
/// A parked turn always shows the row, watchers or not.
|
||||
///
|
||||
/// Real MCP progress (`total > 0`) renders as a compact chip in the top status
|
||||
/// bar instead, so it does not affect this row.
|
||||
|
|
@ -801,7 +814,7 @@ pub fn should_show(
|
|||
parked: bool,
|
||||
) -> bool {
|
||||
if parked {
|
||||
return watchers.total() > 0;
|
||||
return true;
|
||||
}
|
||||
!state.is_idle()
|
||||
|| drain_blocked
|
||||
|
|
@ -1068,9 +1081,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn should_show_parked_only_with_watchers() {
|
||||
// Parked (turn running but rendering the stopped look): the row shows
|
||||
// only to carry the "… still running" cue — never the running chrome.
|
||||
fn should_show_parked_always() {
|
||||
assert!(should_show(
|
||||
&AgentState::TurnRunning,
|
||||
false,
|
||||
|
|
@ -1081,7 +1092,7 @@ mod tests {
|
|||
},
|
||||
true
|
||||
));
|
||||
assert!(!should_show(
|
||||
assert!(should_show(
|
||||
&AgentState::TurnRunning,
|
||||
false,
|
||||
None,
|
||||
|
|
@ -1435,16 +1446,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parked_with_watchers_renders_cue_not_running_chrome() {
|
||||
// A parked running turn renders the still-running cue — never the busy
|
||||
// spinner/timers/[stop] chrome (the wait aborts as soon as the user
|
||||
// types, so that chrome would lie).
|
||||
// The wait aborts as soon as the user types, so busy chrome would lie.
|
||||
let text = render_parked_with_watchers(Watchers {
|
||||
commands: 2,
|
||||
..Watchers::default()
|
||||
});
|
||||
assert!(
|
||||
text.contains("2 commands still running"),
|
||||
"parked with bg work must render the still-running cue, got: {text:?}"
|
||||
text.contains("2 commands still running \u{00b7} send a message to interrupt"),
|
||||
"parked with bg work must render the interruptible still-running cue, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Waiting") && !text.contains("[stop]"),
|
||||
|
|
@ -1453,11 +1462,39 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parked_without_watchers_renders_nothing() {
|
||||
fn parked_without_watchers_renders_waiting_cue() {
|
||||
let text = render_parked_with_watchers(Watchers::default());
|
||||
assert!(
|
||||
text.trim().is_empty(),
|
||||
"parked with no watchers must render nothing, got: {text:?}"
|
||||
text.contains("waiting \u{00b7} send a message to interrupt"),
|
||||
"watcherless parked must render the waiting interrupt cue, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("[stop]"),
|
||||
"watcherless parked must not render the running-turn chrome, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_with_held_queue_renders_queued_hint() {
|
||||
// The queued hint replaces the interrupt copy (Enter = send-now).
|
||||
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
|
||||
let mut args = idle_args(Watchers {
|
||||
commands: 1,
|
||||
..Watchers::default()
|
||||
});
|
||||
args.state = &AgentState::TurnRunning;
|
||||
args.activity = &activity;
|
||||
args.parked = true;
|
||||
args.held_queue = 1;
|
||||
args.held_queue_top_sendable = true;
|
||||
let text = render_row_text(args, 80);
|
||||
assert!(
|
||||
text.contains("1 queued — Enter to send now"),
|
||||
"parked with a held row must advertise the queued hint, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("send a message to interrupt"),
|
||||
"queued hint replaces the interrupt copy, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ enum WorktreeDbCommand {
|
|||
pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> {
|
||||
let cancel = CancellationToken::new();
|
||||
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
|
||||
// Cancel + join on every return path, including the `?` below.
|
||||
let _agent_guard =
|
||||
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
|
||||
|
||||
let _init: acp::InitializeResponse = acp_send(
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
||||
|
|
@ -116,9 +119,7 @@ pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> {
|
|||
)
|
||||
.await?;
|
||||
|
||||
let result = dispatch(args.command, &spawned.channel.tx).await;
|
||||
cancel.cancel();
|
||||
result
|
||||
dispatch(args.command, &spawned.channel.tx).await
|
||||
}
|
||||
|
||||
async fn dispatch(command: WorktreeCommand, tx: &xai_acp_lib::AcpAgentTx) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -81,13 +81,23 @@ async fn campaign_leader_mode_remote_dismiss_on_model_pick() {
|
|||
let mut recorded = false;
|
||||
'attempts: for attempt in 0..3 {
|
||||
let mut h = spawn();
|
||||
h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"leader-mode welcome never rendered (attempt {attempt})\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
)
|
||||
});
|
||||
// Cold leader bring-up (leader election plus an unoptimized-binary
|
||||
// boot) can miss the welcome paint within LEADER_TIMEOUT under
|
||||
// remote-runner load. The leader outlives this client, so a fresh spawn
|
||||
// attaches to the now-live leader and paints promptly: retry like a
|
||||
// missed campaign rather than hard-failing on the first loaded cold
|
||||
// start, and only panic once all attempts are exhausted.
|
||||
if h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.is_err()
|
||||
{
|
||||
let screen = h.screen_contents();
|
||||
h.quit().expect("clean quit");
|
||||
assert!(
|
||||
attempt < 2,
|
||||
"leader-mode welcome never rendered after 3 attempts\nscreen:\n{screen}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)) {
|
||||
// Campaign never applied on this spawn; try a fresh TUI.
|
||||
h.quit().expect("clean quit");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,28 @@
|
|||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// SGR double-click (press/release × 2) at 0-based (row, col).
|
||||
fn double_click_at(harness: &mut PtyHarness, row: u16, col: u16) {
|
||||
let dbl = format!(
|
||||
"{}{}{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("inject SGR double-click");
|
||||
}
|
||||
|
||||
/// Locate `needle` and double-click its first character cell.
|
||||
fn double_click_text(harness: &mut PtyHarness, needle: &str) {
|
||||
let screen = harness.screen_contents();
|
||||
let (row, col) = locate_screen_text(&screen, needle)
|
||||
.unwrap_or_else(|| panic!("locate {needle:?}; screen:\n{screen}"));
|
||||
double_click_at(harness, row, col);
|
||||
}
|
||||
|
||||
/// PTY, against the built binary with real SGR clicks: a finished `!`
|
||||
/// command shows its full output (success and failure), double-click folds
|
||||
/// the block, and a second double-click restores the full output — never
|
||||
|
|
@ -36,23 +58,25 @@ async fn bash_full_output_double_click_fold_pty() {
|
|||
.expect("session ready");
|
||||
|
||||
// 1. Success: 12 lines exceed the streaming window; all visible on finish.
|
||||
//
|
||||
// Truncated (default first=2, last=3) shows L01,L02 + L10–L12. A middle
|
||||
// line (L06) appears only after expand-on-finish — do not gate on L01:
|
||||
// that passes while still truncated and races the L03/L06/L09 asserts.
|
||||
harness
|
||||
.inject_keys(b"! printf 'L%02d\\n' $(seq 1 12)\r")
|
||||
.expect("submit bash-mode command");
|
||||
harness
|
||||
.wait_for_text("L12", Duration::from_secs(30))
|
||||
.expect("bash output tail");
|
||||
// Live tail can show L06–L12 while L01 is still clipped; wait for
|
||||
// expand-on-finish before asserting the head is present.
|
||||
harness
|
||||
.wait_for_text("L01", Duration::from_secs(15))
|
||||
.wait_for_text("L06", Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"finished ! command must not truncate output (L01 missing)\nscreen:\n{}",
|
||||
"finished ! command must expand full output (middle L06 missing)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
for line in ["L03", "L06", "L09"] {
|
||||
for line in ["L01", "L03", "L09"] {
|
||||
assert!(
|
||||
harness.contains_text(line),
|
||||
"finished ! command must not truncate output ({line} missing)\nscreen:\n{}",
|
||||
|
|
@ -65,34 +89,22 @@ async fn bash_full_output_double_click_fold_pty() {
|
|||
harness
|
||||
.wait_for_text("Ctrl+e:", Duration::from_secs(10))
|
||||
.expect("scrollback owns keys");
|
||||
let screen = harness.screen_contents();
|
||||
let (row, col) = locate_screen_text(&screen, "Run (user)")
|
||||
.unwrap_or_else(|| panic!("locate ! block header; screen:\n{screen}"));
|
||||
let dbl = format!(
|
||||
"{}{}{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
double_click_text(&mut harness, "Run (user)");
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click to fold");
|
||||
let gone = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while harness.contains_text("L06") && std::time::Instant::now() < gone {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
!harness.contains_text("L06"),
|
||||
"double-click must collapse the ! block; got:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.update(Duration::from_millis(500)); // let the multi-click window lapse
|
||||
.wait_for_text_absent("L06", Duration::from_secs(15))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"double-click must collapse the ! block; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
// MULTI_CLICK_TIMEOUT_MS is 300ms; clear it before the expand gesture so
|
||||
// the second double-click is not counted as click 3/4 of the first.
|
||||
harness.update(Duration::from_millis(500));
|
||||
// Re-locate: collapse shrinks the block and may move the header on screen.
|
||||
double_click_text(&mut harness, "Run (user)");
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click to expand");
|
||||
harness
|
||||
.wait_for_text("L06", Duration::from_secs(10))
|
||||
.wait_for_text("L06", Duration::from_secs(15))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"double-click must restore the FULL output (middle lines); got:\n{}",
|
||||
|
|
@ -112,7 +124,7 @@ async fn bash_full_output_double_click_fold_pty() {
|
|||
.wait_for_text("E12", Duration::from_secs(30))
|
||||
.expect("failed bash output tail");
|
||||
harness
|
||||
.wait_for_text("E06", Duration::from_secs(10))
|
||||
.wait_for_text("E06", Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"FAILED ! command must show its full output; got:\n{}",
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ pub(crate) const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
|
|||
/// session spawn) on the agent's single-threaded runtime, and the client-side
|
||||
/// `acp_send` has no timeout — so under the fully-parallel pty_e2e suite the
|
||||
/// starved agent thread can push this well past the 20s `WELCOME_TIMEOUT`
|
||||
/// (leaving the "Loading session…" placeholder up). Sized generously for the
|
||||
/// same contention reason as `WRAP_TIMEOUT`, not because resume is slow when
|
||||
/// run alone.
|
||||
pub(crate) const RESUME_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// (leaving the "Loading session…" placeholder up). A prior 60s budget still
|
||||
/// timed out under CI load with the same stuck-loading signature; match
|
||||
/// [`WRAP_TIMEOUT`] (120s) for the same contention reason, not because resume
|
||||
/// is slow when run alone.
|
||||
pub(crate) const RESUME_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Substring we wait for on the welcome screen. Matches the menu label `"Quit"`
|
||||
/// (`render_welcome_done` / gate menus); case-sensitive, so it does **not**
|
||||
|
|
@ -976,13 +977,16 @@ pub(crate) fn wait_minimal_ready(harness: &mut PtyHarness) {
|
|||
|
||||
/// Quit minimal cleanly. The prompt is always focused (a bare `q` would type
|
||||
/// into it), so quit is Ctrl+Q pressed twice (it requires confirmation). Falls
|
||||
/// back to the harness kill path if the chord doesn't take.
|
||||
/// back to the harness kill path if the chord doesn't take. Give the confirm
|
||||
/// chord and process exit enough time under suite load so a SIGKILL does not
|
||||
/// cut off the agent mid-`updates.jsonl` flush (which breaks a subsequent
|
||||
/// `--continue` resume).
|
||||
pub(crate) fn quit_minimal(harness: &mut PtyHarness) {
|
||||
let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — arms the confirm
|
||||
harness.update(Duration::from_millis(80));
|
||||
harness.update(Duration::from_millis(200));
|
||||
let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — confirms
|
||||
match harness
|
||||
.wait_exit_code(Duration::from_secs(5))
|
||||
.wait_exit_code(Duration::from_secs(15))
|
||||
.expect("wait for minimal pager exit")
|
||||
{
|
||||
PtyExitPoll::Running => harness.quit().expect("kill minimal pager after timeout"),
|
||||
|
|
|
|||
|
|
@ -56,16 +56,27 @@ async fn empty_enter_sends_top_not_last_of_two() {
|
|||
.inject_keys(b"\r")
|
||||
.expect("empty Enter send-now top");
|
||||
turn_one.release();
|
||||
// Alpha (the promoted TOP row) then bravo drain back-to-back. Each
|
||||
// promoted "❯ …" block and the intermediate TURNTWO reply is scrolled
|
||||
// above the viewport by the next turn's start-adoption before a 100ms poll
|
||||
// can observe it, so gating on those transient markers is inherently racy.
|
||||
// Gate only on the FINAL reply (stable at the viewport head) and prove the
|
||||
// top-row order + send-now silence via the recorded wire below, which is
|
||||
// not subject to scrolling.
|
||||
harness
|
||||
.wait_for_text("TURNTHREE", Duration::from_secs(90))
|
||||
.expect("all queued turns drained through to the final reply");
|
||||
// Alpha (the promoted TOP row) then bravo drain back-to-back after the
|
||||
// completion release. Each promoted "❯ …" block and every reply —
|
||||
// including the final TURNTHREE — can scroll above the viewport before a
|
||||
// 100ms poll observes it, so gating on any on-screen marker is inherently
|
||||
// racy (a flaky observation, not a real failure — same rationale as
|
||||
// `removed_queued_prompt_never_sent`). Gate on the WIRE instead: wait
|
||||
// until bravo's request has been sent, which is the authoritative record
|
||||
// that both queued rows drained in order. Pump the event loop while
|
||||
// waiting so the queued rows actually promote.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(90);
|
||||
while !all_user_messages(&content)
|
||||
.iter()
|
||||
.any(|u| u.contains("queue-bravo-later"))
|
||||
{
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"queued rows never drained through to the final turn\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied())
|
||||
.await
|
||||
.expect("top queued row expectation satisfied");
|
||||
|
|
|
|||
|
|
@ -1,29 +1,17 @@
|
|||
//! PTY: a parked wait produces two static markers — the park pushes a plain
|
||||
//! "Worked for X" line (the still-running work shows on the status row's
|
||||
//! "… still running" cue, not in the transcript) and the turn that follows ends
|
||||
//! with its own marker below. A prompt typed mid-park is cancel-and-send:
|
||||
//! the shell silently cancels the parked turn (no "Turn cancelled by user"
|
||||
//! marker) and runs the message as its OWN next turn, whose completion pushes
|
||||
//! the second marker; the park line is never edited, so the transcript holds
|
||||
//! BOTH markers with the park text intact, in order.
|
||||
//!
|
||||
//! Wire journey, fully flag-file driven — no timing windows: the model
|
||||
//! backgrounds a flag-gated command, then runs a flag-gated foreground hold
|
||||
//! while the test extracts the runtime task id from the request bodies
|
||||
//! (`<task-id>` envelope; a UUID minted by the terminal actor, so it cannot
|
||||
//! be scripted statically) and enqueues the blocking
|
||||
//! `get_command_or_subagent_output(timeout)` on the real id — the pager
|
||||
//! parks. Typing mid-park cancels-and-sends and the fixed-text reply ends
|
||||
//! the new turn with the second, final marker.
|
||||
//! PTY, fully flag-file driven: the model backgrounds a flag-gated command,
|
||||
//! the test extracts the runtime task id and enqueues a blocking
|
||||
//! `get_command_or_subagent_output` on it (park), then types mid-park
|
||||
//! (cancel-and-send). Asserts exactly ONE "Worked for" marker — the new
|
||||
//! turn's — with no park row and no "Turn cancelled by user" marker.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn endline_park_two_static_markers() {
|
||||
async fn endline_park_is_markerless() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gates the background command the markers count (released at the end).
|
||||
// Gates the background command the watching cue counts (released at the end).
|
||||
let park_flag = content.home().join("endline_park_flag");
|
||||
// Gates the id-extraction hold: created once the wait script is enqueued.
|
||||
let id_ready_flag = content.home().join("endline_id_ready_flag");
|
||||
|
|
@ -32,8 +20,7 @@ async fn endline_park_two_static_markers() {
|
|||
format!("while [ ! -e {} ]; do /bin/sleep 0.2; done", flag.display())
|
||||
};
|
||||
|
||||
// Tool call 1: a flag-gated background command — the work the watching
|
||||
// cue counts ("1 command still running").
|
||||
// Tool call 1: the flag-gated background command the watching cue counts.
|
||||
let bg_args = json!({
|
||||
"command": gated_loop(&park_flag),
|
||||
"description": "flag-gated command",
|
||||
|
|
@ -43,9 +30,8 @@ async fn endline_park_two_static_markers() {
|
|||
let _background_turn =
|
||||
expect_tool_turn(&content, "call_endline_bg", "run_terminal_command", bg_args);
|
||||
|
||||
// Tool call 2: the flag-gated foreground hold — the turn idles here (no
|
||||
// deadline) until the test has extracted the task id and enqueued the
|
||||
// wait script.
|
||||
// Tool call 2: a flag-gated foreground hold keeps the turn open until the
|
||||
// test has extracted the task id and enqueued the wait script.
|
||||
let id_hold_args = json!({
|
||||
"command": gated_loop(&id_ready_flag),
|
||||
"description": "hold for id extraction"
|
||||
|
|
@ -97,8 +83,7 @@ async fn endline_park_two_static_markers() {
|
|||
)
|
||||
});
|
||||
|
||||
// Tool call 3: block on the REAL task — the interruptible wait the pager
|
||||
// parks on (600s survives the wait cap; the send-now cancel aborts it).
|
||||
// Tool call 3: block on the real task id (600s survives the wait cap).
|
||||
let wait_args = json!({
|
||||
"task_ids": [task_id],
|
||||
"timeout_ms": 600_000
|
||||
|
|
@ -114,39 +99,29 @@ async fn endline_park_two_static_markers() {
|
|||
// Everything downstream is scripted — let the id-extraction hold finish.
|
||||
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
|
||||
|
||||
// Park: the first static marker reads as a plain completion; the
|
||||
// still-running work shows on the status row's watching cue instead.
|
||||
harness
|
||||
.wait_for_text("Worked for", Duration::from_secs(90))
|
||||
.wait_for_text("1 command still running", Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
"parked watching cue never appeared; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
harness.screen_contents(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
)
|
||||
});
|
||||
harness
|
||||
.wait_for_text("1 command still running", Duration::from_secs(30))
|
||||
.wait_for_text("send a message to interrupt", Duration::from_secs(30))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"parked watching cue never appeared; screen:\n{}",
|
||||
"parked interrupt cue never appeared; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
// The status row's cue is the only "still running" on screen — the
|
||||
// parked marker line itself stays a plain "Worked for X".
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
screen
|
||||
.lines()
|
||||
.filter(|l| l.contains("Worked for"))
|
||||
.all(|l| !l.contains("still running")),
|
||||
"the parked marker carries no still-running suffix; screen:\n{screen}"
|
||||
!screen.contains("Worked for"),
|
||||
"a park must write no marker; screen:\n{screen}"
|
||||
);
|
||||
|
||||
// Type mid-park: Enter is cancel-and-send (the wait makes it a sendable
|
||||
// parked turn) — the parked turn is cancelled silently and the message
|
||||
// runs as its own next turn.
|
||||
harness
|
||||
.inject_keys(b"hurry up please")
|
||||
.expect("type mid-park");
|
||||
|
|
@ -162,47 +137,29 @@ async fn endline_park_two_static_markers() {
|
|||
)
|
||||
});
|
||||
|
||||
// The turn-start adoption scrolls the promoted "❯ hurry up please" block
|
||||
// to the viewport top, pushing the park marker above the screen. Scroll
|
||||
// the transcript back to its head so both markers are inspectable.
|
||||
// Scroll to the transcript head so the whole journey is on one screen.
|
||||
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"g").expect("goto transcript top");
|
||||
|
||||
// Two static markers: the park line unchanged above the promoted prompt
|
||||
// and the new turn's final marker below it — both plain "Worked for X"
|
||||
// lines (no still-running suffix; the bg command is still gated, so the
|
||||
// status row legitimately shows "1 command still running" — scope the
|
||||
// suffix check to the marker lines) — with NO cancelled marker anywhere
|
||||
// (silent send-now cancel).
|
||||
let two_markers = wait_until(Duration::from_secs(90), || {
|
||||
let single_marker = wait_until(Duration::from_secs(90), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
// Positional: park marker ABOVE the promoted prompt ABOVE the final
|
||||
// marker (screen text is row-major), both markers intact.
|
||||
screen.matches("Worked for").count() == 2
|
||||
&& screen
|
||||
.lines()
|
||||
.filter(|l| l.contains("Worked for"))
|
||||
.all(|l| !l.contains("still running"))
|
||||
screen.matches("Worked for").count() == 1
|
||||
&& !screen.contains("Turn cancelled by user")
|
||||
&& matches!(
|
||||
(
|
||||
screen.find("Worked for"),
|
||||
screen.find("hurry up please"),
|
||||
screen.rfind("Worked for"),
|
||||
),
|
||||
(Some(park), Some(prompt), Some(fin)) if park < prompt && prompt < fin
|
||||
(screen.find("hurry up please"), screen.find("Worked for")),
|
||||
(Some(prompt), Some(fin)) if prompt < fin
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
two_markers,
|
||||
"expected park marker, promoted prompt, then the fresh final marker in order \
|
||||
(and no cancelled marker); screen:\n{}",
|
||||
single_marker,
|
||||
"expected the promoted prompt then ONE final marker (no park marker, \
|
||||
no cancelled marker); screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
write_cast_if_requested(&harness, "endline_park_two_static_markers.cast");
|
||||
write_cast_if_requested(&harness, "endline_park_is_markerless.cast");
|
||||
|
||||
// Release the flag-gated command so nothing outlives the harness teardown.
|
||||
std::fs::write(&park_flag, b"done").expect("release flag");
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
//! PTY: auto-wake turns close MARKERLESS — a turn ends with three flag-gated
|
||||
//! background commands running (one plain "Worked for" marker), and each
|
||||
//! released flag lands a completion chip and the auto-wake response with NO
|
||||
//! wake-end marker after it, while every earlier line stays unchanged above
|
||||
//! (nothing mutates). The persistent "N commands still running" status row
|
||||
//! above the prompt counts the remaining work down between wakes and
|
||||
//! disappears once nothing is left; markers never carry that copy as a suffix.
|
||||
//!
|
||||
//! Positional chain asserted at the end: marker < chip < wake reply < chip <
|
||||
//! reply < chip < reply — exactly ONE "Worked for" total (the user turn's).
|
||||
//! PTY: a turn ends with three flag-gated background commands running; each
|
||||
//! released flag lands a completion chip, the auto-wake reply, and that wake
|
||||
//! turn's own closing marker. Asserts the positional chain marker < chip <
|
||||
//! reply per round — FOUR "Worked for" total — and the cue counting 3→2→1→gone.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
|
|
@ -23,7 +17,7 @@ const ROWS: u16 = 70;
|
|||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn endline_wakeups_are_markerless() {
|
||||
async fn endline_wakeups_close_with_markers() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let flags: Vec<std::path::PathBuf> = (0..TASKS)
|
||||
.map(|i| content.home().join(format!("endline_status_flag_{i}")))
|
||||
|
|
@ -78,8 +72,6 @@ async fn endline_wakeups_are_markerless() {
|
|||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The turn ends with all three commands running: one plain marker, and
|
||||
// the status row's watching cue carrying the count.
|
||||
harness
|
||||
.wait_for_text("STATUS_TURN_SETTLED", Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| {
|
||||
|
|
@ -106,57 +98,49 @@ async fn endline_wakeups_are_markerless() {
|
|||
)
|
||||
});
|
||||
|
||||
// Release flag 0: chip → wake reply, NO wake marker; the watching cue
|
||||
// counts down to 2 while the one user-turn marker stays intact above
|
||||
// (screen text is row-major, so find offsets order the lines).
|
||||
std::fs::write(&flags[0], b"done").expect("release flag 0");
|
||||
let wake_one = wait_until(Duration::from_secs(45), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
screen.contains("WAKE_REPLY_ONE")
|
||||
&& screen.matches("Worked for").count() == 1
|
||||
&& screen.matches("Worked for").count() == 2
|
||||
&& screen.contains("2 commands still running")
|
||||
});
|
||||
assert!(
|
||||
wake_one,
|
||||
"expected chip → wake reply with no wake marker, watching cue at 2; screen:\n{}",
|
||||
"expected chip → wake reply → the wake's closing marker, watching cue at 2; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Release flag 1: the second wake chain joins below; cue counts 1.
|
||||
std::fs::write(&flags[1], b"done").expect("release flag 1");
|
||||
let wake_two = wait_until(Duration::from_secs(45), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
screen.contains("WAKE_REPLY_TWO")
|
||||
&& screen.matches("Worked for").count() == 1
|
||||
&& screen.matches("Worked for").count() == 3
|
||||
&& screen.contains("1 command still running")
|
||||
});
|
||||
assert!(
|
||||
wake_two,
|
||||
"expected the second markerless wake chain below the earlier lines; screen:\n{}",
|
||||
"expected the second wake chain (with its marker) below the earlier lines; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Release flag 2: zero left — still exactly one marker, and the watching
|
||||
// cue disappears entirely (its "still running" copy leaves the screen).
|
||||
std::fs::write(&flags[2], b"done").expect("release flag 2");
|
||||
let wake_three = wait_until(Duration::from_secs(45), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
screen.contains("WAKE_REPLY_THREE")
|
||||
&& screen.matches("Worked for").count() == 1
|
||||
&& screen.matches("Worked for").count() == 4
|
||||
&& !screen.contains("still running")
|
||||
});
|
||||
assert!(
|
||||
wake_three,
|
||||
"the last wake must stay markerless and retire the watching cue; screen:\n{}",
|
||||
"the last chatty wake must close with its marker and retire the watching cue; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Full chain, positional: marker < chip < reply < chip < reply < chip <
|
||||
// reply — one marker total, and no marker carries a "still running"
|
||||
// suffix (that copy belongs to the status row's cue, retired above).
|
||||
// Screen text is row-major, so match offsets order the lines.
|
||||
let screen = harness.screen_contents();
|
||||
let chips: Vec<usize> = screen
|
||||
.match_indices("Task completed")
|
||||
|
|
@ -170,8 +154,8 @@ async fn endline_wakeups_are_markerless() {
|
|||
let markers: Vec<usize> = screen.match_indices("Worked for").map(|(i, _)| i).collect();
|
||||
assert_eq!(
|
||||
markers.len(),
|
||||
1,
|
||||
"exactly one marker — the user turn's; screen:\n{screen}"
|
||||
4,
|
||||
"four markers — the user turn's plus one per chatty wake; screen:\n{screen}"
|
||||
);
|
||||
let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1");
|
||||
let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2");
|
||||
|
|
@ -179,10 +163,13 @@ async fn endline_wakeups_are_markerless() {
|
|||
assert!(
|
||||
markers[0] < chips[0]
|
||||
&& chips[0] < w1
|
||||
&& w1 < chips[1]
|
||||
&& w1 < markers[1]
|
||||
&& markers[1] < chips[1]
|
||||
&& chips[1] < w2
|
||||
&& w2 < chips[2]
|
||||
&& chips[2] < w3,
|
||||
&& w2 < markers[2]
|
||||
&& markers[2] < chips[2]
|
||||
&& chips[2] < w3
|
||||
&& w3 < markers[3],
|
||||
"chain out of order; screen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -193,5 +180,5 @@ async fn endline_wakeups_are_markerless() {
|
|||
"markers must never carry a still-running suffix; screen:\n{screen}"
|
||||
);
|
||||
|
||||
write_cast_if_requested(&harness, "endline_wakeups_are_markerless.cast");
|
||||
write_cast_if_requested(&harness, "endline_wakeups_close_with_markers.cast");
|
||||
}
|
||||
|
|
@ -26,6 +26,14 @@ async fn minimal_continue_reprints_transcript() {
|
|||
first
|
||||
.wait_for_full_text(&turn_sentinel(1), Duration::from_secs(30))
|
||||
.expect("turn 1 committed to scrollback");
|
||||
// Idle before quit so the agent finishes turn completion + updates.jsonl
|
||||
// flush. Quitting mid-finalize under suite load left `--continue` loading a
|
||||
// session with the user message but no assistant payload (resume then
|
||||
// shows "Loading session…" / empty chrome past RESUME_TIMEOUT).
|
||||
first
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(15))
|
||||
.expect("turn 1 returned to idle before quit");
|
||||
first.update(Duration::from_millis(300));
|
||||
quit_minimal(&mut first);
|
||||
|
||||
// Resume the same session. The transcript is reprinted into native
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
//! PTY: the core "queued message appears 2x" regression. A message queued
|
||||
//! mid-turn HOLDS through the turn's sendable wait, rendering exactly once
|
||||
//! as a queue row (id/kind+text reconciled — no optimistic-echo duplicate),
|
||||
//! suppressing the park marker, and advertising send-now on the status row;
|
||||
//! after the wait returns and the turn ends it drains as its own turn and
|
||||
//! renders exactly once as a "❯ " block.
|
||||
//!
|
||||
//! Flag-file driven like `endline_park_two_static_markers`: background a
|
||||
//! flag-gated command, hold the turn on a flag-gated foreground command
|
||||
//! (queueing happens in this window), then block on
|
||||
//! `get_command_or_subagent_output(timeout_ms: 600000)`.
|
||||
//! PTY, flag-file driven like `endline_park_is_markerless`: the "queued
|
||||
//! message appears 2x" regression. A message queued mid-turn holds through
|
||||
//! the turn's sendable wait, then drains as its own turn — asserting it
|
||||
//! renders exactly once as a queue row and exactly once as a "❯ " block.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
|
|
@ -112,7 +105,7 @@ async fn queued_message_renders_once_not_twice() {
|
|||
|
||||
// The wait parks the turn with the row HELD: the status row explains the
|
||||
// hold ("1 queued — Enter to send now"; the top row is a sendable server
|
||||
// row), the park marker is suppressed, and the row renders exactly once.
|
||||
// row), the park writes no marker, and the row renders exactly once.
|
||||
harness
|
||||
.wait_for_text("1 queued \u{2014} Enter to send now", Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| {
|
||||
|
|
@ -124,7 +117,7 @@ async fn queued_message_renders_once_not_twice() {
|
|||
});
|
||||
assert!(
|
||||
!harness.contains_text("Worked for"),
|
||||
"held queued rows must suppress the park marker\nscreen:\n{}",
|
||||
"a park writes no marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
//! PTY: a re-parked wait (new parent output between parks) pushes a fresh
|
||||
//! parked marker for the new park episode, so the transcript keeps a
|
||||
//! boundary where each park began, while the persistent "… still running"
|
||||
//! status row explains the still-running background work.
|
||||
//!
|
||||
//! Wire journey, flag-file driven like `endline_park_two_static_markers`:
|
||||
//! background a flag-gated command, hold on a flag-gated foreground command
|
||||
//! while the runtime task id is extracted, then script three more rounds on
|
||||
//! the real id — a short wait (`timeout_ms: 4000`) that expires with the
|
||||
//! task still running (park #1 + marker), a quick foreground echo, and a
|
||||
//! long wait (park #2: chrome hidden and a fresh marker for the new episode).
|
||||
//! PTY, flag-file driven like `endline_park_is_markerless`: a short wait that
|
||||
//! expires (park #1), foreground work between the parks, then a long wait on
|
||||
//! the same still-running task (park #2). Asserts neither park writes a
|
||||
//! transcript row and only the real turn end pushes the single "Worked for X".
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
|
|
@ -28,7 +21,7 @@ const FINAL: &str = "REPARK_FINAL_ANSWER";
|
|||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn reparked_wait_repushes_buried_marker() {
|
||||
async fn reparked_wait_stays_markerless() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gates the background command both waits block on (released at the end).
|
||||
let park_flag = content.home().join("repark_flag");
|
||||
|
|
@ -99,8 +92,7 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
)
|
||||
});
|
||||
|
||||
// Tool call 3 — park #1: a short wait that expires with the task still
|
||||
// running.
|
||||
// Tool call 3 — park #1: a short wait that expires with the task still running.
|
||||
let short_wait_args = json!({
|
||||
"task_ids": [task_id],
|
||||
"timeout_ms": 4_000
|
||||
|
|
@ -113,8 +105,7 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
short_wait_args,
|
||||
);
|
||||
|
||||
// Tool call 4: foreground work between the parks (`MIDWORK` is the
|
||||
// on-screen sentinel).
|
||||
// Tool call 4: foreground work between the parks (`MIDWORK` is the on-screen sentinel).
|
||||
let midwork_args = json!({
|
||||
"command": "echo repark-midwork-done",
|
||||
"description": MIDWORK
|
||||
|
|
@ -143,27 +134,20 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
// Everything downstream is scripted — release the id-extraction hold.
|
||||
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
|
||||
|
||||
// Park #1 marker (plain "Worked for X" — no still-running suffix).
|
||||
harness
|
||||
.wait_for_text("Worked for", Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"park #1 marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
harness.screen_contents(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
)
|
||||
});
|
||||
// The parked status row carries the still-running story instead.
|
||||
harness
|
||||
.wait_for_text("1 command still running", Duration::from_secs(30))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"parked watching cue never appeared; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
let park_one = wait_until(Duration::from_secs(90), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
screen.contains("1 command still running")
|
||||
&& screen.contains("send a message to interrupt")
|
||||
&& !screen.contains("Worked for")
|
||||
});
|
||||
assert!(
|
||||
park_one,
|
||||
"park #1 must show the parked cue with no marker; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
harness.screen_contents(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
);
|
||||
|
||||
// The short wait expires and the same turn resumes.
|
||||
harness
|
||||
.wait_for_text(MIDWORK, Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| {
|
||||
|
|
@ -174,7 +158,6 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
)
|
||||
});
|
||||
|
||||
// Park #2: the running chrome drops again.
|
||||
let chrome_hidden = wait_until(Duration::from_secs(30), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
!harness.contains_text(CANCEL_HINT)
|
||||
|
|
@ -185,44 +168,21 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Park #2 pushes a second marker below the between-parks content (a new
|
||||
// park episode after new parent output).
|
||||
let repushed = wait_until(Duration::from_secs(30), || {
|
||||
let park_two = wait_until(Duration::from_secs(30), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
harness.screen_contents().matches("Worked for").count() == 2
|
||||
let screen = harness.screen_contents();
|
||||
screen.contains("1 command still running") && !screen.contains("Worked for")
|
||||
});
|
||||
assert!(
|
||||
repushed,
|
||||
"re-park after buried marker must push a fresh marker; screen:\n{}",
|
||||
park_two,
|
||||
"park #2 must stay markerless with the parked cue up; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let screen = harness.screen_contents();
|
||||
|
||||
// Screen text is row-major: marker, content, re-pushed marker in order.
|
||||
let first_marker = screen.find("Worked for").expect("first marker");
|
||||
// U+2800–U+28FF = the braille spinner glyphs.
|
||||
let midwork_at = screen
|
||||
.rfind(MIDWORK)
|
||||
.expect("between-parks content on screen");
|
||||
let second_marker = screen.rfind("Worked for").expect("re-pushed marker");
|
||||
assert!(
|
||||
first_marker < midwork_at && midwork_at < second_marker,
|
||||
"expected marker, content, then the re-pushed marker in order; screen:\n{screen}"
|
||||
);
|
||||
// The still-running story lives in the status row, not the transcript:
|
||||
// no "Worked for" line carries the suffix (line-scoped like the sibling
|
||||
// suites — other surfaces may legitimately use the phrase).
|
||||
assert!(
|
||||
screen
|
||||
.lines()
|
||||
.filter(|l| l.contains("Worked for"))
|
||||
.all(|l| !l.contains("still running")),
|
||||
"no marker line may carry the still-running suffix; screen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains("1 command still running"),
|
||||
"the parked status row keeps the still-running cue during park #2; screen:\n{screen}"
|
||||
);
|
||||
// The parked look still hides spinner and chrome.
|
||||
let below_midwork = &screen[midwork_at..];
|
||||
assert!(
|
||||
!below_midwork
|
||||
|
|
@ -235,10 +195,9 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
"parked look keeps the running chrome hidden during park #2; screen:\n{screen}"
|
||||
);
|
||||
|
||||
eprintln!("── re-park with buried marker: tail explains the park ──\n{screen}\n── end ──");
|
||||
eprintln!("── re-park stays markerless: status cue explains the park ──\n{screen}\n── end ──");
|
||||
|
||||
// Liveness: releasing the flag completes the wait and the same turn
|
||||
// streams the final answer.
|
||||
// Releasing the flag completes the wait and lets the turn finish.
|
||||
std::fs::write(&park_flag, b"done").expect("release flag");
|
||||
harness
|
||||
.wait_for_text(FINAL, Duration::from_secs(90))
|
||||
|
|
@ -253,11 +212,20 @@ async fn reparked_wait_repushes_buried_marker() {
|
|||
harness
|
||||
.wait_for_turn_idle(Duration::from_secs(15))
|
||||
.expect("turn idle");
|
||||
let one_final_marker = wait_until(Duration::from_secs(30), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
harness.screen_contents().matches("Worked for").count() == 1
|
||||
});
|
||||
assert!(
|
||||
one_final_marker,
|
||||
"exactly one marker — the real turn end's; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
write_cast_if_requested(&harness, "reparked_wait_repushes_buried_marker.cast");
|
||||
write_cast_if_requested(&harness, "reparked_wait_stays_markerless.cast");
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
//! PTY: the parked look is not sticky — when a parked sendable wait RETURNS
|
||||
//! and the model resumes streaming in the SAME turn, the running chrome
|
||||
//! (turn-status row + cancel keybar) must come back while the continuation
|
||||
//! streams (regression: stale tracker waits kept the idle look after resume).
|
||||
//!
|
||||
//! Flag-file driven like `endline_park_two_static_markers`: background a
|
||||
//! flag-gated command, hold the turn on a flag-gated foreground command while
|
||||
//! the runtime task id is extracted, then block on
|
||||
//! `get_command_or_subagent_output(timeout_ms: 600000)` — the pager parks.
|
||||
//! Releasing the flag completes the task, the wait returns, and the scripted
|
||||
//! slow continuation streams in the same turn.
|
||||
//! PTY, flag-file driven like `endline_park_is_markerless`: the pager parks
|
||||
//! on a blocking wait; releasing the flag completes the task and a slow
|
||||
//! continuation streams in the SAME turn. Asserts the running chrome returns
|
||||
//! (regression: stale tracker waits kept the idle look after resume).
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
|
|
@ -105,23 +98,23 @@ async fn spinner_reappears_after_wait_resumes() {
|
|||
|
||||
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
|
||||
|
||||
// Parked look: the plain marker renders, the "… still running" cue takes
|
||||
// the status row, and the running chrome (cancel keybar) drops — the
|
||||
// Parked look: the parked cue takes the status row (parks write no
|
||||
// transcript row) and the running chrome (cancel keybar) drops — the
|
||||
// session reads as stopped.
|
||||
harness
|
||||
.wait_for_text("Worked for", Duration::from_secs(60))
|
||||
.wait_for_text("1 command still running", Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
"parked watching cue never appeared; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
harness.screen_contents(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
)
|
||||
});
|
||||
harness
|
||||
.wait_for_text("1 command still running", Duration::from_secs(30))
|
||||
.wait_for_text("send a message to interrupt", Duration::from_secs(30))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"parked watching cue never appeared; screen:\n{}",
|
||||
"parked interrupt cue never appeared; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ mod common;
|
|||
mod background_task_reaped_on_quit;
|
||||
#[path = "pty_e2e/continue_resumes_session_with_history.rs"]
|
||||
mod continue_resumes_session_with_history;
|
||||
#[path = "pty_e2e/endline_park_two_static_markers.rs"]
|
||||
mod endline_park_two_static_markers;
|
||||
#[path = "pty_e2e/endline_wakeups_are_markerless.rs"]
|
||||
mod endline_wakeups_are_markerless;
|
||||
#[path = "pty_e2e/endline_park_is_markerless.rs"]
|
||||
mod endline_park_is_markerless;
|
||||
#[path = "pty_e2e/endline_wakeups_close_with_markers.rs"]
|
||||
mod endline_wakeups_close_with_markers;
|
||||
#[path = "pty_e2e/rename_title_shows_in_prompt_border.rs"]
|
||||
mod rename_title_shows_in_prompt_border;
|
||||
#[path = "pty_e2e/reparked_wait_repushes_buried_marker.rs"]
|
||||
mod reparked_wait_repushes_buried_marker;
|
||||
#[path = "pty_e2e/reparked_wait_stays_markerless.rs"]
|
||||
mod reparked_wait_stays_markerless;
|
||||
#[path = "pty_e2e/spinner_reappears_after_wait_resumes.rs"]
|
||||
mod spinner_reappears_after_wait_resumes;
|
||||
#[path = "pty_e2e/storage_upload_parks_on_401_and_drains_after_recovery.rs"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue