diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf65452ce..93edb1c239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ tag, packages, checksums and release assets exist. ### Added +- `read` responses now always report the file's byte size, line count, and + whether output was truncated, and truncation footers name the total size + alongside the continuation offset — so paging through a large file is + deliberate instead of a surprise (#6283). - File edits are parse-gated before the write lands: Rust goes through `syn::parse_file` for a grammar-exact `line:column`, and `.toml` / `.json` through the parsers already vendored. An edit is refused only when the file diff --git a/crates/cli/src/config_bundles.rs b/crates/cli/src/config_bundles.rs index e892d0225b..42aaf83c98 100644 --- a/crates/cli/src/config_bundles.rs +++ b/crates/cli/src/config_bundles.rs @@ -287,9 +287,22 @@ pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec { rejected } +/// Maximum nesting depth the export walkers descend. Config files are +/// shallow; anything deeper is pathological and fails closed. +const MAX_EXPORT_WALK_DEPTH: usize = 64; + /// Why a value carries nested non-portable authority or looks like a bare /// credential, or `None` when it is safe to move between machines. fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { + value_rejection_reason_at(path, value, 0) +} + +fn value_rejection_reason_at(path: &str, value: &toml::Value, depth: usize) -> Option { + if depth > MAX_EXPORT_WALK_DEPTH { + return Some(format!( + "nested more than {MAX_EXPORT_WALK_DEPTH} levels deep" + )); + } if let Some(reason) = nonportable_value_reason(path, value) { return Some(reason.to_string()); } @@ -297,7 +310,7 @@ fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { toml::Value::String(text) => string_secret_reason(text), toml::Value::Array(items) => items .iter() - .find_map(|value| value_rejection_reason(path, value)) + .find_map(|value| value_rejection_reason_at(path, value, depth + 1)) .map(|reason| format!("array contains an entry where {reason}")), toml::Value::Table(map) => { for (key, nested_value) in map { @@ -309,7 +322,9 @@ fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { if let Some(reason) = nonportable_path_reason(&child_path) { return Some(format!("nested key {key:?} {reason}")); } - if let Some(reason) = value_rejection_reason(&child_path, nested_value) { + if let Some(reason) = + value_rejection_reason_at(&child_path, nested_value, depth + 1) + { return Some(format!("nested under {key:?}, {reason}")); } } @@ -965,6 +980,13 @@ fn config_document(config: &ConfigToml) -> Result Option { + sanitize_export_value_at(path, value, 0) +} + +fn sanitize_export_value_at(path: &str, value: &toml::Value, depth: usize) -> Option { + if depth > MAX_EXPORT_WALK_DEPTH { + return None; + } if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() { return None; } @@ -973,14 +995,14 @@ fn sanitize_export_value(path: &str, value: &toml::Value) -> Option toml::Value::Array(values) => Some(toml::Value::Array( values .iter() - .filter_map(|value| sanitize_export_value(path, value)) + .filter_map(|value| sanitize_export_value_at(path, value, depth + 1)) .collect(), )), toml::Value::Table(table) => { let mut scrubbed = toml::map::Map::new(); for (key, value) in table { let child_path = format!("{path}.{key}"); - if let Some(value) = sanitize_export_value(&child_path, value) { + if let Some(value) = sanitize_export_value_at(&child_path, value, depth + 1) { scrubbed.insert(key.clone(), value); } } @@ -3621,6 +3643,28 @@ command = "/synthetic/direct-tool-override" assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}"); } + #[test] + fn deep_nesting_fails_closed_for_rejection_and_sanitize() { + fn deep_toml(depth: usize) -> toml::Value { + let mut value = toml::Value::String("leaf".to_string()); + for _ in 0..depth { + let mut map = toml::map::Map::new(); + map.insert("t".to_string(), value); + value = toml::Value::Table(map); + } + value + } + + let deep = deep_toml(70); + let reason = value_rejection_reason("t", &deep).expect("over-deep value must be rejected"); + assert!(reason.contains("levels deep"), "{reason}"); + assert!( + sanitize_export_value("t", &deep) + .is_some_and(|scrubbed| !scrubbed.to_string().contains("leaf")), + "over-deep branch must be omitted, not exported" + ); + } + #[test] fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() { let config: ConfigToml = toml::from_str( diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index fd5d709180..e563662e73 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5371,6 +5371,20 @@ fn tui_argv(cli: &Cli, passthrough: Vec) -> Vec { args } +/// Set one process environment variable for the CLI-to-TUI bridge. +/// +/// The dispatcher must call this only on the main thread before the TUI +/// runtime starts; all current callers are inside [`apply_tui_env`]. +fn set_tui_env(key: impl AsRef, value: impl AsRef) { + // SAFETY: the dispatcher runs on the main thread and these setters execute + // before the TUI runtime starts. The only other thread that may be alive + // is the detached telemetry writer, which never reads or writes the + // process environment, so no concurrent environment access can occur. + unsafe { + std::env::set_var(key, value); + } +} + fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) { let mut verbosity = if cli.profile.is_some() { cli.verbosity.clone() @@ -5396,10 +5410,8 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou || provider.to_string(), |provider| provider.as_str().to_string(), ); - unsafe { - std::env::set_var("CODEWHALE_PROVIDER", &provider); - std::env::set_var("DEEPSEEK_PROVIDER", provider); - } + set_tui_env("CODEWHALE_PROVIDER", &provider); + set_tui_env("DEEPSEEK_PROVIDER", provider); } if !(uses_raw_tui_provider || (cli.profile.is_some() @@ -5407,95 +5419,65 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring)) && let Some(api_key) = keyring_bridge_api_key { - unsafe { - for var in provider_env_vars(keyring_bridge_provider) { - std::env::set_var(var, api_key); - } - std::env::set_var( - codewhale_config::CLI_API_KEY_SOURCE_ENV, - RuntimeApiKeySource::Keyring.as_env_value(), - ); + for var in provider_env_vars(keyring_bridge_provider) { + set_tui_env(var, api_key); } + set_tui_env( + codewhale_config::CLI_API_KEY_SOURCE_ENV, + RuntimeApiKeySource::Keyring.as_env_value(), + ); } if let Some(model) = cli.model.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_MODEL", model); - std::env::set_var("DEEPSEEK_MODEL", model); - } + set_tui_env("CODEWHALE_MODEL", model); + set_tui_env("DEEPSEEK_MODEL", model); } if let Some(output_mode) = cli.output_mode.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_OUTPUT_MODE", output_mode); - std::env::set_var("DEEPSEEK_OUTPUT_MODE", output_mode); - } + set_tui_env("CODEWHALE_OUTPUT_MODE", output_mode); + set_tui_env("DEEPSEEK_OUTPUT_MODE", output_mode); } if let Some(v) = verbosity.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_VERBOSITY", v); - std::env::set_var("DEEPSEEK_VERBOSITY", v); - } + set_tui_env("CODEWHALE_VERBOSITY", v); + set_tui_env("DEEPSEEK_VERBOSITY", v); } if let Some(log_level) = cli.log_level.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_LOG_LEVEL", log_level); - std::env::set_var("DEEPSEEK_LOG_LEVEL", log_level); - } + set_tui_env("CODEWHALE_LOG_LEVEL", log_level); + set_tui_env("DEEPSEEK_LOG_LEVEL", log_level); } let telemetry = resolved_runtime.telemetry.to_string(); - unsafe { - std::env::set_var("CODEWHALE_TELEMETRY", &telemetry); - std::env::set_var("DEEPSEEK_TELEMETRY", &telemetry); - } + set_tui_env("CODEWHALE_TELEMETRY", &telemetry); + set_tui_env("DEEPSEEK_TELEMETRY", &telemetry); let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force(); - unsafe { - std::env::set_var( - codewhale_config::TELEMETRY_FLOOR_ENV, - if floor { "1" } else { "0" }, - ); - } + set_tui_env( + codewhale_config::TELEMETRY_FLOOR_ENV, + if floor { "1" } else { "0" }, + ); if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", endpoint); - std::env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint); - } + set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint); + set_tui_env("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint); } if let Some(policy) = cli.approval_policy.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_APPROVAL_POLICY", policy); - std::env::set_var("DEEPSEEK_APPROVAL_POLICY", policy); - } + set_tui_env("CODEWHALE_APPROVAL_POLICY", policy); + set_tui_env("DEEPSEEK_APPROVAL_POLICY", policy); } if let Some(mode) = cli.sandbox_mode.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_SANDBOX_MODE", mode); - std::env::set_var("DEEPSEEK_SANDBOX_MODE", mode); - } + set_tui_env("CODEWHALE_SANDBOX_MODE", mode); + set_tui_env("DEEPSEEK_SANDBOX_MODE", mode); } if cli.yolo { - unsafe { - std::env::set_var("CODEWHALE_YOLO", "true"); - } + set_tui_env("CODEWHALE_YOLO", "true"); } if let Some(api_key) = cli.api_key.as_ref() { - unsafe { - std::env::set_var(codewhale_config::CLI_API_KEY_ENV, api_key); - } + set_tui_env(codewhale_config::CLI_API_KEY_ENV, api_key); if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) { - unsafe { - for var in provider_env_vars(resolved_runtime.provider) { - std::env::set_var(var, api_key); - } + for var in provider_env_vars(resolved_runtime.provider) { + set_tui_env(var, api_key); } } - unsafe { - std::env::set_var(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli"); - } + set_tui_env(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli"); } if let Some(base_url) = cli.base_url.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_BASE_URL", base_url); - std::env::set_var("DEEPSEEK_BASE_URL", base_url); - } + set_tui_env("CODEWHALE_BASE_URL", base_url); + set_tui_env("DEEPSEEK_BASE_URL", base_url); } } @@ -5534,11 +5516,19 @@ fn run_metrics_command(args: MetricsArgs) -> Result<()> { }) } +/// Maximum bytes read for an API key on stdin. Keys are short; anything +/// larger is a piped file, not a key. +const MAX_STDIN_API_KEY_BYTES: u64 = 8 * 1024; + fn read_api_key_from_stdin() -> Result { let mut input = String::new(); io::stdin() + .take(MAX_STDIN_API_KEY_BYTES + 1) .read_to_string(&mut input) .context("failed to read api key from stdin")?; + if input.len() as u64 > MAX_STDIN_API_KEY_BYTES { + bail!("API key on stdin exceeds the 8 KiB limit"); + } let key = input.trim().to_string(); if key.is_empty() { bail!("empty API key provided"); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index eb7386f361..c1577cb3b0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,6 +17,7 @@ fn main() -> std::process::ExitCode { // inherit SIGPIPE set to SIG_IGN, which makes write(2) return EPIPE; // Rust's `println!` then treats that io::Error as fatal and panics. // See issue #4030. + // SAFETY: process entry; no threads or handlers yet. #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7a12f0e71e..9e3e0e1eaf 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -7224,20 +7224,41 @@ fn read_checked_toml_file(path: &Path, label: &str) -> Result { .with_context(|| format!("failed to read {label} at {}", path.display())) } +/// Maximum bytes read from a config file. Configs are kilobytes; anything +/// larger is not a config file. +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; + #[cfg(unix)] fn read_string_no_follow(path: &Path) -> std::io::Result { - let mut file = fs::OpenOptions::new() + let file = fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW) .open(path)?; let mut raw = String::new(); - file.read_to_string(&mut raw)?; + file.take(MAX_CONFIG_FILE_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_CONFIG_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("config file {} exceeds the 1 MiB limit", path.display()), + )); + } Ok(raw) } #[cfg(not(unix))] fn read_string_no_follow(path: &Path) -> std::io::Result { - fs::read_to_string(path) + let file = fs::File::open(path)?; + let mut raw = String::new(); + file.take(MAX_CONFIG_FILE_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_CONFIG_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("config file {} exceeds the 1 MiB limit", path.display()), + )); + } + Ok(raw) } fn reject_path_symlink(path: &Path) -> Result<()> { diff --git a/crates/config/src/persistence.rs b/crates/config/src/persistence.rs index a22eaa3297..8ec765be22 100644 --- a/crates/config/src/persistence.rs +++ b/crates/config/src/persistence.rs @@ -524,6 +524,29 @@ PASSWORD=hunter2hunter2" assert!(!out.to_string().contains(&synthetic_secret), "{out}"); } + #[test] + fn redact_json_truncates_pathological_nesting() { + let mut value = serde_json::Value::String("leaf".to_string()); + for _ in 0..150 { + let mut map = serde_json::Map::new(); + map.insert("t".to_string(), value); + value = serde_json::Value::Object(map); + } + let out = redact_json_secrets(&value); + let mut cursor = &out; + let mut descended = 0; + while let serde_json::Value::Object(map) = cursor { + cursor = map.values().next().expect("single-key nesting"); + descended += 1; + } + assert_eq!( + cursor, + &serde_json::Value::String(REDACTED.to_string()), + "over-deep value must be redacted, not traversed" + ); + assert!(descended < 150, "guard must fire before the leaf"); + } + #[test] fn redact_text_masks_camel_case_and_dotted_secret_assignments() { let synthetic_secret = synthetic_secret_fixture(); diff --git a/crates/config/src/xai_credentials.rs b/crates/config/src/xai_credentials.rs index e19e77e380..ee9dc25935 100644 --- a/crates/config/src/xai_credentials.rs +++ b/crates/config/src/xai_credentials.rs @@ -358,6 +358,7 @@ fn owned_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result Result Result Result Result Result ) })?; anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file"); + // SAFETY: geteuid(2) dereferences no pointers. anyhow::ensure!( metadata.uid() == unsafe { libc::geteuid() }, "xAI OAuth file must be owned by the current user" @@ -1194,6 +1208,7 @@ fn reopen_windows_file_for_owner_security(file: &File, path: &Path) -> Result Result<()> { .context("reading Codewhale-owned xAI OAuth security descriptor"); } let _descriptor = WindowsLocalAllocation(descriptor.cast()); + // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`. anyhow::ensure!( !owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0, "Codewhale-owned xAI OAuth storage owner is not the current user" @@ -1508,6 +1524,7 @@ fn verify_windows_owner_only_handle(file: &File) -> Result<()> { // SAFETY: `count == 1` proves the first returned entry is initialized. let entry = unsafe { &*entries }; let trustee_sid: PSID = entry.Trustee.ptstrName.cast(); + // SAFETY: form and null checked in this expression; sid owned by `user`. anyhow::ensure!( entry.Trustee.TrusteeForm == TRUSTEE_IS_SID && !trustee_sid.is_null() diff --git a/crates/mcp/src/stdio_client.rs b/crates/mcp/src/stdio_client.rs index fdef4d2a4d..87ec6f8730 100644 --- a/crates/mcp/src/stdio_client.rs +++ b/crates/mcp/src/stdio_client.rs @@ -542,8 +542,17 @@ impl ChildProcessMcpClient { } }; let (sender, responses) = sync_channel(MAX_PENDING_CHILD_MESSAGES); + let mut child = child; + let stdin = child + .stdin + .take() + .with_context(|| format!("MCP server '{server_name}': child stdin unavailable"))?; + let stdout = child + .stdout + .take() + .with_context(|| format!("MCP server '{server_name}': child stdout unavailable"))?; let mut connection = Connection { - child, + child: Some(child), stdin: None, responses, next_id: 1, @@ -551,17 +560,6 @@ impl ChildProcessMcpClient { _job: job, }; - let stdin = connection - .child - .stdin - .take() - .with_context(|| format!("MCP server '{server_name}': child stdin unavailable"))?; - let stdout = connection - .child - .stdout - .take() - .with_context(|| format!("MCP server '{server_name}': child stdout unavailable"))?; - // A dedicated reader thread keeps `recv_timeout` able to bound a wait // that a blocking read on the child would not. The custom line reader // also caps memory before a hostile child can complete an oversized @@ -775,7 +773,8 @@ impl McpManagedClient for ChildProcessMcpClient { } struct Connection { - child: Child, + /// `None` once `Drop` hands the child to the reaper thread. + child: Option, stdin: Option>>, responses: Receiver, next_id: u64, @@ -939,9 +938,12 @@ impl Connection { /// so the cost buys a real diagnostic ("exited with status 127" is the /// difference between a crashed server and a missing one). fn exit_note(&mut self) -> String { + let Some(child) = self.child.as_mut() else { + return String::new(); + }; let deadline = Instant::now() + EXIT_STATUS_GRACE; loop { - match self.child.try_wait() { + match child.try_wait() { Ok(Some(status)) => return format!(" (process exited with {status})"), Ok(None) if Instant::now() < deadline => { thread::sleep(Duration::from_millis(5)); @@ -962,30 +964,66 @@ fn is_broken_pipe(err: &anyhow::Error) -> bool { }) } +/// Reap stdio children off the dropping thread (#6211 R4). `Drop` runs on +/// whichever thread drops the client — manager reload, pool rebuild — and +/// the half-second grace wait must not stall it, least of all an executor +/// thread. One shared thread reaps every child; drops only send. What this +/// does not do: join the reaper at process exit, so a child that ignores +/// EOF can outlive a racing shutdown where the old synchronous `Drop` +/// would have killed it first. +fn child_reaper() -> &'static std::sync::mpsc::Sender { + static REAPER: std::sync::OnceLock> = std::sync::OnceLock::new(); + REAPER.get_or_init(|| { + let (tx, rx) = std::sync::mpsc::channel::(); + thread::Builder::new() + .name("mcp-stdio-reaper".to_string()) + .spawn(move || { + for mut child in rx { + reap_child(&mut child); + } + }) + .expect("MCP stdio reaper thread spawns"); + tx + }) +} + +/// Grace-wait a child, then kill what ignores stdin-close. Runs on the +/// reaper thread, or inline in `Drop` if the reaper is gone. +fn reap_child(child: &mut Child) { + let deadline = Instant::now() + SHUTDOWN_GRACE; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(10)); + } + _ => break, + } + } + #[cfg(unix)] + unsafe { + // Also run after the immediate launcher exited. Descendants can + // still own the group and the stdout/stderr pipe descriptors. + let _ = libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); +} + impl Drop for Connection { fn drop(&mut self) { // Closing stdin is the protocol-level shutdown signal for a stdio MCP // server; kill only the ones that ignore it, so servers get a chance // to flush state. self.stdin.take(); - let deadline = Instant::now() + SHUTDOWN_GRACE; - loop { - match self.child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if Instant::now() < deadline => { - thread::sleep(Duration::from_millis(10)); - } - _ => break, - } - } - #[cfg(unix)] - unsafe { - // Also run after the immediate launcher exited. Descendants can - // still own the group and the stdout/stderr pipe descriptors. - let _ = libc::kill(-(self.child.id() as libc::pid_t), libc::SIGKILL); + // The grace wait leaves the dropping thread: the child is reaped on + // the shared reaper thread. If the reaper itself is gone, reap + // inline — today's behavior — rather than leak the child. + if let Some(child) = self.child.take() + && let Err(mut failed) = child_reaper().send(child) + { + reap_child(&mut failed.0); } - let _ = self.child.kill(); - let _ = self.child.wait(); } } @@ -1346,6 +1384,60 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn reaper_kills_a_child_that_ignores_stdin_close() { + let mut child = Command::new("/bin/sh") + .args(["-c", "sleep 30"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + // Close stdin like `Drop` does, then reap: the sleeper ignores it. + drop(child.stdin.take()); + reap_child(&mut child); + assert!( + child.try_wait().unwrap().is_some(), + "reaper must have killed the child" + ); + } + + #[cfg(unix)] + #[test] + fn dropping_a_connection_with_a_live_child_returns_before_the_grace() { + let child = Command::new("/bin/sh") + .args(["-c", "sleep 30"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let pid = child.id(); + let (_tx, responses) = sync_channel(1); + let connection = Connection { + child: Some(child), + stdin: None, + responses, + next_id: 1, + }; + let started = Instant::now(); + drop(connection); + assert!( + started.elapsed() < SHUTDOWN_GRACE, + "drop must hand off instead of grace-waiting" + ); + // The reaper owns the child now: it must die without anyone waiting + // inline, so the handoff leaks nothing. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0; + if !alive { + break; + } + assert!(Instant::now() < deadline, "reaper never reaped pid {pid}"); + thread::sleep(Duration::from_millis(50)); + } + } + #[cfg(unix)] fn assert_descendant_cleanup(handshake: bool, launcher_exits: bool) { use std::io::Read; @@ -1406,6 +1498,8 @@ IFS= read -r initialized .lock() .unwrap() .child + .as_mut() + .expect("child present before drop") .try_wait() .unwrap() .is_some() @@ -1589,9 +1683,22 @@ printf 'stdin closed\n' > "$CODEWHALE_MCP_TEST_MARKER" // The reader owns only a weak stdin handle. Dropping the client must // therefore still deliver EOF to the child and let it exit cleanly; - // a strong reader-thread handle would force Drop's kill fallback. + // a strong reader-thread handle would force the kill fallback. Drop + // hands the child to the reaper thread instead of waiting, so poll + // for the marker the clean exit writes. drop(client); - assert_eq!(std::fs::read_to_string(&marker).unwrap(), "stdin closed\n"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(body) = std::fs::read_to_string(&marker) { + assert_eq!(body, "stdin closed\n"); + break; + } + assert!( + Instant::now() < deadline, + "dropped client never delivered stdin EOF" + ); + thread::sleep(Duration::from_millis(20)); + } std::fs::remove_file(marker).unwrap(); } diff --git a/crates/protocol/src/agent_mail.rs b/crates/protocol/src/agent_mail.rs index 8679c7486f..1c340d5e23 100644 --- a/crates/protocol/src/agent_mail.rs +++ b/crates/protocol/src/agent_mail.rs @@ -21,6 +21,7 @@ pub const AGENT_MAIL_EVENT_DELIVERING: &str = "agent_mail.delivering"; pub const AGENT_MAIL_EVENT_DELIVERED: &str = "agent_mail.delivered"; pub const AGENT_MAIL_EVENT_READ: &str = "agent_mail.read"; pub const AGENT_MAIL_EVENT_DELIVERY_FAILED: &str = "agent_mail.delivery_failed"; +pub const AGENT_MAIL_EVENT_CANCELED: &str = "agent_mail.canceled"; pub const MAX_AGENT_MAIL_MESSAGE_ID_BYTES: usize = 80; pub const MAX_AGENT_MAIL_OPAQUE_ID_BYTES: usize = 128; @@ -223,6 +224,9 @@ pub enum AgentMailStatus { Delivered, Read, Failed, + /// Explicitly withdrawn while queued (#6176). Terminal: delivery and the + /// wake pump never claim it, and re-cancel is an idempotent no-op. + Canceled, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -337,6 +341,19 @@ impl AgentMailEnvelope { } AgentMailStatus::Delivered => self.validate_delivered(false)?, AgentMailStatus::Read => self.validate_delivered(true)?, + AgentMailStatus::Canceled => { + // Canceled mail never started delivery: same absences as + // queued. Attempts stay 0 — cancel is only accepted while + // queued, before any delivery claim. + require_absent(self.delivered_at.is_some(), "delivered_at", "canceled")?; + require_absent(self.read_at.is_some(), "read_at", "canceled")?; + require_absent(self.failure.is_some(), "failure", "canceled")?; + require_absent( + self.delivery_turn_id.is_some(), + "delivery_turn_id", + "canceled", + )?; + } AgentMailStatus::Failed => { if self.attempt_count == 0 { return Err(AgentMailValidationError::new( @@ -719,6 +736,16 @@ mod tests { assert!(envelope.validate().is_ok()); } + #[test] + fn canceled_envelope_validates_without_delivery_fields() { + let mut envelope = queued_envelope(); + envelope.status = AgentMailStatus::Canceled; + assert!(envelope.validate().is_ok()); + envelope.delivery_turn_id = Some("turn_1".into()); + assert!(envelope.validate().is_err()); + assert_eq!(AGENT_MAIL_EVENT_CANCELED, "agent_mail.canceled"); + } + #[test] fn replay_equivalence_ignores_delivery_state_but_not_intent() { let queued = queued_envelope(); diff --git a/crates/secrets/src/redact.rs b/crates/secrets/src/redact.rs index db361dd8ef..09a72f7e53 100644 --- a/crates/secrets/src/redact.rs +++ b/crates/secrets/src/redact.rs @@ -43,6 +43,18 @@ pub const REDACTED: &str = "[redacted]"; /// one flat keyed assignment. #[must_use] pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value { + redact_json_secrets_at(value, 0) +} + +/// Maximum nesting depth the JSON redactor descends. Aligned with +/// serde_json's own parse limit so parsed input never truncates; anything +/// deeper is redacted wholesale. +const MAX_REDACT_JSON_DEPTH: usize = 128; + +fn redact_json_secrets_at(value: &serde_json::Value, depth: usize) -> serde_json::Value { + if depth > MAX_REDACT_JSON_DEPTH { + return serde_json::Value::String(REDACTED.to_string()); + } match value { serde_json::Value::Object(object) => serde_json::Value::Object( object @@ -51,15 +63,18 @@ pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value { let value = if key_is_sensitive(key) { serde_json::Value::String(REDACTED.to_string()) } else { - redact_json_secrets(value) + redact_json_secrets_at(value, depth + 1) }; (key.clone(), value) }) .collect(), ), - serde_json::Value::Array(items) => { - serde_json::Value::Array(items.iter().map(redact_json_secrets).collect()) - } + serde_json::Value::Array(items) => serde_json::Value::Array( + items + .iter() + .map(|item| redact_json_secrets_at(item, depth + 1)) + .collect(), + ), serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)), scalar => scalar.clone(), } diff --git a/crates/tui/src/auto_reasoning.rs b/crates/tui/src/auto_reasoning.rs index 0bb41d1ef8..8227e99145 100644 --- a/crates/tui/src/auto_reasoning.rs +++ b/crates/tui/src/auto_reasoning.rs @@ -1,191 +1,41 @@ -//! Adaptive reasoning-effort tier selection for `Auto` mode (#663). +//! Declared reasoning-effort tier policy for `Auto` mode (#663). //! //! When the user sets `reasoning_effort = "auto"`, the engine calls -//! [`select`] before each turn-level request to pick the actual tier -//! based on the current message. +//! [`select`] before each turn-level request to pick the actual tier. +//! +//! The tier is a declared policy, not a content classification. Until the +//! #6290 rework it guessed from the user's wording (`debug`/`error` → `Max`, +//! `search`/`lookup` → `Low`, with CJK/JP keyword lists) — the host-side +//! semantic-determinism class that made cost and answer quality depend on +//! vocabulary rather than the task. `auto` now means the declared default +//! below; the user's explicit tier is the precise control. A model-declared +//! escalation hint is the intended follow-up — this function is the seam a +//! real signal would land in. +//! +//! Known limitation: there is no per-turn cost policy left here. A turn whose +//! wording would once have classified as `Low` now runs at `High` unless the +//! user or the caller sets a tier explicitly; `auto` is not a cost-saver by +//! itself. use crate::reasoning_preference::ReasoningEffort; -/// Choose a concrete `ReasoningEffort` tier for the next API request. +/// Choose a concrete `ReasoningEffort` tier for an `auto` setting. /// -/// Rules: -/// - Sub-agent contexts (`is_subagent == true`) → `Low` -/// - Last user message contains a high-effort keyword -/// (English: `debug`, `error`; Chinese: 调试 / 错误 / 报错 / 出错 / -/// 崩溃 / 調試 / 錯誤; Japanese: デバッグ / エラー / バグ) → `Max` -/// - Last user message contains a low-effort keyword -/// (English: `search`, `lookup`; Chinese: 搜索 / 查找 / 查询; -/// Japanese: 検索) → `Low` -/// - Everything else → `High` +/// Declared policy: `High`. The message, the model, and the caller are never +/// inspected. #[must_use] -pub fn select(is_subagent: bool, last_msg: &str) -> ReasoningEffort { - if is_subagent { - return ReasoningEffort::Low; - } - - let lower = last_msg.to_ascii_lowercase(); - - if HIGH_EFFORT_KEYWORDS.iter().any(|kw| lower.contains(kw)) { - return ReasoningEffort::Max; - } - - if LOW_EFFORT_KEYWORDS.iter().any(|kw| lower.contains(kw)) { - return ReasoningEffort::Low; - } - +pub fn select() -> ReasoningEffort { ReasoningEffort::High } -/// Keywords that bump `reasoning_effort` to `Max`. Latin terms are -/// lowercase because the caller lowercases the message; CJK has no -/// case so the literal form matches as-is. Covers the Chinese and -/// Japanese vocabulary a non-English user reaches for when reporting -/// the same kind of problem the original `"debug" | "error"` rule was -/// trying to catch — without those terms a Chinese-speaking user -/// paying for Auto mode silently got `High` even on hard debugging -/// tasks. -const HIGH_EFFORT_KEYWORDS: &[&str] = &[ - // English (unchanged from the original keyword set). - "debug", - "error", - // Simplified / Traditional Chinese. - "\u{8c03}\u{8bd5}", // 调试 - "\u{9519}\u{8bef}", // 错误 - "\u{62a5}\u{9519}", // 报错 - "\u{51fa}\u{9519}", // 出错 - "\u{5d29}\u{6e83}", // 崩溃 - "\u{8abf}\u{8a66}", // 調試 - "\u{932f}\u{8aa4}", // 錯誤 - // Japanese. - "\u{30c7}\u{30d0}\u{30c3}\u{30b0}", // デバッグ - "\u{30a8}\u{30e9}\u{30fc}", // エラー - "\u{30d0}\u{30b0}", // バグ -]; - -/// Keywords that drop `reasoning_effort` to `Low`. Same locale coverage -/// as [`HIGH_EFFORT_KEYWORDS`]. -const LOW_EFFORT_KEYWORDS: &[&str] = &[ - "search", - "lookup", - "\u{641c}\u{7d22}", // 搜索 - "\u{67e5}\u{627e}", // 查找 - "\u{67e5}\u{8be2}", // 查询 - "\u{691c}\u{7d22}", // 検索 -]; - #[cfg(test)] mod tests { use super::*; + /// The declared default. Changing this value is a policy change: update the + /// resolution tests in `core/engine/turn_loop.rs` and `lib.rs` with it. #[test] - fn subagent_returns_low() { - assert_eq!(select(true, "anything"), ReasoningEffort::Low); - assert_eq!(select(true, "debug this"), ReasoningEffort::Low); - assert_eq!(select(true, "search query"), ReasoningEffort::Low); - } - - #[test] - fn debug_or_error_returns_max() { - assert_eq!(select(false, "find a bug"), ReasoningEffort::High); - assert_eq!(select(false, "debug crash"), ReasoningEffort::Max); - assert_eq!(select(false, "Error: timeout"), ReasoningEffort::Max); - assert_eq!(select(false, "fix this error"), ReasoningEffort::Max); - assert_eq!(select(false, "DEBUG output"), ReasoningEffort::Max); - } - - #[test] - fn search_or_lookup_returns_low() { - assert_eq!(select(false, "search for the file"), ReasoningEffort::Low); - assert_eq!(select(false, "lookup docs"), ReasoningEffort::Low); - assert_eq!(select(false, "SearchQuery"), ReasoningEffort::Low); - assert_eq!(select(false, "lookup_user"), ReasoningEffort::Low); - } - - #[test] - fn default_returns_high() { - assert_eq!(select(false, "hello"), ReasoningEffort::High); - assert_eq!(select(false, "write a test"), ReasoningEffort::High); - assert_eq!(select(false, "refactor this module"), ReasoningEffort::High); - assert_eq!(select(false, ""), ReasoningEffort::High); - } - - #[test] - fn chinese_debug_keywords_return_max() { - // The original keyword set was English-only; Chinese-speaking - // Auto-mode users paid for `High` even on real debugging tasks. - for msg in [ - "\u{5e2e}\u{6211}\u{8c03}\u{8bd5}\u{4ee3}\u{7801}", // 帮我调试代码 - "\u{8fd9}\u{91cc}\u{6709}\u{4e2a}\u{9519}\u{8bef}", // 这里有个错误 - "\u{4ee3}\u{7801}\u{62a5}\u{9519}\u{4e86}", // 代码报错了 - "\u{7a0b}\u{5e8f}\u{51fa}\u{9519}", // 程序出错 - "\u{7cfb}\u{7edf}\u{5d29}\u{6e83}", // 系统崩溃 - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", // 代碼調試 (zh-Hant) - "\u{6709}\u{500b}\u{932f}\u{8aa4}", // 有個錯誤 (zh-Hant) - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::Max, - "expected Max for `{msg}`", - ); - } - } - - #[test] - fn japanese_debug_keywords_return_max() { - for msg in [ - "\u{30b3}\u{30fc}\u{30c9}\u{3092}\u{30c7}\u{30d0}\u{30c3}\u{30b0}", // コードをデバッグ - "\u{30a8}\u{30e9}\u{30fc}\u{304c}\u{51fa}\u{305f}", // エラーが出た - "\u{30d0}\u{30b0}\u{3092}\u{4fee}\u{6b63}", // バグを修正 - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::Max, - "expected Max for `{msg}`", - ); - } - } - - #[test] - fn chinese_search_keywords_return_low() { - for msg in [ - "\u{641c}\u{7d22}\u{4e00}\u{4e0b}\u{6587}\u{4ef6}", // 搜索一下文件 - "\u{5e2e}\u{6211}\u{67e5}\u{627e}\u{5b9a}\u{4e49}", // 帮我查找定义 - "\u{67e5}\u{8be2}\u{6587}\u{6863}", // 查询文档 - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::Low, - "expected Low for `{msg}`", - ); - } - } - - #[test] - fn japanese_search_keyword_returns_low() { - // 検索 → "search" - assert_eq!( - select( - false, - "\u{30c9}\u{30ad}\u{30e5}\u{30e1}\u{30f3}\u{30c8}\u{691c}\u{7d22}" - ), - ReasoningEffort::Low, - ); - } - - #[test] - fn cjk_default_still_returns_high() { - // No keyword hits — ordinary Chinese/Japanese prose stays on - // the `High` default like English does. - for msg in [ - "\u{5e2e}\u{6211}\u{5199}\u{4e2a}\u{6d4b}\u{8bd5}", // 帮我写个测试 - "\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", // 重构这个模块 - "\u{30c6}\u{30b9}\u{30c8}\u{3092}\u{66f8}\u{304f}", // テストを書く - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::High, - "expected High for `{msg}`", - ); - } + fn declared_default_is_high() { + assert_eq!(select(), ReasoningEffort::High); } } diff --git a/crates/tui/src/child_env.rs b/crates/tui/src/child_env.rs index 794c0ca450..9b1d8e2741 100644 --- a/crates/tui/src/child_env.rs +++ b/crates/tui/src/child_env.rs @@ -442,6 +442,7 @@ fn windows_registry_env_vars() -> Vec<(OsString, OsString)> { fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HKEY, subkey: &str) { let mut key = HKEY::default(); let subkey_wide = windows_wide_null(OsStr::new(subkey)); + // SAFETY: `subkey_wide` is NUL-terminated and live; `key` is live. let open = unsafe { RegOpenKeyExW(root, PCWSTR(subkey_wide.as_ptr()), None, KEY_READ, &mut key) }; if open != ERROR_SUCCESS { @@ -462,6 +463,7 @@ fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HK } } + // SAFETY: `key` was opened above and is not used after. let _ = unsafe { RegCloseKey(key) }; } @@ -481,6 +483,7 @@ fn read_windows_registry_env_value(key: HKEY, index: u32) -> RegistryEnvValue { let mut name_len = name.len() as u32; let mut data_len = data.len() as u32; let mut value_type = 0u32; + // SAFETY: buffers are live with matching lengths passed. let status = unsafe { RegEnumValueW( key, @@ -545,12 +548,14 @@ fn registry_utf16_value_from_bytes(data: &[u8]) -> OsString { #[cfg(windows)] fn expand_windows_env_string(value: &OsStr) -> Option { let src = windows_wide_null(value); + // SAFETY: `src` is NUL-terminated and live. let required_len = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), None) }; if required_len == 0 { return None; } let mut expanded = vec![0u16; required_len as usize]; + // SAFETY: `src` is NUL-terminated; `expanded` has the queried length. let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), Some(&mut expanded)) }; if written == 0 || written > required_len { return None; diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 08be1aed55..80a977f66b 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -25,6 +25,7 @@ //! hacks in the shared paths). use anyhow::{Context, Result}; +use serde::Deserialize; use serde_json::{Value, json}; use crate::config::{ApiProvider, wire_model_for_provider_route}; @@ -790,13 +791,76 @@ fn apply_anthropic_cache_breakpoints(body: &mut Value) { } } +/// Provider event types [`convert_anthropic_sse_data`] accepts. Anything else +/// with a string `type` is tolerated as `None` (future additions); note +/// `tool_projection_warning` is deliberately absent — it is local-only and +/// must never decode from provider SSE. +fn is_known_sse_type(event_type: &str) -> bool { + matches!( + event_type, + "message_start" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "message_delta" + | "message_stop" + | "ping" + | "error" + ) +} + +/// Peek at an SSE payload's `type` without building a DOM. +#[derive(Deserialize)] +struct SseTagPeek<'a> { + #[serde(borrow)] + r#type: Option<&'a str>, +} + /// Convert one SSE `data:` payload into a [`StreamEvent`], normalizing usage /// objects to the #2961 convention. Returns `None` for ignorable payloads. +/// +/// #6213 T7: the per-token path deserializes directly into the tagged +/// [`StreamEvent`] instead of building a `Value` DOM and converting it. +/// Usage-bearing events (two per stream) keep the exact legacy path — the +/// usage rewrite reads wire fields the normalized [`Usage`] cannot +/// represent — and decode failures keep their exact legacy outcomes. fn convert_anthropic_sse_data(data: &str) -> Option> { let trimmed = data.trim(); if trimmed.is_empty() { return None; } + let usage_event = matches!( + serde_json::from_str::(trimmed).map(|peek| peek.r#type), + Ok(Some("message_start" | "message_delta")) + ); + if usage_event { + return convert_anthropic_sse_usage_event(trimmed); + } + match serde_json::from_str::(trimmed) { + // Local-only receipt: the legacy path ignored it (not a provider + // type), so it stays ignored rather than decoding. + Ok(StreamEvent::ToolProjectionWarning { .. }) => None, + Ok(event) => Some(Ok(event)), + Err(error) => { + // Cold path, reached only when direct decode fails: invalid JSON + // and unknown types keep their exact legacy outcomes. + let value: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(e) => return Some(Err(anyhow::anyhow!("invalid SSE JSON: {e}"))), + }; + match value.get("type").and_then(Value::as_str) { + // Tolerate unknown event types (e.g. future additions) silently. + Some(known) if !is_known_sse_type(known) => None, + _ => Some(Err(anyhow::anyhow!("unrecognized SSE event: {error}"))), + } + } + } +} + +/// Legacy `Value` path for `message_start`/`message_delta`: the usage +/// rewrite reads wire fields the normalized [`Usage`] cannot represent, so +/// these two events normalize before decoding, exactly as before. +fn convert_anthropic_sse_usage_event(trimmed: &str) -> Option> { let mut value: Value = match serde_json::from_str(trimmed) { Ok(value) => value, Err(e) => return Some(Err(anyhow::anyhow!("invalid SSE JSON: {e}"))), @@ -817,19 +881,7 @@ fn convert_anthropic_sse_data(data: &str) -> Option> { } } // Tolerate unknown event types (e.g. future additions) silently. - Some(known) - if !matches!( - known, - "message_start" - | "content_block_start" - | "content_block_delta" - | "content_block_stop" - | "message_delta" - | "message_stop" - | "ping" - | "error" - ) => - { + Some(known) if !is_known_sse_type(known) => { return None; } _ => {} @@ -1711,6 +1763,30 @@ mod tests { assert!(convert_anthropic_sse_data(" ").is_none()); } + #[test] + fn sse_decode_failures_keep_legacy_outcomes_on_the_direct_path() { + // Malformed JSON: the invalid-input error, not the unrecognized one. + let error = convert_anthropic_sse_data("{oops") + .expect("malformed is Some") + .expect_err("malformed is Err"); + assert!(error.to_string().contains("invalid SSE JSON"), "{error:?}"); + // Structurally invalid known event: unrecognized, not tolerated. + let error = convert_anthropic_sse_data(r#"{"type":"content_block_stop"}"#) + .expect("known type is Some") + .expect_err("missing index is Err"); + assert!( + error.to_string().contains("unrecognized SSE event"), + "{error:?}" + ); + // Local-only receipt: never provider SSE, stays ignored. + assert!( + convert_anthropic_sse_data( + r#"{"type":"tool_projection_warning","provider":"x","omitted_tool_names":[],"omitted_tool_count":0}"# + ) + .is_none() + ); + } + #[test] fn usage_mapping_handles_missing_cache_fields() { let usage = parse_anthropic_usage(&json!({"input_tokens": 10, "output_tokens": 5})); diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 9f3f0f3616..7364d27ae9 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -3411,7 +3411,7 @@ mod tests { let _guard = EnvGuard::new(temp.path()); let mut app = create_test_app(); app.status_toasts.clear(); - assert!(app.maybe_show_behavioral_tip(BehavioralTip::PlanningMode)); + assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); app.push_status_toast("warning receipt", StatusToastLevel::Warning, None); app.push_status_toast("error receipt", StatusToastLevel::Error, None); app.sticky_status = Some(StatusToast::context_pressure( diff --git a/crates/tui/src/commands/groups/plugins/marketplace_tests.rs b/crates/tui/src/commands/groups/plugins/marketplace_tests.rs index e938ecaf7d..b156d68a99 100644 --- a/crates/tui/src/commands/groups/plugins/marketplace_tests.rs +++ b/crates/tui/src/commands/groups/plugins/marketplace_tests.rs @@ -198,13 +198,39 @@ fn marketplace_add_list_show_remove_roundtrip() { "list/show must not rewrite marketplace state" ); - // duplicate name is refused - let dup = plugins_with_kimi_home_override( + // Re-adding the same source is a refresh, not a duplicate: a catalog is + // keyed by its document, so the second add updates it in place. (The old + // refusal here is what produced a second snapshot of one marketplace + // under a hand-made name.) + let refreshed = plugins_with_kimi_home_override( &mut app, Some(&format!("marketplace add kimi {}", catalog_path.display())), None, ); - assert!(dup.is_error); + assert!(!refreshed.is_error, "{:?}", refreshed.message); + // A *different* source under the same name is still refused. + let other_catalog = catalogs.join("other-marketplace.json"); + fs::write( + &other_catalog, + serde_json::to_string_pretty(&serde_json::json!({ + "version": "2", + "plugins": [ + { + "id": "other-bundle", + "source": "./other-bundle", + "displayName": "Other Bundle" + } + ] + })) + .unwrap(), + ) + .unwrap(); + let clash = plugins_with_kimi_home_override( + &mut app, + Some(&format!("marketplace add kimi {}", other_catalog.display())), + None, + ); + assert!(clash.is_error, "{:?}", clash.message); let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None); assert!(!removed.is_error, "{:?}", removed.message); diff --git a/crates/tui/src/commands/session_lifecycle_regression_tests.rs b/crates/tui/src/commands/session_lifecycle_regression_tests.rs index 5d664c2de3..d2b2e73fb8 100644 --- a/crates/tui/src/commands/session_lifecycle_regression_tests.rs +++ b/crates/tui/src/commands/session_lifecycle_regression_tests.rs @@ -115,8 +115,8 @@ fn save_preserves_latest_auto_route_receipt() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ShortRequest, + reason: crate::model_routing::AutoRouteReason::LocalFallback( + crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault, ), }; app.set_model_selection("auto".to_string()); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 17b3eb9fba..05053915fd 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -4754,7 +4754,7 @@ impl Config { /// Return `true` only when `[auto] cross_provider = true` is persisted in /// config (#4411). Auto mode otherwise stays on the active provider: the - /// classifier never sees other providers' routes, and the local heuristic + /// classifier never sees other providers' routes, and the local fallback /// never selects one. There is no interactive toggle — enabling /// cross-provider Auto is an explicit, durable config edit. #[must_use] diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 3aef386c52..e3621f684d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -40,8 +40,7 @@ use crate::route_runtime::{ ResolvedRuntimeRoute, ValidatedRuntimeRoute, resolve_runtime_route_for_identity, }; use crate::tools::goal::{ - GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, explicit_goal_directive, - new_shared_goal_state, operate_goal_from_prompt, + GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, new_shared_goal_state, }; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; @@ -1709,28 +1708,6 @@ impl Engine { // `run_turn` restarts it per turn; this initial value only matters // for hosts that inspect the engine before the first turn. let turn_wall_clock_budget = config.turn_wall_clock; - // Skill-name snapshot for the plugin-suggestion gate (#6274): the - // SAME catalogue the system prompt indexes (prompts.rs skills block — - // workspace roots + configured skills_dir + plugin-sourced skills), - // so suppression sees everything the session actually has. - let gate_skill_names: std::collections::BTreeSet = - crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins( - &config.workspace, - &config.skills_dir, - crate::skills::SkillDiscoveryMode::from_codewhale_only( - config.skills_scan_codewhale_only, - ), - Some(plugin_registry.as_ref()), - ) - .list() - .iter() - .flat_map(|skill| { - std::iter::once(skill.name.clone()).chain(skill.aliases.iter().cloned()) - }) - .map(|name| name.trim().to_ascii_lowercase()) - .filter(|name| !name.is_empty()) - .collect(); - let engine = Engine { config, api_config: api_config.clone(), @@ -1757,9 +1734,7 @@ impl Engine { mcp_event_generation: 0, plugin_registry, recommended_plugin_gate: StdMutex::new( - crate::plugins::recommend::RecommendedPluginGate::with_skill_names( - gate_skill_names, - ), + crate::plugins::recommend::RecommendedPluginGate::default(), ), api_provider, api_provider_identity, @@ -4877,75 +4852,20 @@ impl Engine { if autonomous && self.cancel_token.is_cancelled() { return SendMessageOutcome::NotStarted { error: None }; } - let mut goal_objective = goal_objective; - let mut goal_token_budget = goal_token_budget; - let mut goal_status = goal_status; let initial_usage_owner = compaction.runtime_cost_owner.clone(); - // A literal natural-language `/goal` declaration is control-plane - // intent, not a suggestion that each provider may acknowledge or - // ignore. Activate it through the same GoalState::create path as the - // model-visible create_goal tool before constructing any provider - // request. Only structurally external user input can authorize this; - // runtime text, recalled memory, handoffs, and pasted multi-line - // transcripts cannot create a goal. - // - // Operate turns an ordinary work prompt into the goal through this - // same path when the host reports no unfinished goal - // (`operate_goal_from_prompt` owns the "is this real work?" rule). - // `GoalState::create` still refuses while an unfinished goal exists, - // so a paused or blocked goal is never silently replaced. + // Goals are created by the model (`create_goal`) or by the leading + // `/goal ` command; the host never infers one from + // wording (docs/design/TUI_DECONSTRUCTION.md — founder clarification + // 2026-09-09: the model decides when a goal is useful). The + // natural-language `/goal` prose parser that used to recognize + // "make it your /goal to ..." is gone with the #6290 rework — a + // prose ask reaches the model, which calls `create_goal` when a goal + // is actually useful. // - // KV-cache effect: this only selects the already-existing volatile - // contributor. It adds no new stable-prefix text. - let goal_request = if provenance.can_authorize_work() { - explicit_goal_directive(&content) - .map(|directive| (directive, true)) - .or_else(|| { - let host_goal_unfinished = - goal_objective.is_some() && goal_status != GoalStatus::Complete; - (mode == AppMode::Operate && !host_goal_unfinished) - .then(|| operate_goal_from_prompt(&content)) - .flatten() - .map(|directive| (directive, false)) - }) - } else { - None - }; - if let Some((directive, explicit)) = goal_request { - let result = self - .config - .goal_state - .lock() - .map_err(|_| "goal state lock poisoned".to_string()) - .and_then(|mut state| { - state - .create(directive.objective, None) - .map_err(str::to_string)?; - Ok(state.snapshot()) - }); - match result { - Ok(snapshot) => { - goal_objective.clone_from(&snapshot.objective); - goal_token_budget = snapshot.token_budget; - goal_status = GoalStatus::Active; - // Publish before TurnStarted/provider dispatch so the TUI - // and durable runtime host observe the real goal action, - // even when this model would otherwise reply only in prose. - let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; - } - Err(error) if explicit => { - let _ = self - .tx_event - .send(Event::status(format!( - "Requested /goal was not created: {error}" - ))) - .await; - } - // Operate keeps the unfinished goal it already has. - Err(_) => {} - } - } + // KV-cache effect: none. Goal state still flows through the existing + // volatile contributor; nothing here touches the + // stable prefix. let effective_provider = route.identity.provider; let provider_identity = route.identity.key.clone(); diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index ff1f598fa2..31820aba9c 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -438,13 +438,12 @@ impl Engine { .preview_runtime_transforms(&messages, system_prompt.as_ref(), &planned_compaction) .await; - // The turn loop resolves an `auto` sentinel tier against the messages - // it is about to send, *after* the planner normalized it. Skipping - // that step described a request carrying a literal `auto`, which no - // route receives. + // The turn loop resolves an `auto` sentinel tier to its declared + // policy value, *after* the planner normalized it. Skipping that step + // described a request carrying a literal `auto`, which no route + // receives. let effective_reasoning_effort = super::turn_loop::resolve_auto_effort( reasoning_effort.as_deref(), - &messages, provider, &base_url, &model, diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 01da0bd1e0..44308ccc0c 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -792,7 +792,6 @@ async fn plan_with_reasoning( reasoning_effort, mode: AppMode::Agent, content: prompt, - display_text: prompt, auto_router_context: "", should_auto_resolve: auto_model, allow_auto_router_response_cache: false, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bf2c2fb664..69a69ff1d7 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3160,7 +3160,7 @@ fn goal_custom_route_config() -> Config { } #[tokio::test] -async fn explicit_natural_goal_activates_before_provider_request() { +async fn ordinary_prose_never_activates_a_goal() { let request_entered = std::sync::Arc::new(tokio::sync::Notify::new()); let release_request = std::sync::Arc::new(tokio::sync::Notify::new()); let model = std::sync::Arc::new(FirstRequestGatedGoalModelClient { @@ -3213,24 +3213,25 @@ async fn explicit_natural_goal_activates_before_provider_request() { .await .expect("send explicit natural goal turn"); - let mut saw_goal_before_turn = false; + // #6290 rework: the natural-language `/goal` prose parser is gone. The + // same wording that used to activate a goal is now an ordinary turn; + // only the model (`create_goal`) or the `/goal` command creates one. + let mut saw_goal = false; loop { let event = tokio::time::timeout(model_turn_event_timeout(), async { handle.rx_event.write().await.recv().await }) .await - .expect("explicit goal event timeout") - .expect("explicit goal event"); + .expect("prose goal event timeout") + .expect("prose goal event"); match event { - Event::GoalUpdated { snapshot } => { - assert_eq!(snapshot.objective.as_deref(), Some("solve navier stokes")); - assert_eq!(snapshot.status, "active"); - saw_goal_before_turn = true; + Event::GoalUpdated { .. } => { + saw_goal = true; } Event::TurnStarted { .. } => { assert!( - saw_goal_before_turn, - "durable goal must be published before provider work starts" + !saw_goal, + "ordinary prose must not publish a goal before provider work starts" ); break; } @@ -3241,29 +3242,15 @@ async fn explicit_natural_goal_activates_before_provider_request() { tokio::time::timeout(model_turn_event_timeout(), request_entered.notified()) .await .expect("provider request was never entered"); - let snapshot = goal_state.lock().expect("goal lock").snapshot(); - assert_eq!(snapshot.objective.as_deref(), Some("solve navier stokes")); - assert!(snapshot.is_active()); - - // Stop autonomous continuation after the one provider-boundary receipt. - handle - .send(Op::SetGoalStatus { - goal_id: None, - status: crate::tools::goal::GoalStatus::Paused, - clear: false, - }) - .await - .expect("queue goal pause"); release_request.notify_one(); let _ = tokio::time::timeout(model_turn_event_timeout(), handle.get_session_snapshot()) .await - .expect("goal pause did not settle") - .expect("post-goal session snapshot"); + .expect("turn did not settle") + .expect("post-turn session snapshot"); + let snapshot = goal_state.lock().expect("goal lock").snapshot(); + assert_eq!(snapshot.objective.as_deref(), None); + assert!(!snapshot.is_active()); assert_eq!(model.calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!( - goal_state.lock().expect("goal lock").snapshot().status, - "paused" - ); handle.send(Op::Shutdown).await.expect("shutdown engine"); run_task.await.expect("engine task"); @@ -3375,17 +3362,19 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option, boo } #[tokio::test] -async fn operate_turns_a_work_prompt_into_the_goal_but_work_mode_does_not() { +async fn operate_never_promotes_wording_to_a_goal() { let prompt = "Migrate the settings loader to the new config crate and keep the old keys readable"; + // The verb-list promotion is gone: an ordinary work prompt is an ordinary + // turn in every mode, and the model decides goals through `create_goal` + // (docs/design/TUI_DECONSTRUCTION.md, founder clarification 2026-09-09). let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, prompt).await; assert_eq!( - objective.as_deref(), - Some(prompt), - "Operate must publish the prompt as the goal before the provider call" + objective, None, + "the host must not infer a goal from wording" ); - assert!(active, "Operate goal must be active in engine state"); + assert!(!active); assert_eq!(contracts, 1, "Operate appends its contract exactly once"); let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, prompt).await; @@ -3393,11 +3382,17 @@ async fn operate_turns_a_work_prompt_into_the_goal_but_work_mode_does_not() { assert!(!active); assert_eq!(contracts, 0, "Work never sees the Operate contract"); + // #6290 rework: even an explicit-looking declaration is ordinary + // prose now — the host never parses it, and the model decides goals + // through `create_goal`. let (objective, active, contracts) = - operate_goal_probe(AppMode::Operate, "thanks, looks good").await; - assert_eq!(objective, None, "chat stays chat even in Operate"); + operate_goal_probe(AppMode::Operate, "Please set /goal to ship the release").await; + assert_eq!( + objective, None, + "prose asking for a goal must not create one host-side" + ); assert!(!active); - assert_eq!(contracts, 1, "the contract is about the mode, not the goal"); + assert_eq!(contracts, 1); } #[tokio::test] @@ -3502,10 +3497,22 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace }) }; - let first = + let first_objective = "Migrate the settings loader to the new config crate and keep the old keys readable"; + // #6290 rework: prose no longer creates goals, so the unfinished goal + // this test needs is seeded directly — the same `GoalState::create` path + // the `/goal` command and the model's `create_goal` tool use. + goal_state + .lock() + .expect("goal lock") + .create(first_objective.to_string(), None) + .expect("seed unfinished goal"); handle - .send(send(first, None, crate::tools::goal::GoalStatus::Active)) + .send(send( + first_objective, + Some(first_objective.to_string()), + crate::tools::goal::GoalStatus::Active, + )) .await .expect("send first Operate turn"); tokio::time::timeout(model_turn_event_timeout(), first_entered.notified()) @@ -3513,7 +3520,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace .expect("first provider request was never entered"); assert_eq!( goal_state.lock().expect("goal lock").objective(), - Some(first) + Some(first_objective) ); handle .send(Op::SetGoalStatus { @@ -3534,7 +3541,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace handle .send(send( "Refactor the provider table so it survives a config reload", - Some(first.to_string()), + Some(first_objective.to_string()), crate::tools::goal::GoalStatus::Paused, )) .await @@ -3554,7 +3561,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace "the contract must not repeat on later Operate turns" ); let goal = goal_state.lock().expect("goal lock").snapshot(); - assert_eq!(goal.objective.as_deref(), Some(first)); + assert_eq!(goal.objective.as_deref(), Some(first_objective)); assert_eq!(goal.status, "paused"); handle.send(Op::Shutdown).await.expect("shutdown engine"); @@ -12368,9 +12375,10 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { "\"finish_reason\":\"stop\"}]}\n\n", "data: [DONE]\n\n", ); - // Operate turns the work prompt into a goal, so after the approved shell - // runs, the model must seal the goal through the same `update_goal` tool - // a live Operate turn uses; only then does the final "done" arrive. + // The goal this fixture seals is seeded directly (prose no longer + // creates goals since the #6290 rework); after the approved shell runs, + // the model seals it through the same `update_goal` tool a live Operate + // turn uses, and only then does the final "done" arrive. let goal_seal_marker = "goal-seal-receipt-0902"; let goal_sse = concat!( "data: {\"id\":\"chatcmpl-operate-goal\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[", @@ -12450,19 +12458,26 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { }, &api_config, ); + engine + .config + .goal_state + .lock() + .expect("goal lock") + .create("write the requested local fixture".to_string(), None) + .expect("seed fixture goal"); let handle_for_approval = handle.clone(); let run_task = tokio::spawn(engine.run()); handle .send(Op::SendMessage(TurnSpec { max_output_tokens: None, - content: "write the requested local fixture".to_string(), + content: "Write the requested local fixture to the workspace".to_string(), images: Vec::new(), mode: AppMode::Operate, route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL), compaction: Box::new(CompactionConfig::default()), initial_routed_usage: Box::default(), - goal_objective: None, + goal_objective: Some("write the requested local fixture".to_string()), goal_token_budget: None, goal_status: crate::tools::goal::GoalStatus::Active, reasoning_effort: None, @@ -12494,19 +12509,8 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { Event::ApprovalRequired { id, tool_name, .. } => { saw_approval = true; assert_eq!(tool_name, "Bash"); - // Operate turned this work prompt into the goal; pause it - // before the turn ends so the goal-continuation loop cannot - // queue passes nobody answers in this mock. The goal-complete - // `update_goal` response below stays: it is the receipt a - // real Operate turn seals with. - handle_for_approval - .send(Op::SetGoalStatus { - goal_id: None, - status: crate::tools::goal::GoalStatus::Paused, - clear: false, - }) - .await - .expect("queue goal pause"); + // The seeded fixture goal is orthogonal to this gate: + // Operate uses the normal approval flow either way. handle_for_approval .approve_tool_call(id) .await diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 94b0c43096..281d84cfed 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1247,7 +1247,6 @@ impl Engine { // Resolve `auto` reasoning_effort to a concrete tier (#663). let effective_reasoning_effort = resolve_auto_effort( self.session.reasoning_effort.as_deref(), - &self.session.messages, self.api_provider, &self.api_config.deepseek_base_url(), &self.config.model, @@ -6267,55 +6266,27 @@ pub(super) const REASONING_EFFORT_AUTO: &str = "auto"; /// Resolve an `"auto"` reasoning-effort tier to a concrete value. /// -/// When the configured effort is `"auto"`, inspects the last user message -/// and calls [`crate::auto_reasoning::select`] to pick the actual tier. -/// Non-`"auto"` values pass through unchanged. +/// When the configured effort is `"auto"`, calls +/// [`crate::auto_reasoning::select`] for the declared policy tier. The message +/// is no longer inspected: the keyword classifier was deleted with the #6290 +/// rework, and `auto` now means the declared default rather than a guess from +/// the user's wording. Non-`"auto"` values pass through unchanged. pub(super) fn resolve_auto_effort( reasoning_effort: Option<&str>, - messages: &[Message], provider: crate::config::ApiProvider, base_url: &str, wire_model: &str, ) -> Option { match reasoning_effort { Some(effort) if effort == REASONING_EFFORT_AUTO => { - // Find the last user message in the conversation. - let last_msg = messages - .iter() - .rev() - .find(|m| m.role == "user") - .map(|m| { - m.content - .iter() - .filter_map(|block| { - if let ContentBlock::Text { text, .. } = block { - if is_turn_metadata_text(text) { - None - } else { - Some(text.as_str()) - } - } else { - None - } - }) - .collect::>() - .join(" ") - }) - .unwrap_or_default(); - - // is_subagent is false here — run_turn runs in the - // main engine (not a sub-agent's inner loop). Sub-agents have - // their own turn pass and can pass is_subagent=true when they - // call this function directly. - let tier = crate::auto_reasoning::select(false, &last_msg); + let tier = crate::auto_reasoning::select(); let resolved = tier .normalize_for_route(provider, base_url, wire_model) .as_setting() .to_string(); tracing::debug!( reasoning_effort = %resolved, - is_subagent = false, - "auto_reasoning: resolved auto tier from user message" + "auto_reasoning: resolved auto tier from declared policy" ); Some(resolved) } @@ -6324,10 +6295,6 @@ pub(super) fn resolve_auto_effort( } } -fn is_turn_metadata_text(text: &str) -> bool { - text.trim_start().starts_with("") -} - #[cfg(test)] mod tests { use super::*; @@ -6792,47 +6759,26 @@ mod tests { } #[test] - fn resolve_auto_effort_ignores_stored_turn_metadata() { - let messages = vec![Message { - role: Role::User, - content: vec![ - ContentBlock::Text { - text: "\nRecent errors: src/failing.rs\n".to_string(), - cache_control: None, - }, - ContentBlock::Text { - text: "hello".to_string(), - cache_control: None, - }, - ], - }]; - + fn resolve_auto_effort_is_content_blind() { + // #6290 rework: the resolved tier no longer depends on message text + // at all — stored metadata, questions, and work prompts alike take + // the declared default. assert_eq!( resolve_auto_effort( Some("auto"), - &messages, crate::config::ApiProvider::Deepseek, crate::config::DEFAULT_DEEPSEEK_BASE_URL, "deepseek-v4-pro", ), Some("high".to_string()), - "auto thinking should classify the user request, not stored metadata" + "auto resolves the declared default" ); } #[test] fn resolve_auto_effort_selects_a_concrete_kimi_code_tier() { - let messages = vec![Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: "inspect this repository and fix the failing tests".to_string(), - cache_control: None, - }], - }]; - let resolved = resolve_auto_effort( Some("auto"), - &messages, crate::config::ApiProvider::Moonshot, crate::config::DEFAULT_KIMI_CODE_BASE_URL, crate::config::KIMI_CODE_K3_MODEL, @@ -6846,7 +6792,6 @@ mod tests { assert_eq!( resolve_auto_effort( None, - &messages, crate::config::ApiProvider::Moonshot, crate::config::DEFAULT_KIMI_CODE_BASE_URL, crate::config::KIMI_CODE_K3_MODEL, diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 31418a541f..511c9f0f43 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -143,6 +143,12 @@ pub struct AgentProgressEventMeta { /// Canonical action/tool name. Presentation aliases are applied by the UI /// when it creates the bounded current-activity projection. pub tool_name: Option, + /// True when this progress is the routine per-step wait heartbeat + /// ("requesting model response"). Retry/timeout waits share the + /// `ModelWait` status but carry informative text, so the status alone + /// cannot tell them apart — the producer sets this instead, and UI + /// consumers rewrite on it rather than sniffing the message (#6290). + pub routine_wait: bool, } impl AgentProgressEventMeta { @@ -152,6 +158,7 @@ impl AgentProgressEventMeta { worker_status, step: None, tool_name: None, + routine_wait: false, } } @@ -161,6 +168,12 @@ impl AgentProgressEventMeta { self } + #[must_use] + pub const fn routine_wait(mut self) -> Self { + self.routine_wait = true; + self + } + #[must_use] pub fn with_tool(mut self, tool_name: impl Into) -> Self { self.tool_name = Some(tool_name.into()); diff --git a/crates/tui/src/exec_agent.rs b/crates/tui/src/exec_agent.rs index a8bd4b199a..45a218151f 100644 --- a/crates/tui/src/exec_agent.rs +++ b/crates/tui/src/exec_agent.rs @@ -383,7 +383,7 @@ pub(crate) async fn run_exec_agent( // `run_one_shot`/`run_one_shot_json` and the interactive launch path do, // so the tier the engine (and the receipt below) sees is concrete. let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| { - cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt) + cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort) }); let settings = crate::settings::Settings::load().unwrap_or_default(); diff --git a/crates/tui/src/external_credentials.rs b/crates/tui/src/external_credentials.rs index 07e6be4c12..78256d581d 100644 --- a/crates/tui/src/external_credentials.rs +++ b/crates/tui/src/external_credentials.rs @@ -228,6 +228,7 @@ fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result } if require_owner_only { use std::os::unix::fs::MetadataExt as _; + // SAFETY: geteuid(2) dereferences no pointers. if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 || metadata.nlink() != 1 @@ -463,6 +464,7 @@ fn verify_windows_owner_only_handle( return Err(io::Error::from_raw_os_error(result as i32)); } let _descriptor = WindowsLocalAllocation(descriptor.cast()); + // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`. if owner.is_null() || unsafe { EqualSid(owner, user.sid()) } == 0 { return Err(io::Error::new( io::ErrorKind::PermissionDenied, @@ -493,6 +495,7 @@ fn verify_windows_owner_only_handle( // SAFETY: `count == 1` proves the first returned entry is initialized. let entry = unsafe { &*entries }; let trustee_sid: PSID = entry.Trustee.ptstrName.cast(); + // SAFETY: form and null checked in this expression; sid owned by `user`. let current_user_only = entry.Trustee.TrusteeForm == TRUSTEE_IS_SID && !trustee_sid.is_null() && unsafe { EqualSid(trustee_sid, user.sid()) } != 0 @@ -532,7 +535,9 @@ impl CurrentWindowsUser { let _ = unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) }; if needed == 0 { + // SAFETY: reads thread-local error state only. let error = io::Error::from_raw_os_error(unsafe { GetLastError() } as i32); + // SAFETY: `token` is owned here and not stored on this path. unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; return Err(error); } @@ -551,11 +556,14 @@ impl CurrentWindowsUser { } == 0 { let error = io::Error::last_os_error(); + // SAFETY: `token` is owned here and not stored on this path. unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; return Err(error); } + // SAFETY: initialized by GetTokenInformation; buffer outlives use. let user = unsafe { &*token_info.as_ptr().cast::() }; if user.User.Sid.is_null() { + // SAFETY: `token` is owned here and not stored on this path. unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index 1137b07475..4a0ffcc9cf 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -898,6 +898,11 @@ impl FleetExecutor { } } + /// Maximum bytes drained from a worker log per call, and the cap for the + /// buffered partial line. Event lines are small; the remainder stays for + /// the next drain. + const MAX_DRAIN_BYTES: u64 = 1024 * 1024; + /// Read any newly-written stream-json lines for a worker and map them to /// fleet ledger events. Safe to call repeatedly; only new bytes are parsed, /// and a trailing partial line is buffered until its newline arrives. @@ -914,7 +919,7 @@ impl FleetExecutor { return events; } let mut buf = Vec::new(); - if let Ok(read) = file.read_to_end(&mut buf) { + if let Ok(read) = file.take(Self::MAX_DRAIN_BYTES).read_to_end(&mut buf) { stream.offset += read as u64; stream.pending.extend_from_slice(&buf); while let Some(idx) = stream.pending.iter().position(|byte| *byte == b'\n') { @@ -923,6 +928,11 @@ impl FleetExecutor { events.push(event); } } + // Whatever remains has no newline; drop it rather than buffering + // a newline-free flood forever. + if stream.pending.len() as u64 > Self::MAX_DRAIN_BYTES { + stream.pending.clear(); + } } events } diff --git a/crates/tui/src/fleet/host.rs b/crates/tui/src/fleet/host.rs index 54a18aedd4..d332ff5aa3 100644 --- a/crates/tui/src/fleet/host.rs +++ b/crates/tui/src/fleet/host.rs @@ -993,6 +993,7 @@ fn unix_session_members( if pid > 0 { // Revalidate against the kernel after parsing the snapshot. A PID // reused by an unrelated process must never receive our signal. + // SAFETY: getsid(2) dereferences no pointers. if unsafe { libc::getsid(pid) } == session_id { members.push(pid); } @@ -1015,6 +1016,7 @@ fn unix_session_members( #[cfg(unix)] fn unix_pid_in_session(pid: libc::pid_t, session_id: libc::pid_t) -> bool { + // SAFETY: getsid(2) dereferences no pointers. unsafe { libc::getsid(pid) == session_id } } @@ -1023,6 +1025,7 @@ fn unix_pid_exists(pid: libc::pid_t) -> bool { if pid <= 0 { return false; } + // SAFETY: kill(2) dereferences no pointers; signal 0 sends nothing. if unsafe { libc::kill(pid, 0) } == 0 { return true; } @@ -1163,6 +1166,7 @@ fn signal_unix_session( signal: libc::c_int, known_leader: Option, ) -> FleetHostResult> { + // SAFETY: getsid(2) dereferences no pointers. let own_session = unsafe { libc::getsid(0) }; if session_id <= 0 || session_id == own_session { return Err(FleetHostError::terminal(format!( @@ -1182,6 +1186,7 @@ fn signal_unix_session( for pid in candidates { // Verify identity again immediately before signalling. Session IDs // remain stable across reparenting and separate process groups. + // SAFETY: getsid(2) dereferences no pointers. if unsafe { libc::getsid(pid) } != session_id { // Leader may already be gone; still try kill on known leader when // getsid fails only with ESRCH-equivalent absence. @@ -1189,6 +1194,7 @@ fn signal_unix_session( continue; } } + // SAFETY: kill(2) dereferences no pointers. if unsafe { libc::kill(pid, signal) } != 0 { let err = std::io::Error::last_os_error(); if err.raw_os_error() != Some(libc::ESRCH) { @@ -1266,10 +1272,12 @@ unsafe impl Sync for FleetWindowsJob {} #[cfg(windows)] impl FleetWindowsJob { fn attach_to_child(child: &Child) -> std::io::Result { + // SAFETY: returned handle is owned by the new wrapper. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; let job = Self { handle }; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is live with matching size; both handles are live. unsafe { SetInformationJobObject( job.handle, @@ -1285,11 +1293,13 @@ impl FleetWindowsJob { } fn terminate(&self) -> std::io::Result<()> { + // SAFETY: `self.handle` is a live owned job handle. unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } } fn has_active_processes(&self) -> std::io::Result { let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: `accounting` is live with matching size. unsafe { QueryInformationJobObject( Some(self.handle), @@ -1307,6 +1317,7 @@ impl FleetWindowsJob { #[cfg(windows)] impl Drop for FleetWindowsJob { fn drop(&mut self) { + // SAFETY: `self.handle` is owned here; Drop runs once. unsafe { let _ = CloseHandle(self.handle); } diff --git a/crates/tui/src/fleet/ledger.rs b/crates/tui/src/fleet/ledger.rs index fab51cba59..b832857570 100644 --- a/crates/tui/src/fleet/ledger.rs +++ b/crates/tui/src/fleet/ledger.rs @@ -8,8 +8,10 @@ #![allow(dead_code)] use std::collections::BTreeMap; +use std::collections::HashMap; #[cfg(test)] use std::fs::OpenOptions; +use std::sync::{Mutex, OnceLock}; use super::files::{WorkspaceFile, same_file}; use std::io::{BufRead, Read, Seek, SeekFrom, Write}; @@ -19,11 +21,69 @@ use anyhow::{Context, Result, bail}; use codewhale_protocol::fleet::*; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use tokio::sync::Notify; const FLEET_DIR: &str = ".codewhale"; const FLEET_LEDGER_FILE: &str = "fleet.jsonl"; const FLEET_LEDGER_LOCK_FILE: &str = "fleet.lock"; +/// Path of the ledger file under `workspace`, mirroring [`FleetLedger::open`]. +pub(crate) fn fleet_ledger_path(workspace: &Path) -> PathBuf { + workspace.join(FLEET_DIR).join(FLEET_LEDGER_FILE) +} + +/// Process-wide append wakes, one [`Notify`] per ledger file (#6211 R7b). +/// Ledger managers open per operation, so instances cannot share a channel +/// handle; the registry joins differently-spelled opens of one file by +/// canonicalized path. A wake means "re-poll with your cursor" — the cursor +/// stays the source of truth, so a missed or spurious wake only costs one +/// extra poll, never lost or duplicated events. +static FLEET_LEDGER_WAKES: OnceLock>>> = + OnceLock::new(); + +fn fleet_ledger_wake_key(ledger_path: &Path) -> PathBuf { + if let Ok(canonical) = ledger_path.canonicalize() { + return canonical; + } + // The ledger file may not exist yet at subscribe time; canonicalize the + // parent so both spellings still meet. A fully missing tree falls back + // to the raw path on both sides. + match ledger_path + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .zip(ledger_path.file_name()) + { + Some((parent, name)) => parent.join(name), + None => ledger_path.to_path_buf(), + } +} + +/// Subscribe to append wakes for the ledger at `ledger_path`. Callers must +/// still poll on a fallback interval: the registry only sees in-process +/// appends, and a wake can race the subscriber's last read. +pub(crate) fn subscribe_fleet_ledger_appends(ledger_path: &Path) -> std::sync::Arc { + let key = fleet_ledger_wake_key(ledger_path); + let mut wakes = FLEET_LEDGER_WAKES + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("fleet ledger wake registry poisoned"); + wakes + .entry(key) + .or_insert_with(|| std::sync::Arc::new(Notify::new())) + .clone() +} + +fn notify_fleet_ledger_append(ledger_path: &Path) { + let key = fleet_ledger_wake_key(ledger_path); + let notify = FLEET_LEDGER_WAKES + .get() + .and_then(|wakes| wakes.lock().ok()) + .and_then(|wakes| wakes.get(&key).cloned()); + if let Some(notify) = notify { + notify.notify_waiters(); + } +} + fn inline_secret_assignment_pattern() -> &'static regex::Regex { static PATTERN: std::sync::OnceLock = std::sync::OnceLock::new(); PATTERN.get_or_init(|| { @@ -420,6 +480,10 @@ impl FleetLedger { .with_context(|| format!("flushing fleet ledger {}", self.ledger_path.display()))?; file.sync_data() .with_context(|| format!("syncing fleet ledger {}", self.ledger_path.display()))?; + // Wake SSE subscribers after the bytes are durable (#6211 R7b). Every + // record kind funnels through here, so a wake only promises "something + // changed" — subscribers re-poll with their cursor. + notify_fleet_ledger_append(&self.ledger_path); Ok(()) } @@ -2533,6 +2597,37 @@ mod tests { use std::time::Duration; use tempfile::TempDir; + #[tokio::test] + async fn ledger_append_wakes_subscribers() { + let workspace = TempDir::new().unwrap(); + let ledger = FleetLedger::open(workspace.path()).unwrap(); + let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(workspace.path())); + let notified = appends.notified(); + tokio::pin!(notified); + let appended = tokio::task::spawn_blocking(move || { + ledger.create_run(&sample_run("run-1")).unwrap(); + }); + tokio::time::timeout(Duration::from_secs(5), &mut notified) + .await + .expect("append should wake subscribers"); + appended.await.unwrap(); + } + + #[tokio::test] + async fn ledger_wake_is_scoped_to_its_file() { + let first = TempDir::new().unwrap(); + let second = TempDir::new().unwrap(); + let ledger = FleetLedger::open(first.path()).unwrap(); + let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(second.path())); + ledger.create_run(&sample_run("run-1")).unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), appends.notified()) + .await + .is_err(), + "an append must not wake another ledger's subscribers" + ); + } + #[test] fn ledger_rejects_replaced_lock_identity() { let workspace = TempDir::new().unwrap(); diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index ca4cb17af2..db06a0e26f 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -950,6 +950,7 @@ impl HookProcessTree { fn terminate(&self, child: &mut Child) { #[cfg(unix)] { + // SAFETY: kill(2) dereferences no pointers. let result = unsafe { libc::kill(-self.pgid, libc::SIGKILL) }; if result != 0 { let error = std::io::Error::last_os_error(); @@ -985,6 +986,7 @@ impl HookProcessTree { impl Drop for HookProcessTree { fn drop(&mut self) { #[cfg(unix)] + // SAFETY: kill(2) dereferences no pointers. unsafe { // The shell may have exited while one of its descendants still // holds a captured pipe. Reaping the process group keeps hook @@ -1004,11 +1006,13 @@ struct WindowsHookJob { #[cfg(windows)] impl WindowsHookJob { fn attach(child: &Child) -> std::io::Result { + // SAFETY: returned handle is owned by the new wrapper. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; let job = Self { handle }; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is live with matching size; both handles are live. unsafe { SetInformationJobObject( job.handle, @@ -1024,6 +1028,7 @@ impl WindowsHookJob { } fn terminate(&self) -> std::io::Result<()> { + // SAFETY: `self.handle` is a live owned job handle. unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } } } @@ -1031,6 +1036,7 @@ impl WindowsHookJob { #[cfg(windows)] impl Drop for WindowsHookJob { fn drop(&mut self) { + // SAFETY: `self.handle` is owned here; Drop runs once. unsafe { let _ = CloseHandle(self.handle); } @@ -1045,21 +1051,26 @@ fn windows_io_error(error: windows::core::Error) -> std::io::Error { #[cfg(windows)] fn resume_windows_process(child: &Child) -> std::io::Result<()> { let snapshot = + // SAFETY: returned handle is owned here; closed before return. unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(windows_io_error)? }; let result = (|| { let mut entry = THREADENTRY32 { dwSize: std::mem::size_of::() as u32, ..Default::default() }; + // SAFETY: `entry` is live with dwSize initialized above. let mut next = unsafe { Thread32First(snapshot, &mut entry) }; let mut resumed = 0usize; while next.is_ok() { if entry.th32OwnerProcessID == child.id() { + // SAFETY: returned handle is owned here; closed below. let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) .map_err(windows_io_error)? }; + // SAFETY: `thread` is a live owned handle. let resume_result = unsafe { ResumeThread(thread) }; + // SAFETY: `thread` is owned here and not used after. let close_result = unsafe { CloseHandle(thread).map_err(windows_io_error) }; if resume_result == u32::MAX { return Err(std::io::Error::last_os_error()); @@ -1067,6 +1078,7 @@ fn resume_windows_process(child: &Child) -> std::io::Result<()> { close_result?; resumed += 1; } + // SAFETY: `entry` is live with dwSize initialized above. next = unsafe { Thread32Next(snapshot, &mut entry) }; } if resumed == 0 { @@ -1076,6 +1088,7 @@ fn resume_windows_process(child: &Child) -> std::io::Result<()> { } Ok(()) })(); + // SAFETY: `snapshot` is owned here and not used after. let close_result = unsafe { CloseHandle(snapshot).map_err(windows_io_error) }; result?; close_result diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a9c8affdf3..d2c29dc990 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -178,6 +178,7 @@ fn configure_windows_console_utf8() { use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP}; const CP_UTF8: u32 = 65001; + // SAFETY: integer argument only; failures discarded. unsafe { let _ = SetConsoleCP(CP_UTF8); let _ = SetConsoleOutputCP(CP_UTF8); @@ -1711,6 +1712,7 @@ fn run_with_args(args: Vec) -> Result<()> { // Match the dispatcher entrypoint: Unix shells and supervisors may inherit // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor | // head` into BrokenPipe panics once this delegated TUI binary prints. + // SAFETY: first call at startup; no threads or handlers yet. #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); @@ -4513,7 +4515,7 @@ async fn run_doctor( println!("{}", "Configuration:".bold()); let config_path = &doctor_paths.config; - if config_path.exists() { + if tokio::fs::try_exists(config_path).await.unwrap_or(false) { println!( " {} config.toml found at {}", "✓".truecolor(aqua_r, aqua_g, aqua_b), @@ -8700,30 +8702,6 @@ Provide findings ordered by severity with file references, then open questions, &request_route.model, review_allowance, ); - // Criterion 4 (no silent caps): whatever a budget stop leaves unread is - // named by file, never silently dropped. - let unreviewed_note = |completed_passes: usize| -> String { - let Some(plan) = pr_plan.as_ref() else { - return "the entire diff".to_string(); - }; - let mut unread: Vec = Vec::new(); - for pass in plan.manifest.passes.iter().skip(completed_passes) { - for file in &pass.files { - if !unread.iter().any(|seen| seen == file) { - unread.push(file.clone()); - } - } - } - if unread.is_empty() { - "no planned file was left unread".to_string() - } else { - format!( - "{} file(s) were never read: {}", - unread.len(), - unread.join(", ") - ) - } - }; let mut output = String::new(); let mut review_stop_reason = None; for (index, user_prompt) in prompts.into_iter().enumerate() { @@ -8733,7 +8711,6 @@ Provide findings ordered by severity with file references, then open questions, &model, effort, review_reserve_percent, - &user_prompt, ) }); let request = MessageRequest { @@ -8797,7 +8774,7 @@ Provide findings ordered by severity with file references, then open questions, reasoning_tokens, review_allowance, review_reserve_tokens, - unreviewed_note(index), + pr_review_unreviewed_note(pr_plan.as_ref(), index), index, planned_passes, ), @@ -8868,7 +8845,7 @@ Provide findings ordered by severity with file references, then open questions, ); if let Some(coverage) = coverage { crate::tools::review::attach_pr_review_coverage(&mut receipt, coverage) - .context("Failed to attach complete PR coverage to review receipt")?; + .context("Failed to attach PR coverage to review receipt")?; } let path = crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref()) @@ -8903,6 +8880,10 @@ Provide findings ordered by severity with file references, then open questions, "stop_reason": review_stop_reason, "usage": usage, "review_passes": pr_plan.as_ref().map(|plan| plan.passes.len()), + "complete": pr_plan + .as_ref() + .is_none_or(|plan| plan.manifest.skipped_files.is_empty()), + "skipped_files": pr_plan.as_ref().map(|plan| &plan.manifest.skipped_files), "receipt_path": receipt .as_ref() .map(|(path, _)| path.display().to_string()), @@ -8937,9 +8918,64 @@ Provide findings ordered by severity with file references, then open questions, eprintln!("Review receipt written: {}", path.display()); } } + if let Some(plan) = &pr_plan + && !plan.manifest.skipped_files.is_empty() + { + // #6285 AC3: the partial review above is real findings, already + // printed and posted — but the gate must not pass on unread + // files. The exit still fails, naming what the gate did not read + // and the remedy, so it reads as limits rather than as "this PR + // failed review". + bail!( + "Partial PR review: {} pass(es) completed, but the gate did not read: {}. Raise --max-chars/--max-passes or shrink the PR, then re-run; publication: {}", + plan.passes.len(), + crate::tools::review::format_skipped_files(&plan.manifest.skipped_files), + publication.as_str() + ); + } Ok(()) } +/// Criterion 4 (no silent caps): whatever a budget stop leaves unread is +/// named by file, never silently dropped — including files the plan itself +/// skipped before the first pass ran (#6285 AC4). A free function so tests +/// can pin the note without running a review. +fn pr_review_unreviewed_note( + plan: Option<&crate::tools::review::PrReviewPlan>, + completed_passes: usize, +) -> String { + let Some(plan) = plan else { + return "the entire diff".to_string(); + }; + let mut unread: Vec = Vec::new(); + for pass in plan.manifest.passes.iter().skip(completed_passes) { + for file in &pass.files { + if !unread.iter().any(|seen| seen == file) { + unread.push(file.clone()); + } + } + } + let mut clauses = Vec::new(); + if !unread.is_empty() { + clauses.push(format!( + "{} file(s) were never read: {}", + unread.len(), + unread.join(", ") + )); + } + if !plan.manifest.skipped_files.is_empty() { + clauses.push(format!( + "the plan never scheduled: {}", + crate::tools::review::format_skipped_files(&plan.manifest.skipped_files) + )); + } + if clauses.is_empty() { + "no planned file was left unread".to_string() + } else { + clauses.join("; ") + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ReviewPublication { NotRequested, @@ -9907,13 +9943,22 @@ fn run_apply(args: ApplyArgs) -> Result<()> { Ok(()) } +/// Maximum bytes read for a patch on stdin. Generous for large diffs; +/// anything larger is not a patch. +const MAX_STDIN_PATCH_BYTES: u64 = 16 * 1024 * 1024; + fn read_patch_from_stdin() -> Result { - let mut stdin = io::stdin(); + let stdin = io::stdin(); if stdin.is_terminal() { bail!("No patch file provided and stdin is empty."); } let mut buffer = String::new(); - stdin.read_to_string(&mut buffer)?; + stdin + .take(MAX_STDIN_PATCH_BYTES + 1) + .read_to_string(&mut buffer)?; + if buffer.len() as u64 > MAX_STDIN_PATCH_BYTES { + bail!("patch on stdin exceeds the 16 MiB limit"); + } Ok(buffer) } @@ -11152,6 +11197,9 @@ fn merge_project_config_with_approval_baseline( } } +/// Maximum bytes read from a project config file. Configs are kilobytes. +const MAX_PROJECT_CONFIG_BYTES: u64 = 1024 * 1024; + fn read_project_config_file(path: &Path) -> io::Result> { let metadata = match std::fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -11169,9 +11217,16 @@ fn read_project_config_file(path: &Path) -> io::Result> { return Ok(None); } - let mut file = open_project_config_file(path)?; + let file = open_project_config_file(path)?; let mut raw = String::new(); - file.read_to_string(&mut raw)?; + file.take(MAX_PROJECT_CONFIG_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_PROJECT_CONFIG_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("project config {} exceeds the 1 MiB limit", path.display()), + )); + } Ok(Some(raw)) } @@ -11562,10 +11617,9 @@ fn cli_reasoning_effort_value_for_prompt( config: &Config, model: &str, effort: crate::reasoning_preference::ReasoningEffort, - prompt: &str, ) -> Option { let resolved = if effort == crate::reasoning_preference::ReasoningEffort::Auto { - crate::auto_reasoning::select(false, prompt) + crate::auto_reasoning::select() } else { effort }; @@ -11580,10 +11634,9 @@ fn review_reasoning_effort_value_for_prompt( model: &str, effort: crate::reasoning_preference::ReasoningEffort, reserve_percent: u32, - prompt: &str, ) -> Option { let resolved = if effort == crate::reasoning_preference::ReasoningEffort::Auto { - crate::auto_reasoning::select(false, prompt) + crate::auto_reasoning::select() } else { effort }; @@ -11746,7 +11799,7 @@ async fn run_one_shot( let execution_config = config_for_cli_route(config, &route); let client = DeepSeekClient::new(&execution_config)?; let reasoning_effort = route.reasoning_effort.and_then(|effort| { - cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt) + cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort) }); let model = route.model; let request_route = client.effective_route_envelope(&model, chrono::Utc::now()); @@ -11809,7 +11862,7 @@ async fn run_one_shot_json( let client = DeepSeekClient::new(&execution_config)?; let model = route.model.clone(); let reasoning_effort = route.reasoning_effort.and_then(|effort| { - cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt) + cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort) }); let request_route = client.effective_route_envelope(&model, chrono::Utc::now()); let request = MessageRequest { @@ -17067,6 +17120,49 @@ api_key = "test-only-key" assert!(!prompt.contains("diff truncated")); } + #[test] + fn pr_review_unreviewed_note_names_budget_stops_and_plan_skips() { + assert_eq!(pr_review_unreviewed_note(None, 0), "the entire diff"); + fn patch(name: &str, content: &str) -> String { + format!( + "diff --git a/{name} b/{name}\nnew file mode 100644\n--- /dev/null\n+++ b/{name}\n@@ -0,0 +1 @@\n+{content}\n" + ) + } + let patches = [ + patch("a.txt", "alpha"), + patch("b.txt", "bravo"), + patch("c.txt", "charlie"), + ]; + let diff = patches.concat(); + let max_chars = patches + .iter() + .map(|patch| patch.chars().count()) + .max() + .unwrap(); + let view = GhPullRequest { + changed_files: 3, + ..Default::default() + }; + // Two passes of budget: a and b are planned, c is skipped by the plan. + let plan = crate::tools::review::plan_pr_review(&diff, &view, max_chars, 2).unwrap(); + let note = pr_review_unreviewed_note(Some(&plan), 0); + assert!(note.contains("were never read"), "{note}"); + assert!(note.contains("a/b.txt b/b.txt"), "{note}"); + assert!(note.contains("the plan never scheduled"), "{note}"); + assert!(note.contains("a/c.txt b/c.txt"), "{note}"); + // One pass done: only b is left unread, but the plan skip still stands. + let note = pr_review_unreviewed_note(Some(&plan), 1); + assert!(!note.contains("a/a.txt b/a.txt"), "{note}"); + assert!(note.contains("a/b.txt b/b.txt"), "{note}"); + assert!(note.contains("a/c.txt b/c.txt"), "{note}"); + // A complete plan with every pass done names nothing. + let complete = crate::tools::review::plan_pr_review(&diff, &view, max_chars, 3).unwrap(); + assert_eq!( + pr_review_unreviewed_note(Some(&complete), 3), + "no planned file was left unread" + ); + } + #[test] fn pr_review_markdown_shows_the_computed_replacement() { // A plain `codewhale review --pr` (no --post) computes and validates @@ -17382,7 +17478,7 @@ api_key = "test-only-key" } #[test] - fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() { + fn cli_auto_resolves_to_the_declared_default_before_k3_route_normalization() { let config = Config { provider: Some("moonshot".to_string()), providers: Some(crate::config::ProvidersConfig { @@ -17396,30 +17492,24 @@ api_key = "test-only-key" ..Default::default() }; - for (prompt, expected) in [ - ("lookup the public docs", "low"), - ("debug this error", "max"), - ("review this ordinary change", "high"), - ] { - assert_eq!( - cli_reasoning_effort_value_for_prompt( - &config, - crate::config::KIMI_CODE_K3_MODEL, - crate::reasoning_preference::ReasoningEffort::Auto, - prompt, - ) - .as_deref(), - Some(expected), - "prompt selector must resolve Auto for `{prompt}`" - ); - } + // #6290 rework: Auto no longer classifies the prompt. Any wording + // resolves the declared policy tier, normalized for the K3 route. + assert_eq!( + cli_reasoning_effort_value_for_prompt( + &config, + crate::config::KIMI_CODE_K3_MODEL, + crate::reasoning_preference::ReasoningEffort::Auto, + ) + .as_deref(), + Some("high"), + "Auto resolves the declared default, not a classification" + ); assert_eq!( cli_reasoning_effort_value_for_prompt( &config, crate::config::KIMI_CODE_K3_MODEL, crate::reasoning_preference::ReasoningEffort::Off, - "debug must not override an explicit effort", ) .as_deref(), Some("low"), @@ -17483,7 +17573,6 @@ api_key = "test-only-key" &config, crate::config::ZAI_GLM_5_2_MODEL, crate::reasoning_preference::ReasoningEffort::Auto, - "debug this failing integration test", ) .expect("Auto must resolve to a concrete tier"); diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 08fe5a4f9f..77b955dad3 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1588,11 +1588,30 @@ impl PendingAuthorityWatch { reason_slot: Arc>>, ) -> Self { let task_cancel = cancel.clone(); + // The watch stays per-connection by design (#6211 R7a): it is born + // with the connect attempt (covering the pre-insertion window) and + // dies with the connection, so a watched server can neither be + // missed nor leak. A pool-level task would need the pool lock — + // held across in-flight calls — and regress the mid-call trip this + // exists for. What moves is the check itself: synchronous + // state fs has no place on the executor at 20Hz, so it runs on the + // blocking pool while the 50ms revocation cadence is unchanged. + let source = Arc::new(source); let handle = tokio::spawn(async move { loop { - if let Err(reason) = + let source = Arc::clone(&source); + let check = tokio::task::spawn_blocking(move || { crate::plugins::registry::verify_plugin_state_authority(&source.authority) - { + }) + .await; + let reason = match check { + Ok(Err(reason)) => Some(reason), + Ok(Ok(())) => None, + Err(_) => { + Some("plugin authority check failed to run; failing closed".to_string()) + } + }; + if let Some(reason) = reason { if let Ok(mut slot) = reason_slot.lock() { *slot = Some(reason); } @@ -5305,6 +5324,9 @@ pub fn load_config(path: &Path) -> Result { }) } +/// Maximum bytes read from an MCP config file. Configs are kilobytes. +const MAX_MCP_CONFIG_BYTES: u64 = 1024 * 1024; + fn read_mcp_config_file(path: &Path) -> Result> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -5319,11 +5341,15 @@ fn read_mcp_config_file(path: &Path) -> Result> { anyhow::bail!("MCP config path must be a regular file: {}", path.display()); } - let mut file = open_mcp_config_file(path) + let file = open_mcp_config_file(path) .with_context(|| format!("Failed to read MCP config {}", path.display()))?; let mut contents = String::new(); - file.read_to_string(&mut contents) + file.take(MAX_MCP_CONFIG_BYTES + 1) + .read_to_string(&mut contents) .with_context(|| format!("Failed to read MCP config {}", path.display()))?; + if contents.len() as u64 > MAX_MCP_CONFIG_BYTES { + anyhow::bail!("MCP config {} exceeds the 1 MiB limit", path.display()); + } Ok(Some(contents)) } diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs index 9332f16661..f2ea81c3da 100644 --- a/crates/tui/src/model_routing.rs +++ b/crates/tui/src/model_routing.rs @@ -22,9 +22,9 @@ use codewhale_models::{ContentBlock, Message, MessageRequest, MessageResponse, S /// Big/cheap model pair the auto-router may choose between for the active /// provider (#3018). /// -/// `cheap == None` means the provider has no known cheap tier: heuristics -/// stay on the current model (only thinking effort varies) and the network -/// router is skipped entirely (#1549). +/// `cheap == None` means the provider has no known cheap tier: the local +/// fallback stays on the current model (only thinking effort varies) and the +/// network router is skipped entirely (#1549). #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RouterCandidates { pub(crate) big: String, @@ -38,11 +38,6 @@ impl RouterCandidates { cheap: Some("deepseek-v4-flash".to_string()), } } - - /// The cheap-tier id, falling back to `big` when no cheap tier exists. - pub(crate) fn cheap_or_big(&self) -> &str { - self.cheap.as_deref().unwrap_or(&self.big) - } } /// Return a provider-owned strong/fast pair for model families whose catalog @@ -218,133 +213,6 @@ pub(crate) fn provider_router_candidates( } } -/// Auto-select a model based on request complexity. -/// -/// Short messages (<100 chars) go to the cheap tier. Long messages and -/// requests with complex keywords go to the big tier. The fallback is cheap. -/// This DeepSeek-candidate wrapper keeps legacy callers and tests intact; -/// provider-aware callers use [`auto_model_heuristic_for_candidates`]. -pub(crate) fn auto_model_heuristic(input: &str, current_model: &str) -> String { - auto_model_heuristic_for_candidates(input, current_model, &RouterCandidates::deepseek()) -} - -/// Candidate-aware variant of [`auto_model_heuristic`] (#3018). -pub(crate) fn auto_model_heuristic_for_candidates( - input: &str, - current_model: &str, - candidates: &RouterCandidates, -) -> String { - auto_model_heuristic_with_bias_for_candidates(input, current_model, false, candidates).model -} - -#[cfg(test)] -fn auto_model_heuristic_with_bias(input: &str, current_model: &str, cost_saving: bool) -> String { - auto_model_heuristic_with_bias_for_candidates( - input, - current_model, - cost_saving, - &RouterCandidates::deepseek(), - ) - .model -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutoRouteHeuristicDecision { - model: String, - reason: AutoRouteHeuristicReason, -} - -fn auto_model_heuristic_with_bias_for_candidates( - input: &str, - _current_model: &str, - cost_saving: bool, - candidates: &RouterCandidates, -) -> AutoRouteHeuristicDecision { - let len = input.chars().count(); - let lower = input.to_lowercase(); - let borderline_pro_keywords: &[&str] = &[ - "implement", - "analyze", - "\u{5b9e}\u{73b0}", - "\u{5206}\u{6790}", - "\u{5be6}\u{73fe}", - ]; - let strong_match = COMPLEX_KEYWORDS - .iter() - .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); - let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); - let pro_match = strong_match || (!cost_saving && borderline_match); - if pro_match { - return AutoRouteHeuristicDecision { - model: candidates.big.clone(), - reason: AutoRouteHeuristicReason::ComplexRequest, - }; - } - if len < 100 { - return AutoRouteHeuristicDecision { - model: candidates.cheap_or_big().to_string(), - reason: if cost_saving && borderline_match { - AutoRouteHeuristicReason::CostSavingPolicy - } else { - AutoRouteHeuristicReason::ShortRequest - }, - }; - } - let long_threshold = if cost_saving { 1_000 } else { 500 }; - if len > long_threshold { - return AutoRouteHeuristicDecision { - model: candidates.big.clone(), - reason: AutoRouteHeuristicReason::LongRequest, - }; - } - - AutoRouteHeuristicDecision { - model: candidates.cheap_or_big().to_string(), - reason: if cost_saving && borderline_match { - AutoRouteHeuristicReason::CostSavingPolicy - } else { - AutoRouteHeuristicReason::RoutineRequest - }, - } -} - -const COMPLEX_KEYWORDS: &[&str] = &[ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - "implement", - "analyze", - "\u{91cd}\u{6784}", - "\u{67b6}\u{6784}", - "\u{8bbe}\u{8ba1}", - "\u{8c03}\u{8bd5}", - "\u{5b89}\u{5168}", - "\u{5ba1}\u{67e5}", - "\u{5ba1}\u{8ba1}", - "\u{8fc1}\u{79fb}", - "\u{4f18}\u{5316}", - "\u{91cd}\u{5199}", - "\u{5b9e}\u{73b0}", - "\u{5206}\u{6790}", - "\u{91cd}\u{69cb}", - "\u{67b6}\u{69cb}", - "\u{8a2d}\u{8a08}", - "\u{8abf}\u{8a66}", - "\u{5be9}\u{67e5}", - "\u{5be9}\u{8a08}", - "\u{9077}\u{79fb}", - "\u{512a}\u{5316}", - "\u{91cd}\u{5beb}", - "\u{5be6}\u{73fe}", -]; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AutoRouteSource { FlashRouter, @@ -398,7 +266,7 @@ pub(crate) enum AutoRouteScope { /// The network classifier saw only the active provider's runnable routes — /// the default Auto scope (#4411). ActiveProvider, - /// The provider-aware local heuristic selected within one resolved route. + /// The local declared fallback selected within one resolved route. ResolvedProvider, } @@ -438,16 +306,34 @@ impl AutoRouteDataPath { } /// Local signal that selected the provider-safe strong/fast candidate. +/// +/// Since the #6290 rework the local fallback never judges request content: +/// without the flash classifier there is no per-request signal, so the route +/// is the configured default (or the runnable fast sibling under the explicit +/// `[auto] cost_saving` opt-in). The content-derived variants below are never +/// constructed for new routes; they are retained so saved sessions from before +/// the rework still deserialize. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AutoRouteHeuristicReason { + /// Legacy: the deleted keyword/length classifier judged the request + /// complex. Retained for saved-session serde compat only. ComplexRequest, + /// Legacy: the deleted length rule judged the request short. Retained + /// for saved-session serde compat only. ShortRequest, + /// Legacy: the deleted length rule judged the request long. Retained + /// for saved-session serde compat only. LongRequest, CostSavingPolicy, + /// Legacy: the deleted classifier judged the request routine. Retained + /// for saved-session serde compat only. RoutineRequest, NoFastSibling, NoRunnableCandidate, + /// The configured default model: no classifier was available and no + /// content signal was consulted. + DeclaredDefault, } impl AutoRouteHeuristicReason { @@ -461,6 +347,7 @@ impl AutoRouteHeuristicReason { Self::RoutineRequest => "routine request", Self::NoFastSibling => "no runnable fast sibling", Self::NoRunnableCandidate => "no runnable inventory candidate", + Self::DeclaredDefault => "configured default (no classifier)", } } } @@ -472,7 +359,10 @@ impl AutoRouteHeuristicReason { #[serde(rename_all = "snake_case")] pub(crate) enum AutoRouteReason { ClassifierRecommendation, - LocalHeuristic(AutoRouteHeuristicReason), + /// The `local_heuristic` alias keeps sessions saved before the #6290 + /// rework loadable; new sessions persist `local_fallback`. + #[serde(alias = "local_heuristic")] + LocalFallback(AutoRouteHeuristicReason), ClassifierFallback(AutoRouteHeuristicReason), } @@ -481,7 +371,7 @@ impl AutoRouteReason { pub(crate) fn label(self) -> String { match self { Self::ClassifierRecommendation => "classifier recommendation".to_string(), - Self::LocalHeuristic(reason) => format!("local heuristic: {}", reason.label()), + Self::LocalFallback(reason) => format!("local fallback: {}", reason.label()), Self::ClassifierFallback(reason) => { format!("classifier fallback: {}", reason.label()) } @@ -683,17 +573,17 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli ) -> Result { let inventory = ModelInventory::from_config(config); if !inventory.router_available { - // Fall back to heuristic-only auto routing when the flash router + // Fall back to declared-default auto routing when the flash router // is unavailable (e.g. non-DeepSeek providers like wanjie-ark). return Ok(normalize_auto_route_selection_for_config( config, - auto_route_from_inventory_heuristic(config, latest_request, &inventory), + auto_route_declared_fallback(config, &inventory), )); } - let heuristic = auto_route_from_inventory_heuristic(config, latest_request, &inventory); + let fallback = auto_route_declared_fallback(config, &inventory); if cfg!(test) { - return Ok(normalize_auto_route_selection_for_config(config, heuristic)); + return Ok(normalize_auto_route_selection_for_config(config, fallback)); } let selection = match auto_route_inventory_recommendation( @@ -708,11 +598,11 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli ) .await { - Ok(attempt) => auto_route_from_classifier_attempt(heuristic, &inventory, attempt), + Ok(attempt) => auto_route_from_classifier_attempt(fallback, &inventory, attempt), // Client construction/preparation failed before a provider request was // admitted. There is no provider usage to invent and no dropped // response receipt to claim. - Err(_) => auto_route_classifier_fallback(heuristic, &inventory), + Err(_) => auto_route_classifier_fallback(fallback, &inventory), }; Ok(normalize_auto_route_selection_for_config(config, selection)) } @@ -812,11 +702,16 @@ fn explicit_model_matches_candidate( .is_some_and(|model| candidate.model.eq_ignore_ascii_case(&model)) } -fn auto_route_from_inventory_heuristic( - config: &Config, - latest_request: &str, - inventory: &ModelInventory, -) -> AutoRouteSelection { +/// Declared local fallback for Auto routing when the flash classifier is +/// unavailable (or fails): the configured default model. +/// +/// There is no per-request signal here by design. Until the #6290 rework this +/// guessed cheap-vs-big from request wording (`COMPLEX_KEYWORDS` plus +/// char-length thresholds) — host-side semantic determinism that made cost +/// and quality depend on vocabulary. The only content-blind override is the +/// explicit `[auto] cost_saving` opt-in, which pins the runnable fast +/// sibling; providers without one stay on the default. +fn auto_route_declared_fallback(config: &Config, inventory: &ModelInventory) -> AutoRouteSelection { let Some(active) = inventory.active_default() else { let model = config.default_model(); return AutoRouteSelection { @@ -827,48 +722,51 @@ fn auto_route_from_inventory_heuristic( &model, AutoRouteScope::ResolvedProvider, AutoRouteDataPath::LocalHeuristic, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate), + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate), )), model, - reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)), + reasoning_effort: Some(crate::auto_reasoning::select()), source: AutoRouteSource::Heuristic, routed_usage: Vec::new(), routed_usage_drop_records: Vec::new(), routed_usage_dropped_records: 0, }; }; - // Use the candidates' cheap/big info for complexity-based routing. let router_candidates = provider_router_candidates(active.provider, &active.model); - let fast_is_runnable = router_candidates.cheap.as_deref().is_some_and(|model| { + let runnable_fast = router_candidates.cheap.as_deref().filter(|model| { inventory .candidate(active.provider, model) .is_some_and(|candidate| candidate.readiness.can_attempt()) }); - let decision = if fast_is_runnable { - auto_model_heuristic_with_bias_for_candidates( - latest_request, - &active.model, - config.auto_cost_saving(), - &router_candidates, - ) - } else { - AutoRouteHeuristicDecision { - model: active.model.clone(), - reason: AutoRouteHeuristicReason::NoFastSibling, + let (model, reason) = if config.auto_cost_saving() { + match runnable_fast { + Some(cheap) => ( + cheap.to_string(), + AutoRouteHeuristicReason::CostSavingPolicy, + ), + None => ( + active.model.clone(), + AutoRouteHeuristicReason::NoFastSibling, + ), } + } else { + ( + active.model.clone(), + AutoRouteHeuristicReason::DeclaredDefault, + ) }; AutoRouteSelection { provider: active.provider, receipt: Some(auto_route_receipt( inventory, active.provider, - &decision.model, + &model, AutoRouteScope::ResolvedProvider, AutoRouteDataPath::LocalHeuristic, - AutoRouteReason::LocalHeuristic(decision.reason), + AutoRouteReason::LocalFallback(reason), )), - model: decision.model, - reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)), + model, + reasoning_effort: Some(crate::auto_reasoning::select()), source: AutoRouteSource::Heuristic, routed_usage: Vec::new(), routed_usage_drop_records: Vec::new(), @@ -911,7 +809,7 @@ fn auto_route_from_classifier( } fn auto_route_from_classifier_attempt( - heuristic: AutoRouteSelection, + fallback: AutoRouteSelection, inventory: &ModelInventory, attempt: InventoryAutoRouteAttempt, ) -> AutoRouteSelection { @@ -922,7 +820,7 @@ fn auto_route_from_classifier_attempt( routed_usage_dropped_records, } = attempt; let mut selection = recommendation.map_or_else( - || auto_route_classifier_fallback(heuristic, inventory), + || auto_route_classifier_fallback(fallback, inventory), |recommendation| auto_route_from_classifier(inventory, recommendation), ); selection.routed_usage = routed_usage; @@ -932,22 +830,22 @@ fn auto_route_from_classifier_attempt( } fn auto_route_classifier_fallback( - mut heuristic: AutoRouteSelection, + mut fallback: AutoRouteSelection, inventory: &ModelInventory, ) -> AutoRouteSelection { - if let Some(receipt) = heuristic.receipt.as_mut() { - let heuristic_reason = match receipt.reason { - AutoRouteReason::LocalHeuristic(reason) + if let Some(receipt) = fallback.receipt.as_mut() { + let fallback_reason = match receipt.reason { + AutoRouteReason::LocalFallback(reason) | AutoRouteReason::ClassifierFallback(reason) => reason, - AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::RoutineRequest, + AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::DeclaredDefault, }; receipt.data_path = AutoRouteDataPath::Classifier { provider: inventory.router_provider, model: inventory.router_model.to_string(), }; - receipt.reason = AutoRouteReason::ClassifierFallback(heuristic_reason); + receipt.reason = AutoRouteReason::ClassifierFallback(fallback_reason); } - heuristic + fallback } fn auto_route_receipt( @@ -1639,10 +1537,10 @@ mod tests { assert_eq!(transport.routed_usage_drop_records.len(), 1); assert!(transport.routed_usage.is_empty()); - let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory); - let valid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, valid); - let invalid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, invalid); - let incomplete = auto_route_from_classifier_attempt(heuristic, &inventory, incomplete); + let fallback = auto_route_declared_fallback(&config, &inventory); + let valid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, valid); + let invalid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, invalid); + let incomplete = auto_route_from_classifier_attempt(fallback, &inventory, incomplete); assert_eq!(valid.source, AutoRouteSource::FlashRouter); for fallback in [&invalid, &incomplete] { assert_eq!(fallback.source, AutoRouteSource::Heuristic); @@ -1736,53 +1634,6 @@ mod tests { ); } - #[test] - fn auto_model_heuristic_chinese_keywords_route_to_pro() { - for msg in [ - "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", - "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", - "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", - "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", - "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", - "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", - "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { - for msg in [ - "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", - "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", - "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", - "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", - "\u{512a}\u{5316}\u{6027}\u{80fd}", - "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", - "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { - assert_eq!( - auto_model_heuristic("\u{4f60}\u{597d}", "auto"), - "deepseek-v4-flash", - ); - } - #[test] fn auto_route_prompt_uses_current_session_mode() { let prompt = auto_route_prompt( @@ -2135,27 +1986,35 @@ mod tests { ..Default::default() }; - let route = - resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto") + // #6290 rework: without the flash classifier there is no + // per-request signal, so every wording resolves the same declared + // default — the short chat and the complex ask below must agree. + for prompt in [ + "quick status check", + "please refactor this architecture and audit its security boundaries", + ] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") .await .expect("inventory route should resolve with authenticated active provider"); - assert_eq!(route.provider, ApiProvider::Zai); - assert_eq!(route.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); - assert_eq!(route.source, AutoRouteSource::Heuristic); - let receipt = route.receipt.expect("Auto route receipt"); - assert_eq!(receipt.tier, AutoRouteTier::Fast); - assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); - assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic); - assert_eq!( - receipt.reason, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ShortRequest) - ); - assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL); - assert_eq!( - receipt.pair.fast.as_deref(), - Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL) - ); + assert_eq!(route.provider, ApiProvider::Zai); + assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL); + assert_eq!(route.source, AutoRouteSource::Heuristic); + let receipt = route.receipt.expect("Auto route receipt"); + assert_eq!(receipt.tier, AutoRouteTier::Strong); + assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); + assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic); + assert_eq!( + receipt.reason, + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault), + "prompt {prompt:?} must take the declared default, not a content judgment" + ); + assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL); + assert_eq!( + receipt.pair.fast.as_deref(), + Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL) + ); + } } #[test] @@ -2258,10 +2117,10 @@ mod tests { #[tokio::test] #[allow(clippy::await_holding_lock)] - async fn active_provider_strong_fast_selection_survives_scoping() { - // Same-provider tier selection is the behavior scoping must not - // break: a complex request still reaches the active provider's strong - // tier, a trivial one still reaches its fast tier (#4411). + async fn active_provider_declared_default_survives_scoping() { + // #4411: scoping keeps Auto on the active provider. #6290 rework: + // without the flash classifier there is no per-request tier signal, + // so both wordings resolve the same declared default on Zai. let _env_lock = crate::test_support::lock_test_env(); let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key"); @@ -2270,30 +2129,24 @@ mod tests { ..Default::default() }; - let strong = resolve_auto_route_with_inventory( - &config, + for prompt in [ "refactor the routing module and audit its security boundaries", - "", - "auto", - "auto", - ) - .await - .expect("strong-tier route"); - assert_eq!(strong.provider, ApiProvider::Zai); - assert_eq!(strong.model, crate::config::DEFAULT_ZAI_MODEL); - let strong_receipt = strong.receipt.expect("strong receipt"); - assert_eq!(strong_receipt.tier, AutoRouteTier::Strong); - assert_eq!(strong_receipt.scope, AutoRouteScope::ResolvedProvider); - - let fast = resolve_auto_route_with_inventory(&config, "hi", "", "auto", "auto") - .await - .expect("fast-tier route"); - assert_eq!(fast.provider, ApiProvider::Zai); - assert_eq!(fast.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); - assert_eq!( - fast.receipt.expect("fast receipt").tier, - AutoRouteTier::Fast - ); + "hi", + ] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") + .await + .expect("scoped Auto route"); + assert_eq!(route.provider, ApiProvider::Zai); + assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL); + let receipt = route.receipt.expect("scoped receipt"); + assert_eq!(receipt.tier, AutoRouteTier::Strong); + assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); + assert_eq!( + receipt.reason, + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault), + "prompt {prompt:?}" + ); + } } #[test] @@ -2352,9 +2205,9 @@ mod tests { ..Default::default() }; let inventory = ModelInventory::from_config(&config); - let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory); + let fallback = auto_route_declared_fallback(&config, &inventory); - let route = auto_route_classifier_fallback(heuristic, &inventory); + let route = auto_route_classifier_fallback(fallback, &inventory); assert_eq!(route.source, AutoRouteSource::Heuristic); let receipt = route.receipt.expect("fallback receipt"); @@ -2368,17 +2221,37 @@ mod tests { )); assert_eq!( receipt.reason, - AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::ShortRequest) + AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::DeclaredDefault) ); assert!(!receipt.reason.label().contains("secret-provider-error")); } + #[test] + fn pre_rework_receipt_shape_still_deserializes() { + // Sessions saved before the #6290 rework persist + // `local_heuristic` + content-derived reasons. They must keep + // loading: the wrapper arrives via serde alias, the legacy reasons + // are retained variants. + let reason: AutoRouteReason = + serde_json::from_str(r#"{"local_heuristic":"complex_request"}"#) + .expect("pre-rework receipt reason deserializes"); + assert_eq!( + reason, + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::ComplexRequest) + ); + let receipt: AutoRouteReceipt = serde_json::from_str( + r#"{"tier":"fast","pair":{"strong":"GLM-5.3","fast":"GLM-5.3-Flash"},"scope":"resolved_provider","data_path":"local_heuristic","reason":{"local_heuristic":"short_request"}}"#, + ) + .expect("pre-rework receipt deserializes"); + assert_eq!(receipt.reason.label(), "local fallback: short request"); + } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn inventory_auto_route_never_falls_back_across_providers_by_default() { // #4411: the active provider has no usable credential, but another // provider does. Auto must stay on the active provider and report a - // no-runnable-candidate heuristic instead of silently spending the + // no-runnable-candidate fallback instead of silently spending the // other provider's key. let _env_lock = crate::test_support::lock_test_env(); let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key"); @@ -2400,7 +2273,7 @@ mod tests { assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); assert_eq!( receipt.reason, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate) + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate) ); } @@ -2428,13 +2301,13 @@ mod tests { .expect("opted-in route should fall back to an authenticated provider"); assert_eq!(route.provider, ApiProvider::Deepseek); - assert_eq!(route.model, "deepseek-v4-flash"); + assert_eq!(route.model, "deepseek-flash"); assert_eq!(route.source, AutoRouteSource::Heuristic); } #[tokio::test] #[allow(clippy::await_holding_lock)] - async fn inventory_auto_route_cost_saving_changes_borderline_zai_route() { + async fn inventory_auto_route_cost_saving_pins_fast_sibling() { let _env_lock = crate::test_support::lock_test_env(); let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key"); @@ -2485,7 +2358,7 @@ mod tests { .map(|receipt| (receipt.tier, receipt.reason)), Some(( AutoRouteTier::Strong, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ComplexRequest), + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault), )) ); assert_eq!( @@ -2495,7 +2368,7 @@ mod tests { .map(|receipt| (receipt.tier, receipt.reason)), Some(( AutoRouteTier::Fast, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::CostSavingPolicy), + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::CostSavingPolicy), )) ); } @@ -2512,26 +2385,16 @@ mod tests { ..Default::default() }; - let route = - resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto") + // #6290 rework: no classifier, no content signal — both wordings + // take the declared Wanjie default. + for prompt in ["quick status check", "please refactor this architecture"] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") .await - .expect("heuristic-only Wanjie route should resolve"); - assert_eq!(route.provider, ApiProvider::WanjieArk); - assert_eq!(route.model, "deepseek-v4-flash"); - assert_eq!(route.source, AutoRouteSource::Heuristic); - - let route = resolve_auto_route_with_inventory( - &config, - "please refactor this architecture", - "", - "auto", - "auto", - ) - .await - .expect("complex Wanjie route should resolve"); - assert_eq!(route.provider, ApiProvider::WanjieArk); - assert_eq!(route.model, "deepseek-v4-pro"); - assert_eq!(route.source, AutoRouteSource::Heuristic); + .expect("declared-default Wanjie route should resolve"); + assert_eq!(route.provider, ApiProvider::WanjieArk); + assert_eq!(route.model, "deepseek-reasoner"); + assert_eq!(route.source, AutoRouteSource::Heuristic); + } } #[tokio::test] @@ -2547,84 +2410,18 @@ mod tests { ..Default::default() }; - let route = - resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto") + // #6290 rework: no classifier, no content signal — both wordings + // take the declared Volcengine default. + for prompt in ["quick status check", "please refactor this architecture"] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") .await - .expect("heuristic-only Volcengine route should resolve"); - assert_eq!(route.provider, ApiProvider::Volcengine); - assert_eq!(route.model, "DeepSeek-V4-Flash"); - assert_eq!(route.source, AutoRouteSource::Heuristic); - - let route = resolve_auto_route_with_inventory( - &config, - "please refactor this architecture", - "", - "auto", - "auto", - ) - .await - .expect("complex Volcengine route should resolve"); - assert_eq!(route.provider, ApiProvider::Volcengine); - assert_eq!(route.model, "DeepSeek-V4-Pro"); - assert_eq!(route.source, AutoRouteSource::Heuristic); - } - - #[test] - fn auto_heuristic_default_routes_implement_to_pro() { - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), - "deepseek-v4-pro" - ); - } - - #[test] - fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), - "deepseek-v4-flash" - ); - assert_eq!( - auto_model_heuristic_with_bias("analyze this snippet", "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { - for kw in [ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - ] { - let req = format!("Please {kw} this module"); - assert_eq!( - auto_model_heuristic_with_bias(&req, "auto", true), - "deepseek-v4-pro", - "expected Pro for strong keyword `{kw}` even in cost-saving mode" - ); + .expect("declared-default Volcengine route should resolve"); + assert_eq!(route.provider, ApiProvider::Volcengine); + assert_eq!(route.model, "deepseek-v4-pro"); + assert_eq!(route.source, AutoRouteSource::Heuristic); } } - #[test] - fn auto_heuristic_cost_saving_raises_long_message_threshold() { - let body = "filler sentence. ".repeat(40); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", false), - "deepseek-v4-pro" - ); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", true), - "deepseek-v4-flash" - ); - } - #[test] fn provider_router_candidates_cover_known_provider_classes() { use crate::config::ApiProvider; @@ -2747,28 +2544,28 @@ mod tests { } } - #[test] - fn heuristic_without_cheap_tier_always_returns_current_model() { - // #3018 AC: Ollama + auto must never fabricate a DeepSeek id. - let candidates = RouterCandidates { - big: "qwen3:32b".to_string(), - cheap: None, + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn declared_fallback_without_cheap_tier_stays_on_default_model() { + // #3018 AC: Ollama + auto must never fabricate a DeepSeek id. The + // declared fallback returns the configured default verbatim, so no + // sibling id can be invented regardless of request wording. + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("ollama".to_string()), + ..Default::default() }; - for cost_saving in [false, true] { - for prompt in [ - "hi", - "please refactor the auth module for security", - &"long filler sentence. ".repeat(60), - ] { - let model = auto_model_heuristic_with_bias_for_candidates( - prompt, - "qwen3:32b", - cost_saving, - &candidates, - ) - .model; - assert_eq!(model, "qwen3:32b", "prompt {prompt:?}"); - } + for prompt in ["hi", "please refactor the auth module for security"] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") + .await + .expect("ollama Auto route should resolve"); + assert_eq!(route.provider, ApiProvider::Ollama); + assert_eq!(route.model, config.default_model()); + assert!( + !route.model.to_ascii_lowercase().contains("deepseek"), + "no DeepSeek id may be fabricated: {}", + route.model + ); } } diff --git a/crates/tui/src/plugins/install/mod.rs b/crates/tui/src/plugins/install/mod.rs index 038fbd16a6..bda1524bcc 100644 --- a/crates/tui/src/plugins/install/mod.rs +++ b/crates/tui/src/plugins/install/mod.rs @@ -426,14 +426,15 @@ pub async fn update( network: &NetworkPolicy, ) -> Result { let target = plugin_target_path(name, user_plugins_dir)?; - if target.exists() { + if tokio::fs::try_exists(&target).await.unwrap_or(false) { ensure_target_within_plugins_dir(&target, user_plugins_dir)?; } let marker_path = target.join(INSTALLED_FROM_MARKER); - if !marker_path.exists() { + if !tokio::fs::try_exists(&marker_path).await.unwrap_or(false) { return Err(PluginInstallError::NotInstalledHere(name.to_string()).into()); } - let marker_body = fs::read_to_string(&marker_path) + let marker_body = tokio::fs::read_to_string(&marker_path) + .await .with_context(|| format!("failed to read {}", marker_path.display()))?; let marker: InstalledFromMarker = serde_json::from_str(&marker_body) .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?; diff --git a/crates/tui/src/plugins/marketplace/parsers/claude.rs b/crates/tui/src/plugins/marketplace/parsers/claude.rs index e2939ea6e9..2a24d325ba 100644 --- a/crates/tui/src/plugins/marketplace/parsers/claude.rs +++ b/crates/tui/src/plugins/marketplace/parsers/claude.rs @@ -28,8 +28,8 @@ use crate::plugins::manifest::PluginInventory; use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_array_field, str_field, unknown_fields_warning}; @@ -326,6 +326,7 @@ fn parse_claude_entry( Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind: MarketplaceEntryKind::Plugin, icon: None, name, display_name, diff --git a/crates/tui/src/plugins/marketplace/parsers/codewhale.rs b/crates/tui/src/plugins/marketplace/parsers/codewhale.rs index 4aeb8ca6d9..0b10193bc6 100644 --- a/crates/tui/src/plugins/marketplace/parsers/codewhale.rs +++ b/crates/tui/src/plugins/marketplace/parsers/codewhale.rs @@ -22,8 +22,8 @@ use crate::plugins::install::PluginInstallSource; use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_field, unknown_fields_warning}; @@ -31,6 +31,7 @@ const TOP_LEVEL_FIELDS: &[&str] = &["name", "description", "version", "plugins"] const ENTRY_FIELDS: &[&str] = &[ "name", "source", + "kind", "description", "version", "homepage", @@ -287,9 +288,38 @@ fn parse_codewhale_entry( Some(index), )); } + // What this entry is. The Codewhale marketplace keeps plugins and + // skills in separate top-level directories, so the source path is the + // signal; a document may also declare `kind` explicitly. A skill entry + // stays installable, but it is not a plugin and is never suggested as + // one (#6290 rework). + let kind = match obj.get("kind").and_then(Value::as_str) { + Some("skill") => MarketplaceEntryKind::Skill, + Some("plugin") | None => { + let path = source + .strip_prefix("path:") + .unwrap_or(source) + .trim_start_matches("./"); + if path == "skills" || path.starts_with("skills/") { + MarketplaceEntryKind::Skill + } else { + MarketplaceEntryKind::Plugin + } + } + Some(other) => { + entry_diags.push(MarketplaceDiagnostic::warning( + "UNKNOWN_ENTRY_KIND", + format!("unknown entry kind `{other}`; treated as a plugin"), + Some(name.clone()), + Some(index), + )); + MarketplaceEntryKind::Plugin + } + }; Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind, icon, name, display_name, diff --git a/crates/tui/src/plugins/marketplace/parsers/codex.rs b/crates/tui/src/plugins/marketplace/parsers/codex.rs index b4c1a6a1e6..0530c17a5b 100644 --- a/crates/tui/src/plugins/marketplace/parsers/codex.rs +++ b/crates/tui/src/plugins/marketplace/parsers/codex.rs @@ -36,8 +36,8 @@ use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name} use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_field, unknown_fields_warning}; @@ -264,6 +264,7 @@ fn parse_codex_entry( Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind: MarketplaceEntryKind::Plugin, icon: None, name, display_name: None, diff --git a/crates/tui/src/plugins/marketplace/parsers/kimi.rs b/crates/tui/src/plugins/marketplace/parsers/kimi.rs index 0112a99b22..8d7553cbe1 100644 --- a/crates/tui/src/plugins/marketplace/parsers/kimi.rs +++ b/crates/tui/src/plugins/marketplace/parsers/kimi.rs @@ -23,8 +23,8 @@ use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name} use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_array_field, str_field, unknown_fields_warning}; @@ -232,6 +232,7 @@ fn parse_kimi_entry( Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind: MarketplaceEntryKind::Plugin, icon: None, name, display_name, diff --git a/crates/tui/src/plugins/marketplace/store.rs b/crates/tui/src/plugins/marketplace/store.rs index 21d4865679..059e44006c 100644 --- a/crates/tui/src/plugins/marketplace/store.rs +++ b/crates/tui/src/plugins/marketplace/store.rs @@ -114,13 +114,23 @@ impl MarketplaceStore { Ok(state) } + /// Maximum bytes read from the marketplace state file. + const MAX_STATE_BYTES: u64 = 1024 * 1024; + fn load_unlocked(&self) -> Result { - let Some(mut file) = open_existing_regular_file(&self.path, false)? else { + let Some(file) = open_existing_regular_file(&self.path, false)? else { return Ok(MarketplaceState::default()); }; let mut raw = String::new(); - file.read_to_string(&mut raw) + file.take(Self::MAX_STATE_BYTES + 1) + .read_to_string(&mut raw) .map_err(|e| format!("failed to read {}: {e}", self.path.display()))?; + if raw.len() as u64 > Self::MAX_STATE_BYTES { + return Err(format!( + "marketplace state {} exceeds the 1 MiB limit", + self.path.display() + )); + } let state: MarketplaceState = serde_json::from_str(&raw) .map_err(|e| format!("failed to parse {}: {e}", self.path.display()))?; if state.schema_version != MARKETPLACE_SCHEMA_VERSION { @@ -130,23 +140,46 @@ impl MarketplaceStore { self.path.display() )); } - Ok(state) + Ok(collapse_same_source_catalogs(state)) } - /// Insert a catalog under `id`, refusing to replace an existing entry. + /// Insert a catalog under `id`, keyed by its source document. + /// + /// A catalog *is* its source: re-adding the same document updates that + /// catalog in place — and renames it to the requested `id` when the name + /// differs — instead of colliding on whatever name the previous add chose. + /// That collision is what produced a second, stale snapshot of the + /// codewhale marketplace under a hand-made name ("cw2") sitting beside + /// `codewhale`, two copies of one source. An existing *different* source + /// under `id` still refuses, so a name never silently re-points. pub fn add( &self, id: &MarketplaceCatalogId, entry: StoredMarketplaceCatalog, ) -> Result<(), String> { self.mutate(|state| { - if state.catalogs.contains_key(id.as_str()) { + let source = canonical_source_path(&entry.source_path); + if let Some(existing) = state.catalogs.get(id.as_str()) + && canonical_source_path(&existing.source_path) != source + { return Err(format!( "a marketplace named `{}` already exists; /plugin marketplace remove {} first", id.as_str(), id.as_str() )); } + let duplicates: Vec = state + .catalogs + .iter() + .filter(|(key, catalog)| { + key.as_str() != id.as_str() + && canonical_source_path(&catalog.source_path) == source + }) + .map(|(key, _)| key.clone()) + .collect(); + for key in duplicates { + state.catalogs.remove(&key); + } state.catalogs.insert(id.as_str().to_string(), entry); Ok(()) }) @@ -184,6 +217,66 @@ impl MarketplaceStore { } } +/// Canonical form of a catalog's source document, for source-identity +/// comparison. This is the identity the store keys updates and duplicate +/// detection on (the marketplace sibling of grokbuild's +/// `MarketplaceSource::identity`). Falls back to the stored string when the +/// path is not readable as a file — a GitHub URL, or a document that has +/// since moved away. +fn canonical_source_path(path: &str) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) +} + +/// Collapse catalogs that point at the same source document. +/// +/// Older states can hold one marketplace twice under two names (that is what +/// `"cw2"` was: a second snapshot of the same document, taken only because the +/// first name was already in use). Every surface should see one catalog per +/// source. The survivor is the entry whose key is the document's own declared +/// name when one exists, otherwise the most recently added; the projection is +/// in-memory, and the next write persists it. +fn collapse_same_source_catalogs(mut state: MarketplaceState) -> MarketplaceState { + let mut groups: BTreeMap> = BTreeMap::new(); + for (key, catalog) in &state.catalogs { + groups + .entry(canonical_source_path(&catalog.source_path)) + .or_default() + .push(key.clone()); + } + for keys in groups.values() { + if keys.len() < 2 { + continue; + } + let named_after_document = keys.iter().find(|key| { + state + .catalogs + .get(key.as_str()) + .is_some_and(|catalog| catalog.catalog.name == key.as_str()) + }); + let keeper = named_after_document.cloned().or_else(|| { + keys.iter() + .max_by(|left, right| { + let added = |key: &String| { + state + .catalogs + .get(key.as_str()) + .map(|catalog| catalog.added_at.as_str()) + .unwrap_or_default() + }; + added(left).cmp(added(right)) + }) + .cloned() + }); + let Some(keeper) = keeper else { continue }; + for key in keys { + if *key != keeper { + state.catalogs.remove(key); + } + } + } + state +} + fn first_party_catalog() -> Result { #[derive(Deserialize)] struct Snapshot { @@ -275,4 +368,101 @@ mod tests { assert!(store.load().is_err()); assert_eq!(fs::read_to_string(store.path()).unwrap(), "broken"); } + + fn catalog_from_source(source: &Path) -> StoredMarketplaceCatalog { + let mut catalog = first_party_catalog().unwrap(); + catalog.source_path = source.display().to_string(); + catalog + } + + /// `add` keys on the source document, not the name the previous add used: + /// re-adding the same marketplace under its real name replaces the + /// hand-made duplicate instead of colliding or leaving both behind. + #[test] + fn re_adding_the_same_source_renames_in_place() { + let root = tempfile::tempdir().unwrap(); + let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap(); + let document = root.path().join("marketplace.json"); + fs::write(&document, "{}").unwrap(); + + store + .add( + &MarketplaceCatalogId::new("cw2"), + catalog_from_source(&document), + ) + .unwrap(); + store + .add( + &MarketplaceCatalogId::new("codewhale"), + catalog_from_source(&document), + ) + .unwrap(); + + let state = store.load().unwrap(); + assert!(state.get("cw2").is_none(), "the duplicate name is gone"); + assert_eq!( + state.get("codewhale").unwrap().source_path, + document.display().to_string() + ); + } + + /// A name is never silently re-pointed at a different source. + #[test] + fn a_name_never_re_points_at_a_different_source() { + let root = tempfile::tempdir().unwrap(); + let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap(); + let first = root.path().join("first.json"); + let second = root.path().join("second.json"); + fs::write(&first, "{}").unwrap(); + fs::write(&second, "{}").unwrap(); + + store + .add( + &MarketplaceCatalogId::new("codewhale"), + catalog_from_source(&first), + ) + .unwrap(); + let refused = store + .add( + &MarketplaceCatalogId::new("codewhale"), + catalog_from_source(&second), + ) + .expect_err("a different source under a taken name must refuse"); + assert!(refused.contains("already exists"), "{refused}"); + assert_eq!( + store.load().unwrap().get("codewhale").unwrap().source_path, + first.display().to_string() + ); + } + + /// A state written before source identity was keyed can hold one source + /// twice under two names; the projection collapses it to the entry named + /// after the document itself. + #[test] + fn load_collapses_one_source_stored_under_two_names() { + let root = tempfile::tempdir().unwrap(); + let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap(); + let document = root.path().join("marketplace.json"); + fs::write(&document, "{}").unwrap(); + + let mut state = MarketplaceState::default(); + for (key, added_at) in [ + ("codewhale", "2026-09-02T00:00:00Z"), + ("cw2", "2026-09-16T00:00:00Z"), + ] { + let mut catalog = catalog_from_source(&document); + catalog.added_at = added_at.to_string(); + state.catalogs.insert(key.to_string(), catalog); + } + ensure_private_plugin_state_directory(store.path().parent().unwrap()).unwrap(); + fs::write(store.path(), serde_json::to_string(&state).unwrap()).unwrap(); + + let loaded = store.load().unwrap(); + assert!(loaded.get("cw2").is_none()); + assert!(loaded.get("codewhale").is_some()); + assert_eq!( + loaded.get("codewhale").unwrap().source_path, + document.display().to_string() + ); + } } diff --git a/crates/tui/src/plugins/marketplace/types.rs b/crates/tui/src/plugins/marketplace/types.rs index fc045aad22..7b9693ac8f 100644 --- a/crates/tui/src/plugins/marketplace/types.rs +++ b/crates/tui/src/plugins/marketplace/types.rs @@ -260,6 +260,26 @@ impl MarketplaceInstallPlan { } } +/// What a catalog entry is. +/// +/// A marketplace carries more than plugins: the Codewhale marketplace lists +/// standalone skills as their own entries, and imported third-party catalogs +/// carry whatever their format allows. The kind is how a surface knows which +/// pool an entry belongs to — in particular, only `Plugin` entries are plugin +/// suggestions. (grokbuild's `MarketplaceEntry` carries the same distinction +/// as inventory flags — `skill_count`, `has_mcp`, … — on an always-a-plugin +/// entry; we keep the kind explicit because our catalogs list skills +/// directly.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MarketplaceEntryKind { + #[default] + Plugin, + /// A standalone skill (instruction pack). Installable, but never + /// suggested as a plugin. + Skill, +} + /// A normalized catalog entry. Catalog-declared component lists are kept /// for display; the reviewed staged-tree manifest at install time remains /// the only authority on what a bundle actually contains. @@ -267,6 +287,10 @@ impl MarketplaceInstallPlan { pub struct MarketplaceCandidate { pub id: MarketplaceCandidateId, pub catalog_id: MarketplaceCatalogId, + /// What this entry is. Defaults to `Plugin` so stored snapshots and + /// formats that do not declare a kind keep their previous meaning. + #[serde(default)] + pub kind: MarketplaceEntryKind, /// Canonical (Agent Plugins standard) plugin name. pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/tui/src/plugins/matcher.rs b/crates/tui/src/plugins/matcher.rs index 4b6339604a..f15041f782 100644 --- a/crates/tui/src/plugins/matcher.rs +++ b/crates/tui/src/plugins/matcher.rs @@ -46,13 +46,17 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec { let mut keywords = Vec::new(); for keyword in candidate.keywords { let normalized = keyword.trim().to_ascii_lowercase(); - if is_specific_term(&normalized) { + if is_matchable_term(&normalized) { keywords.push(normalized); } } for domain in candidate.domains { + // A homepage on a code-hosting platform names where the plugin + // *lives*, not what it is; matching it would make every github-hosted + // plugin fire on any "github" mention. Everything else matches on + // declared data, which the host does not second-guess. if let Some(normalized) = normalize_domain(domain) - && is_specific_term(&normalized) + && is_matchable_term(&normalized) && !matches!( normalized.as_str(), "github.com" | "gitlab.com" | "bitbucket.org" @@ -62,7 +66,7 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec { } } let name = candidate.name.trim().to_ascii_lowercase(); - if is_specific_term(&name) { + if is_matchable_term(&name) { keywords.push(name); } keywords @@ -70,27 +74,17 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec { // Core vocabulary is not evidence that a user needs an integration. A // specific product name, phrase or domain is still eligible. -fn is_specific_term(term: &str) -> bool { - term.chars().count() >= 3 - && !term.chars().any(char::is_control) - && !matches!( - term, - "mcp" - | "plugin" - | "plugins" - | "skill" - | "skills" - | "agent" - | "agents" - | "tool" - | "tools" - | "data" - | "code" - | "model" - | "models" - | "session" - | "sessions" - ) +/// Mechanical admissibility for a match term: long enough to be a word and +/// free of control characters. +/// +/// Deliberately **not** a semantic stoplist. It used to reject declared terms +/// like `mcp`, `agent`, `model`, `data` and `code`, which made a catalog +/// author's declared keywords unmatchable — the same failure mode as the +/// deleted #6274 name suppression, one layer down. Declared keywords are the +/// catalog author's call; the noise controls are the score threshold, the +/// once-per-lifetime gate, and dismissal (#6290 rework). +fn is_matchable_term(term: &str) -> bool { + term.chars().count() >= 3 && !term.chars().any(char::is_control) } pub(crate) fn normalize_domain(domain: &str) -> Option { @@ -153,9 +147,11 @@ mod tests { super::match_plugin_keyword("help with finance", &candidates), Some((0, "finance".into())) ); + // Declared terms are the catalog author's call (#6290 rework): `mcp` + // matches when declared, and the receipt names it. assert_eq!( super::match_plugin_keyword("help with mcp", &candidates), - None + Some((0, "mcp".into())) ); } @@ -237,10 +233,13 @@ mod tests { } #[test] - fn core_vocabulary_short_claims_and_commands_do_not_trigger_suggestions() { + fn declared_vocabulary_matches_and_only_mechanics_filter_terms() { + // Declared keywords are the catalog author's call (#6290 rework): + // `mcp`, `agent`, `model`, … match when declared. The remaining + // filters are mechanical (>= 3 characters, no control characters), + // the `/`-command guard, and the code-hosting homepage exclusion. let words = [ - "mcp", "plugin", "plugins", "skill", "skills", "agent", "agents", "tool", "tools", - "code", "data", "model", "models", "session", "sessions", "go", "ai", + "mcp", "plugin", "skill", "agent", "tool", "code", "data", "model", "session", ]; let keywords = words .iter() @@ -250,10 +249,15 @@ mod tests { for word in words { assert_eq!( match_plugin_keyword(&format!("please help with {word}"), &candidates), - None, + Some(0), "{word}" ); } + // Two-character terms stay out on the mechanical floor. + let short_keywords = vec!["go".to_string()]; + let short = [candidate("git", &[], &short_keywords)]; + assert_eq!(match_plugin_keyword("go", &short), None); + let shared_host = vec!["https://github.com/example/plugin".to_string()]; let candidates = [candidate("supabase", &shared_host, &[])]; assert_eq!( diff --git a/crates/tui/src/plugins/recommend.rs b/crates/tui/src/plugins/recommend.rs index ffe3aa6ee4..8adc768efd 100644 --- a/crates/tui/src/plugins/recommend.rs +++ b/crates/tui/src/plugins/recommend.rs @@ -1,9 +1,12 @@ -//! Deterministic plugin suggestions for a user task. +//! Plugin suggestions for a user task. //! //! Ranks installed bundles and locally-added marketplace candidates. A //! suggestion is never an install, trust, enable, or network side effect. -//! Proactive toasts must use a high `min_score` so description-only matches -//! do not nag; `/plugin suggest` can rank more loosely. +//! +//! The proactive toast and the `` fragment are driven +//! by the declared-keyword matcher (`match_plugin_for_draft`), not by the +//! score below: there is no host score gate on what the model sees. Scoring +//! only ranks the user-invoked `/plugin suggest` list. use std::collections::{BTreeMap, BTreeSet}; @@ -15,8 +18,6 @@ use super::registry::PluginRegistry; use super::types::LoadedPlugin; const DEFAULT_LIMIT: usize = 3; -/// Keyword and name matches score 700–900; description fallbacks are ~120. -pub const PROACTIVE_MIN_SCORE: usize = 700; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RecommendOptions { @@ -35,17 +36,6 @@ impl Default for RecommendOptions { } } -impl RecommendOptions { - #[must_use] - pub fn proactive() -> Self { - Self { - limit: 1, - min_score: PROACTIVE_MIN_SCORE, - include_active: false, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginMatchSource { Installed { id: String }, @@ -190,6 +180,14 @@ pub fn idle_and_catalog_keyword_matches( if candidate.has_errors() { continue; } + // Only plugins are plugin suggestions (#6290 rework): skill entries + // are installable, but this pool feeds the composer toast and the + // `` fragment, so a skill must not be dressed as + // one. This replaces #6274's name suppression, which existed only + // because the catalog mixed the two kinds. + if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin { + continue; + } if installed_names.contains(&candidate.name.to_ascii_lowercase()) { continue; } @@ -261,45 +259,23 @@ pub fn match_plugin_for_draft_among( /// Per-Engine gate for the append-only `` fragment. /// -/// A plugin id is suggested at most once per Engine lifetime, and a plugin -/// whose name (or alias) matches a skill in the session's catalogue is never -/// suggested — the skill already covers the domain, so the nudge is noise -/// (#6274). Dismissals continue to be honored through `Settings`. +/// A plugin id is suggested at most once per Engine lifetime, and dismissals +/// are honored through `Settings`. /// -/// Known limitation: the skill-name set is snapshotted once at Engine -/// construction (from the same catalogue the system prompt indexes), so a -/// skill installed mid-session does not suppress its plugin twin until the -/// next Engine starts. +/// Skill-name suppression (#6274) is gone with the #6290 rework: it existed +/// only because skill entries were catalogued as plugins and then had to be +/// suppressed by name — a snapshot-based check that missed mid-session +/// changes and never applied to the composer toast. Entry kinds now keep +/// skills out of the plugin pool entirely (see `MarketplaceEntryKind`). #[derive(Debug, Default)] pub struct RecommendedPluginGate { shown: BTreeSet, - skill_names: BTreeSet, } impl RecommendedPluginGate { - /// Engine constructor input and test seam: suppress exactly these - /// skill names and aliases (case is normalized here, so callers may - /// pass them in any form). - #[must_use] - pub fn with_skill_names(skill_names: BTreeSet) -> Self { - Self { - shown: BTreeSet::new(), - skill_names: skill_names - .into_iter() - .map(|name| name.trim().to_ascii_lowercase()) - .filter(|name| !name.is_empty()) - .collect(), - } - } - - /// True when this plugin may be suggested now: not covered by a - /// catalogue skill and not already suggested in this Engine's lifetime. - /// First admission records the plugin id. - fn admits(&mut self, id: &str, name: &str) -> bool { - let name_key = name.trim().to_ascii_lowercase(); - if !name_key.is_empty() && self.skill_names.contains(&name_key) { - return false; - } + /// True when this plugin may be suggested now: not already suggested in + /// this Engine's lifetime. First admission records the plugin id. + fn admits(&mut self, id: &str) -> bool { self.shown.insert(id.to_string()) } } @@ -323,9 +299,9 @@ pub fn recommended_plugins_user_fragment( marketplace, &settings.dismissed_plugin_suggestions, )?; - // Once per Engine lifetime per plugin id, and never when a local skill - // already covers the plugin's domain (#6274). - if !gate.admits(&matched.id, &matched.name) { + // Once per Engine lifetime per plugin id. Skill exclusion happens a + // layer down: skill-kind entries never enter the plugin pool (#6290). + if !gate.admits(&matched.id) { return None; } let mut listed = vec![matched]; @@ -379,6 +355,9 @@ pub fn recommend_plugins_for_task( if candidate.has_errors() { continue; } + if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin { + continue; + } if installed_names.contains(&candidate.name.to_ascii_lowercase()) { continue; } @@ -526,7 +505,7 @@ mod tests { use super::*; use crate::plugins::marketplace::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalogId, MarketplaceInstallPlan, MarketplaceSourceSpec, + MarketplaceCatalogId, MarketplaceEntryKind, MarketplaceInstallPlan, MarketplaceSourceSpec, }; use crate::test_support::{EnvVarGuard, lock_test_env}; use std::fs; @@ -558,6 +537,7 @@ mod tests { MarketplaceCandidate { id: MarketplaceCandidateId::new(&MarketplaceCatalogId::new(catalog), name), catalog_id: MarketplaceCatalogId::new(catalog), + kind: MarketplaceEntryKind::Plugin, name: name.to_string(), display_name: Some(format!("{name} plugin")), icon: None, @@ -609,11 +589,11 @@ mod tests { "add supabase auth to this app", ®istry, &[], - RecommendOptions::proactive(), + RecommendOptions::default(), ); assert_eq!(recs.len(), 1); assert_eq!(recs[0].name, "supabase"); - assert!(recs[0].score >= PROACTIVE_MIN_SCORE); + assert!(recs[0].score > 0); assert_eq!(recs[0].next_step, PluginNextStep::Trust); assert_eq!(recs[0].command(), "/plugin trust supabase"); } @@ -631,7 +611,7 @@ mod tests { "wire up supabase row level security", ®istry, &catalog, - RecommendOptions::proactive(), + RecommendOptions::default(), ); assert_eq!(recs.len(), 1); assert_eq!(recs[0].name, "supabase"); @@ -648,7 +628,7 @@ mod tests { } #[test] - fn already_active_plugins_are_skipped_for_proactive_toasts() { + fn already_active_plugins_are_skipped_when_active_excluded() { let _lock = lock_test_env(); let root = TempDir::new().unwrap(); let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); @@ -664,30 +644,10 @@ mod tests { "add supabase auth", ®istry, &[], - RecommendOptions::proactive(), - ); - assert!(recs.is_empty(), "{recs:?}"); - } - - #[test] - fn generic_prompts_do_not_match_on_description_alone() { - let _lock = lock_test_env(); - let root = TempDir::new().unwrap(); - let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); - write_keyword_bundle( - root.path(), - "notes", - "Create and organize spreadsheet notes", - &[], - ); - let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv() - .registry_for_workspace(root.path()); - - let recs = recommend_plugins_for_task( - "fix the failing test", - ®istry, - &[], - RecommendOptions::proactive(), + RecommendOptions { + include_active: false, + ..RecommendOptions::default() + }, ); assert!(recs.is_empty(), "{recs:?}"); } @@ -752,26 +712,44 @@ mod tests { ); } + /// A skill entry in a catalog is installable but is never a plugin + /// suggestion — the structural replacement for #6274's name suppression. #[test] - fn recommended_plugins_fragment_suppressed_by_local_skill_name() { + fn skill_entries_never_enter_the_plugin_suggestion_pool() { let _lock = lock_test_env(); let root = TempDir::new().unwrap(); let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); - write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]); - let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv() - .registry_for_workspace(root.path()); - - let skills: BTreeSet = ["Supabase".to_string()].into_iter().collect(); - let mut gate = RecommendedPluginGate::with_skill_names(skills); + let registry = crate::plugins::PluginRegistry::empty(root.path()); + let mut skill = marketplace_candidate("cw2", "test", &["test"]); + skill.kind = MarketplaceEntryKind::Skill; + let slice = std::slice::from_ref(&skill); + assert!( + idle_and_catalog_keyword_matches(®istry, slice).is_empty(), + "a skill entry must not be a plugin candidate" + ); assert!( recommended_plugins_user_fragment( - "add supabase auth to login", + "run the test suite", ®istry, - &[], - &mut gate, + slice, + &mut RecommendedPluginGate::default(), ) .is_none(), - "a loaded local skill covering the plugin name must suppress the suggestion (#6274)" + "a skill entry must not produce a fragment" + ); + + // Control: the same entry as a plugin still matches, so the + // exclusion is the kind and not a broken fixture. + skill.kind = MarketplaceEntryKind::Plugin; + assert!( + recommended_plugins_user_fragment( + "run the test suite", + ®istry, + std::slice::from_ref(&skill), + &mut RecommendedPluginGate::default(), + ) + .is_some(), + "the same entry as a plugin still matches" ); } diff --git a/crates/tui/src/plugins/registry.rs b/crates/tui/src/plugins/registry.rs index 8ecfd1d8db..e578df820b 100644 --- a/crates/tui/src/plugins/registry.rs +++ b/crates/tui/src/plugins/registry.rs @@ -524,13 +524,23 @@ fn load_state(path: &Path) -> Result { load_state_unlocked(path) } +/// Maximum bytes read from the plugin state file. +const MAX_PLUGIN_STATE_BYTES: u64 = 1024 * 1024; + fn load_state_unlocked(path: &Path) -> Result { - let Some(mut file) = open_existing_regular_file(path, false)? else { + let Some(file) = open_existing_regular_file(path, false)? else { return Ok(PluginStateFile::default()); }; let mut raw = String::new(); - file.read_to_string(&mut raw) + file.take(MAX_PLUGIN_STATE_BYTES + 1) + .read_to_string(&mut raw) .map_err(|e| format!("failed to read {}: {e}", path.display()))?; + if raw.len() as u64 > MAX_PLUGIN_STATE_BYTES { + return Err(format!( + "plugin state {} exceeds the 1 MiB limit", + path.display() + )); + } let state: PluginStateFile = serde_json::from_str(&raw) .map_err(|e| format!("failed to parse {}: {e}", path.display()))?; if state.schema_version != STATE_SCHEMA_VERSION { @@ -648,6 +658,7 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul // NamedTempFile marks the source as temporary. Clear only that temporary // caching hint before publication, matching tempfile's own persistence // contract while retaining the owner-only DACL applied above. + // SAFETY: `temporary_wide` is NUL-terminated and live. unsafe { SetFileAttributesW( PCWSTR::from_raw(temporary_wide.as_ptr()), @@ -658,6 +669,7 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul format!("failed to prepare private plugin state temp file for publication: {error}") })?; + // SAFETY: both paths are NUL-terminated and live. if let Err(error) = unsafe { MoveFileExW( PCWSTR::from_raw(temporary_wide.as_ptr()), @@ -667,6 +679,7 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul } { // Restore tempfile's cleanup hint on the still-private source. The // stable state path remains untouched when MoveFileExW fails. + // SAFETY: `temporary_wide` is NUL-terminated and live. let _ = unsafe { SetFileAttributesW( PCWSTR::from_raw(temporary_wide.as_ptr()), @@ -1755,6 +1768,7 @@ fn apply_windows_owner_only_acl( let result = (|| { let mut required = 0_u32; // The first call intentionally obtains the required byte count. + // SAFETY: null buffer queries size; `required` is live. let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut required) }; if required < size_of::() as u32 { return Err("Windows token did not expose a current-user SID".to_string()); @@ -1897,6 +1911,7 @@ fn ensure_windows_plugin_target_owner( status.0 )); } + // SAFETY: `owner` is non-null from GetSecurityInfo; `expected_owner` is the caller's SID. let owner_matches = !owner.0.is_null() && unsafe { EqualSid(owner, expected_owner) }.is_ok(); if !descriptor.0.is_null() { // SAFETY: the successful GetSecurityInfo allocation is released only diff --git a/crates/tui/src/remote_control.rs b/crates/tui/src/remote_control.rs index 97feea38c8..0059b89257 100644 --- a/crates/tui/src/remote_control.rs +++ b/crates/tui/src/remote_control.rs @@ -335,6 +335,7 @@ impl ClassicSessionOwnerLock { #[cfg(unix)] { use std::os::fd::AsRawFd as _; + // SAFETY: `file` is open and live. if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string()); } @@ -343,6 +344,7 @@ impl ClassicSessionOwnerLock { { use std::os::windows::io::AsRawHandle as _; use windows_sys::Win32::Storage::FileSystem::LockFile; + // SAFETY: `file` is open and live. if unsafe { LockFile(file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX) } == 0 { return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string()); } diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index c6b1198f5f..717cd95f6f 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -56,7 +56,10 @@ use crate::automation_manager::{ use crate::config::DEFAULT_TEXT_MODEL; use crate::config::{ApiProvider, Config, normalize_model_name_for_provider, validate_route}; use crate::fleet::executor::{FleetExecutor, configured_codewhale_binary}; -use crate::fleet::ledger::{FleetEventReplayError, FleetLedgerState, FleetTaskLedgerStatus}; +use crate::fleet::ledger::{ + FleetEventReplayError, FleetLedgerState, FleetTaskLedgerStatus, fleet_ledger_path, + subscribe_fleet_ledger_appends, +}; use crate::fleet::manager::{ FleetManager, FleetStatusSnapshot, FleetWorkerInspection, FleetWorkerRuntimeProjection, ManagedFleetRunDescriptor, @@ -1287,6 +1290,10 @@ pub fn build_router(state: RuntimeApiState) -> Router { "/v1/threads/{id}/agent-mail/{message_id}/read", post(mark_agent_mail_read), ) + .route( + "/v1/threads/{id}/agent-mail/{message_id}/cancel", + post(cancel_agent_mail), + ) .route( "/v1/threads/{id}/goal", get(get_thread_goal) @@ -2221,10 +2228,13 @@ async fn stream_fleet_events( ) -> Result>>, ApiError> { let (after, limit) = validate_fleet_events_query(query)?; let run_id = FleetRunId::from(run_id); + // Subscribe before the initial load so no append between the load and the + // first wait is missed for longer than the fallback poll (#6211 R7b). + let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(&state.workspace)); let initial = load_fleet_event_replay(state.clone(), run_id.clone(), after.clone(), limit) .await .map_err(map_fleet_replay_error)?; - let event_stream = replay_live_fleet_events(state, run_id, after, limit, initial); + let event_stream = replay_live_fleet_events(state, run_id, after, limit, initial, appends); Ok(Sse::new(event_stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) @@ -2232,12 +2242,18 @@ async fn stream_fleet_events( )) } +/// Fallback re-poll when no ledger-append wake arrives. Wakes cover every +/// in-process append; the fallback heals missed wakes, out-of-process +/// writers, and ledger compaction, which replaces rather than appends. +const FLEET_SSE_FALLBACK_POLL: Duration = Duration::from_secs(5); + fn replay_live_fleet_events( state: RuntimeApiState, run_id: FleetRunId, mut after: Option, limit: usize, initial: FleetEventReplay, + appends: std::sync::Arc, ) -> impl futures_util::Stream> { stream! { let mut page = initial; @@ -2256,7 +2272,14 @@ fn replay_live_fleet_events( yield Ok(fleet_sse_event(&event)); } if !page.has_more { - tokio::time::sleep(Duration::from_millis(250)).await; + // Register interest before yielding to the runtime so an + // append racing this wait still wakes us (#6211 R7b). + let notified = appends.notified(); + tokio::pin!(notified); + tokio::select! { + _ = &mut notified => {} + _ = tokio::time::sleep(FLEET_SSE_FALLBACK_POLL) => {} + } } match load_fleet_event_replay( state.clone(), @@ -5276,6 +5299,23 @@ async fn mark_agent_mail_read( Ok(Json(envelope)) } +/// Withdraw a queued envelope before delivery (#6176). Idempotent: a +/// re-cancel returns the stored envelope; mail that already left `queued` +/// is a 409, never silently dropped. +async fn cancel_agent_mail( + State(state): State, + Path((id, message_id)): Path<(String, String)>, +) -> Result, ApiError> { + let message_id = AgentMailMessageId::parse(message_id) + .map_err(|error| ApiError::bad_request(error.to_string()))?; + let envelope = state + .runtime_threads + .cancel_agent_mail(&id, &message_id) + .await + .map_err(map_agent_mail_err)?; + Ok(Json(envelope)) +} + async fn steer_thread_turn( State(state): State, Path((id, turn_id)): Path<(String, String)>, @@ -8785,10 +8825,17 @@ fn map_agent_mail_err(err: anyhow::Error) -> ApiError { let lower = message.to_ascii_lowercase(); if lower.contains("ownership denied") { ApiError::forbidden(message) - } else if lower.contains("already exists with different delivery intent") { + } else if lower.contains("already exists with different delivery intent") + || lower.contains("can be canceled only while queued") + { ApiError::conflict(message) } else if (lower.contains("failed to read agent mail envelope") - && lower.contains("no such file")) + && (lower.contains("no such file") + || err.chain().skip(1).any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) + }))) || (lower.starts_with("thread '") && lower.ends_with("' not found")) { ApiError::not_found(message) diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 00b7b998e1..a30ed4a6d5 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -17057,3 +17057,171 @@ async fn provider_key_clear_round_trips_and_reports_writability() -> Result<()> handle.abort(); Ok(()) } + +#[test] +fn agent_mail_cancel_conflict_maps_to_409() { + let conflict = map_agent_mail_err(anyhow::anyhow!( + "Agent Mail can be canceled only while queued (status: Delivered)" + )); + assert_eq!(conflict.status, StatusCode::CONFLICT); +} + +/// #6176: Agent Mail is the durable "submit while running" queue (nothing +/// ever produces a `Queued` turn record — POST /turns rejects a busy +/// thread). Withdrawing a queued envelope is idempotent, delivery after +/// cancel starts no turn, and mail that already left `queued` is an +/// explicit conflict rather than a silent drop. +#[tokio::test] +async fn agent_mail_cancel_withdraws_queued_mail() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + // Agent Mail addressability needs task-bound threads, and the sender + // identity must be the source thread's task id. + let new_thread = |task_id: &str| { + let client = &client; + let base = base.clone(); + let task_id = task_id.to_string(); + async move { + let thread: Value = client + .post(format!("{base}/v1/threads")) + .json(&json!({ "task_id": task_id })) + .send() + .await? + .error_for_status()? + .json() + .await?; + anyhow::Ok(thread["id"].as_str().expect("thread id").to_string()) + } + }; + let source = new_thread("task_a").await?; + let destination = new_thread("task_b").await?; + + let send = |message_id: &str| { + let client = &client; + let base = base.clone(); + let source = source.clone(); + let destination = destination.clone(); + let message_id = message_id.to_string(); + async move { + let sent = client + .post(format!("{base}/v1/agent-mail")) + .json(&json!({ + "message_id": message_id, + "source_thread_id": source, + "destination_thread_id": destination, + "sender": {"identity": "task_a", "display_label": "Test Sender"}, + "summary": "handoff: review the queued work", + "delivery_mode": "queue_only", + "trigger_turn": false, + })) + .send() + .await?; + anyhow::Ok(sent) + } + }; + + let sent = send("mail_queue_1").await?; + assert_eq!(sent.status(), StatusCode::CREATED); + let body: Value = sent.json().await?; + assert_eq!(body["envelope"]["status"], "queued"); + + let inbox: Value = client + .get(format!("{base}/v1/threads/{destination}/agent-mail")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(inbox.as_array().is_some_and(|mail| { + mail.iter() + .any(|item| item["message_id"] == "mail_queue_1" && item["status"] == "queued") + })); + + let canceled: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_1/cancel" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(canceled["status"], "canceled"); + + // Re-cancel is an idempotent no-op returning the stored envelope. + let recanceled: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_1/cancel" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(recanceled, canceled); + + // Delivery after cancel starts no turn. + let delivered: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_1/deliver" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(delivered["envelope"]["status"], "canceled"); + assert!(delivered.get("turn").is_none()); + + // Mail that already left `queued` is an explicit conflict. + let sent = send("mail_queue_2").await?; + assert_eq!(sent.status(), StatusCode::CREATED); + let delivered: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_2/deliver" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(delivered["envelope"]["status"], "delivered"); + let conflict = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_2/cancel" + )) + .send() + .await?; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert!( + conflict + .text() + .await? + .contains("can be canceled only while queued") + ); + + // A message addressed elsewhere is a foreign-destination rejection. + let foreign = client + .post(format!( + "{base}/v1/threads/{source}/agent-mail/mail_queue_1/cancel" + )) + .send() + .await?; + assert_eq!(foreign.status(), StatusCode::FORBIDDEN); + + // An unknown message id is a 404, never a silent success. + let missing = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_nope/cancel" + )) + .send() + .await?; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) +} diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 5c8cdd4499..ca0540c140 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -98,6 +98,7 @@ impl Drop for TuiLogGuard { impl Drop for TuiLogGuard { fn drop(&mut self) { if let Some(handle) = self.saved_stderr_handle.take() { + // SAFETY: `handle` is owned here via take; Drop runs once. unsafe { let _ = windows::Win32::System::Console::SetStdHandle( windows::Win32::System::Console::STD_ERROR_HANDLE, @@ -109,6 +110,7 @@ impl Drop for TuiLogGuard { // stderr target. This is safe because `SetStdHandle` above already // restored the original handle, so nothing references this one. if let Some(dup) = self.redirected_stderr_handle.take() { + // SAFETY: `dup` is owned here via take; nothing references it. unsafe { let _ = windows::Win32::Foundation::CloseHandle(dup); } @@ -327,8 +329,10 @@ fn redirect_stderr_to( // Without this, `_file` and stderr would alias the same HANDLE; // a rogue `CloseHandle` on stderr would silently invalidate `_file`. let raw = HANDLE(file.as_raw_handle()); + // SAFETY: pseudo-handle; no preconditions. let process = unsafe { GetCurrentProcess() }; let mut dup = HANDLE::default(); + // SAFETY: `file` and `dup` are live; pseudo-handle needs no close. unsafe { DuplicateHandle( process, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 83e15e49f4..ce54bd622f 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -60,11 +60,12 @@ use codewhale_execpolicy::ApprovalMode; use codewhale_models::Role; use codewhale_models::{ContentBlock, Message, SystemPrompt, Usage}; use codewhale_protocol::agent_mail::{ - AGENT_MAIL_EVENT_DELIVERED, AGENT_MAIL_EVENT_DELIVERING, AGENT_MAIL_EVENT_DELIVERY_FAILED, - AGENT_MAIL_EVENT_QUEUED, AGENT_MAIL_EVENT_READ, AGENT_MAIL_SCHEMA_VERSION, AgentMailAddress, - AgentMailDeliveryMode, AgentMailEnvelope, AgentMailEventPayload, AgentMailFailureCode, - AgentMailFailureReceipt, AgentMailMessageId, AgentMailSendRequest, AgentMailSendResponse, - AgentMailStatus, MAX_AGENT_MAIL_DELIVERY_ATTEMPTS, MAX_AGENT_MAIL_SUMMARY_BYTES, + AGENT_MAIL_EVENT_CANCELED, AGENT_MAIL_EVENT_DELIVERED, AGENT_MAIL_EVENT_DELIVERING, + AGENT_MAIL_EVENT_DELIVERY_FAILED, AGENT_MAIL_EVENT_QUEUED, AGENT_MAIL_EVENT_READ, + AGENT_MAIL_SCHEMA_VERSION, AgentMailAddress, AgentMailDeliveryMode, AgentMailEnvelope, + AgentMailEventPayload, AgentMailFailureCode, AgentMailFailureReceipt, AgentMailMessageId, + AgentMailSendRequest, AgentMailSendResponse, AgentMailStatus, MAX_AGENT_MAIL_DELIVERY_ATTEMPTS, + MAX_AGENT_MAIL_SUMMARY_BYTES, }; use codewhale_protocol::runtime::{ DynamicToolCallContent, DynamicToolCallParams, DynamicToolCallResult, DynamicToolSpec, @@ -499,6 +500,7 @@ fn agent_mail_event_for_status(status: AgentMailStatus) -> &'static str { AgentMailStatus::Delivered => AGENT_MAIL_EVENT_DELIVERED, AgentMailStatus::Read => AGENT_MAIL_EVENT_READ, AgentMailStatus::Failed => AGENT_MAIL_EVENT_DELIVERY_FAILED, + AgentMailStatus::Canceled => AGENT_MAIL_EVENT_CANCELED, } } @@ -6246,6 +6248,41 @@ impl RuntimeThreadManager { Ok(envelope) } + /// Withdraw a queued envelope before it starts delivery (#6176). Only + /// `Queued` mail can be canceled; anything that reached delivery keeps + /// its receipt. Re-canceling an already-canceled envelope is an + /// idempotent no-op returning the stored envelope. + pub async fn cancel_agent_mail( + &self, + thread_id: &str, + message_id: &AgentMailMessageId, + ) -> Result { + let thread = self.get_thread(thread_id).await?; + let address = agent_mail_address(&self.store.owner_id, &thread)?; + let envelope = { + let _mail_mutation = self.store.mail_mutation.lock(); + let mut envelope = self.store.load_agent_mail(message_id)?; + if envelope.destination != address { + bail!("Agent Mail ownership denied: message does not belong to this destination"); + } + match envelope.status { + AgentMailStatus::Canceled => envelope, + AgentMailStatus::Queued => { + envelope.status = AgentMailStatus::Canceled; + self.store.save_agent_mail(&envelope)?; + envelope + } + _ => bail!( + "Agent Mail can be canceled only while queued (status: {:?})", + envelope.status + ), + } + }; + self.emit_agent_mail_event(AGENT_MAIL_EVENT_CANCELED, &envelope) + .await?; + Ok(envelope) + } + /// Claim and project one envelope into the existing destination turn /// queue. A busy thread keeps queued mail untouched; retryable failures are /// claimed again only below the bounded attempt ceiling. @@ -6286,6 +6323,9 @@ impl RuntimeThreadManager { AgentMailStatus::Delivered => Some(AGENT_MAIL_EVENT_DELIVERED), AgentMailStatus::Read => Some(AGENT_MAIL_EVENT_READ), AgentMailStatus::Delivering => Some(AGENT_MAIL_EVENT_DELIVERING), + // Canceled mail is terminal: a later deliver returns the + // envelope with no turn rather than claiming it (#6176). + AgentMailStatus::Canceled => Some(AGENT_MAIL_EVENT_CANCELED), AgentMailStatus::Failed if envelope .failure @@ -9456,7 +9496,7 @@ impl RuntimeThreadManager { ); let selected_reasoning = reasoning_preference.map(|effort| { if effort == crate::reasoning_preference::ReasoningEffort::Auto { - crate::auto_reasoning::select(false, &prompt) + crate::auto_reasoning::select() } else { effort } diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 19860e6e65..b936983930 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -2456,6 +2456,7 @@ fn replace_existing_settings_file(path: &Path, replacement: &Path) -> std::io::R let path_wide = wide_path(path); let replacement_wide = wide_path(replacement); + // SAFETY: both paths are NUL-terminated and live; reserved params are null. unsafe { // NamedTempFile marks its source with the temporary caching hint. // Clear it before publication, matching tempfile's persistence path. diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index b151861e56..51a328582d 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -327,10 +327,10 @@ pub async fn install_with_registry( // finalize can restore the previous install. let final_path = skills_dir.join(&staged.skill_name); let mut backup_path: Option = None; - if final_path.exists() { + if tokio::fs::try_exists(&final_path).await.unwrap_or(false) { if !update { // Clean up the staging dir before returning the error. - let _ = fs::remove_dir_all(&staged.staged_path); + let _ = tokio::fs::remove_dir_all(&staged.staged_path).await; return Err(InstallError::AlreadyInstalled(staged.skill_name).into()); } // Same ownership gate as plugins/install/place.rs: an update may only @@ -338,31 +338,35 @@ pub async fn install_with_registry( // is not proof of ownership — without the marker we would delete a // user-authored or system skill that happened to share the name. if let Err(err) = reject_unmarked_update(&final_path, &staged.skill_name) { - let _ = fs::remove_dir_all(&staged.staged_path); + let _ = tokio::fs::remove_dir_all(&staged.staged_path).await; return Err(err.into()); } let backup = skills_dir.join(format!("{}.bak", staged.skill_name)); - if backup.exists() { - fs::remove_dir_all(&backup).ok(); + if tokio::fs::try_exists(&backup).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&backup).await.ok(); } - fs::rename(&final_path, &backup).with_context(|| { - format!( - "failed to backup existing skill at {}", - final_path.display() - ) - })?; - if let Err(err) = fs::rename(&staged.staged_path, &final_path) { - fs::rename(&backup, &final_path).ok(); + tokio::fs::rename(&final_path, &backup) + .await + .with_context(|| { + format!( + "failed to backup existing skill at {}", + final_path.display() + ) + })?; + if let Err(err) = tokio::fs::rename(&staged.staged_path, &final_path).await { + tokio::fs::rename(&backup, &final_path).await.ok(); return Err(err).context("failed to install staged skill"); } backup_path = Some(backup); } else { if let Some(parent) = final_path.parent() { - fs::create_dir_all(parent).with_context(|| { + tokio::fs::create_dir_all(parent).await.with_context(|| { format!("failed to create skills directory {}", parent.display()) })?; } - fs::rename(&staged.staged_path, &final_path).context("failed to install staged skill")?; + tokio::fs::rename(&staged.staged_path, &final_path) + .await + .context("failed to install staged skill")?; } // Write the marker last so a partial install never leaves a stale @@ -371,9 +375,9 @@ pub async fn install_with_registry( let content_digest = match super::package_digest::compute_package_digest(&final_path) { Ok(digest) => digest, Err(err) => { - let _ = fs::remove_dir_all(&final_path); + let _ = tokio::fs::remove_dir_all(&final_path).await; if let Some(backup) = backup_path.take() { - let _ = fs::rename(&backup, &final_path); + let _ = tokio::fs::rename(&backup, &final_path).await; } return Err(anyhow::anyhow!( "installed package failed content digest validation: {err}" @@ -388,14 +392,14 @@ pub async fn install_with_registry( &content_digest, &staged.skill_name, ) { - let _ = fs::remove_dir_all(&final_path); + let _ = tokio::fs::remove_dir_all(&final_path).await; if let Some(backup) = backup_path.take() { - let _ = fs::rename(&backup, &final_path); + let _ = tokio::fs::rename(&backup, &final_path).await; } return Err(err); } if let Some(backup) = backup_path { - fs::remove_dir_all(&backup).ok(); + tokio::fs::remove_dir_all(&backup).await.ok(); } Ok(InstallOutcome::Installed(InstalledSkill { @@ -433,14 +437,15 @@ pub async fn update_with_registry( registry_url: &str, ) -> Result { let target = skill_target_path(name, skills_dir)?; - if target.exists() { + if tokio::fs::try_exists(&target).await.unwrap_or(false) { ensure_target_within_skills_dir(&target, skills_dir)?; } let marker_path = target.join(INSTALLED_FROM_MARKER); - if !marker_path.exists() { + if !tokio::fs::try_exists(&marker_path).await.unwrap_or(false) { return Err(InstallError::NotInstalledHere(name.to_string()).into()); } - let marker_body = fs::read_to_string(&marker_path) + let marker_body = tokio::fs::read_to_string(&marker_path) + .await .with_context(|| format!("failed to read {}", marker_path.display()))?; let marker: InstalledFromMarker = serde_json::from_str(&marker_body) .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?; @@ -476,13 +481,13 @@ pub async fn update_with_registry( // so we get the same atomic-replace semantics. Content updates must not // inherit a previous trust marker. let trust_path = target.join(TRUSTED_MARKER); - let had_trust = trust_path.exists(); + let had_trust = tokio::fs::try_exists(&trust_path).await.unwrap_or(false); let outcome = install_with_registry(source, skills_dir, max_size, network, true, registry_url).await?; match &outcome { InstallOutcome::Installed(installed) => { if had_trust { - let _ = fs::remove_file(installed.path.join(TRUSTED_MARKER)); + let _ = tokio::fs::remove_file(installed.path.join(TRUSTED_MARKER)).await; } } InstallOutcome::NeedsApproval(_) | InstallOutcome::NetworkDenied(_) => {} @@ -708,14 +713,10 @@ async fn sync_one_skill( // Perform a HEAD request (or conditional GET) for freshness. We use a // simple GET with If-None-Match when we have an ETag, falling back to // an unconditional GET for servers that don't support ETags. - let existing_meta: Option = meta_path - .exists() - .then(|| { - fs::read_to_string(&meta_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - }) - .flatten(); + let existing_meta: Option = tokio::fs::read_to_string(&meta_path) + .await + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); // Build the request — add If-None-Match if we have a cached ETag. let client = reqwest_client(); @@ -815,11 +816,11 @@ async fn sync_one_skill( }; // Move staged dir into its final location, replacing any prior cache. let dest = cache_dir.join(name); - if dest.exists() { - let _ = fs::remove_dir_all(&dest); + if tokio::fs::try_exists(&dest).await.unwrap_or(false) { + let _ = tokio::fs::remove_dir_all(&dest).await; } - if let Err(err) = fs::rename(&staged.staged_path, &dest) { - let _ = fs::remove_dir_all(&staged.staged_path); + if let Err(err) = tokio::fs::rename(&staged.staged_path, &dest).await { + let _ = tokio::fs::remove_dir_all(&staged.staged_path).await; return SkillSyncOutcome::Failed { name: name.to_string(), reason: format!("failed to move staged skill into cache: {err:#}"), @@ -828,14 +829,14 @@ async fn sync_one_skill( dest } else { // Plain SKILL.md (or other companion text file). Write directly. - if let Err(err) = fs::create_dir_all(&skill_cache_dir) { + if let Err(err) = tokio::fs::create_dir_all(&skill_cache_dir).await { return SkillSyncOutcome::Failed { name: name.to_string(), reason: format!("failed to create cache dir: {err:#}"), }; } let skill_md_path = skill_cache_dir.join("SKILL.md"); - if let Err(err) = fs::write(&skill_md_path, &bytes) { + if let Err(err) = tokio::fs::write(&skill_md_path, &bytes).await { return SkillSyncOutcome::Failed { name: name.to_string(), reason: format!("failed to write SKILL.md to cache: {err:#}"), @@ -851,7 +852,7 @@ async fn sync_one_skill( url: url.clone(), }; let meta_json = serde_json::to_string(&meta).unwrap_or_default(); - let _ = fs::write(final_path.join(".cache-meta.json"), meta_json); + let _ = tokio::fs::write(final_path.join(".cache-meta.json"), meta_json).await; return SkillSyncOutcome::Downloaded { name: name.to_string(), diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index 76e04edaed..a3b8fb4602 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -1449,14 +1449,17 @@ impl TaskManager { let tasks_dir = cfg.data_dir.join("tasks"); let artifacts_dir = cfg.data_dir.join("artifacts"); let queue_path = cfg.data_dir.join("queue.json"); - fs::create_dir_all(&tasks_dir) + tokio::fs::create_dir_all(&tasks_dir) + .await .with_context(|| format!("Failed to create tasks dir {}", tasks_dir.display()))?; - fs::create_dir_all(&artifacts_dir).with_context(|| { - format!( - "Failed to create task artifacts dir {}", - artifacts_dir.display() - ) - })?; + tokio::fs::create_dir_all(&artifacts_dir) + .await + .with_context(|| { + format!( + "Failed to create task artifacts dir {}", + artifacts_dir.display() + ) + })?; let execution_lease = TaskExecutionLease::new(&cfg.data_dir, identity.0, identity.1)?; let cancel_token = CancellationToken::new(); @@ -1765,7 +1768,9 @@ impl TaskManager { write_json_atomic(&staged_task_path, &task)?; } if let Err(err) = self.persist_queue_locked(&next_queue) { - if !recover_stage && let Err(cleanup_err) = fs::remove_file(&staged_task_path) { + if !recover_stage + && let Err(cleanup_err) = tokio::fs::remove_file(&staged_task_path).await + { tracing::warn!( task_id = %task.id, error = %cleanup_err, @@ -1774,12 +1779,12 @@ impl TaskManager { } return Err(err); } - if let Err(promote_err) = fs::rename(&staged_task_path, &task_path) { + if let Err(promote_err) = tokio::fs::rename(&staged_task_path, &task_path).await { let rollback_error = self.persist_queue_locked(&state.queue).err(); let cleanup_error = if recover_stage { None } else { - fs::remove_file(&staged_task_path).err() + tokio::fs::remove_file(&staged_task_path).await.err() }; let mut message = format!("Failed to promote staged task {}: {promote_err}", task.id); diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 02036aa9ab..38a216ecc7 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -195,7 +195,20 @@ fn hash_json_value(value: &Value) -> String { short } +/// Maximum nesting depth the canonical serializer descends. Aligned with +/// serde_json's own parse limit so parsed input never truncates; anything +/// deeper emits a fixed marker, keeping keys deterministic. +const MAX_CANONICAL_JSON_DEPTH: usize = 128; + fn push_canonical_json(value: &Value, out: &mut String) { + push_canonical_json_at(value, out, 0) +} + +fn push_canonical_json_at(value: &Value, out: &mut String, depth: usize) { + if depth > MAX_CANONICAL_JSON_DEPTH { + out.push_str("maxdepth"); + return; + } match value { Value::Null => out.push_str("null"), Value::Bool(value) => { @@ -240,7 +253,7 @@ fn push_canonical_json(value: &Value, out: &mut String) { if index > 0 { out.push(','); } - push_canonical_json(item, out); + push_canonical_json_at(item, out, depth + 1); } out.push(']'); } @@ -257,7 +270,7 @@ fn push_canonical_json(value: &Value, out: &mut String) { serde_json::to_string(key).expect("serializing an object key cannot fail"); out.push_str(&encoded_key); out.push(':'); - push_canonical_json(value, out); + push_canonical_json_at(value, out, depth + 1); } out.push('}'); } @@ -283,6 +296,19 @@ mod tests { assert_eq!(key_a, key_b); } + #[test] + fn pathological_nesting_yields_a_stable_key() { + let mut value = Value::String("leaf".to_string()); + for _ in 0..150 { + let mut map = serde_json::Map::new(); + map.insert("t".to_string(), value); + value = Value::Object(map); + } + let key_a = build_approval_key("exec_shell", &value); + let key_b = build_approval_key("exec_shell", &value); + assert_eq!(key_a, key_b, "truncated keys must stay deterministic"); + } + #[test] fn shell_keys_include_full_command_arguments() { let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"})); diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 6ff9cd1339..53927ca8d2 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -757,9 +757,13 @@ impl ReadFileTool { } enforce_read_denylist(&file_path, "read")?; check_file_operation_cancelled(context)?; - let bytes = fs::read(&file_path).map_err(|error| { + let bytes = tokio::fs::read(&file_path).await.map_err(|error| { ToolError::execution_failed(format!("Failed to read {}: {error}", file_path.display())) })?; + // #6283: every read response carries the file's byte size, line + // count, and truncation flag so the caller can page deliberately + // instead of discovering a huge file one window at a time. + let size_bytes = bytes.len(); check_file_operation_cancelled(context)?; if let Some(mime_type) = primitive_image_mime(&bytes) { let prepared = crate::image_attach::prepare_tool_image_bytes(&bytes, mime_type); @@ -792,6 +796,11 @@ impl ReadFileTool { }; let selected_content = selected.join("\n"); let window = contract_read_window(&selected_content, max_bytes); + // Truncated means the file holds more than this response shows: + // either the byte budget cut the window, or a bounded range stopped + // before EOF. A whole file that fits is never truncated. + let truncated = + window.truncated || limit.is_some() && start + selected.len() < all_lines.len(); let first_display = start + 1; let mut output = if window.first_line_too_large { let size = selected.first().map_or(0, |line| line.len()); @@ -822,8 +831,9 @@ impl ReadFileTool { String::new() }; output.push_str(&format!( - "\n\n[Showing lines {first_display}-{last_display} of {} ({max_bytes}-byte output budget). Use {hint} to continue{raise}.]", - all_lines.len() + "\n\n[Showing lines {first_display}-{last_display} of {} ({} total, {max_bytes}-byte output budget). Use {hint} to continue{raise}.]", + all_lines.len(), + contract_format_size(size_bytes) )); } else if limit.is_some() { let consumed = selected.len(); @@ -831,7 +841,8 @@ impl ReadFileTool { let remaining = all_lines.len() - (start + consumed); let next_offset = start + consumed + 1; output.push_str(&format!( - "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]" + "\n\n[{remaining} more lines in file ({} total). Use offset={next_offset} to continue.]", + contract_format_size(size_bytes) )); } } @@ -846,7 +857,13 @@ impl ReadFileTool { // The budget this call actually enforced. The context // compactor honors it so an already-bounded read is never // truncated a second time on its way into the conversation. - "read_budget_bytes": max_bytes + "read_budget_bytes": max_bytes, + // #6283: paging contract. `size` is the whole file in bytes, + // `line_count` its total lines, `truncated` whether the file + // holds more than this response shows. + "size": size_bytes, + "truncated": truncated, + "line_count": all_lines.len() })), )) } @@ -935,10 +952,14 @@ impl ToolSpec for ReadFileTool { // Open before parameter parsing so a missing file keeps the // historical "Failed to read …" error shape regardless of the other // arguments. - let file = fs::File::open(&file_path).map_err(|e| { + let file = tokio::fs::File::open(&file_path).await.map_err(|e| { ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e)) })?; - let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX); + let file_bytes = file + .metadata() + .await + .map(|meta| meta.len()) + .unwrap_or(u64::MAX); let explicit_range = input .get("start_line") @@ -950,7 +971,7 @@ impl ToolSpec for ReadFileTool { // tiny file would silently ignore the request. if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 { drop(file); - let contents = fs::read_to_string(&file_path).map_err(|e| { + let contents = tokio::fs::read_to_string(&file_path).await.map_err(|e| { ToolError::execution_failed(format!( "Failed to read {}: {}", file_path.display(), @@ -1030,7 +1051,7 @@ impl ToolSpec for ReadFileTool { // runs to EOF so the total line count and whole-file UTF-8 validation // match the historical read_to_string behavior. let (window, total_lines) = - read_window_streaming(file, start_line, max_lines).map_err(|e| { + read_window_streaming(file.into_std().await, start_line, max_lines).map_err(|e| { ToolError::execution_failed(format!( "Failed to read {}: {}", file_path.display(), @@ -1046,7 +1067,7 @@ impl ToolSpec for ReadFileTool { // pass back to `edit`. Special files are skipped: reopening a FIFO or // device can block indefinitely (or re-consume a one-shot stream), // and a stream has no stable content an edit guard could pin. - let hash = match fs::metadata(&file_path) { + let hash = match tokio::fs::metadata(&file_path).await { Ok(meta) if meta.is_file() => hash_file_streaming(&file_path).ok(), _ => None, }; @@ -1425,16 +1446,16 @@ impl WriteFileTool { let mutation_guard = acquire_file_mutation(&file_path, context).await?; check_file_operation_cancelled(context)?; - let existed_before = file_path.exists(); + let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false); let prior_bytes = if existed_before { - fs::read(&file_path).unwrap_or_default() + tokio::fs::read(&file_path).await.unwrap_or_default() } else { Vec::new() }; let prior_contents = String::from_utf8_lossy(&prior_bytes); if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|error| { + tokio::fs::create_dir_all(parent).await.map_err(|error| { ToolError::execution_failed(format!( "Failed to create directory {}: {error}", parent.display() @@ -1539,9 +1560,11 @@ impl ToolSpec for WriteFileTool { // Snapshot the existing contents (if any) before we overwrite — used // to render an inline diff in the tool result. - let existed_before = file_path.exists(); + let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false); let prior_contents = if existed_before { - fs::read_to_string(&file_path).unwrap_or_default() + tokio::fs::read_to_string(&file_path) + .await + .unwrap_or_default() } else { String::new() }; @@ -1562,7 +1585,7 @@ impl ToolSpec for WriteFileTool { // Create parent directories if needed if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|e| { + tokio::fs::create_dir_all(parent).await.map_err(|e| { ToolError::execution_failed(format!( "Failed to create directory {}: {}", parent.display(), @@ -2006,17 +2029,18 @@ impl EditFileTool { let mutation_guard = acquire_file_mutation(&file_path, context).await?; check_file_operation_cancelled(context)?; - fs::OpenOptions::new() + tokio::fs::OpenOptions::new() .read(true) .write(true) .open(&file_path) + .await .map_err(|error| { ToolError::execution_failed(format!( "Could not edit file {path_str}: target must be readable and writable ({error})" )) })?; check_file_operation_cancelled(context)?; - let raw_bytes = fs::read(&file_path).map_err(|error| { + let raw_bytes = tokio::fs::read(&file_path).await.map_err(|error| { ToolError::execution_failed(format!("Could not edit file {path_str}: {error}")) })?; check_file_operation_cancelled(context)?; @@ -2153,7 +2177,7 @@ impl ToolSpec for EditFileTool { let file_path = context.resolve_path(path_str)?; context.require_fresh_file_read(&file_path, path_str)?; - let contents = fs::read_to_string(&file_path).map_err(|e| { + let contents = tokio::fs::read_to_string(&file_path).await.map_err(|e| { ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e)) })?; @@ -2293,7 +2317,7 @@ impl ToolSpec for EditFileTool { // actually applied. A fabricated "Replaced 1 occurrence" + diff is // worse than a hard error: models trust it and re-edit the same // span 3–5× before noticing nothing changed. - let on_disk = fs::read_to_string(&file_path).map_err(|e| { + let on_disk = tokio::fs::read_to_string(&file_path).await.map_err(|e| { ToolError::execution_failed(format!( "Failed to verify write to {}: {}", file_path.display(), diff --git a/crates/tui/src/tools/file/tests.rs b/crates/tui/src/tools/file/tests.rs index 6b352d9995..9d52d57b64 100644 --- a/crates/tui/src/tools/file/tests.rs +++ b/crates/tui/src/tools/file/tests.rs @@ -209,7 +209,7 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer( .to_string(); assert_eq!( footer, - "[Showing lines 1-100 of 2000 (100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]" + "[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]" ); let shown = first.content.rsplit_once("\n\n").expect("body").0; assert_eq!(shown.lines().count(), 100); @@ -230,6 +230,162 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer( ); } +/// #6283 AC1: a >10 MiB file read without paging params returns page one +/// plus the file's size, line count, and truncated flag — never the whole +/// file. +#[tokio::test] +async fn contract_read_reports_size_and_truncation_for_huge_files() { + let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard(); + let temporary = tempfile::tempdir().expect("tempdir"); + let line = "x".repeat(99); + let content = std::iter::repeat_n(line.as_str(), 110_000) + .collect::>() + .join("\n"); + assert!( + content.len() > 10 * 1024 * 1024, + "fixture exceeds 10 MiB: {}", + content.len() + ); + std::fs::write(temporary.path().join("huge.bin.txt"), &content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let first = ReadFileTool::execute_contract_read(json!({"path": "huge.bin.txt"}), &context) + .await + .expect("first page"); + let metadata = first.metadata.clone().expect("paging metadata"); + assert_eq!(metadata["size"], content.len() as u64); + assert_eq!(metadata["truncated"], true); + assert_eq!(metadata["line_count"], 110_000); + assert!( + first.content.len() < content.len(), + "page one must never be the whole file" + ); + assert!( + first.content.len() <= READ_DEFAULT_MAX_BYTES + 1_024, + "page one stays within the default budget plus footer slack: {}", + first.content.len() + ); + assert!( + first.content.contains("total") && first.content.contains("Use offset="), + "footer names the size and the continuation: {}", + first + .content + .rsplit_once("\n\n") + .map(|(_, f)| f) + .unwrap_or("") + ); +} + +/// #6283 AC2: paging through a file keeps every response bounded and +/// terminates with an untruncated page whose union is the whole file. +#[tokio::test] +async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() { + let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard(); + let temporary = tempfile::tempdir().expect("tempdir"); + let content = (0..3_000) + .map(|index| format!("paged-line-{index:05}")) + .collect::>() + .join("\n"); + std::fs::write(temporary.path().join("paged.txt"), &content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let mut seen: Vec = Vec::new(); + let mut offset = 1usize; + for page in 0..100 { + let result = ReadFileTool::execute_contract_read( + json!({"path": "paged.txt", "offset": offset, "limit": 500}), + &context, + ) + .await + .expect("page read"); + let metadata = result.metadata.clone().expect("paging metadata"); + assert_eq!(metadata["size"], content.len() as u64); + assert!( + result.content.len() <= READ_DEFAULT_MAX_BYTES + 1_024, + "page {page} bounded: {}", + result.content.len() + ); + let body = result + .content + .rsplit_once("\n\n[") + .map(|(body, _)| body) + .unwrap_or(&result.content); + seen.extend(body.lines().map(str::to_string)); + let truncated = metadata["truncated"].as_bool().expect("truncated flag"); + if !truncated { + break; + } + offset += 500; + assert!(page < 99, "paging must terminate"); + } + assert_eq!(seen.len(), 3_000); + assert_eq!(seen.join("\n"), content); +} + +/// #6283: ordinary whole reads carry the same paging metadata (with +/// truncated=false) and keep their footer-free shape. +#[tokio::test] +async fn contract_read_metadata_for_ordinary_whole_read() { + let temporary = tempfile::tempdir().expect("tempdir"); + let content = "alpha\nbeta\ngamma\n"; + std::fs::write(temporary.path().join("small.txt"), content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let result = ReadFileTool::execute_contract_read(json!({"path": "small.txt"}), &context) + .await + .expect("read result"); + assert_eq!(result.content, content); + let metadata = result.metadata.clone().expect("paging metadata"); + assert_eq!(metadata["size"], content.len() as u64); + assert_eq!(metadata["truncated"], false); + assert_eq!(metadata["line_count"], 4); +} + +/// #6283 AC3: grep-then-read flow — locate a marker with `grep_files`, +/// then read exactly that line range. (`grep_files` in the child surface +/// is pinned by `an_explicit_parent_tool_scope_is_enforced_by_the_child_registry`.) +#[tokio::test] +async fn grep_then_read_flow_targets_matched_lines() { + use crate::tools::spec::ToolSpec; + + let temporary = tempfile::tempdir().expect("tempdir"); + let mut lines: Vec = (0..200) + .map(|index| format!("filler line {index}")) + .collect(); + lines[150] = "the needle marker lives here".to_string(); + std::fs::write(temporary.path().join("haystack.txt"), lines.join("\n")).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let grep = crate::tools::search::GrepFilesTool + .execute( + json!({"pattern": "needle marker", "path": ".", "context_lines": 1}), + &context, + ) + .await + .expect("grep result"); + let payload: serde_json::Value = + serde_json::from_str(&grep.content).expect("grep JSON envelope"); + assert_eq!(payload["total_matches"], 1); + let matched = &payload["matches"][0]; + assert_eq!(matched["line_number"], 151); + + let read = ReadFileTool::execute_contract_read( + json!({ + "path": matched["file"].as_str().expect("match file"), + "offset": matched["line_number"].as_u64().expect("match line"), + "limit": 1, + }), + &context, + ) + .await + .expect("targeted read"); + assert!( + read.content.contains("the needle marker lives here"), + "{}", + read.content + ); +} + #[tokio::test] async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() { let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard(); @@ -263,7 +419,7 @@ async fn contract_read_offset_oob_and_limit_continuation_match_contract() { .expect("limited read"); assert_eq!( limited.content, - "two\n\n[1 more lines in file. Use offset=3 to continue.]" + "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]" ); let error = diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs index 106bf88f3f..c9b950c0c9 100644 --- a/crates/tui/src/tools/file_tool.rs +++ b/crates/tui/src/tools/file_tool.rs @@ -60,7 +60,7 @@ impl ToolSpec for ReadTool { } fn description(&self) -> &'static str { - "Read a text file. The whole file comes back in one call when it fits this call's output budget — 100000 bytes by default, raisable to 500000 with max_bytes. There is no line cap. Use offset and limit for an exact line range; when output is budget-limited the footer names the exact offset to continue from." + "Read a text file. The whole file comes back in one call when it fits this call's output budget — 100000 bytes by default, raisable to 500000 with max_bytes. There is no line cap. Use offset and limit for an exact line range; when output is budget-limited the footer names the exact offset to continue from. Every response reports the file's byte size, line count, and whether output was truncated." } fn input_schema(&self) -> Value { diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index cdab60f367..8fab63eb8a 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -6,8 +6,6 @@ //! (`crates/tui/src/client.rs:3484`), so this works on any ChatCompletions //! provider — it is not DeepSeek-specific. -use std::fs; - use async_trait::async_trait; use serde_json::{Value, json}; use thiserror::Error; @@ -117,7 +115,7 @@ impl ToolSpec for FimEditTool { // 1. Read the file let resolved = context.resolve_path(path)?; - let content = fs::read_to_string(&resolved).map_err(|e| { + let content = tokio::fs::read_to_string(&resolved).await.map_err(|e| { ToolError::execution_failed(format!("Failed to read {}: {}", resolved.display(), e)) })?; diff --git a/crates/tui/src/tools/git_history.rs b/crates/tui/src/tools/git_history.rs index e26a5d24aa..7043ca79d6 100644 --- a/crates/tui/src/tools/git_history.rs +++ b/crates/tui/src/tools/git_history.rs @@ -336,7 +336,7 @@ impl ToolSpec for GitBlameTool { async fn execute(&self, input: Value, context: &ToolContext) -> Result { let path_str = required_str(&input, "path")?; let resolved_path = context.resolve_path(path_str)?; - let metadata = fs::metadata(&resolved_path).map_err(|e| { + let metadata = tokio::fs::metadata(&resolved_path).await.map_err(|e| { ToolError::invalid_input(format!( "Path does not exist or is not accessible: {path_str} ({e})" )) diff --git a/crates/tui/src/tools/goal.rs b/crates/tui/src/tools/goal.rs index f42e05ce80..a8bfb5296b 100644 --- a/crates/tui/src/tools/goal.rs +++ b/crates/tui/src/tools/goal.rs @@ -44,259 +44,6 @@ pub fn new_shared_goal_state_from_snapshot(snapshot: &GoalSnapshot) -> SharedGoa Arc::new(Mutex::new(GoalState::from_snapshot(snapshot))) } -/// A goal declaration stated in ordinary user prose rather than as a leading -/// `/goal ` command. -/// -/// This intentionally recognizes only a narrow, explicit `/goal` directive. -/// Ordinary long-running requests are not silently promoted to goals, and a -/// quoted multi-line transcript cannot authorize one through a later line. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExplicitGoalDirective { - pub objective: String, -} - -/// Extract an explicit natural-language `/goal` declaration from the user's -/// first non-empty line. -/// -/// The provider never participates in this decision. That makes an explicit -/// declaration behave the same on DeepSeek, Zai, and every compatible route, -/// while keeping ordinary tasks and discussion *about* `/goal` untouched. -#[must_use] -pub fn explicit_goal_directive(input: &str) -> Option { - let line = input.lines().find(|line| !line.trim().is_empty())?.trim(); - let lower = line.to_ascii_lowercase(); - - // Objective follows the directive. - for pattern in [ - "make it your /goal to", - "make this your /goal to", - "make that your /goal to", - "set your /goal to", - "set my /goal to", - "set the /goal to", - "set /goal to", - "your /goal is", - "my /goal is", - "the /goal is", - ] { - let Some(index) = lower.find(pattern) else { - continue; - }; - if !is_direct_goal_clause(&lower[..index]) { - continue; - } - let objective = normalize_explicit_goal_objective(&line[index + pattern.len()..])?; - return Some(ExplicitGoalDirective { objective }); - } - - // Objective precedes the marker: "make solving X your /goal". - for marker in [" your /goal", " my /goal", " the /goal"] { - let Some(marker_index) = lower.find(marker) else { - continue; - }; - let before_marker = &lower[..marker_index]; - let Some(make_index) = before_marker.rfind("make ") else { - continue; - }; - if !is_direct_goal_clause(&lower[..make_index]) { - continue; - } - let objective = - normalize_explicit_goal_objective(&line[make_index + "make ".len()..marker_index])?; - if matches!( - objective.to_ascii_lowercase().as_str(), - "it" | "this" | "that" - ) { - continue; - } - return Some(ExplicitGoalDirective { objective }); - } - - None -} - -fn is_direct_goal_clause(prefix: &str) -> bool { - let prefix = prefix.trim_end(); - if prefix.is_empty() { - return true; - } - [ - "please", - "and", - "then", - "also", - "now", - "you to", - "need to", - "can you", - "could you", - "would you", - "can we", - "could we", - "should", - "must", - "-", - ";", - ":", - ",", - ] - .iter() - .any(|ending| prefix.ends_with(ending)) -} - -fn normalize_explicit_goal_objective(raw: &str) -> Option { - let objective = raw - .trim() - .trim_start_matches([':', '-', '–', '—']) - .trim() - .trim_end_matches(['.', '!', '?']) - .trim(); - (!objective.is_empty()).then(|| objective.to_string()) -} - -/// Operate's automatic goal: Operate turns an ordinary work prompt into the -/// session goal when no unfinished goal exists (docs/MODES.md, "Operate loop"). -/// It feeds the same `GoalState::create` path as [`explicit_goal_directive`]; -/// an explicit `/goal` declaration always wins, and Plan and Work never call -/// this. -/// -/// Only a direct work instruction is considered for automatic persistence. -/// Prompt length alone never authorizes a goal: questions and conversational -/// followups and requests declining a goal remain ordinary turns. Ambiguous requests still run normally; -/// the user can use `/goal` to explicitly request persistent work. -#[must_use] -pub fn operate_goal_from_prompt(input: &str) -> Option { - let input = input.trim(); - if input.starts_with(['"', '\'', '`', '>']) { - return None; - } - // Mode defaults cannot override a user's request to avoid persistence. - // Keep this conservative: declining an automatic goal still runs the task. - let lower = input.to_lowercase().replace('’', "'"); - if [ - "no goal", - "without a goal", - "without goal", - "不要创建目标", - "不要设置目标", - ] - .iter() - .any(|phrase| lower.contains(phrase)) - || lower - .split(['.', '!', '?', ';', '。', '!', '?', ';']) - .any(|clause| { - ["do not ", "don't ", "never ", "without "] - .iter() - .filter_map(|negation| clause.find(negation)) - .any(|start| { - // A negated list carries through commas: "do not edit - // files, create a goal, or start another tool". - let words: Vec<_> = clause[start..] - .split(|c: char| !c.is_alphanumeric()) - .filter(|word| !word.is_empty()) - .collect(); - words.iter().any(|word| matches!(*word, "goal" | "goals")) - && words.iter().any(|word| { - matches!( - *word, - "create" - | "creating" - | "set" - | "setting" - | "start" - | "starting" - | "track" - | "tracking" - | "use" - | "using" - ) - }) - }) - }) - { - return None; - } - let words: Vec<&str> = input.split_whitespace().collect(); - if words.len() < 3 { - return None; - } - let normalize = |word: &str| { - word.trim_matches(|c: char| !c.is_alphanumeric()) - .to_ascii_lowercase() - }; - let head = words - .iter() - .map(|word| normalize(word)) - .find(|word| word != "please") - .unwrap_or_default(); - if OPERATE_CHAT_OPENERS.contains(&head.as_str()) { - return None; - } - let first_line = input.lines().find(|line| !line.trim().is_empty())?.trim(); - if first_line.ends_with(['?', '?']) || input.ends_with(['?', '?']) { - return None; - } - if !OPERATE_WORK_VERBS.contains(&head.as_str()) { - return None; - } - let mut objective = words.join(" "); - if objective.chars().count() > OPERATE_GOAL_MAX_OBJECTIVE_CHARS { - objective = objective - .chars() - .take(OPERATE_GOAL_MAX_OBJECTIVE_CHARS) - .collect::() - .trim_end() - .to_string(); - objective.push('…'); - } - Some(ExplicitGoalDirective { objective }) -} - -const OPERATE_GOAL_MAX_OBJECTIVE_CHARS: usize = 600; -const OPERATE_CHAT_OPENERS: &[&str] = &[ - "hi", "hello", "hey", "thanks", "thank", "ok", "okay", "yes", "no", "sure", "great", "cool", - "nice", "lol", -]; -const OPERATE_WORK_VERBS: &[&str] = &[ - "add", - "build", - "change", - "clean", - "convert", - "create", - "debug", - "deploy", - "design", - "document", - "extract", - "finish", - "fix", - "implement", - "improve", - "integrate", - "investigate", - "make", - "migrate", - "move", - "optimize", - "port", - "refactor", - "remove", - "rename", - "replace", - "resolve", - "rewrite", - "run", - "ship", - "split", - "test", - "update", - "upgrade", - "verify", - "wire", - "write", -]; - /// Runtime status for a goal. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum GoalStatus { @@ -1129,7 +876,7 @@ impl ToolSpec for CreateGoalTool { } fn description(&self) -> &'static str { - "Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. Call this only when the user explicitly asks to use `/goal`, make an objective the goal, or otherwise explicitly requests persistent goal tracking. When the request is explicit, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Never infer a goal from an ordinary task, its apparent length, a question, or a one-file edit. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another." + "Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. You decide when a request is a durable objective worth carrying across turns — a multi-step outcome the user will want continued and verified. Do not create a goal for a question, a greeting, a one-shot edit, or a conversational probe; those are ordinary turns. When the user explicitly asks to use `/goal` or asks you to make something the goal, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another." } fn input_schema(&self) -> Value { @@ -1444,133 +1191,6 @@ mod tests { use super::*; - #[test] - fn explicit_goal_directive_extracts_user_requested_objective() { - let request = explicit_goal_directive( - "hello - take over and make it your /goal to solve navier stokes", - ) - .expect("explicit /goal request"); - assert_eq!(request.objective, "solve navier stokes"); - - let request = explicit_goal_directive("Please make ship the release your /goal.") - .expect("objective-before-marker request"); - assert_eq!(request.objective, "ship the release"); - - let request = explicit_goal_directive("Could you set /goal to repair provider routing?") - .expect("set /goal request"); - assert_eq!(request.objective, "repair provider routing"); - } - - #[test] - fn explicit_goal_directive_rejects_ordinary_or_quoted_goal_discussion() { - for input in [ - "solve navier stokes", - "why didn't you make it your /goal to solve navier stokes?", - "what does /goal do?", - "review the /goal implementation", - "see this transcript where /goal was ignored:\nhello - take over and make it your /goal to solve navier stokes", - ] { - assert_eq!( - explicit_goal_directive(input), - None, - "must not activate from: {input}" - ); - } - } - - #[test] - fn operate_goal_from_prompt_promotes_work_and_leaves_chat_alone() { - let goal = operate_goal_from_prompt( - "Migrate the settings loader to the new config crate\nand keep the old keys readable.", - ) - .expect("multi-line work prompt"); - assert_eq!( - goal.objective, - "Migrate the settings loader to the new config crate and keep the old keys readable." - ); - assert_eq!( - operate_goal_from_prompt("Please fix the flaky CI test") - .expect("imperative after please") - .objective, - "Please fix the flaky CI test" - ); - for chat in [ - "hi", - "thanks, looks good", - "ok go ahead", - "hello there, how are you doing today my friend", - "what does /goal do?", - "why is the build slow?", - "the tests", - ] { - assert_eq!( - operate_goal_from_prompt(chat), - None, - "must stay chat: {chat}" - ); - } - let long = "fix ".repeat(400); - let objective = operate_goal_from_prompt(&long).expect("bounded").objective; - assert!(objective.chars().count() <= OPERATE_GOAL_MAX_OBJECTIVE_CHARS + 1); - assert!(objective.ends_with('…')); - } - - #[test] - fn operate_does_not_promote_conversation_by_length_or_quoted_commands() { - for prompt in [ - "what about like rust or docker builds or something", - "what about like rust or docker builds or something?", - "why did the build fail on the last step when running docker on macos", - "Could you look at why the provider table drops rows after reload and repair it?", - "the Rust and Docker builds might explain the disk usage we saw", - "explain how the goal loop decides whether to continue working", - "那 rust 或者 docker 构建呢", - "请问这个模块的具体实现原理是什么以及它如何与其他服务交互", - "修复这个问题需要什么步骤", - "Build the release now?", - "Build the release now?", - "\"build the release now\"", - "`build the release now`", - "> build the release now", - "in the log it says build failed, what should we do", - ] { - assert_eq!(operate_goal_from_prompt(prompt), None, "{prompt}"); - } - for prompt in [ - "Build the release and verify the checksums", - "Please fix the flaky CI test", - "Fix 中文文档中的链接 and verify them", - "run \"cargo build --release\" and verify the result", - ] { - assert_eq!( - operate_goal_from_prompt(prompt).expect(prompt).objective, - prompt - ); - } - } - - #[test] - fn operate_respects_goal_opt_out_including_negated_lists() { - for prompt in [ - "Run one bounded cancellation check. Do not edit files, inspect other files, create a goal, spawn agents, or start any other tool.", - "Build the example, but don't create a goal.", - "Fix the button. Don’t create a persistent goal for this.", - "Run the check without a goal.", - "Verify the output; no goal tracking for this task.", - "Update the sample. Never set a goal automatically.", - "Fix the test. Do not use /goal.", - "Run the check without creating a goal.", - "Fix the example. Don't create any goals.", - "Run one check. Do not edit files,\ncreate a goal, or spawn agents.", - "Fix this issue, 不要创建目标。", - ] { - assert_eq!(operate_goal_from_prompt(prompt), None, "{prompt}"); - } - // A separate prohibition must not negate a later work instruction. - let work = "Fix the failing checks. Do not publish. Build the release and verify it."; - assert_eq!(operate_goal_from_prompt(work).unwrap().objective, work); - } - #[tokio::test] async fn update_goal_rejects_objective_knob_instead_of_ignoring_it() { // #5123-class: `objective` used to return a success receipt with no diff --git a/crates/tui/src/tools/image_ocr.rs b/crates/tui/src/tools/image_ocr.rs index 14e7f8fe7f..5de039c1f8 100644 --- a/crates/tui/src/tools/image_ocr.rs +++ b/crates/tui/src/tools/image_ocr.rs @@ -198,6 +198,7 @@ mod macos_vision { let request = new_object(request_class, "VNRecognizeTextRequest")?; // VNRequestTextRecognitionLevelAccurate is 0. Use accurate mode for // screenshots and receipts; the tool is user-facing, not latency-critical. + // SAFETY: selectors and signatures match VNRecognizeTextRequest. unsafe { let _: () = msg_send![&*request, setRecognitionLevel: 0usize]; let _: () = msg_send![&*request, setUsesLanguageCorrection: true]; @@ -207,13 +208,16 @@ mod macos_vision { let options: Retained> = NSDictionary::new(); let handler_alloc = alloc_object(handler_class, "VNImageRequestHandler")?; + // SAFETY: selector and signature match VNImageRequestHandler; consumes the alloc. let handler_raw: *mut AnyObject = unsafe { msg_send![handler_alloc, initWithURL: &*url, options: &*options] }; + // SAFETY: init returns +1; from_raw is null-checked. let handler = unsafe { Retained::from_raw(handler_raw) }.ok_or_else(|| { ToolError::execution_failed("image_ocr: failed to initialize Vision image handler") })?; let mut error: *mut NSError = ptr::null_mut(); + // SAFETY: selector and signature match VNImageRequestHandler. let ok: bool = unsafe { msg_send![&*handler, performRequests: &*requests, error: &mut error] }; if !ok { @@ -227,13 +231,16 @@ mod macos_vision { } fn new_object(class: &AnyClass, label: &str) -> Result, ToolError> { + // SAFETY: +1 or null; null handled by from_raw below. let raw: *mut AnyObject = unsafe { msg_send![class, new] }; + // SAFETY: takes the +1 from `new`; null maps to Err. unsafe { Retained::from_raw(raw) }.ok_or_else(|| { ToolError::execution_failed(format!("image_ocr: failed to create {label}")) }) } fn alloc_object(class: &AnyClass, label: &str) -> Result<*mut AnyObject, ToolError> { + // SAFETY: +1 or null; null checked below. let raw: *mut AnyObject = unsafe { msg_send![class, alloc] }; if raw.is_null() { Err(ToolError::execution_failed(format!( @@ -245,35 +252,43 @@ mod macos_vision { } fn collect_recognized_text(request: &AnyObject) -> Result { + // SAFETY: autoreleased return; used synchronously, never stored. let results: *mut AnyObject = unsafe { msg_send![request, results] }; if results.is_null() { return Ok(String::new()); } + // SAFETY: selector and signature match NSArray. let count: usize = unsafe { msg_send![results, count] }; let mut lines = Vec::new(); for idx in 0..count { + // SAFETY: idx < count. let observation: *mut AnyObject = unsafe { msg_send![results, objectAtIndex: idx] }; if observation.is_null() { continue; } + // SAFETY: selector and signature match VNRecognizedTextObservation. let candidates: *mut AnyObject = unsafe { msg_send![observation, topCandidates: 1usize] }; if candidates.is_null() { continue; } + // SAFETY: selector and signature match NSArray. let candidate_count: usize = unsafe { msg_send![candidates, count] }; if candidate_count == 0 { continue; } + // SAFETY: count > 0 checked above. let candidate: *mut AnyObject = unsafe { msg_send![candidates, objectAtIndex: 0usize] }; if candidate.is_null() { continue; } + // SAFETY: selector and signature match VNRecognizedText. let text: *mut NSString = unsafe { msg_send![candidate, string] }; if text.is_null() { continue; } + // SAFETY: `text` is non-null; used synchronously. let line = unsafe { &*text }.to_string(); let trimmed = line.trim(); if !trimmed.is_empty() { @@ -288,10 +303,12 @@ mod macos_vision { if error.is_null() { return String::new(); } + // SAFETY: selector and signature match NSError. let description: *mut NSString = unsafe { msg_send![error, localizedDescription] }; if description.is_null() { String::new() } else { + // SAFETY: `description` is non-null; used synchronously. format!(": {}", unsafe { &*description }) } } diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 85a621f4c3..a17d3298bf 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -89,7 +89,6 @@ pub mod web_search; pub mod web_tool; pub mod workflow; pub mod workflow_plan_approval; -pub mod workflow_trigger; pub use registry::{AgentToolSurfaceOptions, ToolRegistry, ToolRegistryBuilder}; pub use review::ReviewOutput; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 9232a99f59..81508fb402 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -1427,13 +1427,6 @@ impl ToolRegistryBuilder { use super::subagent::AgentTool; use super::subagent::register_coordination_tools; use super::workflow::WorkflowTool; - use super::workflow_trigger::soft_auto_policy_is_linked; - - // Keep soft-auto trigger policy linked in release builds (#4127). - debug_assert!( - soft_auto_policy_is_linked(), - "workflow soft-auto policy must stay linked" - ); let builder = self .with_tool(Arc::new(WorkflowTool::new( diff --git a/crates/tui/src/tools/review.rs b/crates/tui/src/tools/review.rs index dc289cef40..a8c85a6fcf 100644 --- a/crates/tui/src/tools/review.rs +++ b/crates/tui/src/tools/review.rs @@ -287,6 +287,34 @@ pub struct PrReviewPassManifest { pub files: Vec, } +/// One file patch the plan never scheduled (#6285 AC3/AC4). `file` is the +/// patch label exactly as it would have appeared in a pass manifest +/// (`a/old b/new`, or `… (part k/n)` for a pass-budget cut); `chars` is the +/// budgeted `model_diff` size. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrReviewSkippedFile { + pub file: String, + pub reason: String, + pub chars: usize, +} + +/// Skip reasons are stable sentence fragments rendered into review +/// summaries, receipts, and failure notes; keep them greppable. +const SKIP_REASON_HUNK_EXCEEDS_PASS: &str = "a single hunk exceeds the per-pass limit"; +const SKIP_REASON_NO_HUNK_BOUNDARIES: &str = + "exceeds the per-pass limit with no hunk boundaries to split at"; +const SKIP_REASON_BEYOND_MAX_PASSES: &str = "beyond the max_passes budget"; + +/// Render a skip list the way every consumer shows it: the entries are +/// self-describing, so no caller needs its own format. +pub(crate) fn format_skipped_files(skipped: &[PrReviewSkippedFile]) -> String { + skipped + .iter() + .map(|skip| format!("{} ({} chars; {})", skip.file, skip.chars, skip.reason)) + .collect::>() + .join(", ") +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PrReviewManifest { pub base_sha: String, @@ -298,6 +326,12 @@ pub struct PrReviewManifest { pub binary_contents_semantically_inspected: bool, pub max_chars_per_pass: usize, pub passes: Vec, + /// Files the plan never scheduled, in diff order. Empty means complete + /// coverage; every entry names a file the gate did not read and why. + /// `#[serde(default)]` keeps pre-skip-list receipts readable — those + /// plans were complete by construction. + #[serde(default)] + pub skipped_files: Vec, } #[derive(Debug, Clone)] @@ -367,6 +401,27 @@ struct PrReviewPiece<'a> { header_bytes: usize, } +/// One diff-ordered unit of a (possibly degraded) plan: a reviewable piece +/// or a skipped original patch. The partition guard rebuilds the diff from +/// both, so every byte is either reviewed or named as skipped. +enum PrReviewAtom<'a> { + Piece(PrReviewPiece<'a>), + Skipped { + patch: &'a str, + label: String, + chars: usize, + reason: &'static str, + }, +} + +/// Plan PR review passes over `diff`, degrading instead of failing closed +/// (#6285 AC3): files that fit no pass and passes beyond `max_passes` are +/// skipped in diff order and named in `manifest.skipped_files` (AC4). Only a +/// plan that covers nothing still errors. +/// +/// Known limitations, beside the behaviour: skips are whole files — a file +/// with one oversized hunk is skipped entirely, never truncated — and files +/// stay in diff order rather than re-sorted by estimated risk. pub(crate) fn plan_pr_review( diff: &str, view: &super::review_pr::GhPullRequest, @@ -392,25 +447,36 @@ pub(crate) fn plan_pr_review( // no line is elided, shortened or reordered. Sizes use the model // representation, so a binary payload already omitted there can never // drive a split. - let mut pieces: Vec> = Vec::new(); + let mut atoms: Vec> = Vec::new(); for patch in patches { let patch_chars = super::review_pr::model_diff(patch).chars().count(); if patch_chars <= max_chars { - pieces.push(PrReviewPiece { + atoms.push(PrReviewAtom::Piece(PrReviewPiece { diff: Cow::Borrowed(patch), label: patch_label(patch), header_bytes: 0, - }); + })); continue; } let (header, hunks) = pr_file_hunks(patch); let header_chars = header.chars().count(); let largest_hunk_chars = hunks.iter().map(|hunk| hunk.chars().count()).max(); - anyhow::ensure!( - largest_hunk_chars.is_some_and(|hunk_chars| header_chars + hunk_chars <= max_chars), - "Complete PR file patch {} requires {patch_chars} characters, exceeding the per-pass review limit of {max_chars}. No review was run or posted.", - patch_label(patch) - ); + // A file whose largest hunk cannot share a pass with its own header + // can never be scheduled; it is skipped whole, never truncated, so a + // finding can never rest on half a change. + if !largest_hunk_chars.is_some_and(|hunk_chars| header_chars + hunk_chars <= max_chars) { + atoms.push(PrReviewAtom::Skipped { + patch, + label: patch_label(patch), + chars: patch_chars, + reason: if hunks.is_empty() { + SKIP_REASON_NO_HUNK_BOUNDARIES + } else { + SKIP_REASON_HUNK_EXCEEDS_PASS + }, + }); + continue; + } let label = patch_label(patch); let mut parts: Vec = Vec::new(); let mut part = String::from(header); @@ -426,18 +492,48 @@ pub(crate) fn plan_pr_review( } parts.push(part); let total = parts.len(); - pieces.extend( - parts - .into_iter() - .enumerate() - .map(|(index, part)| PrReviewPiece { - label: format!("{label} (part {}/{total})", index + 1), - header_bytes: if index == 0 { 0 } else { header.len() }, - diff: Cow::Owned(part), - }), - ); + atoms.extend(parts.into_iter().enumerate().map(|(index, part)| { + PrReviewAtom::Piece(PrReviewPiece { + label: format!("{label} (part {}/{total})", index + 1), + header_bytes: if index == 0 { 0 } else { header.len() }, + diff: Cow::Owned(part), + }) + })); } + // The partition guard, byte-for-byte: continuation parts replay the file + // header, so exactly those repeated headers are stripped, skipped + // originals are replayed whole, and the rebuilt plan must equal the + // original diff — every byte is either reviewed or named as skipped. + let mut pieces: Vec> = Vec::new(); + let mut skipped: Vec = Vec::new(); + let mut rebuilt = String::with_capacity(diff.len()); + for atom in atoms { + match atom { + PrReviewAtom::Piece(piece) => { + rebuilt.push_str(&piece.diff[piece.header_bytes..]); + pieces.push(piece); + } + PrReviewAtom::Skipped { + patch, + label, + chars, + reason, + } => { + rebuilt.push_str(patch); + skipped.push(PrReviewSkippedFile { + file: label, + reason: reason.to_string(), + chars, + }); + } + } + } + anyhow::ensure!( + rebuilt == diff, + "PR review plan did not partition the complete diff byte-for-byte" + ); + let mut grouped: Vec>> = Vec::new(); let mut current: Vec> = Vec::new(); let mut current_chars = 0; @@ -453,24 +549,28 @@ pub(crate) fn plan_pr_review( if !current.is_empty() { grouped.push(current); } - anyhow::ensure!( - grouped.len() <= max_passes, - "Complete PR review requires {} passes at {max_chars} characters per pass, but max_passes is {max_passes}. No review was run or posted. Opt in with max_passes/--max-passes of at least {} only after approving the provider spend and run duration.", - grouped.len(), - grouped.len() - ); + // Passes beyond the budget are skipped in diff order, never fatal. The + // plan reviews what fits and names the rest. + for group in grouped.split_off(max_passes.min(grouped.len())) { + for piece in group { + let label = piece.label; + let chars = super::review_pr::model_diff(&piece.diff).chars().count(); + skipped.push(PrReviewSkippedFile { + file: label, + reason: SKIP_REASON_BEYOND_MAX_PASSES.to_string(), + chars, + }); + } + } - // The completeness guard, byte-for-byte as before: continuation parts - // replay the file header, so exactly those repeated headers are stripped - // and the rebuilt plan must equal the original diff. + // Only a plan that covers nothing still errors — and even then it + // names every skipped file, so the failure reads as limits, not as a + // verdict on the code. anyhow::ensure!( - grouped - .iter() - .flatten() - .map(|piece| &piece.diff[piece.header_bytes..]) - .collect::() - == diff, - "PR review plan did not preserve the complete diff byte-for-byte" + !grouped.is_empty(), + "PR review plan covers 0 of {} file patches within {max_chars} characters per pass and {max_passes} pass(es); skipped: {}. No review was run or posted.", + view.changed_files, + format_skipped_files(&skipped) ); let passes = grouped @@ -508,6 +608,7 @@ pub(crate) fn plan_pr_review( binary_contents_semantically_inspected: false, max_chars_per_pass: max_chars, passes: passes.iter().map(|pass| pass.manifest.clone()).collect(), + skipped_files: skipped, }; Ok(PrReviewPlan { manifest, passes }) } @@ -546,8 +647,21 @@ pub(crate) fn build_pr_pass_prompt( .max_chars_per_pass .saturating_sub(pass.manifest.diff_chars), ); + // A degraded plan tells the model it is partial, so a pass summary can + // never honestly claim full coverage; the manifest below carries the + // same skip list for the record. + let task = if plan.manifest.skipped_files.is_empty() { + "Review only defects introduced in this pass. Use supplementary source to check surrounding guards and declarations; it does not expand the commentable diff. Binary contents and omitted callers are not inspected. No build or tests have been run.".to_string() + } else { + format!( + "Review only defects introduced in this pass. This is a partial review (pass {} of {}): the gate did not read {}. Do not claim full coverage. Use supplementary source to check surrounding guards and declarations; it does not expand the commentable diff. Binary contents and omitted callers are not inspected. No build or tests have been run.", + pass.manifest.number, + plan.manifest.passes.len(), + format_skipped_files(&plan.manifest.skipped_files) + ) + }; json!({ - "task": "Review only defects introduced in this pass. Use supplementary source to check surrounding guards and declarations; it does not expand the commentable diff. Binary contents and omitted callers are not inspected. No build or tests have been run.", + "task": task, "untrusted_repository_data": true, "pull_request": { "number": number, "title": view.title, "description": view.body }, "manifest": plan.manifest, @@ -632,21 +746,38 @@ impl PrReviewAccumulator { suggestions.extend(output.suggestions); } let total = self.manifest.passes.len(); - let mut output = ReviewOutput { - summary: format!( - "Complete review coverage: {total}/{total} passes, {} file patches, {}.{}", + let per_pass = if summaries.is_empty() { + String::new() + } else { + format!("\n\n{}", summaries.join("\n\n")) + }; + // A degraded plan must never claim complete coverage: the summary + // names every file the gate did not read. + let summary = if self.manifest.skipped_files.is_empty() { + format!( + "Complete review coverage: {total}/{total} passes, {} file patches, {}.{per_pass}", + self.manifest.file_count, self.manifest.diff_fingerprint, + ) + } else { + format!( + "Partial review coverage: {total} pass(es) completed; the gate did not read: {}. Diff: {} file patches, {}.{per_pass}", + format_skipped_files(&self.manifest.skipped_files), self.manifest.file_count, self.manifest.diff_fingerprint, - if summaries.is_empty() { - String::new() - } else { - format!("\n\n{}", summaries.join("\n\n")) - } - ), + ) + }; + let mut output = ReviewOutput { + summary, issues, suggestions, overall_assessment: if assessments.is_empty() { - format!("All {total} review passes completed with structured output.") + if self.manifest.skipped_files.is_empty() { + format!("All {total} review passes completed with structured output.") + } else { + format!( + "Partial review: {total} pass(es) completed with structured output; see the summary for files never read." + ) + } } else { assessments.join("\n") }, @@ -1089,6 +1220,15 @@ pub fn validate_review_receipt_for_diff( validation.reason = "current diff pass manifest does not match receipt".into(); return validation; } + // A partial review is real findings, but it must never read as a + // gate pass: the check fails, naming what the gate did not read. + if !coverage.manifest.skipped_files.is_empty() { + validation.reason = format!( + "review receipt covers a partial review; the gate did not read: {}", + format_skipped_files(&coverage.manifest.skipped_files) + ); + return validation; + } } if receipt.unresolved_risk.unresolved { validation.reason = receipt.unresolved_risk.summary.clone(); @@ -1862,7 +2002,7 @@ mod tests { } #[test] - fn pr_batch_plan_preserves_utf8_order_and_requires_explicit_pass_budget() { + fn pr_batch_plan_degrades_to_first_pass_and_names_skipped_files() { let patches = [ pr_patch("a.txt", "alpha"), pr_patch("b.txt", "🐋"), @@ -1874,12 +2014,26 @@ mod tests { .map(|patch| patch.chars().count()) .max() .unwrap(); - let error = plan_pr_review(&diff, &pr_view(3), max_chars, 1).unwrap_err(); - assert!(error.to_string().contains("requires 3 passes")); - assert!(error.to_string().contains("No review was run or posted")); + // One pass of budget: the first file is reviewed, the rest are + // skipped in diff order and named — never silently dropped. + let degraded = plan_pr_review(&diff, &pr_view(3), max_chars, 1).unwrap(); + assert_eq!(degraded.passes.len(), 1); + assert_eq!(degraded.passes[0].diff, patches[0]); + assert_eq!(degraded.manifest.passes[0].files, ["a/a.txt b/a.txt"]); + assert_eq!(degraded.manifest.skipped_files.len(), 2); + assert_eq!(degraded.manifest.skipped_files[0].file, "a/b.txt b/b.txt"); + assert_eq!(degraded.manifest.skipped_files[1].file, "a/c.txt b/c.txt"); + assert!( + degraded + .manifest + .skipped_files + .iter() + .all(|skip| skip.reason == SKIP_REASON_BEYOND_MAX_PASSES) + ); let plan = plan_pr_review(&diff, &pr_view(3), max_chars, 3).unwrap(); assert_eq!(plan.passes.len(), 3); + assert!(plan.manifest.skipped_files.is_empty()); assert_eq!( plan.passes .iter() @@ -1896,8 +2050,29 @@ mod tests { fn pr_batch_plan_rejects_one_file_overflow_before_any_pass() { let diff = pr_patch("large.txt", &"x".repeat(200)); let error = plan_pr_review(&diff, &pr_view(1), 100, MAX_REVIEW_PASSES).unwrap_err(); - assert!(error.to_string().contains("large.txt")); - assert!(error.to_string().contains("No review was run or posted")); + let message = error.to_string(); + assert!(message.contains("covers 0 of 1 file patches"), "{message}"); + assert!(message.contains("large.txt"), "{message}"); + assert!(message.contains(SKIP_REASON_HUNK_EXCEEDS_PASS), "{message}"); + assert!(message.contains("No review was run or posted"), "{message}"); + } + + #[test] + fn pr_batch_plan_skips_oversized_file_and_reviews_the_rest() { + let ok = pr_patch("ok.txt", "fine"); + let big = pr_multi_hunk_patch("big.txt", &["fine", &"x".repeat(500)]); + let diff = format!("{ok}{big}"); + let max_chars = ok.chars().count(); + let plan = plan_pr_review(&diff, &pr_view(2), max_chars, MAX_REVIEW_PASSES).unwrap(); + assert_eq!(plan.passes.len(), 1); + assert_eq!(plan.passes[0].diff, ok); + assert_eq!(plan.manifest.skipped_files.len(), 1); + assert_eq!(plan.manifest.skipped_files[0].file, "a/big.txt b/big.txt"); + assert_eq!( + plan.manifest.skipped_files[0].reason, + SKIP_REASON_HUNK_EXCEEDS_PASS + ); + assert!(plan.manifest.skipped_files[0].chars > max_chars); } #[test] @@ -1919,9 +2094,19 @@ mod tests { // not: exactly one hunk per part, four parts, four passes. let max_chars = header.chars().count() + hunks.iter().map(|hunk| hunk.chars().count()).max().unwrap(); - let error = plan_pr_review(&patch, &pr_view(1), max_chars, 3).unwrap_err(); - assert!(error.to_string().contains("requires 4 passes")); - assert!(error.to_string().contains("No review was run or posted")); + // Three passes of budget for four parts: the first three parts are + // reviewed and the last part is skipped by name. + let degraded = plan_pr_review(&patch, &pr_view(1), max_chars, 3).unwrap(); + assert_eq!(degraded.passes.len(), 3); + assert_eq!(degraded.manifest.skipped_files.len(), 1); + assert_eq!( + degraded.manifest.skipped_files[0].file, + "a/big.txt b/big.txt (part 4/4)" + ); + assert_eq!( + degraded.manifest.skipped_files[0].reason, + SKIP_REASON_BEYOND_MAX_PASSES + ); let plan = plan_pr_review(&patch, &pr_view(1), max_chars, 4).unwrap(); assert_eq!(plan.passes.len(), 4); @@ -1958,8 +2143,11 @@ mod tests { // file cannot be split and the plan must fail before any pass. let max_chars = header.chars().count() + hunks[0].chars().count(); let error = plan_pr_review(&patch, &pr_view(1), max_chars, MAX_REVIEW_PASSES).unwrap_err(); - assert!(error.to_string().contains("mixed.txt")); - assert!(error.to_string().contains("No review was run or posted")); + let message = error.to_string(); + assert!(message.contains("mixed.txt"), "{message}"); + assert!(message.contains("covers 0 of 1 file patches"), "{message}"); + assert!(message.contains(SKIP_REASON_HUNK_EXCEEDS_PASS), "{message}"); + assert!(message.contains("No review was run or posted"), "{message}"); } #[test] @@ -2183,6 +2371,101 @@ mod tests { ); } + #[test] + fn pr_batch_aggregate_reports_partial_coverage_and_receipt_check_names_skips() { + let first = pr_patch("a.txt", "alpha"); + let second = pr_patch("b.txt", "bravo"); + let diff = format!("{first}{second}"); + let max_chars = first.chars().count().max(second.chars().count()); + let plan = plan_pr_review(&diff, &pr_view(2), max_chars, 1).unwrap(); + assert_eq!(plan.passes.len(), 1); + assert_eq!(plan.manifest.skipped_files.len(), 1); + let mut accumulator = PrReviewAccumulator::new(&plan); + accumulator + .accept( + &plan.passes[0], + json!({ + "summary": "first", + "issues": [], + "suggestions": [], + "overall_assessment": "" + }) + .to_string(), + ) + .unwrap(); + let (output, content, coverage) = accumulator.finish(&diff).unwrap(); + assert!( + output.summary.contains("Partial review coverage"), + "{}", + output.summary + ); + assert!( + output.summary.contains("a/b.txt b/b.txt"), + "{}", + output.summary + ); + assert!( + !output.summary.contains("Complete review coverage"), + "{}", + output.summary + ); + assert!( + output.overall_assessment.contains("Partial review"), + "{}", + output.overall_assessment + ); + let mut receipt = build_review_receipt( + "pr:1", + &diff, + "fixture", + "fixture-model", + &output, + &content, + Vec::new(), + ); + attach_pr_review_coverage(&mut receipt, coverage).unwrap(); + let validation = validate_review_receipt_for_diff(&diff, &receipt, None); + assert!(!validation.passed); + assert!( + validation.reason.contains("partial review"), + "{}", + validation.reason + ); + assert!( + validation.reason.contains("a/b.txt b/b.txt"), + "{}", + validation.reason + ); + } + + #[test] + fn pr_pass_prompt_marks_degraded_plans_partial_for_the_model() { + let first = pr_patch("a.txt", "alpha"); + let second = pr_patch("b.txt", "bravo"); + let diff = format!("{first}{second}"); + let max_chars = first.chars().count().max(second.chars().count()); + let view = pr_view(2); + let workspace = tempfile::tempdir().unwrap(); + let degraded = plan_pr_review(&diff, &view, max_chars, 1).unwrap(); + let prompt = + build_pr_pass_prompt(1, &view, °raded, °raded.passes[0], workspace.path()); + let task = serde_json::from_str::(&prompt).unwrap()["task"] + .as_str() + .unwrap() + .to_string(); + assert!(task.contains("partial review"), "{task}"); + assert!(task.contains("pass 1 of 1"), "{task}"); + assert!(task.contains("a/b.txt b/b.txt"), "{task}"); + let complete = plan_pr_review(&diff, &view, max_chars, 2).unwrap(); + let prompt = + build_pr_pass_prompt(1, &view, &complete, &complete.passes[0], workspace.path()); + let task = serde_json::from_str::(&prompt).unwrap()["task"] + .as_str() + .unwrap() + .to_string(); + assert!(!task.contains("partial review"), "{task}"); + } + #[test] fn review_usage_aggregates_every_billable_counter() { let mut total = Usage::default(); diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index bb704082ff..cdc53a4e8d 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -438,6 +438,7 @@ fn signal_child_process_group(child: &Child, signal: libc::c_int) -> std::io::Re return Ok(()); } + // SAFETY: kill(2) dereferences no pointers. let result = unsafe { libc::kill(-pgid, signal) }; if result == 0 { Ok(()) @@ -604,12 +605,14 @@ unsafe impl Sync for WindowsJob {} #[cfg(windows)] impl WindowsJob { fn attach_to_child(child: &Child) -> std::io::Result { + // SAFETY: returned handle is owned by the new wrapper. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; let job = Self { handle }; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is live with matching size; both handles are live. unsafe { SetInformationJobObject( job.handle, @@ -627,6 +630,7 @@ impl WindowsJob { } fn terminate(&self) -> std::io::Result<()> { + // SAFETY: `self.handle` is a live owned job handle. unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } } } @@ -634,6 +638,7 @@ impl WindowsJob { #[cfg(windows)] impl Drop for WindowsJob { fn drop(&mut self) { + // SAFETY: `self.handle` is owned here; Drop runs once. unsafe { let _ = CloseHandle(self.handle); } diff --git a/crates/tui/src/tools/subagent/budget_handback_tests.rs b/crates/tui/src/tools/subagent/budget_handback_tests.rs index 4f13d84f3b..2d9930e51d 100644 --- a/crates/tui/src/tools/subagent/budget_handback_tests.rs +++ b/crates/tui/src/tools/subagent/budget_handback_tests.rs @@ -174,7 +174,7 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu agent.status = SubAgentStatus::Running; { let mut guard = manager.write().await; - guard.register_worker_for_session(spec, &runtime.context.state_namespace); + guard.register_worker_for_session(spec, &runtime.context.state_namespace, None); guard.agents.insert("report-worker".to_string(), agent); } let task = tokio::spawn(run_subagent_task(SubAgentTask { diff --git a/crates/tui/src/tools/subagent/completion_usage_tests.rs b/crates/tui/src/tools/subagent/completion_usage_tests.rs index e35b4f3034..62c4fdb141 100644 --- a/crates/tui/src/tools/subagent/completion_usage_tests.rs +++ b/crates/tui/src/tools/subagent/completion_usage_tests.rs @@ -346,6 +346,7 @@ async fn completion_usage_counts_real_manifest_only_root_fork() { checkpoint_continuation: false, ..Default::default() }, + None, ) .unwrap(); let record = guard.worker_records.get_mut(&fork.agent_id).unwrap(); diff --git a/crates/tui/src/tools/subagent/coord.rs b/crates/tui/src/tools/subagent/coord.rs index a77e3edb3e..8a90ab200a 100644 --- a/crates/tui/src/tools/subagent/coord.rs +++ b/crates/tui/src/tools/subagent/coord.rs @@ -616,7 +616,9 @@ impl ToolSpec for AgentsInterruptTool { let manager = self.manager.read().await; manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id) }; - let projection = subagent_session_projection(snapshot, false, context, worker_record).await; + let projection = + subagent_session_projection(&self.manager, snapshot, false, context, worker_record) + .await; let payload = json!({ "action": "interrupt", "agent_id": projection.agent_id, diff --git a/crates/tui/src/tools/subagent/delivery.rs b/crates/tui/src/tools/subagent/delivery.rs index b7dc1cc4f4..334e44cc4b 100644 --- a/crates/tui/src/tools/subagent/delivery.rs +++ b/crates/tui/src/tools/subagent/delivery.rs @@ -1,7 +1,10 @@ //! Delivery evidence replacing the old prose-verb/git-status heuristic. //! The worker ledger retains the spawn baseline; this module only reads files. -use super::{AgentRunVerificationSummary, AgentWorkerSpec, normalize_claim_path}; +use super::{ + AgentRunVerificationSummary, AgentWorkerSpec, default_agent_run_verification, + normalize_claim_path, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -120,14 +123,19 @@ fn fingerprint(root: &Path, relative: &str) -> Option { impl DeliveryEvidence { pub(super) fn capture(spec: &AgentWorkerSpec) -> Self { - let baseline = spec - .runtime_profile - .permissions - .write + Self::capture_for_handle(&spec.workspace, spec.runtime_profile.permissions.write) + } + + /// Baseline capture that needs only the workspace and write permission — + /// the two spec fields the baseline actually reads. The async spawn path + /// calls this in `spawn_blocking` BEFORE the manager write lock (#6210) + /// and threads the evidence through registration, so git + file + /// fingerprints never run under the lock. + pub(super) fn capture_for_handle(workspace: &Path, write: bool) -> Self { + let baseline = write .then(|| { let root = - String::from_utf8(git(&spec.workspace, &["rev-parse", "--show-toplevel"])?) - .ok()?; + String::from_utf8(git(workspace, &["rev-parse", "--show-toplevel"])?).ok()?; let root = PathBuf::from(root.trim()); let head = git(&root, &["rev-parse", "--verify", "HEAD"]) .and_then(|bytes| String::from_utf8(bytes).ok()) @@ -483,3 +491,61 @@ pub(super) fn verify_changes( deliverables: Vec::new(), }) } + +/// Everything delivery verification needs, snapshotted under a read lock. +/// `allowed[i]` is the write-scope verdict for `deliverables[i]`. The compute +/// half runs in `spawn_blocking` with no manager lock held (#6210). +#[derive(Debug, Clone)] +pub(super) struct DeliveryVerificationInputs { + pub evidence: DeliveryEvidence, + pub workspace: PathBuf, + pub result_text: String, + pub write_perm: bool, + pub deliverables: Vec, + pub allowed: Vec, +} + +/// Pure compute half of worker delivery verification: the git trio + +/// fingerprints (`changed_paths`), claim comparison, and per-deliverable +/// presence checks. Runs off the manager lock; the caller stores the summary. +pub(super) fn compute_delivery_verification( + inputs: &DeliveryVerificationInputs, +) -> AgentRunVerificationSummary { + let changed = inputs.evidence.changed_paths(&inputs.workspace); + let mut verification = verify_changes( + &inputs.result_text, + inputs.write_perm, + &inputs.evidence, + changed.as_ref(), + &inputs.deliverables.iter().cloned().collect(), + ) + .unwrap_or_else(default_agent_run_verification); + verification.deliverables = inputs + .deliverables + .iter() + .zip(inputs.allowed.iter()) + .map(|(path, allowed)| check_deliverable(&inputs.workspace, path, *allowed)) + .collect(); + let missing = verification + .deliverables + .iter() + .filter(|verdict| verdict.status != "present") + .map(|verdict| format!("{} ({})", verdict.path, verdict.status)) + .collect::>(); + if !missing.is_empty() { + let prior = if verification.status == "claim_mismatch" { + format!(" {}", verification.summary) + } else { + String::new() + }; + verification.status = "deliverable_missing".to_string(); + verification.summary = format!( + "Declared deliverables not produced as non-empty files in the worker write scope: {}.{prior}", + missing.join(", ") + ); + } else if !inputs.deliverables.is_empty() && verification.status == "self_report_only" { + verification.status = "deliverables_present".to_string(); + verification.summary = "Declared files exist and are non-empty inside the worker write scope; their contents remain a worker self-report.".to_string(); + } + verification +} diff --git a/crates/tui/src/tools/subagent/delivery_tests.rs b/crates/tui/src/tools/subagent/delivery_tests.rs index e7d256a3c4..be16d601f4 100644 --- a/crates/tui/src/tools/subagent/delivery_tests.rs +++ b/crates/tui/src/tools/subagent/delivery_tests.rs @@ -70,6 +70,14 @@ fn complete(manager: &mut SubAgentManager, id: &str, report: &str) -> AgentRunVe result.status = SubAgentStatus::Completed; result.result = Some(report.into()); manager.complete_worker_from_result(id, &result); + // Deferred verification (#6210): the commit leaves verification pending; + // run the same snapshot→compute→store halves `ensure` runs off the lock. + if !manager.worker_records[id].delivery_evidence.checked + && let Some(inputs) = manager.delivery_verification_inputs(id, &result) + { + let verification = delivery::compute_delivery_verification(&inputs); + manager.store_delivery_verification(id, verification); + } manager.worker_records[id].verification.clone() } @@ -540,3 +548,136 @@ async fn enforced_readonly_python_queries_sqlite_under_a_live_peer_write_claim() ); } } + +/// Deferred-verification contract (#6210): the terminal commit stores the +/// worker projection but leaves verification pending for `ensure`. +#[test] +fn terminal_commit_leaves_delivery_verification_pending() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (mut manager, id) = worker(tmp.path(), true, &["report.md"], &["."]); + let mut result = manager.get_result(&id).unwrap(); + result.status = SubAgentStatus::Completed; + result.result = Some("Finished the research.".into()); + manager.complete_worker_from_result(&id, &result); + let record = manager.worker_records.get(&id).unwrap(); + assert!(!record.delivery_evidence.checked); + assert_eq!(record.verification.status, "self_report_only"); + assert_eq!( + record.result_summary.as_deref(), + Some("Finished the research.") + ); +} + +#[tokio::test] +async fn ensure_worker_delivery_verified_stores_verdicts_and_is_idempotent() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (manager, id) = worker(tmp.path(), true, &["report.md"], &["."]); + let manager = Arc::new(RwLock::new(manager)); + let result = { + let mut guard = manager.write().await; + let mut result = guard.get_result(&id).unwrap(); + result.status = SubAgentStatus::Completed; + result.result = Some("Finished the research.".into()); + guard.complete_worker_from_result(&id, &result); + assert!(!guard.worker_records[&id].delivery_evidence.checked); + result + }; + ensure_worker_delivery_verified(&manager, &id, &result).await; + let first = manager.read().await.worker_records[&id] + .verification + .clone(); + assert_eq!(first.status, "deliverable_missing"); + assert!( + manager.read().await.worker_records[&id] + .delivery_evidence + .checked + ); + // A second call is a no-op even with a different report. + let mut other = result.clone(); + other.result = Some("CHANGES: src/lib.rs".into()); + ensure_worker_delivery_verified(&manager, &id, &other).await; + assert_eq!(manager.read().await.worker_records[&id].verification, first); +} + +#[tokio::test] +async fn ensure_worker_delivery_verified_ignores_running_missing_and_checked() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (manager, id) = worker(tmp.path(), true, &[], &["src"]); + let manager = Arc::new(RwLock::new(manager)); + let running = manager.read().await.get_result(&id).unwrap(); + assert_eq!(running.status, SubAgentStatus::Running); + ensure_worker_delivery_verified(&manager, &id, &running).await; + assert!( + !manager.read().await.worker_records[&id] + .delivery_evidence + .checked + ); + // A missing worker id is a silent no-op. + let mut missing = running.clone(); + missing.agent_id = "agent_missing".to_string(); + missing.status = SubAgentStatus::Completed; + ensure_worker_delivery_verified(&manager, "agent_missing", &missing).await; + // A checked record keeps its stored verdict. + let mut done = running.clone(); + done.status = SubAgentStatus::Completed; + done.result = Some("CHANGES: src/lib.rs".into()); + ensure_worker_delivery_verified(&manager, &id, &done).await; + assert_eq!( + manager.read().await.worker_records[&id].verification.status, + "claim_mismatch" + ); + let mut changed_mind = done.clone(); + changed_mind.result = Some("CHANGES: None".into()); + ensure_worker_delivery_verified(&manager, &id, &changed_mind).await; + assert_eq!( + manager.read().await.worker_records[&id].verification.status, + "claim_mismatch" + ); +} + +/// Read-side backstop (#6210): a terminal detail projection heals a +/// verification left pending by a Stop/interrupt/close/stale commit. +#[tokio::test] +async fn detail_projection_heals_pending_delivery_verification() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (manager, id) = worker(tmp.path(), true, &["report.md"], &["."]); + let manager = Arc::new(RwLock::new(manager)); + let result = { + let mut guard = manager.write().await; + let mut result = guard.get_result(&id).unwrap(); + result.status = SubAgentStatus::Completed; + result.result = Some("Finished the research.".into()); + guard.complete_worker_from_result(&id, &result); + result + }; + let mut context = ToolContext::new(tmp.path()); + context.state_namespace = "workspace".to_string(); + let worker_record = manager + .read() + .await + .get_worker_record_for_session("workspace", &id); + assert!( + worker_record + .as_ref() + .is_some_and(|record| !record.delivery_evidence.checked) + ); + let projection = + subagent_session_projection(&manager, result, false, &context, worker_record).await; + assert_eq!(projection.verification.status, "deliverable_missing"); + assert!( + projection + .verification + .deliverables + .iter() + .any(|verdict| verdict.path == "report.md") + ); + assert!( + manager.read().await.worker_records[&id] + .delivery_evidence + .checked + ); +} diff --git a/crates/tui/src/tools/subagent/lifecycle_tests.rs b/crates/tui/src/tools/subagent/lifecycle_tests.rs index 77d211c8cd..9931c15a65 100644 --- a/crates/tui/src/tools/subagent/lifecycle_tests.rs +++ b/crates/tui/src/tools/subagent/lifecycle_tests.rs @@ -714,6 +714,7 @@ async fn lifecycle_detail_budget_keeps_the_transcript_handle_retrievable() { &id, "retained", &messages, 1, true, )); let projection = subagent_session_projection( + &new_shared_subagent_manager(dir.path().to_path_buf(), 1), manager.get_result(&id).unwrap(), false, &context, diff --git a/crates/tui/src/tools/subagent/limits_tests.rs b/crates/tui/src/tools/subagent/limits_tests.rs index 076e929de3..57dfee8499 100644 --- a/crates/tui/src/tools/subagent/limits_tests.rs +++ b/crates/tui/src/tools/subagent/limits_tests.rs @@ -195,6 +195,7 @@ async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() { SubAgentAssignment::new("inspect".to_string(), None), Some(vec![]), options, + None, ) .unwrap(); let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile; @@ -218,6 +219,7 @@ async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() { resume_from_agent_id: Some(child.agent_id), ..Default::default() }, + None, ); assert!( refused @@ -257,6 +259,7 @@ async fn resume_intersects_saved_write_shell_and_tool_permissions_with_current_c preserve_runtime_profile: Some(saved), ..Default::default() }, + None, ) .unwrap(); let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile; @@ -300,6 +303,7 @@ async fn root_fork_of_depth_two_leaf_cannot_regain_a_generation() { resume_from_agent_id: Some("leaf".to_string()), ..Default::default() }, + None, ) .unwrap(); let spec = &guard.worker_records[&child.agent_id].spec; diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 322c15fd88..57a24cb439 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -974,15 +974,27 @@ impl AgentWorkerRecord { /// a hand-rolled struct literal that would drift from this one. #[cfg(test)] pub(crate) fn new(spec: AgentWorkerSpec, now_ms: u64) -> Self { - Self::new_for_session(spec, now_ms, String::new()) + Self::new_for_session(spec, now_ms, String::new(), None) } - fn new_for_session(spec: AgentWorkerSpec, now_ms: u64, owner_session_id: String) -> Self { + fn new_for_session( + spec: AgentWorkerSpec, + now_ms: u64, + owner_session_id: String, + precomputed_evidence: Option, + ) -> Self { let run_id = agent_worker_run_id(&spec); let artifacts = default_subagent_artifacts(&run_id); let follow_up = follow_up_target_for_spec(&spec); let takeover = takeover_target_for_spec(&spec); - let delivery_evidence = DeliveryEvidence::capture(&spec); + // #6210: the async spawn path fingerprints pre-lock assuming write + // capability; the resolved spec permission is authoritative, so a + // baseline for a read-only worker is discarded, never stored. + let delivery_evidence = match precomputed_evidence { + Some(evidence) if spec.runtime_profile.permissions.write => evidence, + Some(_) => DeliveryEvidence::default(), + None => DeliveryEvidence::capture(&spec), + }; let mut verification = default_agent_run_verification(); if let Some(manifest) = spec.launch_manifest.as_ref() { verification.deliverables = manifest @@ -4806,16 +4818,22 @@ impl SubAgentManager { } pub fn register_worker(&mut self, spec: AgentWorkerSpec) { - self.register_worker_for_session(spec, ""); + self.register_worker_for_session(spec, "", None); } - fn register_worker_for_session(&mut self, spec: AgentWorkerSpec, owner_session_id: &str) { + fn register_worker_for_session( + &mut self, + spec: AgentWorkerSpec, + owner_session_id: &str, + precomputed_evidence: Option, + ) { let worker_id = spec.worker_id.clone(); let now_ms = epoch_millis_now(); let mut record = AgentWorkerRecord::new_for_session( normalize_worker_spec(spec), now_ms, owner_session_id.to_string(), + precomputed_evidence, ); self.push_worker_event( &mut record, @@ -5444,73 +5462,66 @@ impl SubAgentManager { } } - fn verify_worker_delivery(&mut self, worker_id: &str, result: &SubAgentResult) { - let Some(record) = self.worker_records.get(worker_id) else { - return; - }; + /// Store a verification computed off the lock. Re-checks `checked` so a + /// racing `ensure_worker_delivery_verified` cannot overwrite a stored + /// verdict (#6210). Returns whether this call stored. + fn store_delivery_verification( + &mut self, + worker_id: &str, + verification: AgentRunVerificationSummary, + ) -> bool { + if let Some(record) = self.worker_records.get_mut(worker_id) + && !record.delivery_evidence.checked + { + record.verification = verification; + record.delivery_evidence.checked = true; + return true; + } + false + } + + /// Snapshot everything delivery verification needs. `None` when there is + /// no record, verification already ran, or the result is not terminal. + /// Pure reads for the read lock in `ensure_worker_delivery_verified` + /// (#6210). + fn delivery_verification_inputs( + &self, + worker_id: &str, + result: &SubAgentResult, + ) -> Option { + let record = self.worker_records.get(worker_id)?; if record.delivery_evidence.checked || result.status == SubAgentStatus::Running { - return; + return None; } - let workspace = &record.spec.workspace; - let changed = record.delivery_evidence.changed_paths(workspace); - let mut verification = delivery::verify_changes( - result.result.as_deref().unwrap_or_default(), - record.spec.runtime_profile.permissions.write, - &record.delivery_evidence, - changed.as_ref(), - &record - .spec - .launch_manifest - .as_ref() - .map(|manifest| manifest.deliverables.iter().cloned().collect()) - .unwrap_or_default(), - ) - .unwrap_or_else(default_agent_run_verification); - let paths = record + let deliverables: Vec = record .spec .launch_manifest .as_ref() - .map(|manifest| manifest.deliverables.as_slice()) + .map(|manifest| manifest.deliverables.clone()) .unwrap_or_default(); - verification.deliverables = paths + let allowed = deliverables .iter() .map(|path| { - let allowed = record.spec.runtime_profile.permissions.write + record.spec.runtime_profile.permissions.write && self .validate_write_scope(worker_id, std::slice::from_ref(path)) - .is_ok(); - delivery::check_deliverable(workspace, path, allowed) + .is_ok() }) .collect(); - let missing = verification - .deliverables - .iter() - .filter(|verdict| verdict.status != "present") - .map(|verdict| format!("{} ({})", verdict.path, verdict.status)) - .collect::>(); - if !missing.is_empty() { - let prior = if verification.status == "claim_mismatch" { - format!(" {}", verification.summary) - } else { - String::new() - }; - verification.status = "deliverable_missing".to_string(); - verification.summary = format!( - "Declared deliverables not produced as non-empty files in the worker write scope: {}.{prior}", - missing.join(", ") - ); - } else if !paths.is_empty() && verification.status == "self_report_only" { - verification.status = "deliverables_present".to_string(); - verification.summary = "Declared files exist and are non-empty inside the worker write scope; their contents remain a worker self-report.".to_string(); - } - if let Some(record) = self.worker_records.get_mut(worker_id) { - record.verification = verification; - record.delivery_evidence.checked = true; - } + Some(delivery::DeliveryVerificationInputs { + evidence: record.delivery_evidence.clone(), + workspace: record.spec.workspace.clone(), + result_text: result.result.clone().unwrap_or_default(), + write_perm: record.spec.runtime_profile.permissions.write, + deliverables, + allowed, + }) } fn complete_worker_from_result(&mut self, worker_id: &str, result: &SubAgentResult) { - self.verify_worker_delivery(worker_id, result); + // Delivery verification is deferred: `ensure_worker_delivery_verified` + // computes it off the lock before the terminal commit (natural and + // panic paths) or heals it on the next detail read (#6210). let status = worker_status_from_subagent_result(result); let message = match &result.status { SubAgentStatus::Completed => Some("completed".to_string()), @@ -6228,6 +6239,9 @@ impl SubAgentManager { assignment, allowed_tools, options, + // Checkpoint resume replays under the write lock; the baseline + // captures inline there, as before (#6210). + None, )?; Ok(resumed) } @@ -6538,7 +6552,7 @@ impl SubAgentManager { child_route: None, launch_manifest: None, }; - self.register_worker_for_session(spec, "workspace"); + self.register_worker_for_session(spec, "workspace", None); (agent_id, input_rx) } @@ -6713,6 +6727,7 @@ impl SubAgentManager { assignment: SubAgentAssignment, allowed_tools: Option>, options: SubAgentSpawnOptions, + precomputed_delivery_evidence: Option, ) -> Result { self.cleanup(COMPLETED_AGENT_RETENTION); @@ -7114,7 +7129,11 @@ impl SubAgentManager { }; agent.owner_session_id = runtime.context.state_namespace.clone(); agent.terminal_delivery = Some(SubAgentTerminalDeliveryContext::from_runtime(&runtime)); - self.register_worker_for_session(worker_spec, &runtime.context.state_namespace); + self.register_worker_for_session( + worker_spec, + &runtime.context.state_namespace, + precomputed_delivery_evidence, + ); // Shared-workspace writers may execute only after their exact worker // identity and claim are durably replayable. Persist a Starting record @@ -7874,7 +7893,6 @@ impl SubAgentManager { handle.abort(); } - self.verify_worker_delivery(agent_id, &result); let delivery = self .agents .get(agent_id) @@ -8149,11 +8167,30 @@ fn subagent_checkpoint_is_continuable(snapshot: &SubAgentResult) -> bool { } async fn subagent_session_projection( + manager: &SharedSubAgentManager, snapshot: SubAgentResult, timed_out: bool, context: &ToolContext, worker_record: Option, ) -> SubAgentSessionProjection { + // Deferred-verification backstop (#6210): terminal commits outside the + // natural/panic epilogues (Stop, interrupt, session close, stale cleanup) + // leave verification pending, so the first detail read heals it before + // the projection reports it. Re-fetch under the session gate afterwards. + let worker_record = if snapshot.status != SubAgentStatus::Running + && worker_record + .as_ref() + .is_some_and(|record| !record.delivery_evidence.checked) + { + ensure_worker_delivery_verified(manager, &snapshot.agent_id, &snapshot).await; + manager + .read() + .await + .get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id) + .or(worker_record) + } else { + worker_record + }; let transcript_session_id = format!("agent:{}", snapshot.agent_id); let continuable = subagent_checkpoint_is_continuable(&snapshot); let transcript_payload = json!({ @@ -8737,6 +8774,10 @@ fn reject_root_relative_symlinks(root: &Path, path: &Path) -> Result<()> { Ok(()) } +/// Maximum bytes read from a sub-agent state or transcript artifact file. +/// Generous for long session histories; anything larger is corrupt. +const MAX_SUBAGENT_STATE_BYTES: u64 = 16 * 1024 * 1024; + fn read_subagent_state_file(state_root: &Path, path: &Path) -> Result { let state_root = normalize_subagent_workspace(state_root); reject_root_relative_symlinks(&state_root, path)?; @@ -8749,9 +8790,16 @@ fn read_subagent_state_file(state_root: &Path, path: &Path) -> Result { )); } - let mut file = open_subagent_state_file(path)?; + let file = open_subagent_state_file(path)?; let mut raw = String::new(); - file.read_to_string(&mut raw)?; + file.take(MAX_SUBAGENT_STATE_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_SUBAGENT_STATE_BYTES { + return Err(anyhow!( + "sub-agent state file {} exceeds the 16 MiB limit", + path.display() + )); + } Ok(raw) } @@ -9672,7 +9720,9 @@ impl ToolSpec for AgentTool { let manager = self.manager.read().await; manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id) }; - let projection = subagent_session_projection(snapshot, false, context, worker_record).await; + let projection = + subagent_session_projection(&self.manager, snapshot, false, context, worker_record) + .await; let mut value = serde_json::to_value(&projection) .map_err(|e| ToolError::execution_failed(e.to_string()))?; compact_spawn_receipt(&mut value, verbose); @@ -9914,7 +9964,7 @@ async fn inspect_agent_from_input( } let mut projection = - subagent_session_projection(snapshot, false, context, worker_record).await; + subagent_session_projection(&manager, snapshot, false, context, worker_record).await; projection.resumed_from = compact .get("resumed_from") .and_then(Value::as_str) @@ -9979,7 +10029,8 @@ async fn cancel_agent_from_input( manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id); (snapshot, worker_record) }; - let projection = subagent_session_projection(snapshot, false, context, worker_record).await; + let projection = + subagent_session_projection(&manager, snapshot, false, context, worker_record).await; let mut tool_result = ToolResult::json(&projection) .map_err(|err| ToolError::execution_failed(err.to_string()))?; tool_result.metadata = Some(json!({ @@ -10246,7 +10297,6 @@ async fn spawn_subagent_from_input( &mut child_runtime, &spawn_request, profile_member.as_ref(), - &effective_prompt, true, ) .await?; @@ -10439,6 +10489,21 @@ async fn spawn_subagent_from_input( if let Some((lease_key, display_path)) = resident_lease.as_ref() { reserve_resident_lease(lease_key, display_path)?; } + // #6210: fingerprint the delivery baseline BEFORE the manager write + // lock. The child workspace is final here (worktree creation already + // ran); write capability resolves under the lock, so capture assuming + // write and let registration discard it for readers. Skipped only when + // the parent ceiling already denies writes — `derive_child` intersects, + // so that child is definitely read-only. A join failure falls back to + // the inline capture, exactly as before. + let precomputed_delivery_evidence = if child_runtime.worker_profile.permissions.write { + let workspace = child_runtime.context.workspace.clone(); + tokio::task::spawn_blocking(move || DeliveryEvidence::capture_for_handle(&workspace, true)) + .await + .ok() + } else { + None + }; let mut manager_guard = manager.write().await; let result = manager_guard.spawn_background_with_assignment_options( @@ -10469,6 +10534,7 @@ async fn spawn_subagent_from_input( claim_pre_namespaced: false, preserve_runtime_profile: None, }, + precomputed_delivery_evidence, ); let result = match result { Ok(result) => result, @@ -11110,28 +11176,29 @@ async fn supervise_subagent_task_body( return; }; let message = crate::utils::panic_message(&*panic); + let mut result = match manager_handle.read().await.get_result(&agent_id) { + Ok(result) => result, + Err(err) => { + tracing::error!( + target: "subagent", + agent_id = %agent_id, + ?err, + "panicked task no longer has a manager record" + ); + std::panic::resume_unwind(panic); + } + }; + result.status = SubAgentStatus::Failed(format!("sub-agent task panicked: {message}")); + result.result = None; + result.needs_input = None; + // Verification precedes the write lock like the natural epilogue (#6210). + ensure_worker_delivery_verified(&manager_handle, &agent_id, &result).await; { let mut manager = manager_handle.write().await; - match manager.get_result(&agent_id) { - Ok(mut result) => { - result.status = - SubAgentStatus::Failed(format!("sub-agent task panicked: {message}")); - result.result = None; - result.needs_input = None; - // Arbitrated exactly like the natural terminal commit: when a - // cancel or another terminal outcome already won, this is a - // no-op rather than a second result. - manager.finish_terminal_result(&agent_id, result, false, true); - } - Err(err) => { - tracing::error!( - target: "subagent", - agent_id = %agent_id, - ?err, - "panicked task no longer has a manager record" - ); - } - } + // Arbitrated exactly like the natural terminal commit: when a + // cancel or another terminal outcome already won, this is a + // no-op rather than a second result. + manager.finish_terminal_result(&agent_id, result, false, true); } std::panic::resume_unwind(panic); } @@ -11201,6 +11268,41 @@ async fn budget_work_preservation_note( }) } +/// Deferred delivery verification (#6210). Snapshots inputs under a read +/// lock, runs the git-subprocess + fingerprint compute in `spawn_blocking` +/// with no lock held, then stores under a follow-up write lock. Idempotent +/// via `DeliveryEvidence.checked`: concurrent callers may duplicate the +/// read-only computation, but only the first store wins. +/// +/// A racing terminal commit can land between the compute and the store; the +/// stored verdict is then based on this call's result text rather than the +/// committed one. The deliverable-presence verdicts are text-independent, so +/// only the claimed-path comparison can differ, and only in that race. +async fn ensure_worker_delivery_verified( + manager: &SharedSubAgentManager, + worker_id: &str, + result: &SubAgentResult, +) { + let inputs = { + let manager = manager.read().await; + let Some(inputs) = manager.delivery_verification_inputs(worker_id, result) else { + return; + }; + inputs + }; + let verification = + tokio::task::spawn_blocking(move || delivery::compute_delivery_verification(&inputs)) + .await + .ok(); + let Some(verification) = verification else { + return; + }; + manager + .write() + .await + .store_delivery_verification(worker_id, verification); +} + fn budget_partial_result_with_note( mut result: SubAgentResult, cause: &str, @@ -11385,40 +11487,48 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) { None }; + // The terminal result is built before the write lock: `get_result` takes + // `&self`, the error transforms are pure, and no live writer can change + // the agent's text between here and the commit — only a racing terminal + // claim, which leaves this epilogue with nothing to commit either way. + // Verification runs here too, so the fan-in completion and the terminal + // persist both carry fresh verdicts with no git subprocess under the lock + // (#6210). + let terminal = match result { + Ok(result) => result, + Err(_) => { + let mut result = match task.manager_handle.read().await.get_result(&agent_id) { + Ok(result) => result, + Err(err) => { + tracing::error!( + target: "subagent", + agent_id = %agent_id, + ?err, + "failed task no longer has a manager record" + ); + return; + } + }; + let error = failure_error + .clone() + .expect("failed task should carry annotated error"); + if error.contains("wall-time budget exhausted") { + budget_partial_result(result, &error, preservation_note.as_deref()) + } else { + result.status = SubAgentStatus::Failed(error); + result.result = None; + result.needs_input = None; + result + } + } + }; + ensure_worker_delivery_verified(&task.manager_handle, &agent_id, &terminal).await; // Every terminal path — successful/fatal model exit, explicit Stop, // coordination interrupt, and stale cleanup — arbitrates and publishes // through `finish_terminal_result`. Cancellation that already won leaves // this late epilogue with no claim and therefore no duplicate fan-in. let terminal_committed = { let mut manager = task.manager_handle.write().await; - let terminal = match result { - Ok(result) => result, - Err(_) => { - let mut result = match manager.get_result(&agent_id) { - Ok(result) => result, - Err(err) => { - tracing::error!( - target: "subagent", - agent_id = %agent_id, - ?err, - "failed task no longer has a manager record" - ); - return; - } - }; - let error = failure_error - .clone() - .expect("failed task should carry annotated error"); - if error.contains("wall-time budget exhausted") { - budget_partial_result(result, &error, preservation_note.as_deref()) - } else { - result.status = SubAgentStatus::Failed(error); - result.result = None; - result.needs_input = None; - result - } - } - }; manager.finish_terminal_result(&agent_id, terminal, false, true) }; if !terminal_committed { @@ -12805,7 +12915,9 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, - AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait).with_step(steps), + AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait) + .with_step(steps) + .routine_wait(), format!( "{}: requesting model response", format_step_counter(steps, max_steps) @@ -14997,7 +15109,6 @@ async fn bind_spawn_model_route( runtime: &mut SubAgentRuntime, request: &SpawnRequest, member: Option<&crate::fleet::profile::AgentProfile>, - prompt: &str, apply_role_pins: bool, ) -> Result<(ModelRoute, SpawnRouteSource), ToolError> { bind_profile_provider(runtime, member)?; @@ -15078,7 +15189,6 @@ async fn bind_spawn_model_route( let route = resolve_subagent_assignment_route( runtime, None, - prompt, &request.agent_type, selection.model_route, request.thinking, @@ -15218,7 +15328,7 @@ async fn resolved_spawn_roster_entry( let mut child = runtime.child_runtime(); let resolved = match request { Ok((request, member)) => { - bind_spawn_model_route(&mut child, &request, member.as_ref(), "", apply_role_pins).await + bind_spawn_model_route(&mut child, &request, member.as_ref(), apply_role_pins).await } Err(error) => Err(error), }; @@ -15717,19 +15827,12 @@ impl SubAgentResolvedRoute { pub(crate) async fn resolve_subagent_assignment_route( runtime: &SubAgentRuntime, configured_model: Option, - prompt: &str, agent_type: &FleetRole, requested_model_route: ModelRoute, requested_thinking: SubAgentThinking, ) -> SubAgentResolvedRoute { let model_route = assignment_model_route(configured_model.as_deref(), requested_model_route); - worker_profile_subagent_assignment_route( - runtime, - &model_route, - requested_thinking, - prompt, - agent_type, - ) + worker_profile_subagent_assignment_route(runtime, &model_route, requested_thinking, agent_type) } fn assignment_model_route( @@ -15765,14 +15868,12 @@ fn fallback_subagent_assignment_route( configured_model: Option, requested_model_route: ModelRoute, requested_thinking: SubAgentThinking, - prompt: &str, ) -> SubAgentResolvedRoute { let model_route = assignment_model_route(configured_model.as_deref(), requested_model_route); worker_profile_subagent_assignment_route( runtime, &model_route, requested_thinking, - prompt, &FleetRole::Worker, ) } @@ -15826,7 +15927,6 @@ fn worker_profile_subagent_assignment_route( runtime: &SubAgentRuntime, model_route: &ModelRoute, requested_thinking: SubAgentThinking, - prompt: &str, agent_type: &FleetRole, ) -> SubAgentResolvedRoute { let candidates = subagent_router_candidates(runtime); @@ -15848,7 +15948,6 @@ fn worker_profile_subagent_assignment_route( let reasoning_effort = subagent_reasoning_effort_for_request( runtime, &model, - prompt, requested_fast_lane, requested_thinking, role_reasoning_default.as_deref(), @@ -15860,7 +15959,6 @@ fn worker_profile_subagent_assignment_route( fn subagent_reasoning_effort_for_request( runtime: &SubAgentRuntime, model: &str, - prompt: &str, requested_fast_lane: bool, requested_thinking: SubAgentThinking, role_reasoning_default: Option<&str>, @@ -15875,7 +15973,7 @@ fn subagent_reasoning_effort_for_request( match requested_thinking { SubAgentThinking::Effort(effort) => Some(normalize(effort).as_setting().to_string()), SubAgentThinking::Auto => Some( - normalize(auto_subagent_reasoning_effort(prompt)) + normalize(auto_subagent_reasoning_effort()) .as_setting() .to_string(), ), @@ -15901,15 +15999,11 @@ fn subagent_reasoning_effort_for_request( }; Some(normalize(effort).as_setting().to_string()) } - SubAgentThinking::Inherit => fallback_subagent_reasoning_effort(runtime, model, prompt), + SubAgentThinking::Inherit => fallback_subagent_reasoning_effort(runtime, model), } } -fn fallback_subagent_reasoning_effort( - runtime: &SubAgentRuntime, - model: &str, - prompt: &str, -) -> Option { +fn fallback_subagent_reasoning_effort(runtime: &SubAgentRuntime, model: &str) -> Option { let normalize = |effort: ReasoningEffort| { effort.normalize_for_route( runtime.client.api_provider(), @@ -15928,7 +16022,7 @@ fn fallback_subagent_reasoning_effort( .is_some_and(|effort| ReasoningEffort::from_setting(effort) == ReasoningEffort::Auto); if requested_auto { Some( - normalize(auto_subagent_reasoning_effort(prompt)) + normalize(auto_subagent_reasoning_effort()) .as_setting() .to_string(), ) @@ -15942,8 +16036,8 @@ fn fallback_subagent_reasoning_effort( } } -fn auto_subagent_reasoning_effort(prompt: &str) -> ReasoningEffort { - crate::auto_reasoning::select(false, prompt) +fn auto_subagent_reasoning_effort() -> ReasoningEffort { + crate::auto_reasoning::select() } fn parse_optional_subagent_model(input: &Value, key: &str) -> Result, ToolError> { @@ -18509,7 +18603,6 @@ async fn configured_model_subagent_full_bind_preserves_task_profile_and_role_ids &mut runtime, &request, (source == "profile").then_some(&member), - "", true, ) .await @@ -18668,7 +18761,7 @@ mod declared_shortlist_tests { &json!({"prompt":"fixture", "type":"reviewer", "model":requested}), ) .unwrap(); - let (route, _) = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let (route, _) = bind_spawn_model_route(&mut runtime, &request, None, true) .await .unwrap(); assert_eq!(route, ModelRoute::Fixed(model.into())); @@ -18732,7 +18825,6 @@ mod declared_shortlist_tests { &mut runtime, &request, (source == "profile").then_some(&member), - "", true, ) .await; @@ -18757,7 +18849,7 @@ mod declared_shortlist_tests { ) .unwrap(); assert!( - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .unwrap_err() .to_string() @@ -18783,7 +18875,7 @@ mod declared_shortlist_tests { parse_spawn_request(&json!({"prompt":"fixture", "type":"reviewer", "model":lower})) .unwrap(); assert!( - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .is_err() ); @@ -18795,7 +18887,7 @@ mod declared_shortlist_tests { ) .unwrap(); assert_eq!( - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .unwrap() .0, diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index e4cd37615a..6b7d28d0a5 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2181,6 +2181,7 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin manager.write().await.register_worker_for_session( make_worker_spec(worker_id, tmp.path().to_path_buf()), "session-detached-usage", + None, ); } let (release_usage_tx, release_usage_rx) = tokio::sync::oneshot::channel(); @@ -2266,6 +2267,7 @@ async fn child_guardian_usage_source_is_sanitized_and_replay_idempotent() { manager.write().await.register_worker_for_session( make_worker_spec("agent_guardian", tmp.path().to_path_buf()), "guardian-usage-session", + None, ); let runtime_owner = "interactive:guardian-usage-session:turn-parent"; @@ -2352,6 +2354,7 @@ async fn ownerless_no_mailbox_provider_usage_reaches_accounting_once() { manager.write().await.register_worker_for_session( make_worker_spec("agent_direct", tmp.path().to_path_buf()), "direct-usage-session", + None, ); let mut runtime = stub_runtime(); runtime.manager = Arc::clone(&manager); @@ -2446,6 +2449,7 @@ async fn ownerless_child_usage_crossing_new_settles_to_its_dispatch_origin_once( manager.write().await.register_worker_for_session( make_worker_spec(agent_id, tmp.path().to_path_buf()), origin_session_id, + None, ); // Dispatch in the origin session: the engine's off-turn continuation @@ -2567,6 +2571,7 @@ async fn provider_success_without_usage_records_one_route_aware_gap_and_no_zero_ manager.write().await.register_worker_for_session( make_worker_spec("agent_missing_usage", tmp.path().to_path_buf()), "missing-usage-session", + None, ); let runtime_owner = "interactive:missing-usage-session:turn-parent"; @@ -3644,7 +3649,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &runtime, &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( @@ -3665,7 +3669,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &stub_runtime(), &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( @@ -3674,6 +3677,8 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { "explicit child reasoning must override the role default" ); + // #6290: explicit auto no longer classifies the child prompt — the debug + // wording below resolves the same declared default as any other wording. let request = parse_spawn_request(&json!({ "prompt": "debug this release failure", "type": "consultant", @@ -3684,13 +3689,12 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &stub_runtime(), &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( route.reasoning_effort.as_deref(), - Some("max"), - "explicit auto must resolve from the child prompt instead of using the consultant high default" + Some("high"), + "explicit auto resolves the declared default instead of classifying the child prompt" ); let request = parse_spawn_request(&json!({ @@ -3703,7 +3707,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &stub_runtime(), &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( @@ -4423,7 +4426,7 @@ async fn manual_config_role_pin_refuses_task_model_and_strength_before_binding() selection.model_route, ModelRoute::Fixed("deepseek-v4-flash".into()) ); - let error = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let error = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("task choices cannot replace a current Config pin"); let message = error.to_string(); @@ -4452,7 +4455,7 @@ async fn manual_role_pin_accepts_only_its_exact_qualified_provider_selector() { let request = parse_spawn_request(&json!({"prompt":"review", "type":"reviewer", "model":model})) .unwrap(); - let (route, source) = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let (route, source) = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect("the task may restate the same exact route"); assert_eq!(route, ModelRoute::Fixed("deepseek-v4-flash".into())); @@ -4463,7 +4466,7 @@ async fn manual_role_pin_accepts_only_its_exact_qualified_provider_selector() { "prompt":"review", "type":"reviewer", "model":"moonshot/deepseek-v4-flash" })) .unwrap(); - let error = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let error = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("a provider prefix cannot retarget the saved pin"); assert!(error.to_string().contains("conflicts"), "{error}"); @@ -4490,7 +4493,7 @@ async fn structured_role_pin_rejects_incomplete_auto_and_unknown_provider_pairs( .unwrap(), ); let request = parse_spawn_request(&json!({"prompt":"review", "type":"reviewer"})).unwrap(); - let error = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let error = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("an invalid explicit route cannot inherit a usable default"); assert!(!error.to_string().is_empty(), "{value:?}: {error}"); @@ -4557,7 +4560,7 @@ async fn manual_role_pin_keeps_case_distinct_custom_provider_identity() { "prompt":"review", "type":"reviewer", "model":selector })) .unwrap(); - let result = bind_spawn_model_route(&mut runtime, &request, None, "", true).await; + let result = bind_spawn_model_route(&mut runtime, &request, None, true).await; if succeeds { assert_eq!(result.unwrap().1, SpawnRouteSource::RolePin); } else { @@ -4599,7 +4602,7 @@ async fn foreign_manual_role_pin_is_not_downgraded_to_an_implicit_default() { assert!(error.to_string().contains("moonshot"), "{error}"); assert_eq!(selected.source, SpawnRouteSource::RolePin); assert!(matches!(selected.model_route, ModelRoute::Fixed(_))); - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("the actual bind must keep the same known-foreign refusal"); assert_eq!(runtime.model, "kimi-k2.6"); @@ -4656,7 +4659,7 @@ async fn structured_custom_pin_refuses_named_provider_migration_but_accepts_lite "prompt":"review", "type":"reviewer", "model":"custom/model-x" })) .unwrap(); - let result = bind_spawn_model_route(&mut runtime, &request, None, "", true).await; + let result = bind_spawn_model_route(&mut runtime, &request, None, true).await; if should_bind { assert_eq!(result.unwrap().1, SpawnRouteSource::RolePin); assert_eq!(runtime.model, "model-x"); @@ -5004,7 +5007,6 @@ fn test_explicit_spawn_thinking_reaches_the_request() { None, ModelRoute::Inherit, request.thinking, - "review this", ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } @@ -5120,7 +5122,6 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { Some("deepseek-v4-pro".to_string()), ModelRoute::Inherit, SubAgentThinking::Inherit, - "debug this release failure", ); assert_eq!( @@ -5134,8 +5135,10 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { Some("auto"), "the raw auto sentinel must never reach the wire" ); - assert_eq!(route.reasoning_effort.as_deref(), Some("max")); - assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::Max)); + // #6290: `auto` resolves the declared default (High); the prompt no + // longer classifies the tier. + assert_eq!(route.reasoning_effort.as_deref(), Some("high")); + assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::High)); } #[test] @@ -5147,7 +5150,6 @@ fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { None, ModelRoute::Inherit, SubAgentThinking::Inherit, - "debug this release failure", ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); @@ -5161,7 +5163,8 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { snapshot.fork_context = true; let ctx = ToolContext::new("."); - let projection = subagent_session_projection(snapshot, false, &ctx, None).await; + let manager = new_shared_subagent_manager(PathBuf::from("."), 1); + let projection = subagent_session_projection(&manager, snapshot, false, &ctx, None).await; assert_eq!(projection.name, "fanout_review"); assert_eq!(projection.context_mode, "forked"); @@ -5207,7 +5210,8 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { ) }; - let projection = subagent_session_projection(snapshot, false, &ctx, None).await; + let manager = new_shared_subagent_manager(PathBuf::from("."), 1); + let projection = subagent_session_projection(&manager, snapshot, false, &ctx, None).await; assert_eq!(projection.transcript_handle, full_handle); assert_eq!(projection.transcript_handle.name, "full_transcript"); @@ -5227,7 +5231,8 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { snapshot.checkpoint = Some(checkpoint.clone()); let ctx = ToolContext::new("."); - let projection = subagent_session_projection(snapshot, false, &ctx, None).await; + let manager = new_shared_subagent_manager(PathBuf::from("."), 1); + let projection = subagent_session_projection(&manager, snapshot, false, &ctx, None).await; assert_eq!(projection.status, "waiting_for_user"); assert!(projection.terminal); @@ -5260,7 +5265,7 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { ); let timed_out_projection = - subagent_session_projection(projection.snapshot.clone(), true, &ctx, None).await; + subagent_session_projection(&manager, projection.snapshot.clone(), true, &ctx, None).await; assert!(timed_out_projection.needs_continuation); assert!(timed_out_projection.timed_out); assert!(timed_out_projection.timed_out_with_checkpoint); @@ -7036,17 +7041,14 @@ fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { let mut runtime = stub_runtime().with_auto_model(true); runtime.model = "deepseek-v4-pro".to_string(); - for prompt in ["implement the release fix", "say hello"] { - let route = fallback_subagent_assignment_route( - &runtime, - None, - ModelRoute::Inherit, - SubAgentThinking::Inherit, - prompt, - ); - assert_eq!(route.model_route, ModelRoute::Inherit); - assert_eq!(route.model, "deepseek-v4-pro", "prompt {prompt:?}"); - } + let route = fallback_subagent_assignment_route( + &runtime, + None, + ModelRoute::Inherit, + SubAgentThinking::Inherit, + ); + assert_eq!(route.model_route, ModelRoute::Inherit); + assert_eq!(route.model, "deepseek-v4-pro"); } #[test] @@ -7059,7 +7061,6 @@ fn subagent_model_strength_faster_uses_known_family_sibling() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!(route.model_route, ModelRoute::Faster); assert_eq!(route.model, "deepseek-v4-flash"); @@ -7075,7 +7076,6 @@ fn subagent_model_strength_explicit_model_wins_over_faster() { Some("deepseek-v4-pro".to_string()), ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!( route.model_route, @@ -7094,7 +7094,6 @@ fn explicit_child_thinking_overrides_faster_default_off() { None, ModelRoute::Faster, SubAgentThinking::Effort(ReasoningEffort::High), - "inspect one file", ); assert_eq!(route.model, "deepseek-v4-flash"); assert_eq!(route.reasoning_effort.as_deref(), Some("high")); @@ -7102,7 +7101,7 @@ fn explicit_child_thinking_overrides_faster_default_off() { } #[test] -fn explicit_child_auto_thinking_resolves_from_child_prompt() { +fn explicit_child_auto_thinking_resolves_to_the_declared_default() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); let route = fallback_subagent_assignment_route( @@ -7110,9 +7109,8 @@ fn explicit_child_auto_thinking_resolves_from_child_prompt() { None, ModelRoute::Inherit, SubAgentThinking::Auto, - "debug this release failure", ); - assert_eq!(route.reasoning_effort.as_deref(), Some("max")); + assert_eq!(route.reasoning_effort.as_deref(), Some("high")); } #[tokio::test] @@ -7126,7 +7124,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole, configured_model: Option<&'static str>, requested_route: ModelRoute, - prompt: &'static str, expected_route: ModelRoute, expected_model: &'static str, expected_reasoning: Option<&'static str>, @@ -7138,7 +7135,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Scout, configured_model: None, requested_route: ModelRoute::Inherit, - prompt: "inspect the parser and report what changed", expected_route: ModelRoute::Inherit, expected_model: "deepseek-v4-pro", expected_reasoning: Some("max"), @@ -7148,7 +7144,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Scout, configured_model: None, requested_route: ModelRoute::Faster, - prompt: "inspect the parser and report what changed", expected_route: ModelRoute::Faster, expected_model: "deepseek-v4-flash", expected_reasoning: Some("off"), @@ -7158,7 +7153,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Worker, configured_model: None, requested_route: ModelRoute::Inherit, - prompt: "synthesize the release blocker fix", expected_route: ModelRoute::Inherit, expected_model: "deepseek-v4-pro", expected_reasoning: Some("max"), @@ -7168,7 +7162,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Builder, configured_model: Some("deepseek-v4-flash"), requested_route: ModelRoute::Inherit, - prompt: "apply the narrow code edit", expected_route: ModelRoute::Fixed("deepseek-v4-flash".to_string()), expected_model: "deepseek-v4-flash", expected_reasoning: Some("max"), @@ -7180,7 +7173,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { let route = resolve_subagent_assignment_route( &runtime, case.configured_model.map(str::to_string), - case.prompt, &case.agent_type, case.requested_route.clone(), SubAgentThinking::Inherit, @@ -7212,7 +7204,10 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { } #[test] -fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { +fn subagent_auto_reasoning_resolves_to_the_declared_default() { + // #6290: a raw `auto` runtime tier used to classify the prompt (Low for + // lookup wording, Max for debug wording). Both wordings are gone with the + // classifier — the declared default is the only resolution. let runtime = stub_runtime().with_reasoning_effort(Some("high".to_string()), true); assert_eq!( @@ -7221,21 +7216,9 @@ fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { None, ModelRoute::Inherit, SubAgentThinking::Inherit, - "quick lookup", ) .reasoning_effort, - Some("low".to_string()) - ); - assert_eq!( - fallback_subagent_assignment_route( - &runtime, - None, - ModelRoute::Inherit, - SubAgentThinking::Inherit, - "debug this release failure" - ) - .reasoning_effort, - Some("max".to_string()) + Some("high".to_string()) ); } @@ -9299,7 +9282,8 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin manager.get_worker_record(&agent_id) }; let projection = - subagent_session_projection(interrupted.clone(), false, &ctx, worker_record).await; + subagent_session_projection(&manager, interrupted.clone(), false, &ctx, worker_record) + .await; assert_eq!(projection.status, "waiting_for_user"); assert!(projection.continuable); assert!(projection.needs_continuation); @@ -9690,6 +9674,7 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { name: Some("researcher".to_string()), ..Default::default() }, + None, ) .expect_err("duplicate session name must error") }; @@ -9757,6 +9742,7 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { name: Some("researcher".to_string()), ..Default::default() }, + None, ) .expect("a prior-session holder must not reject a fresh same-name spawn") }; @@ -9800,6 +9786,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( make_assignment(), Some(vec![]), options, + None, ) .expect("first writer admitted"); let second = guard @@ -9820,6 +9807,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( }), ..Default::default() }, + None, ) .expect_err("overlapping live contract must contend"); (first.agent_id, second.to_string()) @@ -9874,6 +9862,7 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { }), ..Default::default() }, + None, ) .expect_err("writer must fail before spawn when its durable claim cannot commit") .to_string(); @@ -9955,6 +9944,7 @@ async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { }), ..Default::default() }, + None, ) .expect_err("regular-agent launch must see active Fleet ownership"); let launch = launch.to_string(); @@ -15038,6 +15028,7 @@ fn persist_round_trip_preserves_session_and_boot_ownership() { writer.register_worker_for_session( make_worker_spec("headless_persist", dir.path().to_path_buf()), "session-persist", + None, ); writer .persist_state() @@ -16305,17 +16296,16 @@ async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() let mut runtime = stub_runtime_for_provider("ollama").with_auto_model(true); runtime.model = "qwen3:32b".to_string(); - for prompt in ["hi", "please refactor the whole auth module for security"] { + { let route = resolve_subagent_assignment_route( &runtime, None, - prompt, &FleetRole::Worker, ModelRoute::Faster, SubAgentThinking::Inherit, ) .await; - assert_eq!(route.model, "qwen3:32b", "prompt {prompt:?}"); + assert_eq!(route.model, "qwen3:32b"); assert!( !route.model.contains("deepseek"), "no DeepSeek id may be fabricated: {route:?}" @@ -16332,7 +16322,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!(route.model, "deepseek-v4-flash"); @@ -16343,7 +16332,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect docs", ); // GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast // sibling), not down to GLM-5.1. @@ -16357,7 +16345,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect docs", ); assert_eq!(route.model, "z-ai/glm-5-turbo"); assert_ne!(route.model, "z-ai/glm-5.1"); @@ -16373,7 +16360,6 @@ fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { None, ModelRoute::Inherit, SubAgentThinking::Inherit, - "summarize the repo layout", ); assert_eq!(route.model, "deepseek-v4-flash"); @@ -16396,7 +16382,6 @@ fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "quick scan", ); let validated = ensure_subagent_model_for_provider(&runtime, &route.model_route, route.model) .expect("faster should remap to operator route"); @@ -16461,7 +16446,6 @@ fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!(route.model, "gpt-5.5"); assert!( @@ -20333,8 +20317,8 @@ fn init_claim_repo(root: &Path) { assert!(output.status.success(), "git commit: {output:?}"); } -#[test] -fn completed_claim_of_untouched_file_taints_verification() { +#[tokio::test] +async fn completed_claim_of_untouched_file_taints_verification() { // R7 (finish-operator 2026-08-02): the morning report caught a child // claiming edits git had never seen — by hand. At terminal delivery the // claimed changed-files are checked against git status in the child's @@ -20345,15 +20329,29 @@ fn completed_claim_of_untouched_file_taints_verification() { let mut spec = make_worker_spec("agent_claims", tmp.path().to_path_buf()); spec.runtime_profile.permissions.write = true; manager.register_worker(spec); + let manager = Arc::new(RwLock::new(manager)); let mut snapshot = make_snapshot(SubAgentStatus::Completed); snapshot.agent_id = "agent_claims".to_string(); snapshot.name = "agent_claims".to_string(); snapshot.workspace = Some(tmp.path().to_path_buf()); snapshot.result = Some("CHANGES: src/lib.rs".to_string()); - manager.complete_worker_from_result("agent_claims", &snapshot); + // Deferred verification (#6210): the commit leaves it pending; `ensure` + // computes the taint off the lock. + { + let mut guard = manager.write().await; + guard.complete_worker_from_result("agent_claims", &snapshot); + assert!( + !guard.worker_records["agent_claims"] + .delivery_evidence + .checked + ); + } + ensure_worker_delivery_verified(&manager, "agent_claims", &snapshot).await; let record = manager + .read() + .await .get_worker_record("agent_claims") .expect("worker record"); assert_eq!(record.verification.status, "claim_mismatch"); @@ -20437,8 +20435,8 @@ fn resume_from_rejects_running_source() { ); } -#[test] -fn completed_claim_matching_workspace_state_stays_untainted() { +#[tokio::test] +async fn completed_claim_matching_workspace_state_stays_untainted() { let tmp = tempdir().expect("tempdir"); init_claim_repo(tmp.path()); @@ -20446,16 +20444,11 @@ fn completed_claim_matching_workspace_state_stays_untainted() { let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); manager.register_worker(make_worker_spec("agent_honest", tmp.path().to_path_buf())); std::fs::write(tmp.path().join("src/lib.rs"), "pub fn improved() {}\n").expect("edit file"); - let mut snapshot = make_snapshot(SubAgentStatus::Completed); - snapshot.agent_id = "agent_honest".to_string(); - snapshot.name = "agent_honest".to_string(); - snapshot.workspace = Some(tmp.path().to_path_buf()); - snapshot.result = Some("Updated src/lib.rs with the new implementation.".to_string()); - manager.complete_worker_from_result("agent_honest", &snapshot); - let record = manager - .get_worker_record("agent_honest") - .expect("worker record"); - assert_eq!(record.verification.status, "self_report_only"); + let mut honest = make_snapshot(SubAgentStatus::Completed); + honest.agent_id = "agent_honest".to_string(); + honest.name = "agent_honest".to_string(); + honest.workspace = Some(tmp.path().to_path_buf()); + honest.result = Some("Updated src/lib.rs with the new implementation.".to_string()); // Honest committed claim: the child committed its work, so git status is // clean but the commit is newer than the worker record. @@ -20479,20 +20472,33 @@ fn completed_claim_matching_workspace_state_stays_untainted() { // so the just-made commit is unambiguously after it. record.created_at_ms = record.created_at_ms.saturating_sub(60_000); } - let mut snapshot = make_snapshot(SubAgentStatus::Completed); - snapshot.agent_id = "agent_committer".to_string(); - snapshot.name = "agent_committer".to_string(); - snapshot.workspace = Some(tmp.path().to_path_buf()); - snapshot.result = Some("Updated src/lib.rs and committed the change.".to_string()); - manager.complete_worker_from_result("agent_committer", &snapshot); - let record = manager - .get_worker_record("agent_committer") - .expect("worker record"); - assert_eq!( - record.verification.status, "self_report_only", - "{}", - record.verification.summary - ); + let mut committer = make_snapshot(SubAgentStatus::Completed); + committer.agent_id = "agent_committer".to_string(); + committer.name = "agent_committer".to_string(); + committer.workspace = Some(tmp.path().to_path_buf()); + committer.result = Some("Updated src/lib.rs and committed the change.".to_string()); + let manager = Arc::new(RwLock::new(manager)); + // Deferred verification (#6210): both commits leave verification + // pending; `ensure` computes the untainted verdicts off the lock. + { + let mut guard = manager.write().await; + guard.complete_worker_from_result("agent_honest", &honest); + guard.complete_worker_from_result("agent_committer", &committer); + for id in ["agent_honest", "agent_committer"] { + assert!(!guard.worker_records[id].delivery_evidence.checked); + } + } + ensure_worker_delivery_verified(&manager, "agent_honest", &honest).await; + ensure_worker_delivery_verified(&manager, "agent_committer", &committer).await; + let guard = manager.read().await; + for id in ["agent_honest", "agent_committer"] { + let record = guard.get_worker_record(id).expect("worker record"); + assert_eq!( + record.verification.status, "self_report_only", + "{id}: {}", + record.verification.summary + ); + } } #[tokio::test] @@ -20528,8 +20534,9 @@ async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { let snapshot = inner.get_result(&agent_id).expect("snapshot"); let worker_record = inner.get_worker_record(&agent_id); let context = ToolContext::new("."); + let shared = new_shared_subagent_manager(PathBuf::from("."), 1); let mut projection = - subagent_session_projection(snapshot, false, &context, worker_record).await; + subagent_session_projection(&shared, snapshot, false, &context, worker_record).await; // The route receipt rides inside the budget rather than being exempt from // it (#5305), so measure the receipt that ships. let metadata = spawn_route_metadata("zai", "glm-5", "agent_profile.model"); @@ -20682,8 +20689,9 @@ async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { let snapshot = inner.get_result(&agent_id).expect("snapshot"); let worker_record = inner.get_worker_record(&agent_id); let context = ToolContext::new("."); + let shared = new_shared_subagent_manager(PathBuf::from("."), 1); let mut projection = - subagent_session_projection(snapshot, false, &context, worker_record).await; + subagent_session_projection(&shared, snapshot, false, &context, worker_record).await; let metadata = spawn_route_metadata("deepseek", "deepseek-v4-flash", "run.model"); projection.child_route = Some(spawn_child_route_projection(&metadata)); @@ -21713,6 +21721,7 @@ mod child_permission_gate { manager.register_worker_for_session( make_worker_spec("agent_gate", workspace), "guardian-test-session", + None, ); } // Keep the tempdir alive for the registry's lifetime by leaking it @@ -23108,3 +23117,53 @@ async fn test_disallowed_tools_resume_keeps_saved_and_current_ancestor_denials() vec!["mcp_saved_*"] ); } + +/// Precomputed spawn evidence (#6210) is adopted for write-capable workers: +/// a baseline captured before the lock answers changed-path queries. +#[test] +fn precomputed_delivery_evidence_is_adopted_for_write_workers() { + let tmp = tempdir().expect("tempdir"); + init_claim_repo(tmp.path()); + let evidence = DeliveryEvidence::capture_for_handle(tmp.path(), true); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); + let spec = make_write_worker_spec("agent_precomputed", tmp.path().to_path_buf(), "."); + assert!(spec.runtime_profile.permissions.write); + manager.register_worker_for_session(spec, "workspace", Some(evidence)); + let record = manager + .get_worker_record("agent_precomputed") + .expect("worker record"); + assert!(record.delivery_evidence.changed_paths(tmp.path()).is_some()); +} + +/// The resolved spec permission is authoritative: a precomputed baseline for +/// a read-only worker is discarded, never stored (#6210). +#[test] +fn precomputed_delivery_evidence_is_discarded_for_read_only_workers() { + let tmp = tempdir().expect("tempdir"); + init_claim_repo(tmp.path()); + let evidence = DeliveryEvidence::capture_for_handle(tmp.path(), true); + assert!(evidence.changed_paths(tmp.path()).is_some()); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); + let mut spec = make_worker_spec("agent_reader", tmp.path().to_path_buf()); + spec.runtime_profile.permissions.write = false; + manager.register_worker_for_session(spec, "workspace", Some(evidence)); + let record = manager + .get_worker_record("agent_reader") + .expect("worker record"); + assert!(record.delivery_evidence.changed_paths(tmp.path()).is_none()); +} + +/// Paths without precomputed evidence (resume, Fleet, tests) capture inline +/// at registration, exactly as before (#6210). +#[test] +fn missing_precomputed_evidence_falls_back_to_inline_capture() { + let tmp = tempdir().expect("tempdir"); + init_claim_repo(tmp.path()); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); + let spec = make_write_worker_spec("agent_inline", tmp.path().to_path_buf(), "."); + manager.register_worker_for_session(spec, "workspace", None); + let record = manager + .get_worker_record("agent_inline") + .expect("worker record"); + assert!(record.delivery_evidence.changed_paths(tmp.path()).is_some()); +} diff --git a/crates/tui/src/tools/tool_result_retrieval.rs b/crates/tui/src/tools/tool_result_retrieval.rs index 07b11ff92f..cf347a028d 100644 --- a/crates/tui/src/tools/tool_result_retrieval.rs +++ b/crates/tui/src/tools/tool_result_retrieval.rs @@ -5,7 +5,6 @@ //! only when a digest-bound ownership sidecar proves they belong to the active //! session. -use std::fs; use std::path::PathBuf; use async_trait::async_trait; @@ -135,7 +134,7 @@ impl ToolSpec for RetrieveToolResultTool { } else { None }; - let bytes = fs::read(&resolved.path).map_err(|_| { + let bytes = tokio::fs::read(&resolved.path).await.map_err(|_| { ToolError::execution_failed("evidence is missing or no longer retained") })?; if let Some(ownership) = legacy_ownership { @@ -793,6 +792,7 @@ fn clamp_u64(value: u64, min: usize, max: usize) -> usize { #[cfg(test)] mod tests { use super::*; + use std::fs; use std::sync::MutexGuard; use tempfile::tempdir; diff --git a/crates/tui/src/tools/workflow_trigger.rs b/crates/tui/src/tools/workflow_trigger.rs deleted file mode 100644 index 4eb3a30b6d..0000000000 --- a/crates/tui/src/tools/workflow_trigger.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! Automatic Workflow trigger and suppression heuristics (#4127). -//! -//! Soft-auto model: the **agent** decides to use Workflow without the operator -//! saying the word "workflow". Policy here answers "should we orchestrate?" — -//! the parent prompt still **tells the operator** the intended shape and may -//! ask setup questions via `request_user_input` (TUI modal) before calling -//! `workflow` / `plan`. -//! -//! This remains Act/Agent guidance rather than a prose classifier at the host -//! boundary. Operate keeps small tasks in the parent and uses the existing -//! Workflow plan for multi-step delegation through the same sub-agents. - -/// Signals the parent can supply without full conversation replay. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WorkflowTriggerSignals { - /// Approximate open file / edit scope count for the current ask. - pub distinct_file_scopes: usize, - /// True when the operator is mid interactive multi-turn design/chat. - pub highly_interactive: bool, - /// True when the ask requires writes but no clear phase/child decomposition. - pub risky_writes_unclear_decomposition: bool, - /// Estimated child count if Workflow launched now. - pub estimated_children: usize, - /// Soft cap from `[workflow].auto_start_child_limit` (default 16). - pub auto_start_child_limit: usize, - /// Approximate parent context tokens in use (for high-volume signal). - pub context_tokens: usize, - /// Threshold above which high context volume favors Workflow. - pub high_context_token_threshold: usize, -} - -impl WorkflowTriggerSignals { - #[must_use] - pub fn product_defaults() -> Self { - Self { - distinct_file_scopes: 0, - highly_interactive: false, - risky_writes_unclear_decomposition: false, - estimated_children: 0, - auto_start_child_limit: 16, - context_tokens: 0, - high_context_token_threshold: 80_000, - } - } -} - -/// Decision for automatic Workflow launch / recommendation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WorkflowTriggerDecision { - /// Launch or recommend Workflow. - Trigger { reason: &'static str }, - /// Suppress automatic Workflow; prefer direct tools / single agent. - Suppress { reason: &'static str }, -} - -impl WorkflowTriggerDecision { - #[must_use] - pub fn should_trigger(&self) -> bool { - matches!(self, Self::Trigger { .. }) - } - - #[cfg(test)] - #[must_use] - pub fn reason(&self) -> &'static str { - match self { - Self::Trigger { reason } | Self::Suppress { reason } => reason, - } - } -} - -/// Evaluate whether automatic Workflow is appropriate for this user ask. -/// -/// Suppression wins over trigger when both could apply (noisy auto-orchestration -/// is worse than missing a fan-out). Act/Agent soft-auto guidance should stay -/// aligned with these rules. Operate uses a direct worker for one bounded -/// independent task and a Workflow plan for multi-step delegation. -#[must_use] -pub fn evaluate_workflow_trigger( - user_text: &str, - signals: &WorkflowTriggerSignals, -) -> WorkflowTriggerDecision { - let text = user_text.trim(); - let lower = text.to_ascii_lowercase(); - - // --- Hard suppressions (AC) --- - if signals.highly_interactive { - return WorkflowTriggerDecision::Suppress { - reason: "highly interactive task — keep turn-by-turn", - }; - } - if signals.risky_writes_unclear_decomposition { - return WorkflowTriggerDecision::Suppress { - reason: "risky writes without clear decomposition", - }; - } - if signals.estimated_children > 0 - && signals.auto_start_child_limit > 0 - && signals.estimated_children > signals.auto_start_child_limit - { - return WorkflowTriggerDecision::Suppress { - reason: "estimated children exceed auto_start_child_limit", - }; - } - if child_overhead_exceeds_benefit(&lower, signals) { - return WorkflowTriggerDecision::Suppress { - reason: "child overhead greater than benefit", - }; - } - if is_simple_command_or_factual_question(&lower, text) { - return WorkflowTriggerDecision::Suppress { - reason: "simple command or factual question", - }; - } - if is_one_file_edit(&lower, signals) { - return WorkflowTriggerDecision::Suppress { - reason: "one-file edit — use direct tools", - }; - } - - // --- Triggers (AC) --- - if signals.distinct_file_scopes >= 3 { - return WorkflowTriggerDecision::Trigger { - reason: "independent scopes across multiple files", - }; - } - if signals.context_tokens >= signals.high_context_token_threshold { - return WorkflowTriggerDecision::Trigger { - reason: "high context volume favors staged Workflow", - }; - } - if has_fanout_language(&lower) { - return WorkflowTriggerDecision::Trigger { - reason: "audit/sweep/compare/fan-out language", - }; - } - if has_staged_work_language(&lower) { - return WorkflowTriggerDecision::Trigger { - reason: "staged multi-phase work", - }; - } - if has_independent_verification_language(&lower) { - return WorkflowTriggerDecision::Trigger { - reason: "independent verification pass", - }; - } - - WorkflowTriggerDecision::Suppress { - reason: "no automatic Workflow trigger matched", - } -} - -fn child_overhead_exceeds_benefit(lower: &str, signals: &WorkflowTriggerSignals) -> bool { - // Tiny asks or explicit single-step language — spawn cost dominates. - if signals.estimated_children == 1 { - return true; - } - if lower.len() < 24 && !has_fanout_language(lower) && !has_staged_work_language(lower) { - return true; - } - let tiny = [ - "fix typo", - "rename variable", - "one liner", - "one-liner", - "quick peek", - "just check", - ]; - tiny.iter().any(|needle| lower.contains(needle)) -} - -fn is_simple_command_or_factual_question(lower: &str, original: &str) -> bool { - if lower.starts_with('/') { - // Slash commands are UI routing, not orchestration. - return true; - } - let factual_prefixes = [ - "what is ", - "what's ", - "whats ", - "who is ", - "when is ", - "where is ", - "how many ", - "which ", - "define ", - "explain ", - ]; - if factual_prefixes.iter().any(|p| lower.starts_with(p)) && original.len() < 160 { - return true; - } - let simple_cmds = [ - "run tests", - "run the tests", - "cargo test", - "cargo check", - "git status", - "git log", - "git diff", - "ls", - "pwd", - "show version", - "print version", - ]; - if simple_cmds - .iter() - .any(|c| lower == *c || lower.starts_with(&format!("{c} "))) - { - return true; - } - // Short yes/no or status pings. - matches!( - lower.trim_end_matches(['?', '.', '!']), - "ok" | "thanks" | "thank you" | "status" | "ping" | "hello" | "hi" - ) -} - -fn is_one_file_edit(lower: &str, signals: &WorkflowTriggerSignals) -> bool { - if signals.distinct_file_scopes == 1 { - let editish = [ - "edit ", - "fix ", - "patch ", - "update ", - "change ", - "rewrite ", - "in this file", - "this file", - "only this file", - "single file", - "one file", - ]; - return editish.iter().any(|n| lower.contains(n)); - } - // Explicit single-file phrasing without scope signal. - lower.contains("only this file") - || lower.contains("just this file") - || lower.contains("single file") - || (lower.contains("one file") && !has_fanout_language(lower)) -} - -fn has_fanout_language(lower: &str) -> bool { - const NEEDLES: &[&str] = &[ - "audit", - "sweep", - "compare", - "fan-out", - "fan out", - "fanout", - "in parallel", - "parallel across", - "across the codebase", - "across packages", - "across crates", - "every crate", - "all packages", - "all modules", - "multi-repo", - "multi repo", - ]; - NEEDLES.iter().any(|n| lower.contains(n)) -} - -fn has_staged_work_language(lower: &str) -> bool { - const NEEDLES: &[&str] = &[ - "phase 1", - "phase 2", - "first implement", - "then verify", - "implement then", - "staged", - "multi-phase", - "multi phase", - "plan then execute", - "explore then implement", - "scout then", - ]; - NEEDLES.iter().any(|n| lower.contains(n)) -} - -fn has_independent_verification_language(lower: &str) -> bool { - const NEEDLES: &[&str] = &[ - "independent verification", - "verify independently", - "separate verifier", - "second pair of eyes", - "review in parallel", - "verify in parallel", - "independent review", - ]; - NEEDLES.iter().any(|n| lower.contains(n)) -} - -/// Reachability probe so the soft-auto surface stays linked in release builds. -/// -/// Returns `true` when a canonical fan-out ask would trigger Workflow under -/// product defaults (used by registry/tool wiring smoke tests). -#[must_use] -pub fn soft_auto_policy_is_linked() -> bool { - evaluate_workflow_trigger( - "audit every crate for unsafe blocks", - &WorkflowTriggerSignals::product_defaults(), - ) - .should_trigger() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn signals() -> WorkflowTriggerSignals { - WorkflowTriggerSignals::product_defaults() - } - - #[test] - fn suppresses_one_file_edits() { - let mut s = signals(); - s.distinct_file_scopes = 1; - let d = evaluate_workflow_trigger("fix the typo in this file", &s); - assert!(!d.should_trigger(), "{d:?}"); - assert!(d.reason().contains("one-file")); - } - - #[test] - fn suppresses_simple_commands_and_factual_questions() { - let s = signals(); - for ask in [ - "cargo test", - "git status", - "what is a worktree?", - "how many crates are there?", - "/help", - "thanks", - ] { - let d = evaluate_workflow_trigger(ask, &s); - assert!(!d.should_trigger(), "expected suppress for {ask:?}: {d:?}"); - } - } - - #[test] - fn product_defaults_match_workflow_child_limit() { - assert_eq!(signals().auto_start_child_limit, 16); - } - - #[test] - fn suppresses_highly_interactive_and_unclear_risky_writes() { - let mut s = signals(); - s.highly_interactive = true; - assert!(!evaluate_workflow_trigger("redesign the product with me", &s).should_trigger()); - - s = signals(); - s.risky_writes_unclear_decomposition = true; - assert!(!evaluate_workflow_trigger("make it better somehow", &s).should_trigger()); - } - - #[test] - fn suppresses_when_child_overhead_dominates() { - let mut s = signals(); - s.estimated_children = 1; - assert!(!evaluate_workflow_trigger("quick peek at main.rs", &s).should_trigger()); - - s = signals(); - s.estimated_children = 20; - s.auto_start_child_limit = 8; - let d = evaluate_workflow_trigger("audit the whole monorepo", &s); - assert!(!d.should_trigger(), "{d:?}"); - assert!(d.reason().contains("auto_start_child_limit")); - } - - #[test] - fn triggers_on_fanout_and_staged_language() { - let s = signals(); - for ask in [ - "audit every crate for unsafe blocks", - "sweep the codebase for TODO debt", - "compare the two provider implementations in parallel", - "phase 1 explore then phase 2 implement", - "run an independent verification of the release notes", - ] { - let d = evaluate_workflow_trigger(ask, &s); - assert!(d.should_trigger(), "expected trigger for {ask:?}: {d:?}"); - } - } - - #[test] - fn triggers_on_independent_scopes_and_high_context() { - let mut s = signals(); - s.distinct_file_scopes = 5; - assert!( - evaluate_workflow_trigger("touch the related modules carefully", &s).should_trigger() - ); - - s = signals(); - s.context_tokens = 120_000; - assert!(evaluate_workflow_trigger("continue the migration plan", &s).should_trigger()); - } - - #[test] - fn suppression_wins_over_fanout_language_when_interactive() { - let mut s = signals(); - s.highly_interactive = true; - let d = evaluate_workflow_trigger("let's design an audit sweep together", &s); - assert!(!d.should_trigger(), "{d:?}"); - assert!(d.reason().contains("interactive")); - } -} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 36552b6fcf..7a58bfeb42 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1584,10 +1584,10 @@ pub struct App { pub configured_models: Vec, /// Exact provider/model pins loaded from settings, in user order. pub pinned_models: Vec, - /// When true, the model is auto-selected based on request complexity - /// rather than using a fixed model. The `/model auto` command sets this. - /// `dispatch_user_message` calls `auto_model_heuristic` to resolve the - /// effective model for each outbound message. + /// When true, the model is auto-selected rather than using a fixed + /// model. The `/model auto` command sets this. The flash classifier + /// picks the per-turn model when available; otherwise the configured + /// default model is used (no request-content signal). pub auto_model: bool, /// Last concrete model chosen while `auto_model` is active. pub last_effective_model: Option, diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 2fe9559b4b..00b527758d 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -558,8 +558,8 @@ fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ComplexRequest, + reason: crate::model_routing::AutoRouteReason::LocalFallback( + crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault, ), }); app.last_effective_reasoning_effort = diff --git a/crates/tui/src/tui/behavioral_tips.rs b/crates/tui/src/tui/behavioral_tips.rs index e688c722f6..f930d8dfc8 100644 --- a/crates/tui/src/tui/behavioral_tips.rs +++ b/crates/tui/src/tui/behavioral_tips.rs @@ -9,7 +9,6 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use crate::settings::Settings; use crate::tui::app::{App, StatusToast, StatusToastKind, StatusToastLevel}; -use codewhale_config::AppMode; use codewhale_localization::{Locale, MessageId, tr}; const MAX_TIPS_PER_SESSION: u8 = 1; @@ -18,7 +17,6 @@ const MAX_TRACKED_MANUAL_COMMANDS: usize = 128; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BehavioralTip { - PlanningMode, BackgroundJobReceipt, ClearedInputRestore, McpValidation, @@ -31,7 +29,6 @@ pub enum BehavioralTip { impl BehavioralTip { const fn key(self) -> &'static str { match self { - Self::PlanningMode => "planning_mode", Self::BackgroundJobReceipt => "background_job_receipt", Self::ClearedInputRestore => "cleared_input_restore", Self::McpValidation => "mcp_validation", @@ -43,7 +40,6 @@ impl BehavioralTip { const fn message_id(self) -> MessageId { match self { - Self::PlanningMode => MessageId::BehavioralTipPlanning, Self::BackgroundJobReceipt => MessageId::BehavioralTipBackgroundReceipt, Self::ClearedInputRestore => MessageId::BehavioralTipClearedInput, Self::McpValidation => MessageId::BehavioralTipMcpValidation, @@ -56,7 +52,6 @@ impl BehavioralTip { fn message(self, locale: Locale) -> String { let template = tr(locale, self.message_id()); match self { - Self::PlanningMode => template.replace("{key}", "Tab"), Self::BackgroundJobReceipt => template.replace("{key}", "Enter"), Self::ClearedInputRestore => template.replace("{chord}", "Ctrl+Z"), Self::McpValidation => template.replace("{command}", "codewhale mcp validate"), @@ -202,12 +197,6 @@ impl App { true } - pub fn maybe_nudge_for_planning_prompt(&mut self, input: &str) -> bool { - self.mode != AppMode::Plan - && looks_like_planning_prompt(input) - && self.maybe_show_behavioral_tip(BehavioralTip::PlanningMode) - } - pub fn note_manual_command_for_tip(&mut self, input: &str) -> bool { self.behavioral_tips.enabled && self.behavioral_tips.note_manual_command(input) @@ -227,50 +216,21 @@ fn manual_command_fingerprint(input: &str) -> Option { Some(hasher.finish()) } -fn looks_like_planning_prompt(input: &str) -> bool { - let normalized = input - .to_ascii_lowercase() - .chars() - .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' }) - .collect::(); - let words = normalized.split_whitespace().collect::>(); - let has_word = |needle: &str| words.contains(&needle); - - ["plan", "planning", "roadmap", "strategy", "outline"] - .into_iter() - .any(has_word) - || normalized.contains("how should we") - || normalized.contains("before we start") - || normalized.contains("step by step") -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn planning_detector_matches_intent_without_substring_false_positives() { - assert!(looks_like_planning_prompt( - "Please outline a migration strategy" - )); - assert!(looks_like_planning_prompt("How should we approach this?")); - assert!(!looks_like_planning_prompt( - "Explain the planetary boundary" - )); - assert!(!looks_like_planning_prompt("Fix the failing test")); - } - #[test] fn session_and_lifetime_caps_keep_tips_quiet() { let mut state = BehavioralTipState::default(); - assert!(state.eligible(BehavioralTip::PlanningMode, 0)); - state.record_impression(BehavioralTip::PlanningMode); - assert!(!state.eligible(BehavioralTip::PlanningMode, 0)); + assert!(state.eligible(BehavioralTip::McpValidation, 0)); + state.record_impression(BehavioralTip::McpValidation); assert!(!state.eligible(BehavioralTip::McpValidation, 0)); + assert!(!state.eligible(BehavioralTip::BackgroundJobReceipt, 0)); let fresh_session = BehavioralTipState::default(); - assert!(fresh_session.eligible(BehavioralTip::PlanningMode, 1)); - assert!(!fresh_session.eligible(BehavioralTip::PlanningMode, MAX_LIFETIME_IMPRESSIONS)); + assert!(fresh_session.eligible(BehavioralTip::McpValidation, 1)); + assert!(!fresh_session.eligible(BehavioralTip::McpValidation, MAX_LIFETIME_IMPRESSIONS)); } #[test] @@ -288,7 +248,6 @@ mod tests { #[test] fn every_complete_locale_renders_tips_with_code_owned_controls() { let tips = [ - BehavioralTip::PlanningMode, BehavioralTip::BackgroundJobReceipt, BehavioralTip::ClearedInputRestore, BehavioralTip::McpValidation, @@ -302,10 +261,6 @@ mod tests { } } - assert_eq!( - BehavioralTip::PlanningMode.message(Locale::En), - "Planning? Tab cycles to Plan mode" - ); assert_eq!( BehavioralTip::BackgroundJobReceipt.message(Locale::En), "Receipts live in the Work panel — Enter opens the inspector" diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 6d56a8de21..8750fa4cbd 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -45,20 +45,34 @@ pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeActi } } -pub(crate) fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) { +/// Rows one PageUp/PageDown travels in the slash menu. Pages clamp at the +/// ends per the shared vocabulary instead of wrapping (#6290). +const SLASH_MENU_PAGE: usize = 10; + +/// Move the slash-menu selection by one shared-vocabulary motion (#6290). +/// Steps wrap; pages travel [`SLASH_MENU_PAGE`] rows and clamp. The menu is +/// single-column, so the region axis is a no-op. +pub(crate) fn move_slash_menu_selection( + app: &mut App, + entry_count: usize, + motion: crate::tui::list_nav::Motion, +) { if entry_count == 0 { return; } let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); - app.slash_menu_selected = (selected + entry_count - 1) % entry_count; + if let Some(next) = crate::tui::list_nav::apply(selected, entry_count, SLASH_MENU_PAGE, motion) + { + app.slash_menu_selected = next; + } +} + +pub(crate) fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) { + move_slash_menu_selection(app, entry_count, crate::tui::list_nav::Motion::Prev); } pub(crate) fn select_next_slash_menu_entry(app: &mut App, entry_count: usize) { - if entry_count == 0 { - return; - } - let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); - app.slash_menu_selected = (selected + 1) % entry_count; + move_slash_menu_selection(app, entry_count, crate::tui::list_nav::Motion::Next); } pub(crate) fn handle_composer_history_arrow( diff --git a/crates/tui/src/tui/display_refresh.rs b/crates/tui/src/tui/display_refresh.rs index 24463db794..7bb548ce1f 100644 --- a/crates/tui/src/tui/display_refresh.rs +++ b/crates/tui/src/tui/display_refresh.rs @@ -243,6 +243,7 @@ fn probe_macos() -> Result { fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64; fn CGDisplayModeRelease(mode: *mut std::ffi::c_void); } + // SAFETY: `mode` is null-checked and released after use. unsafe { let display = CGMainDisplayID(); let mode = CGDisplayCopyDisplayMode(display); diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs index a9a97d81ec..456b6517aa 100644 --- a/crates/tui/src/tui/file_picker.rs +++ b/crates/tui/src/tui/file_picker.rs @@ -482,12 +482,22 @@ impl FilePickerView { } } - fn move_selection(&mut self, delta: isize) { + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. This is a typing surface, so only the + /// typing-safe vocabulary applies — no letter alias may eat a query + /// character. `Prev`/`Next` wrap; paging and Home/End clamp. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { if self.filtered.is_empty() { - return; + return false; } - self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta); + let Some(next) = + crate::tui::list_nav::apply(self.selected, self.filtered.len(), VISIBLE_ROWS, motion) + else { + return false; + }; + self.selected = next; self.adjust_scroll(); + true } fn selected_path(&self) -> Option<&str> { @@ -527,6 +537,13 @@ impl ModalView for FilePickerView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // Movement keys come from the shared vocabulary (#6290), typing-safe + // set only. This match owns the filter's own keys. + if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc => ViewAction::Close, KeyCode::Enter => { @@ -536,22 +553,6 @@ impl ModalView for FilePickerView { } ViewAction::Close } - KeyCode::Up => { - self.move_selection(-1); - ViewAction::None - } - KeyCode::Down => { - self.move_selection(1); - ViewAction::None - } - KeyCode::PageUp => { - self.move_selection(-(VISIBLE_ROWS as isize)); - ViewAction::None - } - KeyCode::PageDown => { - self.move_selection(VISIBLE_ROWS as isize); - ViewAction::None - } KeyCode::Backspace => { self.query.pop(); self.selected = 0; @@ -584,11 +585,11 @@ impl ModalView for FilePickerView { fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { match mouse.kind { MouseEventKind::ScrollUp => { - self.move_selection(-1); + self.apply_motion(crate::tui::list_nav::Motion::Prev); ViewAction::None } MouseEventKind::ScrollDown => { - self.move_selection(1); + self.apply_motion(crate::tui::list_nav::Motion::Next); ViewAction::None } MouseEventKind::Down(MouseButton::Left) => { diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index aa0161f5dd..cf6f0cc5ac 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -53,11 +53,6 @@ pub(crate) fn maybe_log_provider_wait_incident(app: &mut App) { )); } -pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool { - let status = status.trim().to_ascii_lowercase(); - status.contains("requesting model response") -} - thread_local! { /// Objective summaries keyed by agent id (#6213 T7). The objective is /// immutable per agent, so `summarize_tool_output` — which JSON-parses the @@ -99,16 +94,22 @@ fn memoized_objective_summary(id: &str, objective: &str) -> Option { }) } -pub(crate) fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String { - if !is_noisy_subagent_progress(status) { +pub(crate) fn friendly_subagent_progress( + app: &App, + id: &str, + status: &str, + routine_wait: bool, +) -> String { + if !routine_wait { return summarize_tool_output(status); } if let Some(summary) = subagent_objective_summary(app, id) { return format!("working on {summary}"); } + // Stored entries are always friendly rewrites (the event handler stores + // `display`, never raw text), so no content check is needed here. if let Some(existing) = app.agent_progress.get(id) - && !is_noisy_subagent_progress(existing) && existing != "working" && existing != "in the current" { diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 231c3d7f1f..ec0076d2d6 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -1070,6 +1070,40 @@ impl ModelPickerView { } } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`MODEL_PAGE`] rows + /// and clamp. The region axis toggles between the model and effort panes. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + use crate::tui::list_nav::Motion; + if matches!(motion, Motion::RegionPrev | Motion::RegionNext) { + if self.can_edit_effort() { + self.toggle_focus(); + } + return true; + } + let (current, len) = match self.focus { + Pane::Model => (self.selected_model_idx, self.model_row_count()), + Pane::Effort => (self.selected_effort_idx, self.current_efforts().len()), + }; + if len == 0 { + return false; + } + let Some(next) = crate::tui::list_nav::apply(current, len, MODEL_PAGE, motion) else { + return false; + }; + match self.focus { + Pane::Model => { + self.selected_model_idx = next; + self.select_effort_for_current_model(); + } + Pane::Effort => { + self.selected_effort_idx = next; + self.selected_effort_request = self.resolved_effort(); + } + } + true + } + fn toggle_focus(&mut self) { self.focus = match self.focus { Pane::Model => Pane::Effort, @@ -3595,6 +3629,14 @@ impl ModalView for ModelPickerView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { self.last_mouse_selected = None; + // Movement keys come from the shared vocabulary (#6290); the match + // below owns only the picker's own verbs. The live filter means the + // typing-safe set — no letter aliases to eat the query. + if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Char('s' | 'S') if key.modifiers == KeyModifiers::CONTROL => { self.cycle_sort(); @@ -3700,58 +3742,6 @@ impl ModalView for ModelPickerView { self.update_query(query); ViewAction::None } - KeyCode::Up => { - self.move_up(); - ViewAction::None - } - KeyCode::Down => { - self.move_down(); - ViewAction::None - } - KeyCode::PageUp => { - for _ in 0..5 { - self.move_up(); - } - ViewAction::None - } - KeyCode::PageDown => { - for _ in 0..5 { - self.move_down(); - } - ViewAction::None - } - KeyCode::Home => { - match self.focus { - Pane::Model => { - self.selected_model_idx = 0; - self.select_effort_for_current_model(); - } - Pane::Effort => { - self.selected_effort_idx = 0; - self.selected_effort_request = self.resolved_effort(); - } - } - ViewAction::None - } - KeyCode::End => { - match self.focus { - Pane::Model => { - self.selected_model_idx = self.model_row_count().saturating_sub(1); - self.select_effort_for_current_model(); - } - Pane::Effort => { - self.selected_effort_idx = self.current_efforts().len().saturating_sub(1); - self.selected_effort_request = self.resolved_effort(); - } - } - ViewAction::None - } - KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => { - if self.can_edit_effort() { - self.toggle_focus(); - } - ViewAction::None - } // Explicit readiness + catalog refresh (safe, non-destructive). // Plain `r` remains a route-search character. KeyCode::Char('r') | KeyCode::Char('R') @@ -4026,6 +4016,10 @@ impl ModelPickerView { } } +/// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared +/// vocabulary instead of wrapping (#6290). +const MODEL_PAGE: usize = 5; + /// Previous index in a list that rotates: 0 wraps to the last row. /// `count` must be non-zero. fn wrapping_prev(index: usize, count: usize) -> usize { diff --git a/crates/tui/src/tui/plugin_suggestions.rs b/crates/tui/src/tui/plugin_suggestions.rs index 4dc6c5c2a8..b6b8fc3cb6 100644 --- a/crates/tui/src/tui/plugin_suggestions.rs +++ b/crates/tui/src/tui/plugin_suggestions.rs @@ -382,9 +382,9 @@ mod tests { let (mut app, _root, _home) = app_with_supabase_plugin(); if plugin_first { assert!(app.maybe_nudge_plugin_for_prompt("add supabase auth")); - assert!(!app.maybe_show_behavioral_tip(BehavioralTip::PlanningMode)); + assert!(!app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); } else { - assert!(app.maybe_show_behavioral_tip(BehavioralTip::PlanningMode)); + assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth")); } assert_eq!(app.status_toasts.len(), 1); diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index b4e9f23ec6..77e67d5181 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -5470,9 +5470,14 @@ mod tests { .find(|row| row.provider == ApiProvider::Deepseek) .expect("DeepSeek row"); assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked); + // Readiness is per route identity (provider + endpoint + auth class + + // model), so record the check against the row's own default-route + // model. A hardcoded model literal goes stale whenever the provider's + // default route moves — which is exactly what happened here. + let row_model = row.default_route.logical_model.clone(); let mut health = ProviderReadinessSnapshot::default(); - health.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro"); + health.record_success(&config, ApiProvider::Deepseek, &row_model); let ready = ProviderPickerView::new(ApiProvider::Deepseek, &config).with_provider_health(&health); assert_eq!( @@ -5488,7 +5493,7 @@ mod tests { health.record_failure_message( &config, ApiProvider::Deepseek, - "deepseek-v4-pro", + &row_model, crate::error_taxonomy::ErrorCategory::Authentication, "credential rejected", ); diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 4008c9b1a8..1753e6cbdd 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -315,28 +315,24 @@ impl SessionPickerView { self.refresh_preview(); } - fn move_selection(&mut self, delta: isize) { - self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta); - self.ensure_selected_visible(); - self.refresh_preview(); - } - - /// Page the session list by one viewport (#6014). Clamped, not wrapped: - /// PgDn near the end lands on the last row, not back at the top. - fn page_selection(&mut self, direction: isize) { + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. `Prev`/`Next` wrap (existing behavior); + /// paging and Home/End clamp — a paging key asks to travel, not to + /// teleport. The horizontal axis has nowhere to go on this surface. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { if self.filtered.is_empty() { - return; + return false; } let page = self.list_visible_rows.get().max(1); - self.selected = if direction.is_negative() { - self.selected.saturating_sub(page) - } else { - self.selected - .saturating_add(page) - .min(self.filtered.len() - 1) + let Some(next) = + crate::tui::list_nav::apply(self.selected, self.filtered.len(), page, motion) + else { + return false; }; + self.selected = next; self.ensure_selected_visible(); self.refresh_preview(); + true } fn select_visible_shortcut(&mut self, c: char) -> bool { @@ -650,8 +646,12 @@ impl ModalView for SessionPickerView { fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { match mouse.kind { - MouseEventKind::ScrollUp => self.move_selection(-1), - MouseEventKind::ScrollDown => self.move_selection(1), + MouseEventKind::ScrollUp => { + self.apply_motion(crate::tui::list_nav::Motion::Prev); + } + MouseEventKind::ScrollDown => { + self.apply_motion(crate::tui::list_nav::Motion::Next); + } MouseEventKind::Down(MouseButton::Left) => { let clicked = self .last_row_hitboxes @@ -747,37 +747,33 @@ impl ModalView for SessionPickerView { } } + // Shift-modified paging belongs to the preview pane (#6014) and must + // be claimed before the shared vocabulary sees the bare keys. + if key.modifiers.contains(KeyModifiers::SHIFT) { + match key.code { + KeyCode::PageUp => { + let rows = self.history_visible_rows.get().max(1); + self.scroll_history(-(rows as isize)); + return ViewAction::None; + } + KeyCode::PageDown => { + let rows = self.history_visible_rows.get().max(1); + self.scroll_history(rows as isize); + return ViewAction::None; + } + _ => {} + } + } + // Movement keys come from the shared vocabulary (#6290); this match + // owns only the picker's own verbs. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } + match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.move_selection(-1); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_selection(1); - ViewAction::None - } - // PgUp/PgDn page the session list — the picker's primary - // navigation target (#6014). The history preview keeps keyboard - // paging on Shift+PgUp/PgDn. - KeyCode::PageUp if key.modifiers.contains(KeyModifiers::SHIFT) => { - let rows = self.history_visible_rows.get().max(1); - self.scroll_history(-(rows as isize)); - ViewAction::None - } - KeyCode::PageDown if key.modifiers.contains(KeyModifiers::SHIFT) => { - let rows = self.history_visible_rows.get().max(1); - self.scroll_history(rows as isize); - ViewAction::None - } - KeyCode::PageUp => { - self.page_selection(-1); - ViewAction::None - } - KeyCode::PageDown => { - self.page_selection(1); - ViewAction::None - } KeyCode::Char('/') => { self.enter_search(); ViewAction::None @@ -1706,6 +1702,30 @@ mod tests { assert_eq!(view.selected, 11); } + /// #6290 step 2: the picker navigates on the shared `list_nav` vocabulary, + /// so Home/End exist here and the `j`/`k` aliases keep working — the two + /// behaviors this surface previously hand-rolled (or lacked). + #[test] + fn home_end_and_letter_aliases_come_from_the_shared_vocabulary() { + let sessions: Vec = (0..20) + .map(|i| test_session(i, &format!("work {i}"))) + .collect(); + let mut view = picker_with(sessions, None); + for session in &view.sessions { + view.preview_cache + .insert(session.id.clone(), vec!["preview".to_string()]); + } + + view.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(view.selected, 19, "End lands on the last row"); + view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert_eq!(view.selected, 0, "Home lands on the first row"); + view.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(view.selected, 1, "`j` is still the Down alias"); + view.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(view.selected, 0, "`k` is still the Up alias"); + } + #[test] fn shift_page_keys_still_scroll_the_history_preview() { let mut view = picker_with(vec![test_session(1, "only session")], None); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8d40f6768e..fa00fc12c9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -90,7 +90,7 @@ use crate::tui::composer_ui::*; use crate::tui::context_inspector::ContextInspectorView; use crate::tui::event_broker::EventBroker; use crate::tui::file_picker_relevance; -use crate::tui::footer_ui::{friendly_subagent_progress, is_noisy_subagent_progress}; +use crate::tui::footer_ui::friendly_subagent_progress; use crate::tui::format_helpers; use crate::tui::hotbar::actions::HotbarDispatch; use crate::tui::key_shortcuts; diff --git a/crates/tui/src/tui/ui/dispatch.rs b/crates/tui/src/tui/ui/dispatch.rs index 44f53994bc..05cb97b408 100644 --- a/crates/tui/src/tui/ui/dispatch.rs +++ b/crates/tui/src/tui/ui/dispatch.rs @@ -576,7 +576,6 @@ pub(crate) fn prepare_user_dispatch( message: QueuedMessage, ) -> Result { anyhow::ensure!(!app.redaction_gate, "{INITIAL_PROMPT_DEFERRED_STATUS}"); - let _ = app.maybe_nudge_for_planning_prompt(&message.display); let _ = app.maybe_nudge_plugin_for_prompt(&message.display); // Plan paused-command changes without touching App or the engine pause @@ -766,7 +765,6 @@ pub(crate) async fn spawned_dispatch_inner( reasoning_effort: prepare.reasoning_effort, mode: prepare.mode, content: &prepare.content, - display_text: &prepare.message.display, auto_router_context: &prepare.auto_router_context, should_auto_resolve: prepare.should_auto_resolve, allow_auto_router_response_cache: true, diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 59d457505a..dafa85b329 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -3412,9 +3412,12 @@ pub(crate) async fn run_event_loop( ) => { let display = bound_agent_activity_text(&friendly_subagent_progress( - app, &id, &status, + app, + &id, + &status, + activity.routine_wait, )); - if is_noisy_subagent_progress(&status) { + if activity.routine_wait { app.agent_progress .entry(id.clone()) .or_insert_with(|| display.clone()); @@ -6272,6 +6275,26 @@ pub(crate) async fn run_event_loop( { select_next_slash_menu_entry(app, slash_menu_entries.len()); } + // Paging and edge motions from the shared vocabulary (#6290), + // claimed before the unconditional transcript-scroll arms. + KeyCode::PageUp if key.modifiers.is_empty() && slash_menu_open => { + move_slash_menu_selection( + app, + slash_menu_entries.len(), + crate::tui::list_nav::Motion::PagePrev, + ); + } + KeyCode::PageDown if key.modifiers.is_empty() && slash_menu_open => { + move_slash_menu_selection( + app, + slash_menu_entries.len(), + crate::tui::list_nav::Motion::PageNext, + ); + } + // Home/End deliberately stay cursor keys while the menu is open: + // the composer is still the focused input (same as Left/Right + // and the mention menu), so only vertical travel belongs to + // the popup. KeyCode::Down if key.modifiers.is_empty() && app.selected_composer_attachment_index().is_some() => diff --git a/crates/tui/src/tui/ui/fatal_signal_guard.rs b/crates/tui/src/tui/ui/fatal_signal_guard.rs index 5f68fca0ef..5586af892a 100644 --- a/crates/tui/src/tui/ui/fatal_signal_guard.rs +++ b/crates/tui/src/tui/ui/fatal_signal_guard.rs @@ -68,6 +68,7 @@ pub(crate) fn install_fatal_signal_guard() { #[cfg(unix)] { // Piped/embedded surfaces must never receive escape bytes. + // SAFETY: isatty(2) dereferences no pointers. if unsafe { libc::isatty(libc::STDOUT_FILENO) } == 0 { tracing::debug!("Fatal-signal terminal guard skipped: stdout is not a TTY"); return; @@ -90,6 +91,7 @@ pub(crate) fn install_fatal_signal_guard() { } } for signal in [libc::SIGABRT, libc::SIGBUS, libc::SIGILL, libc::SIGFPE] { + // SAFETY: ABRT/BUS/ILL/FPE all terminate by default. unsafe { install_handler(signal) }; } tracing::debug!("Fatal-signal terminal guard installed (ABRT/BUS/ILL/FPE)"); @@ -112,6 +114,7 @@ pub(crate) fn install_fatal_signal_guard() { /// exists and are never written again). #[cfg(unix)] unsafe extern "C" fn fatal_signal_handler(signal: libc::c_int) { + // SAFETY: signal-safe syscalls only, per the contract above. unsafe { // 1. Restore the terminal: stdout first, stderr as fallback. let mut written: usize = 0; @@ -188,6 +191,7 @@ unsafe extern "C" fn fatal_signal_handler(signal: libc::c_int) { /// `signal` must be a fatal signal whose default action is to terminate. #[cfg(unix)] unsafe fn install_handler(signal: libc::c_int) { + // SAFETY: `action` is live and zeroed; oldact is null. unsafe { // Zero the whole struct then set our two fields: the remaining // members (empty signal mask; any hidden per-OS plumbing like the diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index b8e9c78897..07c01cc7e8 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -837,7 +837,6 @@ pub(crate) async fn build_preview_request_inputs( reasoning_effort: app.reasoning_effort, mode: app.mode, content: &content, - display_text: &prompt, auto_router_context: &auto_router::recent_auto_router_context(&app.api_messages), should_auto_resolve: false, allow_auto_router_response_cache: false, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 639d4eae5c..d2cbf1f182 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -16004,8 +16004,8 @@ fn turn_started_route_is_captured_before_cancel_suppression() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ShortRequest, + reason: crate::model_routing::AutoRouteReason::LocalFallback( + crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault, ), }); let created_at = chrono::Utc::now(); @@ -16695,6 +16695,30 @@ fn slash_menu_down_wraps_from_last_to_first() { assert_eq!(app.input, "/"); } +#[test] +fn slash_menu_paging_clamps_instead_of_wrapping() { + use crate::tui::list_nav::Motion; + let mut app = create_test_app(); + app.input = "/".to_string(); + app.cursor_position = 1; + + let entries = visible_slash_menu_entries(&app, 128); + assert!(entries.len() > 1); + let last = entries.len() - 1; + + // Pages travel and clamp (#6290); only steps wrap. + app.slash_menu_selected = 0; + move_slash_menu_selection(&mut app, entries.len(), Motion::PageNext); + assert_eq!(app.slash_menu_selected, 10.min(last)); + move_slash_menu_selection(&mut app, entries.len(), Motion::PageNext); + assert_eq!(app.slash_menu_selected, 20.min(last)); + move_slash_menu_selection(&mut app, entries.len(), Motion::Last); + assert_eq!(app.slash_menu_selected, last); + move_slash_menu_selection(&mut app, entries.len(), Motion::First); + assert_eq!(app.slash_menu_selected, 0); + assert_eq!(app.input, "/"); +} + #[test] fn apply_slash_menu_selection_appends_space_for_arg_commands() { let mut app = create_test_app(); @@ -20950,7 +20974,7 @@ fn auto_route_receipt_survives_session_snapshot_and_restore() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( + reason: crate::model_routing::AutoRouteReason::LocalFallback( crate::model_routing::AutoRouteHeuristicReason::ComplexRequest, ), }; @@ -23614,12 +23638,35 @@ fn noisy_subagent_progress_keeps_existing_objective_summary() { "starting: inspect release state".to_string(), ); - let display = - friendly_subagent_progress(&app, "agent_live", "step 1/8: requesting model response"); + let display = friendly_subagent_progress( + &app, + "agent_live", + "step 1/8: requesting model response", + true, + ); assert_eq!(display, "starting: inspect release state"); } +#[test] +fn informative_waits_show_their_text_rather_than_rewriting() { + // Retry/timeout waits share the ModelWait status but carry informative + // text: the producer leaves routine_wait false and the footer shows the + // message instead of rewriting it to the objective summary. + let app = create_test_app(); + let display = friendly_subagent_progress( + &app, + "agent_live", + "step 1/8: API call timed out after 30000ms; retrying API request 1/3 in 500ms", + false, + ); + + assert!( + display.contains("retrying API request"), + "informative wait must stay visible: {display}" + ); +} + /// Regression for issue #65: `truncate_line_to_width` with a tiny budget /// must respect display widths, not codepoint counts. The old branch counted /// chars and overran the budget for any double-width grapheme, which diff --git a/crates/tui/src/tui/views/fleet_list.rs b/crates/tui/src/tui/views/fleet_list.rs index 3766e73cee..dfb668a9e4 100644 --- a/crates/tui/src/tui/views/fleet_list.rs +++ b/crates/tui/src/tui/views/fleet_list.rs @@ -37,6 +37,10 @@ use crate::tui::views::{ use codewhale_localization::{Locale, MessageId, tr}; use codewhale_palette as palette; +/// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared +/// vocabulary instead of wrapping (#6290). +const FLEET_LIST_PAGE: usize = 10; + /// What the host should do after this view acted on the store. #[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] // OpenDetail/None are reserved for the qualified-name flow @@ -132,6 +136,23 @@ impl FleetListView { self.hovered_row.set(None); } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`FLEET_LIST_PAGE`] + /// rows and clamp. The list is single-column, so the region axis is a + /// no-op. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + let len = self.entries.len(); + if len == 0 { + return false; + } + let Some(next) = crate::tui::list_nav::apply(self.row, len, FLEET_LIST_PAGE, motion) else { + return false; + }; + self.row = next; + self.hovered_row.set(None); + true + } + fn hit_row(&self, mouse: MouseEvent) -> Option { let position = ratatui::layout::Position::new(mouse.column, mouse.row); self.row_hitboxes @@ -233,12 +254,10 @@ impl ModalView for FleetListView { } match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.move_row(-1); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_row(1); + // Movement keys come from the shared vocabulary (#6290), j/k + // aliases included — this surface captures no text. Pages are new + // here; they used to do nothing. + _ if crate::tui::list_nav::motion(&key).is_some_and(|m| self.apply_motion(m)) => { ViewAction::None } KeyCode::Enter => { @@ -325,16 +344,6 @@ impl ModalView for FleetListView { }), } } - KeyCode::Home => { - self.row = 0; - self.hovered_row.set(None); - ViewAction::None - } - KeyCode::End => { - self.row = self.entries.len().saturating_sub(1); - self.hovered_row.set(None); - ViewAction::None - } _ => ViewAction::None, } } diff --git a/crates/tui/src/tui/views/fleet_roster.rs b/crates/tui/src/tui/views/fleet_roster.rs index 5db2b5f22a..0a6fdfd077 100644 --- a/crates/tui/src/tui/views/fleet_roster.rs +++ b/crates/tui/src/tui/views/fleet_roster.rs @@ -25,7 +25,7 @@ use std::cell::{Cell, RefCell}; -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, @@ -41,6 +41,10 @@ use crate::fleet::roster::{FleetRoster, ProfileLayer, ProfileOrigin, layers_from use crate::fleet::worker_runtime::roster_member_agent_type; use crate::tui::app::App; use crate::tui::menu_style; + +/// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared +/// vocabulary instead of wrapping (#6290). +const FLEET_ROSTER_PAGE: usize = 10; use crate::tui::views::{ ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer, truncate_view_text, @@ -257,6 +261,41 @@ impl FleetRosterView { self.hovered_row.set(None); } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`FLEET_ROSTER_PAGE`] + /// rows and clamp. The region axis is declined so Tab keeps opening the + /// workers view through the explicit arm below. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + use crate::tui::list_nav::Motion; + match motion { + Motion::Prev => { + self.move_up(); + true + } + Motion::Next => { + self.move_down(); + true + } + Motion::RegionPrev | Motion::RegionNext => false, + _ => { + let count = self.row_count(); + if count == 0 { + return false; + } + let Some(next) = + crate::tui::list_nav::apply(self.selected, count, FLEET_ROSTER_PAGE, motion) + else { + return false; + }; + self.selected = next; + self.detail_scroll = 0; + self.last_mouse_selected = None; + self.hovered_row.set(None); + true + } + } + } + fn select_row(&mut self, row: usize) { self.selected = row.min(self.row_count().saturating_sub(1)); self.detail_scroll = 0; @@ -320,16 +359,34 @@ impl ModalView for FleetRosterView { // A keyboard gesture ends any pending mouse double-click sequence so // a later single click can never activate a stale row. self.last_mouse_selected = None; + // Shift-modified paging scrolls the detail pane; bare keys drive the + // row list through the shared vocabulary (#6290, #6014-style split). + if key.modifiers.contains(KeyModifiers::SHIFT) { + match key.code { + KeyCode::PageUp => { + self.detail_scroll = self.detail_scroll.saturating_sub(8); + return ViewAction::None; + } + KeyCode::PageDown => { + self.detail_scroll = self.detail_scroll.saturating_add(8); + return ViewAction::None; + } + KeyCode::Home => { + self.detail_scroll = 0; + return ViewAction::None; + } + _ => {} + } + } + // Movement keys come from the shared vocabulary (#6290), j/k aliases + // included — this surface captures no text. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } KeyCode::Enter => self.activate_selected(), // #5954: the roster stays on the stack under the view it opens, // so `Esc` in workers / saved teams pops back here instead of @@ -339,18 +396,6 @@ impl ModalView for FleetRosterView { ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested) } KeyCode::Char('f') => ViewAction::Emit(ViewEvent::FleetRosterOpenFleetsRequested), - KeyCode::Home => { - self.detail_scroll = 0; - ViewAction::None - } - KeyCode::PageUp => { - self.detail_scroll = self.detail_scroll.saturating_sub(8); - ViewAction::None - } - KeyCode::PageDown => { - self.detail_scroll = self.detail_scroll.saturating_add(8); - ViewAction::None - } _ => ViewAction::None, } } diff --git a/crates/tui/src/tui/views/fleet_roster/tests.rs b/crates/tui/src/tui/views/fleet_roster/tests.rs index b22ecfb25f..46087c03aa 100644 --- a/crates/tui/src/tui/views/fleet_roster/tests.rs +++ b/crates/tui/src/tui/views/fleet_roster/tests.rs @@ -188,12 +188,25 @@ fn arrows_move_selection_and_wrap() { #[test] fn selection_change_resets_detail_scroll() { let mut view = built_in_view(); - view.handle_key(key(KeyCode::PageDown)); + // Bare paging drives the row list; Shift-modified paging scrolls the + // detail pane (#6290, #6014-style split). + view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)); assert_eq!(view.detail_scroll, 8); view.handle_key(key(KeyCode::Down)); assert_eq!(view.detail_scroll, 0); } +#[test] +fn bare_paging_drives_rows_not_the_detail_pane() { + let mut view = built_in_view(); + let last = view.members.len(); + view.handle_key(key(KeyCode::PageDown)); + assert_eq!(view.detail_scroll, 0); + assert_eq!(view.selected, 10.min(last)); + view.handle_key(key(KeyCode::Home)); + assert_eq!(view.selected, 0); +} + #[test] fn enter_opens_the_setup_wizard_for_members_only() { // Operator row: display-only, no wizard hand-off. diff --git a/crates/tui/src/tui/views/fleet_setup.rs b/crates/tui/src/tui/views/fleet_setup.rs index 80b9d2b3b8..7d3e0affb1 100644 --- a/crates/tui/src/tui/views/fleet_setup.rs +++ b/crates/tui/src/tui/views/fleet_setup.rs @@ -52,6 +52,12 @@ use codewhale_palette as palette; const PROFILE_DIR: &str = ".codewhale/agents"; +/// Rows one PageUp/PageDown travels on choice steps. Pages clamp at the ends +/// per the shared vocabulary instead of wrapping (#6290). +const SETUP_PAGE: usize = 10; +/// Lines one PageUp/PageDown scrolls on the Review step (unchanged). +const REVIEW_SCROLL_PAGE: usize = 8; + /// The only two truthful destinations for `/fleet setup`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum FleetSetupEditTarget { @@ -1304,6 +1310,79 @@ impl FleetSetupView { } } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`SETUP_PAGE`] rows + /// and clamp. On the Review step the same keys scroll the proof pane + /// instead — it has no row list — and render clamps the offset. The + /// region axis is declined so Tab/Left/Right keep their explicit wizard + /// arms below. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + use crate::tui::list_nav::Motion; + match motion { + Motion::Prev => { + self.move_up(); + true + } + Motion::Next => { + self.move_down(); + true + } + Motion::RegionPrev | Motion::RegionNext => false, + _ => { + if self.step == Step::Review { + match motion { + Motion::PagePrev => { + self.review_scroll = + self.review_scroll.saturating_sub(REVIEW_SCROLL_PAGE); + } + Motion::PageNext => { + self.review_scroll = + self.review_scroll.saturating_add(REVIEW_SCROLL_PAGE); + } + Motion::First => self.review_scroll = 0, + // Render clamps to the content height. + Motion::Last => self.review_scroll = usize::MAX, + _ => return false, + } + return true; + } + let len = self.step_len(); + if len == 0 { + return false; + } + let current = match self.step { + Step::Role => self.role_idx, + Step::Model => self.model_idx, + Step::Destination => self.destination_idx, + _ => return false, + }; + let Some(next) = crate::tui::list_nav::apply(current, len, SETUP_PAGE, motion) + else { + return false; + }; + match self.step { + Step::Role => { + self.role_idx = next; + self.discard_model_draft(); + self.composition_decision = CompositionDecision::Pending; + } + Step::Model => { + self.model_idx = next; + self.discard_model_draft(); + if self.composition_decision != CompositionDecision::Pending { + self.composition_decision = CompositionDecision::Edited; + } + } + Step::Destination => { + self.destination_idx = next; + } + _ => {} + } + true + } + } + } + /// Re-stat the profile directory. Called on the two transitions that can /// change the answer — entering Review, and toggling project/user scope — /// so the Review step never touches the filesystem while painting. @@ -1612,6 +1691,13 @@ impl ModalView for FleetSetupView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { // Model-step filter input captures keystrokes while active (#4639). if self.step == Step::Model && self.model_filter_active { + // Typing-safe movement set: pages and edges work while filtering + // without letter aliases eating the query (#6290). + if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Enter => { self.model_filter_active = false; @@ -1628,12 +1714,6 @@ impl ModalView for FleetSetupView { self.composition_decision = CompositionDecision::Edited; } } - KeyCode::Up => { - self.move_up(); - } - KeyCode::Down => { - self.move_down(); - } KeyCode::Char(ch) if !key.modifiers.intersects( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, @@ -1654,6 +1734,14 @@ impl ModalView for FleetSetupView { if !matches!(key.code, KeyCode::Null) { self.notice = None; } + // Movement keys come from the shared vocabulary (#6290), j/k aliases + // included; the region axis is declined so Tab/Left/Right keep their + // explicit wizard arms, and letter verbs below are unaffected. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc if self.step != Step::Role => self.back(), KeyCode::Esc => ViewAction::Close, @@ -1691,14 +1779,6 @@ impl ModalView for FleetSetupView { self.model_filter_active = true; ViewAction::None } - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } // Secondary accelerator: jump to the Destination step. The primary // way to change the destination is the focused Review control. KeyCode::Char('s') if self.step == Step::Review => { @@ -1735,18 +1815,6 @@ impl ModalView for FleetSetupView { } KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.advance(), KeyCode::Left | KeyCode::Char('h') => self.back(), - KeyCode::Home => { - self.review_scroll = 0; - ViewAction::None - } - KeyCode::PageUp => { - self.review_scroll = self.review_scroll.saturating_sub(8); - ViewAction::None - } - KeyCode::PageDown => { - self.review_scroll = self.review_scroll.saturating_add(8); - ViewAction::None - } _ => ViewAction::None, } } diff --git a/crates/tui/src/tui/views/mode_picker.rs b/crates/tui/src/tui/views/mode_picker.rs index 287c1ac0b7..c0c35e8cd5 100644 --- a/crates/tui/src/tui/views/mode_picker.rs +++ b/crates/tui/src/tui/views/mode_picker.rs @@ -53,12 +53,21 @@ impl ModePickerView { .unwrap_or(AppMode::Agent) } - fn move_up(&mut self) { - self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), -1); - } - - fn move_down(&mut self) { - self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), 1); + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Vertical motions cover the whole list — + /// Home/End and the page keys included. The horizontal axis has nowhere + /// to go on a single-column surface, so those motions are not consumed. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + let Some(next) = crate::tui::list_nav::apply( + self.cursor, + VISIBLE_MODES.len(), + VISIBLE_MODES.len(), + motion, + ) else { + return false; + }; + self.cursor = next; + true } fn select_by_number(&mut self, number: char) -> Option { @@ -82,19 +91,20 @@ impl ModalView for ModePickerView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // Movement keys come from the shared vocabulary (#6290), so + // `j`/`k`, Home/End and the page keys mean here exactly what they + // mean on every other list. This match owns only the keys the + // vocabulary does not claim. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc => ViewAction::Close, KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected { mode: self.selected_mode(), }), - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None), _ => ViewAction::None, } @@ -103,11 +113,11 @@ impl ModalView for ModePickerView { fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { match mouse.kind { MouseEventKind::ScrollUp => { - self.move_up(); + self.apply_motion(crate::tui::list_nav::Motion::Prev); ViewAction::None } MouseEventKind::ScrollDown => { - self.move_down(); + self.apply_motion(crate::tui::list_nav::Motion::Next); ViewAction::None } MouseEventKind::Down(MouseButton::Left) => { diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index de9c9e5f31..e3894e1194 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -76,22 +76,22 @@ impl StatusPickerView { .collect() } - fn move_up(&mut self) { - if self.rows.is_empty() { - return; - } - if self.cursor == 0 { - self.cursor = self.rows.len() - 1; - } else { - self.cursor -= 1; - } - } - - fn move_down(&mut self) { - if self.rows.is_empty() { - return; - } - self.cursor = (self.cursor + 1) % self.rows.len(); + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Vertical motions wrap at the ends for + /// Prev/Next and clamp for paging and Home/End; the horizontal axis does + /// not exist on this single-column checklist. The checklist fits on one + /// screen, so a page is the whole list. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + let Some(next) = crate::tui::list_nav::apply( + self.cursor, + self.rows.len(), + self.rows.len().max(1), + motion, + ) else { + return false; + }; + self.cursor = next; + true } fn toggle_current(&mut self) { @@ -132,6 +132,14 @@ impl ModalView for StatusPickerView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // Movement keys come from the shared vocabulary (#6290): `j`/`k`, + // Home/End and the page keys mean here what they mean on every other + // list. This match owns only the checklist's own verbs. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc => { // Roll the live preview back to the snapshot so Esc means @@ -139,14 +147,6 @@ impl ModalView for StatusPickerView { ViewAction::EmitAndClose(self.revert_event()) } KeyCode::Enter => ViewAction::EmitAndClose(self.final_event()), - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } KeyCode::Char(' ') | KeyCode::Char('x') | KeyCode::Char('X') => { self.toggle_current(); ViewAction::Emit(self.live_preview_event()) @@ -338,7 +338,8 @@ mod tests { let active = StatusItem::default_footer(); let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - view.move_down(); + // Move through the shared vocabulary, the same path a key takes. + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); match action { diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 996b7cbfda..751a342b25 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -640,6 +640,14 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool true } +/// Known limitation: this still matches on message text, while the footer +/// path keys off the structured `routine_wait` flag (#6290). The mailbox is +/// a stable cross-crate (`protocol`) surface whose payloads may come from +/// another binary, so this arm cannot assume the flag exists — and no +/// in-crate producer sends routine waits here anyway (only "queued" and +/// "running" texts), so threading the flag would change nothing. If a future +/// producer sends routine waits over the mailbox, give `Progress` the flag +/// and match on it here instead of extending this list. fn is_low_signal_progress(status: &str) -> bool { let status = status.trim().to_ascii_lowercase(); status.contains("requesting model response") diff --git a/crates/tui/src/tui/window_control.rs b/crates/tui/src/tui/window_control.rs index 4d0afc077a..a80eab92ac 100644 --- a/crates/tui/src/tui/window_control.rs +++ b/crates/tui/src/tui/window_control.rs @@ -222,7 +222,9 @@ mod imp { // nothing. Skip it entirely and resolve the real host window. let conpty = std::env::var("WT_SESSION").is_ok() || std::env::var("TERM_PROGRAM").is_ok(); if !conpty { + // SAFETY: no preconditions; invalid return handled. let hwnd = unsafe { GetConsoleWindow() }; + // SAFETY: invalid handles return false. if !hwnd.is_invalid() && unsafe { IsWindowVisible(hwnd) }.as_bool() { tracing::debug!("window_control: host window resolved via GetConsoleWindow"); return Some(hwnd); @@ -248,11 +250,14 @@ mod imp { /// window). Visibility is required — a hidden foreground window cannot /// be the host the user is looking at. fn foreground_window_in_parent_chain(pid: u32) -> Option { + // SAFETY: no preconditions; invalid return handled. let foreground = unsafe { GetForegroundWindow() }; + // SAFETY: invalid handles return false. if foreground.is_invalid() || !unsafe { IsWindowVisible(foreground) }.as_bool() { return None; } let mut fg_pid = 0u32; + // SAFETY: `fg_pid` is live for the call. unsafe { GetWindowThreadProcessId(foreground, Some(&mut fg_pid)); } @@ -308,12 +313,14 @@ mod imp { /// Look up a process's parent PID and image name from a toolhelp /// snapshot. The snapshot handle is always closed. fn process_entry(pid: u32) -> Option<(u32, String)> { + // SAFETY: returned handle is owned here; closed below. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?; let mut entry = PROCESSENTRY32W { dwSize: size_of::() as u32, ..Default::default() }; let mut found = None; + // SAFETY: `entry` is live with dwSize initialized above. let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }.is_ok(); while ok { if entry.th32ProcessID == pid { @@ -326,8 +333,10 @@ mod imp { found = Some((entry.th32ParentProcessID, name)); break; } + // SAFETY: `entry` is live with dwSize initialized above. ok = unsafe { Process32NextW(snapshot, &mut entry) }.is_ok(); } + // SAFETY: `snapshot` is owned here and not used after. unsafe { let _ = CloseHandle(snapshot); } @@ -349,8 +358,10 @@ mod imp { found: None, }; unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + // SAFETY: lparam carries the live `ctx` below; EnumWindows is synchronous. let ctx = unsafe { &mut *(lparam.0 as *mut Ctx) }; let mut wpid = 0u32; + // SAFETY: `hwnd` is valid per EnumWindows; `wpid` is live. unsafe { GetWindowThreadProcessId(hwnd, Some(&mut wpid)); if wpid == ctx.target && IsWindowVisible(hwnd).as_bool() { @@ -364,6 +375,7 @@ mod imp { } BOOL(1) } + // SAFETY: `ctx` outlives the synchronous enumeration. unsafe { let _ = EnumWindows(Some(enum_proc), LPARAM(&mut ctx as *mut Ctx as isize)); } diff --git a/crates/tui/src/turn_route_plan.rs b/crates/tui/src/turn_route_plan.rs index 15d8503689..2706ca3bcb 100644 --- a/crates/tui/src/turn_route_plan.rs +++ b/crates/tui/src/turn_route_plan.rs @@ -41,9 +41,6 @@ pub(crate) struct TurnRoutePlanRequest<'a> { /// Model-facing content of the next user message (file mentions and skill /// wrapping already resolved). This is what the auto router classifies. pub(crate) content: &'a str, - /// The user's display text, used by the heuristic and auto-reasoning - /// fallbacks exactly as production does. - pub(crate) display_text: &'a str, pub(crate) auto_router_context: &'a str, pub(crate) should_auto_resolve: bool, /// Production dispatch may use the deterministic response cache for the @@ -88,8 +85,9 @@ pub(crate) enum TurnRoutingSource { ActiveFixedRoute, /// Auto model routing used its provider-backed classifier. AutoProviderClassifier, - /// Auto model routing used the local deterministic fallback heuristic. - AutoLocalHeuristic, + /// Auto model routing fell back to the local declared default (no + /// classifier signal; request wording never inspected). + AutoLocalFallback, } impl TurnRoutingSource { @@ -97,7 +95,7 @@ impl TurnRoutingSource { match self { Self::ActiveFixedRoute => "active-fixed-route", Self::AutoProviderClassifier => "auto-provider-classifier", - Self::AutoLocalHeuristic => "auto-local-heuristic", + Self::AutoLocalFallback => "auto-local-fallback", } } } @@ -170,13 +168,14 @@ pub(crate) async fn plan_turn_route( .map(|selection| selection.provider) .unwrap_or(request.api_provider); + // Without an Auto selection there is no per-request signal, so the + // route is the configured model — the same declared default the local + // fallback uses. Request wording is never inspected (#6290 rework). let effective_model = if request.auto_model { auto_selection .as_ref() .map(|selection| selection.model.clone()) - .unwrap_or_else(|| { - crate::model_routing::auto_model_heuristic(request.display_text, request.app_model) - }) + .unwrap_or_else(|| request.app_model.to_string()) } else { request.app_model.to_string() }; @@ -268,14 +267,14 @@ pub(crate) async fn plan_turn_route( // Model selection and reasoning selection are independent. A fixed // reasoning preference survives auto model routing and is normalized // against the concrete route below; only an explicit `auto` delegates the - // tier to the classifier/heuristic. + // tier to the classifier/declared fallback. let auto_controls_reasoning = request.reasoning_effort == ReasoningEffort::Auto; let selected_reasoning_effort = if auto_controls_reasoning { Some( auto_selection .as_ref() .and_then(|selection| selection.reasoning_effort) - .unwrap_or_else(|| crate::auto_reasoning::select(false, request.display_text)), + .unwrap_or_else(crate::auto_reasoning::select), ) } else { None @@ -295,7 +294,7 @@ pub(crate) async fn plan_turn_route( } else if auto_selection.is_some() { TurnRoutingSource::AutoProviderClassifier } else { - TurnRoutingSource::AutoLocalHeuristic + TurnRoutingSource::AutoLocalFallback }; Ok(PlannedTurnRoute { @@ -405,7 +404,6 @@ mod tests { reasoning_effort: ReasoningEffort::Low, mode: AppMode::Agent, content: "explain this function", - display_text: "explain this function", auto_router_context: "", should_auto_resolve: false, allow_auto_router_response_cache: false, @@ -417,10 +415,7 @@ mod tests { .await .expect("plan auto-model turn"); - assert_eq!( - planned.routing_source, - TurnRoutingSource::AutoLocalHeuristic - ); + assert_eq!(planned.routing_source, TurnRoutingSource::AutoLocalFallback); assert!(!planned.auto_controls_reasoning); assert_eq!(planned.selected_reasoning_effort, None); // First-party DeepSeek routes carry low as the real wire tier diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e6dfe8be80..25767a76a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -334,6 +334,11 @@ command = "echo 'Running tool: $TOOL_NAME'" are not wired into command execution. 5. **Minimal dependencies**: Careful dependency selection for build speed 6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access and are served by the `crates/tui` runtime today +7. **Lock poison**: fail-stop by default. A poisoned lock means a holder + panicked mid-mutation, so `.expect()` with a message naming the lock is + the standard posture — never serve half-updated state. Recover with + `into_inner()` only where stale state is safe (caches, idempotent + rebuilds), with a comment saying why. ## Configuration Files diff --git a/docs/MODES.md b/docs/MODES.md index 3e2a5711c0..490e7ce9a8 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -34,7 +34,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work`, - **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. - **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. Goals still require an explicit user request; approval, sandbox, repository law, and managed policy decide what may execute. -- **Operate**: manage a goal through planned steps and verified results. Fleet configures the same sub-agents and roles that execute those steps. It has the same primitive identities and execution authority as Work. When no unfinished goal exists, a direct work instruction (not a greeting, acknowledgement, question, quoted command, conversational followup, or request declining goal tracking) becomes the session goal automatically, with continuation on; the transcript shows `◆ goal set · Operate keeps working until it is verified · /goal to edit`. An explicit `/goal` declaration still wins, `/goal` still edits it, and an existing goal is never replaced. The parent session is the **operator**: handle small or tightly coupled tasks directly. Before multi-step delegation, state a compact plan with named steps, dependencies, bounded file scopes and a completion check, then run it through the existing Workflow tool. Parallelize independent steps; each phase receives the previous phase's results, and a dependent step cannot start when a required result is missing. A single bounded independent task can use a direct `agent` call. Reuse a worker with followup for corrections and report completed, blocked and next steps. **Dispatch is not completion** — write-capable children must return real verification evidence. The first Operate turn of a session appends this contract once as a user-role runtime message (append-only history, never the pinned system prompt), so Plan, Work, and Operate keep one shared prompt prefix. +- **Operate**: manage a goal through planned steps and verified results. Fleet configures the same sub-agents and roles that execute those steps. It has the same primitive identities and execution authority as Work. Goals are model-decided: the agent calls `create_goal` when a request is a durable objective, and `/goal` always works as the direct user control — the host never infers a goal from wording. Once a goal exists, the transcript shows `◆ goal set · Operate keeps working until it is verified · /goal to edit`. An explicit `/goal` declaration always wins, `/goal` still edits it, and an existing goal is never replaced. The parent session is the **operator**: handle small or tightly coupled tasks directly. Before multi-step delegation, state a compact plan with named steps, dependencies, bounded file scopes and a completion check, then run it through the existing Workflow tool. Parallelize independent steps; each phase receives the previous phase's results, and a dependent step cannot start when a required result is missing. A single bounded independent task can use a direct `agent` call. Reuse a worker with followup for corrections and report completed, blocked and next steps. **Dispatch is not completion** — write-capable children must return real verification evidence. The first Operate turn of a session appends this contract once as a user-role runtime message (append-only history, never the pinned system prompt), so Plan, Work, and Operate keep one shared prompt prefix. `Act` and `/mode act` remain compatibility aliases for Work. Saved settings still normalize to the internal value `agent`.