diff --git a/Cargo.lock b/Cargo.lock index c8eb2db..a8e669b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13253,6 +13253,16 @@ dependencies = [ "url", ] +[[package]] +name = "xai-grok-extra-ca" +version = "0.1.0" +dependencies = [ + "reqwest 0.12.24", + "rustls", + "tempfile", + "tracing", +] + [[package]] name = "xai-grok-hooks" version = "0.1.0" @@ -13282,6 +13292,7 @@ dependencies = [ "serde_json", "tracing", "xai-grok-auth", + "xai-grok-extra-ca", "xai-grok-sampler", "xai-grok-telemetry", "xai-grok-version", @@ -13348,6 +13359,7 @@ dependencies = [ "xai-computer-hub-sdk", "xai-file-utils", "xai-grok-config", + "xai-grok-extra-ca", "xai-grok-telemetry", "xai-grok-tools", "xai-grok-version", @@ -13419,7 +13431,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.114" +version = "0.2.116" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13509,7 +13521,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.114" +version = "0.2.116" dependencies = [ "anyhow", "clap", @@ -13685,6 +13697,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "xai-grok-extra-ca", "xai-grok-sampling-types", "xai-grok-test-support", "xai-grok-version", @@ -13773,7 +13786,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.114" +version = "0.2.116" dependencies = [ "agent-client-protocol", "anyhow", @@ -13879,6 +13892,7 @@ dependencies = [ "xai-grok-compaction", "xai-grok-config", "xai-grok-config-types", + "xai-grok-extra-ca", "xai-grok-hooks", "xai-grok-http", "xai-grok-mcp", @@ -14023,6 +14037,7 @@ dependencies = [ "xai-grok-auth", "xai-grok-config", "xai-grok-env", + "xai-grok-extra-ca", "xai-grok-sampler", "xai-grok-secrets", "xai-mixpanel", @@ -14123,6 +14138,7 @@ dependencies = [ "xai-file-utils", "xai-grok-config", "xai-grok-env", + "xai-grok-extra-ca", "xai-grok-sandbox", "xai-grok-test-support", "xai-grok-tools-api", @@ -14177,7 +14193,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.114" +version = "0.2.116" dependencies = [ "semver", ] diff --git a/Cargo.toml b/Cargo.toml index e1f0ed6..2932320 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/codegen/xai-grok-config", "crates/codegen/xai-grok-config-types", "crates/codegen/xai-grok-env", + "crates/codegen/xai-grok-extra-ca", "crates/codegen/xai-grok-hooks", "crates/codegen/xai-grok-http", "crates/codegen/xai-grok-markdown", @@ -289,6 +290,7 @@ xai-grok-auth = { path = "crates/codegen/xai-grok-auth" } xai-grok-config = { path = "crates/codegen/xai-grok-config" } xai-grok-config-types = { path = "crates/codegen/xai-grok-config-types" } xai-grok-env = { path = "crates/codegen/xai-grok-env" } +xai-grok-extra-ca = { path = "crates/codegen/xai-grok-extra-ca" } xai-grok-http = { path = "crates/codegen/xai-grok-http" } xai-grok-markdown = { path = "crates/codegen/xai-grok-markdown" } xai-grok-markdown-core = { path = "crates/codegen/xai-grok-markdown-core" } diff --git a/SOURCE_REV b/SOURCE_REV index bef75a4..6cab3b8 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -6372e41d828b8a6ee82c29e01a69e27ec895cca9 +2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39 diff --git a/clippy.toml b/clippy.toml index ab1e656..abab250 100644 --- a/clippy.toml +++ b/clippy.toml @@ -6,23 +6,22 @@ # TODO: remove after https://github.com/hyperium/tonic/issues/2253 fixed. large-error-threshold = 256 -# Ban raw canonicalize: on Windows, std/tokio canonicalize return verbatim -# `\\?\C:\...` paths that break external tools (git rejects them as clone -# destinations), leak into model prompt context, and poison path-equality -# keys. Use `dunce::canonicalize` (identical to std on Unix/macOS; strips the -# verbatim prefix on Windows when safely representable). For async contexts -# in xai-grok-tools, use the blessed helpers in `crate::util::fs`. +# Ban raw canonicalize: on Windows it returns verbatim `\\?\C:\...` paths that +# break external tools, leak into prompts, and poison path-equality keys. +# `dunce::canonicalize` keeps the verbatim form for paths it cannot simplify +# (over 260 chars, device names), so containment checks there fail closed. # -# Caveat: dunce keeps the verbatim form for paths it cannot safely simplify -# (notably > 260 chars or reserved device names), so prefix checks between two -# independently canonicalized paths can mismatch on Windows for very long -# paths (containment checks then fail closed). +# Ban raw child-process spawning: an unenrolled child outlives the session that +# started it, while an enrolled one dies with its scope. Allow with a reason +# where a child is waited on or deliberately detached. # -# Enforcement boundary: this ban is applied by the per-crate cargo clippy -# presubmits only — the Bazel lint aspect pins the repo-root //:clippy.toml, -# so Bazel-only crates (currently xai-coding-env) need manual vigilance. +# Enforced by `cargo clippy` and `just lint-rs`. The Bazel lint aspect pins the +# repo-root config instead, so crates this file does not reach carry a +# crate-level allow. disallowed-methods = [ { path = "std::fs::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use dunce::canonicalize" }, { path = "std::path::Path::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use dunce::canonicalize" }, { path = "tokio::fs::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use xai_grok_tools::util::fs helpers or spawn_blocking + dunce::canonicalize" }, + { path = "std::process::Command::spawn", reason = "an unenrolled child outlives its session; use xai_tty_utils::ProcessScope::enroll" }, + { path = "tokio::process::Command::spawn", reason = "an unenrolled child outlives its session; use xai_tty_utils::ProcessScope::enroll" }, ] diff --git a/crates/codegen/xai-chat-state/src/actor/tests.rs b/crates/codegen/xai-chat-state/src/actor/tests.rs index e858da6..c3eada8 100644 --- a/crates/codegen/xai-chat-state/src/actor/tests.rs +++ b/crates/codegen/xai-chat-state/src/actor/tests.rs @@ -468,6 +468,7 @@ async fn record_last_turn_usage_round_trip() { total_tokens: 1290, reasoning_tokens: 0, cached_prompt_tokens: 800, + cache_creation_prompt_tokens: 0, }; h.handle.record_last_turn_usage(usage.clone()); @@ -483,6 +484,7 @@ async fn record_last_turn_usage_round_trip() { total_tokens: 10000, reasoning_tokens: 0, cached_prompt_tokens: 0, + cache_creation_prompt_tokens: 0, }; h.handle.record_last_turn_usage(next); let got2 = h @@ -504,6 +506,7 @@ async fn prompt_usage_ledger_via_handle_resets_and_clears() { total_tokens: 12, reasoning_tokens: 0, cached_prompt_tokens: 0, + cache_creation_prompt_tokens: 0, }; let h = TestHarness::new(); diff --git a/crates/codegen/xai-chat-state/src/usage.rs b/crates/codegen/xai-chat-state/src/usage.rs index 17db0fe..1d67934 100644 --- a/crates/codegen/xai-chat-state/src/usage.rs +++ b/crates/codegen/xai-chat-state/src/usage.rs @@ -33,6 +33,7 @@ pub struct UsageTotals { pub input_tokens: u64, pub output_tokens: u64, pub cached_read_tokens: u64, + pub cache_creation_tokens: u64, pub reasoning_tokens: u64, pub model_calls: u64, pub api_duration_ms: u64, @@ -52,6 +53,7 @@ impl UsageTotals { input_tokens: u64::from(usage.prompt_tokens), output_tokens: u64::from(usage.completion_tokens), cached_read_tokens: u64::from(usage.cached_prompt_tokens), + cache_creation_tokens: u64::from(usage.cache_creation_prompt_tokens), reasoning_tokens: u64::from(usage.reasoning_tokens), model_calls: 1, api_duration_ms: api_duration_ms.unwrap_or(0), @@ -73,6 +75,7 @@ impl UsageTotals { input_tokens, output_tokens, cached_read_tokens, + cache_creation_tokens, reasoning_tokens, model_calls, api_duration_ms, @@ -82,6 +85,9 @@ impl UsageTotals { self.input_tokens = self.input_tokens.saturating_add(*input_tokens); self.output_tokens = self.output_tokens.saturating_add(*output_tokens); self.cached_read_tokens = self.cached_read_tokens.saturating_add(*cached_read_tokens); + self.cache_creation_tokens = self + .cache_creation_tokens + .saturating_add(*cache_creation_tokens); self.reasoning_tokens = self.reasoning_tokens.saturating_add(*reasoning_tokens); self.model_calls = self.model_calls.saturating_add(*model_calls); self.api_duration_ms = self.api_duration_ms.saturating_add(*api_duration_ms); @@ -157,6 +163,7 @@ mod tests { total_tokens: 999_999, reasoning_tokens: 0, cached_prompt_tokens: 0, + cache_creation_prompt_tokens: 0, } } diff --git a/crates/codegen/xai-fast-worktree/src/api.rs b/crates/codegen/xai-fast-worktree/src/api.rs index 3510d00..697ccd5 100644 --- a/crates/codegen/xai-fast-worktree/src/api.rs +++ b/crates/codegen/xai-fast-worktree/src/api.rs @@ -1975,6 +1975,7 @@ pub mod gc { #[test] fn is_pid_alive_false_for_reaped_child() { // A fully reaped child's pid is gone (ESRCH) and must read as dead. + #[allow(clippy::disallowed_methods)] // test fixture; the test reaps it let mut child = std::process::Command::new("true") .spawn() .expect("spawn `true`"); diff --git a/crates/codegen/xai-fast-worktree/src/auto_gc.rs b/crates/codegen/xai-fast-worktree/src/auto_gc.rs index 37157c5..8ad0f21 100644 --- a/crates/codegen/xai-fast-worktree/src/auto_gc.rs +++ b/crates/codegen/xai-fast-worktree/src/auto_gc.rs @@ -1,7 +1,7 @@ //! Throttled automatic worktree GC (feature `metadata`). use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::Result; @@ -9,7 +9,6 @@ use crate::CleanupReport; use crate::api::gc::{GcOptions, GcReport, age_path_enabled, gc_worktrees}; use crate::db::{ListFilter, WorktreeDb, WorktreeKind, now_epoch_secs, resolve_grok_home}; use crate::discovery::{RebuildReport, rebuild_worktree_db}; -use crate::git::checkout::git_command; pub const META_LAST_AUTO_GC_AT: &str = "last_auto_gc_at"; /// Independent throttle stamp for optional DB rebuild (not shared with GC). @@ -58,7 +57,7 @@ pub struct WorktreeAutoGcLayer { pub dry_run: Option, pub include_orphan_snapshots: Option, pub max_age_by_kind: BTreeMap>, - /// Optional discovery rebuild + stale `.git/worktrees/` prune (default off). + /// Optional discovery rebuild + grok-scoped stale `.git/worktrees/` scrub (default off). pub include_rebuild: Option, /// Independent rebuild throttle; absent ⇒ 24h. pub rebuild_min_interval_secs: Option, @@ -459,7 +458,7 @@ pub fn maybe_auto_gc(db: &WorktreeDb, auto_opts: &AutoGcOptions) -> Result BTreeSet { .collect() } +/// Scrub stale grok-owned registrations from each known source repo, +/// scoped to worktrees under the grok home to prove ownership (see +/// [`crate::git::remove_stale_worktree_registrations`] for why a blanket +/// `git worktree prune` is unsafe here). fn prune_stale_git_worktree_registrations(repos: &BTreeSet) -> u64 { + let Ok(grok_home) = resolve_grok_home() else { + tracing::warn!("auto worktree registration scrub skipped: grok home unresolved"); + return 0; + }; let cleaned: u64 = repos .iter() .filter(|repo| repo.is_dir()) - .map(|repo| prune_stale_registrations_in_repo(repo)) + .map(|repo| crate::git::remove_stale_worktree_registrations_under(repo, &grok_home)) .fold(0u64, u64::saturating_add); if cleaned > 0 { tracing::info!( stale_registrations_cleaned = cleaned, - "auto worktree stale git registrations pruned" + "auto worktree stale git registrations scrubbed" ); } cleaned } -fn count_git_worktree_registrations(git_worktrees_dir: &Path) -> u64 { - let Ok(entries) = std::fs::read_dir(git_worktrees_dir) else { - return 0; - }; - entries - .filter_map(Result::ok) - .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) - .count() as u64 -} - -fn prune_stale_registrations_in_repo(source_repo: &Path) -> u64 { - let git_worktrees = source_repo.join(".git").join("worktrees"); - let before = count_git_worktree_registrations(&git_worktrees); - - let output = git_command() - .args(["worktree", "prune"]) - .current_dir(source_repo) - .output(); - - match output { - Ok(o) if o.status.success() => { - let after = count_git_worktree_registrations(&git_worktrees); - before.saturating_sub(after) - } - Ok(o) => { - tracing::warn!( - source_repo = %source_repo.display(), - status = %o.status, - stderr = %String::from_utf8_lossy(&o.stderr), - "git worktree prune failed" - ); - 0 - } - Err(e) => { - tracing::warn!( - source_repo = %source_repo.display(), - error = %e, - "git worktree prune failed to spawn" - ); - 0 - } - } -} - fn run_orphan_cleaners( dry_run: bool, include_orphan_snapshots: bool, @@ -735,6 +698,7 @@ pub fn maybe_auto_gc_default() -> Result { mod tests { use super::*; use crate::db::{WorktreeRecord, WorktreeStatus}; + use std::path::Path; use std::sync::{Mutex, MutexGuard}; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -2095,6 +2059,48 @@ mod tests { assert!(count_regs(&repo) < before); } + #[test] + fn prune_keeps_foreign_registrations_outside_grok_home() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let repo = fx.home.join("foreign-src"); + + let user_dir = tempfile::TempDir::new().unwrap(); + let user_wt = user_dir.path().join("user-wt"); + plant_stale_git_worktree(&repo, &user_wt); + let before = count_regs(&repo); + assert!(before >= 1); + + let tracked = fx.home.join("foreign-tracked"); + std::fs::create_dir_all(&tracked).unwrap(); + let mut rec = make_rec("fk-t", tracked, WorktreeKind::Session, now_epoch_secs()); + rec.source_repo = repo.clone(); + db.register(&rec).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!( + report.stale_registrations_cleaned, 0, + "foreign registrations must never be scrubbed" + ); + assert_eq!( + count_regs(&repo), + before, + "user registrations outside grok home must survive" + ); + } + #[test] fn rebuild_unparseable_stamp_fails_open() { let _g = env_guard(); diff --git a/crates/codegen/xai-fast-worktree/src/git/checkout.rs b/crates/codegen/xai-fast-worktree/src/git/checkout.rs index ef034d1..063df38 100644 --- a/crates/codegen/xai-fast-worktree/src/git/checkout.rs +++ b/crates/codegen/xai-fast-worktree/src/git/checkout.rs @@ -445,10 +445,6 @@ fn rehydrate_worktree_from_ref_inner( ) -> Result { let dest_str = dest.to_string_lossy(); - // A previously-disposed worktree can leave a stale registration for this - // path; prune it so re-adding the original `subagent-` dir succeeds. - snapshot_git(source_repo, &["worktree", "prune"], &[])?; - // The snapshot's first parent is the original base. Resolve it, then confirm // the object is actually present — a parent-repo `git reset --hard` + gc can // leave the parent pointer dangling, which `rev-parse` alone would not catch. @@ -478,11 +474,14 @@ fn rehydrate_worktree_from_ref_inner( // A prior rehydrate may have failed after `worktree add` and left a partial dir; remove it so this attempt starts clean (worktree add fails on an existing path). if dest.exists() { let _ = crate::remove_worktree(dest); - let _ = snapshot_git(source_repo, &["worktree", "prune"], &[]); if dest.exists() { let _ = std::fs::remove_dir_all(dest); } } + // `git worktree add` refuses a path another registration still claims, + // so scrub `dest`'s stale registration (previously-disposed worktree, or + // the raw `remove_dir_all` fallback above) before adding. + crate::git::remove_stale_worktree_registration(source_repo, dest); snapshot_git( source_repo, &[ @@ -509,7 +508,7 @@ fn rehydrate_worktree_from_ref_inner( Err(e) => { // Best-effort cleanup; preserve the original error. let _ = crate::remove_worktree(dest); - let _ = snapshot_git(source_repo, &["worktree", "prune"], &[]); + crate::git::remove_stale_worktree_registration(source_repo, dest); return Err(e); } }; @@ -1121,6 +1120,50 @@ mod tests { ); } + /// Rehydrate must clear its own stale registration (so re-adding the + /// same path succeeds) while leaving every other entry alone — its + /// cleanup once pruned repo-wide and destroyed user registrations whose + /// paths were not visible from the container mount namespace. + #[test] + fn test_rehydrate_clears_only_its_own_stale_registration() { + xai_test_utils::require_git!(); + let temp = TempDir::new().unwrap(); + let (repo_path, wt) = repo_with_worktree(&temp); + + std::fs::write(wt.join("tracked.txt"), "edited").unwrap(); + let snap = snapshot_worktree_to_ref(&wt, "refs/grok/snapshots/stalereg", "stale").unwrap(); + crate::remove_worktree(&wt).unwrap(); + + rehydrate_worktree_from_ref(&wt, &repo_path, &snap, None).unwrap(); + let registration = repo_path + .join(".git") + .join("worktrees") + .join(wt.file_name().unwrap()); + assert!(registration.is_dir(), "linked registration expected"); + + let user_wt = temp.path().join("user-wt"); + git_capture_in( + &repo_path, + &["worktree", "add", "--detach", user_wt.to_str().unwrap()], + &[], + ) + .unwrap(); + std::fs::rename(&user_wt, temp.path().join("user-wt-hidden")).unwrap(); + + std::fs::remove_dir_all(&wt).unwrap(); + + let report = rehydrate_worktree_from_ref(&wt, &repo_path, &snap, None).unwrap(); + assert_eq!(report.worktree_path, wt); + assert_eq!( + std::fs::read_to_string(wt.join("tracked.txt")).unwrap(), + "edited" + ); + assert!( + repo_path.join(".git/worktrees/user-wt").exists(), + "user registration must survive rehydrate even when its path is hidden" + ); + } + #[test] fn test_transfer_snapshot_to_repo_makes_standalone_ref_durable() { xai_test_utils::require_git!(); diff --git a/crates/codegen/xai-fast-worktree/src/git/mod.rs b/crates/codegen/xai-fast-worktree/src/git/mod.rs index ff868cc..c922814 100644 --- a/crates/codegen/xai-fast-worktree/src/git/mod.rs +++ b/crates/codegen/xai-fast-worktree/src/git/mod.rs @@ -18,3 +18,7 @@ pub(crate) use discovery::{find_worktree_root, get_head_commit}; pub(crate) use index::{copy_git_index, update_index_stats}; pub(crate) use status::get_modified_files; pub(crate) use worktree::worktree_add_no_checkout; +pub use worktree::{ + StaleWorktreeMatch, remove_stale_worktree_registration, remove_stale_worktree_registrations, + remove_stale_worktree_registrations_under, +}; diff --git a/crates/codegen/xai-fast-worktree/src/git/worktree.rs b/crates/codegen/xai-fast-worktree/src/git/worktree.rs index 7f12fc1..80faa87 100644 --- a/crates/codegen/xai-fast-worktree/src/git/worktree.rs +++ b/crates/codegen/xai-fast-worktree/src/git/worktree.rs @@ -1,6 +1,6 @@ //! Git worktree operations. -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -28,3 +28,372 @@ pub(crate) fn worktree_add_no_checkout(source: &Path, dest: &str, git_ref: &str) Ok(()) } + +/// Which stale registrations [`remove_stale_worktree_registrations`] removes. +#[derive(Clone, Copy, Debug)] +pub enum StaleWorktreeMatch<'a> { + /// Exactly the registration whose recorded worktree path is this path. + Path(&'a Path), + /// Every registration whose recorded worktree path is under this prefix + /// (e.g. a tool-owned base directory, proving ownership of the entries). + UnderPrefix(&'a Path), +} + +/// Remove stale `.git/worktrees/` registrations matching `match_rule`. +/// +/// Deliberately not `git worktree prune`: prune deletes every registration +/// whose worktree path is not visible from the current mount namespace (git +/// applies no expiry protection to that case) and deletes `.git/worktrees` +/// itself once emptied — under a container that does not mount the user's +/// linked worktrees, that wiped them all. Best-effort: failures are logged, +/// never returned. Returns the number of registrations removed (git suffixes +/// ids on basename collisions, so an id may differ from the basename). +pub fn remove_stale_worktree_registrations( + source_repo: &Path, + match_rule: StaleWorktreeMatch<'_>, +) -> u64 { + if let StaleWorktreeMatch::Path(p) = match_rule + && p.exists() + { + return 0; + } + + let common_dir = match git_command() + .current_dir(source_repo) + .args(["rev-parse", "--git-common-dir"]) + .output() + { + Ok(o) if o.status.success() => { + let path = PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()); + if path.is_absolute() { + path + } else { + source_repo.join(path) + } + } + Ok(o) => { + tracing::warn!( + source_repo = %source_repo.display(), + stderr = %String::from_utf8_lossy(&o.stderr), + "stale registration scrub skipped: git rev-parse --git-common-dir failed" + ); + return 0; + } + Err(e) => { + tracing::warn!( + source_repo = %source_repo.display(), + error = %e, + "stale registration scrub skipped: git failed to spawn" + ); + return 0; + } + }; + + let Ok(entries) = std::fs::read_dir(common_dir.join("worktrees")) else { + return 0; + }; + let normalized_target = match match_rule { + StaleWorktreeMatch::Path(p) | StaleWorktreeMatch::UnderPrefix(p) => normalized_for_match(p), + }; + let mut removed = 0u64; + for entry in entries.flatten() { + let registration = entry.path(); + if !registration.is_dir() || registration.join("locked").exists() { + continue; + } + let Ok(backlink) = std::fs::read_to_string(registration.join("gitdir")) else { + continue; + }; + // The backlink names `/.git`; under + // `worktree.useRelativePaths` (git >= 2.48) it is relative to the + // registration dir, not the CWD. + let backlink_path = Path::new(backlink.trim()); + let backlink_abs = if backlink_path.is_relative() { + registration.join(backlink_path) + } else { + backlink_path.to_path_buf() + }; + let Some(recorded) = backlink_abs.parent() else { + continue; + }; + if recorded.exists() { + continue; + } + let recorded = normalized_for_match(recorded); + let matched = match match_rule { + StaleWorktreeMatch::Path(_) => recorded == normalized_target, + StaleWorktreeMatch::UnderPrefix(_) => recorded.starts_with(&normalized_target), + }; + if !matched { + continue; + } + match std::fs::remove_dir_all(®istration) { + Ok(()) => { + tracing::debug!( + registration = %registration.display(), + worktree = %recorded.display(), + "removed stale worktree registration" + ); + removed += 1; + } + Err(e) => { + tracing::warn!( + registration = %registration.display(), + error = %e, + "failed to remove stale worktree registration" + ); + } + } + } + removed +} + +/// [`remove_stale_worktree_registrations`] scoped to exactly one worktree path. +pub fn remove_stale_worktree_registration(source_repo: &Path, worktree_path: &Path) -> u64 { + remove_stale_worktree_registrations(source_repo, StaleWorktreeMatch::Path(worktree_path)) +} + +/// [`remove_stale_worktree_registrations`] scoped to a tool-owned base directory. +pub fn remove_stale_worktree_registrations_under(source_repo: &Path, prefix: &Path) -> u64 { + remove_stale_worktree_registrations(source_repo, StaleWorktreeMatch::UnderPrefix(prefix)) +} + +/// Canonicalize the deepest existing ancestor and re-append the missing +/// tail: git records the realpath at `worktree add` time, so a symlinked +/// spelling must compare equal even after the path itself is deleted. +fn normalized_for_match(path: &Path) -> PathBuf { + let mut missing = Vec::new(); + let mut cursor = path; + loop { + if let Ok(canonical) = dunce::canonicalize(cursor) { + let mut result = canonical; + for component in missing.iter().rev() { + result.push(component); + } + return result; + } + match (cursor.parent(), cursor.file_name()) { + (Some(parent), Some(name)) => { + missing.push(name.to_os_string()); + cursor = parent; + } + _ => return path.to_path_buf(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_git(cwd: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + fn init_repo_with_worktrees() -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.email", "t@test"]); + run_git(&repo, &["config", "user.name", "t"]); + std::fs::write(repo.join("f.txt"), b"x").unwrap(); + run_git(&repo, &["add", "f.txt"]); + run_git(&repo, &["commit", "-m", "init"]); + (tmp, repo) + } + + fn add_worktree(repo: &Path, wt: &Path) { + run_git( + repo, + &["worktree", "add", "--detach", wt.to_str().unwrap(), "HEAD"], + ); + } + + #[test] + fn removes_only_the_matching_stale_registration() { + let (tmp, repo) = init_repo_with_worktrees(); + let git_worktrees = repo.join(".git").join("worktrees"); + + let target = tmp.path().join("target-wt"); + add_worktree(&repo, &target); + std::fs::remove_dir_all(&target).unwrap(); + + let hidden = tmp.path().join("hidden-wt"); + add_worktree(&repo, &hidden); + std::fs::rename(&hidden, tmp.path().join("hidden-wt-moved")).unwrap(); + + std::fs::create_dir(git_worktrees.join("bare-entry")).unwrap(); + + let removed = remove_stale_worktree_registration(&repo, &target); + + assert_eq!(removed, 1); + assert!(!git_worktrees.join("target-wt").exists()); + assert!( + git_worktrees.join("hidden-wt").exists(), + "non-matching registration must survive even when its path is gone" + ); + assert!(git_worktrees.join("bare-entry").exists()); + assert!(git_worktrees.exists()); + } + + /// Rewrite a registration's `gitdir` backlink to the relative layout + /// `worktree.useRelativePaths` (git >= 2.48) produces, without requiring + /// that git version on the test host. + fn make_backlink_relative(repo: &Path, reg_name: &str, worktree: &Path) { + let reg_dir = repo.join(".git").join("worktrees").join(reg_name); + let target = worktree.join(".git"); + let mut ups = PathBuf::new(); + let mut cursor = reg_dir.as_path(); + loop { + if let Ok(rest) = target.strip_prefix(cursor) { + std::fs::write( + reg_dir.join("gitdir"), + format!("{}\n", ups.join(rest).display()), + ) + .unwrap(); + return; + } + cursor = cursor.parent().expect("shared ancestor"); + ups.push(".."); + } + } + + #[test] + fn resolves_relative_backlink_against_registration_dir() { + let (tmp, repo) = init_repo_with_worktrees(); + + let stale = tmp.path().join("rel-stale"); + add_worktree(&repo, &stale); + make_backlink_relative(&repo, "rel-stale", &stale); + std::fs::remove_dir_all(&stale).unwrap(); + + let live = tmp.path().join("rel-live"); + add_worktree(&repo, &live); + make_backlink_relative(&repo, "rel-live", &live); + + let removed_live = remove_stale_worktree_registrations_under(&repo, tmp.path()); + assert_eq!(removed_live, 1, "only the stale relative entry is removed"); + assert!(!repo.join(".git/worktrees/rel-stale").exists()); + assert!( + repo.join(".git/worktrees/rel-live").exists(), + "live worktree with relative backlink must survive" + ); + } + + #[test] + fn keeps_registration_when_worktree_still_exists() { + let (tmp, repo) = init_repo_with_worktrees(); + let wt = tmp.path().join("live-wt"); + add_worktree(&repo, &wt); + + let removed = remove_stale_worktree_registration(&repo, &wt); + + assert_eq!(removed, 0); + assert!(repo.join(".git/worktrees/live-wt").exists()); + } + + #[test] + fn keeps_locked_registration() { + let (tmp, repo) = init_repo_with_worktrees(); + let wt = tmp.path().join("locked-wt"); + add_worktree(&repo, &wt); + run_git(&repo, &["worktree", "lock", wt.to_str().unwrap()]); + std::fs::remove_dir_all(&wt).unwrap(); + + let removed = remove_stale_worktree_registration(&repo, &wt); + + assert_eq!(removed, 0); + assert!(repo.join(".git/worktrees/locked-wt").exists()); + } + + #[test] + fn under_prefix_removes_only_owned_stale_registrations() { + let (tmp, repo) = init_repo_with_worktrees(); + let git_worktrees = repo.join(".git").join("worktrees"); + + let owned_base = tmp.path().join("owned-base"); + let owned_stale = owned_base.join("instance").join("wt-stale"); + let owned_locked = owned_base.join("instance").join("wt-locked"); + let owned_live = owned_base.join("instance").join("wt-live"); + std::fs::create_dir_all(owned_stale.parent().unwrap()).unwrap(); + add_worktree(&repo, &owned_stale); + add_worktree(&repo, &owned_locked); + add_worktree(&repo, &owned_live); + std::fs::remove_dir_all(&owned_stale).unwrap(); + run_git(&repo, &["worktree", "lock", owned_locked.to_str().unwrap()]); + std::fs::remove_dir_all(&owned_locked).unwrap(); + + let foreign = tmp.path().join("foreign-wt"); + add_worktree(&repo, &foreign); + std::fs::rename(&foreign, tmp.path().join("foreign-wt-moved")).unwrap(); + + std::fs::create_dir(git_worktrees.join("bare-entry")).unwrap(); + + let removed = remove_stale_worktree_registrations_under(&repo, &owned_base); + + assert_eq!(removed, 1); + assert!(!git_worktrees.join("wt-stale").exists()); + assert!( + git_worktrees.join("wt-locked").exists(), + "locked registration must survive even when owned and stale" + ); + assert!( + git_worktrees.join("wt-live").exists(), + "live owned registration must survive" + ); + assert!( + git_worktrees.join("foreign-wt").exists(), + "foreign registration must survive even when its path is hidden" + ); + assert!(git_worktrees.join("bare-entry").exists()); + assert!(git_worktrees.exists()); + } + + #[cfg(unix)] + #[test] + fn under_prefix_matches_symlinked_base_spelling() { + let (tmp, repo) = init_repo_with_worktrees(); + + let real_base = tmp.path().join("real-base"); + std::fs::create_dir(&real_base).unwrap(); + let wt = real_base.join("wt"); + add_worktree(&repo, &wt); + std::fs::remove_dir_all(&wt).unwrap(); + + let alias_base = tmp.path().join("alias-base"); + std::os::unix::fs::symlink(&real_base, &alias_base).unwrap(); + + let removed = remove_stale_worktree_registrations_under(&repo, &alias_base); + assert_eq!(removed, 1, "symlinked base spelling must match"); + assert!(!repo.join(".git/worktrees/wt").exists()); + } + + #[test] + fn matches_across_symlinked_parent_spelling() { + let (tmp, repo) = init_repo_with_worktrees(); + let real_parent = tmp.path().join("real-parent"); + std::fs::create_dir(&real_parent).unwrap(); + let wt = real_parent.join("wt"); + add_worktree(&repo, &wt); + std::fs::remove_dir_all(&wt).unwrap(); + + #[cfg(unix)] + { + let alias = tmp.path().join("alias-parent"); + std::os::unix::fs::symlink(&real_parent, &alias).unwrap(); + let removed = remove_stale_worktree_registration(&repo, &alias.join("wt")); + assert_eq!(removed, 1, "symlinked spelling of the parent must match"); + assert!(!repo.join(".git/worktrees/wt").exists()); + } + } +} diff --git a/crates/codegen/xai-fast-worktree/src/lib.rs b/crates/codegen/xai-fast-worktree/src/lib.rs index e64d32d..792eb62 100644 --- a/crates/codegen/xai-fast-worktree/src/lib.rs +++ b/crates/codegen/xai-fast-worktree/src/lib.rs @@ -63,6 +63,10 @@ pub use discovery::{RebuildReport, discover_worktrees, rebuild_worktree_db}; pub use git::checkout::{ rehydrate_worktree_from_ref, snapshot_worktree_to_ref, transfer_snapshot_to_repo, }; +pub use git::{ + StaleWorktreeMatch, remove_stale_worktree_registration, remove_stale_worktree_registrations, + remove_stale_worktree_registrations_under, +}; pub use sync::{SourceDirtyState, SyncReport, WorktreeSync, collect_source_dirty_state}; #[cfg(target_os = "linux")] pub use worktree::execute::cleanup_snapshot_git_state; diff --git a/crates/codegen/xai-fast-worktree/src/sync.rs b/crates/codegen/xai-fast-worktree/src/sync.rs index a531fad..d4055bf 100644 --- a/crates/codegen/xai-fast-worktree/src/sync.rs +++ b/crates/codegen/xai-fast-worktree/src/sync.rs @@ -604,6 +604,7 @@ fn replay_staged_changes( // SP TAB NUL if !staged_adds.is_empty() { use std::io::Write; + #[allow(clippy::disallowed_methods)] // git command, waited on below let mut child = git_command() .current_dir(worktree) .args(["update-index", "-z", "--index-info"]) diff --git a/crates/codegen/xai-file-utils/src/events/log.rs b/crates/codegen/xai-file-utils/src/events/log.rs index 87571b4..052181e 100644 --- a/crates/codegen/xai-file-utils/src/events/log.rs +++ b/crates/codegen/xai-file-utils/src/events/log.rs @@ -91,7 +91,7 @@ impl std::fmt::Debug for EventWriter { mod tests { use super::*; use crate::events::types::{ - EVENT_SCHEMA_VERSION, Event, SessionRelationship, TurnOutcomeLabel, + EVENT_SCHEMA_VERSION, Event, SessionRelationship, ToolOutcome, TurnOutcomeLabel, }; fn _assert_event_writer_is_send_sync_clone() @@ -116,6 +116,13 @@ mod tests { redirect_kind: None, }); writer.emit(Event::FirstToken); + writer.emit(Event::ToolCompleted { + tool_name: "bash".into(), + duration_ms: 1500, + outcome: ToolOutcome::Success, + tool_call_id: "call_xyz".into(), + source: crate::events::types::ToolCompletedSource::Shell, + }); writer.emit(Event::TurnEnded { outcome: TurnOutcomeLabel::Completed, cancellation_category: None, @@ -124,7 +131,7 @@ mod tests { let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap(); let lines: Vec<&str> = text.trim().split('\n').collect(); - assert_eq!(lines.len(), 3); + assert_eq!(lines.len(), 4); let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); assert_eq!(first["type"], "turn_started"); @@ -135,9 +142,19 @@ mod tests { assert_eq!(second["type"], "first_token"); let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); - assert_eq!(third["type"], "turn_ended"); - assert_eq!(third["outcome"], "completed"); - assert!(third.get("cancellation_category").is_none()); + assert_eq!(third["type"], "tool_completed"); + assert_eq!(third["tool_name"], "bash"); + assert_eq!(third["duration_ms"], 1500); + assert_eq!(third["tool_call_id"], "call_xyz"); + assert!( + third.get("source").is_none(), + "shell ToolCompleted must omit source" + ); + + let fourth: serde_json::Value = serde_json::from_str(lines[3]).unwrap(); + assert_eq!(fourth["type"], "turn_ended"); + assert_eq!(fourth["outcome"], "completed"); + assert!(fourth.get("cancellation_category").is_none()); } #[test] diff --git a/crates/codegen/xai-file-utils/src/events/mod.rs b/crates/codegen/xai-file-utils/src/events/mod.rs index 1e9c7f1..1b2caca 100644 --- a/crates/codegen/xai-file-utils/src/events/mod.rs +++ b/crates/codegen/xai-file-utils/src/events/mod.rs @@ -8,5 +8,6 @@ pub use log::EventWriter; pub use tracker::EventTracker; pub use types::{ CancellationCategory, EVENT_SCHEMA_VERSION, Event, McpConfigServer, McpErrorCategory, - PermissionDecision, Phase, SessionRelationship, ToolOutcome, TurnOutcomeLabel, + PermissionDecision, Phase, SessionRelationship, ToolCompletedSource, ToolOutcome, + TurnOutcomeLabel, }; diff --git a/crates/codegen/xai-file-utils/src/events/tracker.rs b/crates/codegen/xai-file-utils/src/events/tracker.rs index 358fc5c..9f22a87 100644 --- a/crates/codegen/xai-file-utils/src/events/tracker.rs +++ b/crates/codegen/xai-file-utils/src/events/tracker.rs @@ -5,12 +5,21 @@ use std::time::Instant; use super::log::EventWriter; use super::types::{CancellationCategory, Event, RedirectKind, TurnOutcomeLabel}; +/// In-flight tool for cancel telemetry. Duration is the dispatch wall already +/// measured, so cancel can reuse it instead of re-timing post-flight. +#[derive(Debug, Clone)] +struct ActiveTool { + tool_name: String, + tool_call_id: String, + dispatch_duration_ms: u64, +} + /// Per-session event state. `!Send` — lives on the session actor. /// Background tasks use `tracker.writer()` to get a `Clone + Send + Sync` handle. pub struct EventTracker { writer: EventWriter, turn_ended_emitted: Cell, - active_tool: RefCell>, + active_tool: RefCell>, turn_tool_count: Cell, /// Cross-turn one-shot: the *fatal* user-interrupt cause that cancelled the /// most recent turn (set by the cancel paths), consumed by the *next* real @@ -42,7 +51,7 @@ impl std::fmt::Debug for EventTracker { .field("writer", &self.writer) .field("turn_ended_emitted", &self.turn_ended_emitted.get()) .field("turn_tool_count", &self.turn_tool_count.get()) - .field("active_tool", &active_tool.as_ref().map(|(name, _)| name)) + .field("active_tool", &*active_tool) .field( "prior_interrupt_category", &self.prior_interrupt_category.get(), @@ -101,12 +110,21 @@ impl EventTracker { }); } - /// Set the active tool for cancellation tracking and return the start instant. - pub fn tool_started(&self, tool_name: String) -> Instant { - let now = Instant::now(); - *self.active_tool.borrow_mut() = Some((tool_name, now)); - self.turn_tool_count.set(self.turn_tool_count.get() + 1); - now + /// Mark a tool as active for cancellation tracking. + /// + /// `dispatch_duration_ms` is the wall time already measured for this call, so + /// a cancel can report it rather than re-measure from post-flight. + pub fn tool_started(&self, tool_name: String, tool_call_id: String, dispatch_duration_ms: u64) { + let is_new = self.active_tool.borrow().is_none(); + *self.active_tool.borrow_mut() = Some(ActiveTool { + tool_name, + tool_call_id, + dispatch_duration_ms, + }); + // Re-entry (e.g. after reauth adds retry wall time) only refreshes duration. + if is_new { + self.turn_tool_count.set(self.turn_tool_count.get() + 1); + } } pub fn tool_count_this_turn(&self) -> u32 { @@ -123,12 +141,17 @@ impl EventTracker { /// Cancel in-flight tool and emit `ToolCompleted(cancelled)`. /// Called from `cancel_running_task()` before `turn_ended`. + /// + /// A tool cancelled while still dispatching was never marked active, so it + /// gets no `tool_completed` row at all. pub fn cancel_active_tool(&self) { - if let Some((tool_name, start)) = self.active_tool.borrow_mut().take() { + if let Some(tool) = self.active_tool.borrow_mut().take() { self.emit(Event::ToolCompleted { - tool_name, - duration_ms: start.elapsed().as_millis() as u64, + tool_name: tool.tool_name, + duration_ms: tool.dispatch_duration_ms, outcome: super::types::ToolOutcome::Cancelled, + tool_call_id: tool.tool_call_id, + source: super::types::ToolCompletedSource::Shell, }); } } diff --git a/crates/codegen/xai-file-utils/src/events/types.rs b/crates/codegen/xai-file-utils/src/events/types.rs index 9cef1d8..d37c046 100644 --- a/crates/codegen/xai-file-utils/src/events/types.rs +++ b/crates/codegen/xai-file-utils/src/events/types.rs @@ -40,8 +40,18 @@ pub enum Event { }, ToolCompleted { tool_name: String, + /// Dispatch wall time; a cancel row reuses the duration measured at dispatch. duration_ms: u64, outcome: ToolOutcome, + /// Model/ACP tool call id; matches the conversation's `tool_result`. + /// Omitted on write when empty. + #[serde(skip_serializing_if = "String::is_empty")] + tool_call_id: String, + /// Which emitter wrote this row. Shell (default) is omitted on the wire + /// and is what package joins should use; workspace rows time the + /// hub/proxy hop for the same call. + #[serde(skip_serializing_if = "ToolCompletedSource::is_shell")] + source: ToolCompletedSource, }, PermissionRequested { tool_name: String, @@ -457,6 +467,26 @@ pub enum Event { }, } +/// Who emitted a [`Event::ToolCompleted`] row. +/// +/// Wire: shell is omitted (legacy empty/`source` absent); workspace is +/// `"workspace"`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCompletedSource { + /// Shell dispatch clock — join against these. + #[default] + Shell, + /// Workspace hub/proxy hop clock. + Workspace, +} + +impl ToolCompletedSource { + pub fn is_shell(&self) -> bool { + matches!(self, Self::Shell) + } +} + /// Where a mid-turn interjection originated. Drives the `source` field on /// [`Event::Interjected`]. #[derive(Debug, Clone, Copy, Serialize)] @@ -654,6 +684,29 @@ mod tests { } } + #[test] + fn tool_completed_source_omits_shell_writes_workspace() { + let shell = serde_json::to_value(Event::ToolCompleted { + tool_name: "bash".into(), + duration_ms: 10, + outcome: ToolOutcome::Success, + tool_call_id: "c1".into(), + source: ToolCompletedSource::Shell, + }) + .unwrap(); + assert!(shell.get("source").is_none()); + + let workspace = serde_json::to_value(Event::ToolCompleted { + tool_name: "bash".into(), + duration_ms: 10, + outcome: ToolOutcome::Success, + tool_call_id: "c1".into(), + source: ToolCompletedSource::Workspace, + }) + .unwrap(); + assert_eq!(workspace["source"], "workspace"); + } + #[test] fn interjected_event_serializes_tag_source_and_count() { let ev = Event::Interjected { diff --git a/crates/codegen/xai-grok-agent/src/config.rs b/crates/codegen/xai-grok-agent/src/config.rs index 8582b86..4bedcd8 100644 --- a/crates/codegen/xai-grok-agent/src/config.rs +++ b/crates/codegen/xai-grok-agent/src/config.rs @@ -793,7 +793,7 @@ pub struct AgentDefinition { pub isolation: Option, #[serde(default)] pub background: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_agent_color")] pub color: Option, #[serde(default)] pub initial_prompt: Option, @@ -1060,11 +1060,13 @@ const _: () = Eq, Deserialize, serde::Serialize, + AsRefStr, + EnumString, IntoStaticStr, strum::EnumCount, )] #[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] pub enum AgentColor { Red, Blue, @@ -1081,6 +1083,35 @@ impl AgentColor { ]; } const _: () = assert!(AgentColor::VALID_VALUES.len() == ::COUNT); +/// Never fails: `color` is decorative, but a rejected value fails the whole +/// frontmatter parse, and discovery skips agents that fail to parse — so a +/// typo'd or hex color would silently make the agent unspawnable. +/// +/// Frontmatter is only ever decoded by `serde_yaml`, so the intermediate value +/// is captured as `serde_yaml::Value` (total for YAML — tagged scalars and +/// maps with non-string keys included, which have no `serde_json::Value` +/// form). Unrecognized values are dropped to `None` with a warning rather +/// than mapped to a stand-in color the author never wrote. +fn deserialize_agent_color<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use std::str::FromStr; + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let parsed = value + .as_str() + .and_then(|name| AgentColor::from_str(name.trim()).ok()); + if parsed.is_none() { + tracing::warn!( + color = ?value, + valid = ?AgentColor::VALID_VALUES, + "unrecognized agent color, ignoring" + ); + } + Ok(parsed) +} /// Agent memory scope. Distinct from `storage::MemoryScope` (global-vs-workspace write target). #[derive( Debug, @@ -2096,8 +2127,10 @@ description: Minimal agent } for color in AgentColor::VALID_VALUES { let c = format!("---\nname: t\ndescription: t\ncolor: {color}\n---\n"); - assert!( - AgentDefinition::parse(&c).unwrap().color.is_some(), + let parsed = AgentDefinition::parse(&c).unwrap().color; + assert_eq!( + parsed.map(<&'static str>::from), + Some(*color), "color: {color}" ); } @@ -2110,6 +2143,33 @@ description: Minimal agent } } #[test] + fn unparseable_color_is_dropped_instead_of_dropping_the_agent() { + for (declared, expected) in [ + ("Purple", Some(AgentColor::Purple)), + (" CYAN ", Some(AgentColor::Cyan)), + ("teal", None), + ("\"#ff0000\"", None), + ("chartreuse", None), + ("42", None), + ("[red, blue]", None), + ("!custom x", None), + ("{1: 2}", None), + ] { + let c = format!("---\nname: t\ndescription: t\ncolor: {declared}\n---\n"); + let def = AgentDefinition::parse(&c) + .unwrap_or_else(|e| panic!("color {declared} must not fail the parse: {e}")); + assert_eq!(def.color, expected, "color: {declared}"); + assert_eq!(def.name, "t"); + } + } + #[test] + fn absent_or_null_color_stays_none() { + let def = AgentDefinition::parse("---\nname: t\ndescription: t\n---\n").unwrap(); + assert!(def.color.is_none()); + let def = AgentDefinition::parse("---\nname: t\ndescription: t\ncolor:\n---\n").unwrap(); + assert!(def.color.is_none()); + } + #[test] fn test_parse_missing_name() { let content = r#"--- description: No name diff --git a/crates/codegen/xai-grok-agent/src/discovery.rs b/crates/codegen/xai-grok-agent/src/discovery.rs index 01da3ed..4f66263 100644 --- a/crates/codegen/xai-grok-agent/src/discovery.rs +++ b/crates/codegen/xai-grok-agent/src/discovery.rs @@ -399,14 +399,9 @@ fn all_subagents_with_plugins_and_home( if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } - // Use frontmatter-only parsing for untrusted plugins - let def = if plugin.trusted { - AgentDefinition::from_file(&path).ok() - } else { - AgentDefinition::from_file_frontmatter_only(&path).ok() + let Some(def) = load_plugin_agent_definition(plugin, &path) else { + continue; }; - let Some(mut def) = def else { continue }; - def.plugin_name = Some(plugin.name.clone()); let qualified_name = format!("{}:{}", plugin.name, def.name); @@ -481,17 +476,11 @@ fn by_name_in_cwd_with_plugins_and_home( { for agent_dir in &plugin.agent_dirs { let agent_file = agent_dir.join(format!("{agent_name}.md")); - if agent_file.is_file() { - let load_fn = if plugin.trusted { - AgentDefinition::from_file - } else { - AgentDefinition::from_file_frontmatter_only - }; - if let Ok(mut def) = load_fn(&agent_file) { - def.plugin_name = Some(plugin_name.to_string()); - substitute_plugin_vars(&mut def, plugin); - return Some(def); - } + if agent_file.is_file() + && let Some(mut def) = load_plugin_agent_definition(plugin, &agent_file) + { + substitute_plugin_vars(&mut def, plugin); + return Some(def); } } } @@ -510,13 +499,7 @@ fn by_name_in_cwd_with_plugins_and_home( } if matches.len() == 1 { let (plugin, agent_file) = &matches[0]; - let load_fn = if plugin.trusted { - AgentDefinition::from_file - } else { - AgentDefinition::from_file_frontmatter_only - }; - if let Ok(mut def) = load_fn(agent_file) { - def.plugin_name = Some(plugin.name.clone()); + if let Some(mut def) = load_plugin_agent_definition(plugin, agent_file) { substitute_plugin_vars(&mut def, plugin); return Some(def); } @@ -533,6 +516,37 @@ fn by_name_in_cwd_with_plugins_and_home( None } +/// Load one plugin-provided agent file, tagged with its owning plugin. +/// +/// Untrusted plugins are parsed frontmatter-only so their prompt body never +/// reaches the model before the plugin is trusted. A parse failure drops the +/// agent from discovery entirely, so it is logged rather than swallowed. +fn load_plugin_agent_definition( + plugin: &crate::plugins::LoadedPlugin, + path: &Path, +) -> Option { + let loaded = if plugin.trusted { + AgentDefinition::from_file(path) + } else { + AgentDefinition::from_file_frontmatter_only(path) + }; + match loaded { + Ok(mut def) => { + def.plugin_name = Some(plugin.name.clone()); + Some(def) + } + Err(e) => { + tracing::warn!( + plugin = %plugin.name, + path = %path.display(), + error = %e, + "Failed to parse plugin agent definition, skipping" + ); + None + } + } +} + /// Expand `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` (and the Grok /// aliases) in a plugin agent's body so the model receives absolute paths, /// matching the expected load-time resolution for these variables. @@ -1368,6 +1382,44 @@ mod tests { assert!(entries.iter().any(|e| e.name == "plugin-one:reviewer")); } + #[test] + fn plugin_agent_with_unrecognized_color_is_still_discovered() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().join("workspace"); + let home = tmp.path().join("home"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&home).unwrap(); + + let plugin_root = tempfile::tempdir().unwrap(); + let plugin_agents = plugin_root.path().join("agents"); + fs::create_dir_all(&plugin_agents).unwrap(); + fs::write( + plugin_agents.join("painter.md"), + "---\nname: painter\ndescription: Plugin painter\ncolor: chartreuse\n---\nBody.\n", + ) + .unwrap(); + + let registry = make_plugin_registry("plugin-one", PluginScope::User, vec![plugin_agents]); + let entries = all_subagents_with_plugins_and_home( + &cwd, + &HashMap::new(), + Some(®istry), + Some(&home), + Some(&home.join(".grok")), + ); + assert!(entries.iter().any(|e| e.name == "plugin-one:painter")); + + let def = by_name_in_cwd_with_plugins_and_home( + "plugin-one:painter", + &cwd, + Some(®istry), + Some(&home), + Some(&home.join(".grok")), + ) + .expect("agent must resolve despite the unrecognized color"); + assert_eq!(def.color, None, "unrecognized color must be dropped"); + } + #[test] fn test_by_name_in_cwd_with_plugins_prefers_native_over_plugin_bare_name() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/codegen/xai-grok-auth/src/lib.rs b/crates/codegen/xai-grok-auth/src/lib.rs index f74b896..3b830e9 100644 --- a/crates/codegen/xai-grok-auth/src/lib.rs +++ b/crates/codegen/xai-grok-auth/src/lib.rs @@ -10,5 +10,5 @@ pub mod visibility; pub use auth_provider::{AuthCredentialProvider, CredentialSnapshot, StaticAuthCredentialProvider}; #[cfg(feature = "middleware")] -pub use retry_middleware::AuthRetryMiddleware; +pub use retry_middleware::{AuthRetryMiddleware, StampedBearerSuffix, execute_with_stamp}; pub use visibility::HttpAuth; diff --git a/crates/codegen/xai-grok-auth/src/retry_middleware.rs b/crates/codegen/xai-grok-auth/src/retry_middleware.rs index 317e874..802a338 100644 --- a/crates/codegen/xai-grok-auth/src/retry_middleware.rs +++ b/crates/codegen/xai-grok-auth/src/retry_middleware.rs @@ -8,6 +8,47 @@ use reqwest_middleware::{Error, Middleware, Next}; use crate::AuthCredentialProvider; +/// Tail fragment (last [`STAMPED_BEARER_SUFFIX_LEN`] chars) of the bearer +/// this middleware stamped, recorded into the request's `http::Extensions` +/// at stamp time. 401-attribution sites read it back via +/// [`execute_with_stamp`] instead of re-resolving at record time, which +/// races with the refresh the 401 itself triggers. Absent ⇒ nothing was +/// stamped; a retry overwrites it, so it always describes the attempt whose +/// response the caller holds. Only the tail crosses this boundary — JWT +/// heads are a shared constant, and the tail is safe for sinks to log. +#[derive(Clone, Debug)] +pub struct StampedBearerSuffix(pub String); + +/// Length of [`StampedBearerSuffix`]. Matches `token_suffix` in +/// xai-grok-shell (the comparison site for 401 attribution). +const STAMPED_BEARER_SUFFIX_LEN: usize = 12; + +/// Last [`STAMPED_BEARER_SUFFIX_LEN`] chars, counting chars from the end +/// so a non-ASCII credential cannot cause a byte-boundary panic. +fn bearer_suffix(token: &str) -> &str { + match token + .char_indices() + .rev() + .nth(STAMPED_BEARER_SUFFIX_LEN - 1) + { + Some((i, _)) => &token[i..], + None => token, + } +} + +/// Execute `req` on a middleware-wrapped client and return the response +/// together with the [`StampedBearerSuffix`] the auth middleware recorded +/// (if it stamped anything). The one blessed way for 401-attribution +/// call sites to learn what was actually sent on the wire. +pub async fn execute_with_stamp( + client: &reqwest_middleware::ClientWithMiddleware, + req: Request, +) -> reqwest_middleware::Result<(Response, Option)> { + let mut ext = http::Extensions::new(); + let resp = client.execute_with_extensions(req, &mut ext).await?; + Ok((resp, ext.get::().cloned())) +} + pub struct AuthRetryMiddleware { credentials: Arc, max_retries: u32, @@ -22,11 +63,12 @@ impl AuthRetryMiddleware { } } -fn apply_auth_header(req: &mut Request, token: &str) { +fn apply_auth_header(req: &mut Request, token: &str, extensions: &mut http::Extensions) { match HeaderValue::from_str(&format!("Bearer {token}")) { Ok(val) => { req.headers_mut() .insert(reqwest::header::AUTHORIZATION, val); + extensions.insert(StampedBearerSuffix(bearer_suffix(token).to_string())); } Err(e) => { tracing::warn!(error = %e, "auth retry: failed to build Authorization header"); @@ -43,7 +85,7 @@ impl Middleware for AuthRetryMiddleware { next: Next<'_>, ) -> Result { if let Some(ref token) = self.credentials.snapshot().token { - apply_auth_header(&mut req, token); + apply_auth_header(&mut req, token, extensions); } let backup = req.try_clone(); @@ -67,7 +109,7 @@ impl Middleware for AuthRetryMiddleware { let Some(mut retry) = backup.try_clone() else { break; }; - apply_auth_header(&mut retry, token); + apply_auth_header(&mut retry, token, extensions); last_resp = next.clone().run(retry, extensions).await?; if last_resp.status() != StatusCode::UNAUTHORIZED { return Ok(last_resp); @@ -251,6 +293,73 @@ mod tests { mock.assert_async().await; } + /// The stamp must describe the bearer of the attempt whose response + /// the caller holds: after a 401 → refresh → retry, that is the + /// FRESH token, not the stale one stamped on the first attempt. + #[tokio::test] + async fn execute_with_stamp_reports_last_stamped_bearer() { + let mut server = mockito::Server::new_async().await; + let m401 = server + .mock("GET", "/api") + .match_header("authorization", "Bearer stale-token") + .with_status(401) + .create_async() + .await; + let m200 = server + .mock("GET", "/api") + .match_header("authorization", "Bearer fresh-token") + .with_status(200) + .create_async() + .await; + + let p = Arc::new(SimulatedAuthManager::simulated( + "stale-token", + "fresh-token", + )); + let client = build_client(p, 1).await; + + let req = client.get(format!("{}/api", server.url())).build().unwrap(); + let (resp, stamp) = execute_with_stamp(&client, req).await.unwrap(); + assert_eq!(resp.status(), 200); + // ≤ 12 chars → the suffix is the whole token. + assert_eq!(stamp.expect("bearer was stamped").0, "fresh-token"); + m401.assert_async().await; + m200.assert_async().await; + } + + /// No credential ⇒ no stamp: attribution must see "nothing was sent", + /// not an empty string or a stale record. + #[tokio::test] + async fn execute_with_stamp_is_none_when_nothing_stamped() { + let mut server = mockito::Server::new_async().await; + let m = server + .mock("GET", "/") + .with_status(401) + .create_async() + .await; + + let p = Arc::new(MockProvider::new(None, false)); + let client = build_client(p, 0).await; + + let req = client.get(server.url()).build().unwrap(); + let (resp, stamp) = execute_with_stamp(&client, req).await.unwrap(); + assert_eq!(resp.status(), 401); + assert!(stamp.is_none(), "no credential must mean no stamp"); + m.assert_async().await; + } + + #[test] + fn bearer_suffix_takes_char_safe_tail() { + assert_eq!( + bearer_suffix("eyJ0eXAiOiJh.head.tail-distinct"), + "ail-distinct" + ); + assert_eq!(bearer_suffix("short"), "short"); + assert_eq!(bearer_suffix(""), ""); + // 13 multi-byte chars: a byte-index cut would land mid-char. + assert_eq!(bearer_suffix("ééééééééééééé"), "éééééééééééé"); + } + #[tokio::test] async fn test_max_retries_bounds_attempts() { let mut server = mockito::Server::new_async().await; diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index 9ab5ac1..8c79f98 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -151,7 +151,7 @@ pub struct WorktreeAutoGcSettings { skip_serializing_if = "Option::is_none" )] pub max_age_by_kind: Option>, - /// Optional discovery rebuild + stale `.git/worktrees/` prune (default off). + /// Optional discovery rebuild + grok-scoped stale `.git/worktrees/` scrub (default off). #[serde( default, deserialize_with = "de_opt_bool_tolerant", diff --git a/crates/codegen/xai-grok-config/src/managed_text/validator.rs b/crates/codegen/xai-grok-config/src/managed_text/validator.rs index ca8f2fc..d555e29 100644 --- a/crates/codegen/xai-grok-config/src/managed_text/validator.rs +++ b/crates/codegen/xai-grok-config/src/managed_text/validator.rs @@ -66,6 +66,7 @@ fn validate_with_ops( .stderr(Stdio::null()) .envs(xai_tty_utils::pager_env()); xai_tty_utils::detach_std_command(&mut command); + #[allow(clippy::disallowed_methods)] // config validator, waited on with a timeout let mut child = command .spawn() .map_err(|source| ManagedConfigError::Validation { diff --git a/crates/codegen/xai-grok-extra-ca/Cargo.toml b/crates/codegen/xai-grok-extra-ca/Cargo.toml new file mode 100644 index 0000000..7a4c0b5 --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/Cargo.toml @@ -0,0 +1,20 @@ +[package] +license = "Apache-2.0" +name = "xai-grok-extra-ca" +version = "0.1.0" +edition.workspace = true +description = "Opt-in extra TLS roots from GROK_EXTRA_CA_BUNDLE (validated DER cache + reqwest 0.12 adapters)" + +[dependencies] +reqwest = { workspace = true } +# Explicit pin (not `workspace = true`): the workspace rustls pin enables +# aws-lc-rs. This crate only needs RootCertStore + PEM parse, so stay +# default-features = false with `std` (enables pki-types/std for PEM). +rustls = { version = "0.23", default-features = false, features = ["std"] } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/codegen/xai-grok-extra-ca/src/lib.rs b/crates/codegen/xai-grok-extra-ca/src/lib.rs new file mode 100644 index 0000000..defd5d0 --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/src/lib.rs @@ -0,0 +1,184 @@ +//! Opt-in extra TLS roots via `GROK_EXTRA_CA_BUNDLE` (PEM path). +//! +//! Default-off (unset/empty env → no I/O); parsed once into a process +//! `OnceLock`; additive to webpki roots. Each DER is validated with +//! `rustls::RootCertStore::add` before caching so a bad bundle cannot fail +//! `ClientBuilder::build()`. Unreadable/oversized/empty/unparsable → warn and +//! continue. Size cap: [`MAX_EXTRA_CA_BUNDLE_BYTES`]. +//! +//! Source of truth is validated DER ([`extra_root_ders`]) so reqwest 0.12 +//! (this crate's adapters) and MCP's 0.13 can each build their own +//! `Certificate`s. + +use std::io::Read; +use std::sync::OnceLock; + +use rustls::RootCertStore; +use rustls::pki_types::CertificateDer; +use rustls::pki_types::pem::PemObject; + +/// Hard cap on `GROK_EXTRA_CA_BUNDLE` (1 MiB) — avoids unbounded startup reads. +pub const MAX_EXTRA_CA_BUNDLE_BYTES: u64 = 1024 * 1024; + +/// Env var name for the opt-in extra CA bundle (PEM path). +pub const ENV_GROK_EXTRA_CA_BUNDLE: &str = "GROK_EXTRA_CA_BUNDLE"; + +/// Process-wide extra roots as validated DER, parsed once. +/// +/// Empty when the env var is unset/empty or the file yields no usable certs. +pub fn extra_root_ders() -> &'static [Vec] { + static DERS: OnceLock>> = OnceLock::new(); + DERS.get_or_init(load_extra_root_ders).as_slice() +} + +/// Apply [`extra_root_ders`] to a workspace (reqwest 0.12) async `ClientBuilder`. +pub fn with_extra_root_certificates(mut builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + for der in extra_root_ders() { + match reqwest::Certificate::from_der(der) { + Ok(cert) => builder = builder.add_root_certificate(cert), + // WHY: rustls already accepted this DER; skip rather than poison build. + Err(e) => tracing::warn!( + error = %e, + "GROK_EXTRA_CA_BUNDLE: validated DER rejected by reqwest; skipping cert" + ), + } + } + builder +} + +/// Apply [`extra_root_ders`] to a workspace (reqwest 0.12) blocking `ClientBuilder`. +pub fn with_extra_root_certificates_blocking( + mut builder: reqwest::blocking::ClientBuilder, +) -> reqwest::blocking::ClientBuilder { + for der in extra_root_ders() { + match reqwest::Certificate::from_der(der) { + Ok(cert) => builder = builder.add_root_certificate(cert), + // WHY: rustls already accepted this DER; skip rather than poison build. + Err(e) => tracing::warn!( + error = %e, + "GROK_EXTRA_CA_BUNDLE: validated DER rejected by reqwest; skipping cert" + ), + } + } + builder +} + +fn load_extra_root_ders() -> Vec> { + let path = match std::env::var_os(ENV_GROK_EXTRA_CA_BUNDLE) { + Some(p) if !p.is_empty() => std::path::PathBuf::from(p), + _ => return Vec::new(), + }; + + let bytes = match read_bundle_capped(&path) { + Ok(b) => b, + Err(BundleReadError::Io(e)) => { + // WHY: MITM CA is optional; a missing path must not brick HTTP. + tracing::warn!( + path = %path.display(), + error = %e, + "GROK_EXTRA_CA_BUNDLE unreadable; continuing without extra roots" + ); + return Vec::new(); + } + Err(BundleReadError::TooLarge) => { + tracing::warn!( + path = %path.display(), + max_bytes = MAX_EXTRA_CA_BUNDLE_BYTES, + "GROK_EXTRA_CA_BUNDLE exceeds size cap; continuing without extra roots" + ); + return Vec::new(); + } + }; + + let outcome = parse_and_validate_pem(&bytes); + if outcome.no_pem_blocks { + tracing::warn!( + path = %path.display(), + "GROK_EXTRA_CA_BUNDLE contains no PEM certificate blocks; continuing without extra roots" + ); + return outcome.accepted; + } + if outcome.rejected > 0 { + tracing::warn!( + path = %path.display(), + accepted = outcome.accepted.len(), + rejected = outcome.rejected, + "GROK_EXTRA_CA_BUNDLE: dropped unusable certificate block(s)" + ); + } + if outcome.accepted.is_empty() { + tracing::warn!( + path = %path.display(), + "GROK_EXTRA_CA_BUNDLE produced zero usable certificates; continuing without extra roots" + ); + } else { + tracing::info!( + path = %path.display(), + accepted = outcome.accepted.len(), + "GROK_EXTRA_CA_BUNDLE: loaded extra root certificate(s)" + ); + } + outcome.accepted +} + +#[derive(Debug)] +enum BundleReadError { + Io(std::io::Error), + TooLarge, +} + +fn read_bundle_capped(path: &std::path::Path) -> Result, BundleReadError> { + let file = std::fs::File::open(path).map_err(BundleReadError::Io)?; + let mut buf = Vec::new(); + let n = file + .take(MAX_EXTRA_CA_BUNDLE_BYTES + 1) + .read_to_end(&mut buf) + .map_err(BundleReadError::Io)?; + if (n as u64) > MAX_EXTRA_CA_BUNDLE_BYTES { + return Err(BundleReadError::TooLarge); + } + Ok(buf) +} + +/// Result of parsing a PEM bundle into rustls-validated DER roots. +#[derive(Debug, Default)] +pub(crate) struct ParseOutcome { + pub(crate) accepted: Vec>, + /// PEM blocks that failed decode or rustls X.509 validation. + pub(crate) rejected: usize, + /// Input (non-empty) contained no PEM certificate blocks at all. + pub(crate) no_pem_blocks: bool, +} + +/// Parse PEM into rustls-validated DER (no env / OnceLock). Input with no PEM +/// certificate blocks (including empty) → empty accepted, zero rejected, +/// `no_pem_blocks` set. +pub(crate) fn parse_and_validate_pem(pem: &[u8]) -> ParseOutcome { + let mut accepted = Vec::new(); + let mut rejected = 0usize; + let mut saw_block = false; + + // WHY: reject non-X.509 DER before any ClientBuilder sees it; `add` + // validates per certificate, so one store serves the whole bundle. + let mut store = RootCertStore::empty(); + for item in CertificateDer::pem_slice_iter(pem) { + saw_block = true; + match item { + Ok(der) => match store.add(der.clone()) { + Ok(()) => accepted.push(der.as_ref().to_vec()), + Err(_) => rejected += 1, + }, + Err(_) => rejected += 1, + } + } + + ParseOutcome { + accepted, + rejected, + no_pem_blocks: !saw_block, + } +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-extra-ca/src/lib_tests.rs b/crates/codegen/xai-grok-extra-ca/src/lib_tests.rs new file mode 100644 index 0000000..1484d97 --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/src/lib_tests.rs @@ -0,0 +1,130 @@ +use super::*; + +// Self-signed PEMs for unit tests only (CN=test-extra-ca-1 / -2). +const VALID_CERT_1: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIDFTCCAf2gAwIBAgIUT2czXTuxSAjDjEh92UMB1OVahZYwDQYJKoZIhvcNAQEL\n\ +BQAwGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMB4XDTI2MDcyOTE4MzUwNFoX\n\ +DTM2MDcyNjE4MzUwNFowGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMIIBIjAN\n\ +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gNk2BQwUy+n5cCaTFtGpSzVQv//\n\ +d7QD+3QWeE411wIGJzp3nrd7np55X8JHxeg/pRhspQvLQAF7bt55LSkL/+sSth3S\n\ +QTbBqhftic9CXik3llAwbdQkAM9srz5zXWW9KVjZ57dxjjxrS15SCXu/UmvGZy98\n\ +faJcS++TRkczsNFzwQEqeDYARVc/no0C0I++NhGLPaNMfFAevvnu6Kt3CYMI5ls4\n\ +KCFgnlau4CjgRCMSfRDCRcwEwUAp+DyX9IU+tvDAQY1ncVoa/05tvaEvw7pQ+UgW\n\ +0wRG0lk7PLlcWmUkLcFpO+sL5GRkC8RoWM4cFbIOiXoVxUFks/z2y0GCEQIDAQAB\n\ +o1MwUTAdBgNVHQ4EFgQU+lyC70W5aR6BIf4VNtjfiWMNzzkwHwYDVR0jBBgwFoAU\n\ ++lyC70W5aR6BIf4VNtjfiWMNzzkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\n\ +AQsFAAOCAQEA02972nA7LshRgubz6BwXbh1gA5pLzTd5KEae+94Hq6mP2zJ1T0gk\n\ +x+me0NtSgG4BJLdBIylUzo2UmsfB/sz+ght6WX1uB38Vc2UQsp0sRPeeiMovSd6n\n\ +I7xZyuZEF3noYJVBBlKQ8XsCUIBNIROlyKlNjNcWY8tGqPh9cepvtZYkBgRZr1vW\n\ +hJAE3EOL2ZddrMPF64QeU9UhvCm0Ch+Ceqa1ZWE0MygccggX5s2yQwtXO2ovJdjH\n\ +6vW0I02r8sE+NX0d1u8rIPJEKlp89UwCwniD7SxHTNw8bbsTCWz+AMod7vC7De3X\n\ +4Daxme+vD8adOfCeOIu5vNrlXLNST2yaTw==\n\ +-----END CERTIFICATE-----\n"; + +const VALID_CERT_2: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIDFTCCAf2gAwIBAgIUKckMakNVssdBbRUlVtyWZZPx7EcwDQYJKoZIhvcNAQEL\n\ +BQAwGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0yMB4XDTI2MDcyOTE4MzUwNFoX\n\ +DTM2MDcyNjE4MzUwNFowGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0yMIIBIjAN\n\ +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3pVKr4xNdWm+RIYVRuOv+8Pg3I3/\n\ +wsmC7m84I4bw6EofraYY1vTT8XYcWAspo++Tj1hYNAyfdtdrgdZT8dgsTqsVPzYz\n\ +rluGu03mu0aE9Ix2IieLvR9C0s+mYpsfCQYRjsL2wDD6fOAWN4wjj1R4XGgUZKCF\n\ +q8JirftcRBLGjAa8XXD496dUGXzURQ7C9jAxFmPWGbyz3f1ymOLBvp8RdzrJNCsA\n\ +zdEjqJODMMf0czJH5gtt06hIQG9JkPHNqZXVxEIBIDlkmkr9Nk/asqZGhbHILkHX\n\ +/jqfdOMb4Xu95iglbwbACgAtfysNQdjUU7hbjKxx4S4FCjf+gyb4whQo/QIDAQAB\n\ +o1MwUTAdBgNVHQ4EFgQUVrqEwVrKpoc/GinOYZR13TkjdwgwHwYDVR0jBBgwFoAU\n\ +VrqEwVrKpoc/GinOYZR13TkjdwgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\n\ +AQsFAAOCAQEAtK9ylmMIEQsuYm5Qo1pi4xp5rFywO0g5zkWEl/fIMBevP9Thhnco\n\ +gHiOFBhQcuo+Go65p3Fbbt3Vrx30Oi0hQUlYLlY44BO3/TgfZ0VbIheeDfyYaq97\n\ +S3I1cLHJ1qmKq99zKcqvCcD+NmifbuMi03Zo35Kp+jm8GXpONumnPlu17WZLw5N7\n\ +KFHbC1eO3iat27z4WRhPHG4vmPfMHIIvrbA+aEwc1b88NO5UdRmSHvkt4MDEOsIe\n\ +IgKmdcW5+BG5ffCRJ9wNsCCy165AFUmuNWz0aqDWybjK4eiEb88sHKbVv7fyXpwi\n\ +YwiFroodmakt1behpPy1p9Ih94MTqy9pQw==\n\ +-----END CERTIFICATE-----\n"; + +/// Valid PEM framing / base64, but DER is not an X.509 certificate. +const INVALID_DER_PEM: &str = "-----BEGIN CERTIFICATE-----\n\ +MAMBAf8=\n\ +-----END CERTIFICATE-----\n"; + +#[test] +fn parse_empty_bytes_returns_empty() { + let o = parse_and_validate_pem(b""); + assert!(o.accepted.is_empty()); + assert_eq!(o.rejected, 0); + assert!(o.no_pem_blocks); +} + +#[test] +fn parse_garbage_non_pem_flags_no_blocks_without_panic() { + let o = parse_and_validate_pem(b"this is not a certificate"); + assert!(o.accepted.is_empty()); + assert_eq!(o.rejected, 0); + assert!(o.no_pem_blocks); +} + +#[test] +fn parse_valid_single_cert_pem() { + let o = parse_and_validate_pem(VALID_CERT_1.as_bytes()); + assert_eq!(o.accepted.len(), 1); + assert_eq!(o.rejected, 0); + assert!(!o.no_pem_blocks); +} + +#[test] +fn parse_multi_cert_bundle() { + let o = parse_and_validate_pem(format!("{VALID_CERT_1}\n{VALID_CERT_2}").as_bytes()); + assert_eq!(o.accepted.len(), 2); + assert_eq!(o.rejected, 0); +} + +#[test] +fn parse_invalid_der_pem_rejected() { + let o = parse_and_validate_pem(INVALID_DER_PEM.as_bytes()); + assert!(o.accepted.is_empty()); + assert!(o.rejected >= 1); + assert!(!o.no_pem_blocks); +} + +#[test] +fn parse_mixed_bundle_keeps_valid_drops_invalid() { + let o = parse_and_validate_pem( + format!("{VALID_CERT_1}\n{INVALID_DER_PEM}\n{VALID_CERT_2}").as_bytes(), + ); + assert_eq!(o.accepted.len(), 2); + assert!(o.rejected >= 1); +} + +#[test] +fn validated_ders_build_reqwest_client() { + let o = parse_and_validate_pem(VALID_CERT_1.as_bytes()); + assert_eq!(o.accepted.len(), 1); + let mut builder = reqwest::Client::builder(); + for der in &o.accepted { + builder = builder.add_root_certificate( + reqwest::Certificate::from_der(der).expect("from_der after rustls validation"), + ); + } + builder + .build() + .expect("client with validated roots must construct"); +} + +#[test] +fn read_bundle_capped_rejects_oversized() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("huge.pem"); + std::fs::write(&path, vec![b'A'; (MAX_EXTRA_CA_BUNDLE_BYTES as usize) + 1]).unwrap(); + match read_bundle_capped(&path) { + Err(BundleReadError::TooLarge) => {} + other => panic!("expected TooLarge, got {other:?}"), + } +} + +#[test] +fn read_bundle_capped_accepts_at_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ok.pem"); + std::fs::write(&path, vec![b'B'; MAX_EXTRA_CA_BUNDLE_BYTES as usize]).unwrap(); + let got = read_bundle_capped(&path).expect("at-limit read"); + assert_eq!(got.len(), MAX_EXTRA_CA_BUNDLE_BYTES as usize); +} diff --git a/crates/codegen/xai-grok-extra-ca/tests/extra_ca_invalid_file.rs b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_invalid_file.rs new file mode 100644 index 0000000..84973b7 --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_invalid_file.rs @@ -0,0 +1,22 @@ +//! Process-isolated: missing GROK_EXTRA_CA_BUNDLE path → fail-open client build. + +#[test] +fn missing_bundle_path_builds_clients_without_panic() { + // Safety: sole test in this binary; set before any OnceLock resolve. + unsafe { + std::env::set_var( + xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE, + "/nonexistent/grok-extra-ca-bundle-invalid-file.pem", + ); + } + + assert!(xai_grok_extra_ca::extra_root_ders().is_empty()); + + xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder()) + .build() + .expect("async client builds when bundle is unreadable"); + + xai_grok_extra_ca::with_extra_root_certificates_blocking(reqwest::blocking::Client::builder()) + .build() + .expect("blocking client builds when bundle is unreadable"); +} diff --git a/crates/codegen/xai-grok-extra-ca/tests/extra_ca_oversized.rs b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_oversized.rs new file mode 100644 index 0000000..10372ea --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_oversized.rs @@ -0,0 +1,34 @@ +//! Process-isolated: oversize GROK_EXTRA_CA_BUNDLE → ignored; client still builds. + +use std::io::Write; + +#[test] +fn oversized_bundle_ignored_clients_build() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("oversized.pem"); + { + let mut f = std::fs::File::create(&path).expect("create"); + let chunk = vec![b'X'; 64 * 1024]; + let mut written = 0u64; + let target = xai_grok_extra_ca::MAX_EXTRA_CA_BUNDLE_BYTES + 1; + while written < target { + let n = ((target - written) as usize).min(chunk.len()); + f.write_all(&chunk[..n]).expect("write"); + written += n as u64; + } + } + + // Safety: sole test in this binary; set before any OnceLock resolve. + unsafe { + std::env::set_var( + xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE, + path.as_os_str(), + ); + } + + assert!(xai_grok_extra_ca::extra_root_ders().is_empty()); + + xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder()) + .build() + .expect("client builds after oversized reject"); +} diff --git a/crates/codegen/xai-grok-extra-ca/tests/extra_ca_valid_env.rs b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_valid_env.rs new file mode 100644 index 0000000..957feff --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_valid_env.rs @@ -0,0 +1,41 @@ +//! Process-isolated: valid GROK_EXTRA_CA_BUNDLE loads one root via OnceLock. + +#[test] +fn valid_bundle_loads_one_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ca.pem"); + const CERT: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIDFTCCAf2gAwIBAgIUT2czXTuxSAjDjEh92UMB1OVahZYwDQYJKoZIhvcNAQEL\n\ +BQAwGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMB4XDTI2MDcyOTE4MzUwNFoX\n\ +DTM2MDcyNjE4MzUwNFowGjEYMBYGA1UEAwwPdGVzdC1leHRyYS1jYS0xMIIBIjAN\n\ +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gNk2BQwUy+n5cCaTFtGpSzVQv//\n\ +d7QD+3QWeE411wIGJzp3nrd7np55X8JHxeg/pRhspQvLQAF7bt55LSkL/+sSth3S\n\ +QTbBqhftic9CXik3llAwbdQkAM9srz5zXWW9KVjZ57dxjjxrS15SCXu/UmvGZy98\n\ +faJcS++TRkczsNFzwQEqeDYARVc/no0C0I++NhGLPaNMfFAevvnu6Kt3CYMI5ls4\n\ +KCFgnlau4CjgRCMSfRDCRcwEwUAp+DyX9IU+tvDAQY1ncVoa/05tvaEvw7pQ+UgW\n\ +0wRG0lk7PLlcWmUkLcFpO+sL5GRkC8RoWM4cFbIOiXoVxUFks/z2y0GCEQIDAQAB\n\ +o1MwUTAdBgNVHQ4EFgQU+lyC70W5aR6BIf4VNtjfiWMNzzkwHwYDVR0jBBgwFoAU\n\ ++lyC70W5aR6BIf4VNtjfiWMNzzkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\n\ +AQsFAAOCAQEA02972nA7LshRgubz6BwXbh1gA5pLzTd5KEae+94Hq6mP2zJ1T0gk\n\ +x+me0NtSgG4BJLdBIylUzo2UmsfB/sz+ght6WX1uB38Vc2UQsp0sRPeeiMovSd6n\n\ +I7xZyuZEF3noYJVBBlKQ8XsCUIBNIROlyKlNjNcWY8tGqPh9cepvtZYkBgRZr1vW\n\ +hJAE3EOL2ZddrMPF64QeU9UhvCm0Ch+Ceqa1ZWE0MygccggX5s2yQwtXO2ovJdjH\n\ +6vW0I02r8sE+NX0d1u8rIPJEKlp89UwCwniD7SxHTNw8bbsTCWz+AMod7vC7De3X\n\ +4Daxme+vD8adOfCeOIu5vNrlXLNST2yaTw==\n\ +-----END CERTIFICATE-----\n"; + std::fs::write(&path, CERT).expect("write cert"); + + // Safety: sole test in this binary; set before any OnceLock resolve. + unsafe { + std::env::set_var( + xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE, + path.as_os_str(), + ); + } + + assert_eq!(xai_grok_extra_ca::extra_root_ders().len(), 1); + + xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder()) + .build() + .expect("client with env-loaded root builds"); +} diff --git a/crates/codegen/xai-grok-extra-ca/tests/extra_ca_zero_certs.rs b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_zero_certs.rs new file mode 100644 index 0000000..492b577 --- /dev/null +++ b/crates/codegen/xai-grok-extra-ca/tests/extra_ca_zero_certs.rs @@ -0,0 +1,22 @@ +//! Process-isolated: configured garbage file → zero roots; client still builds. + +#[test] +fn configured_garbage_file_yields_zero_roots_and_builds() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("garbage.pem"); + std::fs::write(&path, b"not a pem at all").expect("write"); + + // Safety: sole test in this binary; set before any OnceLock resolve. + unsafe { + std::env::set_var( + xai_grok_extra_ca::ENV_GROK_EXTRA_CA_BUNDLE, + path.as_os_str(), + ); + } + + assert!(xai_grok_extra_ca::extra_root_ders().is_empty()); + + xai_grok_extra_ca::with_extra_root_certificates(reqwest::Client::builder()) + .build() + .expect("client builds after zero-cert configured file"); +} diff --git a/crates/codegen/xai-grok-hooks/src/runner/command.rs b/crates/codegen/xai-grok-hooks/src/runner/command.rs index 492307a..59274cb 100644 --- a/crates/codegen/xai-grok-hooks/src/runner/command.rs +++ b/crates/codegen/xai-grok-hooks/src/runner/command.rs @@ -156,6 +156,7 @@ pub async fn run_command_hook( // See the `runner_injected_vars_override_extra_env_at_spawn` // regression test in `tests/integration.rs` and the rustdoc on // `HookSpec::extra_env`. + #[allow(clippy::disallowed_methods)] // enrolled in the session scope below let mut child = match cmd .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/crates/codegen/xai-grok-http/Cargo.toml b/crates/codegen/xai-grok-http/Cargo.toml index f491aa4..3b50a59 100644 --- a/crates/codegen/xai-grok-http/Cargo.toml +++ b/crates/codegen/xai-grok-http/Cargo.toml @@ -11,6 +11,7 @@ reqwest-middleware = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } xai-grok-auth = { workspace = true, features = ["middleware"] } +xai-grok-extra-ca = { workspace = true } xai-grok-sampler = { path = "../xai-grok-sampler" } xai-grok-telemetry = { workspace = true } xai-grok-version = { workspace = true } diff --git a/crates/codegen/xai-grok-http/src/lib.rs b/crates/codegen/xai-grok-http/src/lib.rs index 9552383..d0c8d68 100644 --- a/crates/codegen/xai-grok-http/src/lib.rs +++ b/crates/codegen/xai-grok-http/src/lib.rs @@ -24,7 +24,8 @@ //! a fresh client per `SamplingClient`. //! //! TLS root certificates are warmed at process start via -//! `warm_async_http_client()` (in `mvp_agent.rs`). +//! `warm_async_http_client()` (in `mvp_agent.rs`). Optional extra roots: +//! `GROK_EXTRA_CA_BUNDLE` via `xai_grok_extra_ca` (see env-var registry). use std::sync::OnceLock; @@ -320,16 +321,18 @@ pub fn shared_client() -> reqwest::Client { CLIENT .get_or_init(|| { let _timer = startup_timer!("startup.http_client_build"); - reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .user_agent(process_user_agent_string()) - .pool_idle_timeout(std::time::Duration::from_secs(30)) - .http2_keep_alive_interval(std::time::Duration::from_secs(20)) - .http2_keep_alive_timeout(std::time::Duration::from_secs(10)) - .http2_keep_alive_while_idle(true) - .tcp_keepalive(std::time::Duration::from_secs(30)) - .build() - .expect("failed to build shared HTTP client") + xai_grok_extra_ca::with_extra_root_certificates( + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .user_agent(process_user_agent_string()) + .pool_idle_timeout(std::time::Duration::from_secs(30)) + .http2_keep_alive_interval(std::time::Duration::from_secs(20)) + .http2_keep_alive_timeout(std::time::Duration::from_secs(10)) + .http2_keep_alive_while_idle(true) + .tcp_keepalive(std::time::Duration::from_secs(30)), + ) + .build() + .expect("failed to build shared HTTP client") }) .clone() } @@ -362,19 +365,21 @@ pub fn shared_upload_client() -> reqwest::Client { static UPLOAD_CLIENT: OnceLock = OnceLock::new(); UPLOAD_CLIENT .get_or_init(|| { - reqwest::Client::builder() - // Force HTTP/1.1: batch_upload multipart bodies are silently - // dropped when an HTTP/2 connection degrades (GOAWAY, flow-control - // exhaustion). Because all streams share one connection, a single - // bad connection causes every subsequent request to arrive with - // Content-Length: 0, producing thousands of 400s until the process - // restarts. HTTP/1.1 isolates failures to individual connections. - .http1_only() - .pool_max_idle_per_host(2) - .pool_idle_timeout(std::time::Duration::from_secs(10)) - .user_agent(process_user_agent_string()) - .build() - .expect("failed to build shared upload HTTP client") + xai_grok_extra_ca::with_extra_root_certificates( + reqwest::Client::builder() + // Force HTTP/1.1: batch_upload multipart bodies are silently + // dropped when an HTTP/2 connection degrades (GOAWAY, flow-control + // exhaustion). Because all streams share one connection, a single + // bad connection causes every subsequent request to arrive with + // Content-Length: 0, producing thousands of 400s until the process + // restarts. HTTP/1.1 isolates failures to individual connections. + .http1_only() + .pool_max_idle_per_host(2) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .user_agent(process_user_agent_string()), + ) + .build() + .expect("failed to build shared upload HTTP client") }) .clone() } @@ -387,11 +392,13 @@ pub fn shared_upload_client() -> reqwest::Client { /// Fallible: build can fail under fd/TLS pressure; the caller must not /// panic on error (fallback policy lives at the call site). pub(crate) fn fresh_http1_client() -> reqwest::Result { - reqwest::Client::builder() - .http1_only() - .pool_max_idle_per_host(0) - .user_agent(process_user_agent_string()) - .build() + xai_grok_extra_ca::with_extra_root_certificates( + reqwest::Client::builder() + .http1_only() + .pool_max_idle_per_host(0) + .user_agent(process_user_agent_string()), + ) + .build() } /// Joins an error's `source()` chain into one string. A `reqwest::Error`'s `Display` @@ -543,14 +550,16 @@ pub fn shared_startup_blocking_client() -> reqwest::blocking::Client { BLOCKING_CLIENT .get_or_init(|| { let _timer = startup_timer!("startup.http_blocking_client_build"); - reqwest::blocking::Client::builder() - .connect_timeout(STARTUP_FETCH_TIMEOUT) - .timeout(STARTUP_FETCH_TIMEOUT) - .user_agent(process_user_agent_string()) - .pool_idle_timeout(std::time::Duration::from_secs(30)) - .tcp_keepalive(std::time::Duration::from_secs(30)) - .build() - .expect("failed to build shared blocking HTTP client") + xai_grok_extra_ca::with_extra_root_certificates_blocking( + reqwest::blocking::Client::builder() + .connect_timeout(STARTUP_FETCH_TIMEOUT) + .timeout(STARTUP_FETCH_TIMEOUT) + .user_agent(process_user_agent_string()) + .pool_idle_timeout(std::time::Duration::from_secs(30)) + .tcp_keepalive(std::time::Duration::from_secs(30)), + ) + .build() + .expect("failed to build shared blocking HTTP client") }) .clone() } diff --git a/crates/codegen/xai-grok-mcp/Cargo.toml b/crates/codegen/xai-grok-mcp/Cargo.toml index 940b600..8b143b5 100644 --- a/crates/codegen/xai-grok-mcp/Cargo.toml +++ b/crates/codegen/xai-grok-mcp/Cargo.toml @@ -14,6 +14,7 @@ rmcp = { version = "2.1", features = [ "transport-streamable-http-client-reqwest", "reqwest", ] } +xai-grok-extra-ca = { workspace = true } xai-grok-version = { workspace = true } # reqwest 0.13 feature set for MCP transports. Notably: # - `blocking` is for build-script style use (not actually used here today, but diff --git a/crates/codegen/xai-grok-mcp/src/credentials.rs b/crates/codegen/xai-grok-mcp/src/credentials.rs index e18750a..82ef731 100644 --- a/crates/codegen/xai-grok-mcp/src/credentials.rs +++ b/crates/codegen/xai-grok-mcp/src/credentials.rs @@ -121,22 +121,12 @@ impl McpCredentialStore { self.save_to(&path) } - /// Atomically insert a credential and save — safe for concurrent use. - /// - /// Instead of the caller doing `insert_rmcp` + `save_default` (which races - /// with other processes), this method: - /// 1. Acquires a file lock on `mcp_credentials.json.lock` - /// 2. Reloads the store from disk (picks up other processes' writes) - /// 3. Inserts the new entry - /// 4. Saves atomically (temp + rename) - /// 5. Updates `self` with the merged result - /// 6. Releases the lock - pub fn insert_and_save( - &mut self, - server_name: &str, - server_url: &url::Url, - creds: rmcp::transport::auth::StoredCredentials, - ) -> Result<()> { + /// Read-modify-write the **default** store under the cross-process + /// `mcp_credentials.json.lock` flock: reload from disk (merging concurrent + /// writers), apply `mutate`, save atomically, and update `self` with the + /// merged result. On flock failure (non-EINTR error, or non-Unix), falls + /// back to mutating `self` and saving best-effort — the pre-lock behavior. + fn locked_mutate_and_save(&mut self, mutate: &dyn Fn(&mut Self)) -> Result<()> { let path = Self::default_path().ok_or_else(|| { McpCredentialError::Other("no user grok home (set $GROK_HOME or $HOME)".into()) })?; @@ -165,14 +155,14 @@ impl McpCredentialStore { if err.kind() == std::io::ErrorKind::Interrupted { continue; // Retry on EINTR. } - // Lock failed for another reason — fall back to non-atomic insert. - self.insert_rmcp(server_name, server_url, creds); + // Lock failed for another reason — fall back to non-atomic write. + mutate(self); return self.save_to(&path); } // Reload from disk under lock to merge with concurrent writes. let mut fresh = Self::load_from(&path).unwrap_or_default(); - fresh.insert_rmcp(server_name, server_url, creds); + mutate(&mut fresh); fresh.save_to(&path)?; *self = fresh; @@ -182,13 +172,38 @@ impl McpCredentialStore { #[cfg(not(unix))] { // No flock on non-unix — best-effort. - self.insert_rmcp(server_name, server_url, creds); + mutate(self); self.save_to(&path)?; } Ok(()) } + /// Locked insert ([`Self::locked_mutate_and_save`]) with a freshness + /// guard: skipped when the disk entry is strictly newer by + /// `token_received_at` (see [`disk_entry_is_newer`]) — otherwise a slow + /// writer (canonically a refresh suspended across system sleep that + /// completes after wake) rolls the stored refresh token back to a + /// rotated-out value (`invalid_grant` on its next use). + pub fn insert_and_save( + &mut self, + server_name: &str, + server_url: &url::Url, + creds: rmcp::transport::auth::StoredCredentials, + ) -> Result<()> { + let key = Self::key(server_name, server_url); + self.locked_mutate_and_save(&move |store: &mut Self| { + if disk_entry_is_newer(store.entries.get(&key), &creds) { + tracing::info!( + key = key.as_str(), + "mcp credentials: skipping stale save (disk entry is newer)" + ); + return; + } + store.entries.insert(key.clone(), creds.clone()); + }) + } + /// Save to a specific path. /// /// Writes atomically via temp file + rename to prevent credential loss on @@ -273,6 +288,18 @@ impl McpCredentialStore { self.entries.remove(&Self::key(server_name, server_url)); } + /// Remove a server's credentials and persist, under the cross-process + /// file lock (reload-merge → remove → atomic save). The locked + /// counterpart of [`Self::remove`] + [`Self::save_default`] for callers + /// that persist the removal — an unlocked whole-file rewrite can drop + /// other processes' concurrent writes for unrelated servers. + pub fn remove_and_save(&mut self, server_name: &str, server_url: &Url) -> Result<()> { + let key = Self::key(server_name, server_url); + self.locked_mutate_and_save(&move |store: &mut Self| { + store.entries.remove(&key); + }) + } + /// Remove all credentials for a server by name (any URL). pub fn remove_by_server_name(&mut self, server_name: &str) -> usize { let prefix = format!("{server_name}:"); @@ -292,6 +319,23 @@ impl McpCredentialStore { } } +/// `true` when the on-disk `existing` entry is strictly newer than the +/// `incoming` credentials by `token_received_at` — the [`Self::insert_and_save`] +/// freshness guard. Missing timestamps on either side compare as "not newer" +/// (the write proceeds), preserving pre-guard behavior for expiry-less tokens. +fn disk_entry_is_newer( + existing: Option<&rmcp::transport::auth::StoredCredentials>, + incoming: &rmcp::transport::auth::StoredCredentials, +) -> bool { + match ( + existing.and_then(|e| e.token_received_at), + incoming.token_received_at, + ) { + (Some(existing), Some(incoming)) => existing > incoming, + _ => false, + } +} + /// Adapter implementing rmcp's `CredentialStore` trait backed by the on-disk /// `McpCredentialStore`. Each adapter instance is scoped to a single MCP server /// (keyed by name + URL); rmcp's `AuthorizationManager` calls load/save/clear @@ -349,10 +393,13 @@ impl rmcp::transport::auth::CredentialStore for McpCredentialStoreAdapter { let name = self.server_name.clone(); let url = self.server_url.clone(); tokio::task::spawn_blocking(move || { + // Under the same flock as `insert_and_save`: this is a whole-file + // read-modify-write, and an unlocked snapshot here could silently + // drop *other servers'* entries written concurrently by another + // process (their just-rotated refresh tokens with them). let mut store = McpCredentialStore::load_default().unwrap_or_default(); - store.remove(&name, &url); store - .save_default() + .remove_and_save(&name, &url) .map_err(|e| rmcp::transport::auth::AuthError::InternalError(e.to_string())) }) .await @@ -515,4 +562,76 @@ mod tests { let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600); } + + /// The `insert_and_save` freshness guard: a save older (by + /// `token_received_at`) than the on-disk entry must be skipped. + #[test] + fn stale_save_does_not_clobber_newer_disk_entry() { + // `StoredCredentials` is #[non_exhaustive]; construct via `new` and + // set the (public) timestamp field afterwards. + let mut older = test_stored_creds("c"); + older.token_received_at = Some(1_000); + let mut newer = test_stored_creds("c"); + newer.token_received_at = Some(2_000); + let no_ts = test_stored_creds("c"); + + assert!( + disk_entry_is_newer(Some(&newer), &older), + "older incoming vs newer disk → skip the write" + ); + assert!( + !disk_entry_is_newer(Some(&older), &newer), + "newer incoming vs older disk → write proceeds" + ); + assert!( + !disk_entry_is_newer(Some(&older), &older), + "equal timestamps → write proceeds (idempotent re-save)" + ); + assert!( + !disk_entry_is_newer(None, &older), + "no disk entry → write proceeds" + ); + assert!( + !disk_entry_is_newer(Some(&newer), &no_ts), + "timestamp-less incoming keeps pre-guard behavior (writes)" + ); + assert!( + !disk_entry_is_newer(Some(&no_ts), &older), + "timestamp-less disk entry keeps pre-guard behavior (writes)" + ); + } + + /// The refresh-failure classifier that gates browser escalation + /// (`force_reauth`): network-level failures — the `oauth2` crate's + /// `Display` for request/parse errors — are transient; IdP rejections and + /// missing-credential states stay terminal (escalate, as before). + #[test] + fn refresh_failure_transient_classification() { + use crate::servers::mcp_refresh_failure_is_transient; + use rmcp::transport::auth::AuthError; + + // oauth2 RequestTokenError::Request renders exactly "Request failed". + assert!(mcp_refresh_failure_is_transient( + &AuthError::TokenRefreshFailed("Request failed".into()) + )); + // 5xx/proxy bodies that aren't OAuth JSON parse-fail. + assert!(mcp_refresh_failure_is_transient( + &AuthError::TokenRefreshFailed("Failed to parse server response".into()) + )); + + // IdP rejections carry the RFC 6749 code → terminal. + assert!(!mcp_refresh_failure_is_transient( + &AuthError::TokenRefreshFailed( + "Server returned error response: invalid_grant: token revoked".into() + ) + )); + // No refresh token at all → only the browser flow can help. + assert!(!mcp_refresh_failure_is_transient( + &AuthError::TokenRefreshFailed("No refresh token available".into()) + )); + // Empty credential store → interactive auth required. + assert!(!mcp_refresh_failure_is_transient( + &AuthError::AuthorizationRequired + )); + } } diff --git a/crates/codegen/xai-grok-mcp/src/oauth.rs b/crates/codegen/xai-grok-mcp/src/oauth.rs index ba1c35d..7a2ecfc 100644 --- a/crates/codegen/xai-grok-mcp/src/oauth.rs +++ b/crates/codegen/xai-grok-mcp/src/oauth.rs @@ -28,6 +28,22 @@ const MCP_OAUTH_CLIENT_NAME: &str = "Grok"; /// a login completed in another window or process. const CREDENTIAL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); +/// Overall budget for one interactive browser consent flow (waiting for the +/// loopback callback / disk poll after opening the browser). Mirrors the main +/// grok.com login's 10-minute callback budget. Without a bound, an abandoned +/// browser tab left the leader parked in its `select!` forever — holding both +/// the in-process watch channel and the cross-process `mcp_auth_*.lock`, so +/// every other session blocked indefinitely on the same server's auth. +const BROWSER_AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); + +/// How long a follower waits for the cross-process auth lock before giving up +/// on dedup and proceeding with its own flow. Slightly above +/// [`BROWSER_AUTH_TIMEOUT`] so a legitimately-slow leader (user reading the +/// consent screen) finishes first and the follower reuses its token. +#[cfg(unix)] +const AUTH_LOCK_WAIT: std::time::Duration = + BROWSER_AUTH_TIMEOUT.saturating_add(std::time::Duration::from_secs(60)); + // --------------------------------------------------------------------------- // Two-layer dedup: prevents duplicate browser tabs both within one process // (multiple async tasks / sessions) and across separate processes (leader @@ -185,31 +201,49 @@ async fn authenticate_with_fs_lock( } }; + // Bounded, non-blocking poll instead of an unbounded `flock(LOCK_EX)`: + // the leader can legitimately hold this lock for minutes (user consent), + // but an abandoned/wedged leader must not park followers forever. On + // timeout we fall back to running our own flow (same as lock-acquisition + // failure), which the token-changed re-check below keeps from producing a + // duplicate consent when the leader did finish. let lock_file = tokio::task::spawn_blocking(move || { use std::os::unix::io::AsRawFd; let fd = lock_file.as_raw_fd(); + let deadline = std::time::Instant::now() + AUTH_LOCK_WAIT; loop { - if unsafe { libc::flock(fd, libc::LOCK_EX) } == 0 { + if unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0 { return Some(lock_file); } let err = std::io::Error::last_os_error(); - if err.kind() == std::io::ErrorKind::Interrupted { - continue; + match err.kind() { + std::io::ErrorKind::Interrupted => continue, + std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + _ => return None, } - return None; } }) .await .ok() .flatten(); - let Some(_lock_guard) = lock_file else { - tracing::warn!("Failed to acquire auth lock; proceeding without cross-process dedup"); - return run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await; - }; + if lock_file.is_none() { + tracing::warn!("Timed out waiting for auth lock; re-checking the store before a new flow"); + } + // On timeout: proceed unlocked; the token-changed re-check below + // dedups a leader that finished just past our deadline. + let _lock_guard = lock_file; - // We hold the lock. Reload from disk and check if another process - // wrote a DIFFERENT token while we waited (not just any token). + // Reload from disk and check whether another process wrote a DIFFERENT + // token while we waited (not just any token). This runs on the timeout + // path too: a leader whose token exchange finished just past our deadline + // has already written fresh tokens, and opening a second consent browser + // would be strictly worse than this unlocked best-effort read. { let mut mgr = auth_manager.lock().await; if let Ok(true) = mgr.initialize_from_store().await { @@ -436,6 +470,22 @@ async fn run_browser_auth_flow( "Fresh tokens detected on disk from another auth flow; skipping callback wait" ); } + // Abandoned consent: bound the wait so this leader releases the + // in-process watch and the cross-process `mcp_auth_*.lock` instead of + // wedging every future auth attempt for this server (see + // `BROWSER_AUTH_TIMEOUT`). + _ = tokio::time::sleep(BROWSER_AUTH_TIMEOUT) => { + callback_server.abort(); + tracing::warn!( + server = server_name, + timeout_secs = BROWSER_AUTH_TIMEOUT.as_secs(), + "OAuth consent timed out (browser flow abandoned?)" + ); + return Err(format!( + "OAuth consent timed out after {}s; re-run authentication to try again", + BROWSER_AUTH_TIMEOUT.as_secs() + )); + } } Ok(()) diff --git a/crates/codegen/xai-grok-mcp/src/servers.rs b/crates/codegen/xai-grok-mcp/src/servers.rs index 8fdf567..efa1e4b 100644 --- a/crates/codegen/xai-grok-mcp/src/servers.rs +++ b/crates/codegen/xai-grok-mcp/src/servers.rs @@ -44,6 +44,20 @@ use xai_grok_tools::util::{ProcessGroup, ProcessScope}; /// for callers that historically imported it from this module. pub use xai_grok_workspace_types::MCP_TOOL_NAME_DELIMITER; +/// Reqwest 0.13 adapter over `xai_grok_extra_ca::extra_root_ders` (DER is version-neutral). +fn with_extra_root_certificates(mut builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + for der in xai_grok_extra_ca::extra_root_ders() { + match reqwest::Certificate::from_der(der) { + Ok(cert) => builder = builder.add_root_certificate(cert), + Err(e) => tracing::warn!( + error = %e, + "GROK_EXTRA_CA_BUNDLE: validated DER rejected by reqwest 0.13; skipping cert" + ), + } + } + builder +} + /// Normalize an MCP server URL for comparison: strip trailing slashes. /// Must match the normalization the host's managed-config layer uses /// (e.g. shell's `session::managed_mcp::normalize_url`) so refresh @@ -1148,6 +1162,24 @@ impl McpError { } } +/// True when a failed refresh-token grant was a **network-level** failure +/// that never reached the IdP (RT validity unknown, presumed good); IdP +/// rejections and missing credentials stay terminal (escalate to browser). +/// rmcp 2.1 collapses the error into `TokenRefreshFailed(String)`, so this +/// anchors on the `oauth2` crate's stable `Display` texts via +/// `starts_with` (an IdP error description can't spoof a match): +/// `"Request failed"` = network, `"Failed to parse server response"` = +/// non-OAuth 5xx/proxy bodies; `"Server returned error response: …"` does +/// NOT match. +pub(crate) fn mcp_refresh_failure_is_transient(err: &rmcp::transport::auth::AuthError) -> bool { + match err { + rmcp::transport::auth::AuthError::TokenRefreshFailed(msg) => { + msg.starts_with("Request failed") || msg.starts_with("Failed to parse server response") + } + _ => false, + } +} + /// True if an MCP error *message* indicates an auth rejection (vs. a transport /// drop, timeout, or protocol error), so host recovery can decide whether a /// credential re-fetch would help. @@ -2037,6 +2069,7 @@ impl SafeTokioChildProcess { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + #[allow(clippy::disallowed_methods)] // enrolled in the session scope below let mut child = cmd.spawn()?; let stdin = child .stdin @@ -2810,7 +2843,12 @@ impl McpClient { /// Tries in order: /// 1. Reload from disk (picks up tokens from background auth task) /// 2. Refresh via refresh_token grant - /// 3. Full browser-based OAuth flow + /// 3. Full browser-based OAuth flow — unless the refresh failure was a + /// pure network failure ([`mcp_refresh_failure_is_transient`]): the + /// stored refresh token is then still presumed valid, and opening a + /// browser tab / re-running DCR for a Wi-Fi blip right after + /// wake-from-sleep is both useless (the IdP is unreachable for the + /// browser too) and destructive (it discards a working credential). pub async fn force_reauth(&self, force: bool) -> bool { let (Some(auth_mgr), Some(config)) = (&self.auth_manager, &self.http_config) else { return false; @@ -2861,22 +2899,43 @@ impl McpClient { } // Try token refresh. - let refresh_ok = { + let refresh_result = { let mgr = auth_mgr.lock().await; - mgr.refresh_token().await.is_ok() + mgr.refresh_token().await }; - if refresh_ok { - tracing::info!( - server = self.server_name.as_str(), - "Token refreshed successfully (no browser)" - ); - self.replace_state(ClientState::Pending(PendingTransport::HttpAuth { - config: config.clone(), - auth_manager: auth_mgr.clone(), - })) - .await; - return true; + match refresh_result { + Ok(_) => { + tracing::info!( + server = self.server_name.as_str(), + "Token refreshed successfully (no browser)" + ); + self.replace_state(ClientState::Pending(PendingTransport::HttpAuth { + config: config.clone(), + auth_manager: auth_mgr.clone(), + })) + .await; + return true; + } + // Transient (network never reached the IdP): fail the attempt + // instead of discarding a presumed-good credential — the retry + // paths re-run the refresh once the network is back. An explicit + // user trigger (`force`) still opens the browser. + Err(ref e) if !force && mcp_refresh_failure_is_transient(e) => { + tracing::warn!( + server = self.server_name.as_str(), + error = %e, + "Token refresh failed transiently (network); skipping browser escalation" + ); + return false; + } + Err(e) => { + tracing::info!( + server = self.server_name.as_str(), + error = %e, + "Token refresh failed terminally; falling back to browser auth" + ); + } } // Full browser-based OAuth flow. @@ -3416,12 +3475,11 @@ impl McpClient { } } ensure_figma_user_agent(&mut headers, name, &config.url); - let http_client = reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(|e| { - McpError::ClientError(format!("Failed to build HTTP client: {e}")) - })?; + let http_client = with_extra_root_certificates( + reqwest::Client::builder().default_headers(headers), + ) + .build() + .map_err(|e| McpError::ClientError(format!("Failed to build HTTP client: {e}")))?; // `AuthClient::new` wants an owned manager, but ours is shared // (`Arc`) with the OAuth flow; the struct is non_exhaustive, so // build with a throwaway manager and swap in the shared one. @@ -3626,10 +3684,10 @@ impl McpClient { } } ensure_figma_user_agent(&mut headers, server_name, &config.url); - let client = reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(|e| McpError::ClientError(format!("Failed to build HTTP client: {e}")))?; + let client = + with_extra_root_certificates(reqwest::Client::builder().default_headers(headers)) + .build() + .map_err(|e| McpError::ClientError(format!("Failed to build HTTP client: {e}")))?; let mcp_http_client = crate::mcp_http_client::McpHttpClient::new(client, server_name, warn_budget); let transport_config = StreamableHttpClientTransportConfig::with_uri(config.url.as_str()); diff --git a/crates/codegen/xai-grok-mermaid/src/subprocess.rs b/crates/codegen/xai-grok-mermaid/src/subprocess.rs index a1b7788..eb7a24f 100644 --- a/crates/codegen/xai-grok-mermaid/src/subprocess.rs +++ b/crates/codegen/xai-grok-mermaid/src/subprocess.rs @@ -98,6 +98,7 @@ pub fn run_with_timeout( /// race. It is transient and clears within milliseconds, so retry a few times /// with a short backoff. (No-op on the steady-state path; only the failing /// transient case changes behaviour.) +#[allow(clippy::disallowed_methods)] // the caller owns the reap fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result { const MAX_ATTEMPTS: u32 = 5; let mut attempt = 0; @@ -273,6 +274,7 @@ mod tests { let mut cmd = Command::new("sleep"); cmd.arg("30"); let mut cmd = detached(cmd); + #[allow(clippy::disallowed_methods)] // test fixture; the test kills it let mut child = cmd.spawn().expect("spawn sleep"); let pid = child.id() as libc::pid_t; diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index 35a9d6a..cedf8c8 100644 --- a/crates/codegen/xai-grok-pager-bin/Cargo.toml +++ b/crates/codegen/xai-grok-pager-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager-bin" -version = "0.2.114" +version = "0.2.116" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager-bin/src/main.rs b/crates/codegen/xai-grok-pager-bin/src/main.rs index f3485aa..49bb94c 100644 --- a/crates/codegen/xai-grok-pager-bin/src/main.rs +++ b/crates/codegen/xai-grok-pager-bin/src/main.rs @@ -2156,6 +2156,7 @@ async fn async_main(args: PagerArgs) -> Result<()> { yolo: launch_yolo.yolo, trust: args.trust, output_format: args.output_format, + include_partial_messages: args.include_partial_messages, json_schema, model: args.model, rules: args.rules, diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/host_clipboard.rs b/crates/codegen/xai-grok-pager-pty-harness/src/host_clipboard.rs index f43ae46..13d019d 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/host_clipboard.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/host_clipboard.rs @@ -25,6 +25,7 @@ pub fn pbcopy(text: &str) -> Result<()> { let mut cmd = Command::new("pbcopy"); cmd.stdin(Stdio::piped()); xai_tty_utils::detach_std_command(&mut cmd); + #[allow(clippy::disallowed_methods)] // short-lived clipboard helper, waited on below let mut child = cmd.spawn().context("spawn pbcopy")?; child .stdin @@ -52,6 +53,7 @@ pub fn pbcopy(text: &str) -> Result<()> { ]) .stdin(Stdio::piped()); xai_tty_utils::detach_std_command(&mut cmd); + #[allow(clippy::disallowed_methods)] // short-lived clipboard helper, waited on below let mut child = cmd.spawn().context("spawn powershell Set-Clipboard")?; child .stdin diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs index 427bd1b..c5c092c 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs @@ -187,6 +187,7 @@ fn write_tmux_buffer(text: &str) -> bool { .stdout(Stdio::null()) .stderr(Stdio::null()); xai_tty_utils::detach_std_command(&mut cmd); + #[allow(clippy::disallowed_methods)] // short-lived clipboard helper, waited on below let mut child = cmd.spawn()?; // Bounded wait: a wedged tmux server must not freeze the UI thread. let status = xai_grok_shared::clipboard::wait_with_deadline( diff --git a/crates/codegen/xai-grok-pager-render/src/glyphs.rs b/crates/codegen/xai-grok-pager-render/src/glyphs.rs index 172266a..e5fe5e8 100644 --- a/crates/codegen/xai-grok-pager-render/src/glyphs.rs +++ b/crates/codegen/xai-grok-pager-render/src/glyphs.rs @@ -482,6 +482,21 @@ pub fn legacy_glyph_fallback(s: &str) -> Cow<'_, str> { Cow::Owned(to_legacy_glyphs(s)) } +/// Single-row toast sinks: glyph fallback, then map control chars to spaces. +/// Borrows when the input is already clean (common path). +pub fn sanitize_toast_message(msg: &str) -> Cow<'_, str> { + let glyph = legacy_glyph_fallback(msg); + if !glyph.chars().any(char::is_control) { + return glyph; + } + Cow::Owned( + glyph + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(), + ) +} + /// Pure glyph → legacy-safe mapping behind [`legacy_glyph_fallback`], split /// out so tests can exercise the substitution without faking the host probe. /// `√` matches [`check_mark`]'s fallback; `x` matches [`ballot_x`]'s. @@ -734,6 +749,22 @@ mod tests { )); } + #[test] + fn sanitize_toast_message_borrows_when_clean() { + assert!(!is_legacy_windows_console()); + assert!(matches!( + sanitize_toast_message("plain toast"), + Cow::Borrowed("plain toast") + )); + } + + #[test] + fn sanitize_toast_message_maps_controls_to_spaces() { + let out = sanitize_toast_message("a\nb\tc"); + assert_eq!(out.as_ref(), "a b c"); + assert!(!out.chars().any(char::is_control)); + } + #[test] fn forced_legacy_console_override_parses_known_values() { assert_eq!(parse_forced_legacy_console(Some("1")), Some(true)); diff --git a/crates/codegen/xai-grok-pager-render/src/link_opener.rs b/crates/codegen/xai-grok-pager-render/src/link_opener.rs index d907daa..f6aa7a1 100644 --- a/crates/codegen/xai-grok-pager-render/src/link_opener.rs +++ b/crates/codegen/xai-grok-pager-render/src/link_opener.rs @@ -45,10 +45,23 @@ pub fn browser_open_likely_available() -> bool { browser_open_likely_available_from_env(&env) } -/// User-facing copy when the browser opener cannot run. Includes the full -/// URL on its own line so it is easy to select/copy in the TUI. +const BROWSER_UNAVAILABLE_NOTICE: &str = "Could not open a browser. Open this URL manually"; + +/// Multi-line copy for agent scrollback: notice, then the full URL alone +/// so it is easy to select/copy in the TUI. pub fn browser_unavailable_message(url: &str) -> String { - format!("Could not open a browser. Open this URL manually:\n{url}") + format!("{BROWSER_UNAVAILABLE_NOTICE}:\n{url}") +} + +/// Single-line welcome toast: URL first so prefix truncation keeps the +/// destination. `copied` is true only when clipboard delivery reported +/// success — never claim a copy that did not happen. +pub fn browser_unavailable_line(url: &str, copied: bool) -> String { + if copied { + format!("{url} — {BROWSER_UNAVAILABLE_NOTICE} (URL copied)") + } else { + format!("{url} — {BROWSER_UNAVAILABLE_NOTICE}") + } } /// Open a URL in the system's default browser/handler. @@ -59,10 +72,12 @@ pub fn browser_unavailable_message(url: &str) -> String { /// /// Returns `true` when the opener was launched (or the test seam recorded /// the URL). Returns `false` when the environment looks headless or spawn -/// fails — callers should show [`browser_unavailable_message`]. +/// fails — callers should surface the URL via [`browser_unavailable_message`] +/// (scrollback) or [`browser_unavailable_line`] (welcome toast). /// /// **Callers handling untrusted input** should call [`is_safe_to_open`] /// first, or use [`open_url_if_safe`] / [`try_open_url`] which combine both. +#[allow(clippy::disallowed_methods)] // fire and forget; the child is reaped when this process exits pub fn open_url(url: &str) -> bool { // Test seam: PTY e2e must observe the open without launching a real // browser. When set, append the URL to the file and skip the OS opener. @@ -156,6 +171,7 @@ fn build_open_path_command(path: &std::path::Path) -> std::process::Command { /// expansion corrupts the percent-encoded session-directory segment in /// imagine media paths (e.g. `…\C%3A%5CUsers…`). /// - **macOS / Linux**: `open` / `xdg-open` open the file in its default app. +#[allow(clippy::disallowed_methods)] // fire and forget; the child is reaped when this process exits pub fn open_path(path: &std::path::Path) -> bool { // Never launch a real GUI app in tests. #[cfg(test)] @@ -189,6 +205,7 @@ pub fn open_path(path: &std::path::Path) -> bool { /// Prefer the on-disk path as-is. When the file is missing, open the parent /// folder (no `/select`) so the user lands near the media instead of Home. #[cfg(all(not(test), target_os = "windows"))] +#[allow(clippy::disallowed_methods)] // fire and forget; the child is reaped when this process exits fn reveal_in_explorer(path: &std::path::Path) -> bool { use std::os::windows::process::CommandExt; @@ -569,11 +586,34 @@ mod tests { #[test] fn browser_unavailable_message_includes_full_url() { let url = "https://grok.com/supergrok?referrer=grok-build"; - let msg = browser_unavailable_message(url); - assert!(msg.contains("Could not open a browser")); - assert!(msg.contains(url)); - // URL on its own line for easy select/copy in the TUI. - assert!(msg.lines().any(|l| l == url)); + assert_eq!( + browser_unavailable_message(url), + format!("{BROWSER_UNAVAILABLE_NOTICE}:\n{url}") + ); + } + + #[test] + fn browser_unavailable_line_is_url_first_single_line() { + let url = "https://grok.com/supergrok?referrer=grok-build"; + let plain = browser_unavailable_line(url, false); + assert!(plain.starts_with(url), "{plain}"); + assert!(!plain.contains('\n'), "{plain}"); + assert!( + !plain.to_ascii_lowercase().contains("copied"), + "must not claim copy on failure: {plain}" + ); + assert!( + plain.contains(BROWSER_UNAVAILABLE_NOTICE), + "shares notice stem with multi-line form: {plain}" + ); + + let with_copy = browser_unavailable_line(url, true); + assert!(with_copy.starts_with(url), "{with_copy}"); + assert!(!with_copy.contains('\n'), "{with_copy}"); + assert!( + with_copy.contains("URL copied"), + "copy claim only when copied=true: {with_copy}" + ); } #[test] diff --git a/crates/codegen/xai-grok-pager-render/src/terminal/tmux_probe.rs b/crates/codegen/xai-grok-pager-render/src/terminal/tmux_probe.rs index 89e4977..39a120d 100644 --- a/crates/codegen/xai-grok-pager-render/src/terminal/tmux_probe.rs +++ b/crates/codegen/xai-grok-pager-render/src/terminal/tmux_probe.rs @@ -42,6 +42,7 @@ fn run_tmux_bounded( timeout: Duration, ) -> Result { let mut command = build_tmux_command(command); + #[allow(clippy::disallowed_methods)] // bounded probe, waited on with a timeout let mut child = command .spawn() .map_err(|error| format!("failed to run tmux: {error}"))?; diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml index 758af4c..804a7db 100644 --- a/crates/codegen/xai-grok-pager/Cargo.toml +++ b/crates/codegen/xai-grok-pager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager" -version = "0.2.114" +version = "0.2.116" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] @@ -196,6 +196,10 @@ harness = false name = "edit_highlight" harness = false +[[bench]] +name = "resize" +harness = false + # PTY integration tests are split into coherent scheduling families. The test # modules remain under tests/pty_e2e/; these roots only define which cases share # one Cargo/Bazel process. All cases stay #[ignore]d for ordinary Cargo runs. diff --git a/crates/codegen/xai-grok-pager/benches/resize.rs b/crates/codegen/xai-grok-pager/benches/resize.rs new file mode 100644 index 0000000..ca5314f --- /dev/null +++ b/crates/codegen/xai-grok-pager/benches/resize.rs @@ -0,0 +1,195 @@ +//! Criterion benchmarks for the terminal-RESIZE path. +//! +//! Dragging a terminal edge sends a stream of `Event::Resize`, and the +//! reported symptom is that the drag gets laggier the longer a session runs. +//! +//! Regressions these guard against, each of which was measured on a real +//! session and removed: +//! - re-deriving an entry's source text per width instead of reusing its +//! cached line-width profile (makes the estimate pass O(conversation bytes)), +//! - building or cloning an `AppearanceConfig` per entry, +//! - running `warm_measure_pages_above` on every resize instead of once the +//! width settles. + +use std::time::Duration; + +use criterion::{Criterion, criterion_group, criterion_main}; + +use xai_grok_pager::scrollback::{RenderBlock, ScrollbackState}; + +/// Roughly a VS Code editor pane maximized on a laptop screen. +const VIEWPORT_WIDTH: u16 = 120; +const VIEWPORT_HEIGHT: u16 = 50; + +/// ~3,200 entries / ~5 MB of text — the scale of a multi-hour session. +const TURNS: usize = 400; + +fn agent_markdown(i: usize) -> String { + format!( + "Here is what I found for step {i}.\n\n\ + The `ScrollbackState` keeps a layout cache keyed by width, so a resize \ + invalidates every entry. That matters because the estimate pass has to \ + re-derive each block's source text before it can compute a height.\n\n\ + - first observation about entry {i}\n\ + - second observation, slightly longer, about how the wrap cache is keyed \ + on `(width, generation, theme)` and therefore misses after a drag\n\ + - third observation\n\n\ + ```rust\n\ + fn rebuild_layout_cache(&mut self, width: u16) {{\n\ + \x20 for entry in self.entries.values() {{\n\ + \x20 let renderer = EntryRenderer::new(entry, &theme)\n\ + \x20 .with_appearance(self.appearance.clone());\n\ + \x20 let height = renderer.estimate_height(width);\n\ + \x20 }}\n\ + }}\n\ + ```\n\n\ + In short: the {i}th response re-wraps on every width change, and the \ + syntax highlighting of the fence above is recomputed with it. \ + {}\n", + "Additional prose so the message spans several wrapped rows. ".repeat(6) + ) +} + +fn thinking_text(i: usize) -> String { + format!( + "Considering approach {i}. {}", + "The user asked about resize latency, so I should look at the layout cache. ".repeat(8) + ) +} + +fn edit_texts(i: usize) -> (String, String) { + let old = format!( + "fn handler_{i}(req: Request) -> Response {{\n\ + \x20 let body = req.body();\n\ + \x20 let parsed = serde_json::from_slice(body)?;\n\ + \x20 Response::ok(parsed)\n\ + }}\n" + ); + let new = format!( + "fn handler_{i}(req: Request) -> Response {{\n\ + \x20 let body = req.body();\n\ + \x20 let parsed: Payload = serde_json::from_slice(body)\n\ + \x20 .map_err(|e| Error::BadRequest(e.to_string()))?;\n\ + \x20 Response::ok(parsed)\n\ + }}\n" + ); + (old, new) +} + +fn bash_output(i: usize) -> String { + (0..40) + .map(|l| format!("crates/codegen/xai-grok-pager/src/file_{i}_{l}.rs:{l}: match found")) + .collect::>() + .join("\n") +} + +/// Block mix and proportions follow what a real coding session produces. +fn build_session() -> (ScrollbackState, usize) { + let mut state = ScrollbackState::new(); + let mut bytes = 0usize; + let mut push = |state: &mut ScrollbackState, block: RenderBlock, n: usize| { + bytes += n; + state.push_block(block); + }; + for i in 0..TURNS { + let p = format!("please investigate issue {i} and report back with a plan"); + push(&mut state, RenderBlock::user_prompt(p.clone()), p.len()); + let t = thinking_text(i); + push(&mut state, RenderBlock::thinking(t.clone()), t.len()); + let out = bash_output(i); + push( + &mut state, + RenderBlock::execute_with_output( + format!("rg -n 'pattern{i}' crates/"), + out.clone(), + None::, + ), + out.len(), + ); + push( + &mut state, + RenderBlock::read( + format!("crates/codegen/xai-grok-pager/src/mod_{i}.rs"), + None, + ), + 64, + ); + let (old, new) = edit_texts(i); + push( + &mut state, + RenderBlock::edit_with_hunks( + format!("crates/codegen/xai-grok-pager/src/mod_{i}.rs"), + xai_grok_pager::diff::diff_hunks_from_strings(&old, &new, 1), + ), + old.len() + new.len(), + ); + push( + &mut state, + RenderBlock::search(format!("fn handler_{i}"), 12, Vec::new()), + 48, + ); + let md = agent_markdown(i); + push(&mut state, RenderBlock::agent_message(md.clone()), md.len()); + let t2 = thinking_text(i + 1); + push(&mut state, RenderBlock::thinking(t2.clone()), t2.len()); + } + state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); + (state, bytes) +} + +fn bench_resize_step(c: &mut Criterion) { + let (mut state, bytes) = build_session(); + eprintln!( + "resize corpus: {} entries, ~{:.1} MB text", + state.len(), + bytes as f64 / (1024.0 * 1024.0) + ); + let mut g = c.benchmark_group("resize"); + g.sample_size(20).warm_up_time(Duration::from_millis(500)); + g.bench_function("width_step", |b| { + let mut w = VIEWPORT_WIDTH; + b.iter(|| { + w = if w == VIEWPORT_WIDTH { + VIEWPORT_WIDTH - 1 + } else { + VIEWPORT_WIDTH + }; + state.prepare_layout(w, VIEWPORT_HEIGHT); + }); + }); + g.finish(); +} + +fn bench_resize_drag(c: &mut Criterion) { + let (mut state, _) = build_session(); + let mut g = c.benchmark_group("resize"); + g.sample_size(10).warm_up_time(Duration::from_millis(500)); + g.bench_function("drag_20_steps", |b| { + b.iter(|| { + for step in 0..20u16 { + state.prepare_layout(VIEWPORT_WIDTH - step, VIEWPORT_HEIGHT); + } + state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); + }); + }); + g.finish(); +} + +fn bench_resize_noop(c: &mut Criterion) { + let (mut state, _) = build_session(); + let mut g = c.benchmark_group("resize"); + g.bench_function("same_width_noop", |b| { + b.iter(|| { + state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); + }); + }); + g.finish(); +} + +criterion_group!( + benches, + bench_resize_step, + bench_resize_drag, + bench_resize_noop +); +criterion_main!(benches); diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/05-slash-commands.md b/crates/codegen/xai-grok-pager/docs/tutorial/05-slash-commands.md index 9ee8cde..f04f76a 100644 --- a/crates/codegen/xai-grok-pager/docs/tutorial/05-slash-commands.md +++ b/crates/codegen/xai-grok-pager/docs/tutorial/05-slash-commands.md @@ -11,7 +11,7 @@ A few worth knowing on day one: | `/new` | Start a fresh session | | `/compact` | Compress a long conversation to free up context | | `/btw` | Send Grok an aside *without* interrupting its current task | -| `/rewind` | Restore your files and history to an earlier prompt | +| `/rewind` (alias `/undo`) | Restore your files and history to an earlier prompt | | `/docs` | Full How-to Guides, in the TUI or on the web | | `/feedback` | Send feedback to the team | @@ -20,8 +20,8 @@ Two of those deserve a second look: - **`/compact`** takes an optional hint: `/compact keep the auth details`. Check context usage anytime with `/context` — Grok also auto-compacts when the window fills up. -- **`/rewind`** restores actual file snapshots taken at each prompt, not - just the chat. +- **`/rewind`** (or **`/undo`**) restores actual file snapshots taken at each + prompt, not just the chat. ## The command palette diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md index 05947f0..2bc4f81 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md @@ -2,9 +2,9 @@ Type `/` in the prompt to open the command menu. It fuzzy-matches as you type, and picking a command runs it immediately. -Commands come from two places: **shell builtins**, handled by the agent backend (xai-grok-shell), and **pager builtins**, handled by the TUI frontend (xai-grok-pager). Both show up in the same menu, and any enabled skill with `user-invocable: true` appears there too. +Commands come from two places: **shell builtins**, handled by the agent backend (xai-grok-shell), and **pager builtins**, handled by the pager frontend (xai-grok-pager). Both show up in the same menu, and any enabled skill with `user-invocable: true` appears there too. -Every command below lists its aliases where it has them. A few commands only appear when a feature or session state enables them; those cases are called out inline. +Every command below lists its aliases where it has them. A few commands only appear when a feature or session state enables them; those cases are called out inline. The menu is also filtered by render mode — see [`/minimal` and `/fullscreen`](#minimal-and-fullscreen). --- @@ -47,9 +47,9 @@ Show session details — auth method, model, turn count, and context usage. Alia Branch the current session into a new agent, keeping history up to this point. -### `/rewind` +### `/rewind` (alias: `/undo`) -Roll the conversation back to an earlier turn and discard everything after it. +Roll the conversation back to an earlier turn and discard everything after it. `/undo` is the same command. ### `/edit-prompt` @@ -153,7 +153,9 @@ Toggle vim-style scrollback keys (`j`/`k`, `h`/`l`, `g`/`G`, `y`/`Y`, and so on) ### `/minimal` and `/fullscreen` -Reopen the current session in the other render mode. `/minimal` (offered while you're in fullscreen) switches to the experimental scrollback-native mode; `/fullscreen` (offered while you're in minimal; alias `/full`) switches back to the standard alt-screen TUI. Both relaunch the pager on the same conversation for this session only — they don't touch `config.toml`, and the relaunch banner reminds you how to switch back. The `--minimal` / `--fullscreen` CLI flags are session-scoped the same way. To make plain `grok` open in a given mode by default, use `/settings` → **Default screen mode** or set `[ui] screen_mode`. +Reopen the current session in the other render mode. `/minimal` (offered while you're in fullscreen) switches to the experimental scrollback-native mode; `/fullscreen` (offered while you're in minimal; alias `/full`) switches back to standard fullscreen mode. Both relaunch the pager on the same conversation for this session only — they don't touch `config.toml`, and the relaunch banner reminds you how to switch back. The `--minimal` / `--fullscreen` CLI flags are session-scoped the same way. To make plain `grok` open in a given mode by default, use `/settings` → **Default screen mode** or set `[ui] screen_mode`. + +A handful of commands only work in one of the two modes, because the surface they drive doesn't exist in the other: `/find`, `/jump`, `/timeline`, `/theme`, `/tutorial`, `/workflows`, and `/dashboard` are fullscreen-only, while `/expand` and `/edit-prompt` are minimal-only. Those are hidden from the command menu and the palette in the mode they can't run in. If you type one out anyway, Grok says why — and points you at whichever is actually useful. When the other mode is the only way to get it, that's the mode switch: `/theme isn't available in minimal mode (minimal renders with your terminal's own palette). Run /fullscreen to switch this session.` When this mode already does the job another way, it names that instead: `/expand isn't available in fullscreen mode — press Tab to focus the scrollback, then → on the block.` Everything else works in both. Note that `--no-alt-screen` still counts as fullscreen here, so it keeps the fullscreen-only commands. ### `/plan` @@ -208,13 +210,13 @@ Save a note to memory immediately, without waiting for an automatic summary. Open the extensions modal on the Hooks tab, where you can view loaded hooks, add or remove custom ones, and toggle them individually. The modal does not grant project trust — see [10-hooks.md](10-hooks.md) for the trust model. -The shell also advertises individual `/hooks-list`, `/hooks-trust`, `/hooks-add`, `/hooks-remove`, and `/hooks-untrust` commands; in the TUI pager these are folded into the `/hooks` modal. +The shell also advertises individual `/hooks-list`, `/hooks-trust`, `/hooks-add`, `/hooks-remove`, and `/hooks-untrust` commands; in the pager these are folded into the `/hooks` modal. ### `/plugins` Open the extensions modal on the Plugins tab to view installed plugins, install new ones from the marketplace, and manage trust. -The shell additionally supports subcommands (`/plugins list`, `/plugins install `, `/plugins uninstall `, `/plugins update`, `/plugins reload`). In the TUI, the modal does the same work visually. +The shell additionally supports subcommands (`/plugins list`, `/plugins install `, `/plugins uninstall `, `/plugins update`, `/plugins reload`). In the pager, the modal does the same work visually. ### `/marketplace` @@ -315,7 +317,7 @@ Open the live workflows **run** dashboard — active and retained runs, not a ca ### `/theme` -Switch the TUI color theme. Alias: `/t`. +Switch the color theme. Alias: `/t`. ### `/feedback [message]` @@ -347,7 +349,7 @@ View release notes for the current version. Alias: `/changelog`. ### `/docs` -Browse the in-TUI How-to Guides, open the online Build docs, or jump straight to a guide by title. Aliases: `/howto`, `/guides`. +Browse the built-in How-to Guides, open the online Build docs, or jump straight to a guide by title. Aliases: `/howto`, `/guides`. ``` /docs diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md b/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md index 3ea1977..f523cdd 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md @@ -22,12 +22,13 @@ Grok processes the prompt, runs any necessary tools, and prints the result to st | ----------------------- | ----------------------------------------------------- | | `-p, --single ` | The prompt to send (or use `--prompt-json` / `--prompt-file`) | | `-m, --model ` | Model to use (e.g., `grok-build`) | -| `-s, --session-id ` | Create a **new** session with this **UUID** (errors if invalid UUID or already in use under the target session directory; does not resume — use `-r`/`-c`) | +| `-s, --session-id ` | Create a **new** session with this **UUID** (errors if invalid UUID or already in use under the target session directory; does not resume, use `-r`/`-c`) | | `--fork-session` | With `-r`/`-c`, fork into a new session ID instead of appending to the original | | `-r, --resume ` | Resume an existing session by ID, or by title for the current directory, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path; scripts should prefer IDs) | | `-c, --continue` | Continue the most recent session in current directory | | `--cwd ` | Set working directory | -| `--output-format ` | Output format: `plain`, `json`, `streaming-json` | +| `--output-format ` | Output format: `plain`, `json`, `streaming-json`, `streaming-messages-json` | +| `--include-partial-messages` | Emit raw `stream_event` deltas. Only affects `--output-format streaming-messages-json`; ignored (with a warning) otherwise. | | `--yolo` | Auto-approve all tool executions | | `--rules ` | Custom rules for the system prompt | | `--tools ` | Allowlist of built-in tools (comma-separated). MCP meta-tools remain available unless denied. Headless only. | @@ -115,7 +116,7 @@ grok -p "Build the project" --allow "Bash" ## Output Formats -Headless mode supports three output formats, selected with `--output-format`. +Headless mode supports four output formats, selected with `--output-format`. ### plain (default) @@ -130,18 +131,20 @@ Here's a summary of the codebase... A single JSON object emitted after the response completes: response text, stop reason, session ID, request ID (plus `thought` when reasoning is present). When the prompt reached the model, the same object also carries spend fields -(`usage`, `num_turns`, `modelUsage`, cost). +(`usage`, `num_turns`, `modelUsage`, cost). `stopReason` is the snake_case +ACP/Messages token (`end_turn`, `max_tokens`, …). ```json { "text": "Here's a summary of the codebase...", - "stopReason": "EndTurn", + "stopReason": "end_turn", "sessionId": "abc123", "requestId": "xyz789", "num_turns": 7, "usage": { "input_tokens": 7210, "cache_read_input_tokens": 41000, + "cache_creation_input_tokens": 0, "output_tokens": 1893, "reasoning_tokens": 412, "total_tokens": 50103 @@ -168,8 +171,8 @@ Usage notes: - **Token field policy (headless result / `end` / error spend):** - `usage.input_tokens` and `modelUsage.*.inputTokens` are **uncached only**. - `cache_read_input_tokens` / `cacheReadInputTokens` are cache hits. - - `total_tokens` is full input + output (includes cache): - `total_tokens = input_tokens + cache_read_input_tokens + output_tokens`. + - `total_tokens` is full input + output (includes both cache buckets): + `total_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens`. - ACP `_meta.usage.inputTokens` (PromptUsage) is still the **full** prompt sum; only the headless projector subtracts cache. Prefer headless fields for spend automation. @@ -208,29 +211,119 @@ failures may also include frozen spend fields when usage was recorded: ### streaming-json -Newline-delimited JSON events emitted in real time. Each line is a self-contained JSON object with a `type` field: +Newline-delimited JSON, one `type`-tagged object per line, derived from the agent's ACP session updates. Leaf field names (`toolCallId`, `kind`, `rawInput`, `rawOutput`) follow ACP; `toolName` and the `usage` line are xAI additions. Consume it by switching on `type`. ```json -{"type":"text","data":"Here's"} -{"type":"text","data":" a summary"} {"type":"thought","data":"Analyzing the directory structure..."} -{"type":"end","stopReason":"EndTurn","sessionId":"abc123","requestId":"xyz789","usage":{...},"num_turns":7,"modelUsage":{...}} +{"type":"tool_call","toolCallId":"call_1","title":"Read","kind":"read","status":"in_progress","toolName":"read_file","rawInput":{"path":"src/main.rs"},"content":[],"locations":[]} +{"type":"tool_call_update","toolCallId":"call_1","status":"completed","content":[],"rawOutput":{"lines":42},"locations":[]} +{"type":"text","data":"Here's a summary"} +{"type":"usage","messageId":"resp_1","stopReason":"end_turn","usage":{"input_tokens":812,"output_tokens":45,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"reasoning_tokens":0},"signature":"..."} +{"type":"end","stopReason":"end_turn","sessionId":"abc123","requestId":"xyz789","usage":{...},"num_turns":7,"modelUsage":{...}} ``` Event types: -| Type | Description | -| ---------- | -------------------------------------------------------------- | -| `text` | A chunk of the agent's response text | -| `thought` | Internal reasoning (thinking tokens) | -| `end` | Final event with metadata and spend fields when available | -| `error` | An error occurred (carries `message`, and spend fields if any) | +| Type | Description | +| ------------------ | ------------------------------------------------------------------------------------------- | +| `text` | A chunk of the agent's response text | +| `thought` | Internal reasoning (thinking tokens) | +| `tool_call` | A tool call the agent started (`toolCallId`, `toolName`, `kind`, `status`, `rawInput`, `content`, `locations`) | +| `tool_call_update` | Progress or result for a tool call (`status`, `rawOutput`, `content`, `locations`) | +| `usage` | Per-response boundary (`messageId`, `stopReason`, `usage`, `signature`), one per model response | +| `plan` | The agent's current plan (`entries`) | +| `available_commands` | Tool and slash command lists (`tools`, `commands`) | +| `end` | Final event with metadata and spend fields when available | +| `error` | An error occurred (carries `message`, and spend fields if any) | `end` is always the last event. Spend fields on `end` match the json object -shape (snake_case uncached `input_tokens`, safe cost floats). +shape (snake_case uncached `input_tokens`, safe cost floats). `end.stopReason` +is the turn stop reason in snake_case (`end_turn`, `max_tokens`, +`max_turn_requests`, `refusal`, `cancelled`); the verbatim per-response provider +reason (e.g. `tool_use`, `pause_turn`) is on the `usage` line's `stopReason`. +Per-response `message_id`/`stopReason`/`signature` are populated on the Messages +API backend; other backends report what they carry. Grok may also emit `max_turns_reached` and `auto_compact_*` events; treat the list as non-exhaustive and switch on `type`. +### streaming-messages-json + +Newline-delimited JSON in the Messages API `stream-json` wire format. The data-bearing surface matches the Messages shape exactly. This includes the `assistant`/`user` message bodies, `usage`, `tool_use`/`tool_result`, inline web search, `stop_reason`, and the `--include-partial-messages` event framing. A consumer that reconstructs messages, reads spend, or detects errors works without changes. + +The `system`/`init` and terminal `result` lines carry metadata. Grok emits the fields it has real data for and omits pure-placeholder fields it cannot fill, rather than zero-filling them. As a result, those two lines may not pass strict `init`/`result` schema validation. The individual fields are listed below. Read the fidelity notes before treating any one field as authoritative. For a clean xAI-native stream with no placeholder shape, use `streaming-json`. + +The stream opens with a `system`/`init` line, then `assistant` messages whose `message.content[]` holds `text`, `thinking`, and `tool_use` blocks, `user` messages carrying `tool_result` blocks, and a terminal `result`: + +```json +{"type":"system","subtype":"init","session_id":"abc123","apiKeySource":"user","model":"grok-build","cwd":"/repo","permissionMode":"default","tools":["read_file","bash"],"slash_commands":["review"],"mcp_servers":[{"name":"linear","status":"connected"}],"skills":[],"uuid":"..."} +{"type":"assistant","message":{"id":"msg_0","type":"message","role":"assistant","model":"grok-build","content":[{"type":"text","text":"Let me read the file."},{"type":"tool_use","id":"call_1","name":"read_file","input":{"path":"src/main.rs"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{...}},"parent_tool_use_id":null,"session_id":"abc123","uuid":"..."} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"fn main() {}","is_error":false}]},"parent_tool_use_id":null,"session_id":"abc123","uuid":"..."} +{"type":"result","subtype":"success","is_error":false,"duration_ms":0,"duration_api_ms":0,"num_turns":7,"result":"Here's a summary...","stop_reason":"end_turn","total_cost_usd":0.0127,"usage":{"input_tokens":812,"output_tokens":210,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"server_tool_use":{"web_search_requests":0}},"modelUsage":{},"session_id":"abc123","uuid":"..."} +``` + +Message types: + +| Type | Description | +| ----------- | ---------------------------------------------------------------------- | +| `system` | Session preamble (`subtype: "init"`) with model, cwd, permission mode, tools, slash commands, and MCP servers. `subtype: "compact_boundary"` marks an auto compaction | +| `assistant` | A model message; `message.content[]` holds `text`/`thinking`/`tool_use`, plus `server_tool_use`/`web_search_tool_result` for inline backend web search | +| `user` | Tool results, as `tool_result` blocks inside `message.content[]` | +| `result` | Terminal message with final text, stop reason, and spend fields | + +The `assistant` and `user` messages carry `session_id`, `uuid`, and `parent_tool_use_id` (`null` for the main conversation). The `system`/`init` and terminal `result` lines carry `session_id` and `uuid` but no `parent_tool_use_id`. + +The `uuid` on each line is freshly generated per emitted line. It is not a provider, message, or event id, and not a correlation key. It does not match the provider `message.id` (that value rides `assistant.message.id`). It is unique per line, even for lines that describe the same message, and it carries no cross-line or cross-run identity. Do not use it to correlate or deduplicate. + +Text and reasoning chunks are grouped into one assistant message per model response. A response's parallel `tool_result` blocks are grouped into a single `user` message. `result.result` is the final assistant message text. A model response that produces no content blocks emits no `assistant` line in the default mode. Only `--include-partial-messages` surfaces such a response, as its empty `message_start` … `message_stop` envelope. + +On `init`, `skills` is live. It lists the session's user-invocable skill names, a subset of `slash_commands` sourced from the session's advertised commands, or `[]` when the session surfaces no skills. The `init` line is emitted once, deferred to the first output line so it captures the session's advertised `tools`, `slash_commands`, and `skills`. The Messages schema defines no second `init`, so a command list that changes after streaming begins is not re-advertised. + +The other `init` fields carry real data: + +- `apiKeySource` is `user` for API-key auth and `oauth` otherwise. Grok does not distinguish the schema's `project`, `org`, and `temporary` sources. +- `permissionMode` is the effective headless mode mapped to the Messages enum: the `--permission-mode` value, or `bypassPermissions` under `--yolo`, else `default`. Grok-only modes such as `auto` collapse to `default`. +- `mcp_servers[].status` reflects configuration, not live connection state. A configured server always reports `"connected"`, because per-server handshake state is not resolved by the time `init` is emitted. + +Grok omits the schema's pure-placeholder `init` fields it has no data for, rather than emitting dummy values: `claude_code_version`, `output_style`, and `plugins`. + +`result` includes `duration_ms`, `duration_api_ms`, `num_turns`, `stop_reason`, `total_cost_usd`, `usage` (Messages API `message.usage` shape), and `modelUsage`. It also includes `errors[]` on the error subtypes. Grok omits the schema's always-empty `permission_denials`, because it does not collect permission denials. `structured_output` (with `--json-schema`) is snake_case, matching the schema. + +`model` appears on `init` and every `assistant` frame. It is the real model id when known, and the literal `"unknown"` only when no model is known at emit time. + +The assistant frame's `stop_sequence` is wired end-to-end. It carries the provider's matched stop sequence when the model stopped on a configured one (`stop_reason: "stop_sequence"`), and is `null` on every other stop reason and backend. In `--include-partial-messages` framing, the matched sequence rides both the flushed `assistant` frame and the partial `message_delta.stop_sequence`, so a partial rebuild matches the frame. Only the partial `message_start.stop_sequence` stays `null`, because the matched sequence is not known at message open. + +The emitted error subtypes are `error_max_turns`, `error_during_execution`, and `error_max_structured_output_retries`. The schema's `error_max_budget_usd` subtype is never emitted, because grok has no budget feature. + +`result.usage` reports the Messages `message.usage` shape with the three token buckets disjoint: `input_tokens` (uncached), `cache_read_input_tokens`, and `cache_creation_input_tokens`. Grok derives these from the turn's aggregate ledger, reshaped into those buckets. Subagent cache creation is included in `cache_creation_input_tokens`. The aggregate ledger tracks it as its own bucket, so it is no longer folded into `input_tokens`. + +`result.usage` always emits numeric buckets, even when data is missing. This happens when the turn's usage ledger is incomplete (the same condition that surfaces `usage_is_incomplete` in the `json` format), or when no aggregate ledger reached the reducer at all. Any bucket grok cannot account for falls back to `0`, because the Messages API schema has no marker for incomplete or absent usage. The reducer logs a warning to stderr in both cases. Read an all-zero `usage` here as "unknown", not "free". + +The nested `server_tool_use` counter is populated. `web_search_requests` is the number of *successful* backend web searches emitted this run. Failed searches and non-search `WebSearch` actions such as open_page are excluded, matching the Messages API, which does not bill errored searches. A failed backend search still emits a `web_search_tool_result` in the error shape (`content.type: "web_search_tool_result_error"`), but is not counted. Its `error_code` is a fixed `"unavailable"` placeholder, not a code forwarded from the backend. There is no `web_fetch_requests` key, because grok has no server-side `web_fetch`, so the placeholder is omitted. + +Backend web search is inline. It folds into the same `assistant` frame as the surrounding text. The frame carries a `server_tool_use` block (`name: "web_search"`, `input.query`) immediately followed by a `web_search_tool_result` block. That result block's `tool_use_id` matches the `server_tool_use.id`, and its `content` is a `web_search_result` hit array of `{type, url, title}`. This matches the Messages API's inline server-tool shape rather than splitting the response across frames. + +X search and code interpreter are a documented divergence. They stay generic, surfaced as a client `tool_use` block plus a `user` `tool_result`, because the Messages API defines no inline block type for them. Every other client tool likewise keeps the `tool_use`/`tool_result` split. + +`--include-partial-messages` emits the raw event framing so a consumer can rebuild each message with the Messages streaming accumulator. The framing is `message_start`, `content_block_start`/`content_block_delta`/`content_block_stop`, `message_delta`, and `message_stop`. It carries the structural events an accumulator needs. The deltas are coarser than the Messages API's token-level streaming: tool input arrives as a single `input_json_delta`, and `citations_delta` is never produced (see below). The result is a faithful reconstruction of each message rather than a token-by-token replay. + +On the Messages API backend, the framing is faithful. `message_start` carries the real provider `message.id` and the input-side `usage`. A thinking block emits its `signature_delta` in order, before the block's `content_block_stop`. The `message_start.usage` input side reports all three prompt-side buckets known at message open: `input_tokens` (the uncached portion), `cache_read_input_tokens`, and `cache_creation_input_tokens`. A cache hit is therefore visible on `message_start`, rather than only appearing later on `message_delta`/`result`. `output_tokens` seeds `0` there and is finalized on `message_delta`. A response that starts but produces no content still emits the `message_start` … `message_stop` envelope with no content blocks. + +Some backends surface per-response metadata only at end of turn. Those backends fall back to a synthesized `message_start.id` and zero-seeded input `usage`. They defer the reasoning `signature` to the final `assistant` line, which is authoritative in that case. + +Tool-call input is emitted as a single `input_json_delta` carrying the complete arguments JSON, followed by `content_block_stop`. It is not a sequence of token-level fragments. This is a deliberate divergence from the Messages API's incremental `partial_json` streaming. Grok's ACP tool-call path delivers each tool call as one validated JSON object once the arguments are fully parsed, so a single delta is the accurate representation. A consumer that concatenates `partial_json` reassembles the identical object either way. The backend web-search `server_tool_use` block's `input.query` is emitted the same way, as one `input_json_delta`. + +The Messages API `citations_delta` carries inline citations for cited text spans, such as those from web search. This stream does not produce it. Grok's Messages content deltas are limited to text, thinking, signature, and tool-input JSON, so there is no citation data to surface as a `citations_delta`. Backend web-search source URLs are reported inline on the completed `web_search_tool_result` block instead (see above), not as per-span text citations. + +Fidelity caveats apply to a few fields. + +`duration_ms` is the prompt-execution wall clock. `duration_api_ms` is the summed *reported* per-call model time. A model call that does not report its own duration contributes `0`, so `duration_api_ms` can under-count the true API time. + +`num_turns` and `total_cost_usd` are authoritative when known. When they are not, `num_turns` falls back to the count of completed model responses this turn, and `total_cost_usd` falls back to `0`. A completed but contentless response emits no `assistant` line, yet still counts as a turn. Spend is never overreported. + +`modelUsage` carries the per-model token and cost fields grok tracks, plus `webSearchRequests` attributed to the active model. The reducer tracks a single global web-search count rather than per-model, so the whole count lands on the current or last model and other rows stay `0`. A per-model `modelUsage.*.costUSD` is `0` when that model's cost is unknown or withheld. This is the same fail-closed-to-zero behavior as the top-level `total_cost_usd`. The `json` format omits cost floats entirely when partial, but this stream keeps the field present and `0`. `contextWindow` is the current model's real total context window (the same value grok uses for auto-compaction), and it appears only on the current model's row. Other rows omit it, and so does the current row when the window is unknown. `maxOutputTokens` has no grok catalog, so that key is omitted entirely. `modelUsage` is `{}` when no per-model breakdown is available. + +Like `streaming-json`, this stream is read only. Tool approvals and other bidirectional flows use the ACP interface (`grok agent`). + --- ## Session Management in Headless Mode @@ -239,7 +332,7 @@ By default, each `grok -p` invocation creates a fresh session. To maintain conte ### Named Sessions (`-s`) -To carry context across headless calls, use `-r/--resume` or `-c/--continue`. Use `-s/--session-id` only for a **new** session with a **UUID** (errors if not a UUID or already in use under the target directory). Older hidden `-s` upsert/resume behavior is gone — use `-r`/`-c` to continue. With `-r`/`-c`, `-s` requires `--fork-session`: +To carry context across headless calls, use `-r/--resume` or `-c/--continue`. Use `-s/--session-id` only for a **new** session with a **UUID** (errors if not a UUID or already in use under the target directory). Older hidden `-s` upsert/resume behavior is gone. Use `-r`/`-c` to continue. With `-r`/`-c`, `-s` requires `--fork-session`: ```bash # Start a headless session and capture its ID @@ -256,7 +349,7 @@ grok -p "hello" --session-id "$(uuidgen | tr '[:upper:]' '[:lower:]')" --output- ### Resume (`-r`) -The `-r/--resume` flag resumes a specific session by ID, or by title for the current directory when the value is not an ID, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path — scripts should prefer IDs). It errors if the session does not exist: +The `-r/--resume` flag resumes a specific session by ID, or by title for the current directory when the value is not an ID, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path, so scripts should prefer IDs). It errors if the session does not exist: ```bash # Get the session ID from a previous JSON response @@ -467,8 +560,8 @@ grok -p "Run the test suite" --yolo | Code | Meaning | | ---- | ------------------------------------ | -| `0` | Success -- prompt completed normally | -| `1` | Error -- authentication failure, network error, or runtime error | +| `0` | Success. The prompt completed normally | +| `1` | Error. Authentication failure, network error, or runtime error | | `130` | Interrupted by SIGINT (Ctrl+C) | | `143` | Terminated by SIGTERM | @@ -478,10 +571,10 @@ grok -p "Run the test suite" --yolo For headless use, authenticate with one of: -- **`XAI_API_KEY`** — simplest for CI. See [Environment Variables](#environment-variables-for-headless) above. -- **`grok login --device-auth`** (or `--device-code`) — no browser needed on the target machine. +- **`XAI_API_KEY`**: simplest for CI. See [Environment Variables](#environment-variables-for-headless) above. +- **`grok login --device-auth`** (or `--device-code`): no browser needed on the target machine. See [Authentication > Device Code Flow](02-authentication.md#device-code-flow). -- **`grok login`** — browser-based OAuth2 on machines with a GUI. +- **`grok login`**: browser-based OAuth2 on machines with a GUI. If you've previously logged in, cached credentials are used automatically. diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/17-sessions.md b/crates/codegen/xai-grok-pager/docs/user-guide/17-sessions.md index 00a9e73..cf2f329 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/17-sessions.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/17-sessions.md @@ -132,13 +132,14 @@ Alias: `/title`. ## The /rewind Command -`/rewind` undoes recent changes by restoring files to their state at an earlier point in the conversation. Use it to recover from mistakes. +`/rewind` (alias `/undo`) undoes recent changes by restoring files to their state at an earlier point in the conversation. Use it to recover from mistakes. ``` /rewind +/undo ``` -When you run `/rewind` (or press **Esc Esc** within 800ms while idle with an empty prompt and conversation messages), Grok: +When you run `/rewind` or `/undo` (or press **Esc Esc** within 800ms while idle with an empty prompt and conversation messages), Grok: 1. Shows a list of rewind points (one per user prompt) 2. Lets you select which point to rewind to diff --git a/crates/codegen/xai-grok-pager/src/acp/spawn.rs b/crates/codegen/xai-grok-pager/src/acp/spawn.rs index 9fba58b..ff7caea 100644 --- a/crates/codegen/xai-grok-pager/src/acp/spawn.rs +++ b/crates/codegen/xai-grok-pager/src/acp/spawn.rs @@ -192,9 +192,10 @@ pub async fn spawn_grok_shell( // here, so the agent's external-OTEL gate is applied exactly once, before boot. xai_grok_shell::agent::app::apply_otel_config(&auth_manager, &agent_config.grok_com_config); - // Best-effort refresh of managed policy before bootstrap reads it (repairs a wrong-identity/missing - // cache). Never errors — the OS-protected system/MDM layers still apply, and every network step - // inside is bounded (SESSION_START_AUTH_DEADLINE / SyncBudget::SessionStart). + // Best-effort refresh of managed policy before bootstrap reads it (repairs a + // wrong-identity/missing cache). Never errors — the OS-protected system/MDM + // layers still apply, and every network step inside is bounded + // (SESSION_START_AUTH_DEADLINE / SyncBudget::SessionStart). xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; // Run the full bootstrap sequence: config resolution, process-level diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs index 6e7fa94..c128098 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs @@ -93,11 +93,16 @@ pub(super) fn viewer_turn_anchor(turn_start_ms: Option) -> std::time::Insta /// when silent (the user's standing instruction stopped executing invisibly). /// Silent rate limits defer to the retry notifications, like the real-turn /// rails. +/// +/// `cancel_trigger` is the signal's `_meta.cancelTrigger`. `"send_now"` marks +/// an internal cancel-and-send, so the `TurnCancelled` marker is suppressed +/// (wire trigger wins; `expect_send_now_cancel` is the older-shell fallback). pub(super) fn finish_wake_turn( agent: &mut AgentView, prompt_id: &str, stop_reason: &str, agent_result: Option<&str>, + cancel_trigger: Option<&str>, ) { use crate::scrollback::blocks::SessionEvent; @@ -117,6 +122,11 @@ pub(super) fn finish_wake_turn( } else { None }; + // Wire trigger carries this case; pid-matched fallback is consistency-only (do not take/clear). + let send_now_cancel = match cancel_trigger { + Some(trigger) => trigger == "send_now", + None => agent.expect_send_now_cancel.as_deref() == Some(prompt_id), + }; let already_failed = agent.failed_wake_marker_for.as_deref() == Some(prompt_id); let event = match stop_reason { "error" | "rate_limit" @@ -138,6 +148,8 @@ pub(super) fn finish_wake_turn( }) } "cancelled" if !had_output => None, + // Send-now cancel: no marker (the sender's new prompt is the next turn). + "cancelled" if send_now_cancel => None, "cancelled" => Some(SessionEvent::TurnCancelled { elapsed: elapsed.unwrap_or_default(), }), diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs index abfefae..2708eaa 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs @@ -244,7 +244,18 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu false } } else { - finish_wake_turn(agent, &prompt_id, &stop_reason, agent_result.as_deref()); + let cancel_trigger = session_notif + .meta + .as_ref() + .and_then(|v| v.get("cancelTrigger")) + .and_then(|v| v.as_str()); + finish_wake_turn( + agent, + &prompt_id, + &stop_reason, + agent_result.as_deref(), + cancel_trigger, + ); true } } else if is_server_initiated_prompt(&prompt_id) @@ -401,6 +412,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu child_view.active_pane = crate::views::agent::ActivePane::Scrollback; child_view.set_sharing_enabled(agent.sharing_enabled); child_view.set_billing_surface_visible(agent.billing_surface_visible); + child_view.set_usage_command_visible(agent.usage_command_visible); let dashboard_visible = agent .prompt .slash_controller diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/settings.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/settings.rs index 6c1fa12..9efac8f 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/settings.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/settings.rs @@ -121,12 +121,13 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App if let Some(v) = update.show_resolved_model { app.show_resolved_model = v; } - if let Some(v) = update.sharing_enabled { - app.sharing_enabled = v; - // Propagate to existing agents so slash-command registries stay - // in sync (same fan-out pattern used when creating new agents). + // Temporary client kill switch: ignore remote `sharing_enabled` until + // session share links are restored. Presence is still observed so a + // later re-enable can go back to `app.sharing_enabled = v`. + if update.sharing_enabled.is_some() { + app.sharing_enabled = false; for agent in app.agents.values_mut() { - agent.set_sharing_enabled(v); + agent.set_sharing_enabled(false); } } // Env overrides win over live updates too, mirroring the startup @@ -152,7 +153,7 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App let was_api_key = app.is_api_key_auth; let is_key = super::super::app_view::is_api_key_label(&v); app.is_api_key_auth = is_key; - app.usage_visible = !is_key && app.team_name.is_none(); + app.usage_visible = !is_key && app.team_name.is_none() && !app.has_external_auth_provider; app.sync_billing_surface_to_agents(); app.subscription_tier = Some(v); app.apply_tier_restrictions(); diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs index e5eb6a1..b397215 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs @@ -988,6 +988,33 @@ pub(super) fn xai_turn_completed_notif( std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()), ) } +/// Live `TurnCompleted` stamped with `_meta.cancelTrigger` (send-now / ctrl_c). +pub(super) fn xai_turn_completed_notif_with_cancel_trigger( + session_id: &str, + prompt_id: &str, + stop_reason: &str, + cancel_trigger: &str, +) -> acp::ExtNotification { + let payload = SessionNotification { + session_id: acp::SessionId::new(session_id), + update: XaiSessionUpdate::TurnCompleted { + prompt_id: prompt_id.into(), + stop_reason: stop_reason.into(), + agent_result: None, + usage: None, + }, + meta: Some( + serde_json::json!({ + "isReplay": false, + "cancelTrigger": cancel_trigger, + }), + ), + }; + acp::ExtNotification::new( + "x.ai/session/update", + std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()), + ) +} /// A live durable `TurnCompleted`, optionally stamped with the shell /// completion clock (`agentTimestampMs`) the wake marker's elapsed reads. pub(super) fn xai_wake_turn_completed_notif( diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/plan_mode.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/plan_mode.rs index 093a344..98c50dc 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/plan_mode.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/plan_mode.rs @@ -121,7 +121,7 @@ agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( agent.sharing_enabled, - agent.prompt.slash_controller.screen_mode(), + &agent.prompt.slash_controller, ), state: crate::views::picker::PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs index 308b76f..61ab5f2 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs @@ -427,6 +427,13 @@ Some("legacy_auth"), "Unauthorized (401) ... deprecated authentication method" )); + // auth_transient = the shell says the failure self-heals (refreshable + // credential, no sticky verdict — e.g. post-wake network gap). Even + // with a 401 in the message, the `/login` banner must not fire. + assert!(!is_reauthable_failure( + Some("auth_transient"), + "Unauthorized (401)\n\nAuthentication is temporarily unavailable" + )); // Unrelated failures must not be treated as re-authable. assert!(!is_reauthable_failure( Some("server_error"), diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/settings.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/settings.rs index a2782e3..28f6366 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/settings.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/settings.rs @@ -565,7 +565,7 @@ let notif = acp::ExtNotification::new( "x.ai/settings/update", serde_json::value::to_raw_value(&serde_json::json!({ - "sharing_enabled": true, + "show_resolved_model": false, "announcements": [critical_announcement("from-settings")], })) .unwrap() @@ -579,7 +579,46 @@ "settings/update must not replace the pushed announcements" ); assert_eq!(app.announcements_last_gen, 7, "watermark untouched"); - assert!(app.sharing_enabled, "other settings fields still apply"); + assert!(!app.show_resolved_model, "other settings fields still apply"); + } + + /// Temporary client kill switch: remote `sharing_enabled: true` must not + /// re-enable share UI. Agents stay off and `/share` stays menu-hidden + /// (typed `/share` still dispatches for the disable message). + #[test] + fn settings_update_sharing_enabled_true_stays_forced_off() { + let mut app = make_app_with_agent("sess-share-kill"); + app.sharing_enabled = true; + for agent in app.agents.values_mut() { + agent.set_sharing_enabled(true); + } + + let notif = acp::ExtNotification::new( + "x.ai/settings/update", + serde_json::value::to_raw_value(&serde_json::json!({ + "sharing_enabled": true, + })) + .unwrap() + .into(), + ); + let _ = handle_ext_notification(¬if, &mut app); + + assert!( + !app.sharing_enabled, + "remote true must not lift the temporary kill switch" + ); + for agent in app.agents.values() { + assert!(!agent.sharing_enabled); + let reg = agent.prompt.slash_controller.registry(); + assert!( + reg.get("share").is_none(), + "/share stays out of the completion menu" + ); + assert!( + reg.get_for_dispatch("share").is_some(), + "typed /share still resolves so the disable path can run" + ); + } } /// User-owned mode must not re-arm default_yolo or rewrite UI from remote. diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs index 2bbe3ec..07ebf9d 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs @@ -609,6 +609,97 @@ ); } + #[test] + fn chatty_send_now_cancelled_wake_is_markerless() { + // A wake with output cancelled by send-now must stay silent — same + // suppression the other three turn-end rails already apply. + use crate::app::agent_view::test_fixtures::count_turn_markers; + + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + let len_before = app.agents[&AgentId(0)].scrollback.len(); + + let _ = handle_ext_notification( + &xai_turn_completed_notif_with_cancel_trigger( + "sess-wake", + "task-completed-bg1", + "cancelled", + "send_now", + ), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!( + agent.scrollback.len(), + len_before, + "a send-now cancelled chatty wake must push no marker" + ); + assert_eq!(count_turn_markers(agent), 0); + assert!( + !matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnCancelled { .. }) + ), + "send_now must not surface as Turn cancelled by user" + ); + } + + #[test] + fn chatty_user_cancelled_wake_pushes_cancelled_marker() { + // Genuine cancel (Ctrl+C / Esc, no wire trigger) still shows the marker. + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "cancelled", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnCancelled { .. }) + )); + } + + #[test] + fn foreign_send_now_arm_does_not_suppress_wake_cancel_marker() { + // A flag armed for a different (user) prompt must not eat this wake's + // genuine cancel marker, and must stay armed after close-out. + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + app.agents + .get_mut(&AgentId(0)) + .unwrap() + .expect_send_now_cancel = Some("user-prompt-other".into()); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "cancelled", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnCancelled { .. }) + )); + assert_eq!( + agent.expect_send_now_cancel.as_deref(), + Some("user-prompt-other"), + "wake close-out must not clear a foreign send-now arm" + ); + } + #[test] fn chatty_rate_limited_wake_closes_with_failure_marker() { let mut app = make_app_with_agent("sess-wake"); diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs index 292b6b8..ca6f0ce 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs @@ -1201,7 +1201,7 @@ impl AgentView { self.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( self.sharing_enabled, - self.prompt.slash_controller.screen_mode(), + &self.prompt.slash_controller, ), state: crate::views::picker::PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), @@ -1323,7 +1323,7 @@ impl AgentView { self.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( self.sharing_enabled, - self.prompt.slash_controller.screen_mode(), + &self.prompt.slash_controller, ), state: crate::views::picker::PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), @@ -2111,7 +2111,7 @@ mod focus_gained_restore_tests { agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( false, - agent.prompt.slash_controller.screen_mode(), + &agent.prompt.slash_controller, ), state: crate::views::picker::PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs index 5307c3a..0b47c85 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs @@ -1399,6 +1399,8 @@ pub struct AgentView { pub scheduler_background_loops: Option, /// Mirrors `AppView::usage_visible` (credit warning + `/usage manage`). pub billing_surface_visible: bool, + /// Whether `/usage` is offered. Mirrors `!AppView::has_external_auth_provider`. + pub usage_command_visible: bool, /// Input flight recorder — rolling buffer of recent key events. /// Dumped to file via Esc→d combo for debugging. pub(crate) input_log: crate::input_log::InputRingBuffer, @@ -2019,6 +2021,10 @@ pub(super) fn apply_settings_outcome( } SettingsKeyOutcome::Action(a) => InputOutcome::Action(a), SettingsKeyOutcome::ActionPair(a, b) => InputOutcome::ActionPair(a, b), + SettingsKeyOutcome::ActionThenClose(a) => { + agent.active_modal = None; + InputOutcome::Action(a) + } SettingsKeyOutcome::Changed => InputOutcome::Changed, SettingsKeyOutcome::Unchanged => InputOutcome::Unchanged, } diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs index 2c939b3..6d4fc1c 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs @@ -16,8 +16,7 @@ impl AgentView { /// is replaced; [`Self::sticky_toast`] is preserved and returns after this /// expires or is dismissed. pub fn show_toast(&mut self, msg: &str) { - let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned(); - self.toast = Some((msg, 90)); + self.toast = Some((crate::glyphs::sanitize_toast_message(msg).into_owned(), 90)); } /// Show an ephemeral tip in the banner row above the prompt, gated by the @@ -216,7 +215,7 @@ impl AgentView { /// Set or clear the sticky status banner (process-wide indicators should /// use [`Self::set_sticky_toast_recursive`] on every agent view). pub fn set_sticky_toast(&mut self, msg: Option<&str>) { - self.sticky_toast = msg.map(|m| crate::glyphs::legacy_glyph_fallback(m).into_owned()); + self.sticky_toast = msg.map(|m| crate::glyphs::sanitize_toast_message(m).into_owned()); } /// Propagate sticky status to this view and every nested subagent view. @@ -229,8 +228,10 @@ impl AgentView { /// Show a toast with an explicit tick duration. pub fn show_toast_ticks(&mut self, msg: &str, ticks: u8) { - let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned(); - self.toast = Some((msg, ticks)); + self.toast = Some(( + crate::glyphs::sanitize_toast_message(msg).into_owned(), + ticks, + )); } /// Message currently drawn in the toast slot: transient wins while active, @@ -401,4 +402,38 @@ mod mouse_off_banner_tests { view.active_pane = AgentPane::Prompt; assert_eq!(view.active_toast_message(), Some("Reconnecting")); } + + #[test] + fn show_toast_scrubs_control_chars() { + let mut view = make_running_agent(); + view.show_toast("a\nb\rc\thttps://x.ai"); + let msg = view.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or(""); + assert!( + !msg.chars().any(char::is_control), + "show_toast must scrub controls: {msg:?}" + ); + assert!(msg.contains("https://x.ai"), "{msg:?}"); + } + + #[test] + fn show_toast_ticks_scrubs_control_chars() { + let mut view = make_running_agent(); + view.show_toast_ticks("x\ny\tz", 10); + let msg = view.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or(""); + assert!( + !msg.chars().any(char::is_control), + "show_toast_ticks must scrub controls: {msg:?}" + ); + } + + #[test] + fn set_sticky_toast_scrubs_control_chars() { + let mut view = make_running_agent(); + view.set_sticky_toast(Some("sticky\nline")); + let msg = view.sticky_toast.as_deref().unwrap_or(""); + assert!( + !msg.chars().any(char::is_control), + "sticky toast must scrub controls: {msg:?}" + ); + } } diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/plan.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/plan.rs index b3072d1..f8af8d4 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/plan.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/plan.rs @@ -353,6 +353,18 @@ impl AgentView { } return InputOutcome::Changed; } + if !is_commenting + && key.code == KeyCode::Char('a') + && key.modifiers.is_empty() + && self.prompt.text().trim().is_empty() + && !self.prompt.file_search_visible() + && self + .plan_approval_view + .as_ref() + .is_some_and(|pav| pav.comments.is_empty()) + { + return self.approve_plan(); + } match self.prompt.route_enter(key) { EnterOutcome::NewlineInserted => return InputOutcome::Changed, EnterOutcome::Submit => { @@ -370,7 +382,8 @@ impl AgentView { .is_some_and(|pav| pav.focus == PlanApprovalFocus::Prompt); if prompt_focused { if text.trim().is_empty() && !has_comments { - return self.approve_plan(); + self.show_toast("Type revision notes, or press a to approve."); + return InputOutcome::Changed; } let freeform = if text.trim().is_empty() { None @@ -872,3 +885,111 @@ mod plan_chip_tests { )); } } +#[cfg(test)] +mod plan_approval_enter_tests { + use super::test_fixtures::make_agent; + use super::*; + use crate::views::plan_approval_view::PlanApprovalFocus; + fn enter_key() -> KeyEvent { + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) + } + fn agent_with_revise_prompt() -> AgentView { + let mut agent = make_agent(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let request = crate::views::plan_approval_view::ExitPlanModeExtRequest { + session_id: "test-session".into(), + tool_call_id: "call-1".into(), + plan_content: Some("# Plan\n\n## Step 1\nDo something".into()), + }; + let mut pav = crate::views::plan_approval_view::PlanApprovalViewState::new( + request, + crate::views::prompt_widget::StashedPrompt { + text: String::new(), + cursor: 0, + images: Vec::new(), + chip_elements: Vec::new(), + image_counter: 0, + image_undo_stash: Vec::new(), + }, + tx, + ); + pav.focus = PlanApprovalFocus::Prompt; + agent.plan_approval_view = Some(pav); + agent.prompt.set_text(""); + agent + } + #[test] + fn empty_enter_on_revise_prompt_does_not_approve() { + let mut agent = agent_with_revise_prompt(); + let outcome = agent.handle_plan_feedback_key(&enter_key()); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!( + agent.plan_approval_view.is_some(), + "empty Enter must leave plan approval open" + ); + assert_eq!( + agent.toast.as_ref().map(|(msg, _)| msg.as_str()), + Some("Type revision notes, or press a to approve.") + ); + } + #[test] + fn enter_with_revision_text_requests_changes() { + let mut agent = agent_with_revise_prompt(); + agent.prompt.set_text("please use auth middleware"); + let outcome = agent.handle_plan_feedback_key(&enter_key()); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!(agent.plan_approval_view.is_none()); + assert_eq!( + agent.toast.as_ref().map(|(msg, _)| msg.as_str()), + Some("Plan revision sent.") + ); + } + #[test] + fn empty_enter_with_pending_comments_still_requests_changes() { + let mut agent = agent_with_revise_prompt(); + if let Some(ref mut pav) = agent.plan_approval_view { + pav.comments.push(PlanComment { + id: 1, + line_range: 0..1, + text: "nit".into(), + }); + } + let outcome = agent.handle_plan_feedback_key(&enter_key()); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!(agent.plan_approval_view.is_none()); + assert_eq!( + agent.toast.as_ref().map(|(msg, _)| msg.as_str()), + Some("Plan revision sent.") + ); + } + #[test] + fn a_on_empty_revise_prompt_approves() { + let mut agent = agent_with_revise_prompt(); + let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + let outcome = agent.handle_plan_feedback_key(&a); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!(agent.plan_approval_view.is_none(), "`a` must approve"); + assert_ne!( + agent.toast.as_ref().map(|(msg, _)| msg.as_str()), + Some("Plan revision sent.") + ); + } + #[test] + fn a_with_pending_comments_does_not_approve() { + let mut agent = agent_with_revise_prompt(); + if let Some(ref mut pav) = agent.plan_approval_view { + pav.comments.push(PlanComment { + id: 1, + line_range: 0..1, + text: "nit".into(), + }); + } + let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + let _ = agent.handle_plan_feedback_key(&a); + assert!( + agent.plan_approval_view.is_some(), + "`a` with pending comments must type, not approve" + ); + assert_eq!(agent.prompt.text(), "a"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs index 50e30cd..e5e335c 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs @@ -122,7 +122,7 @@ impl AgentView { ] } else { vec![ - HintItem::new(key!(Enter), "approve"), + HintItem::new(key!('a'), "approve"), HintItem::new(key!(Tab), "plan"), HintItem::new(key!(Esc), "back"), ] @@ -698,6 +698,7 @@ impl AgentView { voice_interim, esc_owned_before_agent, } = app_params; + self.scrollback.begin_frame(); self.in_dashboard_overlay = in_dashboard_overlay; let super::BannerSlotParams { height: banner_height, diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs index 011573b..9a7e1f1 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs @@ -299,6 +299,7 @@ impl AgentView { sharing_enabled: false, scheduler_background_loops: None, billing_surface_visible: false, + usage_command_visible: true, input_log: crate::input_log::InputRingBuffer::new(), esc_pressed_at: None, rewind_suppress_deadline: None, @@ -918,6 +919,12 @@ impl AgentView { .slash_controller .set_billing_surface_visible(visible); } + pub fn set_usage_command_visible(&mut self, visible: bool) { + self.usage_command_visible = visible; + self.prompt + .slash_controller + .set_usage_command_visible(visible); + } /// Replace the restricted slash-command deny list in this agent's /// registry (e.g. `/usage` denied on the free / X Basic tiers). Deny /// wins over every `set_*_visible` gate. @@ -945,6 +952,7 @@ impl AgentView { &mut self, sharing_enabled: bool, billing_surface_visible: bool, + usage_command_visible: bool, chat_mode: bool, screen_mode: crate::app::ScreenMode, announcements: &[xai_grok_announcements::RemoteAnnouncement], @@ -952,6 +960,7 @@ impl AgentView { ) { self.set_sharing_enabled(sharing_enabled); self.set_billing_surface_visible(billing_surface_visible); + self.set_usage_command_visible(usage_command_visible); self.app_chat_mode = chat_mode; self.prompt.set_screen_mode(screen_mode); self.set_dashboard_visible(crate::views::dashboard::dashboard_enabled()); diff --git a/crates/codegen/xai-grok-pager/src/app/app_view.rs b/crates/codegen/xai-grok-pager/src/app/app_view.rs index d532c4c..87a15a9 100644 --- a/crates/codegen/xai-grok-pager/src/app/app_view.rs +++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs @@ -687,16 +687,19 @@ pub struct AppView { pub tip: Option, /// Whether to show the resolved model ID in /session-info output. pub show_resolved_model: bool, - /// Whether the `/share` slash command is available. Gated by - /// `RemoteSettings.sharing_enabled`; defaults to `false` when remote - /// settings are unavailable or the field is absent. + /// Whether the `/share` slash command is available. Currently forced off + /// while session share links are temporarily disabled in clients. pub sharing_enabled: bool, /// Whether the plugin marketplace CTA is enabled. Env `GROK_PLUGIN_CTA` /// overrides `RemoteSettings.plugin_cta` (remote settings); defaults to `false`. pub plugin_cta_enabled: bool, /// Consumer billing surface (credit fetches / warnings). False for team - /// and API-key auth. `/usage` itself stays available for session token/cost. + /// and API-key auth. `/usage` itself stays available for session token/cost + /// unless [`Self::has_external_auth_provider`]. pub usage_visible: bool, + /// External `auth_provider_command` deployment. + /// No grok.com billing session exists; `/usage` and credit UI stay off. + pub has_external_auth_provider: bool, /// Slash commands denied for the current subscription tier /// ([`TIER_RESTRICTED_COMMANDS`] when the user is on the free / X Basic /// tier, empty otherwise). Recomputed by [`Self::apply_tier_restrictions`] @@ -1197,47 +1200,6 @@ fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option) -> bo }; chrono::Utc::now() >= next } -/// Welcome-screen toast overlay (mirrors agent toast style). -/// -/// Prefer one row above the prompt, right-aligned to it. Fall back to -/// the view bottom-right when no prompt rect is available (login / gate). -fn paint_welcome_toast( - buf: &mut ratatui::buffer::Buffer, - area: ratatui::layout::Rect, - msg: &str, - prompt_rect: Option, -) { - let theme = crate::theme::Theme::current(); - let max_msg = (area.width as usize).saturating_sub(4); - if max_msg == 0 || area.height == 0 { - return; - } - let toast = if msg.chars().count() <= max_msg { - format!(" {msg} ") - } else { - let truncated: String = msg.chars().take(max_msg.saturating_sub(1)).collect(); - format!(" {}… ", truncated.trim_end()) - }; - let w = toast.chars().count() as u16; - let (x, y) = if let Some(prompt) = prompt_rect.filter(|r| r.width > 0 && r.y > area.y) { - let max_x = area.right().saturating_sub(w).max(area.x); - let x = prompt.right().saturating_sub(w + 1).clamp(area.x, max_x); - (x, prompt.y.saturating_sub(1)) - } else { - ( - area.right().saturating_sub(w + 1), - area.bottom().saturating_sub(1), - ) - }; - for (i, ch) in toast.chars().enumerate() { - if let Some(cell) = buf.cell_mut((x + i as u16, y)) { - cell.set_char(ch); - cell.fg = theme.accent_user; - cell.bg = theme.bg_base; - cell.modifier = ratatui::prelude::Modifier::BOLD; - } - } -} impl AppView { pub fn is_zdr_blocked(&self) -> bool { self.is_zdr && !self.zdr_access_enabled @@ -1344,7 +1306,8 @@ impl AppView { .subscription_tier .as_deref() .is_some_and(is_api_key_label); - self.usage_visible = meta.team_name.is_none() && !self.is_api_key_auth; + self.usage_visible = + meta.team_name.is_none() && !self.is_api_key_auth && !self.has_external_auth_provider; self.sync_billing_surface_to_agents(); self.apply_tier_restrictions(); if self.is_api_key_auth { @@ -1358,23 +1321,34 @@ impl AppView { self.show_resolved_model = show; } } - /// Mirror [`Self::usage_visible`] onto every slash surface that can run - /// `/usage` (agents, welcome, dashboard dispatch / peek-reply). + /// Mirror billing + `/usage` gates onto every slash surface (agents, + /// welcome, dashboard dispatch / peek-reply). pub(crate) fn sync_billing_surface_to_agents(&mut self) { - let visible = self.usage_visible; + let billing = self.usage_visible; + let usage_cmd = !self.has_external_auth_provider; for agent in self.agents.values_mut() { - agent.set_billing_surface_visible(visible); + agent.set_billing_surface_visible(billing); + agent.set_usage_command_visible(usage_cmd); } self.welcome_prompt .slash_controller - .set_billing_surface_visible(visible); + .set_billing_surface_visible(billing); + self.welcome_prompt + .slash_controller + .set_usage_command_visible(usage_cmd); if let Some(dash) = self.dashboard.as_mut() { dash.dispatch .slash_controller - .set_billing_surface_visible(visible); + .set_billing_surface_visible(billing); + dash.dispatch + .slash_controller + .set_usage_command_visible(usage_cmd); dash.peek_reply .slash_controller - .set_billing_surface_visible(visible); + .set_billing_surface_visible(billing); + dash.peek_reply + .slash_controller + .set_usage_command_visible(usage_cmd); } } /// Force voice on for API-key sessions when only a remote rule left it off. @@ -1574,6 +1548,7 @@ impl AppView { sharing_enabled: false, plugin_cta_enabled: false, usage_visible: true, + has_external_auth_provider: false, tier_restricted_commands: Vec::new(), leader_mode: false, credit_balance: None, @@ -1681,6 +1656,7 @@ impl AppView { pub fn apply_tier_restrictions(&mut self) { let restricted = self.team_name.is_none() && !self.is_api_key_auth + && !self.has_external_auth_provider && is_restricted_tier(self.subscription_tier.as_deref()); let names: Vec = if restricted { TIER_RESTRICTED_COMMANDS @@ -1997,13 +1973,14 @@ impl AppView { } ActiveView::AgentDashboard => { if let Some(d) = self.dashboard.as_mut() { - d.error_toast = Some(crate::glyphs::legacy_glyph_fallback(msg).into_owned()); + d.error_toast = Some(crate::glyphs::sanitize_toast_message(msg).into_owned()); } } ActiveView::Welcome => { - let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned(); - self.welcome_toast = - Some((msg, std::time::Instant::now() + WELCOME_TOAST_DURATION)); + self.welcome_toast = Some(( + crate::glyphs::sanitize_toast_message(msg).into_owned(), + std::time::Instant::now() + WELCOME_TOAST_DURATION, + )); } } } @@ -4389,7 +4366,7 @@ impl AppView { self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect; self.welcome_changelog_cta_rect = result.changelog_cta_rect; if let Some((ref msg, _)) = self.welcome_toast { - paint_welcome_toast( + crate::views::welcome::paint_welcome_toast( f.buffer_mut(), view_area, msg, @@ -5624,6 +5601,22 @@ pub(crate) mod tests { Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; #[test] + fn welcome_show_toast_scrubs_control_chars() { + let mut app = test_app(); + assert!(matches!(app.active_view, ActiveView::Welcome)); + app.show_toast("a\nb\rc\thttps://x.ai"); + let toast = app + .welcome_toast + .as_ref() + .map(|(m, _)| m.as_str()) + .unwrap_or(""); + assert!( + !toast.chars().any(|c| c.is_control()), + "control chars must be scrubbed at write: {toast:?}" + ); + assert!(toast.contains("https://x.ai"), "{toast:?}"); + } + #[test] fn parse_esc_ttl_bounds() { let default = PendingAction::ESC_DOUBLE_PRESS_TTL; assert_eq!(parse_esc_ttl(None), default); @@ -5865,6 +5858,7 @@ pub(crate) mod tests { sharing_enabled: false, plugin_cta_enabled: false, usage_visible: true, + has_external_auth_provider: false, tier_restricted_commands: Vec::new(), leader_mode: true, credit_balance: None, @@ -7049,6 +7043,22 @@ pub(crate) mod tests { assert_eq!(counts.get("t_seen"), Some(&2)); } #[test] + fn external_auth_provider_keeps_billing_off_after_auth_meta() { + let mut app = test_app(); + app.has_external_auth_provider = true; + app.usage_visible = false; + app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default()); + assert!(!app.usage_visible); + assert!(app.tier_restricted_commands.is_empty()); + assert!( + !app.welcome_prompt + .slash_controller + .registry() + .is_restricted("usage") + ); + assert!(!app.welcome_prompt.slash_controller.usage_command_visible()); + } + #[test] fn apply_auth_meta_disables_billing_surface_for_team_users() { let mut app = test_app(); assert!(app.usage_visible); diff --git a/crates/codegen/xai-grok-pager/src/app/cli.rs b/crates/codegen/xai-grok-pager/src/app/cli.rs index b239348..5e2cee2 100644 --- a/crates/codegen/xai-grok-pager/src/app/cli.rs +++ b/crates/codegen/xai-grok-pager/src/app/cli.rs @@ -502,6 +502,10 @@ pub struct PagerArgs { /// Output format for headless mode. #[clap(long = "output-format", value_enum, default_value = "plain")] pub output_format: OutputFormat, + /// Emit incremental `stream_event` lines (text/thinking deltas) alongside + /// whole messages. Only affects `--output-format streaming-messages-json`. + #[clap(long = "include-partial-messages")] + pub include_partial_messages: bool, /// JSON Schema for structured output. When set, the model is constrained to /// produce JSON matching this schema. Implies --output-format json. /// Example: --json-schema '{"type":"object","properties":{"name":{"type":"string"}}}' diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/ctx.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/ctx.rs index 157b84a..63bf808 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/ctx.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/ctx.rs @@ -46,15 +46,14 @@ pub(super) fn open_url_or_show(app: &mut AppView, url: &str) { return; } - use crate::app::link_opener::{OpenUrlResult, browser_unavailable_message, try_open_url}; + use crate::app::link_opener::{OpenUrlResult, browser_unavailable_line, try_open_url}; use crate::terminal::hyperlinks::SchemeFilter; match try_open_url(url, SchemeFilter::Standard) { OpenUrlResult::Opened | OpenUrlResult::RejectedScheme => {} OpenUrlResult::BrowserUnavailable => { - let _ = crate::clipboard::SystemClipboard::try_set(url); - // No scrollback on the welcome screen — toast carries the URL. - app.show_toast(&browser_unavailable_message(url)); + let copied = crate::clipboard::SystemClipboard::try_set(url).reported_success(); + app.show_toast(&browser_unavailable_line(url, copied)); } } } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs index 091cd70..a8445cd 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs @@ -56,14 +56,23 @@ pub(super) fn ensure_dashboard_state(app: &mut AppView) { state.set_voice_visible(app.voice_mode_enabled); state.set_restricted_commands(&app.tier_restricted_commands); let billing = app.usage_visible; + let usage_cmd = !app.has_external_auth_provider; state .dispatch .slash_controller .set_billing_surface_visible(billing); + state + .dispatch + .slash_controller + .set_usage_command_visible(usage_cmd); state .peek_reply .slash_controller .set_billing_surface_visible(billing); + state + .peek_reply + .slash_controller + .set_usage_command_visible(usage_cmd); app.dashboard = Some(state); } @@ -1270,9 +1279,10 @@ pub(super) fn dispatch_dashboard_dispatch( /// /// Offer / execute tri-state (matches completion's [`command_offered`]): /// - **Unknown** token → [`dispatch_dashboard_dispatch`] (new session prompt). -/// - **Registered, not offered** (session-scoped hidden on this surface, -/// or `dashboard_only` off-dashboard) → clear dispatch + error toast; -/// do **not** spawn with the slash text as the prompt. +/// - **Registered, session-scoped** (hidden on this surface) → clear +/// dispatch + error toast; do **not** spawn with the slash as a prompt. +/// - **Registered, not visible** (auth/feature gate, e.g. `/usage` on +/// external auth) → still `command.run` so the command owns the error. /// - **Registered, offered** → MRU + `command.run` (e.g. `/model` / /// `/plan` stage the next spawn). pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) -> Vec { @@ -1350,14 +1360,16 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) // path so the text becomes a new session's prompt. return dispatch_dashboard_dispatch(app, text, /* attach */ false); }; - // Registered but not offered on this surface (session-scoped - // hidden from the dropdown, or non-dashboard `dashboard_only`): - // error toast — never spawn a session whose first prompt is the - // slash text (that was worse than the old loud Action toasts). + // Registered but not offered on this surface: + // - session-scoped → toast; never spawn with the slash as a prompt + // - `visible() == false` (e.g. `/usage` on external auth) → still + // `run()` so the command owns the refusal message if !dashboard .dispatch .slash_controller .is_command_offered(command.as_ref(), &app.models) + && command.session_scoped() + && !command.offered_when_session_less() { let name = command.name(); if let Some(d) = app.dashboard.as_mut() { @@ -1381,6 +1393,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) bundle_state: &app.bundle_state, screen_mode: app.screen_mode, billing_surface_visible: app.usage_visible, + usage_command_visible: !app.has_external_auth_provider, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: dashboard_multiline, yolo_mode: app.default_yolo, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs index 3bd7150..f9f78c2 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs @@ -551,6 +551,7 @@ pub(super) fn dispatch_send_prompt_inner( bundle_state: &app.bundle_state, screen_mode: app.screen_mode, billing_surface_visible: app.usage_visible, + usage_command_visible: !app.has_external_auth_provider, // PAGER-owned snapshot for slash commands. pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: agent.multiline_mode, @@ -607,15 +608,15 @@ pub(super) fn dispatch_send_prompt_inner( }); } if let Some(command) = command { - if ctx.screen_mode.is_minimal() && !command.available_in_minimal() { - // Central minimal gate: commands that drive the deleted - // fullscreen pane / dashboard (/find, /dashboard, …) - // have nothing to act on in scrollback-native mode. - // Surface a friendly system block instead of running them. - CommandResult::Message(format!( - "/{} is not available in minimal mode", - invocation.token - )) + // Central screen-mode gate. Such a command is already + // filtered out of every completion surface, but it stays + // resolvable so a fully-typed invocation earns a hint that + // names the way out instead of leaking to the model. + if let Some(refusal) = command + .mode_support() + .refusal(invocation.token, ctx.screen_mode) + { + CommandResult::Message(refusal) } else { agent .prompt diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/fork.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/fork.rs index 37f721f..708c3ed 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/fork.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/fork.rs @@ -213,6 +213,7 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved( agent.apply_app_scoped_gates( app.sharing_enabled, app.usage_visible, + !app.has_external_auth_provider, app.chat_mode, app.screen_mode, &app.active_announcements, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs index e65cbb4..9fb97ec 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs @@ -340,6 +340,7 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id( agent.apply_app_scoped_gates( app.sharing_enabled, app.usage_visible, + !app.has_external_auth_provider, app.chat_mode, app.screen_mode, &app.active_announcements, @@ -783,6 +784,7 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session( agent.apply_app_scoped_gates( app.sharing_enabled, app.usage_visible, + !app.has_external_auth_provider, app.chat_mode, app.screen_mode, &app.active_announcements, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs index 3a36b40..f3cd940 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs @@ -203,6 +203,7 @@ fn dispatch_load_session_ungated( agent_mut.apply_app_scoped_gates( app.sharing_enabled, app.usage_visible, + !app.has_external_auth_provider, app.chat_mode, app.screen_mode, &app.active_announcements, @@ -862,6 +863,7 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore( agent.apply_app_scoped_gates( app.sharing_enabled, app.usage_visible, + !app.has_external_auth_provider, app.chat_mode, app.screen_mode, &app.active_announcements, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs index da33972..606ae74 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs @@ -122,7 +122,7 @@ pub(in crate::app::dispatch) fn dispatch_open_command_palette(app: &mut AppView) agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( agent.sharing_enabled, - agent.prompt.slash_controller.screen_mode(), + &agent.prompt.slash_controller, ), // Type-to-find: open in input mode (matches Ctrl+P). state: crate::views::picker::PickerState::input_active(), @@ -257,7 +257,9 @@ pub(in crate::app::dispatch) fn dispatch_open_settings( { // Try the chooser; a locked row keeps Browse (`try_enter_picking_enum` // refuses when `row_lock` is set). - state.try_enter_picking_enum(); + if state.try_enter_picking_enum() { + state.close_on_picker_exit = true; + } } agent.active_modal = Some(ActiveModal::Settings { state }); effects diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs index dd35c68..443b1d3 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs @@ -11,37 +11,10 @@ use crate::app::app_view::{ActiveView, AppView}; use crate::notifications::{NotificationEvent, NotificationEventKind}; use crate::scrollback::block::RenderBlock; -/// Toggle YOLO mode (auto-approve all permissions). -/// -/// When turning ON: auto-approve all currently queued permissions and -/// restore the stashed prompt. Future incoming permissions will be -/// auto-approved in `handle_permission_request`. -/// -/// Share the current session via a public URL. -/// -/// Produces Effect::ShareSession which spawns an async ACP ext request. -/// On completion, TaskResult::ShareSessionComplete shows the URL in scrollback. +/// Temporary kill switch: client share links are disabled. pub(super) fn dispatch_share_session(app: &mut AppView) -> Vec { - if !app.sharing_enabled { - app.show_toast("Sharing is disabled"); - return vec![]; - } - let ActiveView::Agent(id) = app.active_view else { - return vec![]; - }; - let Some(agent) = app.agents.get_mut(&id) else { - return vec![]; - }; - let Some(session_id) = agent.session.session_id.clone() else { - // No active session — error should have been caught by slash command, - // but guard here just in case. - return vec![]; - }; - - vec![Effect::ShareSession { - agent_id: id, - session_id, - }] + app.show_toast("Session sharing is temporarily disabled"); + vec![] } /// Show session info: fetch via x.ai/session/info and display in scrollback. diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs index cb6c0c0..5e2a442 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs @@ -1273,13 +1273,9 @@ fn open_url_shows_manual_url_when_browser_unavailable() { "must push a system message with the URL" ); let text = last_system_text(&app, AgentId(0)); - assert!( - text.contains("Could not open a browser"), - "fallback copy missing: {text}" - ); - assert!( - text.contains(url), - "full billing URL must be visible for copy: {text}" + assert_eq!( + text, + crate::app::link_opener::browser_unavailable_message(url) ); let toast = app.agents[&AgentId(0)] .toast @@ -1322,6 +1318,65 @@ fn open_url_does_not_show_fallback_when_opener_succeeds() { let _ = std::fs::remove_file(&url_file); } +/// Welcome has no scrollback: browser-unavailable OpenUrl must put a +/// single-line toast that includes the full URL (no `\n` — the welcome +/// painter is one row). Privacy-banner Terms/Policy clicks hit this path. +#[serial_test::serial(GROK_TEST_OPEN_URL_FILE)] +#[test] +fn open_url_welcome_toasts_single_line_url_when_browser_unavailable() { + let bad = std::env::temp_dir().join(format!( + "grok-open-url-welcome-missing-{}/out.txt", + std::process::id() + )); + // SAFETY: serialized via `serial_test` so no other test races the env var. + unsafe { std::env::set_var("GROK_TEST_OPEN_URL_FILE", &bad) }; + + let mut app = test_app(); + assert!( + matches!(app.active_view, ActiveView::Welcome), + "fixture must start on welcome" + ); + + use crate::app::link_opener::browser_unavailable_line; + + let terms = crate::views::privacy_banner::PRIVACY_BANNER_TERMS_URL; + let effects = dispatch(Action::OpenUrl(terms.to_string()), &mut app); + assert!(effects.is_empty()); + let toast = app + .welcome_toast + .as_ref() + .map(|(m, _)| m.as_str()) + .unwrap_or(""); + // Structure only: clipboard delivery varies by host, so do not lock the + // exact "copied" phrase here (constructor unit tests cover both arms). + assert!(toast.starts_with(terms), "{toast}"); + assert!(!toast.contains('\n'), "{toast}"); + assert!( + toast == browser_unavailable_line(terms, true) + || toast == browser_unavailable_line(terms, false), + "welcome toast must match a delivery-honest line form: {toast}" + ); + + // Policy URL is shorter than terms (compile-time constants); second toast + // replaces the first in welcome toast state. + let policy = crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL; + let _ = dispatch(Action::OpenUrl(policy.to_string()), &mut app); + let toast = app + .welcome_toast + .as_ref() + .map(|(m, _)| m.as_str()) + .unwrap_or(""); + assert!(toast.starts_with(policy), "{toast}"); + assert!( + toast == browser_unavailable_line(policy, true) + || toast == browser_unavailable_line(policy, false), + "second welcome toast must match a delivery-honest line form: {toast}" + ); + + // SAFETY: serialized via `serial_test`; restore the env for other tests. + unsafe { std::env::remove_var("GROK_TEST_OPEN_URL_FILE") }; +} + /// Credit-limit upsell Q&A submit routes through OpenUrl; when the browser /// is unavailable the full option URL must land in scrollback. #[serial_test::serial(GROK_TEST_OPEN_URL_FILE)] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs index 3c72794..27a6889 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs @@ -1688,6 +1688,41 @@ fn dashboard_does_not_advertise_or_dispatch_doctor() { assert_eq!(dashboard.error_toast.as_deref(), Some(expected.as_str())); } } +/// External-auth hides `/usage` via `visible()`, not session-scope. Typed +/// `/usage` on the dashboard must refuse with the command's message, not +/// claim it only works in a session. +#[serial_test::serial(GROK_AGENT_DASHBOARD)] +#[test] +fn dashboard_slash_usage_hidden_for_external_auth() { + let mut app = three_agent_app(); + app.has_external_auth_provider = true; + app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default()); + open_dashboard(&mut app); + let before = app.agents.len(); + let effects = dispatch_dashboard_dispatch_slash(&mut app, "/usage".into()); + assert!(effects.is_empty(), "must not enqueue spawn effects"); + assert_eq!(app.agents.len(), before, "must not add an agent"); + assert_eq!(app.dashboard.as_ref().unwrap().dispatch.text(), ""); + let toast = app + .dashboard + .as_ref() + .unwrap() + .error_toast + .as_deref() + .expect("error toast for gated /usage"); + assert!( + toast.contains("/usage is not available"), + "unexpected toast: {toast}" + ); + assert!( + !toast.contains("only works in a session"), + "must not mis-label /usage as session-scoped: {toast}" + ); + assert!( + !toast.contains("SuperGrok"), + "must not upsell billing on external auth: {toast}" + ); +} /// Session-scoped Action builtins must not spawn an agent whose first /// prompt is the slash text (registered + not offered → error toast). #[serial_test::serial(GROK_AGENT_DASHBOARD)] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs index d49ac34..384b625 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs @@ -250,6 +250,7 @@ fn test_app() -> AppView { sharing_enabled: false, plugin_cta_enabled: false, usage_visible: true, + has_external_auth_provider: false, tier_restricted_commands: Vec::new(), leader_mode: true, credit_balance: None, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs index 7802836..f852726 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs @@ -3077,7 +3077,7 @@ fn slash_and_exit_input_does_not_trigger_project_picker() { assert!(!input_can_trigger_project_picker(" ")); } -// ── Minimal-mode slash gate tests ─────────────────────────────────── +// ── Screen-mode slash gate tests ──────────────────────────────────── /// Returns true if any system block in agent 0's scrollback contains /// `needle`. Avoids `last_system_text`'s "last block must be System" panic @@ -3105,20 +3105,52 @@ fn minimal_mode_blocks_fullscreen_pane_slash_command() { before + 1, "the gate should commit exactly one system block" ); + let refusal = last_system_text(&app, AgentId(0)); assert!( - last_system_text(&app, AgentId(0)).contains("not available in minimal mode"), - "got: {:?}", - last_system_text(&app, AgentId(0)) + refusal.starts_with("/find isn't available in minimal mode"), + "got: {refusal:?}" + ); + assert!( + refusal.contains("Run /fullscreen"), + "the refusal must name the way out, got: {refusal:?}" ); } +#[test] +fn fullscreen_mode_blocks_minimal_only_slash_command() { + let mut app = test_app_with_agent(); + app.screen_mode = crate::app::ScreenMode::Fullscreen; + let effects = dispatch_send_prompt(&mut app, "/expand".to_string()); + assert!(effects.is_empty(), "got: {effects:?}"); + let refusal = last_system_text(&app, AgentId(0)); + assert_eq!( + refusal, + "/expand isn't available in fullscreen mode — press Tab to focus the scrollback, \ + then → on the block." + ); +} + +#[test] +fn mode_switcher_in_its_own_mode_says_you_are_already_there() { + let mut app = test_app_with_agent(); + app.screen_mode = crate::app::ScreenMode::Minimal; + let effects = dispatch_send_prompt(&mut app, "/minimal".to_string()); + assert!(effects.is_empty(), "got: {effects:?}"); + assert_eq!( + last_system_text(&app, AgentId(0)), + "You're already in minimal mode." + ); +} + +/// Inline (`--no-alt-screen`) is a full TUI, so fullscreen-only commands run +/// there — the gate keys off "is minimal", not "is `ScreenMode::Fullscreen`". #[test] fn non_minimal_mode_allows_fullscreen_pane_slash_command() { let mut app = test_app_with_agent(); app.screen_mode = crate::app::ScreenMode::Inline; let _ = dispatch_send_prompt(&mut app, "/find foo".to_string()); assert!( - !scrollback_has_system_text(&app, AgentId(0), "not available in minimal mode"), + !scrollback_has_system_text(&app, AgentId(0), "isn't available"), "the gate must not fire outside minimal mode" ); } @@ -3130,7 +3162,7 @@ fn minimal_mode_allows_mode_agnostic_slash_command() { // `/help` is a minimal-native command (opens the command palette). let _ = dispatch_send_prompt(&mut app, "/help".to_string()); assert!( - !scrollback_has_system_text(&app, AgentId(0), "not available in minimal mode"), + !scrollback_has_system_text(&app, AgentId(0), "isn't available"), "denylist default must keep mode-agnostic commands available" ); } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs index a53c461..3bf1c92 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs @@ -665,6 +665,7 @@ fn dispatch_open_settings_opens_then_close_on_reentry() { #[test] fn dispatch_open_settings_focus_reopens_when_already_open() { use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalMode; let mut app = test_app_with_agent(); let _ = dispatch(Action::OpenSettings, &mut app); let agent = app.agents.get(&AgentId(0)).unwrap(); @@ -687,6 +688,15 @@ fn dispatch_open_settings_focus_reopens_when_already_open() { Some("coding_data_sharing"), "focused re-entry must land on the requested row" ); + assert!( + matches!(state.mode(), SettingsModalMode::PickingEnum { .. }), + "focused re-entry must open the chooser, got {:?}", + state.mode() + ); + assert!( + state.close_on_picker_exit, + "focused re-entry must arm close_on_picker_exit" + ); } /// Chooser when editable, browse row when locked. The team-admin arm is the /// one a `team_name.is_some()` shortcut would break. @@ -744,6 +754,201 @@ fn dispatch_open_settings_focus_skips_the_chooser_only_when_locked() { "a team admin is not locked" ); } +/// Focused open that enters the chooser sets `close_on_picker_exit` so Esc +/// dismisses the modal (GB-4470). Locked landings stay in Browse with the +/// flag clear — chrome Esc already closes. +#[test] +fn dispatch_open_settings_focus_sets_close_on_picker_exit_when_chooser_opens() { + use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalMode; + let mut app = test_app_with_agent(); + let _ = dispatch( + Action::OpenSettingsFocus { + key: "coding_data_sharing", + }, + &mut app, + ); + let agent = app.agents.get(&AgentId(0)).unwrap(); + let Some(ActiveModal::Settings { state }) = &agent.active_modal else { + panic!("settings modal must be open") + }; + assert!( + matches!(state.mode(), SettingsModalMode::PickingEnum { .. }), + "editable focus must open the chooser" + ); + assert!( + state.close_on_picker_exit, + "deep-link chooser open must set close_on_picker_exit" + ); + let mut app = test_app_with_agent(); + app.is_zdr = true; + let _ = dispatch( + Action::OpenSettingsFocus { + key: "coding_data_sharing", + }, + &mut app, + ); + let agent = app.agents.get(&AgentId(0)).unwrap(); + let Some(ActiveModal::Settings { state }) = &agent.active_modal else { + panic!("settings modal must be open") + }; + assert!(matches!(state.mode(), SettingsModalMode::Browse)); + assert!( + !state.close_on_picker_exit, + "locked focus must not set close_on_picker_exit" + ); +} +/// Plain OpenSettings does not arm close-on-picker-Esc. +#[test] +fn dispatch_open_settings_does_not_set_close_on_picker_exit() { + use crate::views::modal::ActiveModal; + let mut app = test_app_with_agent(); + let _ = dispatch(Action::OpenSettings, &mut app); + let agent = app.agents.get(&AgentId(0)).unwrap(); + let Some(ActiveModal::Settings { state }) = &agent.active_modal else { + panic!("settings modal must be open") + }; + assert!(!state.close_on_picker_exit); +} +/// Full path: `/privacy`-style focus open → Esc dismisses the settings modal. +#[test] +fn open_settings_focus_esc_closes_settings_modal() { + use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalMode; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + let mut app = test_app_with_agent(); + let id = AgentId(0); + let _ = dispatch( + Action::OpenSettingsFocus { + key: "coding_data_sharing", + }, + &mut app, + ); + { + let agent = app.agents.get(&id).unwrap(); + let Some(ActiveModal::Settings { state }) = &agent.active_modal else { + panic!("settings modal must be open") + }; + assert!(matches!( + state.mode(), + SettingsModalMode::PickingEnum { .. } + )); + assert!(state.close_on_picker_exit); + } + let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = app.handle_input(&esc); + assert!( + app.agents.get(&id).unwrap().active_modal.is_none(), + "deep-link Esc must dismiss the settings modal" + ); +} +/// Full path: `/privacy`-style focus open → Enter commits and dismisses. +#[test] +fn open_settings_focus_enter_closes_settings_modal() { + use crate::app::app_view::InputOutcome; + use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalMode; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + let mut app = test_app_with_agent(); + let id = AgentId(0); + let _ = dispatch( + Action::OpenSettingsFocus { + key: "coding_data_sharing", + }, + &mut app, + ); + { + let agent = app.agents.get(&id).unwrap(); + let Some(ActiveModal::Settings { state }) = &agent.active_modal else { + panic!("settings modal must be open") + }; + assert!(matches!( + state.mode(), + SettingsModalMode::PickingEnum { .. } + )); + assert!(state.close_on_picker_exit); + } + let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let outcome = app.handle_input(&enter); + assert!( + app.agents.get(&id).unwrap().active_modal.is_none(), + "deep-link Enter must dismiss the settings modal" + ); + assert!( + matches!( + outcome, + InputOutcome::Action(Action::SetCodingDataSharing { .. }) + ), + "deep-link Enter must commit SetCodingDataSharing, got {outcome:?}" + ); +} +/// Browse path: OpenSettings → enter picker → Esc keeps modal open in Browse. +#[test] +fn open_settings_enter_picker_esc_stays_open_in_browse() { + use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalMode; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + let mut app = test_app_with_agent(); + let id = AgentId(0); + let _ = dispatch(Action::OpenSettings, &mut app); + { + let agent = app.agents.get_mut(&id).unwrap(); + let Some(ActiveModal::Settings { state }) = &mut agent.active_modal else { + panic!("settings modal must be open") + }; + assert!(state.focus_key("coding_data_sharing")); + assert!(state.try_enter_picking_enum()); + assert!(!state.close_on_picker_exit); + } + let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = app.handle_input(&esc); + let agent = app.agents.get(&id).unwrap(); + let Some(ActiveModal::Settings { state }) = &agent.active_modal else { + panic!("browse-path Esc must keep the settings modal open") + }; + assert!( + matches!(state.mode(), SettingsModalMode::Browse), + "browse-path Esc must return to Browse, got {:?}", + state.mode() + ); +} +/// `ActionThenClose` closes the modal and forwards the preview-revert Action +/// through `apply_settings_outcome` (handle_input path). +#[test] +fn deep_link_preview_esc_closes_modal_and_forwards_revert_action() { + use crate::app::app_view::InputOutcome; + use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalMode; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + let mut app = test_app_with_agent(); + let id = AgentId(0); + let _ = dispatch(Action::OpenSettings, &mut app); + { + let agent = app.agents.get_mut(&id).unwrap(); + let Some(ActiveModal::Settings { state }) = &mut agent.active_modal else { + panic!("settings modal must be open") + }; + assert!(state.focus_key("theme")); + assert!(state.try_enter_picking_enum()); + state.close_on_picker_exit = true; + assert!(matches!( + state.mode(), + SettingsModalMode::PickingEnum { .. } + )); + } + let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let outcome = app.handle_input(&esc); + assert!( + app.agents.get(&id).unwrap().active_modal.is_none(), + "ActionThenClose must clear active_modal" + ); + match outcome { + InputOutcome::Action(Action::PreviewTheme(name)) => { + assert_eq!(name, "groknight"); + } + other => panic!("expected Action(PreviewTheme), got {other:?}"), + } +} /// `dispatch_open_reset_confirm` moves the Settings modal state /// into the new `ResetSettingsConfirm` variant, preserving it /// across the confirm dialog's lifecycle. The dispatch arm is diff --git a/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs b/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs index 363af63..f0dc48f 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs @@ -342,12 +342,13 @@ impl SessionFlags { if meta.is_empty() { None } else { Some(meta) } } } -/// Workspace-bind `_meta` keys forbidden on chat create/load: backend owns -/// workspace for `kind=chat`; the client must not bind Direct/envId/attach. +/// Workspace-bind `_meta` keys **always** forbidden on chat create/load. +/// +/// `x.ai/cloud_existing_workspace` is intentionally omitted: scrub keeps it +/// iff `x.ai/local_workspace.mode == "attach"`. pub(super) const CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS: &[&str] = &[ "envId", "x.ai/cloud_server_id", - "x.ai/cloud_existing_workspace", ]; /// Stamp `_meta["x.ai/session"].kind = "chat"` and strip Build `agentProfile` (K12). pub(super) fn apply_chat_kind_meta(meta: &mut Option) { @@ -355,7 +356,23 @@ pub(super) fn apply_chat_kind_meta(meta: &mut Option) { obj.insert("x.ai/session".into(), serde_json::json!({ "kind": "chat" })); obj.remove("agentProfile"); } +/// Shared chat create/load/worktree meta finalize: kind + local stamp + scrub. +pub(super) fn finalize_chat_session_meta( + meta: &mut Option, + is_chat_path: bool, + #[allow(unused_variables)] + session_flags: &SessionFlags, +) { + if !is_chat_path { + return; + } + apply_chat_kind_meta(meta); + scrub_chat_workspace_bind_meta(meta); +} /// Remove client workspace-bind keys from chat create/load meta (defense in depth). +/// +/// Narrow scrub exception: keep `x.ai/cloud_existing_workspace` when local +/// intent is attach. Never keep `envId` or Direct hub `x.ai/cloud_server_id`. pub(super) fn scrub_chat_workspace_bind_meta(meta: &mut Option) { let Some(obj) = meta.as_mut() else { return; @@ -363,6 +380,9 @@ pub(super) fn scrub_chat_workspace_bind_meta(meta: &mut Option) { for key in CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS { obj.remove(*key); } + { + obj.remove("x.ai/cloud_existing_workspace"); + } } /// Metadata returned from effect execution so the event loop can patch /// state that requires a spawned task handle (e.g., auth AbortHandle). diff --git a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs index 70bb99b..c17efca 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs @@ -142,9 +142,7 @@ pub(crate) fn execute( #[allow(unused_mut)] let mut meta = session_flags.to_meta(); let is_chat_path = chat_kind || session_flags.chat_mode; - if is_chat_path { - apply_chat_kind_meta(&mut meta); - } + finalize_chat_session_meta(&mut meta, is_chat_path, session_flags); if let Some(ref mid) = model_id { meta.get_or_insert_with(acp::Meta::new) .insert("modelId".into(), serde_json::json!(mid.0)); @@ -238,13 +236,11 @@ pub(crate) fn execute( let tx = acp_tx.clone(); let cwd = cwd.to_path_buf(); let mut meta = session_flags.to_meta(); - if chat_kind || session_flags.chat_mode { - meta.get_or_insert_with(acp::Meta::new) - .insert( - "x.ai/session".into(), - serde_json::json!({ "kind": "chat" }), - ); - } + finalize_chat_session_meta( + &mut meta, + chat_kind || session_flags.chat_mode, + session_flags, + ); if let Some(ref mid) = model_id { meta.get_or_insert_with(acp::Meta::new) .insert("modelId".into(), serde_json::json!(mid.0)); @@ -520,10 +516,7 @@ pub(crate) fn execute( let tx = acp_tx.clone(); let mut meta = session_flags.to_meta(); let is_chat_path = chat_kind || session_flags.chat_mode; - if is_chat_path { - apply_chat_kind_meta(&mut meta); - scrub_chat_workspace_bind_meta(&mut meta); - } + finalize_chat_session_meta(&mut meta, is_chat_path, session_flags); if let Some(true) = session_flags.restore_code { meta.get_or_insert_with(acp::Meta::new) .insert("x.ai/restore_code".into(), serde_json::Value::Bool(true)); diff --git a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs index a6ae275..25d8bd5 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs @@ -1979,7 +1979,7 @@ fn to_meta_chat_mode_stamps_kind_and_omits_agent_profile() { ..Default::default() }; let meta = flags.to_meta().expect("chat_mode must emit meta"); - assert_eq!(meta["x.ai/session"]["kind"], "chat"); + assert_eq!(meta["x.ai/session"] ["kind"], "chat"); assert!( meta.get("agentProfile").is_none(), "K12: chat mode must omit Build agentProfile" @@ -2006,7 +2006,7 @@ fn load_meta_chat_kind_alone_stamps_kind_and_strips_profile() { scrub_chat_workspace_bind_meta(&mut meta); } let meta = meta.expect("chat_kind must produce meta"); - assert_eq!(meta["x.ai/session"]["kind"], "chat"); + assert_eq!(meta["x.ai/session"] ["kind"], "chat"); assert!( meta.get("agentProfile").is_none(), "entry chat_kind must strip Build agentProfile" @@ -2025,6 +2025,10 @@ fn assert_chat_meta_has_no_workspace_bind_keys(meta: &serde_json::Value) { "chat meta must not include workspace-bind key {key:?}: {meta}" ); } + assert!( + meta.get("x.ai/cloud_existing_workspace").is_none(), + "chat meta without attach must not include existing workspace: {meta}" + ); } #[test] fn chat_create_meta_never_includes_workspace_bind_keys_when_cloud_fields_set() { @@ -2036,7 +2040,7 @@ fn chat_create_meta_never_includes_workspace_bind_keys_when_cloud_fields_set() { apply_chat_kind_meta(&mut meta); scrub_chat_workspace_bind_meta(&mut meta); let meta = meta.expect("chat create must emit meta"); - assert_eq!(meta["x.ai/session"]["kind"], "chat"); + assert_eq!(meta["x.ai/session"] ["kind"], "chat"); assert_chat_meta_has_no_workspace_bind_keys( &serde_json::Value::Object(meta.clone()), ); @@ -2060,7 +2064,7 @@ fn chat_load_meta_never_includes_workspace_bind_keys() { } scrub_chat_workspace_bind_meta(&mut meta); let meta = meta.expect("chat load must emit meta"); - assert_eq!(meta["x.ai/session"]["kind"], "chat"); + assert_eq!(meta["x.ai/session"] ["kind"], "chat"); assert_chat_meta_has_no_workspace_bind_keys( &serde_json::Value::Object(meta.clone()), ); diff --git a/crates/codegen/xai-grok-pager/src/app/event_loop.rs b/crates/codegen/xai-grok-pager/src/app/event_loop.rs index 0f44df3..98cbd5b 100644 --- a/crates/codegen/xai-grok-pager/src/app/event_loop.rs +++ b/crates/codegen/xai-grok-pager/src/app/event_loop.rs @@ -4,34 +4,27 @@ //! management is delegated to [`AppView`]. The event loop only handles //! IO plumbing: terminal events, ACP channel, spawned task results, //! animation ticks, and hot-reloadable config changes. - -use std::time::Duration; - -use anyhow::Context as _; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use tokio::task::JoinSet; -use tokio::time::{Instant, sleep_until}; - -use crate::appearance::ConfigWatcher; -use crate::client_identity::{PAGER_CLIENT_TYPE, PAGER_CLIENT_VERSION}; -use crate::theme::system_appearance::{self, SystemAppearanceWatcher}; -use crate::theme::{Theme, ThemeKind, cache as theme_cache}; - -use agent_client_protocol as acp; -use xai_acp_lib::acp_send; - use super::actions::{Action, Effect, TaskResult}; use super::app_view::{ ActiveView, AppView, AuthState, InputOutcome, PasteProvenance, TrustState, VoiceState, }; use super::{PagerArgs, PagerTerminal, acp_handler, dispatch, effects}; - +use crate::appearance::ConfigWatcher; +use crate::client_identity::{PAGER_CLIENT_TYPE, PAGER_CLIENT_VERSION}; +use crate::theme::system_appearance::{self, SystemAppearanceWatcher}; +use crate::theme::{Theme, ThemeKind, cache as theme_cache}; +use agent_client_protocol as acp; +use anyhow::Context as _; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::time::Duration; +use tokio::task::JoinSet; +use tokio::time::{Instant, sleep_until}; +use xai_acp_lib::acp_send; #[derive(Clone, Debug, PartialEq)] pub(super) struct TimedInputEvent { pub(super) event: Event, pub(super) arrived_at: std::time::Instant, } - impl TimedInputEvent { fn now(event: Event) -> Self { Self { @@ -40,7 +33,6 @@ impl TimedInputEvent { } } } - /// Values resolved before `init_terminal` and consumed by the event loop. /// /// All fields must be computed while stdin is still in cooked mode and @@ -56,7 +48,6 @@ pub(crate) struct TerminalState { /// its OSC 11 fallback reads stdin and competes with the input reader. pub initial_theme: ThemeKind, } - /// Result of the event loop run. pub(crate) struct RunResult { pub exit_info: Option, @@ -65,7 +56,6 @@ pub(crate) struct RunResult { /// terminal restore. See `/minimal` and `/fullscreen`. pub relaunch: Option, } - /// In-flight reconnect re-initialization, tied to the agents whose reload /// windows it opened so completion lands on them even if the user switches /// views (or closes one) while the re-init runs. @@ -77,7 +67,6 @@ struct ReconnectReinit { /// Reconnect generation that opened the reload windows. generation: u64, } - /// Result of a reconnect re-initialization task. struct ReinitOutcome { /// Whether initialize/authenticate succeeded; when false no load was @@ -85,7 +74,6 @@ struct ReinitOutcome { init_ok: bool, loads: Vec, } - /// Per-agent `session/load` outcome from the re-init task. struct AgentLoadOutcome { agent_id: super::agent::AgentId, @@ -100,7 +88,6 @@ struct AgentLoadOutcome { /// describes a runtime the new actor will not use. scheduler_background_loops: Option, } - /// Fields of the reconnect `session/load`, derived from the agent being /// reloaded. `None` when the agent has no session yet. struct ReconnectLoadPlan { @@ -114,7 +101,6 @@ struct ReconnectLoadPlan { /// and full-replays when it doesn't. meta: serde_json::Value, } - fn restore_dashboard_peek_before_reload( dashboard: &mut Option, agents: &mut indexmap::IndexMap, @@ -123,7 +109,6 @@ fn restore_dashboard_peek_before_reload( dashboard.restore_peek_viewport(agents); } } - fn plan_reconnect_load( agent: &super::agent_view::AgentView, fallback_cwd: &std::path::Path, @@ -135,12 +120,6 @@ fn plan_reconnect_load( agent.session.cwd.clone() }; let yolo = agent.session.is_yolo(); - // Set BOTH yoloMode and autoMode explicitly. The leader's capability injection - // only fills ABSENT keys, so omitting autoMode here lets a stale launch-time - // `ClientCapabilities.auto_mode` re-enable Auto after the user left it (e.g. - // Shift+Tab to Ask). Auto is per-agent (symmetric with yolo) — derive it from - // this agent's own `auto_mode` so a background tab reconnects with ITS mode, - // not the active tab's global `current_ui` mirror. let auto = super::dispatch::effective_auto(yolo, agent.session.is_auto()); let mut meta = serde_json::json!({ "yoloMode": yolo, "autoMode": auto }); if let Some(ref cursor) = agent.last_seen_event_id { @@ -152,7 +131,6 @@ fn plan_reconnect_load( meta, }) } - /// Resolve the two post-reconnect restore outcomes from the per-agent /// `session/load` results. /// @@ -180,7 +158,6 @@ fn reconnect_restore_outcome( && active_agent_id.is_some_and(|aid| pending_agent_ids.contains(&aid) && load_ok(&aid)); (all_restored, active_restored) } - /// Compute the folder-trust verdict for the session cwd and seed /// [`AppView::trust_state`]. Pager-side mirror of the agent's resolve: read the /// local store, scan for repo-local code-exec config, and run the pure @@ -199,30 +176,19 @@ fn seed_trust_state( TrustOutcome, decide, decide_inputs_with_interactive, feature_enabled, }; use xai_grok_workspace::trust::workspace_key; - let feature = feature_enabled(remote); if !feature { app.trust_state = TrustState::Done; return; } - - // The cwd the user launched in == the process cwd == `app.cwd` (set at - // construction), matching the `--trust` grant's `std::env::current_dir()`. let cwd = app.cwd.clone(); let key = workspace_key(&cwd); - // Reuse the canonical gather (store trust + repo-config scan) but pass the - // pager's stdin-only interactivity: the TUI prompts via the rendered - // question + crossterm keyboard, NOT stderr (the pager redirects native - // stderr at startup, so the engine's `stdin && stderr` would be false here - // and the question would never show). TTY stdin => user can answer; - // otherwise fail closed (no prompt). let inputs = decide_inputs_with_interactive(&cwd, &key, std::io::stdin().is_terminal()); app.trust_state = match decide(feature, &inputs) { TrustOutcome::Prompt => TrustState::Pending { workspace: key }, TrustOutcome::Trusted | TrustOutcome::Untrusted => TrustState::Done, }; } - /// Pause terminal input and wait up to `timeout` for the reader to acknowledge. /// Returns with the pause still asserted; the handoff owner resumes the reader. fn park_input_reader( @@ -231,8 +197,6 @@ fn park_input_reader( timeout: Duration, ) -> bool { use std::sync::atomic::Ordering; - // Storing `reader_parked = false` before `input_paused = true` is - // intentionally ordered to prevent accepting a stale parked acknowledgement. reader_parked.store(false, Ordering::Release); input_paused.store(true, Ordering::Release); let deadline = std::time::Instant::now() + timeout; @@ -241,7 +205,6 @@ fn park_input_reader( } reader_parked.load(Ordering::Acquire) } - /// Suspend the TUI, let a blocking child own the tty, then restore it. /// /// Input is parked before the asynchronous frame writer is drained with a @@ -279,9 +242,6 @@ fn suspend_for_child( return Err(error); } } - - // Pre-child cursor probe (minimal only — minimal's startup already proved - // this terminal answers CPR). Reader is parked, so the reply is ours. let pre_cursor = screen_mode .is_minimal() .then(|| crossterm::cursor::position().ok()) @@ -299,24 +259,17 @@ fn suspend_for_child( let _ = crossterm::execute!(stderr, crossterm::terminal::EnterAlternateScreen); }); } - // Discard child-exit ANSI query replies (DA/DSR/cursor reports) the terminal - // buffered; reader is parked, so the main thread is the only crossterm caller. while crossterm::event::poll(Duration::from_millis(0)).unwrap_or(false) { let _ = crossterm::event::read(); } - // Post-child cursor probe: `Some` iff the child left the cursor somewhere - // other than where it found it; restore_after_child uses that to re-anchor - // minimal mode after main-screen output. let moved_cursor = pre_cursor.and_then(|pre| { let post = crossterm::cursor::position().ok()?; (post != pre).then_some(post) }); - // Only the pre-park race can reach this channel; later input stays in the tty. while input_rx.try_recv().is_ok() {} input_paused.store(false, Ordering::Release); Ok(moved_cursor) } - /// Coalesces draw requests, gates in-flight frames, and owns draw cadence. #[derive(Debug)] struct Presenter { @@ -326,7 +279,6 @@ struct Presenter { last_draw_at: Instant, draw_scheduled_at: Option, } - impl Presenter { fn new() -> Self { Self { @@ -337,7 +289,6 @@ impl Presenter { draw_scheduled_at: None, } } - fn acknowledge(&mut self, sequence: u64) { if self .in_flight_target @@ -346,7 +297,6 @@ impl Presenter { self.in_flight_target = None; } } - fn try_present( &mut self, queued_before: u64, @@ -365,12 +315,10 @@ impl Presenter { } true } - fn request(&mut self, force_full_repaint: bool) { self.dirty = true; self.force_full_repaint |= force_full_repaint; } - /// Request now when cadence permits; otherwise schedule the earliest draw. fn request_throttled(&mut self, now: Instant, min_draw_interval: Duration) -> bool { if now.duration_since(self.last_draw_at) < min_draw_interval { @@ -382,12 +330,10 @@ impl Presenter { self.request(false); true } - fn mark_drawn(&mut self, now: Instant) { self.last_draw_at = now; self.draw_scheduled_at = None; } - fn present_if_dirty(&mut self, app: &mut AppView, terminal: &mut PagerTerminal) { let sync = terminal.backend_mut().writer_mut().writer_sync().clone(); let queued_before = sync.queued(); @@ -405,7 +351,6 @@ impl Presenter { self.mark_drawn(Instant::now()); } } - fn request_presentation( &mut self, app: &mut AppView, @@ -416,26 +361,21 @@ impl Presenter { self.present_if_dirty(app, terminal); } } - fn writer_event_sequence(event: crate::render::draw::WriterEvent) -> std::io::Result { match event { crate::render::draw::WriterEvent::Written(sequence) => Ok(sequence), crate::render::draw::WriterEvent::Failed(error) => Err(error), } } - const SUSPEND_RETRY_DELAY: Duration = Duration::from_millis(250); - fn suspend_retry_ready(retry_after: Option, now: Instant) -> bool { retry_after.is_none_or(|deadline| now >= deadline) } - #[derive(Debug, Default)] struct SuspendWaitReports { editor_reported: bool, pager_reported: bool, } - impl SuspendWaitReports { fn reset_missing(&mut self, editor_pending: bool, pager_pending: bool) { if !editor_pending { @@ -446,7 +386,6 @@ impl SuspendWaitReports { } } } - /// Arm the deferred retry and return whether this pending handoff needs feedback. fn defer_suspend_retry( retry_after: &mut Option, @@ -459,16 +398,13 @@ fn defer_suspend_retry( *wait_reported = true; should_report } - const EDITOR_SUSPEND_WAIT: &str = "Editor is waiting for a safe terminal handoff"; const TRANSCRIPT_SUSPEND_WAIT: &str = "Transcript is waiting for a safe terminal handoff"; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SuspendWaitSink { Toast, SystemBlock, } - fn suspend_wait_sink(screen_mode: crate::app::ScreenMode) -> SuspendWaitSink { if screen_mode.is_minimal() { SuspendWaitSink::SystemBlock @@ -476,7 +412,6 @@ fn suspend_wait_sink(screen_mode: crate::app::ScreenMode) -> SuspendWaitSink { SuspendWaitSink::Toast } } - /// Report a handoff wait through the sink visible in the current screen mode. /// The caller deduplicates reports across retries per handoff request. fn report_suspend_wait(app: &mut AppView, message: &str) { @@ -498,12 +433,9 @@ fn report_suspend_wait(app: &mut AppView, message: &str) { } } } - fn requeue_after_suspend_timeout(pending: &mut Option, request: T) { - // The child never started, so preserve the one-shot request. *pending = Some(request); } - /// Restore presentation after a child releases the tty. /// /// A cat-style child leaves minimal mode's cursor below appended main-screen @@ -522,7 +454,6 @@ fn restore_after_child( let screen = terminal.last_known_area(); let cur = terminal.viewport_area(); let vh = cur.height.max(1).min(screen.height.max(1)); - // Buffered append stays ordered before the gated repaint. let _ = terminal.backend_mut().append_lines(vh.saturating_sub(1)); let available = screen.height.saturating_sub(y).saturating_sub(1); let top = y.saturating_sub(vh.saturating_sub(1).saturating_sub(available)); @@ -533,7 +464,6 @@ fn restore_after_child( }); } } - /// Consume a pending `$EDITOR` / `$PAGER` suspend request, if any. /// /// Called at the top of every event-loop iteration because any select arm can @@ -558,17 +488,11 @@ fn run_pending_suspends( if !suspend_retry_ready(*suspend_retry_after, Instant::now()) { return Ok(()); } - // The gate is consumed before any blocking park/drain attempt. A timeout - // must arm a fresh deadline before this function returns. if !editor_pending && !pager_pending { *suspend_retry_after = None; return Ok(()); } *suspend_retry_after = None; - - // $EDITOR suspend: leave alt screen, disable raw mode, spawn - // editor, wait for exit, then restore. Preparation materializes prompt - // drafts only immediately before this safe terminal handoff. if let Some(request) = app.pending_editor.take() { let retry_request = request.clone(); match crate::app::external_editor::prepare(app, request) { @@ -609,9 +533,6 @@ fn run_pending_suspends( Err(error) => return Err(error.into()), }; crate::app::external_editor::finish(app, prepared, editor_result); - // The child owned the screen; re-anchor if it printed inline, and - // repaint the full viewport rather than diffing against a screen - // state we can no longer vouch for. restore_after_child(terminal, app.screen_mode, moved_cursor); presenter.request_presentation(app, terminal, true); suspend_wait_reports.editor_reported = false; @@ -627,10 +548,6 @@ fn run_pending_suspends( } } } - - // /transcript suspend: open the rendered transcript in $PAGER, - // then restore and delete the temp file. Shares the editor's - // suspend/restore dance (reader park, raw mode, alt screen). if let Some(path) = app.pending_pager_path.take() { let ansi = std::mem::take(&mut app.pending_pager_ansi); let pager = std::env::var("PAGER") @@ -644,15 +561,9 @@ fn run_pending_suspends( reader_parked, input_rx, || { - // $PAGER may carry flags (e.g. "less -R"); split on - // whitespace so program + args are both honored. let mut parts = pager.split_whitespace(); if let Some(prog) = parts.next() { let mut args: Vec = parts.map(str::to_string).collect(); - // An ANSI transcript (minimal full view) needs - // `less` to interpret raw control codes, else the - // colors show as literal escapes. Add `-R` when - // using less and it isn't already requested. let is_less = std::path::Path::new(prog) .file_name() .and_then(|n| n.to_str()) @@ -668,10 +579,6 @@ fn run_pending_suspends( { args.push("-R".to_string()); } - // Open the transcript at its END: minimal's prompt sits at - // the bottom of the conversation, so the pager starts where - // the user already is (`g` jumps back to the top). less-only - // like `-R` — other $PAGERs may not understand `+G`. if ansi && is_less && !args.iter().any(|a| a == "+G") { args.push("+G".to_string()); } @@ -700,16 +607,12 @@ fn run_pending_suspends( Err(error) => return Err(error.into()), }; let _ = std::fs::remove_file(&path); - // The pager owned the screen; re-anchor if it printed inline (cat) and - // repaint the full viewport rather than diffing against a screen state - // we can no longer vouch for. restore_after_child(terminal, app.screen_mode, moved_cursor); presenter.request_presentation(app, terminal, true); suspend_wait_reports.pager_reported = false; } Ok(()) } - /// Run the main event loop until quit. /// /// Returns a [`RunResult`] with optional exit info (for the resume hint) @@ -733,16 +636,10 @@ pub(crate) async fn run( >, mut writer_event_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> anyhow::Result { - // Initialize tracing capture. The channel `rx` will be wired to a - // TracingModel (and ultimately a tracing pane) once integrated. - // For now we drain-and-discard in `AppView::tick()` to avoid unbounded - // memory growth. if args.log_sampling { - // SAFETY: called before any threads are spawned by init_tracing. unsafe { std::env::set_var("GROK_LOG_SAMPLING", "1") }; } let tracing_handle = crate::tracing::init_tracing(); - crate::unified_log::init(connection.tx.clone()); crate::unified_log::info("pager started", None, None); let mut app = AppView::new( @@ -751,22 +648,10 @@ pub(crate) async fn run( connection.available_commands, ); app.tracing_rx = Some(tracing_handle.rx); - // Startup terminal height for the auto-compact derivation; kept fresh by - // `Event::Resize` from here on. 0 (probe failure) never forces compact. app.last_known_terminal_rows = crossterm::terminal::size().map(|(_, r)| r).unwrap_or(0); - // Leader mode: a live `leader_status_rx` means the pager is connected via a - // leader. The dashboard itself is NOT gated on this flag (it renders local - // sessions regardless); `leader_mode` only controls whether we additionally - // poll the leader roster (see the roster-poll arm below). app.leader_mode = connection.leader_status_rx.is_some(); app.screen_mode = term_state.screen_mode; - // `AppView::new` precedes the terminal's resolved screen mode. Rebuild the - // registry at this I/O boundary; the later config-aware rebuild preserves - // this mode while adding the optional mouse-reporting action. app.registry = crate::actions::ActionRegistry::defaults_for(term_state.screen_mode); - // Agent/dashboard prompts pick the mode up at their creation sites - // (`apply_app_scoped_gates` / `ensure_dashboard_state`); the welcome prompt - // already exists, so inject here. app.welcome_prompt.set_screen_mode(term_state.screen_mode); if app.screen_mode.is_minimal() && term_state.relaunched_into_minimal { app.minimal_state.welcome_pending = true; @@ -785,8 +670,6 @@ pub(crate) async fn run( remote_permission_mode, ); app.default_yolo = launch_yolo.yolo; - // Gated launch-auto (CLI `--permission-mode auto` or config). Hoisted so it can - // be re-applied after `load_initial_ui_config()` replaces `current_ui` below. let launch_auto = xai_grok_shell::util::config::effective_auto_for_launch( args.yolo, args.permission_mode_flag.as_deref(), @@ -795,27 +678,19 @@ pub(crate) async fn run( if launch_auto { app.current_ui.permission_mode = Some("auto".into()); } - // One effective-config read for launch-mode ownership + the display - // resolve below (the launch resolvers above keep their own internal read). let launch_effective_ui = xai_grok_shell::config::load_effective_config() .ok() .and_then(|root| root.get("ui").cloned()); - // Soft-default owns the mode only when neither CLI nor effective TOML - // claimed it; while owned, `settings/update` pushes may re-arm it. let cli_owns_mode = args.yolo || args.permission_mode_flag.is_some(); let toml_owns_mode = launch_effective_ui .as_ref() .and_then(xai_grok_shell::util::config::permission_mode_from_ui_if_set) .is_some(); app.permission_mode_from_soft_default = !cli_owns_mode && !toml_owns_mode; - // Cached pin snapshot gating dispatch's runtime always-approve toggles. A - // mid-session pin change is missed here, but only cosmetically: the agent's - // permission manager re-clamps yolo authoritatively at decision time. app.yolo_policy_block = launch_yolo.policy_block; if let Some(warning) = launch_yolo.blocked_warning { tracing::warn!("{warning}"); crate::unified_log::warn(warning, None, None); - // Consumed by `switch_to_agent` once the first agent view opens. app.yolo_launch_block_notice = Some(warning); } app.require_plan_approval = xai_grok_shell::util::config::load_require_plan_approval(); @@ -867,10 +742,7 @@ pub(crate) async fn run( .as_ref() .and_then(|s| s.show_resolved_model) .unwrap_or(true); - app.sharing_enabled = remote_settings - .as_ref() - .and_then(|s| s.sharing_enabled) - .unwrap_or(false); + app.sharing_enabled = false; app.privacy_notice_rollout = xai_grok_config::env_bool("GROK_PRIVACY_NOTICE_ROLLOUT") .or_else(|| { remote_settings @@ -886,7 +758,6 @@ pub(crate) async fn run( .as_ref() .and_then(|s| s.privacy_banner_reshow_days) }); - // Local dismiss timestamp for the coding-data privacy banner. app.privacy_banner_acked = xai_grok_shell::config::load_from_disk() .ok() .and_then(|root| { @@ -897,7 +768,6 @@ pub(crate) async fn run( app.plugin_cta_enabled = xai_grok_config::env_bool("GROK_PLUGIN_CTA") .or_else(|| remote_settings.as_ref().and_then(|s| s.plugin_cta)) .unwrap_or(false); - // Voice is applied after auth_meta so API-key detection is accurate. app.session_picker_grouped = std::env::var("GROK_SESSION_PICKER_GROUPED") .ok() .and_then(|v| match v.as_str() { @@ -918,19 +788,12 @@ pub(crate) async fn run( .unwrap_or(true); app.cancel_rewind_enabled = connection.cancel_rewind_enabled; apply_session_recap_available(&mut app, connection.session_recap_available); - - // Preserve auth methods so logout→re-login works without restarting. app.auth_methods = connection.auth_methods.clone(); - - // Seed auth state from ACP connection metadata. - // --force-login overrides: show the login screen even when credentials exist. let force_login = args.force_login && !connection.auth_methods.is_empty(); let needs_interactive_login = connection.needs_login || force_login; if needs_interactive_login { app.welcome_prompt_focused = false; - if connection.needs_login { - // Normal path: use the metadata from startup_auth_metadata() app.login_label = connection.login_label; app.login_method_id = connection.login_method_id; app.auth_start_mode = match connection.auth_start_mode { @@ -938,7 +801,6 @@ pub(crate) async fn run( crate::acp::AuthStartMode::Command => super::app_view::AuthMode::Command, }; } else { - // --force-login: find the grok.com method from the advertised list let grok_com = connection .auth_methods .iter() @@ -958,31 +820,20 @@ pub(crate) async fn run( super::app_view::AuthMode::Pending }; } else { - // No grok.com method available, use the first method as fallback let first = &connection.auth_methods[0]; app.login_label = Some(first.name().to_string()); app.login_method_id = Some(first.id().clone()); app.auth_start_mode = super::app_view::AuthMode::Pending; } } - - // Skip the login splash screen — auto-trigger login immediately - // by reusing dispatch_login. Effects are stashed and drained after - // the initial render so the user sees the auth UI right away. - // Empty auth_methods (preferred_method pin with no credentials) is - // fail-closed: do not invent grok.com / auto-start OIDC. tracing::info!( method_id = ?app.login_method_id, methods_empty = connection.auth_methods.is_empty(), "auto-triggering login at startup" ); } - // else: auth_state defaults to Done (already authenticated eagerly) - // Effects stashed until after the initial render, so the user sees the - // welcome/auth UI right away. let mut post_render_effects = if needs_interactive_login { if connection.auth_methods.is_empty() { - // preferred_method pin unavailable — no advertised method to start. app.auth_state = super::app_view::AuthState::Pending { error: Some( xai_grok_shell::agent::auth_method::PREFERRED_API_KEY_UNAVAILABLE.to_string(), @@ -995,25 +846,22 @@ pub(crate) async fn run( } else { vec![] }; - + app.has_external_auth_provider = + crate::slash::commands::usage::detect_external_auth_provider(&app.auth_methods); if let Some(meta) = connection.auth_meta.as_ref() { match serde_json::from_value::(meta.clone()) { Ok(auth_meta) => app.apply_auth_meta(&auth_meta), Err(e) => tracing::warn!("failed to deserialize auth_meta: {e}"), } } else { - // No cached session — check if the API key is the active credential. app.is_api_key_auth = app.auth_methods.iter().any(|m| { m.id().0.as_ref() == xai_grok_shell::agent::auth_method::XAI_API_KEY_METHOD_ID }); - // No AuthMeta on this path — API keys have no consumer billing surface. - if app.is_api_key_auth { + if app.is_api_key_auth || app.has_external_auth_provider { app.usage_visible = false; app.sync_billing_surface_to_agents(); } } - - // After auth so API-key + managed policy resolve correctly. let voice_mode_enabled = crate::app::resolve_voice_mode_live( remote_settings.as_ref().and_then(|s| s.voice_mode_enabled), app.is_api_key_auth, @@ -1023,31 +871,18 @@ pub(crate) async fn run( app.voice_ui_active = false; } app.apply_voice_mode_enabled(voice_mode_enabled); - - // Fallback: prefetch may have gate info the shell's AuthMeta missed. - // Errs on the side of blocking if stale. if app.gate.is_none() && let Some(rs) = remote_settings.as_ref() { app.gate = AppView::gate_from_settings(rs); } - - // Re-impose the startup gate through the chokepoint: cached auth meta - // and the settings prefetch are both possibly stale, so a consumer - // session's gate is deferred for live verification before first paint. if let Some(gate) = app.gate.take() { post_render_effects.extend(app.impose_gate(gate)); } - - // Load persisted per-ID hidden state app.hidden_announcement_ids = xai_grok_announcements::read_hidden_announcement_ids().await; - - // Load config layers once, resolve announcements, tips, and feature flags. let requirements = xai_grok_shell::config::load_merged_requirements(); let user_config = xai_grok_shell::config::load_from_disk().ok(); let managed_config = xai_grok_shell::config::load_managed_config().ok(); - - // Full merge when every layer parses; partial merge below if any layer fails. let effective_config = match xai_grok_shell::config::load_effective_config() { Ok(raw) => Some(raw), Err(e) => { @@ -1065,15 +900,11 @@ pub(crate) async fn run( codex: compat.codex.sessions, cursor: compat.cursor.sessions, }; - - // Load notification config from [ui.notifications] in config.toml. if let Some(ref raw) = effective_config { app.notification_service = crate::notifications::NotificationService::new( crate::notifications::load_notification_config(raw), ); if let Some(table) = raw.as_table() { - // Voice inherits the same resolved endpoints base as chat - // (config > GROK_XAI_API_BASE_URL env > default). let endpoints_base = xai_grok_shell::agent::config::EndpointsConfig::from_config_value(raw) .xai_api_base_url; @@ -1081,26 +912,17 @@ pub(crate) async fn run( xai_grok_voice::VoiceConfig::from_config_table(table, Some(&endpoints_base)); } } - // Stamp request-identity headers so the STT handshake attributes voice usage - // to grok-cli server-side (mirrors sampler / imagine). Done after - // `from_config_table` — which yields a fresh config with these - // `#[serde(skip)]` fields defaulted to empty — and unconditionally, so they - // apply even when there is no `[voice]` table (or no config at all). app.voice_config.client_identifier = crate::client_identity::HEADLESS_CLIENT_TYPE.to_string(); app.voice_config.user_agent = crate::client_identity::client_user_agent(); - app.zdr_access_enabled = xai_grok_shell::util::config::resolve_zdr_access_enabled( requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), remote_settings.as_ref(), ); - app.subscription_watch_interval_secs = remote_settings .as_ref() .and_then(|rs| rs.subscription_watch_interval_secs); - - // Full layered resolve (env/requirements/remote may beat plain `[ui]`). crate::appearance::cache::set_show_thinking_blocks( xai_grok_shell::util::config::resolve_show_thinking_blocks( requirements.as_ref(), @@ -1128,32 +950,22 @@ pub(crate) async fn run( ) .value, ); - - // Pre-arrival seed only. The authoritative per-session value rides the - // `session/new` / `session/load` response, but `/loop` can be reached from - // the session-less dashboard and from a session whose response has not - // landed yet; both need an answer now, and this is the same resolver the - // shell runs at spawn, so the seed agrees with the flag as it stands today. app.scheduler_background_loops_seed = xai_grok_shell::util::config::resolve_scheduler_background_loops( remote_settings .as_ref() .and_then(|s| s.scheduler_background_loops), ); - app.usage_billing_redirect_url = remote_settings .as_ref() .and_then(|s| s.usage_billing_redirect_url.clone()); - if app.is_access_blocked() { app.welcome_prompt_focused = false; } - { use xai_grok_shell::util::config::{ resolve_announcements, resolve_slash_command_tags, resolve_tips, }; - let remote_announcements = remote_settings .as_ref() .and_then(|s| s.announcements.as_deref()); @@ -1170,7 +982,6 @@ pub(crate) async fn run( app.announcement = app.active_announcements.get(idx).cloned(); } app.sync_session_announcement_slash_gate(); - let remote_tips = remote_settings.as_ref().and_then(|s| s.tips.as_deref()); app.tips = resolve_tips( requirements.as_ref(), @@ -1178,14 +989,10 @@ pub(crate) async fn run( managed_config.as_ref(), remote_tips, ); - if !app.tips.is_empty() { let grok_home = xai_grok_tools::util::grok_home::grok_home(); app.tip = xai_grok_shell::util::tips::pick_and_advance(&app.tips, &grok_home); } - - // Slash-command dropdown tags: remote base, local [slash_command_tags] - // wins per key. Mutate the shared map in place so every adopter sees it. let remote_slash_tags = remote_settings .as_ref() .and_then(|s| s.slash_command_tags.as_ref()); @@ -1193,7 +1000,6 @@ pub(crate) async fn run( let tags_config = effective_config.as_ref().unwrap_or(&empty_toml); *app.command_tags.borrow_mut() = resolve_slash_command_tags(tags_config, remote_slash_tags); } - let hints = xai_grok_shell::util::config::resolve_hints( effective_config.as_ref(), requirements.as_ref(), @@ -1201,21 +1007,12 @@ pub(crate) async fn run( managed_config.as_ref(), ); app.project_picker_disabled = hints.project_picker_disabled; - // Per-tip contextual hints resolve from `[ui.contextual_hints]` (loaded into - // `app.current_ui` further below) + the remote tier; the resolve + prompt - // propagation happen after `current_ui` is hydrated. app.remote_contextual_hints = remote_settings .as_ref() .and_then(|s| s.contextual_hints.clone()); app.new_session_worktree_mode = hints.new_session_worktree_mode.into(); app.fork_worktree_mode = hints.fork_worktree_mode.into(); - // Ephemeral-tip seen counts are intentionally NOT hydrated: the cap is - // per-session (in-memory `app.tip_seen_counts`), so each run starts fresh. - - // Cache whether cwd is inside a git repo (avoids repeated stat() in draw). app.cwd_has_git_ancestor = app.cwd.ancestors().any(|p| p.join(".git").exists()); - - // Probe / auto-cadence / terminal telemetry — see `display_refresh_startup`. let motion = super::display_refresh_startup::start( requirements.as_ref(), user_config.as_ref(), @@ -1224,10 +1021,6 @@ pub(crate) async fn run( ); let min_draw_interval = motion.min_draw_interval; let scroll_cadence = motion.scroll_cadence; - - // Collect structured startup warnings from the terminal diagnostics engine. - // These are stored on AppView and rendered as a dismissible in-app banner - // when the user enters an agent session. { let ctx = crate::terminal::terminal_context(); let query = crate::diagnostics::probes::LiveTmuxProbe; @@ -1249,9 +1042,6 @@ pub(crate) async fn run( app.notification_service.protocol(), app.notification_service.config().condition, ); - // Deduplicate by category: general terminal warnings take priority - // over notification-specific ones (e.g. DcsPassthrough can fire from - // both sources when allow-passthrough is off). let mut seen = std::collections::HashSet::new(); for w in &warnings { seen.insert(w.category); @@ -1265,17 +1055,7 @@ pub(crate) async fn run( if !all_warnings.is_empty() { tracing::info!("Collected {} startup warnings", all_warnings.len()); } - // WezTerm without the Kitty keyboard protocol breaks local input - // (Shift+Enter can't insert newlines), so its banner is surfaced - // directly (no SSH gate) and first — see `assemble_startup_warnings`. - // `xtversion::detected()` is structurally `None` here (the probe is - // only sent further down, right before the input reader thread is - // spawned), so this banner covers env-detected WezTerm; the SSH shape - // surfaces in /doctor once the async reply has landed. let wezterm_warning = crate::diagnostics::wezterm_kitty_keyboard_warning(&snapshot); - // Wayland no-data-control is surfaced without the SSH gate of - // `summarize_warnings` — the broken shape is local (see - // `assemble_startup_warnings`). let wayland_clipboard_warning = all_warnings .iter() .find(|w| w.category == crate::diagnostics::WarningCategory::WaylandNoDataControl); @@ -1290,11 +1070,7 @@ pub(crate) async fn run( .collect(), ); } - - // Apply initial config (may come from existing ~/.grok/pager.toml). let mut initial_config = config_watcher.current().clone(); - // The cache holds the USER compact value; the render value is derived - // (auto-compact while the startup terminal is short). initial_config.prompt.compact = crate::views::agent::effective_compact( crate::appearance::cache::load(), app.last_known_terminal_rows, @@ -1304,14 +1080,7 @@ pub(crate) async fn run( let tick_interval = initial_config.animation.tick_interval(); crate::appearance::set_tab_width(initial_config.scrollback.display.tab_width); app.set_appearance(initial_config); - - // Seed app state from disk once at the I/O boundary so dispatch - // stays sans-IO. app.current_ui = load_initial_ui_config(); - // Field-tolerant: a whole-`UiConfig` default (malformed unrelated `[ui]` - // field) must not wipe a valid `show_timeline` or leave appearance / - // cache / `current_ui` disagreeing — `/timeline` and the rail all read - // the same canonical value after this sync + `prime` below. let show_timeline = crate::appearance::cache::load_show_timeline(); app.current_ui.show_timeline = Some(show_timeline); if app.appearance.show_timeline != show_timeline { @@ -1319,21 +1088,13 @@ pub(crate) async fn run( config.show_timeline = show_timeline; app.set_appearance(config); } - // Single-key load so a malformed unrelated `[ui]` field cannot wipe this. let page_flip_on_send = crate::appearance::cache::load_page_flip_on_send(); app.current_ui.page_flip_on_send = Some(page_flip_on_send); - // Disk load replaces `current_ui`. Assign one policy-clamped resolved - // launch mode unconditionally (CLI > TOML > remote > Ask) so disk Auto - // cannot win over `--permission-mode ask`, and a policy-clamped remote - // AlwaysApprove cannot leave the UI claiming AlwaysApprove while - // enforcement is Ask. let display_mode: &'static str = if launch_auto { "auto" } else if launch_yolo.yolo { "always-approve" } else if let Some(cli) = args.permission_mode_flag.as_deref() { - // CLI always-approve/auto that did not become launch_yolo/launch_auto - // (policy pin / gate) display as Ask. xai_grok_shell::util::config::clamped_display_permission_mode( xai_grok_shell::util::config::parse_permission_mode_canonical(cli), ) @@ -1345,38 +1106,20 @@ pub(crate) async fn run( }; app.current_ui.permission_mode = Some(display_mode.to_string()); super::dispatch::downgrade_displayed_auto_if_gated(&mut app); - // Seed `/auto` feature-gate visibility from the resolved gate (so `/auto` - // is offered on the welcome prompt when available). app.sync_permission_mode_slash_gate(); - // Settings UI language (`[ui].voice_stt_language`) overrides `[voice].language` - // when set. Store the preference (including client-only `auto`); the voice - // crate resolves the wire code at STT connect. When unset, keep whatever - // `from_config_table` loaded (default `en`, or an explicit `[voice].language`). - // Must run after `load_initial_ui_config()` hydrates `current_ui` from disk. if let Some(ref pref) = app.current_ui.voice_stt_language { app.voice_config.language = crate::settings::canonical_voice_stt_language(Some(pref)).to_string(); } - // Seed the Voice shortcut gate's process-global mirror for key-routing and - // view code without an `AppView`; the chord intercept reads `current_ui` - // live and the settings setter updates both. crate::app::VOICE_KEYBIND_ENABLED.store( app.current_ui.voice_keybind_enabled.unwrap_or(true), std::sync::atomic::Ordering::Release, ); - // Resolve the per-tip contextual hints now that `current_ui` is hydrated and - // propagate the prompt-relevant tips to any agents built at startup. New - // agents adopt the gates at creation; settings toggles re-apply at runtime. let resolved_hints = xai_grok_shell::util::config::resolve_contextual_hints( &app.current_ui.contextual_hints, app.remote_contextual_hints.as_ref(), ); app.apply_contextual_hints(resolved_hints); - - // Opt-in mouse-reporting toggle shortcut (Ctrl+R on scrollback). Off unless - // explicitly enabled. Resolved in shell config (env override > effective - // config > the parsed `UiConfig` field) so a partial `UiConfig` deserialize - // failure cannot silently drop it. let mouse_toggle = xai_grok_shell::util::config::resolve_mouse_reporting_toggle( effective_config.as_ref(), &app.current_ui, @@ -1385,8 +1128,6 @@ pub(crate) async fn run( term_state.screen_mode, mouse_toggle.value, ); - // Cache the resolved flag so the `/toggle-mouse-reporting` slash command can - // gate its visibility/execution without re-reading config on every keystroke. crate::app::MOUSE_REPORTING_TOGGLE_ENABLED .store(mouse_toggle.value, std::sync::atomic::Ordering::Release); let action_registered = app @@ -1411,71 +1152,29 @@ pub(crate) async fn run( app.show_tips = config_session_bools.show_tips; app.auto_update = config_session_bools.auto_update; app.ask_user_question_timeout_enabled = config_session_bools.ask_user_question_timeout_enabled; - // Prime thread-local caches so first render doesn't hit disk. crate::appearance::cache::prime(&app.current_ui); - // Re-derive the render-value compact flag from the hydrated `current_ui`: - // the seed above used the pre-hydration disk read, which layered/remote - // config can contradict — the canonical single-writer corrects it (and - // fans out to any startup agents) before the first draw. app.apply_effective_compact(); - - // Apply the scroll settings from the caches (seeded by `prime` above; - // GROK_SCROLL_SPEED/_MODE/_LINES + GROK_INVERT_SCROLL env overrides - // apply on first load). app.scroll_config = crate::input::mouse::ScrollConfig::from_settings(); - - // Fire-and-forget XTVERSION query; must sit immediately before the input - // reader thread is spawned so no earlier stdin consumer eats the reply. - // DA2 shares that constraint but runs earlier, in `init_terminal`, so its - // version is already resolved when the startup telemetry above is emitted. crate::terminal::xtversion::probe_at_startup(); - - // Read terminal events on a dedicated thread and forward them over an mpsc - // channel. The main `select!` consumes via `input_rx.recv()`, which is - // cancellation-safe: when another arm wins, the recv future is dropped and - // re-created without losing the wakeup. Polling crossterm's `EventStream` - // directly in the select is NOT safe -- dropping its `next()` future - // mid-poll (a losing arm) strands its background waker (crossterm #936), so - // input on an idle screen was not serviced until an unrelated arm happened - // to re-poll (every ~20s via recap_poll). The always-on tracing_rx tick - // used to mask this by re-polling ~30Hz; this removes that dependency. let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); - // Set true around tty handoffs (e.g. $EDITOR) so the reader stops touching - // stdin and the inheriting child process keeps every keystroke. The handoff - // does not proceed until `reader_parked` acknowledges this pause. let input_paused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reader_paused = input_paused.clone(); - // Set by the reader once it has parked (stopped calling crossterm) so the - // $EDITOR handoff can wait for it: poll/read share one global lock, so the - // main-thread drain must be the sole crossterm caller. let reader_parked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reader_parked_thread = reader_parked.clone(); std::thread::spawn(move || { use std::sync::atomic::Ordering; - // Short enough that a pause / receiver-drop is observed promptly, long - // enough to keep the thread parked when idle. A `poll()` timeout here - // does NOT wake the main loop -- only a successful `send` does -- so the - // idle event loop still parks (no reintroduced metronome tick). const POLL_TIMEOUT: Duration = Duration::from_millis(100); let mut consecutive_event_errors: u32 = 0; loop { - // Shutdown observed within one poll cycle in every state (idle or - // paused); the send() break below covers close-while-sending. if input_tx.is_closed() { break; } - // While a tty handoff owns stdin, do not read(): the child (e.g. the - // editor) must keep its bytes. Re-check soon without touching stdin. if reader_paused.load(Ordering::Acquire) { - // Signal the handoff that the reader is no longer in crossterm. reader_parked_thread.store(true, Ordering::Release); std::thread::sleep(POLL_TIMEOUT); continue; } - // Active path: this thread owns crossterm again this iteration. reader_parked_thread.store(false, Ordering::Release); - // poll()+read() (not a bare blocking read) so the pause flag and a - // dropped receiver are observed within POLL_TIMEOUT. let event = match crossterm::event::poll(POLL_TIMEOUT) { Ok(true) => crossterm::event::read(), Ok(false) => continue, @@ -1486,13 +1185,10 @@ pub(crate) async fn run( consecutive_event_errors = 0; let timed = TimedInputEvent::now(ev); if input_tx.send(timed).is_err() { - break; // event loop has shut down + break; } } Err(e) => { - // VTE terminals / SSH PTYs can emit garbage that crossterm's - // parser rejects; skip transient errors rather than kill the - // TUI (ratatui#1275), bailing only if they never stop. consecutive_event_errors += 1; if consecutive_event_errors >= 50 { tracing::error!( @@ -1512,85 +1208,41 @@ pub(crate) async fn run( let mut tasks: JoinSet = JoinSet::new(); let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel::(); - - // Voice STT pipeline is started lazily on first successful `/voice` (see - // `VoiceState::ColdStart`), not at launch — avoids background work for users - // who never enable voice mode. `AUDIO_SUPPORTED` reflects whether mic - // capture is compiled in: true for production CLI builds on macOS/Windows - // (cpal) and Linux (subprocess recorder), false for Bazel builds (no - // capture in the test sandbox). let mut voice_rx = None::>; let voice_auth_factory = connection.auth_manager.clone(); - - // Animation tick: only scheduled when there are running entries. let mut tick_interval = tick_interval; let mut animation_tick_at: Option = None; - - // Whether the extra Kitty keyboard layer (WASD release events) is - // currently pushed for the /gboom game. Synced to `gboom_active` each - // iteration so it is popped on every close path. let mut gboom_keyboard_pushed = false; - const BILLING_POLL_INTERVAL: Duration = Duration::from_secs(30); let mut billing_poll_at: Option = None; - const GATE_POLL_INTERVAL: Duration = Duration::from_secs(30); let mut gate_poll_at: Option = None; - - // Free→paid subscription watch (see `app::subscription`). let mut subscription_watch_at: Option = if app.subscription_watch_wanted() { app.subscription_watch_interval() .map(|iv| Instant::now() + iv) } else { None }; - - // Leader-mode roster poll (FleetView dashboard). Only fires while the - // dashboard is open AND we're connected via a leader. Armed to fire - // immediately at loop start so an already-open dashboard refreshes - // without waiting a full interval. const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(1); let mut roster_poll_at: Option = Some(Instant::now()); - - // Pre-generate the automatic "return-from-away" recap while the terminal is - // unfocused, so it's already in the scrollback (instant) when the user - // returns. The arm is a cheap no-op while focused / not-yet-eligible; the - // heavy lifting (the model call) only fires once per away period via - // `should_pregenerate_away_recap`. const RECAP_POLL_INTERVAL: Duration = Duration::from_secs(20); let mut recap_poll_at: Option = Some(Instant::now() + RECAP_POLL_INTERVAL); - - // Seed the folder-trust verdict BEFORE the first render and before any - // session is created (no repo-local MCP/LSP/hooks/plugins have loaded yet). - // Feature-off (kill-switch / opt-out / local build) resolves `Trusted`, so - // this stays `TrustState::Done`. seed_trust_state(&mut app, remote_settings.as_ref()); - let mut presenter = Presenter::new(); - // A timed-out handoff stays queued but cannot synchronously retry until - // this deadline fires. Feedback is one-shot per editor/pager request, even - // across multiple deferred attempts. let mut suspend_retry_after: Option = None; let mut suspend_wait_reports = SuspendWaitReports::default(); - - // Initial render presenter.request_presentation(&mut app, terminal, false); - - // status only; shell auto-syncs post-auth if matches!(app.auth_state, AuthState::Done) { let effs = dispatch::dispatch(Action::RequestBundleStatus, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - // Fetch billing early so the welcome screen can show a credit warning. if app.usage_visible { let effs = vec![super::actions::Effect::FetchAppBilling]; if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } } - // Fetch changelog off the render path so the welcome screen - // can display bullets and /release-notes uses the cached result. let effs = vec![super::actions::Effect::FetchChangelog]; if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); @@ -1599,16 +1251,11 @@ pub(crate) async fn run( gate_poll_at = Some(Instant::now() + GATE_POLL_INTERVAL); } } - if !post_render_effects.is_empty() && process_effects(post_render_effects, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - - // Session startup from pre-materialized CLI intent. - // These actions are dispatched UNCONDITIONALLY: the session-creating - // chokepoints self-gate when auth + folder trust is closed. use crate::app::session_startup::MaterializedStartup; let startup_action = match &materialized { MaterializedStartup::Resume { @@ -1621,8 +1268,6 @@ pub(crate) async fn run( restore_code = ?app.restore_code, "RESTORE_CODE_DEBUG: worktree+resume path taken" ); - // Materialization-time provenance for the worktree failure hint; - // the effect matches it against the exact deferred target. app.resume_local_miss = deferred_local_miss.then(|| session_id.clone()); Some(Action::NewWorktreeSession { load_session_id: Some(session_id.clone()), @@ -1630,21 +1275,12 @@ pub(crate) async fn run( git_ref: args.worktree_ref.clone(), }) } - MaterializedStartup::Resume { session_id, .. } => { - // CLI resume has no roster entry: `chat_kind` on LoadSession is the - // conversation-entry bit only (false here). Process-wide `--chat` - // still stamps kind=chat via SessionFlags.chat_mode in the load - // effect; local Build disk rows are refused in dispatch / startup. - Some(Action::LoadSession( - session_id.clone(), - session_cwd.clone(), - false, - )) - } + MaterializedStartup::Resume { session_id, .. } => Some(Action::LoadSession( + session_id.clone(), + session_cwd.clone(), + false, + )), MaterializedStartup::NewWithId { session_id } if args.worktree.is_some() => { - // Stash preferred id; `dispatch_new_worktree_session` consumes it and - // passes through `CreateWorktreeSession.preferred_session_id` so the - // worktree + ACP session use the CLI-chosen id (not an auto `pager-*`). app.deferred_startup.preferred_session_id = Some(session_id.clone()); Some(Action::NewWorktreeSession { load_session_id: None, @@ -1674,7 +1310,6 @@ pub(crate) async fn run( } MaterializedStartup::NewAuto => None, }; - if let Some(action) = startup_action { let effs = dispatch::dispatch(action, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { @@ -1682,7 +1317,6 @@ pub(crate) async fn run( } presenter.request_presentation(&mut app, terminal, false); } else if args.worktree.is_some() { - // --worktree only: create worktree + new session. let effs = dispatch::dispatch( Action::NewWorktreeSession { load_session_id: None, @@ -1696,13 +1330,6 @@ pub(crate) async fn run( } presenter.request_presentation(&mut app, terminal, false); } - - // Initial prompt from the CLI positional (`grok "fix the bug"`). When - // already authenticated, hand it to the shared dispatcher helper (same - // `NewSession`/`SendPrompt` path the welcome screen uses). ZDR-blocked - // accounts cannot start a session, so drop the prompt — this mirrors the - // deferred post-login path, which clears the startup prompt for ZDR-blocked - // accounts. When not yet authenticated, stash it for `AuthComplete`. if let Some(initial_prompt) = args.initial_prompt() { if !app.session_startup_allowed() { app.deferred_startup.prompt = Some(initial_prompt.to_string()); @@ -1714,12 +1341,7 @@ pub(crate) async fn run( presenter.request_presentation(&mut app, terminal, false); } } - - // `grok dashboard` startup: open the dashboard view immediately. The - // CLI subcommand wrote a `GROK_OPEN_DASHBOARD_AT_STARTUP=1` env var - // so we don't have to thread a flag through every arg struct. if std::env::var("GROK_OPEN_DASHBOARD_AT_STARTUP").as_deref() == Ok("1") { - // SAFETY: we are pre-multithreaded init for this app loop. unsafe { std::env::remove_var("GROK_OPEN_DASHBOARD_AT_STARTUP") }; if app.session_startup_allowed() { let effs = dispatch::dispatch(Action::OpenDashboard, &mut app); @@ -1728,111 +1350,45 @@ pub(crate) async fn run( } presenter.request_presentation(&mut app, terminal, false); } else { - // Not signed in yet — the env var is already consumed, so - // without a stash the request would be silently dropped and - // the post-login flow would land on the welcome screen. - // Defer to the `AuthComplete` handler (mirrors - // the deferred session/prompt owner). app.deferred_startup.open_dashboard = true; } } - - // Minimal (scrollback-native) mode has no welcome screen: the live region - // only renders for an Agent view. If nothing above already started a - // session (no resume / initial prompt / worktree / dashboard), open an - // empty one so the user lands directly at the prompt. Unauthenticated / - // ZDR-blocked startup stays on Welcome, where `crate::minimal::live` shows - // a sign-in hint instead of a blank region. if term_state.screen_mode.is_minimal() && matches!(app.active_view, ActiveView::Welcome) && !app.is_zdr_blocked() { if app.session_startup_allowed() { - // Already authenticated + trusted: open the empty session now so the - // user lands directly at the prompt. let effs = dispatch::dispatch(Action::NewSession, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } presenter.request_presentation(&mut app, terminal, false); } else { - // Sign-in (or folder-trust) still pending: minimal renders the - // device / external sign-in flow in its live region. Defer the - // empty-session creation so the post-auth (or post-trust) drain - // (`drain_startup_actions`) opens it — otherwise minimal would - // authenticate but never create a session, stranding the user on the - // sign-in screen. app.deferred_startup.new_session = true; } } - - // Startup intents are now fully classified; only an untouched welcome can nudge. if let Some(effect) = app.begin_foreign_resume_detection() && process_effects(vec![effect], &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - - // Schedule the first animation tick so live updates start immediately - // (without waiting for user input). schedule_tick(&mut animation_tick_at, &app, tick_interval); - - // Resize debounce: during continuous terminal drags, dozens of resize - // events fire per second. Each would trigger a full layout rebuild of all - // entries (the most expensive per-frame operation). Instead of drawing on - // every resize, we schedule a single deferred draw after the size stabilizes. const RESIZE_DEBOUNCE: Duration = Duration::from_millis(16); let mut resize_debounce_at: Option = None; - - // Cadences resolved once above (env > auto > 16ms). AppView/Default stays hermetic. app.scroll_state.set_redraw_cadence(scroll_cadence); - // ACP batch bound: large enough to keep the hundreds-buffered streaming - // case batched (draws stay cadence-throttled regardless), small enough that - // loop-top work (suspends, deadline re-derivation) never waits on an - // unbounded drain during a token firehose. const ACP_DRAIN_BATCH_MAX: usize = 32; - let mut reconnect_reinit: Option = None; let mut reconnect_abort_handle: Option = None; - // Highest `Connected` generation already handled. Starts at 0 — the - // initial pre-reconnect watch value — so startup never triggers a reload; - // any greater generation is a reconnect, even when the intermediate - // `Reconnecting` state was coalesced away by the watch channel. let mut last_leader_generation: u64 = 0; - - // Persistent CSI fragment filter — carries parsing state across - // drain_and_process calls so a mouse report split across batches is still - // caught; a focus report is only swallowed when its `\e` and `[I`/`[O` - // land in the same batch. let mut csi_filter = super::csi_filter::CsiFragmentFilter::new(); - - // Swallows the fire-and-forget XTVERSION reply whenever it arrives; - // armed only when the startup query is still unanswered. let mut xt_filter = super::xt_filter::XtversionFilter::new(); - - // Background update check: resolves when the spawned update task - // determines whether a newer version is available. let mut bg_update_rx = bg_update_rx; - - // `app::run` publishes the resolved theme into `theme_cache::CURRENT` - // before `init_terminal` so `apply_cursor_color()` sees it. Pin the - // invariant so a future refactor that drops the `theme_cache::set` call - // fails loudly in debug builds rather than silently regressing the - // initial cursor color. debug_assert_eq!(term_state.initial_theme, theme_cache::current_kind()); let mut appearance_watcher = SystemAppearanceWatcher::start_if_auto(theme_cache::is_auto_mode()); - - // Registered so the signal handler can request a graceful quit; see signal_handler. let quit_notify = std::sync::Arc::new(tokio::sync::Notify::new()); crate::app::signal_handler::set_quit_notify(quit_notify.clone()); - loop { - // Pending $EDITOR / $PAGER suspends first: they can be armed by ANY - // arm of the select below (input, ticks — e.g. minimal's incremental - // /transcript build finishing inside a tick draw — tasks, ACP), so - // consuming them here keeps the handoff immediate instead of waiting - // for the next unrelated event. run_pending_suspends( &mut app, terminal, @@ -1843,10 +1399,6 @@ pub(crate) async fn run( &mut suspend_retry_after, &mut suspend_wait_reports, )?; - - // Lazy voice pipeline: only after `/voice` or Ctrl+Space while gates - // allow. Consume the queued cold-start, carrying its hold-ownership and - // bound target forward into the live recording it spawns. if let VoiceState::ColdStart { hold, target } = app.voice_state { if app.voice_cmd_tx.is_none() && app.voice_can_start_pipeline() { let voice_auth = crate::voice::build_voice_auth(voice_auth_factory.clone()); @@ -1863,13 +1415,6 @@ pub(crate) async fn run( app.voice_cmd_tx = Some(cmd_tx); voice_rx = Some(event_rx); tracing::info!("voice pipeline started (/voice or Ctrl+Space)"); - // The spawn is async, so begin capture now the pipeline is live - // — but only if the user is still on a surface that can receive - // dictation (an agent prompt or the dashboard dispatch input). - // This runs at loop-top before any new input, so the surface - // normally can't have changed since the keypress; the else-arm - // is defensive cleanup so voice mode can't stay armed without - // capture ever starting. if matches!( app.active_view, ActiveView::Agent(_) | ActiveView::AgentDashboard @@ -1884,100 +1429,56 @@ pub(crate) async fn run( app.voice_ui_active = false; app.show_toast("Voice could not start. Restart Grok."); } else { - // Defensive: a queued start with the pipeline already up (which - // shouldn't occur) — drop it so we don't re-enter every tick. app.voice_state = VoiceState::Idle; } - // The lazy spawn runs at loop-top, after the key/slash arm already - // drew (with capture still off). Render now so the recording banner - // appears immediately instead of waiting for the next input or - // network event to wake the select! loop. presenter.request_presentation(&mut app, terminal, false); } - - // Stop voice if the user has left the recording session (see method). app.enforce_voice_session_bound(); - - // Keep the /gboom keyboard layer in sync with whether the game is - // open, so WASD emit releases while it runs and the layer is popped - // on every close path (Esc, game-over dismiss, session switch). let want_gboom_keyboard = app.gboom_active(); if want_gboom_keyboard { if !gboom_keyboard_pushed { super::push_gboom_keyboard_flags(); gboom_keyboard_pushed = true; } - // Only the active game receives release events; any other open - // game must drop its latched holds, or it resumes walking with - // no key down when reopened after a tab/view switch. app.gboom_release_backgrounded_games(); } else if gboom_keyboard_pushed { super::pop_gboom_keyboard_flags(); gboom_keyboard_pushed = false; - // No game is the active input target now (switched to a non-game - // view); clear every game's holds for the same reason. app.gboom_release_all_games(); } - - // Re-arm the dashboard roster poll when the dashboard is open but the - // poll has gone dormant — i.e. the dashboard was just opened. The poll - // arm leaves `roster_poll_at = None` only when it fired with the - // dashboard closed, so this fires an immediate refresh exactly on the - // closed→open transition rather than every iteration. Applies in both - // modes: leader mode polls the live roster, non-leader mode polls the - // local on-disk idle-session list. if roster_poll_at.is_none() && matches!(app.active_view, ActiveView::AgentDashboard) { roster_poll_at = Some(Instant::now()); } - - // (Re-)arm the subscription watch on the dormant→wanted transition - // and after each fired tick. if subscription_watch_at.is_none() && app.subscription_watch_wanted() && let Some(iv) = app.subscription_watch_interval() { subscription_watch_at = Some(Instant::now() + iv); } - - // Future that sleeps until the next animation tick, or waits forever if none. let animation_tick = async { match animation_tick_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - - // Dedicated scroll clock, derived fresh each iteration — a pure - // function of scroll state, so no arm can forget to reschedule it. - // Armed only while a wheel/trackpad stream is active, at the state - // machine's own deadline (16ms cadence flushes while lines are - // pending, the 80ms stream-gap finalize otherwise): scroll pacing - // must never ride the slower animation fps, which turned residual - // flushes into visible jumps. let scroll_tick_at = { let now = Instant::now(); app.scroll_state .scroll_clock_deadline(now.into_std()) .map(|delay| now + delay) }; - - // Future that sleeps until the scroll deadline, or waits forever. let scroll_tick = async { match scroll_tick_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - - // Future that sleeps until the resize debounce fires, or waits forever. let resize_debounce = async { match resize_debounce_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - - // Future that sleeps until a throttled draw fires, or waits forever. let deferred_draw_at = presenter.draw_scheduled_at; let deferred_draw = async move { match deferred_draw_at { @@ -1985,8 +1486,6 @@ pub(crate) async fn run( None => std::future::pending().await, } }; - - // Wake a deferred suspend retry without requiring unrelated input. let suspend_retry_at = if app.pending_editor.is_some() || app.pending_pager_path.is_some() { suspend_retry_after } else { @@ -1998,42 +1497,36 @@ pub(crate) async fn run( None => std::future::pending().await, } }; - let billing_poll = async { match billing_poll_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - let gate_poll = async { match gate_poll_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - let subscription_watch = async { match subscription_watch_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - let roster_poll = async { match roster_poll_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - let recap_poll = async { match recap_poll_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; - tokio::select! { biased; @@ -2791,15 +2284,11 @@ pub(crate) async fn run( } } } - presenter.present_if_dirty(&mut app, terminal); } - app.notification_service.shutdown(); - Ok(make_run_result(&app)) } - /// Load `UiConfig` from the shell's layered config at startup. /// Falls back to `UiConfig::default()` on any failure. pub(crate) fn load_initial_ui_config() -> xai_grok_shell::agent::config::UiConfig { @@ -2812,7 +2301,6 @@ pub(crate) fn load_initial_ui_config() -> xai_grok_shell::agent::config::UiConfi }; ui_value.try_into::().unwrap_or_default() } - /// Config `Option` mirrors seeded once at startup. `None` = no /// TOML override; the modal falls back to the per-setting default. #[derive(Default)] @@ -2821,7 +2309,6 @@ struct InitialConfigSessionBools { auto_update: Option, ask_user_question_timeout_enabled: Option, } - fn load_initial_config_session_bools() -> InitialConfigSessionBools { let Ok(root) = xai_grok_shell::config::load_effective_config() else { return InitialConfigSessionBools::default(); @@ -2837,7 +2324,6 @@ fn load_initial_config_session_bools() -> InitialConfigSessionBools { .and_then(|v| v.as_bool()), } } - /// Whether to pre-generate the automatic "return-from-away" recap right now. /// /// True only when the terminal has been unfocused past the recap threshold @@ -2860,7 +2346,6 @@ fn apply_session_recap_available(app: &mut AppView, available: bool) { dashboard.set_recap_visible(available); } } - fn should_pregenerate_away_recap(app: &AppView) -> bool { if !(app.session_recap_available && app.notification_service.focus_tracker.recap_due() @@ -2879,20 +2364,15 @@ fn should_pregenerate_away_recap(app: &AppView) -> bool { && !agent.session.has_running_bg_tasks() }) } - /// Schedule the next animation tick when demanded and none is pending. fn schedule_tick(tick_at: &mut Option, app: &AppView, interval: Duration) { if tick_at.is_none() { let interval = match app.tick_demand() { crate::app::app_view::TickDemand::None => return, - // A view can request a faster cadence than the configured - // animation fps (e.g. the /gboom easter egg targets ~30 fps). crate::app::app_view::TickDemand::Fast => match app.tick_interval_ceiling() { Some(ceiling) => interval.min(ceiling), None => interval, }, - // Only low-frequency work (welcome shimmer, Cmd link poll): - // don't spin the full 30fps loop for it. crate::app::app_view::TickDemand::Slow => { interval.max(crate::app::app_view::SLOW_TICK_INTERVAL) } @@ -2900,7 +2380,6 @@ fn schedule_tick(tick_at: &mut Option, app: &AppView, interval: Duratio *tick_at = Some(Instant::now() + interval); } } - /// Sync `appearance_watcher` with the current `AUTO_MODE` flag. /// Starts or stops the watcher as needed; no-op when consistent. fn sync_appearance_watcher(watcher: &mut Option) { @@ -2909,7 +2388,6 @@ fn sync_appearance_watcher(watcher: &mut Option) { *watcher = SystemAppearanceWatcher::start_if_auto(should_auto); } } - /// Build [`ExitInfo`] from the active agent's session (if any). /// /// Sole construction site of [`super::ExitSummary`]: fullscreen quits only @@ -2948,7 +2426,6 @@ fn make_run_result(app: &AppView) -> RunResult { relaunch: app.relaunch.clone(), } } - /// Result of draining and processing terminal events. struct DrainResult { /// Whether any event produced a visual change requiring a draw. @@ -2963,17 +2440,14 @@ struct DrainResult { /// refocus in editor/multiplexer contexts to heal out-of-band stranded rows. force_repaint: bool, } - struct RoutedInputEvent { event: Event, arrived_at: std::time::Instant, paste_provenance: PasteProvenance, } - fn tty_suspend_armed(app: &AppView) -> bool { app.pending_editor.is_some() || app.pending_pager_path.is_some() } - fn normalize_input_event(timed: TimedInputEvent) -> RoutedInputEvent { let TimedInputEvent { event, arrived_at } = timed; #[cfg(target_os = "linux")] @@ -3002,7 +2476,6 @@ fn normalize_input_event(timed: TimedInputEvent) -> RoutedInputEvent { paste_provenance: PasteProvenance::Terminal, } } - /// Process a terminal event, then drain any buffered events before returning. /// /// Crossterm buffers input events while the app is drawing. Without draining, @@ -3027,34 +2500,19 @@ async fn drain_and_process( let mut had_resize = false; let mut had_non_resize_change = false; let mut force_repaint = false; - - // Collect all immediately-available events for paste coalescing. let mut raw_events = vec![first]; drain_immediate(&mut raw_events, input_rx); - - // XTVERSION reply removal must precede paste coalescing so reply chars - // are never folded into a synthetic Paste. if xt_filter.armed() { raw_events = super::xt_filter::filter_with_fragment_wait(xt_filter, raw_events, input_rx).await; } - - // On terminals without bracketed paste, try to capture more events - // that may still be in transit from the input reader thread. if should_extend_for_paste(&raw_events) && detect_paste(&mut raw_events, input_rx).await { collect_remaining_paste(&mut raw_events, input_rx).await; - // The paste extension pulled more events off the channel without - // running them through the still-armed filter — a late or split - // XTVERSION reply could otherwise be folded into the paste. if xt_filter.armed() { raw_events = super::xt_filter::filter_with_fragment_wait(xt_filter, raw_events, input_rx).await; } } - - // The /gboom game tracks keys by press → release, so it needs the - // release events that `coalesce_rapid_keys` strips (and it never - // pastes). Skip coalescing while it owns input. let coalesced = if app.gboom_active() { raw_events } else { @@ -3065,45 +2523,28 @@ async fn drain_and_process( .into_iter() .map(normalize_input_event) .collect::>(); - let suspend_armed_after_event = std::cell::Cell::new(false); let mut handle_one = |routed: &RoutedInputEvent| -> bool { let ev = &routed.event; match ev { Event::FocusGained => { - // Force a full repaint on refocus to heal out-of-band stranded rows. - // Sets needs_draw (not had_non_resize_change); the draw site honors force_repaint - // ahead of the resize debounce, clearing even a coalesced same-size resize. if crate::terminal::terminal_context().repaints_pane_out_of_band() { force_repaint = true; needs_draw = true; } - // Capture recap eligibility BEFORE on_focus_gained() clears the - // away timer. Auto recap requires the shell rollout flag plus - // the notifications opt-in; manual `/recap` only needs the flag. let recap_due = app.session_recap_available && app.notification_service.focus_tracker.recap_due() && app.notification_service.config().session_recap; app.notification_service.focus_tracker.on_focus_gained(); - // Pre-warm AppKit's lazy dlopen off the UI thread (once) so the - // first changeCount poll after returning is just the cheap - // metadata read and never stalls a frame on the framework load. - // FocusGained is itself an active loop iteration, so the - // opportunistic poll (driven after drain_and_process) does the - // actual clipboard check — no debounce, no timer, and - // `needs_animation` is never kept hot for it. if app.contextual_hints.image_input && crate::clipboard::clipboard_image_probe_supported() { crate::clipboard::prewarm_image_probe(); } - // The user may have just subscribed in the browser and - // tabbed back. let effs = app.fire_subscription_check("focus"); if process_effects(effs, tasks, app, progress_tx) { return true; } - // Restore Prompt on refocus: needs-input overlay always, else idle non-vim. match app.active_view { ActiveView::Agent(id) => { if let Some(agent) = app.agents.get_mut(&id) @@ -3113,12 +2554,6 @@ async fn drain_and_process( needs_draw = true; had_non_resize_change = true; } - - // Automatic "where was I" recap: the user just returned - // after being away long enough. Only when the session is - // idle and not blocked by a modal or pending question. - // Compute eligibility into a bool first so the immutable - // agent borrow is dropped before dispatch (&mut app). let eligible = app.agents.get(&id).is_some_and(|agent| { agent.session.state.is_idle() && agent.active_modal.is_none() @@ -3146,17 +2581,12 @@ async fn drain_and_process( had_non_resize_change = true; } } - // The dashboard manages its own input/overview focus - // (`list_focused`); refocusing the terminal must not - // override the user's choice (e.g. vim overview focus). ActiveView::AgentDashboard => {} } return false; } Event::FocusLost => { app.notification_service.focus_tracker.on_focus_lost(); - // The /gboom game latches held keys until their release; a - // release can be lost while unfocused, so stop all movement. if app.gboom_active() { app.gboom_release_all_games(); needs_draw = true; @@ -3165,15 +2595,6 @@ async fn drain_and_process( } _ => {} } - // Voice capture chord (Ctrl+Space or F8), handled here before normal - // routing so the release reaches us and the key never lands as text. - // Hold-to-talk where releases are reported (press records, release - // stops), else tap toggle. A release is only ours when a hold session - // owns it, so a bare Space release (Ctrl lifted first) stops - // hold-to-talk without eating every Space release during normal typing. - // `[ui].voice_keybind_enabled` (read live, like `voice_capture_mode`) - // silences chord presses without touching `/voice` — see - // `voice_chord_claims_event` for the exact press/release/hold gating. if let Event::Key(ke) = ev && app.voice_mode_enabled && xai_grok_voice::AUDIO_SUPPORTED @@ -3184,8 +2605,6 @@ async fn drain_and_process( app.voice_hold_owned(), ) { - // Hold-to-talk only when selected AND the terminal reports key - // releases (Kitty protocol); otherwise fall back to a tap toggle. let hold_mode = crate::settings::canonical_voice_capture_mode( app.current_ui.voice_capture_mode.as_deref(), ) == "hold"; @@ -3221,9 +2640,6 @@ async fn drain_and_process( had_non_resize_change = true; } InputOutcome::ActionThenForward(action) => { - // Dispatch the action (e.g. create session), then re-process - // the same event through the now-active view so the input - // (character, paste) lands in the session's prompt. let effs = dispatch::dispatch(action, app); if process_effects(effs, tasks, app, progress_tx) { return true; @@ -3242,8 +2658,6 @@ async fn drain_and_process( had_non_resize_change = true; } InputOutcome::ActionPair(first, second) => { - // Dispatch both in order; first must fully resolve - // before second (e.g. revert preview then open reset). let effs = dispatch::dispatch(first, app); if process_effects(effs, tasks, app, progress_tx) { return true; @@ -3263,7 +2677,6 @@ async fn drain_and_process( had_non_resize_change = true; } } - // AppView converts ArmPending → Changed; defensive if one slips through. InputOutcome::ArmPending { .. } => { needs_draw = true; had_non_resize_change = true; @@ -3273,7 +2686,6 @@ async fn drain_and_process( suspend_armed_after_event.set(tty_suspend_armed(app)); false }; - for routed in &coalesced { if handle_one(routed) { return DrainResult { @@ -3283,12 +2695,10 @@ async fn drain_and_process( force_repaint: false, }; } - // Hand off to the TTY-taking child before later buffered events mutate UI state. if suspend_armed_after_event.get() { break; } } - DrainResult { needs_draw, should_quit: false, @@ -3296,26 +2706,19 @@ async fn drain_and_process( force_repaint, } } - -// ── Paste coalescing for terminals without bracketed paste ─────────── - /// Timeout for the first extension round (detection). If no event /// arrives within this window the batch was a normal keystroke. const PASTE_DETECT_TIMEOUT: Duration = Duration::from_millis(2); - /// Timeout for subsequent rounds once paste has been detected. const PASTE_CONTINUE_TIMEOUT: Duration = Duration::from_millis(10); - /// Safety cap on events accumulated in one extension pass. const PASTE_EXTEND_MAX_EVENTS: usize = 5_000; - /// Returns `true` when the batch contains pasteable key events but no /// `Event::Paste` (i.e. bracketed paste is not handling it). fn should_extend_for_paste(events: &[TimedInputEvent]) -> bool { !events.iter().any(|e| matches!(e.event, Event::Paste(_))) && events.iter().any(|e| is_pasteable_key_event(&e.event)) } - /// Wait [`PASTE_DETECT_TIMEOUT`] for a follow-up event. Returns `true` /// if a **pasteable key event** arrives within the window. Non-key events /// (mouse, focus, releases) are collected but do not count as paste evidence. @@ -3335,7 +2738,6 @@ async fn detect_paste( _ => false, } } - /// Collect remaining paste events using [`PASTE_CONTINUE_TIMEOUT`]. /// Only pasteable key events extend the timeout; non-key events are /// collected but do not keep the loop alive. @@ -3365,7 +2767,6 @@ async fn collect_remaining_paste( } } } - /// Non-blocking drain of all immediately available events. pub(super) fn drain_immediate( batch: &mut Vec, @@ -3375,16 +2776,13 @@ pub(super) fn drain_immediate( batch.push(ev); } } - /// Minimum key events in a run to trigger paste coalescing. const PASTE_COALESCE_THRESHOLD: usize = 3; - /// Minimum run length for the Windows path-shape coalesce branch. /// Covers the shortest realistic dropped image path (`C:\x.png`, /// `/a.png`) while leaving short typed prose alone. #[cfg(target_os = "windows")] const PATH_COALESCE_THRESHOLD: usize = 8; - /// Check if a terminal event is a pasteable key press — a character, /// Enter, or Tab with no control modifiers (Ctrl/Alt/Super). /// @@ -3404,7 +2802,6 @@ fn is_pasteable_key_event(ev: &Event) -> bool { _ => false, } } - /// Map a voice-chord key event to its action (pure, so it's unit-testable). /// /// Hold mode is press-to-record / release-to-stop, but only a hold-*owned* @@ -3423,7 +2820,7 @@ fn voice_chord_action( KeyEventKind::Press if !listening => Some(Action::EnableVoiceMode), KeyEventKind::Press if !hold_owned => Some(Action::VoiceToggle), KeyEventKind::Release => Some(Action::VoiceStop), - _ => None, // repeat while a hold is held, or press of a hold-owned session + _ => None, } } else if kind == KeyEventKind::Press { Some(Action::VoiceToggle) @@ -3431,7 +2828,6 @@ fn voice_chord_action( None } } - /// Whether the event-loop intercept claims a voice-chord key event (pure for /// unit tests). /// @@ -3447,7 +2843,6 @@ fn voice_chord_claims_event(kind: KeyEventKind, keybind_enabled: bool, hold_owne } kind != KeyEventKind::Release && keybind_enabled } - /// The voice-capture chord: **Ctrl+Space** or **F8**. A press needs the exact /// chord (matching the registry, so Shift+F8 / Ctrl+Alt+Space don't fire); a /// release matches the key alone (Space/F8), since on Kitty the Ctrl release can @@ -3462,7 +2857,6 @@ fn is_voice_chord(ke: &KeyEvent) -> bool { } } } - /// Coalesce runs of rapid key events into synthetic `Event::Paste` /// events. On terminals without bracketed paste, pasted text arrives /// as individual key events; Enter keys mid-run would otherwise @@ -3481,14 +2875,9 @@ fn is_voice_chord(ke: &KeyEvent) -> bool { /// /// No-op when bracketed paste already arrives as `Event::Paste`. fn coalesce_rapid_keys(events: Vec) -> Vec { - // Fast path: not enough events for coalescing to trigger. if events.len() < PASTE_COALESCE_THRESHOLD { return events; } - - // If Event::Paste fragments are mixed with key events (Windows - // Terminal can split a large bracketed paste across read boundaries), - // merge everything into a single Event::Paste. let (mut has_paste, mut has_keys) = (false, false); for e in &events { has_paste |= matches!(e.event, Event::Paste(_)); @@ -3501,9 +2890,6 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { events }; } - - // Remove Release events — handlers ignore them and they'd break run - // detection. Exception: voice-chord releases (needed for hold-to-talk). let events: Vec = events .into_iter() .filter(|ev| { @@ -3511,10 +2897,8 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { if ke.kind == KeyEventKind::Release && !is_voice_chord(ke)) }) .collect(); - let mut result = Vec::with_capacity(events.len()); let mut i = 0; - while i < events.len() { if is_pasteable_key_event(&events[i].event) { let run_start = i; @@ -3522,7 +2906,6 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { let mut text = String::new(); let mut seen_enter = false; let mut has_char_after_enter = false; - while i < events.len() && is_pasteable_key_event(&events[i].event) { if let Event::Key(ke) = &events[i].event { match ke.code { @@ -3547,13 +2930,8 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { } i += 1; } - let run_len = i - run_start; let multiline_paste = run_len >= PASTE_COALESCE_THRESHOLD && has_char_after_enter; - // Windows fallback for drag-drops that arrive as a key - // burst instead of a bracketed paste — reuse the drop - // classifier's anchor detector so the two layers can't - // drift on what counts as a path. #[cfg(target_os = "windows")] let path_shaped_drop = run_len >= PATH_COALESCE_THRESHOLD && crate::prompt_images::starts_with_drop_anchor(&text); @@ -3580,10 +2958,8 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { i += 1; } } - result } - pub(super) fn is_bare_esc_press(ev: &Event) -> bool { matches!( ev, @@ -3592,7 +2968,6 @@ pub(super) fn is_bare_esc_press(ev: &Event) -> bool { && ke.modifiers == KeyModifiers::NONE ) } - /// Merge `Event::Paste` fragments and interleaved key events into a /// single `Event::Paste`. Non-paste, non-key events (Resize, Mouse, /// Focus) are preserved in order around the merged paste. @@ -3600,7 +2975,6 @@ fn merge_paste_fragments(events: Vec) -> Vec { let mut result = Vec::new(); let mut merged_text = String::new(); let mut merged_arrived_at = None; - for ev in events { match &ev.event { Event::Paste(text) => { @@ -3616,8 +2990,6 @@ fn merge_paste_fragments(events: Vec) -> Vec { _ => {} } } - // Non-pasteable keys (Ctrl+C, Backspace, arrows, Release - // events, etc.) are artifacts of paste fragmentation — drop. Event::Key(_) => {} _ => { if !merged_text.is_empty() { @@ -3632,17 +3004,14 @@ fn merge_paste_fragments(events: Vec) -> Vec { } } } - if !merged_text.is_empty() { result.push(TimedInputEvent { event: Event::Paste(merged_text), arrived_at: merged_arrived_at.expect("non-empty merged paste has an arrival time"), }); } - result } - /// Spawn effects into the task set. Returns `true` if the app should quit. fn process_effects( effs: Vec, @@ -3668,7 +3037,6 @@ fn process_effects( }; for eff in effs { let (quit, meta) = effects::execute(eff, tasks, &app.acp_tx, &app.cwd, &flags, progress_tx); - // Install auth abort handle if the current auth state still matches. if let Some((seq, abort_handle)) = meta.auth_abort_handle && let super::app_view::AuthState::Authenticating { request_seq, @@ -3679,8 +3047,6 @@ fn process_effects( { *handle = Some(abort_handle); } - // Install URL-poll abort handle when the seq still matches (or is the - // current Authenticating attempt). Aborted in `abort_prior_auth`. if let Some((seq, abort_handle)) = meta.auth_url_poll_handle { let still_current = matches!( &app.auth_state, @@ -3697,12 +3063,10 @@ fn process_effects( } false } - #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyEvent, KeyEventState}; - #[test] fn tty_suspend_arm_stops_same_batch_before_later_ownership_changes() { let mut app = crate::app::app_view::tests::test_app(); @@ -3715,9 +3079,6 @@ mod tests { ); assert!(tty_suspend_armed(&app)); } - - // ── is_voice_chord ─────────────────────────────────────────────────── - #[test] fn is_voice_chord_press_exact_release_keycode() { use KeyEventKind::{Press, Release}; @@ -3735,23 +3096,15 @@ mod tests { KeyModifiers::CONTROL, KeyModifiers::NONE, ); - // Press: exact chord only — stray mods / bare Space don't fire (Thread 4). assert!(hit(sp, ctrl, Press) && hit(f8, none, Press)); assert!(!hit(sp, ctrl | KeyModifiers::ALT, Press)); assert!(!hit(f8, KeyModifiers::SHIFT, Press) && !hit(sp, none, Press)); - // Release: key alone — a bare Space release (Ctrl lifted first) matches so - // hold-to-talk can still stop (Thread 3); non-chord keys don't. assert!(hit(sp, none, Release) && hit(f8, none, Release)); assert!(!hit(KeyCode::Char('a'), none, Release)); } - - // ── voice_chord_action ─────────────────────────────────────────────── - #[test] fn voice_chord_action_cases() { use crate::app::actions::Action; - // (hold_mode, releases_reported, kind, listening, hold_owned) -> action - // tag, with the toggle-stop case being a past regression. let press = KeyEventKind::Press; let release = KeyEventKind::Release; let tag = |a: Option| match a { @@ -3762,15 +3115,10 @@ mod tests { _ => "other", }; let cases = [ - // hold + releases: press idle starts; release stops; press on a - // hold-owned session waits; press on a non-hold (/voice/toggle) - // session toggles off. ((true, true, press, false, false), "start"), ((true, true, release, true, true), "stop"), ((true, true, press, true, true), "none"), ((true, true, press, true, false), "toggle"), - // Non-hold (toggle mode or no reported releases): press toggles, - // release noops. ((false, false, press, false, false), "toggle"), ((false, false, release, true, false), "none"), ((true, false, release, true, false), "none"), @@ -3783,7 +3131,6 @@ mod tests { ); } } - /// Hold-owned events are claimed even with the setting off (a dropped /// release would wedge the mic open — past regression); otherwise presses /// honor the setting and bare releases are never claimed. @@ -3792,19 +3139,15 @@ mod tests { let press = KeyEventKind::Press; let repeat = KeyEventKind::Repeat; let release = KeyEventKind::Release; - // (kind, keybind_enabled, hold_owned) -> claimed let cases = [ - // Hold-owned: everything claimed, setting on or off. ((release, false, true), true), ((release, true, true), true), ((press, false, true), true), ((repeat, false, true), true), - // No hold: press/repeat follow the setting. ((press, true, false), true), ((press, false, false), false), ((repeat, true, false), true), ((repeat, false, false), false), - // No hold: a bare release is never ours (normal typing). ((release, true, false), false), ((release, false, false), false), ]; @@ -3816,15 +3159,11 @@ mod tests { ); } } - - // ── plan_reconnect_load ────────────────────────────────────────────── - #[test] fn plan_reconnect_load_requires_session_id() { let agent = crate::test_util::make_agent_view(None, "/work/project"); assert!(plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).is_none()); } - /// The session's own cwd keys its on-disk storage — the pager cwd /// is only a fallback for agents without one. #[test] @@ -3833,12 +3172,10 @@ mod tests { let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.session_id.0.as_ref(), "sess-1"); assert_eq!(plan.cwd, std::path::PathBuf::from("/work/worktree-a")); - let agent = crate::test_util::make_agent_view(Some("sess-1"), ""); let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.cwd, std::path::PathBuf::from("/pager/cwd")); } - /// The reconnect cursor rides `_meta.cursor` when known; yolo mode /// always rides `_meta.yoloMode`. Auto rides `_meta.autoMode` per-agent. #[test] @@ -3850,28 +3187,20 @@ mod tests { plan.meta.get("cursor").is_none(), "no cursor key before any event was applied" ); - // autoMode is always set explicitly (false when not in auto) so the leader's - // capability injection can't re-enable Auto on reconnect. assert_eq!(plan.meta["autoMode"], serde_json::json!(false)); - agent.last_seen_event_id = Some("sess-1-42".into()); agent.session.yolo_mode = true; let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.meta["yoloMode"], serde_json::json!(true)); assert_eq!(plan.meta["cursor"], serde_json::json!("sess-1-42")); } - #[test] fn plan_reconnect_load_meta_carries_auto_mode_from_session() { - // Auto rides `_meta.autoMode`, derived from THIS agent's own - // `auto_mode` (per-agent, symmetric with yolo) — not the global UI mirror. let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); agent.session.auto_mode = true; let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.meta["yoloMode"], serde_json::json!(false)); assert_eq!(plan.meta["autoMode"], serde_json::json!(true)); - - // Yolo wins: autoMode is explicitly false even if the session is in auto. let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); agent.session.auto_mode = true; agent.session.yolo_mode = true; @@ -3879,7 +3208,6 @@ mod tests { assert_eq!(plan.meta["yoloMode"], serde_json::json!(true)); assert_eq!(plan.meta["autoMode"], serde_json::json!(false)); } - /// Multi-agent reconnect must seed each tab's `autoMode` from ITS OWN /// session, not a shared global mirror: an active Auto tab and a background /// Ask tab reconnect with `autoMode:true` and `autoMode:false` respectively. @@ -3888,12 +3216,9 @@ mod tests { let mut active = crate::test_util::make_agent_view(Some("sess-active"), "/work"); active.session.auto_mode = true; let background = crate::test_util::make_agent_view(Some("sess-bg"), "/work"); - // background.session.auto_mode stays false (Ask). - let active_plan = plan_reconnect_load(&active, std::path::Path::new("/pager/cwd")).unwrap(); let background_plan = plan_reconnect_load(&background, std::path::Path::new("/pager/cwd")).unwrap(); - assert_eq!(active_plan.meta["autoMode"], serde_json::json!(true)); assert_eq!( background_plan.meta["autoMode"], @@ -3901,13 +3226,11 @@ mod tests { "background Ask tab must reconnect with autoMode:false regardless of the active tab" ); } - #[test] fn reconnect_restores_dashboard_peek_before_replacing_scrollback() { use crate::scrollback::block::RenderBlock; use crate::views::dashboard::{DashboardRowId, DashboardState}; use indexmap::IndexMap; - let id = super::super::agent::AgentId(0); let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); agent @@ -3925,16 +3248,11 @@ mod tests { .begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); assert!(dashboard.as_ref().unwrap().peek_viewport.is_some()); assert!(agents[&id].scrollback.is_follow_mode()); - restore_dashboard_peek_before_reload(&mut dashboard, &mut agents); - assert!(dashboard.as_ref().unwrap().peek_viewport.is_none()); assert_eq!(agents[&id].scrollback.selected(), Some(0)); assert!(!agents[&id].scrollback.is_follow_mode()); } - - // ── reconnect_restore_outcome ──────────────────────────────────────── - /// The regression guard: one background tab fails, the active tab /// succeeds. The whole-reconnect flag goes false (toast says "failed"), /// but the active tab's OWN drain must still fire — a failed background tab @@ -3948,7 +3266,6 @@ mod tests { loads.insert(active, (true, None, None)); loads.insert(background, (false, None, None)); let pending = vec![active, background]; - let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, Some(active)); assert!( @@ -3960,7 +3277,6 @@ mod tests { "the active tab's own success still drains its queue" ); } - /// The active tab's OWN reload failed: its drain stays suppressed even /// though a background tab succeeded. #[test] @@ -3972,7 +3288,6 @@ mod tests { loads.insert(active, (false, None, None)); loads.insert(background, (true, None, None)); let pending = vec![active, background]; - let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, Some(active)); assert!(!all_restored); @@ -3981,7 +3296,6 @@ mod tests { "the active tab's own failure must block its drain" ); } - /// Single-agent behavior is preserved: the lone active tab succeeds → both /// flags true (toast "restored" + drain). #[test] @@ -3991,13 +3305,11 @@ mod tests { let mut loads = std::collections::HashMap::new(); loads.insert(active, (true, None, None)); let pending = vec![active]; - let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, Some(active)); assert!(all_restored); assert!(active_restored); } - /// A failed init (`init_ok == false`, empty `loads`) suppresses everything. #[test] fn reconnect_drain_blocked_when_init_failed() { @@ -4005,13 +3317,11 @@ mod tests { let active = AgentId(0); let loads = std::collections::HashMap::new(); let pending = vec![active]; - let (all_restored, active_restored) = reconnect_restore_outcome(false, &pending, &loads, Some(active)); assert!(!all_restored); assert!(!active_restored); } - /// No active agent (dashboard/welcome view): nothing to drain, even when /// every reloaded tab restored. #[test] @@ -4021,7 +3331,6 @@ mod tests { let mut loads = std::collections::HashMap::new(); loads.insert(background, (true, None, None)); let pending = vec![background]; - let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, None); assert!(all_restored); @@ -4030,11 +3339,9 @@ mod tests { "no active agent → no active-tab drain to fire" ); } - fn timed(event: Event, arrived_at: std::time::Instant) -> TimedInputEvent { TimedInputEvent { event, arrived_at } } - fn key_event(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> TimedInputEvent { TimedInputEvent::now(Event::Key(KeyEvent { code, @@ -4043,7 +3350,6 @@ mod tests { state: KeyEventState::NONE, })) } - fn scroll_event( kind: crossterm::event::MouseEventKind, arrived_at: std::time::Instant, @@ -4058,23 +3364,18 @@ mod tests { arrived_at, ) } - fn press(code: KeyCode) -> TimedInputEvent { key_event(code, KeyModifiers::NONE, KeyEventKind::Press) } - fn release(code: KeyCode) -> TimedInputEvent { key_event(code, KeyModifiers::NONE, KeyEventKind::Release) } - fn press_shift(code: KeyCode) -> TimedInputEvent { key_event(code, KeyModifiers::SHIFT, KeyEventKind::Press) } - fn press_ctrl(code: KeyCode) -> TimedInputEvent { key_event(code, KeyModifiers::CONTROL, KeyEventKind::Press) } - #[cfg(target_os = "linux")] fn mouse_event( kind: crossterm::event::MouseEventKind, @@ -4087,26 +3388,21 @@ mod tests { modifiers, })) } - #[test] fn park_input_reader_timeout_clears_stale_acknowledgement() { use std::sync::atomic::{AtomicBool, Ordering}; - let input_paused = AtomicBool::new(false); let reader_parked = AtomicBool::new(true); let acknowledged = park_input_reader(&input_paused, &reader_parked, Duration::ZERO); - assert!(!acknowledged); assert!(!reader_parked.load(Ordering::Acquire)); assert!(input_paused.load(Ordering::Acquire)); } - #[test] fn suspend_retry_gate_blocks_until_deadline() { let now = Instant::now(); let mut retry_after = None; let mut wait_reported = false; - assert!(defer_suspend_retry( &mut retry_after, &mut wait_reported, @@ -4116,8 +3412,6 @@ mod tests { assert_eq!(retry_after, Some(now + SUSPEND_RETRY_DELAY)); assert!(suspend_retry_ready(retry_after, now + SUSPEND_RETRY_DELAY)); assert!(wait_reported); - - // Mirrors the timer arm: expiry opens the gate for the next loop top. retry_after = None; assert!(suspend_retry_ready(retry_after, now)); assert!(!defer_suspend_retry( @@ -4128,22 +3422,17 @@ mod tests { assert_eq!(retry_after, Some(now + SUSPEND_RETRY_DELAY)); assert!(!suspend_retry_ready(retry_after, now)); } - #[test] fn suspend_timeout_requeues_request() { let mut pending = None; - requeue_after_suspend_timeout(&mut pending, "request"); - assert_eq!(pending, Some("request")); } - #[test] fn suspend_wait_feedback_is_reported_only_once_across_retries() { let now = Instant::now(); let mut retry_after = None; let mut reports = SuspendWaitReports::default(); - assert!(defer_suspend_retry( &mut retry_after, &mut reports.editor_reported, @@ -4155,7 +3444,6 @@ mod tests { &mut reports.editor_reported, now )); - reports.reset_missing(false, false); assert!(!reports.editor_reported); retry_after = None; @@ -4165,22 +3453,18 @@ mod tests { now )); } - #[test] fn editor_report_then_success_does_not_suppress_pager_first_timeout() { let now = Instant::now(); let mut retry_after = None; let mut reports = SuspendWaitReports::default(); - assert!(defer_suspend_retry( &mut retry_after, &mut reports.editor_reported, now )); - // The editor retry succeeds while the pager request remains pending. retry_after = None; reports.editor_reported = false; - assert!(defer_suspend_retry( &mut retry_after, &mut reports.pager_reported, @@ -4193,7 +3477,6 @@ mod tests { now )); } - #[test] fn suspend_wait_sink_is_mode_appropriate() { assert_eq!( @@ -4209,20 +3492,16 @@ mod tests { SuspendWaitSink::Toast ); } - #[test] fn suspend_wait_report_uses_system_block_in_minimal_mode() { use crate::scrollback::block::RenderBlock; - let mut app = crate::app::app_view::tests::test_app(); let id = crate::app::agent::AgentId(0); let agent = crate::test_util::make_agent_view(Some("session"), "/tmp"); app.agents.insert(id, agent); app.active_view = ActiveView::Agent(id); app.screen_mode = crate::app::ScreenMode::Minimal; - report_suspend_wait(&mut app, EDITOR_SUSPEND_WAIT); - let agent = app.agents.get(&id).expect("active agent"); let entry = agent.scrollback.last().expect("system block"); assert!(matches!( @@ -4231,7 +3510,6 @@ mod tests { )); assert!(agent.toast.is_none()); } - #[test] fn suspend_wait_report_uses_toast_outside_minimal_mode() { let mut app = crate::app::app_view::tests::test_app(); @@ -4240,9 +3518,7 @@ mod tests { app.agents.insert(id, agent); app.active_view = ActiveView::Agent(id); app.screen_mode = crate::app::ScreenMode::Inline; - report_suspend_wait(&mut app, EDITOR_SUSPEND_WAIT); - let agent = app.agents.get(&id).expect("active agent"); assert_eq!( agent.toast.as_ref().map(|(message, _)| message.as_str()), @@ -4250,22 +3526,18 @@ mod tests { ); assert!(agent.scrollback.last().is_none()); } - #[test] fn writer_failure_event_returns_original_error() { let error = writer_event_sequence(crate::render::draw::WriterEvent::Failed( std::io::Error::other("injected writer failure"), )) .expect_err("writer failure must terminate the event loop"); - assert_eq!(error.to_string(), "injected writer failure"); } - #[test] fn presenter_coalesces_until_ack() { let mut presenter = Presenter::new(); let mut draws = 0; - presenter.request(false); assert!(presenter.try_present(0, |_| draws += 1, || 1)); assert_eq!(presenter.in_flight_target, Some(1)); @@ -4275,27 +3547,22 @@ mod tests { } assert_eq!(draws, 1); assert!(presenter.dirty); - presenter.acknowledge(1); assert!(presenter.try_present(1, |_| draws += 1, || 2)); assert_eq!(draws, 2); assert_eq!(presenter.in_flight_target, Some(2)); } - #[test] fn presenter_no_output_does_not_wedge() { let mut presenter = Presenter::new(); presenter.request(false); - assert!(presenter.try_present(4, |_| {}, || 4)); assert_eq!(presenter.in_flight_target, None); assert!(!presenter.dirty); - presenter.request(false); assert!(presenter.try_present(4, |_| {}, || 5)); assert_eq!(presenter.in_flight_target, Some(5)); } - #[test] fn presenter_keeps_forced_repaint_sticky() { let mut presenter = Presenter { @@ -4305,13 +3572,11 @@ mod tests { presenter.request(false); presenter.request(true); let mut forced = false; - presenter.acknowledge(8); assert!(presenter.try_present(8, |force| forced = force, || 9)); assert!(forced); assert!(!presenter.force_full_repaint); } - #[test] fn presenter_immediate_ack_before_request_is_not_lost() { let mut presenter = Presenter { @@ -4320,37 +3585,30 @@ mod tests { }; presenter.acknowledge(3); presenter.request(false); - assert!(presenter.try_present(3, |_| {}, || 4)); assert_eq!(presenter.in_flight_target, Some(4)); } - #[test] fn presenter_later_ack_clears_target() { let mut presenter = Presenter { in_flight_target: Some(3), ..Presenter::new() }; - presenter.acknowledge(4); - assert_eq!(presenter.in_flight_target, None); } - #[test] fn presenter_waits_for_last_payload_in_turn() { let mut presenter = Presenter::new(); presenter.request(false); assert!(presenter.try_present(10, |_| {}, || 13)); presenter.request(false); - presenter.acknowledge(11); assert!(!presenter.try_present(13, |_| panic!("target not acknowledged"), || 14)); presenter.acknowledge(13); assert!(presenter.try_present(13, |_| {}, || 14)); assert_eq!(presenter.in_flight_target, Some(14)); } - #[test] fn timed_paste_uses_first_contributing_event() { let start = std::time::Instant::now(); @@ -4368,12 +3626,10 @@ mod tests { start + Duration::from_millis(8), ), ]; - let coalesced = coalesce_rapid_keys(events); assert_eq!(coalesced.len(), 1); assert_eq!(coalesced[0].arrived_at, start); assert_eq!(coalesced[0].event, Event::Paste("a\nb".to_owned())); - let fragments = vec![ timed(Event::Paste("a".to_owned()), start), timed( @@ -4389,11 +3645,9 @@ mod tests { assert_eq!(merged[0].arrived_at, start); assert_eq!(merged[0].event, Event::Paste("a\nb".to_owned())); } - #[test] fn delayed_scroll_batch_preserves_arrival_spacing_and_reversal() { use crossterm::event::MouseEventKind::{ScrollDown, ScrollUp}; - let mut app = crate::app::app_view::tests::test_app(); let start = std::time::Instant::now() + Duration::from_secs(1); app.scroll_state = Default::default(); @@ -4416,7 +3670,6 @@ mod tests { spaced.stream.expect("up stream active").avg_interval_ms, Some(8.0) ); - let routed = normalize_input_event(scroll_event(ScrollDown, start + Duration::from_millis(40))); let _ = app.handle_input_at_with_paste_provenance( @@ -4424,7 +3677,6 @@ mod tests { routed.arrived_at, routed.paste_provenance, ); - let snapshot = app .scroll_state .debug_snapshot(&app.scroll_config, start + Duration::from_millis(40)); @@ -4433,7 +3685,6 @@ mod tests { assert_eq!(stream.events, 1); assert_eq!(stream.gap_remaining_ms, 80); } - #[cfg(target_os = "linux")] #[test] fn unmodified_middle_down_reads_primary_once() { @@ -4444,21 +3695,18 @@ mod tests { x11_primary_available: true, ..Default::default() }); - let input = mouse_event( MouseEventKind::Down(MouseButton::Middle), KeyModifiers::NONE, ); let arrived_at = input.arrived_at; let normalized = normalize_input_event(input); - assert_eq!(normalized.event, Event::Paste("PRIMARY\nexact".to_owned())); assert_eq!(normalized.arrived_at, arrived_at); assert_eq!(normalized.paste_provenance, PasteProvenance::X11Primary); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 1); crate::clipboard::clear_clipboard_probe_hook(); } - #[cfg(target_os = "linux")] #[test] fn nonqualifying_mouse_events_do_not_read_primary() { @@ -4468,7 +3716,6 @@ mod tests { x11_primary_available: true, ..Default::default() }); - let release = mouse_event(MouseEventKind::Up(MouseButton::Middle), KeyModifiers::NONE); let normalized = normalize_input_event(release.clone()); assert_eq!(normalized.event, release.event); @@ -4487,7 +3734,6 @@ mod tests { assert_eq!(crate::clipboard::primary_selection_read_call_count(), 0); crate::clipboard::clear_clipboard_probe_hook(); } - #[cfg(target_os = "linux")] #[test] fn empty_primary_preserves_original_middle_event() { @@ -4501,14 +3747,12 @@ mod tests { MouseEventKind::Down(MouseButton::Middle), KeyModifiers::NONE, ); - let normalized = normalize_input_event(middle.clone()); assert_eq!(normalized.event, middle.event); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 1); crate::clipboard::clear_clipboard_probe_hook(); } - #[test] fn coalesce_multiline_paste_without_bracketed_paste() { let events = vec![ @@ -4522,10 +3766,8 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("ab\ncd".to_string())); } - #[test] fn coalesce_filters_release_events() { - // Press+Release pairs (Windows Terminal, Kitty) must not break runs. let events = vec![ press(KeyCode::Char('a')), release(KeyCode::Char('a')), @@ -4540,7 +3782,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("ab\nc".to_string())); } - #[test] fn coalesce_preserves_shifted_chars() { let events = vec![ @@ -4555,7 +3796,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("Hi\nBye".to_string())); } - #[test] fn coalesce_below_threshold_no_change() { let events = vec![press(KeyCode::Char('a')), press(KeyCode::Enter)]; @@ -4564,10 +3804,8 @@ mod tests { assert!(matches!(&result[0].event, Event::Key(ke) if ke.code == KeyCode::Char('a'))); assert!(matches!(&result[1].event, Event::Key(ke) if ke.code == KeyCode::Enter)); } - #[test] fn coalesce_no_enter_no_change() { - // No Enter in the run — no premature-send risk. let events = vec![ press(KeyCode::Char('h')), press(KeyCode::Char('e')), @@ -4581,10 +3819,8 @@ mod tests { assert!(matches!(&ev.event, Event::Key(_))); } } - #[test] fn coalesce_only_enters_no_change() { - // All-Enter runs must not coalesce (held Enter key repeat). let events = vec![ press(KeyCode::Enter), press(KeyCode::Enter), @@ -4594,7 +3830,6 @@ mod tests { let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 4); } - #[test] fn coalesce_preserves_non_key_events() { let events = vec![ @@ -4610,7 +3845,6 @@ mod tests { assert_eq!(result[1].event, Event::Paste("a\nb".to_string())); assert!(matches!(&result[2].event, Event::Resize(100, 30))); } - #[test] fn coalesce_ctrl_key_breaks_run() { let events = vec![ @@ -4621,10 +3855,8 @@ mod tests { press(KeyCode::Char('d')), ]; let result = coalesce_rapid_keys(events); - // "ab" (2, no Enter) | Ctrl+C | "\nd" (2) — both runs below threshold. assert_eq!(result.len(), 5); } - #[test] fn coalesce_tabs_in_pasted_code() { let events = vec![ @@ -4638,7 +3870,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("if\n\tx".to_string())); } - #[test] fn coalesce_exactly_at_threshold() { let events = vec![ @@ -4650,10 +3881,8 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("a\nb".to_string())); } - #[test] fn coalesce_type_then_submit_not_coalesced() { - // Enter is the LAST event — "type + submit", not paste. let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Char('b')), @@ -4664,10 +3893,8 @@ mod tests { assert_eq!(result.len(), 4); assert!(matches!(&result[3].event, Event::Key(ke) if ke.code == KeyCode::Enter)); } - #[test] fn fragmented_paste_merged_with_keys() { - // Event::Paste mixed with key events — merge into one paste. let events = vec![ TimedInputEvent::now(Event::Paste("real paste".into())), press(KeyCode::Char('a')), @@ -4678,7 +3905,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("real pastea\nb".to_string())); } - #[test] fn coalesce_single_event_passthrough() { let events = vec![press(KeyCode::Enter)]; @@ -4686,18 +3912,13 @@ mod tests { assert_eq!(result.len(), 1); assert!(matches!(&result[0].event, Event::Key(_))); } - #[test] fn coalesce_empty_input() { let result = coalesce_rapid_keys(vec![]); assert!(result.is_empty()); } - - // ── Multi-newline coalescing tests ─────────────────────────────── - #[test] fn coalesce_three_lines() { - // "foo\nbar\nbaz" — 3 lines, 2 newlines. let events = vec![ press(KeyCode::Char('f')), press(KeyCode::Char('o')), @@ -4715,10 +3936,8 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("foo\nbar\nbaz".to_string())); } - #[test] fn coalesce_four_lines_trailing_newline() { - // "a\nb\nc\nd\n" — 4 lines + trailing newline. let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Enter), @@ -4733,21 +3952,16 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("a\nb\nc\nd\n".to_string())); } - - // ── should_extend_for_paste tests ─────────────────────────────── - #[test] fn extend_triggered_with_single_pasteable_key() { let events = vec![press(KeyCode::Char('a'))]; assert!(should_extend_for_paste(&events)); } - #[test] fn extend_triggered_with_enter_key() { let events = vec![press(KeyCode::Enter)]; assert!(should_extend_for_paste(&events)); } - #[test] fn extend_not_triggered_with_bracketed_paste() { let events = vec![ @@ -4758,18 +3972,13 @@ mod tests { ]; assert!(!should_extend_for_paste(&events)); } - #[test] fn extend_not_triggered_with_only_non_pasteable() { let events = vec![TimedInputEvent::now(Event::Resize(80, 24))]; assert!(!should_extend_for_paste(&events)); } - - // ── merge_paste_fragments tests ───────────────────────────────── - #[test] fn merge_paste_and_key_fragments() { - // Fragmented bracketed paste: Event::Paste + loose key events. let events = vec![ TimedInputEvent::now(Event::Paste("hello\nwor".into())), press(KeyCode::Char('l')), @@ -4779,7 +3988,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("hello\nworld".to_string())); } - #[test] fn merge_multiple_paste_fragments() { let events = vec![ @@ -4791,7 +3999,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("aa\nbb\nc".to_string())); } - #[test] fn merge_preserves_non_key_events() { let events = vec![ @@ -4805,7 +4012,6 @@ mod tests { assert!(matches!(result[1].event, Event::Resize(80, 24))); assert_eq!(result[2].event, Event::Paste("x".to_string())); } - #[test] fn merge_skips_release_events() { let events = vec![ @@ -4817,7 +4023,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("abc".to_string())); } - #[test] fn pure_paste_no_merge_needed() { let events = vec![TimedInputEvent::now(Event::Paste("hello\nworld".into()))]; @@ -4825,9 +4030,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste("hello\nworld".to_string())); } - - // ── is_pasteable_key_event filtering tests ───────────────────────── - #[test] fn pasteable_rejects_mouse_events() { use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; @@ -4846,24 +4048,20 @@ mod tests { }); assert!(!is_pasteable_key_event(&click)); } - #[test] fn pasteable_rejects_focus_events() { assert!(!is_pasteable_key_event(&Event::FocusGained)); assert!(!is_pasteable_key_event(&Event::FocusLost)); } - #[test] fn pasteable_rejects_release_events() { assert!(!is_pasteable_key_event(&release(KeyCode::Char('a')).event)); assert!(!is_pasteable_key_event(&release(KeyCode::Enter).event)); } - #[test] fn pasteable_rejects_resize() { assert!(!is_pasteable_key_event(&Event::Resize(80, 24))); } - #[test] fn pasteable_rejects_repeat_events() { let ev = Event::Key(KeyEvent { @@ -4874,7 +4072,6 @@ mod tests { }); assert!(!is_pasteable_key_event(&ev)); } - #[test] fn pasteable_accepts_valid_key_presses() { assert!(is_pasteable_key_event(&press(KeyCode::Char('a')).event)); @@ -4884,7 +4081,6 @@ mod tests { assert!(is_pasteable_key_event(&press(KeyCode::Enter).event)); assert!(is_pasteable_key_event(&press(KeyCode::Tab).event)); } - #[test] fn extend_not_triggered_with_only_mouse_and_focus() { use crossterm::event::{MouseEvent, MouseEventKind}; @@ -4899,7 +4095,6 @@ mod tests { ]; assert!(!should_extend_for_paste(&events)); } - #[test] fn extend_triggered_only_when_key_present_in_mixed_batch() { use crossterm::event::{MouseEvent, MouseEventKind}; @@ -4915,12 +4110,8 @@ mod tests { ]; assert!(should_extend_for_paste(&events)); } - #[test] fn coalesce_mouse_events_interleaved_with_paste_chars() { - // Simulates the batch produced by the fixed detect_paste: - // a key press followed by mouse events. The mouse events - // should not prevent the key from being processed. use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ press(KeyCode::Char('a')), @@ -4938,17 +4129,13 @@ mod tests { })), ]; let result = coalesce_rapid_keys(events); - // Below coalesce threshold, all events pass through unchanged. assert_eq!(result.len(), 3); assert!(matches!(&result[0].event, Event::Key(ke) if ke.code == KeyCode::Char('a'))); assert!(matches!(&result[1].event, Event::Mouse(_))); assert!(matches!(&result[2].event, Event::Mouse(_))); } - #[test] fn coalesce_mouse_breaks_key_run_preserves_events() { - // A genuine paste batch that also collected mouse events. - // The paste chars should still coalesce; mouse events are preserved. use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ press(KeyCode::Char('a')), @@ -4963,22 +4150,12 @@ mod tests { press(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); - // The mouse event breaks the key run: [a, b, Enter] (3 keys, but - // Enter is last in that sub-run → no char after Enter → not coalesced), - // then [mouse], then [c] (1 key). assert_eq!(result.len(), 5); } - - // ── Windows path-shape coalescing (drag-drop without bracketed paste) ─ - // - // Windows-gated: the path-shape branch only exists on Windows - // (other platforms reliably get bracketed paste for drag-drop). - #[cfg(target_os = "windows")] fn press_run(text: &str) -> Vec { text.chars().map(|c| press(KeyCode::Char(c))).collect() } - /// Smoke test across every anchor variant the branch should match: /// drive-letter (both separators), UNC, Unix absolute, `file://`, /// and the Windows-Terminal-quoted form for paths with spaces. @@ -4998,26 +4175,24 @@ mod tests { assert_eq!(result[0].event, Event::Paste(input.to_string())); } } - /// Below-threshold path-shape (< 8 chars) and non-path prose of any /// length must NOT coalesce — keep typed editing intact. #[cfg(target_os = "windows")] #[test] fn coalesce_path_shape_rejects_short_or_non_path() { - let short = "/foo.tx"; // 7 chars, below PATH_COALESCE_THRESHOLD + let short = "/foo.tx"; assert!( coalesce_rapid_keys(press_run(short)) .iter() .all(|e| matches!(e.event, Event::Key(_))) ); - let prose = "helloworld"; // 10 chars, no path anchor + let prose = "helloworld"; assert!( coalesce_rapid_keys(press_run(prose)) .iter() .all(|e| matches!(e.event, Event::Key(_))) ); } - /// `:` in a US-layout drive-letter path arrives as Shift+`;`; /// `is_pasteable_key_event` accepts SHIFT so the run must assemble /// cleanly. @@ -5031,9 +4206,6 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].event, Event::Paste(r"C:\foo.png".to_string())); } - - // ── make_run_result exit info ──────────────────────────────────────── - /// App focused on an agent (session `test-session`) with a seeded /// prompt → prompt → response exchange in its scrollback. fn seeded_quit_app(screen_mode: crate::app::ScreenMode) -> AppView { @@ -5049,7 +4221,6 @@ mod tests { scrollback.push_block(RenderBlock::agent_message("Pinned the seed.\nSecond line.")); app } - #[test] fn make_run_result_fullscreen_quit_builds_summary() { let app = seeded_quit_app(crate::app::ScreenMode::Fullscreen); @@ -5057,7 +4228,6 @@ mod tests { assert_eq!(info.session_id, "test-session"); assert!(!info.minimal); let summary = info.summary.expect("summary on fullscreen quit"); - // Deliberate: title comes from the first prompt, last_prompt from the newest. assert_eq!(summary.title, "fix the flaky CI test"); assert_eq!( summary.last_prompt.as_deref(), @@ -5065,7 +4235,6 @@ mod tests { ); assert_eq!(summary.last_response.as_deref(), Some("Pinned the seed.")); } - #[test] fn make_run_result_unanswered_prompt_omits_stale_response() { use crate::scrollback::block::RenderBlock; @@ -5084,23 +4253,19 @@ mod tests { summary.last_prompt.as_deref(), Some("now rerun the whole suite") ); - // The earlier reply answered an older prompt — it must not appear here. assert!(summary.last_response.is_none()); } - #[test] fn make_run_result_inline_and_minimal_quits_omit_summary() { let app = seeded_quit_app(crate::app::ScreenMode::Inline); let info = make_run_result(&app).exit_info.expect("agent exit info"); assert!(info.summary.is_none()); assert!(!info.minimal); - let app = seeded_quit_app(crate::app::ScreenMode::Minimal); let info = make_run_result(&app).exit_info.expect("agent exit info"); assert!(info.summary.is_none()); assert!(info.minimal); } - #[test] fn make_run_result_empty_session_omits_summary() { let mut app = crate::app::app_view::tests::test_app_with_agent(); @@ -5108,7 +4273,6 @@ mod tests { let info = make_run_result(&app).exit_info.expect("agent exit info"); assert!(info.summary.is_none()); } - #[test] fn make_run_result_non_agent_views_have_no_exit_info() { for view in [ActiveView::Welcome, ActiveView::AgentDashboard] { diff --git a/crates/codegen/xai-grok-pager/src/app/leader_cluster/mod.rs b/crates/codegen/xai-grok-pager/src/app/leader_cluster/mod.rs index f02e22d..f49e9a2 100644 --- a/crates/codegen/xai-grok-pager/src/app/leader_cluster/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/leader_cluster/mod.rs @@ -29,13 +29,19 @@ //! seams), where env is set before any process-global's first touch. //! //! Unix-only: the leader transport here is a unix socket. - +use super::actions::{Action, TaskResult}; +use super::agent::AgentState; +use super::agent_view::AgentView; +use super::app_view::{AppView, AuthState, TrustState}; +use super::{acp_handler, dispatch, effects}; +use crate::acp::leader_bridge::bridge_channels; +use crate::acp::model_state::ModelState; +use crate::scrollback::block::RenderBlock; +use agent_client_protocol as acp; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::time::Duration; - -use agent_client_protocol as acp; use tempfile::TempDir; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; @@ -46,19 +52,8 @@ use xai_grok_shell::leader::{ LeaderServerControlState, LeaderServerMetadata, ReconnectPolicy, run_leader_server, }; use xai_grok_test_support::MockInferenceServer; - -use super::actions::{Action, TaskResult}; -use super::agent::AgentState; -use super::agent_view::AgentView; -use super::app_view::{AppView, AuthState, TrustState}; -use super::{acp_handler, dispatch, effects}; -use crate::acp::leader_bridge::bridge_channels; -use crate::acp::model_state::ModelState; -use crate::scrollback::block::RenderBlock; - const PUMP_TICK: Duration = Duration::from_millis(10); const TURN_BUDGET: Duration = Duration::from_secs(60); - /// Await a bring-up step with a hard budget so an on-demand run that hangs /// names its phase instead of parking until the test-runner kill. async fn bounded(what: &str, fut: impl std::future::Future) -> T { @@ -66,13 +61,11 @@ async fn bounded(what: &str, fut: impl std::future::Future) -> T .await .unwrap_or_else(|_| panic!("leader-cluster bring-up timed out: {what}")) } - /// The grok home the agent actually persisted under: `grok_home()` is /// process-cached, so an earlier test in this binary may have pinned it. fn effective_grok_home() -> PathBuf { xai_grok_config::grok_home() } - /// Concatenated agent-message text across a view's scrollback (copy of the /// acp_handler tests' helper; that one is test-mod private). fn agent_message_text(view: &AgentView) -> String { @@ -86,7 +79,6 @@ fn agent_message_text(view: &AgentView) -> String { } out } - /// One pager client: a full `AppView` behind the production leader bridge. struct ClusterClient { app: AppView, @@ -99,7 +91,6 @@ struct ClusterClient { /// generation bumps after a leader kill/respawn. status_rx: Option>, } - impl ClusterClient { /// Drain everything currently ready (inbound ACP + finished tasks). /// Returns whether anything was processed. @@ -119,14 +110,12 @@ impl ClusterClient { } progressed } - fn drain_pending_effects(&mut self) { if !self.app.pending_effects.is_empty() { let effs = std::mem::take(&mut self.app.pending_effects); self.process_effects(effs); } } - /// The event loop's `process_effects`, minus terminal/auth-handle wiring /// (that fn is event_loop-private; this mirrors its body). fn process_effects(&mut self, effs: Vec) { @@ -158,20 +147,17 @@ impl ClusterClient { } self.drain_pending_effects(); } - /// Dispatch a user action and run its effects. fn act(&mut self, action: Action) { let effs = dispatch::dispatch(action, &mut self.app); self.process_effects(effs); } - /// Pump until `pred(app)` holds, within [`TURN_BUDGET`]. No fixed sleeps /// beyond the pump tick; panics with `what` on expiry. Single-client sugar /// over [`pump_clients_until`] so there is exactly one pump loop. async fn pump_until(&mut self, what: &str, pred: impl Fn(&AppView) -> bool) { pump_clients_until(&mut [self], what, |clients| pred(&clients[0].app)).await; } - /// The most recently created agent view (scenarios add tabs in order). fn latest_agent(&self) -> &AgentView { self.app @@ -180,7 +166,6 @@ impl ClusterClient { .last() .expect("client has no agent view yet") } - fn agent_for_session(&self, sid: &str) -> &AgentView { self.app .agents @@ -193,7 +178,6 @@ impl ClusterClient { }) .unwrap_or_else(|| panic!("no agent view for session {sid}")) } - /// Create a new session through the real dispatch → effect → agent path. async fn new_session(&mut self) -> String { self.act(Action::NewSession); @@ -211,7 +195,6 @@ impl ClusterClient { .0 .to_string() } - /// Attach to an existing session (viewer path) and wait for the replay to /// land. async fn load_session(&mut self, sid: &str) { @@ -228,7 +211,6 @@ impl ClusterClient { }) .await; } - /// Drive one full turn on the active agent and wait until it lands /// (sentinel visible + agent back to Idle). async fn run_turn(&mut self, prompt: &str, sentinel: &str) { @@ -242,12 +224,10 @@ impl ClusterClient { }) .await; } - fn sever(self) { self.bridge_cancel.cancel(); } } - /// Pump several clients until `pred` holds across them, within /// [`TURN_BUDGET`]; panics with `what` on expiry. async fn pump_clients_until( @@ -270,7 +250,6 @@ async fn pump_clients_until( tokio::time::sleep(PUMP_TICK).await; } } - /// The cluster: leader server + real agent, plus knobs to kill/respawn the /// leader generation under the same socket path. struct PagerLeaderCluster { @@ -296,18 +275,15 @@ struct PagerLeaderCluster { _env: Vec, _grok_home: TempDir, } - impl PagerLeaderCluster { /// Stand up the cluster. Callers MUST be `#[serial_test::serial(GROK_HOME)]` /// (env mutation) and run inside a current-thread `LocalSet`. async fn start() -> Self { let _ = rustls::crypto::ring::default_provider().install_default(); - let server = MockInferenceServer::start().await.expect("mock server"); let grok_home = TempDir::new().unwrap(); let workdir = TempDir::new().unwrap(); let sock_path = grok_home.path().join("leader-cluster.sock"); - let env = vec![ crate::test_util::EnvVarGuard::set("GROK_HOME", grok_home.path()), crate::test_util::EnvVarGuard::set("GROK_CLI_CHAT_PROXY_BASE_URL", server.url()), @@ -320,15 +296,12 @@ impl PagerLeaderCluster { // connect_or_spawn) to this cluster's socket. crate::test_util::EnvVarGuard::set(LEADER_SOCKET_ENV, &sock_path), ]; - - // Hold the flock for the cluster's lifetime (see field doc). let mut flock = LeaderLock::new(""); assert!( flock.try_acquire().expect("acquire cluster flock"), "cluster flock unexpectedly held" ); flock.write_pid().expect("stamp cluster flock"); - let client_count = Arc::new(AtomicUsize::new(0)); let mut cluster = Self { sock_path, @@ -345,7 +318,6 @@ impl PagerLeaderCluster { cluster.spawn_leader_generation().await; cluster } - /// Bind a fresh leader-server generation at the fixed socket path and /// wire a fresh REAL agent behind it. async fn spawn_leader_generation(&mut self) { @@ -354,15 +326,11 @@ impl PagerLeaderCluster { let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); self.server_cancel = cancel.clone(); - let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: self.sock_path.clone(), lock_path: self.sock_path.with_extension("lock"), ws_url_suffix: String::new(), - // MUST be the client-side comparison source (xai_grok_version), not - // this crate's version: a reconnecting client evicts strictly-older - // leaders, and "evict" here would signal THIS test process. leader_binary_version: xai_grok_version::VERSION.to_string(), }); let sock_for_server = self.sock_path.clone(); @@ -387,20 +355,17 @@ impl PagerLeaderCluster { ) .await; })); - generation_tasks.extend(xai_grok_shell::leader::in_process::spawn_agent( acp_rx, response_tx, )); self.generation_tasks = generation_tasks; - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); while !self.sock_path.exists() && tokio::time::Instant::now() < deadline { tokio::time::sleep(Duration::from_millis(20)).await; } assert!(self.sock_path.exists(), "leader socket never bound"); } - /// Kill the current leader generation (server + agent die together, like /// a real leader process crash) and wait for the socket to vanish. async fn kill_leader(&mut self) { @@ -409,32 +374,19 @@ impl PagerLeaderCluster { while self.sock_path.exists() && tokio::time::Instant::now() < deadline { tokio::time::sleep(Duration::from_millis(20)).await; } - // Fail HERE if the old generation never released the socket: its late - // shutdown cleanup would otherwise delete the respawned generation's - // fresh socket from under it (same-path race), which surfaces as a - // confusing reconnect-budget expiry downstream. assert!( !self.sock_path.exists(), "old leader generation never released the socket" ); - // Abort + drain the generation's agent/bridge tasks (the server task - // has already run its socket cleanup above). Channel-closure teardown - // is only eventual; without this drain an old agent task could still - // be running against the same GROK_HOME when the next generation's - // agent starts — two writers on one updates.jsonl, the corruption - // class the real leader's flock prevents. for task in self.generation_tasks.drain(..) { task.abort(); let _ = task.await; } - // The next generation's agent must re-authenticate its ACP surface. self.authenticated = false; } - async fn respawn_leader(&mut self) { self.spawn_leader_generation().await; } - /// Connect a pager client. With `reconnect: true` the bridge gets a real /// `LeaderReconnector` (socket pinned via `GROK_LEADER_SOCKET`, flock held /// by the cluster, so reconnects always adopt the in-process server). @@ -454,7 +406,6 @@ impl PagerLeaderCluster { .await .expect("cluster client connect"); let (leader_tx, leader_rx) = conn.into_channels(); - let cancel = CancellationToken::new(); let (reconnector, status_rx) = if reconnect { let (status_tx, status_rx) = LeaderReconnector::status_channel(); @@ -475,7 +426,6 @@ impl PagerLeaderCluster { } else { (None, None) }; - let bridge = bridge_channels( leader_tx, leader_rx, @@ -486,8 +436,6 @@ impl PagerLeaderCluster { .expect("bridge spawn"); let tx = bridge.channel.tx; let rx = bridge.channel.rx; - - // Same handshake the pager performs after bridging (spawn path). let _init: acp::InitializeResponse = bounded( "initialize", acp_send( @@ -528,14 +476,12 @@ impl PagerLeaderCluster { .expect("authenticate through bridge"); self.authenticated = true; } - let mut app = AppView::new(tx, ModelState::default(), Vec::new()); app.leader_mode = true; app.auth_state = AuthState::Done; app.trust_state = TrustState::Done; app.project_picker_shown = true; app.cwd = self.workdir.path().to_path_buf(); - let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel(); ClusterClient { app, @@ -547,7 +493,6 @@ impl PagerLeaderCluster { status_rx, } } - /// Inference request count (chat/responses/messages only), for /// no-turn-was-re-driven invariants. fn inference_request_count(&self) -> usize { @@ -562,20 +507,15 @@ impl PagerLeaderCluster { .count() } } - impl Drop for PagerLeaderCluster { fn drop(&mut self) { self.server_cancel.cancel(); - // Best-effort (Drop cannot await): stop the generation's tasks so they - // never outlive the env guards / temp dirs dropping right after. for task in self.generation_tasks.drain(..) { task.abort(); } } } - fn occurrences(haystack: &str, needle: &str) -> usize { haystack.matches(needle).count() } - mod scenarios; diff --git a/crates/codegen/xai-grok-pager/src/app/mod.rs b/crates/codegen/xai-grok-pager/src/app/mod.rs index b471af2..ffbbd2a 100644 --- a/crates/codegen/xai-grok-pager/src/app/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/mod.rs @@ -2019,6 +2019,12 @@ mod tests { assert!(try_parse_pager(&["grok-pager", "--chat"]).is_err()); } #[test] + fn cli_local_workspace_flags_rejected_without_feature() { + assert!(try_parse_pager(&["grok-pager", "--local-workspace-attach=srv"]).is_err()); + assert!(try_parse_pager(&["grok-pager", "--local-workspace"]).is_err()); + assert!(try_parse_pager(&["grok-pager", "--local-workspace-cwd=/tmp"]).is_err()); + } + #[test] fn chat_mode_leader_guard_truth_table() { assert!(session_startup::chat_mode_conflicts_with_leader(true, true)); assert!(!session_startup::chat_mode_conflicts_with_leader( diff --git a/crates/codegen/xai-grok-pager/src/app/modals.rs b/crates/codegen/xai-grok-pager/src/app/modals.rs index a924e82..eb1d590 100644 --- a/crates/codegen/xai-grok-pager/src/app/modals.rs +++ b/crates/codegen/xai-grok-pager/src/app/modals.rs @@ -67,6 +67,7 @@ impl AgentView { cwd, has_session_announcements: slash_controller.has_session_announcements(), billing_surface_visible: slash_controller.billing_surface_visible(), + usage_command_visible: slash_controller.usage_command_visible(), workflows_available: slash_controller.workflows_available(), screen_mode: slash_controller.screen_mode(), }; @@ -710,7 +711,7 @@ impl AgentView { let filtered = crate::views::modal::filter_palette_entries( state.query(), self.sharing_enabled, - self.prompt.slash_controller.screen_mode(), + &self.prompt.slash_controller, ); let non_sel: Vec = filtered .iter() @@ -932,7 +933,7 @@ impl AgentView { *entries = crate::views::modal::filter_palette_entries( state.query(), sharing_enabled, - self.prompt.slash_controller.screen_mode(), + &self.prompt.slash_controller, ); state.selected = state.selected.min(entries.len().saturating_sub(1)); } @@ -1716,7 +1717,7 @@ impl AgentView { let filtered = modal::filter_palette_entries( state.query(), self.sharing_enabled, - self.prompt.slash_controller.screen_mode(), + &self.prompt.slash_controller, ); let non_sel: Vec = filtered .iter() @@ -2764,7 +2765,7 @@ mod command_palette_vim_input_tests { agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( agent.sharing_enabled, - agent.prompt.slash_controller.screen_mode(), + &agent.prompt.slash_controller, ), state: PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), @@ -2796,7 +2797,7 @@ mod command_palette_vim_input_tests { agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( agent.sharing_enabled, - crate::app::ScreenMode::Minimal, + &agent.prompt.slash_controller, ), state: { let mut state = PickerState::input_active(); @@ -2855,7 +2856,7 @@ mod command_palette_vim_input_tests { agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( agent.sharing_enabled, - crate::app::ScreenMode::Minimal, + &agent.prompt.slash_controller, ), state: { let mut state = PickerState::input_active(); diff --git a/crates/codegen/xai-grok-pager/src/app/queue_edit.rs b/crates/codegen/xai-grok-pager/src/app/queue_edit.rs index a122a56..cfca78c 100644 --- a/crates/codegen/xai-grok-pager/src/app/queue_edit.rs +++ b/crates/codegen/xai-grok-pager/src/app/queue_edit.rs @@ -1323,7 +1323,7 @@ mod tests { agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries( agent.sharing_enabled, - agent.prompt.slash_controller.screen_mode(), + &agent.prompt.slash_controller, ), state: crate::views::picker::PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), diff --git a/crates/codegen/xai-grok-pager/src/app/screen_mode_relaunch.rs b/crates/codegen/xai-grok-pager/src/app/screen_mode_relaunch.rs index 2bc1589..04bf3a4 100644 --- a/crates/codegen/xai-grok-pager/src/app/screen_mode_relaunch.rs +++ b/crates/codegen/xai-grok-pager/src/app/screen_mode_relaunch.rs @@ -285,6 +285,7 @@ pub(crate) fn exec_screen_mode_relaunch(session_id: &str, want_minimal: bool) -> // reader competes with the child for console records and swallows its // first keystrokes. std::thread::sleep(std::time::Duration::from_millis(150)); + #[allow(clippy::disallowed_methods)] // the parent waits and exits with its status let mut child = cmd.spawn()?; let status = child.wait()?; std::process::exit(status.code().unwrap_or(0)); diff --git a/crates/codegen/xai-grok-pager/src/app/session_startup.rs b/crates/codegen/xai-grok-pager/src/app/session_startup.rs index 4b87d7a..11347a2 100644 --- a/crates/codegen/xai-grok-pager/src/app/session_startup.rs +++ b/crates/codegen/xai-grok-pager/src/app/session_startup.rs @@ -300,6 +300,9 @@ pub fn chat_mode_flag_conflict( } None } +pub fn active_local_workspace() -> anyhow::Result> { + Ok(None) +} /// Conservative shape check for a chat-mode `--resume ` passthrough. /// /// The id skips disk/GCS resolution and flows to the gateway, but it is also diff --git a/crates/codegen/xai-grok-pager/src/app/status_blocks.rs b/crates/codegen/xai-grok-pager/src/app/status_blocks.rs index 9e36cc4..d500840 100644 --- a/crates/codegen/xai-grok-pager/src/app/status_blocks.rs +++ b/crates/codegen/xai-grok-pager/src/app/status_blocks.rs @@ -289,6 +289,7 @@ mod tests { output_tokens: output, total_tokens: input + output, cached_read_tokens: 0, + cache_creation_tokens: 0, reasoning_tokens: 0, model_calls: 1, api_duration_ms: 1_000, diff --git a/crates/codegen/xai-grok-pager/src/headless.rs b/crates/codegen/xai-grok-pager/src/headless.rs index 2f3e46f..7f54bd4 100644 --- a/crates/codegen/xai-grok-pager/src/headless.rs +++ b/crates/codegen/xai-grok-pager/src/headless.rs @@ -1,15 +1,13 @@ //! Headless single-turn mode (`grok -p "prompt"`). //! -//! Runs the agent in-process via -//! `spawn_grok_shell`, sends the ACP lifecycle (init → auth → session → prompt), -//! streams text to stdout, and exits cleanly via `CancellationToken`. +//! Runs the agent in-process via `spawn_grok_shell`, drives the ACP lifecycle +//! (init, auth, session, prompt), streams to stdout, and exits via `CancellationToken`. use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use anyhow::Result; -use clap::ValueEnum; use tokio_util::sync::CancellationToken; use agent_client_protocol as acp; @@ -28,143 +26,32 @@ use xai_grok_shell::util::config as cli_config; use crate::acp::model_state::{EffortTokenError, ModelState}; use crate::acp::spawn::{AgentShutdownGuard, spawn_grok_shell}; use crate::client_identity::{HEADLESS_CLIENT_TYPE, PAGER_CLIENT_VERSION}; +use crate::headless::reducer::{ + Lifecycle, McpServer, Reducer, SessionContext, StreamEvent, TurnEnd, map_session_update, + reducer_for, +}; -// ── Types ──────────────────────────────────────────────────────────────── +mod ext_protocol; +mod reducer; +use ext_protocol::{ExtEvent, handle_ext_notification}; -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] -pub enum OutputFormat { - #[default] - Plain, - Json, - #[value(name = "streaming-json")] - StreamingJson, -} - -pub fn parse_json_schema(input: &str) -> anyhow::Result { - let schema: serde_json::Value = serde_json::from_str(input) - .map_err(|e| anyhow::anyhow!("--json-schema: invalid JSON: {e}"))?; - if !schema.is_object() { - anyhow::bail!("--json-schema: must be a JSON object describing a JSON Schema"); - } - Ok(schema) -} - -#[derive(Debug, Clone)] -pub enum HeadlessPrompt { - Text(String), - Blocks(Vec), -} - -impl HeadlessPrompt { - /// Build from mutually-exclusive CLI prompt args. `None` = interactive mode. - pub fn from_args( - single: Option<&str>, - prompt_json: Option<&str>, - prompt_file: Option<&Path>, - ) -> anyhow::Result> { - if let Some(text) = single { - Self::from_text(text) - .map(Some) - .map_err(|e| anyhow::anyhow!("--single: {e}")) - } else if let Some(json_str) = prompt_json { - Self::from_json(json_str) - .map(Some) - .map_err(|e| anyhow::anyhow!("--prompt-json: {e}")) - } else if let Some(path) = prompt_file { - Self::from_file(path).map(Some) - } else { - Ok(None) - } - } - - /// `.json` files are parsed as content blocks, everything else as text. - pub fn from_file(path: &Path) -> anyhow::Result { - let content = std::fs::read_to_string(path) - .map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", path.display()))?; - - let context = |e| anyhow::anyhow!("'{}': {e}", path.display()); - if path.extension().and_then(|e| e.to_str()) == Some("json") { - Self::from_json(&content).map_err(context) - } else { - Self::from_text(&content).map_err(context) - } - } - - fn from_text(text: &str) -> anyhow::Result { - let trimmed = text.trim(); - if trimmed.is_empty() { - anyhow::bail!("prompt is empty"); - } - Ok(Self::Text(trimmed.to_string())) - } - - fn from_json(json_str: &str) -> anyhow::Result { - let blocks = parse_prompt_json(json_str)?; - Ok(Self::Blocks(blocks)) - } - - pub fn into_content_blocks(self) -> Vec { - match self { - Self::Text(text) => vec![acp::ContentBlock::Text(acp::TextContent::new(text))], - Self::Blocks(blocks) => blocks, - } - } -} - -/// Parse a JSON string into ACP content blocks. -/// -/// Accepts an array (`[...]`) or typed wrapper (`{"type":"acp","content":[...]}`). -fn parse_prompt_json(json_str: &str) -> anyhow::Result> { - let value: serde_json::Value = - serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?; - - let blocks: Vec = match value { - serde_json::Value::Array(_) => serde_json::from_value(value) - .map_err(|e| anyhow::anyhow!("Invalid ACP content blocks: {e}"))?, - - serde_json::Value::Object(ref map) => { - let format_type = map.get("type").and_then(|v| v.as_str()).ok_or_else(|| { - anyhow::anyhow!( - "JSON object must have a \"type\" field \ - (e.g., {{\"type\": \"acp\", \"content\": [...]}})" - ) - })?; - let content = map - .get("content") - .ok_or_else(|| anyhow::anyhow!("JSON object must have a \"content\" field"))?; - - match format_type { - "acp" => serde_json::from_value(content.clone()).map_err(|e| { - anyhow::anyhow!("Invalid ACP content blocks in \"content\": {e}") - })?, - other => anyhow::bail!( - "Unsupported prompt format type: \"{other}\". Supported types: \"acp\"" - ), - } - } - - _ => { - anyhow::bail!("Expected JSON array or {{\"type\": \"...\", \"content\": [...]}} object") - } - }; - - if blocks.is_empty() { - anyhow::bail!("content blocks array is empty"); - } - Ok(blocks) -} +mod cli; +pub use cli::{HeadlessPrompt, OutputFormat, parse_json_schema, parse_permission_rules_lenient}; +pub(crate) use cli::{ResolvedAgent, resolve_agent_arg}; +use cli::{apply_agent_flag, parse_cli_agents, parse_comma_list, parse_permission_rules_strict}; #[derive(Debug, Clone)] pub struct HeadlessOptions { pub session_id: Option, pub resume: Option, - /// The composition root pinned (or definitively missed) `resume` before - /// the OS sandbox; materialization must not re-run local title selection. + /// Resume was pinned pre-sandbox; materialization must not re-run title selection. pub resume_title_pinned: bool, pub cwd: Option, pub yolo: bool, pub trust: bool, pub output_format: OutputFormat, + /// Emit `stream_event` deltas for `streaming-messages-json`. + pub include_partial_messages: bool, pub json_schema: Option, pub model: Option, pub rules: Option, @@ -185,149 +72,29 @@ pub struct HeadlessOptions { pub permission_mode_flag: Option, /// Effort token (`--reasoning-effort` / `--effort`); resolved like `/effort` after models load. pub reasoning_effort: Option, - /// Wait for background tasks (bash, subagent, monitor) to report - /// `task_completed` before exiting. Default: true. Does not wait for - /// server-side auto-wake (that runs inside the shell). Use - /// `--no-wait-for-background` for fast smoke tests; long-lived monitors - /// are capped by `background_wait_timeout`. + /// Wait for background tasks to report `task_completed` before exiting (default true). pub wait_for_background: bool, /// Max time to wait for background quiescence after the first turn ends. pub background_wait_timeout: Duration, } -// ── CLI flag helpers ───────────────────────────────────────────────────── - -/// Parse a comma-separated list into a vec, or None if empty. -fn parse_comma_list(s: Option<&str>) -> Option> { - s.and_then(|s| { - let v: Vec = s - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - if v.is_empty() { None } else { Some(v) } - }) -} - -pub fn parse_permission_rules_strict( - allow: &[String], - deny: &[String], -) -> anyhow::Result> { - let (rules, errors) = parse_permission_rules_inner(allow, deny); - if !errors.is_empty() { - let msgs: Vec = errors - .into_iter() - .map(|(flag, rule, err)| format!("{flag} \"{rule}\": {err}")) - .collect(); - anyhow::bail!("{}", msgs.join("; ")); - } - Ok(rules) -} - -pub fn parse_permission_rules_lenient( - allow: &[String], - deny: &[String], -) -> Vec { - let (rules, errors) = parse_permission_rules_inner(allow, deny); - for (flag, rule, err) in errors { - eprintln!("warning: {flag} \"{rule}\": {err}, skipping"); - } - rules -} - -// Deny rules are processed before allow rules so that after prepending -// to the config's rule list the order is [cli_deny, cli_allow, config_rules...]. -// The policy evaluator is order-independent (deny > ask > allow), so this -// ordering is cosmetic for logging/provenance, not functional. -pub(crate) fn parse_permission_rules_inner( - allow: &[String], - deny: &[String], -) -> ( - Vec, - Vec<(&'static str, String, String)>, -) { - use xai_grok_workspace::permission::rules::parse_permission_rule; - use xai_grok_workspace::permission::types::RuleAction; - - let mut rules = Vec::new(); - let mut errors = Vec::new(); - for rule_str in deny { - match parse_permission_rule(rule_str, RuleAction::Deny) { - Ok(rule) => rules.push(rule), - Err(e) => errors.push(("--deny", rule_str.clone(), e.to_string())), - } - } - for rule_str in allow { - match parse_permission_rule(rule_str, RuleAction::Allow) { - Ok(rule) => rules.push(rule), - Err(e) => errors.push(("--allow", rule_str.clone(), e.to_string())), - } - } - (rules, errors) -} - -pub(crate) enum ResolvedAgent { - FilePath(PathBuf), - Name(String), -} - -pub(crate) fn resolve_agent_arg(agent: &str) -> ResolvedAgent { - let path = std::path::Path::new(agent); - if path.exists() && path.is_file() { - ResolvedAgent::FilePath(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())) - } else { - ResolvedAgent::Name(agent.to_string()) - } -} - -fn parse_cli_agents( - json: &str, -) -> anyhow::Result> { - let map: std::collections::HashMap = - serde_json::from_str(json).map_err(|e| anyhow::anyhow!("--agents: invalid JSON: {e}"))?; - let mut agents = Vec::with_capacity(map.len()); - for (name, mut value) in map { - if let serde_json::Value::Object(ref mut obj) = value { - // Accept "prompt" as an alias for "promptBody". - if !obj.contains_key("promptBody") - && let Some(prompt) = obj.remove("prompt") - { - obj.insert("promptBody".to_string(), prompt); - } - obj.entry("name".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - obj.entry("description".to_string()) - .or_insert_with(|| serde_json::Value::String(name.clone())); - } - let mut def = xai_grok_shell::agent::config::AgentDefinition::from_json(&value) - .map_err(|e| anyhow::anyhow!("--agents: failed to parse '{name}': {e}"))?; - def.name = name; - agents.push(def); - } - Ok(agents) -} - -fn apply_agent_flag(agent: &Option, config: &mut xai_grok_shell::agent::config::Config) { - if let Some(agent) = agent { - match resolve_agent_arg(agent) { - ResolvedAgent::FilePath(path) => config.agent_profile_path = Some(path), - ResolvedAgent::Name(name) => config.agent.name = Some(name), - } - } -} - -// ── Emitter ────────────────────────────────────────────────────────────── - struct HeadlessEmitter { format: OutputFormat, parse_structured_output: bool, text_buffer: String, thought_buffer: String, - /// Agent's schema-validated output (both backends), read from the - /// prompt-response `_meta`. + /// Schema-validated output read from the prompt-response `_meta`. structured_output: Option>, - /// From `_meta.usage`, projected onto the final result when present. usage: Option, + /// Reducer for the streaming formats; `None` for `plain`/`json`. + reducer: Option>, + /// Set when the prompt is sent; the terminal `result.duration_ms` wall-clock. + prompt_started: Option, + out: std::io::Stdout, + /// Latched once stdout is unwritable so later writes are dropped instead of panicking. + output_closed: bool, + /// First hard stdout IO error (not a broken pipe), surfaced so the process exits non-zero. + write_error: Option, } impl HeadlessEmitter { @@ -339,13 +106,119 @@ impl HeadlessEmitter { thought_buffer: String::new(), structured_output: None, usage: None, + reducer: reducer_for(format), + prompt_started: None, + out: std::io::stdout(), + output_closed: false, + write_error: None, } } - /// Read structured output from the prompt-response `_meta` — the same - /// object headless awaits for `sessionId`/`requestId`, so delivery is - /// deterministic (no side-channel race). `structuredOutput` carries the - /// value, `structuredOutputError` the failure; absence leaves `None`. + /// Checked write to stdout: broken pipe latches a clean stop, any other error is latched and returned. + fn write_out(&mut self, bytes: &[u8], flush: bool) -> std::io::Result<()> { + if self.output_closed { + return Ok(()); + } + use std::io::Write as _; + let result = { + let mut handle = self.out.lock(); + handle + .write_all(bytes) + .and_then(|()| if flush { handle.flush() } else { Ok(()) }) + }; + self.record_write_result(result) + } + + /// Fold a write result into the latches: broken pipe is a clean stop, any other error is surfaced. + fn record_write_result(&mut self, result: std::io::Result<()>) -> std::io::Result<()> { + let Err(e) = result else { + return Ok(()); + }; + self.output_closed = true; + if e.kind() == std::io::ErrorKind::BrokenPipe { + tracing::debug!("headless: stdout closed (broken pipe); halting output"); + return Ok(()); + } + tracing::error!(error = %e, "headless: stdout write failed; halting output"); + if self.write_error.is_none() { + self.write_error = Some(std::io::Error::new(e.kind(), e.to_string())); + } + Err(e) + } + + /// Take the latched hard stdout error, if any. + fn take_output_error(&mut self) -> Option { + self.write_error.take() + } + + /// Emit one compact NDJSON wire line plus newline. + fn emit_line(&mut self, line: &serde_json::Value) { + let mut buf = line.to_string(); + buf.push('\n'); + let _ = self.write_out(buf.as_bytes(), false); + } + + /// Mark the wall-clock start of the run for `result.duration_ms`. + fn mark_prompt_started(&mut self) { + self.prompt_started = Some(Instant::now()); + } + + fn duration_ms(&self) -> u64 { + self.prompt_started + .map_or(0, |t| t.elapsed().as_millis() as u64) + } + + /// Emit the reducer preamble once the session context is known. + fn begin_session(&mut self, ctx: SessionContext) { + let Some(reducer) = self.reducer.as_mut() else { + return; + }; + let lines = reducer.begin(ctx); + self.emit_lines(lines); + } + + /// Emit a batch of NDJSON wire lines produced by the reducer. + fn emit_lines(&mut self, lines: Vec) { + for line in lines { + self.emit_line(&line); + } + } + + /// Render an `x.ai/*` lifecycle notification for the active format. + fn on_lifecycle(&mut self, event: Lifecycle) { + match self.format { + OutputFormat::Plain => { + eprintln!("{}", event.plain_message()); + } + OutputFormat::Json => {} + OutputFormat::StreamingJson | OutputFormat::StreamingMessagesJson => { + self.reduce_and_emit(StreamEvent::Lifecycle(event)); + } + } + } + + /// Fold one event through the reducer and emit its lines; a no-op for `plain`/`json`. + fn reduce_and_emit(&mut self, event: StreamEvent) { + let Some(reducer) = self.reducer.as_mut() else { + return; + }; + let lines = reducer.reduce(event); + self.emit_lines(lines); + } + + /// Schema output for a terminal line: `Ok`, `Err`, or `None` when not requested. + fn resolved_structured_output(&self) -> Option> { + if !self.parse_structured_output { + return None; + } + Some( + self.structured_output + .clone() + .unwrap_or_else(|| Err("model did not produce structured output".to_string())), + ) + } + + /// Read structured output (or its error) from the prompt-response `_meta`. fn set_structured_output_from_meta(&mut self, meta: Option<&acp::Meta>) { if !self.parse_structured_output { return; @@ -366,31 +239,30 @@ impl HeadlessEmitter { fn on_text_chunk(&mut self, text: &str) { match self.format { OutputFormat::Plain => { - use std::io::Write as _; - print!("{text}"); - let _ = std::io::stdout().flush(); - } - OutputFormat::StreamingJson => { - println!("{}", serde_json::json!({"type":"text","data": text})); - if self.parse_structured_output { - self.text_buffer.push_str(text); - } + let _ = self.write_out(text.as_bytes(), true); } OutputFormat::Json => { self.text_buffer.push_str(text); } + OutputFormat::StreamingMessagesJson => { + self.text_buffer.push_str(text); + self.reduce_and_emit(StreamEvent::AgentMessage(text.to_string())); + } + OutputFormat::StreamingJson => { + self.reduce_and_emit(StreamEvent::AgentMessage(text.to_string())); + } } } fn on_thought_chunk(&mut self, text: &str) { match self.format { OutputFormat::Plain => { /* no-op */ } - OutputFormat::StreamingJson => { - println!("{}", serde_json::json!({"type":"thought","data": text})); - } OutputFormat::Json => { self.thought_buffer.push_str(text); } + OutputFormat::StreamingJson | OutputFormat::StreamingMessagesJson => { + self.reduce_and_emit(StreamEvent::AgentThought(text.to_string())); + } } } @@ -398,22 +270,12 @@ impl HeadlessEmitter { if !self.parse_structured_output { return; } - // The agent is the only source of validated output; never parse the raw - // text buffer (that would bypass validation). Absent `_meta` output - // (max-turns/cancel) → a clean error, never unvalidated JSON. + // Only the agent's validated `_meta` output is trusted; never parse the raw text buffer. let result = self .structured_output .clone() .unwrap_or_else(|| Err("model did not produce structured output".to_string())); - match result { - Ok(value) => { - target["structuredOutput"] = value; - } - Err(e) => { - target["structuredOutput"] = serde_json::Value::Null; - target["structuredOutputError"] = e.into(); - } - } + crate::headless::reducer::attach_structured_output(target, Some(result)); } /// Final object for `--output-format json`, including spend fields when present. @@ -442,50 +304,123 @@ impl HeadlessEmitter { fn on_end(&mut self, stop_reason: &str, session_id: &str, request_id: &str) { match self.format { OutputFormat::Plain => { - println!(); - } - OutputFormat::StreamingJson => { - let mut end = serde_json::json!({ - "type": "end", - "stopReason": stop_reason, - "sessionId": session_id, - "requestId": request_id - }); - if let Some(usage) = &self.usage { - attach_result_usage(&mut end, usage); - } - self.attach_structured_output(&mut end); - println!("{end}"); + let _ = self.write_out(b"\n", false); } OutputFormat::Json => { let result = self.build_json_result(stop_reason, session_id, request_id); - println!( - "{}", - serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()) - ); + let mut rendered = + serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()); + rendered.push('\n'); + let _ = self.write_out(rendered.as_bytes(), false); + } + OutputFormat::StreamingJson | OutputFormat::StreamingMessagesJson => { + let usage = self.usage.clone(); + let structured_output = self.resolved_structured_output(); + let result_text = self.text_buffer.clone(); + let duration_ms = self.duration_ms(); + let lines = self.reducer.as_mut().map(|reducer| { + let end = TurnEnd { + stop_reason, + session_id, + request_id, + usage: usage.as_ref(), + structured_output, + result_text: result_text.as_str(), + duration_ms, + }; + reducer.finish(&end) + }); + if let Some(lines) = lines { + self.emit_lines(lines); + } } } } - fn on_error(&self, message: &str) { + /// Emit the max turns marker for the active format. + fn on_max_turns(&mut self) { + match self.format { + OutputFormat::Plain => eprintln!("Max turns reached"), + // Conveyed by `stopReason` in the terminal JSON and result. + OutputFormat::Json => {} + OutputFormat::StreamingJson | OutputFormat::StreamingMessagesJson => { + let lines = self.reducer.as_mut().map(|reducer| reducer.max_turns()); + if let Some(lines) = lines { + self.emit_lines(lines); + } + } + } + } + + /// Emit the terminal error; `stop_reason_override` stamps a Messages stop reason (e.g. `max_tokens`). + fn on_error(&mut self, message: &str, stop_reason_override: Option<&str>) { match self.format { OutputFormat::Plain => eprintln!("{message}"), - OutputFormat::StreamingJson | OutputFormat::Json => { + OutputFormat::Json => { let mut err = serde_json::json!({"type":"error","message": message}); if let Some(usage) = &self.usage { attach_result_usage(&mut err, usage); } - println!("{err}"); + self.emit_line(&err); + } + OutputFormat::StreamingJson | OutputFormat::StreamingMessagesJson => { + let usage = self.usage.clone(); + let duration_ms = self.duration_ms(); + let lines = self.reducer.as_mut().map(|reducer| { + reducer.error(message, usage.as_ref(), duration_ms, stop_reason_override) + }); + if let Some(lines) = lines { + self.emit_lines(lines); + } } } } } -fn attach_result_usage(result: &mut serde_json::Value, usage: &serde_json::Value) { +pub(crate) fn attach_result_usage(result: &mut serde_json::Value, usage: &serde_json::Value) { xai_grok_shell::extensions::notification::attach_result_usage_fail_closed(result, usage); } -// ── Helpers ────────────────────────────────────────────────────────────── +/// Snake_case wire token for an ACP stop reason. +fn stop_reason_wire(reason: acp::StopReason) -> String { + match reason { + acp::StopReason::EndTurn => "end_turn", + acp::StopReason::MaxTokens => "max_tokens", + acp::StopReason::MaxTurnRequests => "max_turn_requests", + acp::StopReason::Refusal => "refusal", + acp::StopReason::Cancelled => "cancelled", + // Fail loud on an unknown future variant, then degrade to `end_turn`. + other => { + tracing::warn!( + stop_reason = ?other, + "headless: unknown ACP StopReason; defaulting wire token to end_turn" + ); + "end_turn" + } + } + .to_string() +} + +/// Configured MCP servers for the `init` line; all report `"connected"` (status is not resolved here). +fn mcp_server_names(cwd: &Path) -> Vec { + let servers = + cli_config::load_mcp_servers(cwd, &xai_grok_tools::types::compat::CompatConfig::default()); + servers + .iter() + .filter_map(|s| { + let name = match s { + acp::McpServer::Http(h) => h.name.clone(), + acp::McpServer::Sse(h) => h.name.clone(), + acp::McpServer::Stdio(h) => h.name.clone(), + _ => return None, + }; + Some(McpServer { + name, + status: "connected".to_string(), + }) + }) + .collect() +} fn auto_respond_to_permissions( args: &acp::RequestPermissionRequest, @@ -520,11 +455,8 @@ fn auth_required_message(interactive: bool) -> String { } } -/// Authenticate using the agent's `defaultAuthMethodId` (source of truth for -/// `[auth] preferred_method`). Fail closed when no method is available — do not -/// invent api_key vs session ordering client-side. -/// -/// Returns whether the selected method is API-key auth (for rate-limit copy). +/// Authenticate via the agent's `defaultAuthMethodId`, failing closed when none is available. +/// Returns whether the selected method is API-key auth. async fn authenticate( acp_tx: &AcpAgentTx, auths: &[acp::AuthMethod], @@ -587,6 +519,8 @@ fn build_headless_init_request( struct OpenedSession { session_id: acp::SessionId, models: ModelState, + /// Directory the session is anchored to (launch cwd, resume `original_cwd`, or fork `write_cwd`). + cwd: PathBuf, } async fn open_session( @@ -595,9 +529,7 @@ async fn open_session( session_id_flag: Option<&str>, restore_code: Option, ) -> anyhow::Result { - // Pager opens sessions before the agent resolves per-vendor compat; - // default (all-on) preserves existing behavior — the agent applies - // the resolved config once the session is live. + // Sessions open before the agent resolves per-vendor compat; default all-on until it does. let mcp_servers = cli_config::load_mcp_servers(cwd, &xai_grok_tools::types::compat::CompatConfig::default()); @@ -620,6 +552,7 @@ async fn open_session( return Ok(OpenedSession { session_id: acp::SessionId::new(sid.to_string()), models: ModelState::from(resp.models), + cwd: cwd.to_path_buf(), }); } anyhow::bail!("Session does not exist"); @@ -633,6 +566,7 @@ async fn open_session( Ok(OpenedSession { session_id: new_resp.session_id, models: ModelState::from(new_resp.models), + cwd: cwd.to_path_buf(), }) } @@ -659,6 +593,7 @@ async fn open_session_with_id( Ok(OpenedSession { session_id: new_resp.session_id, models: ModelState::from(new_resp.models), + cwd: cwd.to_path_buf(), }) } @@ -675,8 +610,7 @@ async fn fork_then_open( fork_response_new_session_id, fork_session_params, parent_session_is_worktree, }; let launch_cwd_str = launch_cwd.to_string_lossy().into_owned(); - // Align with interactive: child lands under parent session cwd when the - // parent was resolved from another directory (`newCwd` = parent_cwd). + // Match interactive: child lands under the parent session cwd, not the launch cwd. let new_cwd_str = effective_fork_new_cwd(&launch_cwd_str, parent_cwd); let write_cwd = PathBuf::from(&new_cwd_str); if let Some(nid) = new_id { @@ -684,12 +618,9 @@ async fn fork_then_open( } let parent_is_worktree = parent_session_is_worktree(parent_id, &write_cwd); let payload = fork_session_params(parent_id, &write_cwd, new_id, parent_is_worktree); - let req = acp::ExtRequest::new( - "x.ai/session/fork", - serde_json::value::to_raw_value(&payload) - .expect("serialize fork params") - .into(), - ); + let fork_params = serde_json::value::to_raw_value(&payload) + .map_err(|e| anyhow::anyhow!("serialize fork params: {e}"))?; + let req = acp::ExtRequest::new("x.ai/session/fork", fork_params.into()); let resp = acp_send(req, acp_tx).await?; if let Some(err) = fork_response_error(resp.0.get()) { anyhow::bail!("fork failed: {err}"); @@ -704,14 +635,8 @@ async fn fork_then_open( } } -/// Apply `-m` / effort after session open (via `resolve_effort_for_model`, then -/// SetSessionModel). -/// -/// Headless maps the classified [`EffortTokenError`] differently from the TUI: a -/// one-shot run soft-ignores effort on a non-supporting model (still applying -/// `-m`) but hard-fails on a genuinely unknown token. The TUI instead keeps the -/// `-m` switch and only toasts — intentional, since headless has no scrollback -/// to carry a non-fatal warning. +/// Apply `-m` / effort after session open. Effort is soft-ignored on a non-supporting +/// model (still applying `-m`) but hard-fails on a genuinely unknown token. async fn apply_headless_model_and_effort( acp_tx: &AcpAgentTx, session_id: &acp::SessionId, @@ -735,13 +660,9 @@ async fn apply_headless_model_and_effort( let effort = match effort_token { None => None, - // Pre-catalog: the canonical token was already stamped into the agent - // config; a remapped menu id can't resolve without a loaded catalog. + // Pre-catalog: canonical tokens are already stamped; remapped menu ids can't resolve yet. Some(token) if models.available.is_empty() => { if parse_canonical_effort_token(token).is_none() { - // Do not hardcode a level list here: without a catalog we cannot - // know what the model offers, and advertising none/minimal/… has - // led users to try values that then 400 on the API. anyhow::bail!( "--effort/--reasoning-effort: unknown effort level '{token}' \ (model catalog unavailable; remapped menu ids require a loaded catalog)" @@ -751,7 +672,6 @@ async fn apply_headless_model_and_effort( } Some(token) => match models.resolve_effort_for_model(&model_id, token) { Ok(effort) => Some(effort), - // Soft-ignore effort on a non-supporting model; still apply `-m`. Err(EffortTokenError::Unsupported) => { tracing::warn!( model = %model_id.0, @@ -764,8 +684,6 @@ async fn apply_headless_model_and_effort( }, }; - // Nothing to apply (effort pre-stamped or ignored, and no model override): - // skip the no-op SetSessionModel. if model_name.is_none() && effort.is_none() { return Ok(()); } @@ -803,11 +721,7 @@ async fn apply_headless_model_and_effort( Ok(()) } -// ── Main entry point ───────────────────────────────────────────────────── - -/// Startup-materialization context for headless (`-p`) runs. Never chat: -/// `HeadlessOptions` carries no chat flag, so headless resume targets are -/// always disk/GCS Build sessions. +/// Startup-materialization context for headless (`-p`) runs; never chat mode. fn headless_materialize_ctx( has_worktree: bool, resume_title_pinned: bool, @@ -825,17 +739,13 @@ fn headless_materialize_ctx( } } -/// Run a headless single-turn prompt. -/// -/// Spawns the agent in-process, runs the full ACP lifecycle (init → auth → -/// session → prompt), streams output to stdout, and returns cleanly. +/// Run a headless single-turn prompt: spawn the agent, drive the ACP lifecycle, stream to stdout. pub async fn run_single_turn( prompt: HeadlessPrompt, verbatim: bool, options: HeadlessOptions, ) -> Result<()> { - // Stamp proxy requests as headless before the agent spawns and issues - // its first request (auth enrichment, model list, etc.). + // Stamp proxy requests as headless before the agent issues its first request. xai_grok_shell::http::set_process_client_mode_headless(); let cwd = match options.cwd { @@ -845,7 +755,14 @@ pub async fn run_single_turn( let mut emitter = HeadlessEmitter::new(options.output_format, options.json_schema.is_some()); - // Load config and spawn agent + if options.include_partial_messages + && options.output_format != OutputFormat::StreamingMessagesJson + { + eprintln!( + "warning: --include-partial-messages only affects --output-format streaming-messages-json; ignoring it" + ); + } + let t_spawn = Instant::now(); let raw_config = xai_grok_shell::config::load_effective_config() .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; @@ -858,7 +775,7 @@ pub async fn run_single_turn( { agent_config.reasoning_effort_override = Some(effort); } - // So initial system prompt / `system_prompt_label` use `-m`, not a later SetSessionModel. + // Stamp `-m` early so the initial system prompt uses it, not a later SetSessionModel. if let Some(ref model) = options.model { agent_config.default_model_override = Some(model.clone()); } @@ -880,18 +797,12 @@ pub async fn run_single_turn( agent_config.mode = xai_grok_shell::agent::config::AgentMode::Headless; agent_config.default_yolo_mode = options.yolo; - // Remote arg is None: the remote settings permission_mode soft-default is - // TUI-only; headless runs must not change permission behavior on a - // remote flag flip. agent_config.default_auto_mode = xai_grok_shell::util::config::effective_auto_for_launch( options.yolo, options.permission_mode_flag.as_deref(), None, ); - // No agent-level hub client URL (gateway-only cloud; workspace provider - // hub_url lives on `grok workspace` / WorkspaceStartArgs only). - apply_agent_flag(&options.agent, &mut agent_config); if let Some(ref json) = options.agents_json { @@ -913,7 +824,6 @@ pub async fn run_single_turn( .transpose()?, }; - // Persist an explicit --trust grant before the agent starts. if options.trust { xai_grok_shell::agent::folder_trust::grant_folder_trust(&cwd); } @@ -924,11 +834,10 @@ pub async fn run_single_turn( Ok(s) => s, Err(e) => { let msg = format!("Couldn't start session: {e}"); - emitter.on_error(&msg); + emitter.on_error(&msg, None); anyhow::bail!("{msg}"); } }; - // Cancel + join on every return path (success or bail). let _agent_guard = AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle)); let (acp_tx, mut acp_rx) = (spawned.channel.tx, spawned.channel.rx); crate::unified_log::init(acp_tx.clone()); @@ -939,7 +848,6 @@ pub async fn run_single_turn( ); crate::unified_log::flush(); - // Initialize with headless hints let init_req = build_headless_init_request( options.rules.as_deref(), options.system_prompt_override.as_deref(), @@ -948,7 +856,7 @@ pub async fn run_single_turn( Ok(r) => r, Err(e) => { let msg = format!("Couldn't initialize: {e}"); - emitter.on_error(&msg); + emitter.on_error(&msg, None); anyhow::bail!("{msg}"); } }; @@ -957,7 +865,6 @@ pub async fn run_single_turn( "headless: spawn + initialize complete" ); - // Authenticate using agent defaultAuthMethodId (preferred_method pin). let t_auth = Instant::now(); let default_auth_method_id = crate::acp::parse_default_auth_method_id(init_resp.meta.as_ref()); let is_api_key_auth = match authenticate( @@ -969,7 +876,7 @@ pub async fn run_single_turn( { Ok(is_api_key) => is_api_key, Err(e) => { - emitter.on_error(&e.to_string()); + emitter.on_error(&e.to_string(), None); return Err(e); } }; @@ -978,7 +885,6 @@ pub async fn run_single_turn( "headless: authenticate complete" ); - // Same intent + materialize path as interactive (shared SSOT). use crate::app::session_startup::{self, MaterializedStartup, SessionStartupFlags}; let has_resume_id = options.resume.as_deref().filter(|s| !s.is_empty()); let resume_most_recent = options.resume.as_deref() == Some(""); @@ -1000,7 +906,6 @@ pub async fn run_single_turn( ) .await?; - // Open session let restore_code = options.restore_code.then_some(true); let t_session = Instant::now(); let opened = match materialized { @@ -1036,11 +941,12 @@ pub async fn run_single_turn( let OpenedSession { session_id, models: session_models, + cwd: session_cwd, } = match opened { Ok(v) => v, Err(e) => { let msg = format!("Couldn't create session: {e}"); - emitter.on_error(&msg); + emitter.on_error(&msg, None); anyhow::bail!("{msg}"); } }; @@ -1050,7 +956,6 @@ pub async fn run_single_turn( "headless: open_session complete" ); - // Debug: track headless sessions in active_sessions.json when env var is set. let track_active = std::env::var("GROK_TRACK_HEADLESS").is_ok(); if track_active { let _ = xai_grok_shell::active_sessions::register( @@ -1063,6 +968,28 @@ pub async fn run_single_turn( ); } + // Seed the reducer's session context BEFORE applying model/effort so a later failure carries it. + { + let model = options + .model + .clone() + .or_else(|| session_models.current_model_id_str().map(str::to_string)); + let permission_mode = options + .permission_mode_flag + .clone() + .or_else(|| options.yolo.then(|| "bypassPermissions".to_string())); + emitter.begin_session(SessionContext { + session_id: session_id.0.to_string(), + model, + cwd: session_cwd.to_string_lossy().to_string(), + permission_mode, + mcp_servers: mcp_server_names(&session_cwd), + include_partial_messages: options.include_partial_messages, + api_key_auth: is_api_key_auth, + context_window: session_models.get_context_window(), + }); + } + if let Err(e) = apply_headless_model_and_effort( &acp_tx, &session_id, @@ -1073,11 +1000,10 @@ pub async fn run_single_turn( .await { let msg = e.to_string(); - emitter.on_error(&msg); + emitter.on_error(&msg, None); anyhow::bail!("{msg}"); } - // Send prompt and stream response let prompt_blocks = prompt.into_content_blocks(); let prompt_meta = { @@ -1088,8 +1014,6 @@ pub async fn run_single_turn( if let Some(ref schema) = options.json_schema { meta.insert("outputSchema".to_string(), schema.clone()); } - // Screen-mode telemetry (`prompt_submitted.screen_mode`): headless is - // its own mode, distinct from the TUI's fullscreen/inline/minimal. meta.insert( "screenMode".to_string(), serde_json::Value::String("headless".to_string()), @@ -1099,46 +1023,40 @@ pub async fn run_single_turn( let request = acp::PromptRequest::new(session_id.clone(), prompt_blocks).meta(prompt_meta); let t_prompt = Instant::now(); + emitter.mark_prompt_started(); let mut ttf_logged = false; let mut prompt_fut = Box::pin(acp_send(request, &acp_tx)); let mut prompt_result = None; - // Pending background work: bash/monitor via x.ai/task_backgrounded + - // task_completed; background subagents via SubagentSpawned + SubagentFinished - // on x.ai/session_notification (prefixed `subagent:{id}` in pending_bg). - // Tracked regardless of wait_for_background so the exit reaper always - // sees still-running work; the flag only gates waiting. - // No idle/quiet polling and no wait for server-side auto-wake text — exit - // when lifecycle sets are empty. Auto-wake may still be in flight at exit. - let mut pending_bg: HashSet = HashSet::new(); - // task_completed can arrive before task_backgrounded; remember those IDs - // so a late backgrounded does not re-arm waiting. - let mut completed_before_bg: HashSet = HashSet::new(); + // Tracked regardless of wait_for_background so the exit reaper always sees running work. + let mut pending_bg: HashSet = HashSet::new(); + // Tombstone of completed ids so an out-of-order backgrounded never re-arms them. + let mut completed_bg: HashSet = HashSet::new(); let mut prompt_done_at: Option = None; + // On mid-turn channel close, break (not bail) so the exit path still drains and reaps. + let mut connection_closed = false; loop { - // First turn done and no tracked bg/monitor tasks still running. - // Drain buffered ACP first: PromptResponse can complete while - // task_backgrounded is still queued on acp_rx (never reached select!). + if emitter.write_error.is_some() { + tracing::warn!("headless: stdout write failed; stopping the stream loop"); + break; + } + // Drain buffered ACP first: PromptResponse can complete while task_backgrounded is still queued. if options.wait_for_background && prompt_result.is_some() && pending_bg.is_empty() { - while let Ok(msg) = acp_rx.try_recv() { - handle_headless_acp_message( - msg.boxed(), - &mut emitter, - t_prompt, - &mut ttf_logged, - options.yolo, - options.output_format, - &mut pending_bg, - &mut completed_before_bg, - ); - } + drain_pending_acp_messages( + &mut acp_rx, + &mut emitter, + t_prompt, + &mut ttf_logged, + options.yolo, + &mut pending_bg, + &mut completed_bg, + ); if pending_bg.is_empty() { tracing::debug!("headless: no pending background tasks, exiting"); break; } } - // Safety valve so evals don't hang on long-lived monitors or stuck tasks. if options.wait_for_background && let Some(done_at) = prompt_done_at && done_at.elapsed() >= options.background_wait_timeout @@ -1151,8 +1069,6 @@ pub async fn run_single_turn( break; } - // Only needed while waiting on tasks (timeout enforcement); otherwise - // the loop blocks on ACP until task_completed or PromptResponse. let timeout_deadline = if options.wait_for_background && prompt_result.is_some() && !pending_bg.is_empty() @@ -1174,8 +1090,9 @@ pub async fn run_single_turn( biased; msg = acp_rx.recv() => { let Some(msg) = msg else { - emitter.on_error("Connection closed unexpectedly"); - anyhow::bail!("Connection closed unexpectedly"); + emitter.on_error("Connection closed unexpectedly", None); + connection_closed = true; + break; }; handle_headless_acp_message( msg.boxed(), @@ -1183,9 +1100,8 @@ pub async fn run_single_turn( t_prompt, &mut ttf_logged, options.yolo, - options.output_format, &mut pending_bg, - &mut completed_before_bg, + &mut completed_bg, ); } res = &mut prompt_fut, if prompt_result.is_none() => { @@ -1199,41 +1115,43 @@ pub async fn run_single_turn( t_prompt, &mut ttf_logged, options.yolo, - options.output_format, &mut pending_bg, - &mut completed_before_bg, + &mut completed_bg, ) .await; break; } - // With wait_for_background: keep draining ACP for task_completed. + // Drain now so a task_backgrounded around completion is recorded before the empty-check. + drain_pending_acp_messages( + &mut acp_rx, + &mut emitter, + t_prompt, + &mut ttf_logged, + options.yolo, + &mut pending_bg, + &mut completed_bg, + ); } _ = tokio::time::sleep(timeout_deadline), if options.wait_for_background && prompt_result.is_some() && !pending_bg.is_empty() => { - // Wake to re-check background_wait_timeout at the top of the loop. + // Wake to re-check the timeout at the top of the loop. } } } - // Track lifecycle notifications still queued at loop exit so the reaper - // sees them (the timeout path breaks without draining). - while let Ok(msg) = acp_rx.try_recv() { - handle_headless_acp_message( - msg.boxed(), - &mut emitter, - t_prompt, - &mut ttf_logged, - options.yolo, - options.output_format, - &mut pending_bg, - &mut completed_before_bg, - ); - } + // Final drain-to-empty so the reaper sees work buffered right at exit (the timeout path skips draining). + drain_pending_acp_messages( + &mut acp_rx, + &mut emitter, + t_prompt, + &mut ttf_logged, + options.yolo, + &mut pending_bg, + &mut completed_bg, + ); - // Kill background tasks/subagents still pending at exit (background-wait - // timeout or --no-wait-for-background) so they don't outlive the process. if !pending_bg.is_empty() { tracing::warn!( pending_bg = pending_bg.len(), @@ -1242,32 +1160,44 @@ pub async fn run_single_turn( reap_pending_background_tasks(&pending_bg, &session_id, &acp_tx).await; } - // Flush buffered unified log entries before exit. crate::unified_log::flush_blocking().await; - // Handle result if track_active { // Non-blocking flock so a slow/network ~/.grok can't hang exit. let _ = xai_grok_shell::active_sessions::try_unregister(&session_id); } - // Agent cancel + join (SessionEnd flush) runs in AgentShutdownGuard::drop. - match prompt_result { + // A mid-turn ACP close already reaped above; return that error before the normal outcome. + if connection_closed { + anyhow::bail!("Connection closed unexpectedly"); + } + let outcome: Result<()> = match prompt_result { Some(Ok(resp)) => { - let stop_reason = format!("{:?}", resp.stop_reason); + let stop_reason = stop_reason_wire(resp.stop_reason); emitter.set_structured_output_from_meta(resp.meta.as_ref()); emitter.set_usage_from_meta(resp.meta.as_ref()); + // Prefer the response `_meta` ids, falling back to the typed session id rather than "". let sid = resp .meta .as_ref() .and_then(|m| m.get("sessionId")) .and_then(|v| v.as_str()) - .unwrap_or_default(); - let rid = resp + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| session_id.0.as_ref()); + let rid = match resp .meta .as_ref() .and_then(|m| m.get("requestId")) .and_then(|v| v.as_str()) - .unwrap_or_default(); + .filter(|s| !s.is_empty()) + { + Some(r) => r, + None => { + tracing::warn!( + "headless: prompt response carried no requestId; emitting an empty requestId" + ); + "" + } + }; let is_max_turns = resp .meta .as_ref() @@ -1275,18 +1205,13 @@ pub async fn run_single_turn( .and_then(|v| v.as_str()) == Some("max_turns_reached"); if is_max_turns { - match emitter.format { - OutputFormat::Plain => eprintln!("Max turns reached"), - OutputFormat::StreamingJson => { - println!("{}", serde_json::json!({"type": "max_turns_reached"})) - } - OutputFormat::Json => {} // conveyed by stopReason in the final JSON - } + emitter.on_max_turns(); emitter.on_end(&stop_reason, sid, rid); - anyhow::bail!("max turns reached"); + Err(anyhow::anyhow!("max turns reached")) + } else { + emitter.on_end(&stop_reason, sid, rid); + Ok(()) } - emitter.on_end(&stop_reason, sid, rid); - Ok(()) } Some(Err(err)) => { let msg = if i32::from(err.code) == RATE_LIMITED_ERROR_CODE { @@ -1298,91 +1223,111 @@ pub async fn run_single_turn( } else { err.to_string() }; - if let Some(usage) = xai_grok_shell::sampling::error::prompt_usage_from_error(&err) - && let Ok(v) = serde_json::to_value(&usage) - { - emitter.usage = Some(v); + if let Some(usage) = xai_grok_shell::sampling::error::prompt_usage_from_error(&err) { + match serde_json::to_value(&usage) { + Ok(v) => emitter.usage = Some(v), + // Log rather than swallow: a serialize failure would drop the frozen spend fields. + Err(e) => tracing::warn!( + error = %e, + "headless: failed to serialize prompt-error usage; spend fields dropped" + ), + } } - emitter.on_error(&msg); - anyhow::bail!("{msg}") + let stop_reason_override = + (xai_grok_shell::sampling::error::stop_reason_for_turn_error(&err) == "MaxTokens") + .then_some("max_tokens"); + emitter.on_error(&msg, stop_reason_override); + Err(anyhow::anyhow!("{msg}")) } None => Ok(()), + }; + + // A hard stdout write error outranks the normal outcome: output is dead, so exit non-zero. + if let Some(err) = emitter.take_output_error() { + return Err(anyhow::Error::new(err).context("headless: stdout write failed")); } + outcome } -/// Ext request that kills pending background work `key` (a `pending_bg` -/// entry): `subagent:{id}` cancels the subagent, anything else kills the -/// bash/monitor task with that id. -fn reap_request_for_key( - key: &str, +/// Background work tracked for exit: bash/monitor tasks and background subagents, keyed by id. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum BackgroundWork { + Task(String), + Subagent(String), +} + +/// Ext request that kills one unit of background work (subagent cancel or task kill). +fn reap_request_for_work( + work: &BackgroundWork, session_id: &acp::SessionId, ) -> serde_json::Result { - let (method, params) = match key.strip_prefix("subagent:") { - Some(id) => ( + let (method, params) = match work { + BackgroundWork::Subagent(id) => ( "x.ai/subagent/cancel", serde_json::value::to_raw_value(&CancelSubagentRequest { - subagent_id: id.to_string(), + subagent_id: id.clone(), })?, ), - None => ( + BackgroundWork::Task(id) => ( "x.ai/task/kill", serde_json::value::to_raw_value(&KillTaskRequest { session_id: session_id.0.to_string(), - task_id: key.to_string(), + task_id: id.clone(), })?, ), }; Ok(acp::ExtRequest::new(method, params.into())) } -/// Best-effort kill of background work still pending when headless exits -/// (background-wait timeout or `--no-wait-for-background`) so model-spawned -/// processes never outlive the process. Failures are logged, never fatal. +/// Best-effort kill of background work still pending at exit so it never outlives the process. async fn reap_pending_background_tasks( - pending_bg: &HashSet, + pending_bg: &HashSet, session_id: &acp::SessionId, acp_tx: &AcpAgentTx, ) { - for key in pending_bg { - let request = match reap_request_for_key(key, session_id) { + for work in pending_bg { + let request = match reap_request_for_work(work, session_id) { Ok(request) => request, Err(e) => { - tracing::warn!(key = %key, error = %e, "headless: failed to build reap request"); + tracing::warn!(?work, error = %e, "headless: failed to build reap request"); continue; } }; let method = request.method.clone(); match tokio::time::timeout(Duration::from_secs(10), acp_send(request, acp_tx)).await { Ok(Ok(_)) => { - tracing::debug!(key = %key, %method, "headless: reaped pending background work") + tracing::debug!(?work, %method, "headless: reaped pending background work") } Ok(Err(e)) => { - tracing::warn!(key = %key, %method, error = %e, "headless: failed to reap background work") + tracing::warn!(?work, %method, error = %e, "headless: failed to reap background work") } Err(_) => { - tracing::warn!(key = %key, %method, "headless: timed out reaping background work") + tracing::warn!(?work, %method, "headless: timed out reaping background work") } } } } -/// Track a background lifecycle event in the pending set. -/// -/// Tracking is unconditional — independent of `--no-wait-for-background` — so -/// the exit reaper sees everything still running. `wait_for_background` only -/// gates whether the loop waits for this set to drain. +/// Track a background lifecycle event. `completed_bg` tombstones finished ids so a late or +/// out-of-order backgrounded/spawned cannot resurrect them into `pending_bg`. fn track_background_lifecycle( event: ExtEvent, - pending_bg: &mut HashSet, - completed_before_bg: &mut HashSet, + pending_bg: &mut HashSet, + completed_bg: &mut HashSet, ) { match event { ExtEvent::TaskBackgrounded { task_id, is_monitor, } => { - if !completed_before_bg.remove(&task_id) { - pending_bg.insert(task_id); + let work = BackgroundWork::Task(task_id); + if completed_bg.contains(&work) { + tracing::debug!( + is_monitor, + "headless: ignoring task_backgrounded for already-completed task" + ); + } else { + pending_bg.insert(work); tracing::debug!( pending = pending_bg.len(), is_monitor, @@ -1391,19 +1336,24 @@ fn track_background_lifecycle( } } ExtEvent::TaskCompleted { task_id } => { - if pending_bg.remove(&task_id) { + let work = BackgroundWork::Task(task_id); + let was_pending = pending_bg.remove(&work); + completed_bg.insert(work); + if was_pending { tracing::debug!( pending = pending_bg.len(), "headless: background task completed" ); - } else { - completed_before_bg.insert(task_id); } } ExtEvent::SubagentSpawned { subagent_id } => { - let key = format!("subagent:{subagent_id}"); - if !completed_before_bg.remove(&key) { - pending_bg.insert(key); + let work = BackgroundWork::Subagent(subagent_id); + if completed_bg.contains(&work) { + tracing::debug!( + "headless: ignoring subagent_spawned for already-finished subagent" + ); + } else { + pending_bg.insert(work); tracing::debug!( pending = pending_bg.len(), "headless: tracking background subagent" @@ -1411,21 +1361,45 @@ fn track_background_lifecycle( } } ExtEvent::SubagentFinished { subagent_id } => { - let key = format!("subagent:{subagent_id}"); - if pending_bg.remove(&key) { + let work = BackgroundWork::Subagent(subagent_id); + let was_pending = pending_bg.remove(&work); + completed_bg.insert(work); + if was_pending { tracing::debug!( pending = pending_bg.len(), "headless: background subagent finished" ); - } else { - completed_before_bg.insert(key); } } - ExtEvent::MonitorEvent | ExtEvent::None => {} + // Routed to the emitter by the caller, never tracked. + ExtEvent::MonitorEvent | ExtEvent::None | ExtEvent::Lifecycle(_) | ExtEvent::Stream(_) => {} } } -// ── ACP client message handling (select arm + pre-exit drain) ──────────── +/// Non-blocking drain-to-empty of `acp_rx`, so background work buffered around prompt +/// completion is recorded in `pending_bg` before the empty-check decides whether to exit. +#[allow(clippy::too_many_arguments)] +fn drain_pending_acp_messages( + acp_rx: &mut AcpClientRx, + emitter: &mut HeadlessEmitter, + t_prompt: Instant, + ttf_logged: &mut bool, + yolo: bool, + pending_bg: &mut HashSet, + completed_bg: &mut HashSet, +) { + while let Ok(msg) = acp_rx.try_recv() { + handle_headless_acp_message( + msg.boxed(), + emitter, + t_prompt, + ttf_logged, + yolo, + pending_bg, + completed_bg, + ); + } +} #[allow(clippy::too_many_arguments)] async fn drain_acp_with_grace( @@ -1435,9 +1409,8 @@ async fn drain_acp_with_grace( t_prompt: Instant, ttf_logged: &mut bool, yolo: bool, - output_format: OutputFormat, - pending_bg: &mut HashSet, - completed_before_bg: &mut HashSet, + pending_bg: &mut HashSet, + completed_bg: &mut HashSet, ) { let deadline = Instant::now() + grace; loop { @@ -1448,9 +1421,8 @@ async fn drain_acp_with_grace( t_prompt, ttf_logged, yolo, - output_format, pending_bg, - completed_before_bg, + completed_bg, ); } let remaining = deadline.saturating_duration_since(Instant::now()); @@ -1467,9 +1439,8 @@ async fn drain_acp_with_grace( t_prompt, ttf_logged, yolo, - output_format, pending_bg, - completed_before_bg, + completed_bg, ); } _ = tokio::time::sleep(remaining) => { @@ -1479,9 +1450,7 @@ async fn drain_acp_with_grace( } } -/// Process one inbound ACP client message. Used by both `acp_rx.recv()` and -/// `try_recv()` so buffered `task_backgrounded` is not dropped when -/// `PromptResponse` completes first. +/// Process one inbound ACP client message; shared by `recv()` and `try_recv()`. #[allow(clippy::too_many_arguments)] fn handle_headless_acp_message( msg: AcpClientMessageBox, @@ -1489,9 +1458,8 @@ fn handle_headless_acp_message( t_prompt: Instant, ttf_logged: &mut bool, yolo: bool, - output_format: OutputFormat, - pending_bg: &mut HashSet, - completed_before_bg: &mut HashSet, + pending_bg: &mut HashSet, + completed_bg: &mut HashSet, ) { match msg { AcpClientMessageBox::SessionNotification(boxed) => { @@ -1511,7 +1479,9 @@ fn handle_headless_acp_message( } } acp::SessionUpdate::AgentThoughtChunk(chunk) => { - if let acp::ContentBlock::Text(text) = &chunk.content { + if let acp::ContentBlock::Text(text) = &chunk.content + && !text.text.is_empty() + { if !*ttf_logged { *ttf_logged = true; tracing::debug!( @@ -1522,6 +1492,14 @@ fn handle_headless_acp_message( emitter.on_thought_chunk(&text.text); } } + acp::SessionUpdate::ToolCall(_) + | acp::SessionUpdate::ToolCallUpdate(_) + | acp::SessionUpdate::Plan(_) + | acp::SessionUpdate::AvailableCommandsUpdate(_) => { + if let Some(event) = map_session_update(&boxed.request.update) { + emitter.reduce_and_emit(event); + } + } _ => {} } let _ = boxed.response_tx.send(Ok(())); @@ -1548,9 +1526,13 @@ fn handle_headless_acp_message( } } AcpClientMessageBox::ExtNotification(notif) => { - let event = handle_ext_notification(¬if, output_format); + let event = handle_ext_notification(¬if); let _ = notif.response_tx.send(Ok(())); - track_background_lifecycle(event, pending_bg, completed_before_bg); + match event { + ExtEvent::Lifecycle(l) => emitter.on_lifecycle(l), + ExtEvent::Stream(event) => emitter.reduce_and_emit(*event), + other => track_background_lifecycle(other, pending_bg, completed_bg), + } } AcpClientMessageBox::WaitForTerminalExit(args) => { args.response_tx @@ -1563,603 +1545,6 @@ fn handle_headless_acp_message( } } -// ── Extension notification handling ────────────────────────────────────── - -enum ExtEvent { - None, - TaskBackgrounded { - task_id: String, - is_monitor: bool, - }, - TaskCompleted { - task_id: String, - }, - SubagentSpawned { - subagent_id: String, - }, - SubagentFinished { - subagent_id: String, - }, - /// Monitor emitted a line (or ended streaming). Does not complete the task; - /// completion still arrives via `TaskCompleted`. - MonitorEvent, -} - -fn handle_ext_notification( - notif: &xai_acp_lib::AcpArgsBox, - format: OutputFormat, -) -> ExtEvent { - let method = notif.request.method.as_ref(); - - // Background task lifecycle uses dedicated methods (not session_notification). - if method == "x.ai/task_backgrounded" { - #[derive(serde::Deserialize)] - struct TaskBgEnvelope { - update: TaskBgUpdate, - } - #[derive(serde::Deserialize)] - #[serde(rename_all = "snake_case", tag = "sessionUpdate")] - enum TaskBgUpdate { - TaskBackgrounded { - task_id: String, - #[serde(default)] - monitor_description: Option, - }, - #[serde(other)] - Other, - } - if let Ok(env) = serde_json::from_str::(notif.request.params.get()) - && let TaskBgUpdate::TaskBackgrounded { - task_id, - monitor_description, - } = env.update - { - return ExtEvent::TaskBackgrounded { - task_id, - is_monitor: monitor_description.is_some(), - }; - } - return ExtEvent::None; - } - - if method == "x.ai/task_completed" { - #[derive(serde::Deserialize)] - struct TaskDoneEnvelope { - update: TaskDoneUpdate, - } - #[derive(serde::Deserialize)] - #[serde(rename_all = "snake_case", tag = "sessionUpdate")] - enum TaskDoneUpdate { - TaskCompleted { - task_snapshot: TaskSnapshotLite, - }, - #[serde(other)] - Other, - } - #[derive(serde::Deserialize)] - struct TaskSnapshotLite { - task_id: String, - } - if let Ok(env) = serde_json::from_str::(notif.request.params.get()) - && let TaskDoneUpdate::TaskCompleted { task_snapshot } = env.update - { - return ExtEvent::TaskCompleted { - task_id: task_snapshot.task_id, - }; - } - return ExtEvent::None; - } - - if method == "x.ai/monitor_event" { - return ExtEvent::MonitorEvent; - } - - match method { - "x.ai/session_notification" | "x.ai/session/update" => {} - _ => return ExtEvent::None, - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "snake_case", tag = "sessionUpdate")] - enum XaiUpdate { - AutoCompactStarted { - percentage: u8, - }, - AutoCompactCompleted {}, - AutoCompactFailed { - error: String, - }, - AutoCompactCancelled {}, - AutoContinueCompleted { - total_tokens: u64, - }, - ImageCompressed { - message: String, - }, - SubagentSpawned { - subagent_id: String, - }, - SubagentFinished { - subagent_id: String, - }, - #[serde(other)] - Other, - } - #[derive(serde::Deserialize)] - struct XaiNotif { - update: XaiUpdate, - } - - let Ok(xai_notif) = serde_json::from_str::(notif.request.params.get()) else { - return ExtEvent::None; - }; - - match xai_notif.update { - XaiUpdate::AutoCompactStarted { percentage } => match format { - OutputFormat::StreamingJson => { - println!( - "{}", - serde_json::json!({"type": "auto_compact_started", "percentage": percentage}) - ); - } - OutputFormat::Plain => { - eprintln!("Auto-compacting conversation ({percentage}% full)..."); - } - OutputFormat::Json => {} - }, - XaiUpdate::AutoCompactCompleted {} => match format { - OutputFormat::StreamingJson => { - println!("{}", serde_json::json!({"type": "auto_compact_completed"})); - } - OutputFormat::Plain => eprintln!("Conversation compacted."), - OutputFormat::Json => {} - }, - XaiUpdate::AutoCompactFailed { error } => match format { - OutputFormat::StreamingJson => { - println!( - "{}", - serde_json::json!({"type": "auto_compact_failed", "error": error}) - ); - } - OutputFormat::Plain => { - if error.trim().is_empty() { - eprintln!("Auto-compact failed."); - } else { - eprintln!("Auto-compact failed: {error}"); - } - } - OutputFormat::Json => {} - }, - XaiUpdate::AutoCompactCancelled {} => match format { - OutputFormat::StreamingJson => { - println!("{}", serde_json::json!({"type": "auto_compact_cancelled"})); - } - OutputFormat::Plain => eprintln!("Auto-compact cancelled."), - OutputFormat::Json => {} - }, - XaiUpdate::AutoContinueCompleted { total_tokens } => match format { - OutputFormat::StreamingJson => { - println!( - "{}", - serde_json::json!({"type": "auto_continue_completed", "total_tokens": total_tokens}) - ); - } - OutputFormat::Plain => eprintln!("Resumed after compaction."), - OutputFormat::Json => {} - }, - XaiUpdate::ImageCompressed { message } => match format { - OutputFormat::StreamingJson => { - println!( - "{}", - serde_json::json!({"type": "image_compressed", "message": message}) - ); - } - OutputFormat::Plain => eprintln!("{message}"), - OutputFormat::Json => {} - }, - XaiUpdate::SubagentSpawned { subagent_id } => { - return ExtEvent::SubagentSpawned { subagent_id }; - } - XaiUpdate::SubagentFinished { subagent_id, .. } => { - return ExtEvent::SubagentFinished { subagent_id }; - } - XaiUpdate::Other => {} - } - ExtEvent::None -} - #[cfg(test)] -mod tests { - #[test] - fn lifecycle_tracking_is_independent_of_wait_flag() { - let mut pending = std::collections::HashSet::new(); - let mut completed = std::collections::HashSet::new(); - super::track_background_lifecycle( - super::ExtEvent::TaskBackgrounded { - task_id: "t1".into(), - is_monitor: false, - }, - &mut pending, - &mut completed, - ); - super::track_background_lifecycle( - super::ExtEvent::SubagentSpawned { - subagent_id: "s1".into(), - }, - &mut pending, - &mut completed, - ); - assert!(pending.contains("t1")); - assert!(pending.contains("subagent:s1")); - - super::track_background_lifecycle( - super::ExtEvent::TaskCompleted { - task_id: "t1".into(), - }, - &mut pending, - &mut completed, - ); - super::track_background_lifecycle( - super::ExtEvent::SubagentFinished { - subagent_id: "s1".into(), - }, - &mut pending, - &mut completed, - ); - assert!(pending.is_empty()); - } - - #[test] - fn completion_before_backgrounded_never_rearms_pending() { - let mut pending = std::collections::HashSet::new(); - let mut completed = std::collections::HashSet::new(); - super::track_background_lifecycle( - super::ExtEvent::TaskCompleted { - task_id: "t1".into(), - }, - &mut pending, - &mut completed, - ); - super::track_background_lifecycle( - super::ExtEvent::TaskBackgrounded { - task_id: "t1".into(), - is_monitor: false, - }, - &mut pending, - &mut completed, - ); - assert!(pending.is_empty()); - } - - #[test] - fn reap_request_for_task_kills_with_session_scope() { - let session_id = acp::SessionId::new("sess-1"); - let request = super::reap_request_for_key("task-42", &session_id).unwrap(); - assert_eq!(request.method.as_ref(), "x.ai/task/kill"); - let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap(); - assert_eq!(params["sessionId"], "sess-1"); - assert_eq!(params["taskId"], "task-42"); - } - - #[test] - fn reap_request_for_subagent_cancels_with_stripped_id() { - let session_id = acp::SessionId::new("sess-1"); - let request = super::reap_request_for_key("subagent:sub-7", &session_id).unwrap(); - assert_eq!(request.method.as_ref(), "x.ai/subagent/cancel"); - let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap(); - assert_eq!(params["subagentId"], "sub-7"); - } - - use super::*; - use xai_grok_workspace::permission::types::{RuleAction, ToolFilter}; - - fn s(v: &str) -> String { - v.to_owned() - } - - /// Headless materialization is never chat, regardless of worktree flag — - /// resume targets stay disk/GCS Build sessions. The pre-sandbox pin flag - /// must carry through so a pinned target is never re-title-selected. - #[test] - fn headless_materialize_ctx_stays_non_chat() { - use crate::app::session_startup::TitleResolution; - for has_worktree in [false, true] { - for pinned in [false, true] { - let ctx = headless_materialize_ctx(has_worktree, pinned); - assert!(!ctx.chat_mode); - assert_eq!(ctx.has_worktree, has_worktree); - assert_eq!( - ctx.title_resolution, - if pinned { - TitleResolution::PinnedPreSandbox - } else { - TitleResolution::Allowed - } - ); - } - } - } - - #[test] - fn strict_valid_rules_parse_deny_before_allow() { - let allow = vec![s("Bash(npm*)")]; - let deny = vec![s("Bash(rm*)"), s("Edit(/etc/**)")]; - let rules = parse_permission_rules_strict(&allow, &deny).unwrap(); - assert_eq!(rules.len(), 3); - assert_eq!(rules[0].action, RuleAction::Deny); - assert!(matches!(rules[0].tool, ToolFilter::Bash)); - assert_eq!(rules[1].action, RuleAction::Deny); - assert!(matches!(rules[1].tool, ToolFilter::Edit)); - assert_eq!(rules[2].action, RuleAction::Allow); - assert!(matches!(rules[2].tool, ToolFilter::Bash)); - } - - #[test] - fn strict_invalid_rule_errors() { - let result = parse_permission_rules_strict(&[], &[s("EnterWorktree(foo)")]); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("--deny")); - assert!(msg.contains("EnterWorktree")); - } - - #[test] - fn strict_reports_all_invalid_rules() { - let result = parse_permission_rules_strict( - &[s("BadTool(x)")], - &[s("EnterWorktree(foo)"), s("Bash(rm*)")], - ); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("EnterWorktree"), - "should mention first bad deny" - ); - assert!(msg.contains("BadTool"), "should mention bad allow"); - } - - #[test] - fn lenient_skips_invalid_keeps_valid() { - let allow = vec![s("Bash(npm*)")]; - let deny = vec![s("EnterWorktree(foo)"), s("Bash(rm*)")]; - let rules = parse_permission_rules_lenient(&allow, &deny); - assert_eq!(rules.len(), 2); - assert_eq!(rules[0].action, RuleAction::Deny); - assert_eq!(rules[0].pattern.as_deref(), Some("rm*")); - assert_eq!(rules[1].action, RuleAction::Allow); - assert_eq!(rules[1].pattern.as_deref(), Some("npm*")); - } - - #[test] - fn empty_inputs_produce_empty_rules() { - let rules = parse_permission_rules_strict(&[], &[]).unwrap(); - assert!(rules.is_empty()); - let rules = parse_permission_rules_lenient(&[], &[]); - assert!(rules.is_empty()); - } - - #[test] - fn domain_mode_web_fetch() { - let rules = parse_permission_rules_strict(&[], &[s("WebFetch(domain:evil.com)")]).unwrap(); - assert_eq!(rules.len(), 1); - assert!(matches!(rules[0].tool, ToolFilter::WebFetch)); - assert_eq!( - rules[0].pattern_mode, - xai_grok_workspace::permission::types::PatternMode::Domain - ); - assert_eq!(rules[0].pattern.as_deref(), Some("evil.com")); - } - - #[test] - fn bash_colon_wildcard_deny_translates_to_prefix() { - let rules = parse_permission_rules_strict(&[], &[s("Bash(sed:*)")]).unwrap(); - assert_eq!(rules.len(), 1); - assert!(matches!(rules[0].tool, ToolFilter::Bash)); - assert_eq!(rules[0].pattern.as_deref(), Some("sed")); - } - - #[test] - fn structured_output_without_meta_errors_never_parses_text() { - // No `_meta` structured output (e.g. max-turns/cancel): emit a clean - // error, never an unvalidated parse of the raw text buffer. - let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true); - emitter.text_buffer = r#"{"name":"alice","age":30}"#.into(); - emitter.set_structured_output_from_meta(serde_json::json!({}).as_object()); - let result = emitter.build_json_result("EndTurn", "sess-1", "req-1"); - assert!(result["structuredOutput"].is_null()); - assert_eq!( - result["structuredOutputError"], - "model did not produce structured output" - ); - } - - #[test] - fn structured_output_from_meta_wins_over_text_buffer() { - // The agent's validated output (from `_meta`) must override accumulated - // prose (the multi-round corruption bug). - let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true); - emitter.text_buffer = "thinking out loud...".into(); - emitter.set_structured_output_from_meta( - serde_json::json!({"structuredOutput": {"name": "carol"}}).as_object(), - ); - let result = emitter.build_json_result("EndTurn", "sess-1", "req-1"); - assert_eq!(result["structuredOutput"]["name"], "carol"); - assert!(result.get("structuredOutputError").is_none()); - - let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true); - emitter.set_structured_output_from_meta( - serde_json::json!({ - "structuredOutputError": "output does not match the required schema" - }) - .as_object(), - ); - let result = emitter.build_json_result("EndTurn", "sess-1", "req-1"); - assert!(result["structuredOutput"].is_null()); - assert_eq!( - result["structuredOutputError"], - "output does not match the required schema" - ); - } - - #[test] - fn streaming_json_structured_output_emits_from_meta() { - let mut emitter = HeadlessEmitter::new(OutputFormat::StreamingJson, true); - emitter.on_text_chunk(r#"{"name":"#); - emitter.on_text_chunk(r#""bob"}"#); - assert_eq!(emitter.text_buffer, r#"{"name":"bob"}"#); - - // structuredOutput comes from the prompt-response `_meta`, not the buffer. - emitter.set_structured_output_from_meta( - serde_json::json!({"structuredOutput": {"name": "bob"}}).as_object(), - ); - let mut target = serde_json::json!({}); - emitter.attach_structured_output(&mut target); - assert_eq!(target["structuredOutput"]["name"], "bob"); - assert!(target.get("structuredOutputError").is_none()); - } - - #[test] - fn parse_json_schema_rejects_non_objects_and_invalid_json() { - assert!(super::parse_json_schema(r#"{"type":"object"}"#).is_ok()); - assert!( - super::parse_json_schema(r#"[1,2,3]"#) - .unwrap_err() - .to_string() - .contains("must be a JSON object") - ); - assert!( - super::parse_json_schema(r#"{not json"#) - .unwrap_err() - .to_string() - .contains("invalid JSON") - ); - } - - fn make_ext_notif( - method: &str, - update: serde_json::Value, - ) -> xai_acp_lib::AcpArgsBox { - let payload = serde_json::json!({ - "sessionId": "sess-1", - "update": update, - }); - let raw = serde_json::value::to_raw_value(&payload).unwrap(); - let (tx, _rx) = tokio::sync::oneshot::channel(); - xai_acp_lib::AcpArgs { - request: acp::ExtNotification::new(method, raw.into()), - response_tx: tx, - } - .boxed() - } - - #[test] - fn headless_task_backgrounded_parses_task_id() { - // `make_ext_notif` wraps the arg under `update`, so pass - // the inner update object (matching the real `x.ai/task_backgrounded` - // wire shape: `{ "update": { "sessionUpdate": ..., "task_id": ... } }`). - let notif = make_ext_notif( - "x.ai/task_backgrounded", - serde_json::json!({ - "sessionUpdate": "task_backgrounded", - "task_id": "task-abc", - }), - ); - assert!(matches!( - handle_ext_notification(¬if, OutputFormat::Plain), - ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "task-abc" - )); - } - - #[test] - fn headless_task_backgrounded_with_monitor_description_is_monitor() { - let notif = make_ext_notif( - "x.ai/task_backgrounded", - serde_json::json!({ - "sessionUpdate": "task_backgrounded", - "task_id": "mon-1", - "monitor_description": "watching logs", - }), - ); - assert!(matches!( - handle_ext_notification(¬if, OutputFormat::Plain), - ExtEvent::TaskBackgrounded { task_id, is_monitor: true } if task_id == "mon-1" - )); - } - - #[test] - fn headless_task_completed_parses_task_id() { - // `task_completed` nests the id under `task_snapshot`. The - // internally-tagged `rename_all = "snake_case"` renames only the - // `sessionUpdate` tag, so `task_id` / `task_snapshot` stay snake_case; - // this test guards against a future `rename_all = "camelCase"` on - // `TaskSnapshot` silently turning waiting into a no-op. - let notif = make_ext_notif( - "x.ai/task_completed", - serde_json::json!({ - "sessionUpdate": "task_completed", - "task_snapshot": { "task_id": "task-abc" } - }), - ); - assert!(matches!( - handle_ext_notification(¬if, OutputFormat::Plain), - ExtEvent::TaskCompleted { task_id } if task_id == "task-abc" - )); - } - - #[test] - fn headless_subagent_spawned_and_finished_parse() { - let spawned = make_ext_notif( - "x.ai/session_notification", - serde_json::json!({ - "sessionUpdate": "subagent_spawned", - "subagent_id": "sub-1", - "parent_session_id": "p", - "child_session_id": "c", - "subagent_type": "explore", - "description": "test" - }), - ); - assert!(matches!( - handle_ext_notification(&spawned, OutputFormat::Plain), - ExtEvent::SubagentSpawned { subagent_id } if subagent_id == "sub-1" - )); - let finished = make_ext_notif( - "x.ai/session_notification", - serde_json::json!({ - "sessionUpdate": "subagent_finished", - "subagent_id": "sub-1", - "child_session_id": "c", - "status": "completed", - "tool_calls": 0, - "turns": 1, - "duration_ms": 5 - }), - ); - assert!(matches!( - handle_ext_notification(&finished, OutputFormat::Plain), - ExtEvent::SubagentFinished { subagent_id } if subagent_id == "sub-1" - )); - } - - #[test] - fn headless_session_update_unknown_method_is_none() { - let payload = serde_json::json!({ - "sessionId": "sess-1", - "update": { - "sessionUpdate": "subagent_spawned", - "subagent_id": "sub-1" - } - }); - let raw = serde_json::value::to_raw_value(&payload).unwrap(); - let (tx, _rx) = tokio::sync::oneshot::channel(); - let notif = xai_acp_lib::AcpArgs { - request: acp::ExtNotification::new("x.ai/other", raw.into()), - response_tx: tx, - } - .boxed(); - assert!(matches!( - handle_ext_notification(¬if, OutputFormat::Plain), - ExtEvent::None - )); - } -} +#[path = "headless_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-pager/src/headless/cli.rs b/crates/codegen/xai-grok-pager/src/headless/cli.rs new file mode 100644 index 0000000..dd9d6cb --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/cli.rs @@ -0,0 +1,249 @@ +//! Headless CLI parsing: output format, prompt sources, permission rules, and agent args. + +use std::path::{Path, PathBuf}; + +use agent_client_protocol as acp; +use clap::ValueEnum; + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + #[default] + Plain, + Json, + /// NDJSON of the agent native ACP session updates. + #[value(name = "streaming-json")] + StreamingJson, + /// NDJSON in the Anthropic Messages API wire format. + #[value(name = "streaming-messages-json")] + StreamingMessagesJson, +} + +pub fn parse_json_schema(input: &str) -> anyhow::Result { + let schema: serde_json::Value = serde_json::from_str(input) + .map_err(|e| anyhow::anyhow!("--json-schema: invalid JSON: {e}"))?; + if !schema.is_object() { + anyhow::bail!("--json-schema: must be a JSON object describing a JSON Schema"); + } + Ok(schema) +} + +#[derive(Debug, Clone)] +pub enum HeadlessPrompt { + Text(String), + Blocks(Vec), +} + +impl HeadlessPrompt { + /// Build from mutually-exclusive CLI prompt args. `None` = interactive mode. + pub fn from_args( + single: Option<&str>, + prompt_json: Option<&str>, + prompt_file: Option<&Path>, + ) -> anyhow::Result> { + if let Some(text) = single { + Self::from_text(text) + .map(Some) + .map_err(|e| anyhow::anyhow!("--single: {e}")) + } else if let Some(json_str) = prompt_json { + Self::from_json(json_str) + .map(Some) + .map_err(|e| anyhow::anyhow!("--prompt-json: {e}")) + } else if let Some(path) = prompt_file { + Self::from_file(path).map(Some) + } else { + Ok(None) + } + } + + /// `.json` files are parsed as content blocks, everything else as text. + pub fn from_file(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", path.display()))?; + + let context = |e| anyhow::anyhow!("'{}': {e}", path.display()); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + Self::from_json(&content).map_err(context) + } else { + Self::from_text(&content).map_err(context) + } + } + + fn from_text(text: &str) -> anyhow::Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + anyhow::bail!("prompt is empty"); + } + Ok(Self::Text(trimmed.to_string())) + } + + fn from_json(json_str: &str) -> anyhow::Result { + let blocks = parse_prompt_json(json_str)?; + Ok(Self::Blocks(blocks)) + } + + pub fn into_content_blocks(self) -> Vec { + match self { + Self::Text(text) => vec![acp::ContentBlock::Text(acp::TextContent::new(text))], + Self::Blocks(blocks) => blocks, + } + } +} + +/// Parse ACP content blocks from an array (`[...]`) or typed wrapper (`{"type":"acp","content":[...]}`). +fn parse_prompt_json(json_str: &str) -> anyhow::Result> { + let value: serde_json::Value = + serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?; + + let blocks: Vec = match value { + serde_json::Value::Array(_) => serde_json::from_value(value) + .map_err(|e| anyhow::anyhow!("Invalid ACP content blocks: {e}"))?, + + serde_json::Value::Object(ref map) => { + let format_type = map.get("type").and_then(|v| v.as_str()).ok_or_else(|| { + anyhow::anyhow!( + "JSON object must have a \"type\" field \ + (e.g., {{\"type\": \"acp\", \"content\": [...]}})" + ) + })?; + let content = map + .get("content") + .ok_or_else(|| anyhow::anyhow!("JSON object must have a \"content\" field"))?; + + match format_type { + "acp" => serde_json::from_value(content.clone()).map_err(|e| { + anyhow::anyhow!("Invalid ACP content blocks in \"content\": {e}") + })?, + other => anyhow::bail!( + "Unsupported prompt format type: \"{other}\". Supported types: \"acp\"" + ), + } + } + + _ => { + anyhow::bail!("Expected JSON array or {{\"type\": \"...\", \"content\": [...]}} object") + } + }; + + if blocks.is_empty() { + anyhow::bail!("content blocks array is empty"); + } + Ok(blocks) +} + +/// Parse a comma-separated list into a vec, or None if empty. +pub(crate) fn parse_comma_list(s: Option<&str>) -> Option> { + s.and_then(|s| { + let v: Vec = s + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + if v.is_empty() { None } else { Some(v) } + }) +} + +pub fn parse_permission_rules_strict( + allow: &[String], + deny: &[String], +) -> anyhow::Result> { + let (rules, errors) = parse_permission_rules_inner(allow, deny); + if !errors.is_empty() { + let msgs: Vec = errors + .into_iter() + .map(|(flag, rule, err)| format!("{flag} \"{rule}\": {err}")) + .collect(); + anyhow::bail!("{}", msgs.join("; ")); + } + Ok(rules) +} + +pub fn parse_permission_rules_lenient( + allow: &[String], + deny: &[String], +) -> Vec { + let (rules, errors) = parse_permission_rules_inner(allow, deny); + for (flag, rule, err) in errors { + eprintln!("warning: {flag} \"{rule}\": {err}, skipping"); + } + rules +} + +// Deny before allow is cosmetic: the policy evaluator is order-independent (deny > ask > allow). +pub(crate) fn parse_permission_rules_inner( + allow: &[String], + deny: &[String], +) -> ( + Vec, + Vec<(&'static str, String, String)>, +) { + use xai_grok_workspace::permission::rules::parse_permission_rule; + use xai_grok_workspace::permission::types::RuleAction; + + let mut rules = Vec::new(); + let mut errors = Vec::new(); + for rule_str in deny { + match parse_permission_rule(rule_str, RuleAction::Deny) { + Ok(rule) => rules.push(rule), + Err(e) => errors.push(("--deny", rule_str.clone(), e.to_string())), + } + } + for rule_str in allow { + match parse_permission_rule(rule_str, RuleAction::Allow) { + Ok(rule) => rules.push(rule), + Err(e) => errors.push(("--allow", rule_str.clone(), e.to_string())), + } + } + (rules, errors) +} + +pub(crate) enum ResolvedAgent { + FilePath(PathBuf), + Name(String), +} + +pub(crate) fn resolve_agent_arg(agent: &str) -> ResolvedAgent { + let path = std::path::Path::new(agent); + if path.exists() && path.is_file() { + ResolvedAgent::FilePath(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())) + } else { + ResolvedAgent::Name(agent.to_string()) + } +} + +pub(crate) fn parse_cli_agents( + json: &str, +) -> anyhow::Result> { + let map: std::collections::HashMap = + serde_json::from_str(json).map_err(|e| anyhow::anyhow!("--agents: invalid JSON: {e}"))?; + let mut agents = Vec::with_capacity(map.len()); + for (name, mut value) in map { + if let serde_json::Value::Object(ref mut obj) = value { + if !obj.contains_key("promptBody") + && let Some(prompt) = obj.remove("prompt") + { + obj.insert("promptBody".to_string(), prompt); + } + obj.entry("name".to_string()) + .or_insert_with(|| serde_json::Value::String(name.clone())); + obj.entry("description".to_string()) + .or_insert_with(|| serde_json::Value::String(name.clone())); + } + let mut def = xai_grok_shell::agent::config::AgentDefinition::from_json(&value) + .map_err(|e| anyhow::anyhow!("--agents: failed to parse '{name}': {e}"))?; + def.name = name; + agents.push(def); + } + Ok(agents) +} + +pub(crate) fn apply_agent_flag( + agent: &Option, + config: &mut xai_grok_shell::agent::config::Config, +) { + if let Some(agent) = agent { + match resolve_agent_arg(agent) { + ResolvedAgent::FilePath(path) => config.agent_profile_path = Some(path), + ResolvedAgent::Name(name) => config.agent.name = Some(name), + } + } +} diff --git a/crates/codegen/xai-grok-pager/src/headless/ext_protocol.rs b/crates/codegen/xai-grok-pager/src/headless/ext_protocol.rs new file mode 100644 index 0000000..a6c8776 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/ext_protocol.rs @@ -0,0 +1,308 @@ +//! Decoding of the shell's `x.ai/*` extension notifications into the headless +//! [`ExtEvent`] the orchestrator dispatches. Owns the wire envelope shapes and +//! the method to event mapping, kept out of `headless.rs`. + +use agent_client_protocol as acp; + +use crate::headless::reducer::{Lifecycle, StreamEvent}; + +/// Tolerate a numeric `task_id` (version skew) by coercing it to a string, so a +/// numeric id does not fail the decode and leak an untracked background task. +fn de_task_id<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::String(s) => Ok(s), + serde_json::Value::Number(n) => Ok(n.to_string()), + other => Err(serde::de::Error::custom(format!( + "task_id must be a JSON string or number, got {other}" + ))), + } +} + +fn session_update_tag(params: &str) -> Option { + serde_json::from_str::(params) + .ok()? + .get("update")? + .get("sessionUpdate")? + .as_str() + .map(str::to_string) +} + +pub(crate) enum ExtEvent { + None, + TaskBackgrounded { task_id: String, is_monitor: bool }, + TaskCompleted { task_id: String }, + SubagentSpawned { subagent_id: String }, + SubagentFinished { subagent_id: String }, + MonitorEvent, + Lifecycle(Lifecycle), + Stream(Box), +} + +pub(crate) fn handle_ext_notification( + notif: &xai_acp_lib::AcpArgsBox, +) -> ExtEvent { + let method = notif.request.method.as_ref(); + let params = notif.request.params.get(); + match method { + "x.ai/task_backgrounded" => decode_task_backgrounded(method, params), + "x.ai/task_completed" => decode_task_completed(method, params), + "x.ai/monitor_event" => ExtEvent::MonitorEvent, + "x.ai/session_notification" | "x.ai/session/update" => { + decode_session_notification(method, params) + } + _ => ExtEvent::None, + } +} + +fn decode_task_backgrounded(method: &str, params: &str) -> ExtEvent { + #[derive(serde::Deserialize)] + struct TaskBgEnvelope { + update: TaskBgUpdate, + } + #[derive(serde::Deserialize)] + #[serde(rename_all = "snake_case", tag = "sessionUpdate")] + enum TaskBgUpdate { + TaskBackgrounded { + #[serde(deserialize_with = "de_task_id")] + task_id: String, + #[serde(default)] + monitor_description: Option, + }, + #[serde(other)] + Other, + } + match serde_json::from_str::(params) { + Ok(env) => match env.update { + TaskBgUpdate::TaskBackgrounded { + task_id, + monitor_description, + } => ExtEvent::TaskBackgrounded { + task_id, + is_monitor: monitor_description.is_some(), + }, + // Known-tag-on-wrong-carrier: log loudly instead of silently dropping. + TaskBgUpdate::Other => { + tracing::error!( + method, + payload = params, + "headless: x.ai/task_backgrounded with mismatched sessionUpdate \ + tag; background task will not be tracked for reaping" + ); + ExtEvent::None + } + }, + Err(e) => { + tracing::error!( + method, + error = %e, + payload = params, + "headless: undecodable x.ai/task_backgrounded notification; \ + background task will not be tracked for reaping" + ); + ExtEvent::None + } + } +} + +fn decode_task_completed(method: &str, params: &str) -> ExtEvent { + #[derive(serde::Deserialize)] + struct TaskDoneEnvelope { + update: TaskDoneUpdate, + } + #[derive(serde::Deserialize)] + #[serde(rename_all = "snake_case", tag = "sessionUpdate")] + enum TaskDoneUpdate { + TaskCompleted { + task_snapshot: TaskSnapshotLite, + }, + #[serde(other)] + Other, + } + #[derive(serde::Deserialize)] + struct TaskSnapshotLite { + #[serde(deserialize_with = "de_task_id")] + task_id: String, + } + match serde_json::from_str::(params) { + Ok(env) => match env.update { + TaskDoneUpdate::TaskCompleted { task_snapshot } => ExtEvent::TaskCompleted { + task_id: task_snapshot.task_id, + }, + // Known-tag-on-wrong-carrier: log loudly instead of silently dropping. + TaskDoneUpdate::Other => { + tracing::error!( + method, + payload = params, + "headless: x.ai/task_completed with mismatched sessionUpdate \ + tag; background task completion will not be recorded" + ); + ExtEvent::None + } + }, + Err(e) => { + tracing::error!( + method, + error = %e, + payload = params, + "headless: undecodable x.ai/task_completed notification; \ + background task completion will not be recorded" + ); + ExtEvent::None + } + } +} + +fn decode_session_notification(method: &str, params: &str) -> ExtEvent { + #[derive(serde::Deserialize)] + #[serde(rename_all = "snake_case", tag = "sessionUpdate")] + enum XaiUpdate { + AutoCompactStarted { + percentage: u8, + }, + AutoCompactCompleted { + #[serde(default)] + tokens_before: Option, + }, + AutoCompactFailed { + error: String, + }, + AutoCompactCancelled {}, + AutoContinueCompleted { + total_tokens: u64, + }, + ImageCompressed { + message: String, + }, + SubagentSpawned { + subagent_id: String, + }, + SubagentFinished { + subagent_id: String, + }, + ResponseStarted { + #[serde(default)] + message_id: Option, + #[serde(default)] + model: Option, + #[serde(default)] + input_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, + #[serde(default)] + cache_creation_input_tokens: u64, + }, + ReasoningCompleted { + #[serde(default)] + signature: Option, + }, + ResponseCompleted { + #[serde(default)] + message_id: Option, + #[serde(default)] + stop_reason: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + signature: Option, + #[serde(default)] + stop_sequence: Option, + }, + #[serde(other)] + Other, + } + #[derive(serde::Deserialize)] + struct XaiNotif { + update: XaiUpdate, + } + + let xai_notif = match serde_json::from_str::(params) { + Ok(n) => n, + Err(e) => { + tracing::warn!( + method, + error = %e, + "headless: malformed x.ai session notification; ignoring" + ); + return ExtEvent::None; + } + }; + + match xai_notif.update { + XaiUpdate::AutoCompactStarted { percentage } => { + ExtEvent::Lifecycle(Lifecycle::CompactStarted { percentage }) + } + XaiUpdate::AutoCompactCompleted { tokens_before } => { + ExtEvent::Lifecycle(Lifecycle::CompactCompleted { + pre_tokens: tokens_before.unwrap_or(0), + }) + } + XaiUpdate::AutoCompactFailed { error } => { + ExtEvent::Lifecycle(Lifecycle::CompactFailed { error }) + } + XaiUpdate::AutoCompactCancelled {} => ExtEvent::Lifecycle(Lifecycle::CompactCancelled), + XaiUpdate::AutoContinueCompleted { total_tokens } => { + ExtEvent::Lifecycle(Lifecycle::AutoContinue { total_tokens }) + } + XaiUpdate::ImageCompressed { message } => { + ExtEvent::Lifecycle(Lifecycle::ImageCompressed { message }) + } + XaiUpdate::SubagentSpawned { subagent_id } => ExtEvent::SubagentSpawned { subagent_id }, + XaiUpdate::SubagentFinished { subagent_id, .. } => { + ExtEvent::SubagentFinished { subagent_id } + } + XaiUpdate::ResponseStarted { + message_id, + model, + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } => ExtEvent::Stream(Box::new(StreamEvent::ResponseStarted { + message_id, + model, + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + })), + XaiUpdate::ReasoningCompleted { signature } => { + ExtEvent::Stream(Box::new(StreamEvent::ReasoningCompleted { signature })) + } + XaiUpdate::ResponseCompleted { + message_id, + stop_reason, + usage, + signature, + stop_sequence, + } => ExtEvent::Stream(Box::new(StreamEvent::ResponseCompleted { + message_id, + stop_reason, + usage, + signature, + stop_sequence, + })), + // Background lifecycle tag on the wrong carrier: log loudly, but a + // genuinely unknown display tag stays a clean ignore. + XaiUpdate::Other => { + if let Some(tag) = session_update_tag(params) + && matches!(tag.as_str(), "task_backgrounded" | "task_completed") + { + tracing::error!( + method, + tag, + payload = params, + "headless: background-task lifecycle tag on a session notification \ + (expected the dedicated x.ai/task_backgrounded|task_completed method); \ + background tracking will not be updated" + ); + } + ExtEvent::None + } + } +} + +#[cfg(test)] +#[path = "ext_protocol_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-pager/src/headless/ext_protocol_tests.rs b/crates/codegen/xai-grok-pager/src/headless/ext_protocol_tests.rs new file mode 100644 index 0000000..22fdbb2 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/ext_protocol_tests.rs @@ -0,0 +1,405 @@ +use super::*; +use crate::headless::reducer::StreamEvent; +use pretty_assertions::assert_eq; +use std::io::Write; +use std::sync::{Arc, Mutex}; +use tracing_subscriber::fmt::MakeWriter; + +#[derive(Clone, Default)] +struct CapturedLogs(Arc>>); + +impl CapturedLogs { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +impl Write for CapturedLogs { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for CapturedLogs { + type Writer = CapturedLogs; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +fn capture_logs(f: impl FnOnce()) -> String { + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(logs.clone()) + .with_max_level(tracing::Level::WARN) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(subscriber, f); + logs.text() +} + +fn make_ext_notif( + method: &str, + update: serde_json::Value, +) -> xai_acp_lib::AcpArgsBox { + let payload = serde_json::json!({ + "sessionId": "sess-1", + "update": update, + }); + let raw = serde_json::value::to_raw_value(&payload).unwrap(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + xai_acp_lib::AcpArgs { + request: acp::ExtNotification::new(method, raw.into()), + response_tx: tx, + } + .boxed() +} + +#[test] +fn headless_task_backgrounded_parses_task_id() { + let notif = make_ext_notif( + "x.ai/task_backgrounded", + serde_json::json!({ + "sessionUpdate": "task_backgrounded", + "task_id": "task-abc", + }), + ); + assert!(matches!( + handle_ext_notification(¬if), + ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "task-abc" + )); +} + +#[test] +fn headless_task_backgrounded_numeric_task_id_is_coerced() { + let notif = make_ext_notif( + "x.ai/task_backgrounded", + serde_json::json!({ + "sessionUpdate": "task_backgrounded", + "task_id": 4242, + }), + ); + assert!(matches!( + handle_ext_notification(¬if), + ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "4242" + )); +} + +#[test] +fn headless_task_completed_numeric_task_id_is_coerced() { + let notif = make_ext_notif( + "x.ai/task_completed", + serde_json::json!({ + "sessionUpdate": "task_completed", + "task_snapshot": { "task_id": 4242 } + }), + ); + assert!(matches!( + handle_ext_notification(¬if), + ExtEvent::TaskCompleted { task_id } if task_id == "4242" + )); +} + +#[test] +fn headless_task_backgrounded_with_monitor_description_is_monitor() { + let notif = make_ext_notif( + "x.ai/task_backgrounded", + serde_json::json!({ + "sessionUpdate": "task_backgrounded", + "task_id": "mon-1", + "monitor_description": "watching logs", + }), + ); + assert!(matches!( + handle_ext_notification(¬if), + ExtEvent::TaskBackgrounded { task_id, is_monitor: true } if task_id == "mon-1" + )); +} + +#[test] +fn headless_task_completed_parses_task_id() { + let notif = make_ext_notif( + "x.ai/task_completed", + serde_json::json!({ + "sessionUpdate": "task_completed", + "task_snapshot": { "task_id": "task-abc" } + }), + ); + assert!(matches!( + handle_ext_notification(¬if), + ExtEvent::TaskCompleted { task_id } if task_id == "task-abc" + )); +} + +#[test] +fn headless_subagent_spawned_and_finished_parse() { + let spawned = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": "subagent_spawned", + "subagent_id": "sub-1", + "parent_session_id": "p", + "child_session_id": "c", + "subagent_type": "explore", + "description": "test" + }), + ); + assert!(matches!( + handle_ext_notification(&spawned), + ExtEvent::SubagentSpawned { subagent_id } if subagent_id == "sub-1" + )); + let finished = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": "subagent_finished", + "subagent_id": "sub-1", + "child_session_id": "c", + "status": "completed", + "tool_calls": 0, + "turns": 1, + "duration_ms": 5 + }), + ); + assert!(matches!( + handle_ext_notification(&finished), + ExtEvent::SubagentFinished { subagent_id } if subagent_id == "sub-1" + )); +} + +#[test] +fn headless_response_completed_parses_per_response_fields() { + let notif = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": "response_completed", + "message_id": "msg_01", + "stop_reason": "tool_use", + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "cache_read_input_tokens": 2, + "cache_creation_input_tokens": 0, + }, + "signature": "sig-xyz", + "stop_sequence": "", + }), + ); + let ExtEvent::Stream(event) = handle_ext_notification(¬if) else { + panic!("expected Stream event"); + }; + let StreamEvent::ResponseCompleted { + message_id, + stop_reason, + usage, + signature, + stop_sequence, + } = *event + else { + panic!("expected ResponseCompleted"); + }; + assert_eq!(message_id.as_deref(), Some("msg_01")); + assert_eq!(stop_reason.as_deref(), Some("tool_use")); + assert_eq!(signature.as_deref(), Some("sig-xyz")); + assert_eq!(stop_sequence.as_deref(), Some("")); + let usage = usage.expect("usage present"); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.cache_read_input_tokens, 2); +} + +#[test] +fn headless_response_started_parses_per_response_fields() { + let notif = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": "response_started", + "message_id": "msg_01", + "model": "grok-4", + "input_tokens": 42, + "cache_read_input_tokens": 7, + "cache_creation_input_tokens": 3, + }), + ); + let ExtEvent::Stream(event) = handle_ext_notification(¬if) else { + panic!("expected Stream event"); + }; + let StreamEvent::ResponseStarted { + message_id, + model, + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } = *event + else { + panic!("expected ResponseStarted"); + }; + assert_eq!(message_id.as_deref(), Some("msg_01")); + assert_eq!(model.as_deref(), Some("grok-4")); + assert_eq!(input_tokens, 42); + assert_eq!(cache_read_input_tokens, 7); + assert_eq!(cache_creation_input_tokens, 3); +} + +#[test] +fn headless_reasoning_completed_parses_signature() { + let notif = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": "reasoning_completed", + "signature": "sig-xyz", + }), + ); + let ExtEvent::Stream(event) = handle_ext_notification(¬if) else { + panic!("expected Stream event"); + }; + let StreamEvent::ReasoningCompleted { signature } = *event else { + panic!("expected ReasoningCompleted"); + }; + assert_eq!(signature.as_deref(), Some("sig-xyz")); +} + +#[test] +fn headless_undecodable_known_background_task_errors_not_silent() { + let notif = make_ext_notif( + "x.ai/task_backgrounded", + serde_json::json!({ + "sessionUpdate": "task_backgrounded", + "task_id": { "nested": "object" }, + }), + ); + let mut is_none = false; + let logs = capture_logs(|| { + is_none = matches!(handle_ext_notification(¬if), ExtEvent::None); + }); + assert!(is_none, "undecodable known method degrades to None"); + assert!( + logs.contains("task_backgrounded"), + "log names the method: {logs}" + ); + assert!(logs.contains("ERROR"), "logged at error level: {logs}"); +} + +#[test] +fn headless_task_backgrounded_mismatched_tag_errors_not_silent() { + let notif = make_ext_notif( + "x.ai/task_backgrounded", + serde_json::json!({ + "sessionUpdate": "task_completed", + "task_id": "task-abc", + }), + ); + let mut is_none = false; + let logs = capture_logs(|| { + is_none = matches!(handle_ext_notification(¬if), ExtEvent::None); + }); + assert!(is_none, "mismatched-tag known method degrades to None"); + assert!( + logs.contains("task_backgrounded"), + "log names the method: {logs}" + ); + assert!(logs.contains("ERROR"), "logged at error level: {logs}"); +} + +#[test] +fn headless_task_completed_mismatched_tag_errors_not_silent() { + let notif = make_ext_notif( + "x.ai/task_completed", + serde_json::json!({ + "sessionUpdate": "task_backgrounded", + "task_snapshot": { "task_id": "task-abc" }, + }), + ); + let mut is_none = false; + let logs = capture_logs(|| { + is_none = matches!(handle_ext_notification(¬if), ExtEvent::None); + }); + assert!(is_none, "mismatched-tag known method degrades to None"); + assert!( + logs.contains("task_completed"), + "log names the method: {logs}" + ); + assert!(logs.contains("ERROR"), "logged at error level: {logs}"); +} + +#[test] +fn headless_malformed_known_response_boundary_warns_not_silent() { + let notif = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": "response_completed", + "usage": "not-an-object", + }), + ); + let mut is_none = false; + let logs = capture_logs(|| { + is_none = matches!(handle_ext_notification(¬if), ExtEvent::None); + }); + assert!(is_none, "malformed known notification degrades to None"); + assert!( + logs.contains("session notification"), + "warning describes the malformed session notification: {logs}" + ); + assert!(logs.contains("WARN"), "logged at warn level: {logs}"); +} + +#[test] +fn headless_session_update_unknown_method_is_none() { + let payload = serde_json::json!({ + "sessionId": "sess-1", + "update": { + "sessionUpdate": "subagent_spawned", + "subagent_id": "sub-1" + } + }); + let raw = serde_json::value::to_raw_value(&payload).unwrap(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let notif = xai_acp_lib::AcpArgs { + request: acp::ExtNotification::new("x.ai/other", raw.into()), + response_tx: tx, + } + .boxed(); + assert!(matches!(handle_ext_notification(¬if), ExtEvent::None)); +} + +#[test] +fn headless_session_notification_task_tag_errors_not_silent() { + for tag in ["task_backgrounded", "task_completed"] { + let notif = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ + "sessionUpdate": tag, + "task_id": "task-abc", + }), + ); + let mut is_none = false; + let logs = capture_logs(|| { + is_none = matches!(handle_ext_notification(¬if), ExtEvent::None); + }); + assert!(is_none, "misrouted lifecycle tag degrades to None ({tag})"); + assert!(logs.contains(tag), "log names the tag {tag}: {logs}"); + assert!( + logs.contains("ERROR"), + "logged at error level ({tag}): {logs}" + ); + } +} + +#[test] +fn headless_session_notification_unknown_tag_is_clean_ignore() { + let notif = make_ext_notif( + "x.ai/session_notification", + serde_json::json!({ "sessionUpdate": "totally_unknown_display_tag" }), + ); + let mut is_none = false; + let logs = capture_logs(|| { + is_none = matches!(handle_ext_notification(¬if), ExtEvent::None); + }); + assert!(is_none, "unknown display tag degrades to None"); + assert!( + !logs.contains("ERROR"), + "an unknown display tag is a clean ignore, not an error: {logs}" + ); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/acp.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/acp.rs new file mode 100644 index 0000000..8c28b66 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/acp.rs @@ -0,0 +1,201 @@ +//! The `streaming-json` reducer: native ACP session updates, one JSON object +//! per line. Owns its own wire line shapes ([`AcpLine`] et al.). + +use serde::Serialize; +use serde_json::Value; + +use crate::headless::attach_result_usage; +use xai_grok_shell::extensions::notification::ResponseUsage; + +use super::{ + Lifecycle, Reducer, StreamEvent, TurnEnd, attach_structured_output, to_line, + tool_call_status_wire, +}; + +/// `streaming-json` per-response `usage` line (camelCase keys). +#[derive(Serialize)] +struct AcpUsageLine { + #[serde(rename = "type")] + kind: &'static str, + #[serde(rename = "messageId", skip_serializing_if = "Option::is_none")] + message_id: Option, + #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")] + stop_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, +} + +/// `streaming-json` line shapes: an xAI `type`-tagged envelope derived from ACP updates. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum AcpLine { + Text { + data: String, + }, + Thought { + data: String, + }, + ToolCall { + #[serde(rename = "toolCallId")] + tool_call_id: String, + title: String, + kind: Option, + status: Option, + #[serde(rename = "toolName")] + tool_name: String, + #[serde(rename = "rawInput")] + raw_input: Value, + content: Value, + locations: Value, + }, + ToolCallUpdate { + #[serde(rename = "toolCallId")] + tool_call_id: String, + status: Option, + content: Value, + #[serde(rename = "rawOutput")] + raw_output: Value, + locations: Value, + }, + Plan { + entries: Value, + }, + AvailableCommands { + tools: Vec, + commands: Vec, + }, + MaxTurnsReached, + Error { + message: String, + }, + AutoCompactStarted { + percentage: u8, + }, + AutoCompactCompleted, + AutoCompactFailed { + error: String, + }, + AutoCompactCancelled, + AutoContinueCompleted { + total_tokens: u64, + }, + ImageCompressed { + message: String, + }, +} + +/// `streaming-json` terminal `end` line (spend fields merged in by the caller). +#[derive(Serialize)] +struct AcpEndLine<'a> { + #[serde(rename = "type")] + kind: &'static str, + #[serde(rename = "stopReason")] + stop_reason: &'a str, + #[serde(rename = "sessionId")] + session_id: &'a str, + #[serde(rename = "requestId")] + request_id: &'a str, +} + +/// `streaming-json`: native ACP session updates, one object per line. +pub(crate) struct AcpReducer; + +impl Reducer for AcpReducer { + fn reduce(&mut self, event: StreamEvent) -> Vec { + let line = match event { + StreamEvent::AgentMessage(data) => AcpLine::Text { data }, + StreamEvent::AgentThought(data) => AcpLine::Thought { data }, + StreamEvent::ToolCall(tc) => AcpLine::ToolCall { + tool_call_id: tc.tool_call_id, + title: tc.title, + kind: tc.tool_kind, + status: tc.status.and_then(tool_call_status_wire), + tool_name: tc.tool_name, + raw_input: tc.raw_input, + content: tc.content, + locations: tc.locations, + }, + StreamEvent::ToolCallUpdate(u) => AcpLine::ToolCallUpdate { + tool_call_id: u.tool_call_id, + status: u.status.and_then(tool_call_status_wire), + content: u.content, + raw_output: u.raw_output, + locations: u.locations, + }, + StreamEvent::Plan(entries) => AcpLine::Plan { entries }, + StreamEvent::AvailableCommands { + tools, + commands, + skills: _, + } => AcpLine::AvailableCommands { tools, commands }, + StreamEvent::Lifecycle(l) => return vec![to_line(&acp_lifecycle_line(l))], + // These events feed only the Messages reducer's partial framing. + StreamEvent::ResponseStarted { .. } | StreamEvent::ReasoningCompleted { .. } => { + return vec![]; + } + StreamEvent::ResponseCompleted { + message_id, + stop_reason, + usage, + signature, + stop_sequence: _, + } => { + return vec![to_line(&AcpUsageLine { + kind: "usage", + message_id, + stop_reason, + usage, + signature, + })]; + } + }; + vec![to_line(&line)] + } + + fn max_turns(&mut self) -> Vec { + vec![to_line(&AcpLine::MaxTurnsReached)] + } + + fn finish(&mut self, end: &TurnEnd<'_>) -> Vec { + let mut line = to_line(&AcpEndLine { + kind: "end", + stop_reason: end.stop_reason, + session_id: end.session_id, + request_id: end.request_id, + }); + if let Some(usage) = end.usage { + attach_result_usage(&mut line, usage); + } + attach_structured_output(&mut line, end.structured_output.clone()); + vec![line] + } + + fn error( + &mut self, + message: &str, + usage: Option<&Value>, + _duration_ms: u64, + _stop_reason: Option<&str>, + ) -> Vec { + let mut line = to_line(&AcpLine::Error { + message: message.to_string(), + }); + if let Some(usage) = usage { + attach_result_usage(&mut line, usage); + } + vec![line] + } +} + +fn acp_lifecycle_line(l: Lifecycle) -> AcpLine { + match l { + Lifecycle::CompactStarted { percentage } => AcpLine::AutoCompactStarted { percentage }, + Lifecycle::CompactCompleted { .. } => AcpLine::AutoCompactCompleted, + Lifecycle::CompactFailed { error } => AcpLine::AutoCompactFailed { error }, + Lifecycle::CompactCancelled => AcpLine::AutoCompactCancelled, + Lifecycle::AutoContinue { total_tokens } => AcpLine::AutoContinueCompleted { total_tokens }, + Lifecycle::ImageCompressed { message } => AcpLine::ImageCompressed { message }, + } +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/mod.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/mod.rs new file mode 100644 index 0000000..2d50904 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/mod.rs @@ -0,0 +1,817 @@ +//! The `streaming-messages-json` reducer (Anthropic Messages API wire format). +//! The coordinator: owns [`MessagesReducer`] and its [`Reducer`] impl; cohesive +//! pieces live in the `wire`/`state`/`partial`/`usage`/`web_search` submodules. + +use agent_client_protocol as acp; +use serde_json::{Value, json}; + +use super::{ + Lifecycle, Reducer, SessionContext, StreamEvent, ToolCallEvent, ToolCallUpdateEvent, TurnEnd, + to_line, +}; + +mod partial; +mod state; +mod usage; +mod web_search; +mod wire; + +#[cfg(test)] +mod tests; + +use state::{ + PartialFraming, PendingResponse, ResponseIdentity, ResponseState, SessionState, TextKind, +}; +use wire::{ + AssistantFrame, AssistantMessage, CompactBoundaryLine, CompactMetadata, ContentBlock, + MessageUsage, MessagesLine, PartialDelta, ResultLine, SystemInitLine, SystemLine, + ToolResultBlock, ToolResultLine, ToolResultMessage, messages_permission_mode, new_uuid, +}; + +/// `streaming-messages-json`: the Messages API wire format. +pub(crate) struct MessagesReducer { + /// Session facts, populated by `begin`; `None` until then. + session: Option, + tools: Vec, + slash_commands: Vec, + /// Skill names for the Messages `init` `skills` field. + skills: Vec, + init_emitted: bool, + max_turns_hit: bool, + blocks: Vec, + open_kind: Option, + open_text: String, + msg_seq: u64, + /// Assistant frames flushed this turn; gates the `result.result` final-text fallback. + assistant_frames: u64, + /// Completed responses this turn, including contentless ones; the `num_turns` fallback. + completed_responses: u64, + /// Current response lifecycle phase; dropped at response boundaries so it cannot leak. + response: ResponseState, + /// In-order signature for the currently-open thinking block, so each block keeps its own. + open_signature: Option, + /// Terminal tool results buffered for one grouped `user` message, tagged with + /// the `tool_use`'s emission order so the group flushes in `tool_use` order. + pending_tool_results: Vec<(u64, ToolResultBlock)>, + /// Monotonic order stamped on each `tool_use` so a later `tool_result` sorts back into place. + next_tool_use_order: u64, + /// Unmatched client `tool_use` blocks (id -> emission order); leftovers at turn + /// end get an `is_error` `tool_result` to keep the transcript valid. + pending_client_tool_uses: std::collections::HashMap, + /// In-flight backend `web_search` calls (id -> order + call); query and results + /// arrive only at completion, so the `ToolCall` defers here. + backend_web_search_calls: std::collections::HashMap, + /// Count of successful inline backend `web_search` invocations (errored ones excluded, not billed). + web_search_requests: u64, + /// Text of the most recently flushed assistant frame (the `result.result` value). + last_text: String, + /// Typed partial-stream framing sub-state; only with `--include-partial-messages`. + framing: PartialFraming, + /// Monotonic counter for synthesized partial `message_start.id` placeholders. + partial_msg_seq: u64, +} + +impl MessagesReducer { + pub(crate) fn new() -> Self { + Self { + session: None, + tools: Vec::new(), + slash_commands: Vec::new(), + skills: Vec::new(), + init_emitted: false, + max_turns_hit: false, + blocks: Vec::new(), + open_kind: None, + open_text: String::new(), + msg_seq: 0, + assistant_frames: 0, + completed_responses: 0, + response: ResponseState::Idle, + open_signature: None, + pending_tool_results: Vec::new(), + next_tool_use_order: 0, + pending_client_tool_uses: std::collections::HashMap::new(), + backend_web_search_calls: std::collections::HashMap::new(), + web_search_requests: 0, + last_text: String::new(), + framing: PartialFraming::Idle, + partial_msg_seq: 0, + } + } + + /// The session id, or `""` before `begin` (the startup-error last resort). + fn session_id(&self) -> &str { + self.session.as_ref().map_or("", |s| s.session_id.as_str()) + } + + /// Whether `--include-partial-messages` framing is on; `false` before `begin`. + fn include_partials(&self) -> bool { + self.session.as_ref().is_some_and(|s| s.include_partials) + } + + fn init_line(&self) -> Value { + let session = self.session.as_ref(); + to_line(&MessagesLine::System(SystemLine::Init(SystemInitLine { + session_id: self.session_id().to_string(), + api_key_source: if session.is_none_or(|s| s.api_key_auth) { + "user" + } else { + "oauth" + }, + model: self.model_or_unknown(), + cwd: session.map(|s| s.cwd.clone()).unwrap_or_default(), + permission_mode: messages_permission_mode( + session.and_then(|s| s.permission_mode.as_deref()), + ), + tools: self.tools.clone(), + slash_commands: self.slash_commands.clone(), + mcp_servers: session.map(|s| s.mcp_servers.clone()).unwrap_or_default(), + skills: self.skills.clone(), + uuid: new_uuid(), + }))) + } + + fn ensure_init(&mut self) -> Option { + if self.init_emitted { + return None; + } + self.init_emitted = true; + Some(self.init_line()) + } + + fn append_text(&mut self, kind: TextKind, text: &str) { + // Finalize a differing or pending signature-only block so it keeps its position. + if self.open_kind.is_some_and(|k| k != kind) + || (self.open_kind.is_none() && self.open_signature.is_some()) + { + self.finalize_open(); + } + self.open_kind = Some(kind); + self.open_text.push_str(text); + } + + fn finalize_open(&mut self) { + // Consume this block's own signature so it stamps onto THIS block, never a later one. + let signature = self.open_signature.take(); + let Some(kind) = self.open_kind.take() else { + if let Some(signature) = signature { + self.blocks.push(ContentBlock::Thinking { + thinking: String::new(), + signature, + }); + } + return; + }; + let text = std::mem::take(&mut self.open_text); + match kind { + TextKind::Text => { + if !text.is_empty() { + self.blocks.push(ContentBlock::Text { text }); + } + } + TextKind::Thinking => { + if !text.is_empty() || signature.is_some() { + self.blocks.push(ContentBlock::Thinking { + thinking: text, + signature: signature.unwrap_or_default(), + }); + } + } + } + } + + fn add_tool_use(&mut self, tc: ToolCallEvent) { + self.finalize_open(); + self.blocks.push(ContentBlock::ToolUse { + id: tc.tool_call_id, + name: tc.tool_name, + input: normalized_tool_input(tc.raw_input), + }); + } + + /// Add a client tool's `tool_use` block to the open frame, with partial framing when enabled. + fn emit_client_tool_call(&mut self, out: &mut Vec, tc: ToolCallEvent) { + // Track emission order so out-of-order `tool_result`s sort back into place. + let order = self.take_tool_use_order(); + self.pending_client_tool_uses + .insert(tc.tool_call_id.clone(), order); + if self.include_partials() { + self.partial_signature_only_block(out); + self.partial_close_block(out); + let id = tc.tool_call_id.clone(); + let name = tc.tool_name.clone(); + let input = normalized_tool_input(tc.raw_input.clone()); + self.add_tool_use(tc); + let index = self.blocks.len().saturating_sub(1); + self.partial_tool_use(out, index, &id, &name, &input); + } else { + self.add_tool_use(tc); + } + } + + // The frame and its partial `message_delta` resolve stop reason, usage, and + // stop sequence through these three, so the two renderings never disagree. + + /// Reported reason, else `default`; a `None` default forces null so a failed turn is not mislabeled. + fn resolved_stop_reason(&self, default: Option<&str>) -> Option { + let default = default?; + self.response + .pending() + .and_then(|p| p.stop_reason.clone()) + .or_else(|| Some(default.to_string())) + } + + /// Reported usage, else the identity's input-side usage (`output_tokens` 0). + fn resolved_usage(&self) -> MessageUsage { + self.response + .pending() + .and_then(|p| p.usage.as_ref()) + .cloned() + .unwrap_or_else(|| self.response.identity().input_usage()) + } + + fn resolved_stop_sequence(&self) -> Option { + self.response + .pending() + .and_then(|p| p.stop_sequence.clone()) + } + + /// Flush the accumulated blocks as one assistant message. `default_stop_reason` + /// applies only when no `ResponseCompleted` supplied one; `None` stamps null. + fn flush_assistant(&mut self, default_stop_reason: Option<&str>) -> Option { + self.finalize_open(); + if self.blocks.is_empty() { + if self.response.started() { + self.completed_responses += 1; + } + self.clear_pending(); + return None; + } + let identity = self.response.identity(); + let usage = self.resolved_usage(); + let stop_reason = self.resolved_stop_reason(default_stop_reason); + let stop_sequence = self.resolved_stop_sequence(); + let pending = self.response.take_pending(); + let mut content = std::mem::take(&mut self.blocks); + let fallback_sig = pending + .signature + .clone() + .or_else(|| self.open_signature.take()); + if let Some(sig) = fallback_sig + && let Some(ContentBlock::Thinking { + signature: slot, .. + }) = content + .iter_mut() + .rev() + .find(|b| matches!(b, ContentBlock::Thinking { .. })) + && slot.is_empty() + { + *slot = sig; + } + self.open_signature = None; + let text: String = content + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + self.last_text = text; + let id = pending + .message_id + .clone() + .or_else(|| identity.message_id.clone()) + .unwrap_or_else(|| { + let id = format!("msg_{}", self.msg_seq); + self.msg_seq += 1; + id + }); + let frame = AssistantFrame { + message: AssistantMessage { + id, + kind: "message", + role: "assistant", + model: self.frame_model(&identity), + content, + stop_reason, + stop_sequence, + usage, + }, + parent_tool_use_id: None, + session_id: self.session_id().to_string(), + uuid: new_uuid(), + }; + self.assistant_frames += 1; + self.completed_responses += 1; + Some(to_line(&MessagesLine::Assistant(frame))) + } + + /// Drop all per-response state so none leaks onto a later response. + fn clear_pending(&mut self) { + self.response.reset(); + self.open_signature = None; + } + + /// Close the open partial message and flush the assistant frame with the same + /// default stop reason, so the partial rebuild and frame never disagree. + fn close_and_flush(&mut self, out: &mut Vec, default_stop_reason: Option<&str>) { + self.partial_close_message(out, default_stop_reason); + if let Some(assistant) = self.flush_assistant(default_stop_reason) { + out.push(assistant); + } + } + + /// Shared terminal preamble for `finish`/`error`: init, reconcile deferred web + /// searches, close+flush the open frame, then flush grouped tool results. + fn flush_terminal_preamble(&mut self, out: &mut Vec, default_stop_reason: Option<&str>) { + if let Some(init) = self.ensure_init() { + out.push(init); + } + self.flush_unresolved_web_searches(out); + self.close_and_flush(out, default_stop_reason); + self.reconcile_unmatched_client_tools(); + self.flush_tool_results(out); + } + + /// Reconcile deferred `web_search` calls that never terminated: emit each as a + /// `server_tool_use` + `web_search_tool_result_error` pair, in invocation order. + fn flush_unresolved_web_searches(&mut self, out: &mut Vec) { + if self.backend_web_search_calls.is_empty() { + return; + } + let mut leftovers: Vec<(u64, String)> = self + .backend_web_search_calls + .drain() + .map(|(id, (order, _tc))| (order, id)) + .collect(); + leftovers.sort_by_key(|(order, _)| *order); + let error = json!({ + "type": "web_search_tool_result_error", + "error_code": "unavailable", + }); + for (_order, id) in leftovers { + // Query never arrived, so empty; the error result reflects an unresolved search. + self.append_web_search_result(out, &id, "", &error); + } + } + + /// The session model for the `init` line and `result` `modelUsage`, or `"unknown"`. + fn model_or_unknown(&self) -> String { + self.session + .as_ref() + .and_then(|s| s.model.clone()) + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) + } + + /// The model for one response's frames: its own model, then the session model, then `"unknown"`. + fn frame_model(&self, identity: &ResponseIdentity) -> String { + identity + .model + .as_deref() + .filter(|m| !m.is_empty()) + .map(str::to_string) + .or_else(|| { + self.session + .as_ref() + .and_then(|s| s.model.clone()) + .filter(|m| !m.is_empty()) + }) + .unwrap_or_else(|| "unknown".to_string()) + } + + /// Flush a completed-but-un-flushed response before new content begins. + fn flush_prior_response(&mut self, out: &mut Vec) { + if self.response.is_completed() { + self.close_and_flush(out, Some("end_turn")); + } + } + + /// The next monotonic `tool_use` emission order. + fn take_tool_use_order(&mut self) -> u64 { + let order = self.next_tool_use_order; + self.next_tool_use_order += 1; + order + } + + /// Buffer one terminal tool result for the next grouped `user` message, tagged + /// with its `tool_use`'s emission order. + fn buffer_tool_result(&mut self, u: ToolCallUpdateEvent) { + let is_error = u.status == Some(acp::ToolCallStatus::Failed); + let order = self + .pending_client_tool_uses + .remove(&u.tool_call_id) + .unwrap_or_else(|| self.take_tool_use_order()); + self.pending_tool_results.push(( + order, + ToolResultBlock { + kind: "tool_result", + tool_use_id: u.tool_call_id, + content: tool_result_content(u.raw_output, u.content), + is_error, + }, + )); + } + + /// Buffer an `is_error` `tool_result` for any client `tool_use` that never got one, + /// so every `tool_use` is matched and the transcript stays valid. + fn reconcile_unmatched_client_tools(&mut self) { + if self.pending_client_tool_uses.is_empty() { + return; + } + for (id, order) in std::mem::take(&mut self.pending_client_tool_uses) { + self.pending_tool_results.push(( + order, + ToolResultBlock { + kind: "tool_result", + tool_use_id: id, + content: Value::String("tool call did not complete".to_string()), + is_error: true, + }, + )); + } + } + + /// Emit the buffered tool results as one grouped `user` message, in `tool_use` order. + fn flush_tool_results(&mut self, out: &mut Vec) { + if self.pending_tool_results.is_empty() { + return; + } + let mut buffered = std::mem::take(&mut self.pending_tool_results); + buffered.sort_by_key(|(order, _)| *order); + let content = buffered.into_iter().map(|(_, block)| block).collect(); + out.push(to_line(&MessagesLine::User(ToolResultLine { + message: ToolResultMessage { + role: "user", + content, + }, + parent_tool_use_id: None, + session_id: self.session_id().to_string(), + uuid: new_uuid(), + }))); + } + + /// Flush a prior response's frame and grouped tool results before new content begins. + fn flush_boundary(&mut self, out: &mut Vec) { + self.flush_prior_response(out); + self.flush_tool_results(out); + } + + /// Whether any prior-response state remains that a new `ResponseStarted` must flush first. + fn has_unflushed_response(&self) -> bool { + self.response.is_active() + || !self.blocks.is_empty() + || !self.open_text.is_empty() + || self.open_signature.is_some() + || !self.pending_tool_results.is_empty() + } + + fn result_session_id<'a>(&'a self, end_session_id: &'a str) -> &'a str { + if end_session_id.is_empty() { + self.session_id() + } else { + end_session_id + } + } +} + +impl Reducer for MessagesReducer { + fn begin(&mut self, ctx: SessionContext) -> Vec { + debug_assert!( + self.session.is_none(), + "MessagesReducer::begin called twice; the session context is set once" + ); + self.session = Some(SessionState { + session_id: ctx.session_id, + model: ctx.model, + cwd: ctx.cwd, + permission_mode: ctx.permission_mode, + api_key_auth: ctx.api_key_auth, + mcp_servers: ctx.mcp_servers, + include_partials: ctx.include_partial_messages, + context_window: ctx.context_window, + }); + // Init is deferred to the first output line so tool/command lists fill. + Vec::new() + } + + fn reduce(&mut self, event: StreamEvent) -> Vec { + let mut out = Vec::new(); + // Metadata accumulates before init; `ResponseCompleted` must not force init. + let is_metadata = matches!( + event, + StreamEvent::AvailableCommands { .. } + | StreamEvent::ResponseStarted { .. } + | StreamEvent::ReasoningCompleted { .. } + | StreamEvent::ResponseCompleted { .. } + ); + if !is_metadata && let Some(init) = self.ensure_init() { + out.push(init); + } + match event { + StreamEvent::AvailableCommands { + tools, + commands, + skills, + } => { + if !tools.is_empty() { + self.tools = tools; + } + // Update commands and skills together so a later empty update clears neither. + if !commands.is_empty() { + self.slash_commands = commands; + self.skills = skills; + } + } + // Skip empty chunks so the partial block index can never desync. + StreamEvent::AgentMessage(text) if text.is_empty() => {} + StreamEvent::AgentThought(text) if text.is_empty() => {} + StreamEvent::AgentMessage(text) => { + self.flush_boundary(&mut out); + self.partial_signature_only_block(&mut out); + if self.include_partials() && self.open_kind.is_some_and(|k| k != TextKind::Text) { + self.partial_close_block(&mut out); + } + self.append_text(TextKind::Text, &text); + if self.include_partials() { + let index = self.blocks.len(); + self.partial_delta( + &mut out, + TextKind::Text, + index, + PartialDelta::Text { text }, + ); + } + } + StreamEvent::AgentThought(text) => { + self.flush_boundary(&mut out); + self.partial_signature_only_block(&mut out); + if self.include_partials() + && self.open_kind.is_some_and(|k| k != TextKind::Thinking) + { + self.partial_close_block(&mut out); + } + self.append_text(TextKind::Thinking, &text); + if self.include_partials() { + let index = self.blocks.len(); + self.partial_delta( + &mut out, + TextKind::Thinking, + index, + PartialDelta::Thinking { thinking: text }, + ); + } + } + StreamEvent::ToolCall(tc) if tc.backend_web_search => { + // Query and results are unknown until completion, so defer; stamp invocation order. + let order = self.take_tool_use_order(); + self.backend_web_search_calls + .insert(tc.tool_call_id.clone(), (order, tc)); + } + StreamEvent::ToolCall(tc) => { + // Flush a prior tool round's results so rounds interleave on backends without `ResponseStarted`. + self.flush_tool_results(&mut out); + self.emit_client_tool_call(&mut out, tc); + } + StreamEvent::ToolCallUpdate(u) => { + let terminal = matches!( + u.status, + Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) + ); + if terminal { + if let Some((_order, tc)) = + self.backend_web_search_calls.remove(&u.tool_call_id) + { + self.finish_web_search(&mut out, tc, u); + } else { + self.close_and_flush(&mut out, Some("tool_use")); + self.buffer_tool_result(u); + } + } + } + StreamEvent::Lifecycle(Lifecycle::CompactCompleted { pre_tokens }) => { + self.flush_boundary(&mut out); + out.push(to_line(&MessagesLine::System(SystemLine::CompactBoundary( + CompactBoundaryLine { + compact_metadata: CompactMetadata { + trigger: "auto", + pre_tokens, + }, + session_id: self.session_id().to_string(), + uuid: new_uuid(), + }, + )))); + } + StreamEvent::ResponseStarted { + message_id, + model, + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } => { + // Flush any prior response before this opens so metadata is not cross-attributed. + if self.has_unflushed_response() { + if let Some(init) = self.ensure_init() { + out.push(init); + } + self.close_and_flush(&mut out, Some("end_turn")); + self.flush_tool_results(&mut out); + } + // Adopt this response's model as the session model so `init`/`modelUsage` track a switch. + if let Some(model) = model.clone() + && !model.is_empty() + && let Some(session) = self.session.as_mut() + { + session.model = Some(model); + } + // Clone (never take) the identity so both the partial start and final frame read it. + self.response.open(ResponseIdentity { + message_id, + model, + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + }); + } + StreamEvent::ReasoningCompleted { signature } => { + // A pending signature belongs to a new block, so finalize the current one first. + if self.open_signature.is_some() { + if self.include_partials() { + if let Some(init) = self.ensure_init() { + out.push(init); + } + if self.framing.open_block().is_some() { + self.partial_close_block(&mut out); + } else { + self.partial_signature_only_block(&mut out); + } + } + self.finalize_open(); + } + self.open_signature = signature; + } + StreamEvent::ResponseCompleted { + message_id, + stop_reason, + usage, + signature, + stop_sequence, + } => { + self.flush_boundary(&mut out); + // Drop a late completion for an already-flushed response (id differs), else it cross-attributes. + let open_id = self.response.identity().message_id; + let stale = self.response.is_started() + && matches!((&open_id, &message_id), (Some(o), Some(d)) if o != d); + if stale { + tracing::warn!( + open_id = ?open_id, + completed_id = ?message_id, + "messages: dropping late ResponseCompleted for an already-flushed response" + ); + } else { + let usage: Option = usage.as_ref().map(MessageUsage::from); + self.response.complete(PendingResponse { + message_id, + stop_reason, + usage, + signature, + stop_sequence, + }); + } + } + StreamEvent::Lifecycle(_) | StreamEvent::Plan(_) => {} + } + out + } + + fn max_turns(&mut self) -> Vec { + self.max_turns_hit = true; + Vec::new() + } + + fn finish(&mut self, end: &TurnEnd<'_>) -> Vec { + let mut out = Vec::new(); + let refused = end.stop_reason == "refusal"; + let cancelled = end.stop_reason == "cancelled"; + let structured_err = match &end.structured_output { + Some(Err(e)) => Some(e.clone()), + _ => None, + }; + // Default stop reason when no `ResponseCompleted` supplied one; `null` when the turn did not complete normally. + let did_not_complete_normally = + self.max_turns_hit || refused || cancelled || structured_err.is_some(); + let flush_default = if did_not_complete_normally { + None + } else { + match end.stop_reason { + "max_tokens" => Some("max_tokens"), + _ => Some("end_turn"), + } + }; + self.flush_terminal_preamble(&mut out, flush_default); + let (subtype, is_error, errors) = if self.max_turns_hit { + ( + "error_max_turns", + true, + Some(vec!["Reached the maximum number of turns".to_string()]), + ) + } else if refused { + ( + "error_during_execution", + true, + Some(vec!["The model refused to continue".to_string()]), + ) + } else if cancelled { + // No `cancelled` subtype in the Messages SDK, so use the catch-all `error_during_execution`. + ( + "error_during_execution", + true, + Some(vec!["cancelled".to_string()]), + ) + } else if let Some(msg) = structured_err { + ("error_max_structured_output_retries", true, Some(vec![msg])) + } else { + ("success", false, None) + }; + let structured_output = match end.structured_output.clone() { + Some(Ok(value)) if !is_error => Some(value), + _ => None, + }; + let ru = self.messages_result_usage(end.usage); + out.push(to_line(&MessagesLine::Result(Box::new(ResultLine { + subtype, + is_error, + duration_ms: end.duration_ms, + duration_api_ms: ru.duration_api_ms, + num_turns: ru.num_turns, + // Fall back to the caller's buffer only when no frame was flushed; else `last_text` is authoritative. + result: (!is_error).then(|| { + if self.assistant_frames == 0 && self.last_text.is_empty() { + end.result_text.to_string() + } else { + self.last_text.clone() + } + }), + stop_reason: Some(end.stop_reason.to_string()), + total_cost_usd: ru.total_cost_usd, + usage: ru.usage, + model_usage: ru.model_usage, + structured_output, + errors, + session_id: self.result_session_id(end.session_id).to_string(), + uuid: new_uuid(), + })))); + out + } + + fn error( + &mut self, + message: &str, + usage: Option<&Value>, + duration_ms: u64, + stop_reason: Option<&str>, + ) -> Vec { + let mut out = Vec::new(); + // Max-tokens truncation stamps `max_tokens`; any other error falls back to `null`. + let flush_default = match stop_reason { + Some("max_tokens") => Some("max_tokens"), + _ => None, + }; + self.flush_terminal_preamble(&mut out, flush_default); + let ru = self.messages_result_usage(usage); + out.push(to_line(&MessagesLine::Result(Box::new(ResultLine { + subtype: "error_during_execution", + is_error: true, + duration_ms, + duration_api_ms: ru.duration_api_ms, + num_turns: ru.num_turns, + result: None, + stop_reason: stop_reason.map(str::to_string), + total_cost_usd: ru.total_cost_usd, + usage: ru.usage, + model_usage: ru.model_usage, + structured_output: None, + errors: Some(vec![message.to_string()]), + session_id: self.session_id().to_string(), + uuid: new_uuid(), + })))); + out + } +} + +/// A `tool_use.input` must be a JSON object; anything else degrades to `{}`. +fn normalized_tool_input(raw: Value) -> Value { + if raw.is_object() { raw } else { json!({}) } +} + +/// Reduce a tool result to a `tool_result.content` string (verbatim, else compact JSON). +fn tool_result_content(output: Value, content: Value) -> Value { + match output { + Value::String(s) => Value::String(s), + Value::Null => match &content { + Value::Array(items) if !items.is_empty() => Value::String(content.to_string()), + _ => Value::String(String::new()), + }, + other => Value::String(other.to_string()), + } +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/partial.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/partial.rs new file mode 100644 index 0000000..a074375 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/partial.rs @@ -0,0 +1,242 @@ +//! The `--include-partial-messages` stream framing for `streaming-messages-json`: +//! the raw Messages API `stream_event` mechanics and their interaction with the +//! typed [`PartialFraming`] state. Only reachable when partial messages are on. + +use serde_json::{Value, json}; + +use crate::headless::reducer::to_line; + +use super::MessagesReducer; +use super::state::{OpenBlock, PartialFraming, TextKind}; +use super::wire::{ + EmptyObject, MessageDeltaBody, MessagesLine, PartialBlock, PartialDelta, PartialEventLine, + PartialMessage, StreamEventBody, new_uuid, +}; + +impl MessagesReducer { + /// Wrap a raw Messages API stream event in a `stream_event` line. + fn partial_wrap(&self, event: StreamEventBody) -> Value { + to_line(&MessagesLine::StreamEvent(PartialEventLine { + event: to_line(&event), + parent_tool_use_id: None, + session_id: self.session_id().to_string(), + uuid: new_uuid(), + })) + } + + /// Open the partial `message_start` on first use, carrying the real id, model, + /// and input-side usage (or a synthesized id and zero usage when absent). + fn partial_open_message(&mut self, out: &mut Vec) { + if self.framing.message_open() { + return; + } + // Clone (never move out) so the real values remain for the final frame. + let identity = self.response.identity(); + let id = identity.message_id.clone().unwrap_or_else(|| { + let id = format!("msg_{}", self.partial_msg_seq); + self.partial_msg_seq += 1; + id + }); + let model = self.frame_model(&identity); + out.push(self.partial_wrap(StreamEventBody::MessageStart { + message: PartialMessage { + id, + kind: "message", + role: "assistant", + model, + content: Vec::new(), + stop_reason: None, + stop_sequence: None, + usage: identity.input_usage(), + }, + })); + self.framing = PartialFraming::MessageOpen { block: None }; + } + + /// Emit the partial framing for a text/thinking delta at `index`, opening the + /// message and content block on first use. + pub(super) fn partial_delta( + &mut self, + out: &mut Vec, + kind: TextKind, + index: usize, + delta: PartialDelta, + ) { + self.partial_open_message(out); + let open = self.framing.open_block().unwrap_or_else(|| { + let content_block = match kind { + TextKind::Text => PartialBlock::Text { text: "" }, + TextKind::Thinking => PartialBlock::Thinking { + thinking: "", + signature: "", + }, + }; + out.push(self.partial_wrap(StreamEventBody::ContentBlockStart { + index, + content_block, + })); + let block = OpenBlock { index, kind }; + self.framing = PartialFraming::MessageOpen { block: Some(block) }; + block + }); + // Target the open block's own index, not the caller's, so a delta cannot drift. + out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta { + index: open.index, + delta, + })); + } + + /// Emit a full `tool_use` content block in the partial stream (one `input_json_delta`). + pub(super) fn partial_tool_use( + &mut self, + out: &mut Vec, + index: usize, + id: &str, + name: &str, + input: &Value, + ) { + self.partial_open_message(out); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStart { + index, + content_block: PartialBlock::ToolUse { + id: id.to_string(), + name: name.to_string(), + input: EmptyObject {}, + }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta { + index, + delta: PartialDelta::InputJson { + partial_json: input.to_string(), + }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index })); + } + + /// Emit the partial framing for a `server_tool_use` block (start, `input_json_delta`, stop). + pub(super) fn partial_server_tool_use( + &mut self, + out: &mut Vec, + index: usize, + id: &str, + query: &str, + ) { + self.partial_open_message(out); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStart { + index, + content_block: PartialBlock::ServerToolUse { + id: id.to_string(), + name: "web_search", + input: EmptyObject {}, + }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta { + index, + delta: PartialDelta::InputJson { + partial_json: json!({ "query": query }).to_string(), + }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index })); + } + + /// Emit the partial framing for a `web_search_tool_result` block (hits ride `content_block_start`). + pub(super) fn partial_web_search_result( + &mut self, + out: &mut Vec, + index: usize, + tool_use_id: &str, + hits: &Value, + ) { + self.partial_open_message(out); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStart { + index, + content_block: PartialBlock::WebSearchToolResult { + tool_use_id: tool_use_id.to_string(), + content: hits.clone(), + }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index })); + } + + /// Emit the partial framing for a signature-only thinking block (start + `signature_delta` + stop). + /// The signature is cloned (not taken) so `finalize_open` materializes the same block once. + pub(super) fn partial_signature_only_block(&mut self, out: &mut Vec) { + if !self.include_partials() + || self.open_kind.is_some() + || self.framing.open_block().is_some() + { + return; + } + let Some(signature) = self.open_signature.clone() else { + return; + }; + self.partial_open_message(out); + let index = self.blocks.len(); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStart { + index, + content_block: PartialBlock::Thinking { + thinking: "", + signature: "", + }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta { + index, + delta: PartialDelta::Signature { signature }, + })); + out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index })); + } + + /// Close the open content block. A thinking block emits `signature_delta` first + /// when its signature is known; cloned (not taken) so `finalize_open` can reuse it. + pub(super) fn partial_close_block(&mut self, out: &mut Vec) { + let Some(block) = self.framing.open_block() else { + return; + }; + let index = block.index; + if block.kind == TextKind::Thinking { + let sig = self + .open_signature + .clone() + .or_else(|| self.response.pending().and_then(|p| p.signature.clone())); + if let Some(sig) = sig { + out.push(self.partial_wrap(StreamEventBody::ContentBlockDelta { + index, + delta: PartialDelta::Signature { signature: sig }, + })); + } + } + out.push(self.partial_wrap(StreamEventBody::ContentBlockStop { index })); + self.framing = PartialFraming::MessageOpen { block: None }; + } + + /// Close the open message framing before a frame is flushed. `default_stop_reason` + /// must match `flush_assistant`'s so the partial rebuild and frame never disagree. + pub(super) fn partial_close_message( + &mut self, + out: &mut Vec, + default_stop_reason: Option<&str>, + ) { + if !self.include_partials() { + return; + } + self.partial_close_block(out); + self.partial_signature_only_block(out); + if !self.framing.message_open() && self.response.started() { + self.partial_open_message(out); + } + if self.framing.message_open() { + let stop_reason = self.resolved_stop_reason(default_stop_reason); + let usage = self.resolved_usage(); + let stop_sequence = self.resolved_stop_sequence(); + out.push(self.partial_wrap(StreamEventBody::MessageDelta { + delta: MessageDeltaBody { + stop_reason, + stop_sequence, + }, + usage, + })); + out.push(self.partial_wrap(StreamEventBody::MessageStop)); + self.framing = PartialFraming::Idle; + } + } +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/state.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/state.rs new file mode 100644 index 0000000..00da84c --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/state.rs @@ -0,0 +1,186 @@ +//! The `streaming-messages-json` reducer state: the per-response phase state +//! machine, partial-framing state, terminal metadata buffer, and session facts. + +use crate::headless::reducer::McpServer; + +use super::wire::MessageUsage; + +/// Metadata from the latest `ResponseCompleted`, cleared by the next `flush_assistant`. +#[derive(Default)] +pub(super) struct PendingResponse { + pub(super) message_id: Option, + pub(super) stop_reason: Option, + pub(super) usage: Option, + pub(super) signature: Option, + /// Provider's matched stop sequence; set only when `stop_reason == "stop_sequence"`. + pub(super) stop_sequence: Option, +} + +/// The real per-response identity from `ResponseStarted`: `message.id`, `model`, +/// and input-side usage. Retained (cloned, never moved) so both the partial +/// `message_start` and the final frame recover the same id/model/usage. +#[derive(Clone, Default)] +pub(super) struct ResponseIdentity { + pub(super) message_id: Option, + pub(super) model: Option, + pub(super) input_tokens: u64, + pub(super) cache_read_input_tokens: u64, + pub(super) cache_creation_input_tokens: u64, +} + +impl ResponseIdentity { + /// The input-side `message.usage` this identity seeds (`output_tokens` stays 0). + pub(super) fn input_usage(&self) -> MessageUsage { + MessageUsage { + input_tokens: self.input_tokens, + cache_read_input_tokens: self.cache_read_input_tokens, + cache_creation_input_tokens: self.cache_creation_input_tokens, + ..MessageUsage::default() + } + } +} + +/// The open block within a partial `message_start` envelope: its wire `index` and kind. +#[derive(Clone, Copy)] +pub(super) struct OpenBlock { + pub(super) index: usize, + pub(super) kind: TextKind, +} + +/// Typed `--include-partial-messages` framing state; an enum makes "block open with no message" unrepresentable. +pub(super) enum PartialFraming { + /// No partial `message_start` envelope is open. + Idle, + /// A `message_start` envelope is open; `block` is the open content block, if any. + MessageOpen { block: Option }, +} + +impl PartialFraming { + pub(super) fn message_open(&self) -> bool { + matches!(self, PartialFraming::MessageOpen { .. }) + } + + pub(super) fn open_block(&self) -> Option { + match self { + PartialFraming::MessageOpen { block } => *block, + PartialFraming::Idle => None, + } + } +} + +/// The lifecycle phase of the current model response. The retained identity and +/// pending metadata are dropped only when the response flushes, so one response's +/// id, usage, and signature cannot leak onto the next. +#[derive(Default)] +pub(super) enum ResponseState { + /// No response is open. + #[default] + Idle, + /// A `ResponseStarted` opened this response; its identity is retained until flush. + Started(ResponseIdentity), + /// A `ResponseCompleted` closed this response; awaiting flush. Retains the + /// identity and whether a `ResponseStarted` opened it. + Completed { + identity: ResponseIdentity, + pending: PendingResponse, + started: bool, + }, +} + +impl ResponseState { + /// This response's retained identity (default when none was surfaced). + pub(super) fn identity(&self) -> ResponseIdentity { + match self { + ResponseState::Idle => ResponseIdentity::default(), + ResponseState::Started(identity) | ResponseState::Completed { identity, .. } => { + identity.clone() + } + } + } + + /// Whether a `ResponseStarted` opened the current response. + pub(super) fn started(&self) -> bool { + match self { + ResponseState::Idle => false, + ResponseState::Started(_) => true, + ResponseState::Completed { started, .. } => *started, + } + } + + /// Whether a `ResponseCompleted` has closed but not yet flushed the response. + pub(super) fn is_completed(&self) -> bool { + matches!(self, ResponseState::Completed { .. }) + } + + /// Whether a `ResponseStarted` opened this response and it hasn't completed. + pub(super) fn is_started(&self) -> bool { + matches!(self, ResponseState::Started(_)) + } + + /// Whether a response is open at all (not `Idle`). + pub(super) fn is_active(&self) -> bool { + !matches!(self, ResponseState::Idle) + } + + /// The terminal metadata awaiting flush, if the response completed. + pub(super) fn pending(&self) -> Option<&PendingResponse> { + match self { + ResponseState::Completed { pending, .. } => Some(pending), + _ => None, + } + } + + /// Open the response with a real identity from `ResponseStarted`. A well-formed + /// stream always transitions from `Idle`; the debug assertion catches a skipped flush. + pub(super) fn open(&mut self, identity: ResponseIdentity) { + debug_assert!( + matches!(self, ResponseState::Idle), + "ResponseState::open called on a non-Idle response; the coordinator \ + must flush the prior response first" + ); + *self = ResponseState::Started(identity); + } + + /// Record the terminal `ResponseCompleted`, retaining the identity and `started` marker. + pub(super) fn complete(&mut self, pending: PendingResponse) { + *self = ResponseState::Completed { + identity: self.identity(), + started: self.started(), + pending, + }; + } + + /// Take the terminal metadata and reset to `Idle`, dropping the retained identity. + pub(super) fn take_pending(&mut self) -> PendingResponse { + match std::mem::take(self) { + ResponseState::Completed { pending, .. } => pending, + _ => PendingResponse::default(), + } + } + + /// Drop all per-response state so nothing leaks onto the next response. + pub(super) fn reset(&mut self) { + *self = ResponseState::Idle; + } +} + +/// The session facts captured at `MessagesReducer::begin`; `model` is `Option` +/// because a backend may not surface it until the first `ResponseStarted`. +pub(super) struct SessionState { + pub(super) session_id: String, + pub(super) model: Option, + pub(super) cwd: String, + pub(super) permission_mode: Option, + /// True when the session authenticated with an API key (vs OAuth). + pub(super) api_key_auth: bool, + pub(super) mcp_servers: Vec, + pub(super) include_partials: bool, + /// The current model's total context window in tokens, when known. + pub(super) context_window: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum TextKind { + Text, + Thinking, +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/acp_reducer.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/acp_reducer.rs new file mode 100644 index 0000000..9fe64ca --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/acp_reducer.rs @@ -0,0 +1,99 @@ +//! The streaming-json `AcpReducer` native-shape mapping. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn acp_reducer_maps_agent_message_to_text() { + let mut r = AcpReducer; + assert_eq!( + r.reduce(StreamEvent::AgentMessage("hi".into()))[0], + json!({"type": "text", "data": "hi"}) + ); +} + +#[test] +fn acp_reducer_maps_tool_call_to_native_shape() { + let mut r = AcpReducer; + assert_eq!( + r.reduce(StreamEvent::ToolCall(tool_call_ev()))[0], + json!({ + "type": "tool_call", + "toolCallId": "t1", + "title": "Bash", + "kind": "execute", + "status": "in_progress", + "toolName": "bash", + "rawInput": {"command": "ls"}, + "content": [], + "locations": [], + }) + ); +} + +#[test] +fn acp_reducer_maps_tool_call_update_to_native_shape() { + let mut r = AcpReducer; + assert_eq!( + r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!({"ok": true}), + )))[0], + json!({ + "type": "tool_call_update", + "toolCallId": "t1", + "status": "completed", + "content": [], + "rawOutput": {"ok": true}, + "locations": [], + }) + ); +} + +#[test] +fn acp_response_completed_emits_usage_line() { + let mut r = AcpReducer; + let out = r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_1".into()), + stop_reason: Some("tool_use".into()), + usage: Some(ResponseUsage { + input_tokens: 5, + output_tokens: 2, + ..Default::default() + }), + signature: Some("sig".into()), + stop_sequence: None, + }); + assert_eq!(out[0]["type"], "usage"); + assert_eq!(out[0]["messageId"], "msg_1"); + assert_eq!(out[0]["stopReason"], "tool_use"); + assert_eq!(out[0]["usage"]["input_tokens"], 5); + assert_eq!(out[0]["signature"], "sig"); +} + +#[test] +fn acp_finish_emits_end_line_with_usage_and_structured_output() { + let mut r = AcpReducer; + let aggregate = json!({ + "inputTokens": 5, + "outputTokens": 2, + "totalTokens": 7, + "numTurns": 1, + }); + let out = r.finish(&TurnEnd { + stop_reason: "end_turn", + session_id: "sess-1", + request_id: "req-1", + usage: Some(&aggregate), + structured_output: Some(Ok(json!({"name": "alice"}))), + result_text: "", + duration_ms: 0, + }); + let end = out.last().unwrap(); + assert_eq!(end["type"], "end"); + assert_eq!(end["stopReason"], "end_turn"); + assert_eq!(end["sessionId"], "sess-1"); + assert_eq!(end["requestId"], "req-1"); + assert_eq!(end["structuredOutput"]["name"], "alice"); + assert!(end["usage"].is_object()); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/content.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/content.rs new file mode 100644 index 0000000..e9fde42 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/content.rs @@ -0,0 +1,387 @@ +//! Text/thinking/signature blocks and assistant-frame boundaries (default mode). + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn messages_groups_thinking_and_coalesced_text() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentThought("mulling".into())); + r.reduce(StreamEvent::AgentMessage("Hello ".into())); + r.reduce(StreamEvent::AgentMessage("world".into())); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant message"); + assert_eq!(msg["type"], "assistant"); + assert_eq!(msg["message"]["stop_reason"], "end_turn"); + assert_eq!(msg["session_id"], "sess-1"); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["thinking"], "mulling"); + assert_eq!(blocks[1]["type"], "text"); + assert_eq!(blocks[1]["text"], "Hello world"); +} + +#[test] +fn messages_response_completed_stamps_assistant_frame() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentThought("plan".into())); + r.reduce(StreamEvent::AgentMessage("hi".into())); + assert!( + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_real".into()), + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + input_tokens: 12, + output_tokens: 7, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 0, + ..Default::default() + }), + signature: Some("sig-abc".into()), + stop_sequence: None, + }) + .is_empty() + ); + let msg = r.flush_assistant(Some("stop")).expect("assistant message"); + assert_eq!(msg["message"]["id"], "msg_real"); + assert_eq!(msg["message"]["stop_reason"], "end_turn"); + assert_eq!(msg["message"]["usage"]["input_tokens"], 12); + assert_eq!(msg["message"]["usage"]["output_tokens"], 7); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["signature"], "sig-abc"); +} + +#[test] +fn messages_multiple_thinking_blocks_stamp_signature_on_last_only() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentThought("first think".into())); + r.reduce(StreamEvent::AgentMessage("interlude".into())); + r.reduce(StreamEvent::AgentThought("second think".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_a".into()), + stop_reason: Some("end_turn".into()), + usage: None, + signature: Some("sig-final".into()), + stop_sequence: None, + }); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant message"); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["thinking"], "first think"); + assert_eq!(blocks[0]["signature"], ""); + assert_eq!(blocks[1]["type"], "text"); + assert_eq!(blocks[2]["type"], "thinking"); + assert_eq!(blocks[2]["thinking"], "second think"); + assert_eq!(blocks[2]["signature"], "sig-final"); +} + +#[test] +fn messages_response_completed_consumed_per_response() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("call".into())); + r.reduce(response_completed("msg_a", "tool_use")); + r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "in_progress", + Value::Null, + ))); + let out = r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + ))); + let assistant = out.iter().find(|m| m["type"] == "assistant").unwrap(); + assert_eq!(assistant["message"]["id"], "msg_a"); + assert_eq!(assistant["message"]["stop_reason"], "tool_use"); + r.reduce(StreamEvent::AgentMessage("next".into())); + let msg = r.flush_assistant(Some("end_turn")).expect("assistant"); + assert_eq!(msg["message"]["id"], "msg_0"); + assert_eq!(msg["message"]["stop_reason"], "end_turn"); +} + +#[test] +fn messages_signature_only_thinking_block_kept_in_frame() { + let mut r = messages(false); + r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-only".into()), + }); + r.reduce(StreamEvent::AgentMessage("answer".into())); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant frame"); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["thinking"], ""); + assert_eq!(blocks[0]["signature"], "sig-only"); + assert_eq!(blocks[1]["type"], "text"); + assert_eq!(blocks[1]["text"], "answer"); +} + +#[test] +fn messages_pure_signature_only_response_emits_thinking_block() { + let mut r = messages(false); + r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-only".into()), + }); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant frame"); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["thinking"], ""); + assert_eq!(blocks[0]["signature"], "sig-only"); +} + +#[test] +fn messages_no_spurious_empty_thinking_block() { + let mut r = messages(false); + assert!(r.flush_assistant(Some("end_turn")).is_none()); +} + +#[test] +fn messages_per_response_model_reflects_mid_session_switch() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", Some("grok-4"), 5))); + out.extend(r.reduce(StreamEvent::AgentMessage("from A".into()))); + out.extend(r.reduce(response_completed("msg_a", "end_turn"))); + out.extend(r.reduce(response_started("msg_b", Some("grok-4-fast"), 6))); + out.extend(r.reduce(StreamEvent::AgentMessage("from B".into()))); + out.extend(r.reduce(response_completed("msg_b", "end_turn"))); + out.extend(r.finish(&end_turn())); + let frames: Vec<&Value> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!(frames.len(), 2, "one frame per response: {out:?}"); + assert_eq!(frames[0]["message"]["id"], "msg_a"); + assert_eq!(frames[0]["message"]["model"], "grok-4"); + assert_eq!(frames[1]["message"]["id"], "msg_b"); + assert_eq!(frames[1]["message"]["model"], "grok-4-fast"); +} + +#[test] +fn messages_per_block_thinking_signatures_kept() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentThought("first think".into())); + r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-1".into()), + }); + r.reduce(StreamEvent::AgentMessage("interlude".into())); + r.reduce(StreamEvent::AgentThought("second think".into())); + r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-2".into()), + }); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_a".into()), + stop_reason: Some("end_turn".into()), + usage: None, + signature: Some("sig-2".into()), + stop_sequence: None, + }); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant message"); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["thinking"], "first think"); + assert_eq!(blocks[0]["signature"], "sig-1"); + assert_eq!(blocks[1]["type"], "text"); + assert_eq!(blocks[2]["type"], "thinking"); + assert_eq!(blocks[2]["thinking"], "second think"); + assert_eq!(blocks[2]["signature"], "sig-2"); +} + +#[test] +fn messages_assistant_frame_carries_stop_sequence() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("answer".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_seq".into()), + stop_reason: Some("stop_sequence".into()), + usage: None, + signature: None, + stop_sequence: Some("".into()), + }); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant frame"); + assert_eq!(msg["message"]["stop_reason"], "stop_sequence"); + assert_eq!(msg["message"]["stop_sequence"], ""); +} + +#[test] +fn messages_consecutive_text_responses_split_into_frames() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("first".into())); + r.reduce(response_completed("msg_a", "end_turn")); + let out = r.reduce(StreamEvent::AgentMessage("second".into())); + let a = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame A flushed on new content"); + assert_eq!(a["message"]["id"], "msg_a"); + assert_eq!(a["message"]["content"][0]["text"], "first"); + r.reduce(response_completed("msg_b", "end_turn")); + let out2 = r.finish(&turn_end("end_turn", "second")); + let b = out2 + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame B flushed at finish"); + assert_eq!(b["message"]["id"], "msg_b"); + assert_eq!(b["message"]["content"][0]["text"], "second"); +} + +#[test] +fn messages_duplicate_response_started_does_not_merge_content() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", Some("grok-4"), 5))); + out.extend(r.reduce(StreamEvent::AgentMessage("A".into()))); + out.extend(r.reduce(response_started("msg_b", Some("grok-4"), 6))); + out.extend(r.reduce(StreamEvent::AgentMessage("B".into()))); + out.extend(r.reduce(response_completed("msg_b", "end_turn"))); + out.extend(r.finish(&end_turn())); + let frames: Vec<&Value> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!(frames.len(), 2, "A flushed before B opens: {out:?}"); + assert_eq!(frames[0]["message"]["id"], "msg_a"); + assert_eq!(frames[0]["message"]["content"][0]["text"], "A"); + assert_eq!( + frames[0]["message"]["content"].as_array().unwrap().len(), + 1, + "A did not absorb B's content" + ); + assert_eq!(frames[1]["message"]["id"], "msg_b"); + assert_eq!(frames[1]["message"]["content"][0]["text"], "B"); + assert_eq!( + frames[1]["message"]["content"].as_array().unwrap().len(), + 1, + "B did not absorb A's content" + ); +} + +#[test] +fn messages_signature_only_restart_does_not_leak_signature() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", None, 0))); + out.extend(r.reduce(StreamEvent::AgentThought("mull".into()))); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-a".into()), + })); + out.extend(r.reduce(response_started("msg_b", None, 0))); + out.extend(r.reduce(StreamEvent::AgentMessage("B".into()))); + out.extend(r.reduce(response_completed("msg_b", "end_turn"))); + out.extend(r.finish(&end_turn())); + let frames: Vec<&Value> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!(frames.len(), 2, "{out:?}"); + assert_eq!(frames[0]["message"]["id"], "msg_a"); + assert_eq!(frames[0]["message"]["content"][0]["type"], "thinking"); + assert_eq!(frames[0]["message"]["content"][0]["signature"], "sig-a"); + assert_eq!(frames[1]["message"]["id"], "msg_b"); + assert_eq!(frames[1]["message"]["content"][0]["type"], "text"); + assert!( + frames[1]["message"]["content"] + .as_array() + .unwrap() + .iter() + .all(|b| b["type"] != "thinking"), + "no thinking block leaked into B: {:?}", + frames[1] + ); +} + +#[test] +fn messages_content_before_late_response_started_flushes_first() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentMessage("early".into()))); + out.extend(r.reduce(response_started("msg_b", None, 0))); + out.extend(r.reduce(StreamEvent::AgentMessage("late".into()))); + out.extend(r.reduce(response_completed("msg_b", "end_turn"))); + out.extend(r.finish(&end_turn())); + let frames: Vec<&Value> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!(frames.len(), 2, "early content is its own frame: {out:?}"); + assert_eq!(frames[0]["message"]["content"][0]["text"], "early"); + assert_eq!(frames[0]["message"]["id"], "msg_0"); + assert_eq!(frames[1]["message"]["id"], "msg_b"); + assert_eq!(frames[1]["message"]["content"][0]["text"], "late"); +} + +#[test] +fn messages_consecutive_signature_blocks_keep_own_signature() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentThought("first".into())); + r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-1".into()), + }); + r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-2".into()), + }); + let msg = r + .flush_assistant(Some("end_turn")) + .expect("assistant frame"); + let blocks = msg["message"]["content"].as_array().unwrap(); + assert_eq!( + blocks.len(), + 2, + "two thinking blocks, not collapsed: {blocks:?}" + ); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["thinking"], "first"); + assert_eq!(blocks[0]["signature"], "sig-1"); + assert_eq!(blocks[1]["type"], "thinking"); + assert_eq!(blocks[1]["thinking"], ""); + assert_eq!(blocks[1]["signature"], "sig-2"); +} + +#[test] +fn messages_compact_completed_maps_to_system_boundary() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("hi".into())); + let out = r.reduce(StreamEvent::Lifecycle(Lifecycle::CompactCompleted { + pre_tokens: 1234, + })); + let boundary = out.last().unwrap(); + assert_eq!(boundary["type"], "system"); + assert_eq!(boundary["subtype"], "compact_boundary"); + assert_eq!(boundary["compact_metadata"]["trigger"], "auto"); + assert_eq!(boundary["compact_metadata"]["pre_tokens"], 1234); +} + +#[test] +fn messages_late_response_completed_for_flushed_response_is_dropped() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", Some("grok-4"), 1))); + out.extend(r.reduce(StreamEvent::AgentMessage("a-text".into()))); + out.extend(r.reduce(response_started("msg_b", Some("grok-4"), 2))); + out.extend(r.reduce(StreamEvent::AgentMessage("b-text".into()))); + out.extend(r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_a".into()), + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + input_tokens: 99, + output_tokens: 99, + ..Default::default() + }), + signature: None, + stop_sequence: None, + })); + out.extend(r.finish(&end_turn())); + let assistants: Vec<_> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!( + assistants.len(), + 2, + "A flushed at B's start, B flushed at finish" + ); + assert_eq!(assistants[0]["message"]["id"], "msg_a"); + assert_eq!(assistants[0]["message"]["content"][0]["text"], "a-text"); + let b = assistants[1]; + assert_eq!(b["message"]["id"], "msg_b"); + assert_eq!(b["message"]["content"][0]["text"], "b-text"); + assert_ne!( + b["message"]["usage"]["input_tokens"], 99, + "A's late usage must not land on B" + ); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/init.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/init.rs new file mode 100644 index 0000000..505a51c --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/init.rs @@ -0,0 +1,128 @@ +//! Init / available-commands / skills projection. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn messages_init_is_deferred_and_carries_tools() { + let mut r = messages(false); + assert!( + r.reduce(StreamEvent::AvailableCommands { + tools: vec!["read_file".into(), "bash".into()], + commands: vec!["review".into()], + skills: Vec::new(), + }) + .is_empty() + ); + let out = r.reduce(StreamEvent::AgentMessage("hi".into())); + assert_eq!(out[0]["type"], "system"); + assert_eq!(out[0]["subtype"], "init"); + assert_eq!(out[0]["model"], "grok-4"); + assert_eq!(out[0]["permissionMode"], "bypassPermissions"); + assert_eq!(out[0]["tools"][0], "read_file"); + assert_eq!(out[0]["slash_commands"][0], "review"); + assert_eq!(out[0]["mcp_servers"][0]["name"], "linear"); + assert_eq!(out[0]["mcp_servers"][0]["status"], "connected"); + assert_eq!(out[0]["apiKeySource"], "user"); + assert!(out[0]["skills"].is_array()); + assert!(out[0]["claude_code_version"].is_null()); + assert!(out[0]["output_style"].is_null()); + assert!(out[0]["plugins"].is_null()); + assert!( + !r.reduce(StreamEvent::AgentMessage(" there".into())) + .iter() + .any(|m| m["type"] == "system") + ); +} + +#[test] +fn skill_names_extracts_only_skill_commands() { + let commands = vec![ + builtin_command("clear"), + skill_command("pdf"), + workflow_command("ship-it"), + skill_command("brainstorm"), + ]; + assert_eq!(skill_names(&commands), vec!["pdf", "brainstorm"]); +} + +#[test] +fn messages_init_carries_real_skills() { + let mut r = messages(false); + r.reduce(StreamEvent::AvailableCommands { + tools: vec!["bash".into()], + commands: vec!["clear".into(), "pdf".into(), "brainstorm".into()], + skills: vec!["pdf".into(), "brainstorm".into()], + }); + let out = r.reduce(StreamEvent::AgentMessage("hi".into())); + assert_eq!(out[0]["subtype"], "init"); + assert_eq!(out[0]["skills"][0], "pdf"); + assert_eq!(out[0]["skills"][1], "brainstorm"); +} + +#[test] +fn messages_init_skills_fallback_is_empty() { + let mut r = messages(false); + r.reduce(StreamEvent::AvailableCommands { + tools: vec!["bash".into()], + commands: vec!["clear".into()], + skills: Vec::new(), + }); + let out = r.reduce(StreamEvent::AgentMessage("hi".into())); + assert_eq!(out[0]["subtype"], "init"); + assert_eq!(out[0]["skills"], json!([])); +} + +#[test] +fn messages_init_maps_permission_mode_and_api_key_source() { + let mut r = MessagesReducer::new(); + r.begin(SessionContext { + session_id: "s".into(), + model: None, + cwd: "/c".into(), + permission_mode: Some("auto".into()), + mcp_servers: Vec::new(), + include_partial_messages: false, + api_key_auth: false, + context_window: None, + }); + let out = r.reduce(StreamEvent::AgentMessage("hi".into())); + assert_eq!(out[0]["permissionMode"], "default"); + assert_eq!(out[0]["apiKeySource"], "oauth"); + assert!(out[0]["model"].is_string(), "{:?}", out[0]["model"]); +} + +#[test] +fn messages_skills_stay_subset_when_later_command_update_is_empty() { + let mut r = messages(false); + r.reduce(StreamEvent::AvailableCommands { + tools: vec!["bash".into()], + commands: vec!["review".into(), "pdf".into()], + skills: vec!["pdf".into()], + }); + r.reduce(StreamEvent::AvailableCommands { + tools: Vec::new(), + commands: Vec::new(), + skills: Vec::new(), + }); + let out = r.reduce(StreamEvent::AgentMessage("hi".into())); + let cmds: Vec = out[0]["slash_commands"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let skills: Vec = out[0]["skills"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + assert!(skills.contains(&"pdf".to_string())); + for s in &skills { + assert!( + cmds.contains(s), + "skill {s} escaped slash_commands {cmds:?}" + ); + } +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/mod.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/mod.rs new file mode 100644 index 0000000..4a1e6e1 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/mod.rs @@ -0,0 +1,206 @@ +//! Reducer test suite. A child module of `messages` so it can reach +//! `MessagesReducer`'s private state directly (and the coordinator's re-exported +//! `wire`/`state` items via `use super::*`), while pulling the shared +//! transport/`acp` reducer items in from the crate root. + +use super::usage::messages_model_usage; +use super::wire::ModelUsage; +use super::*; +use crate::headless::reducer::acp::AcpReducer; +use crate::headless::reducer::{McpServer, skill_names, tool_call_event}; +use serde::Serialize; +use serde_json::{Value, json}; +use xai_grok_shell::extensions::notification::ResponseUsage; + +fn tool_call_ev() -> ToolCallEvent { + ToolCallEvent { + tool_call_id: "t1".into(), + title: "Bash".into(), + tool_kind: Some("execute".into()), + status: Some(acp::ToolCallStatus::InProgress), + tool_name: "bash".into(), + raw_input: json!({"command": "ls"}), + content: json!([]), + locations: json!([]), + backend_web_search: false, + } +} + +/// A backend `web_search` `ToolCall`, as `tool_call_event` classifies it from +/// the shell's `_meta.backend == true` + `raw_input.variant == "WebSearch"`. +fn web_search_call(id: &str) -> ToolCallEvent { + ToolCallEvent { + tool_call_id: id.into(), + title: "Web search:".into(), + tool_kind: Some("search".into()), + status: Some(acp::ToolCallStatus::InProgress), + tool_name: "web_search".into(), + raw_input: json!({"variant": "WebSearch", "backend": true}), + content: json!([]), + locations: json!([]), + backend_web_search: true, + } +} + +/// A terminal backend `web_search` `ToolCallUpdate` carrying Grok's nested +/// `WebSearchCall` `raw_output` (`action.query` + `action.sources[].url`). +fn web_search_done(id: &str) -> ToolCallUpdateEvent { + ToolCallUpdateEvent { + tool_call_id: id.into(), + status: Some(acp::ToolCallStatus::Completed), + content: json!([]), + raw_output: json!({ + "id": id, + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "rust async runtime", + "sources": [ + {"type": "url", "url": "https://tokio.rs", "title": "Tokio"}, + {"type": "url", "url": "https://async.rs"}, + ], + }, + }), + locations: json!([]), + } +} + +fn tool_update(status: &str, raw_output: Value) -> ToolCallUpdateEvent { + ToolCallUpdateEvent { + tool_call_id: "t1".into(), + status: serde_json::from_value(Value::String(status.into())).ok(), + content: json!([]), + raw_output, + locations: json!([]), + } +} + +fn messages(partials: bool) -> MessagesReducer { + let mut r = MessagesReducer::new(); + r.begin(SessionContext { + session_id: "sess-1".into(), + model: Some("grok-4".into()), + cwd: "/repo".into(), + permission_mode: Some("bypassPermissions".into()), + mcp_servers: vec![McpServer { + name: "linear".into(), + status: "connected".into(), + }], + include_partial_messages: partials, + api_key_auth: true, + context_window: Some(256_000), + }); + r +} + +/// A skill command carries `_meta.scope` + `_meta.path`; a workflow carries +/// `workflowPath`/`workflowSource`; a builtin carries no `_meta`. Only the +/// skill is projected into `init.skills`. +fn skill_command(name: &str) -> acp::AvailableCommand { + let meta = serde_json::json!({"scope": "user", "path": "/skills/foo.md"}) + .as_object() + .cloned(); + acp::AvailableCommand::new(name.to_string(), "a skill".to_string()).meta(meta) +} + +fn workflow_command(name: &str) -> acp::AvailableCommand { + let meta = serde_json::json!({"workflowSource": "user", "workflowPath": "/wf.md"}) + .as_object() + .cloned(); + acp::AvailableCommand::new(name.to_string(), "a workflow".to_string()).meta(meta) +} + +fn builtin_command(name: &str) -> acp::AvailableCommand { + acp::AvailableCommand::new(name.to_string(), "a builtin".to_string()) +} + +fn stream_delta(out: &[Value]) -> &Value { + out.iter() + .find(|m| m["type"] == "stream_event" && m["event"]["type"] == "content_block_delta") + .expect("a content_block_delta stream_event") +} + +/// A `Failed` terminal backend `web_search` update carrying no results. +fn web_search_failed(id: &str) -> ToolCallUpdateEvent { + ToolCallUpdateEvent { + tool_call_id: id.into(), + status: Some(acp::ToolCallStatus::Failed), + content: json!([]), + raw_output: json!({"id": id, "type": "web_search_call", "status": "failed"}), + locations: json!([]), + } +} + +/// A completed backend `WebSearch` update for a NON-search action (e.g. +/// open_page): the `raw_output` carries no `action.query`/`action.sources`. +fn web_search_non_search(id: &str) -> ToolCallUpdateEvent { + ToolCallUpdateEvent { + tool_call_id: id.into(), + status: Some(acp::ToolCallStatus::Completed), + content: json!([]), + raw_output: json!({ + "id": id, + "type": "web_search_call", + "status": "completed", + "action": {"type": "open_page", "url": "https://example.com"}, + }), + locations: json!([]), + } +} + +/// A small `TurnEnd` for a clean end-of-turn flush at `sess-1`. +fn end_turn() -> TurnEnd<'static> { + TurnEnd { + stop_reason: "end_turn", + session_id: "sess-1", + request_id: "req-1", + usage: None, + structured_output: None, + result_text: "", + duration_ms: 0, + } +} + +/// A `ResponseStarted` with the given id/model and input tokens; cache buckets zero. +fn response_started(id: &str, model: Option<&str>, input_tokens: u64) -> StreamEvent { + StreamEvent::ResponseStarted { + message_id: Some(id.into()), + model: model.map(str::to_string), + input_tokens, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + } +} + +/// A `ResponseCompleted` carrying only an id and stop reason (no usage/signature/stop sequence). +fn response_completed(id: &str, stop_reason: &str) -> StreamEvent { + StreamEvent::ResponseCompleted { + message_id: Some(id.into()), + stop_reason: Some(stop_reason.into()), + usage: None, + signature: None, + stop_sequence: None, + } +} + +/// A `TurnEnd` at `sess-1`/`req-1` with no usage/structured output and zero duration. +fn turn_end(stop_reason: &'static str, result_text: &'static str) -> TurnEnd<'static> { + TurnEnd { + stop_reason, + session_id: "sess-1", + request_id: "req-1", + usage: None, + structured_output: None, + result_text, + duration_ms: 0, + } +} + +mod acp_reducer; +mod content; +mod init; +mod partial; +mod result_usage; +mod tool_calls; +mod web_search; diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/partial.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/partial.rs new file mode 100644 index 0000000..9e26e2e --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/partial.rs @@ -0,0 +1,444 @@ +//! `--include-partial-messages` streaming framing. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn messages_partial_deltas_emitted_when_enabled() { + let mut r = messages(true); + let out = r.reduce(StreamEvent::AgentMessage("hi".into())); + let start = out + .iter() + .find(|m| m["event"]["type"] == "message_start") + .expect("message_start"); + assert!(start["event"]["message"]["model"].is_string()); + assert!(start["event"]["message"]["usage"].is_object()); + let block_start = out + .iter() + .find(|m| m["event"]["type"] == "content_block_start") + .expect("content_block_start"); + assert_eq!(block_start["event"]["content_block"]["type"], "text"); + assert_eq!(block_start["event"]["content_block"]["text"], ""); + let delta = stream_delta(&out); + assert_eq!(delta["event"]["index"], 0); + assert_eq!(delta["event"]["delta"]["type"], "text_delta"); + assert_eq!(delta["event"]["delta"]["text"], "hi"); +} + +#[test] +fn messages_partial_framing_closes_with_stop_reason_and_usage() { + let mut r = messages(true); + r.reduce(StreamEvent::AgentMessage("hi".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_a".into()), + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + input_tokens: 3, + output_tokens: 7, + ..Default::default() + }), + signature: None, + stop_sequence: None, + }); + let out = r.reduce(StreamEvent::AgentMessage("more".into())); + let delta = out + .iter() + .find(|m| m["event"]["type"] == "message_delta") + .expect("message_delta closes the prior message"); + assert_eq!(delta["event"]["delta"]["stop_reason"], "end_turn"); + assert_eq!(delta["event"]["usage"]["output_tokens"], 7); + assert_eq!(delta["event"]["usage"]["input_tokens"], 3); + assert!(out.iter().any(|m| m["event"]["type"] == "message_stop")); +} + +#[test] +fn messages_partial_tool_use_framed() { + let mut r = messages(true); + r.reduce(StreamEvent::AgentMessage("run".into())); + let out = r.reduce(StreamEvent::ToolCall(tool_call_ev())); + let start = out + .iter() + .find(|m| { + m["event"]["type"] == "content_block_start" + && m["event"]["content_block"]["type"] == "tool_use" + }) + .expect("tool_use content_block_start"); + assert_eq!(start["event"]["content_block"]["name"], "bash"); + assert!( + out.iter() + .any(|m| m["event"]["delta"]["type"] == "input_json_delta") + ); +} + +#[test] +fn messages_partial_tool_flush_without_pending_agrees_on_stop_reason() { + let mut r = messages(true); + r.reduce(StreamEvent::AgentMessage("searching".into())); + r.reduce(StreamEvent::ToolCall(tool_call_ev())); + let out = r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + ))); + let delta = out + .iter() + .find(|m| m["event"]["type"] == "message_delta") + .expect("message_delta"); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame"); + assert_eq!(delta["event"]["delta"]["stop_reason"], "tool_use"); + assert_eq!(assistant["message"]["stop_reason"], "tool_use"); +} + +#[test] +fn messages_partial_delta_index_tracks_block() { + let mut r = messages(true); + let t = r.reduce(StreamEvent::AgentThought("mull".into())); + assert_eq!(stream_delta(&t)["event"]["index"], 0); + let x = r.reduce(StreamEvent::AgentMessage("hi".into())); + assert_eq!(stream_delta(&x)["event"]["index"], 1); + assert!(x.iter().any(|m| m["event"]["type"] == "content_block_stop")); +} + +#[test] +fn messages_partial_thinking_then_text_defers_signature_to_frame() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentThought("mull".into()))); + out.extend(r.reduce(StreamEvent::AgentMessage("hi".into()))); + out.extend(r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_real".into()), + stop_reason: Some("end_turn".into()), + usage: None, + signature: Some("sig-xyz".into()), + stop_sequence: None, + })); + assert!( + !out.iter() + .any(|m| m["event"]["delta"]["type"] == "signature_delta") + ); + let start = out + .iter() + .find(|m| m["event"]["type"] == "message_start") + .expect("message_start"); + assert_eq!(start["event"]["message"]["id"], "msg_0"); + let frame = r + .flush_assistant(Some("end_turn")) + .expect("assistant frame"); + assert_eq!(frame["message"]["id"], "msg_real"); + assert_eq!(frame["message"]["content"][0]["signature"], "sig-xyz"); +} + +#[test] +fn messages_partial_response_started_emits_real_id_and_input_usage() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::ResponseStarted { + message_id: Some("msg_real".into()), + model: Some("grok-4".into()), + input_tokens: 42, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 20, + })); + out.extend(r.reduce(StreamEvent::AgentThought("mull".into()))); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-xyz".into()), + })); + out.extend(r.reduce(StreamEvent::AgentMessage("hi".into()))); + out.extend(r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_real".into()), + stop_reason: Some("end_turn".into()), + usage: None, + signature: Some("sig-xyz".into()), + stop_sequence: None, + })); + + let start = out + .iter() + .find(|m| m["event"]["type"] == "message_start") + .expect("message_start"); + assert_eq!(start["event"]["message"]["id"], "msg_real"); + assert_eq!(start["event"]["message"]["usage"]["input_tokens"], 42); + assert_eq!( + start["event"]["message"]["usage"]["cache_read_input_tokens"], + 100 + ); + assert_eq!( + start["event"]["message"]["usage"]["cache_creation_input_tokens"], + 20 + ); + assert_eq!(start["event"]["message"]["usage"]["output_tokens"], 0); + + let sig = out + .iter() + .position(|m| m["event"]["delta"]["type"] == "signature_delta") + .expect("signature_delta emitted in order"); + assert_eq!(out[sig]["event"]["delta"]["signature"], "sig-xyz"); + let stop = out + .iter() + .position(|m| m["event"]["type"] == "content_block_stop") + .expect("content_block_stop"); + assert!(sig < stop, "signature_delta precedes content_block_stop"); + + let frame = r + .flush_assistant(Some("end_turn")) + .expect("assistant frame"); + assert_eq!(frame["message"]["id"], "msg_real"); + assert_eq!(frame["message"]["content"][0]["signature"], "sig-xyz"); +} + +#[test] +fn messages_partial_response_started_ids_do_not_leak_across_responses() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::ResponseStarted { + message_id: Some("msg_real".into()), + model: None, + input_tokens: 9, + cache_read_input_tokens: 5, + cache_creation_input_tokens: 0, + })); + out.extend(r.reduce(StreamEvent::AgentMessage("one".into()))); + out.extend(r.reduce(response_completed("msg_real", "end_turn"))); + out.extend(r.reduce(StreamEvent::AgentMessage("two".into()))); + let starts: Vec<&Value> = out + .iter() + .filter(|m| m["event"]["type"] == "message_start") + .collect(); + assert_eq!(starts[0]["event"]["message"]["id"], "msg_real"); + assert_eq!(starts[0]["event"]["message"]["usage"]["input_tokens"], 9); + assert_eq!( + starts[0]["event"]["message"]["usage"]["cache_read_input_tokens"], + 5 + ); + assert_eq!(starts[1]["event"]["message"]["id"], "msg_0"); + assert_eq!(starts[1]["event"]["message"]["usage"]["input_tokens"], 0); + assert_eq!( + starts[1]["event"]["message"]["usage"]["cache_read_input_tokens"], + 0 + ); + assert_eq!( + starts[1]["event"]["message"]["usage"]["cache_creation_input_tokens"], + 0 + ); +} + +#[test] +fn messages_partial_thinking_terminal_emits_signature_delta() { + let mut r = messages(true); + r.reduce(StreamEvent::AgentThought("mull".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_a".into()), + stop_reason: Some("end_turn".into()), + usage: None, + signature: Some("sig-term".into()), + stop_sequence: None, + }); + let out = r.reduce(StreamEvent::AgentMessage("answer".into())); + let sig = out + .iter() + .position(|m| m["event"]["delta"]["type"] == "signature_delta") + .expect("signature_delta emitted"); + assert_eq!(out[sig]["event"]["delta"]["signature"], "sig-term"); + let stop = out + .iter() + .position(|m| m["event"]["type"] == "content_block_stop") + .expect("content_block_stop"); + assert!(sig < stop, "signature_delta precedes content_block_stop"); +} + +#[test] +fn messages_partial_message_start_ids_are_unique() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentMessage("one".into()))); + out.extend(r.reduce(response_completed("msg_a", "end_turn"))); + out.extend(r.reduce(StreamEvent::AgentMessage("two".into()))); + let ids: Vec = out + .iter() + .filter(|m| m["event"]["type"] == "message_start") + .map(|m| m["event"]["message"]["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, vec!["msg_0", "msg_1"]); +} + +#[test] +fn messages_partial_signature_only_thinking_block_emits_framing() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", Some("grok-4"), 5))); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-only".into()), + })); + out.extend(r.reduce(StreamEvent::AgentMessage("answer".into()))); + out.extend(r.reduce(response_completed("msg_a", "end_turn"))); + out.extend(r.finish(&end_turn())); + let cb_start = out + .iter() + .position(|m| { + m["event"]["type"] == "content_block_start" + && m["event"]["content_block"]["type"] == "thinking" + }) + .expect("thinking content_block_start"); + let sig = out + .iter() + .position(|m| m["event"]["delta"]["type"] == "signature_delta") + .expect("signature_delta"); + assert_eq!(out[cb_start]["event"]["index"], 0); + assert_eq!(out[sig]["event"]["delta"]["signature"], "sig-only"); + assert!( + cb_start < sig, + "content_block_start precedes signature_delta" + ); + let frame = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame"); + let blocks = frame["message"]["content"].as_array().unwrap(); + assert_eq!(blocks[0]["type"], "thinking"); + assert_eq!(blocks[0]["signature"], "sig-only"); + assert_eq!(blocks[1]["type"], "text"); + assert_eq!(blocks[1]["text"], "answer"); +} + +#[test] +fn messages_partial_per_block_signature_deltas() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentThought("first think".into()))); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-1".into()), + })); + out.extend(r.reduce(StreamEvent::AgentMessage("interlude".into()))); + out.extend(r.reduce(StreamEvent::AgentThought("second think".into()))); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-2".into()), + })); + out.extend(r.finish(&end_turn())); + let sigs: Vec<&str> = out + .iter() + .filter(|m| m["event"]["delta"]["type"] == "signature_delta") + .map(|m| m["event"]["delta"]["signature"].as_str().unwrap()) + .collect(); + assert_eq!(sigs, vec!["sig-1", "sig-2"], "each block keeps its own sig"); +} + +#[test] +fn messages_partial_empty_response_still_frames_message() { + let mut r = messages(true); + r.reduce(response_started("msg_empty", Some("grok-4"), 5)); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_empty".into()), + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + input_tokens: 5, + output_tokens: 0, + ..Default::default() + }), + signature: None, + stop_sequence: None, + }); + let out = r.finish(&end_turn()); + let start = out + .iter() + .find(|m| m["event"]["type"] == "message_start") + .expect("message_start for the empty response"); + assert!(out.iter().any(|m| m["event"]["type"] == "message_delta")); + assert!(out.iter().any(|m| m["event"]["type"] == "message_stop")); + assert!( + !out.iter().any(|m| m["event"]["type"] + .as_str() + .is_some_and(|t| t.starts_with("content_block"))), + "no content_block_* events: {out:?}" + ); + assert!(out.iter().all(|m| m["type"] != "assistant"), "{out:?}"); + assert_eq!(start["event"]["message"]["id"], "msg_empty"); + assert_eq!(start["event"]["message"]["usage"]["input_tokens"], 5); +} + +#[test] +fn messages_partial_empty_then_real_response_do_not_cross_attribute() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", None, 11))); + out.extend(r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_a".into()), + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + input_tokens: 11, + output_tokens: 0, + ..Default::default() + }), + signature: None, + stop_sequence: None, + })); + out.extend(r.reduce(response_started("msg_b", None, 22))); + out.extend(r.reduce(StreamEvent::AgentMessage("real".into()))); + let starts: Vec<&Value> = out + .iter() + .filter(|m| m["event"]["type"] == "message_start") + .collect(); + assert_eq!(starts.len(), 2, "one envelope per response: {out:?}"); + assert_eq!(starts[0]["event"]["message"]["id"], "msg_a"); + assert_eq!(starts[0]["event"]["message"]["usage"]["input_tokens"], 11); + assert_eq!(starts[1]["event"]["message"]["id"], "msg_b"); + assert_eq!(starts[1]["event"]["message"]["usage"]["input_tokens"], 22); +} + +#[test] +fn messages_partial_message_delta_carries_stop_sequence() { + let mut r = messages(true); + r.reduce(StreamEvent::AgentMessage("answer".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: Some("msg_seq".into()), + stop_reason: Some("stop_sequence".into()), + usage: None, + signature: None, + stop_sequence: Some("".into()), + }); + let out = r.reduce(StreamEvent::AgentMessage("more".into())); + let delta = out + .iter() + .find(|m| m["event"]["type"] == "message_delta") + .expect("message_delta closes the prior message"); + assert_eq!(delta["event"]["delta"]["stop_reason"], "stop_sequence"); + assert_eq!(delta["event"]["delta"]["stop_sequence"], ""); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + assert_eq!(assistant["message"]["stop_sequence"], ""); + let start = out.iter().find(|m| m["event"]["type"] == "message_start"); + if let Some(start) = start { + assert!(start["event"]["message"]["stop_sequence"].is_null()); + } +} + +#[test] +fn messages_partial_consecutive_signature_blocks_keep_own_signature() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentThought("first".into()))); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-1".into()), + })); + out.extend(r.reduce(StreamEvent::ReasoningCompleted { + signature: Some("sig-2".into()), + })); + out.extend(r.finish(&end_turn())); + let sigs: Vec<&str> = out + .iter() + .filter(|m| m["event"]["delta"]["type"] == "signature_delta") + .map(|m| m["event"]["delta"]["signature"].as_str().unwrap()) + .collect(); + assert_eq!(sigs, vec!["sig-1", "sig-2"], "{out:?}"); + let frame = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + let blocks = frame["message"]["content"].as_array().unwrap(); + assert_eq!(blocks.len(), 2, "{blocks:?}"); + assert_eq!(blocks[0]["signature"], "sig-1"); + assert_eq!(blocks[1]["signature"], "sig-2"); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/result_usage.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/result_usage.rs new file mode 100644 index 0000000..b5f3114 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/result_usage.rs @@ -0,0 +1,529 @@ +//! Terminal result line: usage/modelUsage, error subtypes, cost, num_turns. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn messages_usage_drops_reasoning_tokens() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("hi".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: None, + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + input_tokens: 4, + output_tokens: 2, + cache_read_input_tokens: 1, + cache_creation_input_tokens: 0, + reasoning_tokens: 9, + }), + signature: None, + stop_sequence: None, + }); + let msg = r.flush_assistant(Some("end_turn")).expect("assistant"); + let usage = &msg["message"]["usage"]; + assert_eq!(usage["input_tokens"], 4); + assert_eq!(usage["output_tokens"], 2); + assert!(usage.get("reasoning_tokens").is_none(), "{usage:?}"); +} + +#[test] +fn messages_refusal_marks_result_error() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("declined".into())); + let out = r.finish(&turn_end("refusal", "declined")); + let result = out.last().expect("result line"); + assert_eq!(result["type"], "result"); + assert_eq!(result["is_error"], true, "{result:?}"); + assert_eq!(result["subtype"], "error_during_execution", "{result:?}"); + assert!(result["errors"].is_array(), "{result:?}"); + assert!( + result.get("result").is_none(), + "error result omits result text" + ); +} + +#[test] +fn messages_result_carries_required_fields() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("hi".into())); + let out = r.finish(&turn_end("end_turn", "done")); + let result = out.last().expect("result line"); + assert_eq!(result["subtype"], "success"); + assert_eq!(result["result"], "hi"); + assert_eq!(result["stop_reason"], "end_turn"); + for key in [ + "duration_ms", + "duration_api_ms", + "num_turns", + "total_cost_usd", + "modelUsage", + ] { + assert!(result.get(key).is_some(), "missing {key}: {result:?}"); + } + assert!(result["permission_denials"].is_null()); + for key in [ + "input_tokens", + "output_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ] { + assert!(result["usage"].get(key).is_some(), "usage missing {key}"); + } +} + +#[test] +fn messages_result_usage_splits_disjoint_buckets() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("hi".into())); + let aggregate = json!({ + "inputTokens": 100, + "outputTokens": 7, + "totalTokens": 107, + "cachedReadTokens": 10, + "cacheCreationTokens": 5, + "numTurns": 1, + }); + let out = r.finish(&TurnEnd { + stop_reason: "end_turn", + session_id: "sess-1", + request_id: "req-1", + usage: Some(&aggregate), + structured_output: None, + result_text: "", + duration_ms: 0, + }); + let usage = &out.last().unwrap()["usage"]; + assert_eq!(usage["input_tokens"], 85); + assert_eq!(usage["cache_read_input_tokens"], 10); + assert_eq!(usage["cache_creation_input_tokens"], 5); + assert_eq!(usage["output_tokens"], 7); +} + +#[test] +fn messages_result_usage_incomplete_aggregate_zeroes_buckets() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("hi".into())); + r.reduce(StreamEvent::ResponseCompleted { + message_id: None, + stop_reason: Some("end_turn".into()), + usage: Some(ResponseUsage { + cache_creation_input_tokens: 5, + ..Default::default() + }), + signature: None, + stop_sequence: None, + }); + let out = r.finish(&end_turn()); + let usage = &out.last().unwrap()["usage"]; + assert_eq!(usage["input_tokens"], 0); + assert_eq!(usage["cache_creation_input_tokens"], 0); +} + +#[test] +fn messages_model_usage_maps_and_zero_fills() { + let rows = json!({ + "grok-4": {"inputTokens": 90, "outputTokens": 7, "cacheReadInputTokens": 10, "cacheCreationInputTokens": 25, "costUSD": 0.02}, + }); + let out = messages_model_usage(Some(&rows), Some("grok-4"), 0, Some(131_072)); + let mu = &out["grok-4"]; + assert_eq!(mu["inputTokens"], 90); + assert_eq!(mu["outputTokens"], 7); + assert_eq!(mu["cacheReadInputTokens"], 10); + assert_eq!(mu["cacheCreationInputTokens"], 25); + assert_eq!(mu["webSearchRequests"], 0); + assert_eq!(mu["contextWindow"], 131_072); + assert!(mu["maxOutputTokens"].is_null()); + assert!((mu["costUSD"].as_f64().unwrap() - 0.02).abs() < 1e-9); + assert_eq!(messages_model_usage(None, None, 0, None), json!({})); +} + +#[test] +fn messages_model_usage_attributes_web_search_to_current_model() { + let rows = json!({ + "grok-4": {"inputTokens": 90, "outputTokens": 7, "costUSD": 0.02}, + "grok-mini": {"inputTokens": 5, "outputTokens": 1}, + }); + let out = messages_model_usage(Some(&rows), Some("grok-4"), 3, Some(131_072)); + assert_eq!(out["grok-4"]["webSearchRequests"], 3); + assert_eq!(out["grok-mini"]["webSearchRequests"], 0); + assert_eq!(out["grok-4"]["contextWindow"], 131_072); + assert!(out["grok-mini"]["contextWindow"].is_null()); +} + +#[test] +fn messages_result_carries_durations() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("hi".into())); + let aggregate = json!({"inputTokens": 10, "outputTokens": 2, "apiDurationMs": 1234}); + let out = r.finish(&TurnEnd { + stop_reason: "end_turn", + session_id: "sess-1", + request_id: "req-1", + usage: Some(&aggregate), + structured_output: None, + result_text: "", + duration_ms: 4242, + }); + let result = out.last().unwrap(); + assert_eq!(result["duration_ms"], 4242); + assert_eq!(result["duration_api_ms"], 1234); +} + +#[test] +fn messages_error_flushes_then_marks_error_result() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("partial answer".into())); + let out = r.error("boom", None, 0, None); + assert!(out.iter().any(|m| m["type"] == "assistant")); + let result = out.last().unwrap(); + assert_eq!(result["type"], "result"); + assert_eq!(result["subtype"], "error_during_execution"); + assert_eq!(result["is_error"], true); + assert_eq!(result["errors"][0], "boom"); + assert!(result.get("result").is_none()); + let assistant = out.iter().find(|m| m["type"] == "assistant").unwrap(); + assert!( + assistant["message"]["stop_reason"].is_null(), + "generic error frame reports null stop_reason, not end_turn: {assistant:?}" + ); + assert!(result["stop_reason"].is_null()); +} + +#[test] +fn messages_error_max_tokens_stamps_stop_reason() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage( + "partial before truncation".into(), + )); + let out = r.error("output truncated", None, 0, Some("max_tokens")); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("partial content flushed as an assistant frame"); + assert_eq!(assistant["message"]["stop_reason"], "max_tokens"); + assert_eq!( + assistant["message"]["content"][0]["text"], + "partial before truncation" + ); + let result = out.last().unwrap(); + assert_eq!(result["type"], "result"); + assert_eq!(result["subtype"], "error_during_execution"); + assert_eq!(result["is_error"], true); + assert_eq!(result["stop_reason"], "max_tokens"); +} + +#[test] +fn messages_partial_error_max_tokens_recovers_real_id_and_usage() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::ResponseStarted { + message_id: Some("msg_real".into()), + model: Some("grok-4".into()), + input_tokens: 42, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 20, + })); + out.extend(r.reduce(StreamEvent::AgentMessage( + "partial before truncation".into(), + ))); + out.extend(r.error("output truncated", None, 0, Some("max_tokens"))); + let start = out + .iter() + .find(|m| m["event"]["type"] == "message_start") + .expect("message_start"); + assert_eq!(start["event"]["message"]["id"], "msg_real"); + assert_eq!(start["event"]["message"]["usage"]["input_tokens"], 42); + assert_eq!( + start["event"]["message"]["usage"]["cache_read_input_tokens"], + 100 + ); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("partial content flushed as an assistant frame"); + assert_eq!(assistant["message"]["id"], "msg_real"); + assert_eq!(assistant["message"]["stop_reason"], "max_tokens"); + assert_eq!(assistant["message"]["usage"]["input_tokens"], 42); + assert_eq!( + assistant["message"]["usage"]["cache_read_input_tokens"], + 100 + ); + assert_eq!( + assistant["message"]["usage"]["cache_creation_input_tokens"], + 20 + ); + assert_eq!(assistant["message"]["usage"]["output_tokens"], 0); +} + +#[test] +fn messages_partial_error_max_tokens_delta_carries_input_usage() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::ResponseStarted { + message_id: Some("msg_real".into()), + model: Some("grok-4".into()), + input_tokens: 42, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 20, + })); + out.extend(r.reduce(StreamEvent::AgentMessage( + "partial before truncation".into(), + ))); + out.extend(r.error("output truncated", None, 0, Some("max_tokens"))); + let delta = out + .iter() + .find(|m| m["event"]["type"] == "message_delta") + .expect("message_delta"); + assert_eq!(delta["event"]["delta"]["stop_reason"], "max_tokens"); + assert_eq!(delta["event"]["usage"]["input_tokens"], 42); + assert_eq!(delta["event"]["usage"]["cache_read_input_tokens"], 100); + assert_eq!(delta["event"]["usage"]["cache_creation_input_tokens"], 20); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame"); + assert_eq!(assistant["message"]["usage"]["input_tokens"], 42); + assert_eq!( + assistant["message"]["usage"]["cache_read_input_tokens"], + 100 + ); +} + +#[test] +fn messages_partial_generic_error_delta_stop_reason_null() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentMessage("partial answer".into()))); + out.extend(r.error("boom", None, 0, None)); + let delta = out + .iter() + .find(|m| m["event"]["type"] == "message_delta") + .expect("message_delta"); + assert!( + delta["event"]["delta"]["stop_reason"].is_null(), + "generic error partial delta reports null stop_reason: {delta:?}" + ); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame"); + assert!(assistant["message"]["stop_reason"].is_null()); +} + +#[test] +fn messages_structured_output_error_marks_retry_subtype() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("bad output".into())); + let out = r.finish(&TurnEnd { + stop_reason: "end_turn", + session_id: "sess-1", + request_id: "req-1", + usage: None, + structured_output: Some(Err("output does not match schema".into())), + result_text: "", + duration_ms: 0, + }); + let result = out.last().unwrap(); + assert_eq!(result["subtype"], "error_max_structured_output_retries"); + assert_eq!(result["is_error"], true); + assert_eq!(result["errors"][0], "output does not match schema"); + assert!(result.get("result").is_none()); + assert!(result.get("structured_output").is_none()); +} + +#[test] +fn messages_max_turns_marks_error_subtype() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("working".into())); + assert!(r.max_turns().is_empty()); + let out = r.finish(&turn_end("cancelled", "")); + let result = out.last().expect("result line"); + assert_eq!(result["subtype"], "error_max_turns", "{result:?}"); + assert_eq!(result["is_error"], true, "{result:?}"); +} + +#[test] +fn to_line_degrades_failing_serialize_to_error_line() { + struct AlwaysFails; + impl Serialize for AlwaysFails { + fn serialize(&self, _s: S) -> Result { + Err(serde::ser::Error::custom("boom")) + } + } + let line = to_line(&AlwaysFails); + assert_eq!(line["type"], "error"); + let message = line["message"].as_str().expect("message string"); + assert!(message.contains("serialize failed"), "{message}"); + assert!(message.contains("boom"), "{message}"); +} + +#[test] +fn non_finite_cost_serializes_to_finite_result_frame() { + let line = to_line(&MessagesLine::Result(Box::new(ResultLine { + subtype: "success", + is_error: false, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + result: None, + stop_reason: None, + total_cost_usd: f64::INFINITY, + usage: MessageUsage::default(), + model_usage: json!({}), + structured_output: None, + errors: None, + session_id: "s".into(), + uuid: "u".into(), + }))); + assert_eq!(line["type"], "result", "not the error fallback: {line}"); + assert_eq!(line["total_cost_usd"], 0.0); + assert!(line["total_cost_usd"].as_f64().unwrap().is_finite()); + + let mu = to_line(&ModelUsage { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + web_search_requests: 0, + cost_usd: f64::NAN, + context_window: None, + }); + assert_ne!(mu["type"], "error", "not the error fallback: {mu}"); + assert_eq!(mu["costUSD"], 0.0); +} + +#[test] +fn messages_finish_abnormal_outcomes_stamp_null_stop_reason() { + let frame_stop = |stop_reason: &str, prime: fn(&mut MessagesReducer)| -> Value { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("streamed content".into())); + prime(&mut r); + let out = r.finish(&TurnEnd { + stop_reason, + session_id: "sess-1", + request_id: "req-1", + usage: None, + structured_output: None, + result_text: "", + duration_ms: 0, + }); + out.iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame")["message"]["stop_reason"] + .clone() + }; + assert!(frame_stop("refusal", |_| {}).is_null(), "refusal"); + assert!(frame_stop("cancelled", |_| {}).is_null(), "cancelled"); + assert!( + frame_stop("end_turn", |r| { + r.max_turns(); + }) + .is_null(), + "max_turns" + ); + assert_eq!(frame_stop("end_turn", |_| {}), "end_turn"); +} + +#[test] +fn messages_cancelled_turn_marks_error_result() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("partial before cancel".into())); + let out = r.finish(&turn_end("cancelled", "partial before cancel")); + let result = out.last().expect("result line"); + assert_eq!(result["type"], "result"); + assert_eq!(result["is_error"], true, "{result:?}"); + assert_ne!( + result["subtype"], "success", + "cancelled is not success: {result:?}" + ); + assert_eq!(result["subtype"], "error_during_execution", "{result:?}"); + assert_eq!(result["errors"][0], "cancelled", "{result:?}"); + assert!( + result.get("result").is_none(), + "error result omits result text" + ); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + assert!( + assistant["message"]["stop_reason"].is_null(), + "cancelled frame reports null stop_reason: {assistant:?}" + ); +} + +#[test] +fn messages_num_turns_counts_contentless_response() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(response_started("msg_a", None, 0))); + out.extend(r.reduce(StreamEvent::AgentMessage("hi".into()))); + out.extend(r.reduce(response_completed("msg_a", "end_turn"))); + out.extend(r.reduce(response_started("msg_b", None, 0))); + out.extend(r.reduce(response_completed("msg_b", "end_turn"))); + out.extend(r.finish(&end_turn())); + let frames = out.iter().filter(|m| m["type"] == "assistant").count(); + assert_eq!(frames, 1, "contentless B emits no frame: {out:?}"); + let result = out.last().expect("result line"); + assert_eq!( + result["num_turns"], 2, + "both the content-bearing and the contentless response count: {result:?}" + ); +} + +#[test] +fn messages_retry_exhausted_null_stop_reason_overrides_retained_end_turn() { + for partials in [false, true] { + let mut r = messages(partials); + r.reduce(StreamEvent::AgentMessage("streamed content".into())); + r.reduce(response_completed("msg_a", "end_turn")); + let out = r.finish(&TurnEnd { + stop_reason: "end_turn", + session_id: "sess-1", + request_id: "req-1", + usage: None, + structured_output: Some(Err("output does not match schema".into())), + result_text: "", + duration_ms: 0, + }); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + assert!( + assistant["message"]["stop_reason"].is_null(), + "retained end_turn must not win on failure (partials={partials}): {assistant:?}" + ); + let result = out.last().expect("result line"); + assert_eq!(result["subtype"], "error_max_structured_output_retries"); + if partials { + let delta = out + .iter() + .find(|m| m["event"]["type"] == "message_delta") + .expect("message_delta"); + assert!( + delta["event"]["delta"]["stop_reason"].is_null(), + "partial delta null too: {delta:?}" + ); + } + } +} + +#[test] +fn messages_late_orphaned_completion_does_not_inflate_num_turns() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::ToolCall(tool_call_ev()))); + out.extend(r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + )))); + out.extend(r.reduce(response_completed("msg_late", "end_turn"))); + out.extend(r.finish(&end_turn())); + let result = out.last().expect("result line"); + assert_eq!( + result["num_turns"], 1, + "orphaned late completion must not add a turn: {result:?}" + ); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/tool_calls.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/tool_calls.rs new file mode 100644 index 0000000..42697fb --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/tool_calls.rs @@ -0,0 +1,174 @@ +//! Client `tool_use`/`tool_result` ordering (non-web). + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn messages_tool_use_grouped_then_user_results() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("running it".into())); + assert!( + r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "in_progress", + Value::Null + ))) + .is_empty() + ); + let out = r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + ))); + let assistant = out.iter().find(|m| m["type"] == "assistant").unwrap(); + assert_eq!(assistant["message"]["stop_reason"], "tool_use"); + assert!(out.iter().all(|m| m["type"] != "user"), "result is grouped"); + let fin = r.finish(&end_turn()); + let user = fin.iter().find(|m| m["type"] == "user").unwrap(); + assert_eq!(user["message"]["content"][0]["tool_use_id"], "t1"); + assert_eq!(user["message"]["content"][0]["is_error"], false); + assert_eq!(user["message"]["content"][0]["content"], "done"); +} + +#[test] +fn messages_sequential_tool_rounds_interleave_without_response_started() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::ToolCall(tool_call_ev()))); + out.extend(r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("a"), + )))); + let mut second = tool_call_ev(); + second.tool_call_id = "t2".into(); + out.extend(r.reduce(StreamEvent::ToolCall(second))); + let mut u2 = tool_update("completed", json!("b")); + u2.tool_call_id = "t2".into(); + out.extend(r.reduce(StreamEvent::ToolCallUpdate(u2))); + out.extend(r.finish(&end_turn())); + let seq: Vec<&str> = out + .iter() + .filter_map(|m| m["type"].as_str()) + .filter(|t| *t == "assistant" || *t == "user") + .collect(); + assert_eq!( + seq, + ["assistant", "user", "assistant", "user"], + "each tool round interleaves assistant -> user before the next round" + ); + let users: Vec<_> = out.iter().filter(|m| m["type"] == "user").collect(); + assert_eq!(users[0]["message"]["content"][0]["tool_use_id"], "t1"); + assert_eq!(users[1]["message"]["content"][0]["tool_use_id"], "t2"); + let assts: Vec<_> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!(assts[0]["message"]["content"][0]["id"], "t1"); + assert_eq!(assts[1]["message"]["content"][0]["id"], "t2"); +} + +#[test] +fn messages_text_then_tool_led_response_split_into_frames() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("first".into())); + r.reduce(response_completed("msg_a", "pause_turn")); + let out = r.reduce(response_completed("msg_b", "tool_use")); + let a = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame A flushed at B's boundary"); + assert_eq!(a["message"]["id"], "msg_a"); + assert_eq!(a["message"]["content"][0]["text"], "first"); + r.reduce(StreamEvent::ToolCall(tool_call_ev())); + let out2 = r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + ))); + let b = out2 + .iter() + .find(|m| m["type"] == "assistant") + .expect("frame B"); + assert_eq!(b["message"]["id"], "msg_b"); + assert_eq!(b["message"]["content"][0]["type"], "tool_use"); +} + +#[test] +fn messages_result_reflects_final_text_not_earlier_response() { + let mut r = messages(false); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentMessage("hi".into()))); + out.extend(r.reduce(response_completed("msg_a", "end_turn"))); + out.extend(r.reduce(StreamEvent::AgentThought("planning".into()))); + out.extend(r.reduce(StreamEvent::ToolCall(tool_call_ev()))); + out.extend(r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + )))); + out.extend(r.finish(&turn_end("end_turn", "hi"))); + let result = out.last().expect("result line"); + assert_eq!(result["subtype"], "success"); + assert_eq!(result["result"], "", "{result:?}"); + let frames: Vec<&Value> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!(frames.len(), 2, "{out:?}"); + assert_eq!(frames[0]["message"]["content"][0]["text"], "hi"); + assert!( + frames[1]["message"]["content"] + .as_array() + .unwrap() + .iter() + .all(|b| b["type"] != "text"), + "final frame is text-less: {:?}", + frames[1] + ); +} + +#[test] +fn messages_unmatched_client_tool_use_reconciled_at_finish() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("running it".into())); + r.reduce(StreamEvent::ToolCall(tool_call_ev())); + let out = r.finish(&end_turn()); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + assert!( + assistant["message"]["content"] + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["id"] == "t1"), + "tool_use present: {assistant:?}" + ); + let user = out + .iter() + .find(|m| m["type"] == "user") + .expect("reconciled tool_result"); + let block = &user["message"]["content"][0]; + assert_eq!(block["type"], "tool_result"); + assert_eq!(block["tool_use_id"], "t1"); + assert_eq!(block["is_error"], true, "{block:?}"); + assert_eq!(block["content"], "tool call did not complete"); +} + +#[test] +fn messages_parallel_tool_results_ordered_by_tool_use_not_completion() { + let mut r = messages(false); + r.reduce(StreamEvent::ToolCall(tool_call_ev())); + let mut b = tool_call_ev(); + b.tool_call_id = "t2".into(); + r.reduce(StreamEvent::ToolCall(b)); + let mut ub = tool_update("completed", json!("b-result")); + ub.tool_call_id = "t2".into(); + r.reduce(StreamEvent::ToolCallUpdate(ub)); + r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("a-result"), + ))); + let fin = r.finish(&end_turn()); + let user = fin + .iter() + .find(|m| m["type"] == "user") + .expect("grouped user message"); + let content = user["message"]["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["tool_use_id"], "t1"); + assert_eq!(content[0]["content"], "a-result"); + assert_eq!(content[1]["tool_use_id"], "t2"); + assert_eq!(content[1]["content"], "b-result"); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/web_search.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/web_search.rs new file mode 100644 index 0000000..f31f3c9 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/tests/web_search.rs @@ -0,0 +1,319 @@ +//! Backend web search folding and reconciliation. + +use super::*; +use pretty_assertions::assert_eq; +// `acp` binds to the protocol crate here to avoid resolving to the sibling module through `use super::*`. +use agent_client_protocol as acp; + +#[test] +fn tool_call_event_classifies_only_backend_web_search() { + let ws = acp::ToolCall::new( + acp::ToolCallId::from("ws1".to_string()), + "Web search:".to_string(), + ) + .kind(acp::ToolKind::Search) + .status(acp::ToolCallStatus::InProgress) + .raw_input(Some(json!({"variant": "WebSearch", "backend": true}))) + .meta(json!({"backend": true}).as_object().cloned()); + assert!(tool_call_event(&ws).backend_web_search); + + let xs = acp::ToolCall::new( + acp::ToolCallId::from("xs1".to_string()), + "X search:".to_string(), + ) + .raw_input(Some(json!({"variant": "XSearch", "backend": true}))) + .meta(json!({"backend": true}).as_object().cloned()); + assert!(!tool_call_event(&xs).backend_web_search); + + let client = acp::ToolCall::new(acp::ToolCallId::from("c1".to_string()), "bash".to_string()) + .raw_input(Some(json!({"variant": "WebSearch"}))); + assert!(!tool_call_event(&client).backend_web_search); +} + +#[test] +fn tool_name_and_kind_prefer_canonical_x_ai_tool_over_acp_fields() { + let named = acp::ToolCall::new( + acp::ToolCallId::from("t1".to_string()), + "X search:".to_string(), + ) + .kind(acp::ToolKind::Other) + .meta( + json!({"x.ai/tool": {"name": "x_search", "kind": "search"}}) + .as_object() + .cloned(), + ); + let ev = tool_call_event(&named); + assert_eq!(ev.tool_name, "x_search"); + assert_eq!(ev.tool_kind.as_deref(), Some("search")); + + let bare = acp::ToolCall::new(acp::ToolCallId::from("t2".to_string()), "Read".to_string()) + .kind(acp::ToolKind::Read); + let ev = tool_call_event(&bare); + assert_eq!(ev.tool_name, "Read"); + assert_eq!(ev.tool_kind.as_deref(), Some("read")); +} + +#[test] +fn messages_backend_web_search_inline_single_frame() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("Let me search. ".into())); + assert!( + r.reduce(StreamEvent::ToolCall(web_search_call("ws1"))) + .is_empty(), + "backend web search ToolCall emits no client tool_use" + ); + assert!( + r.reduce(StreamEvent::ToolCallUpdate(web_search_done("ws1"))) + .is_empty(), + "completion folds inline, does not flush a frame or a user result" + ); + r.reduce(StreamEvent::AgentMessage("Found it.".into())); + r.reduce(response_completed("msg_real", "end_turn")); + let out = r.finish(&end_turn()); + let assistants: Vec<_> = out.iter().filter(|m| m["type"] == "assistant").collect(); + assert_eq!( + assistants.len(), + 1, + "web search stays in one assistant frame" + ); + assert!( + out.iter().all(|m| m["type"] != "user"), + "backend web search is not a client user tool_result" + ); + let content = assistants[0]["message"]["content"].as_array().unwrap(); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "Let me search. "); + assert_eq!(content[1]["type"], "server_tool_use"); + assert_eq!(content[1]["name"], "web_search"); + assert_eq!(content[1]["id"], "ws1"); + assert_eq!(content[1]["input"]["query"], "rust async runtime"); + assert_eq!(content[2]["type"], "web_search_tool_result"); + assert_eq!(content[2]["tool_use_id"], "ws1"); + assert_eq!(content[2]["content"][0]["type"], "web_search_result"); + assert_eq!(content[2]["content"][0]["url"], "https://tokio.rs"); + assert_eq!(content[2]["content"][0]["title"], "Tokio"); + assert_eq!(content[2]["content"][1]["url"], "https://async.rs"); + assert_eq!(content[2]["content"][1]["title"], "https://async.rs"); + assert_eq!(content[3]["type"], "text"); + assert_eq!(content[3]["text"], "Found it."); +} + +#[test] +fn messages_backend_web_search_inline_partial() { + let mut r = messages(true); + let mut out = Vec::new(); + out.extend(r.reduce(StreamEvent::AgentMessage("Let me search. ".into()))); + out.extend(r.reduce(StreamEvent::ToolCall(web_search_call("ws1")))); + out.extend(r.reduce(StreamEvent::ToolCallUpdate(web_search_done("ws1")))); + out.extend(r.reduce(StreamEvent::AgentMessage("Found it.".into()))); + + let stu_start = out + .iter() + .find(|m| { + m["event"]["type"] == "content_block_start" + && m["event"]["content_block"]["type"] == "server_tool_use" + }) + .expect("server_tool_use content_block_start"); + assert_eq!(stu_start["event"]["index"], 1); + assert_eq!(stu_start["event"]["content_block"]["name"], "web_search"); + assert_eq!(stu_start["event"]["content_block"]["id"], "ws1"); + let ijd = out + .iter() + .find(|m| m["event"]["delta"]["type"] == "input_json_delta") + .expect("input_json_delta carrying the query"); + assert!( + ijd["event"]["delta"]["partial_json"] + .as_str() + .unwrap() + .contains("rust async runtime") + ); + + let res_start = out + .iter() + .find(|m| { + m["event"]["type"] == "content_block_start" + && m["event"]["content_block"]["type"] == "web_search_tool_result" + }) + .expect("web_search_tool_result content_block_start"); + assert_eq!(res_start["event"]["index"], 2); + assert_eq!(res_start["event"]["content_block"]["tool_use_id"], "ws1"); + assert_eq!( + res_start["event"]["content_block"]["content"][0]["url"], + "https://tokio.rs" + ); + + let text_delta = out + .iter() + .rev() + .find(|m| m["event"]["delta"]["type"] == "text_delta") + .expect("trailing text_delta"); + assert_eq!(text_delta["event"]["index"], 3); + + let fin = r.finish(&end_turn()); + assert!(fin.iter().all(|m| m["type"] != "user")); + let frame = fin + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + let content = frame["message"]["content"].as_array().unwrap(); + assert_eq!(content.len(), 4); + assert_eq!(content[1]["type"], "server_tool_use"); + assert_eq!(content[2]["type"], "web_search_tool_result"); +} + +#[test] +fn messages_backend_web_search_failed_emits_error_not_counted() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("searching".into())); + r.reduce(StreamEvent::ToolCall(web_search_call("ws1"))); + assert!( + r.reduce(StreamEvent::ToolCallUpdate(web_search_failed("ws1"))) + .is_empty() + ); + let out = r.finish(&end_turn()); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + let content = assistant["message"]["content"].as_array().unwrap(); + let stu = content + .iter() + .find(|b| b["type"] == "server_tool_use") + .expect("server_tool_use still paired with the error result"); + assert_eq!(stu["id"], "ws1"); + let res = content + .iter() + .find(|b| b["type"] == "web_search_tool_result") + .expect("web_search_tool_result"); + assert_eq!(res["tool_use_id"], "ws1"); + assert_eq!(res["content"]["type"], "web_search_tool_result_error"); + assert_eq!(res["content"]["error_code"], "unavailable"); + assert!(out.iter().all(|m| m["type"] != "user")); + let result = out.last().unwrap(); + assert_eq!(result["usage"]["server_tool_use"]["web_search_requests"], 0); +} + +#[test] +fn messages_backend_web_search_non_search_action_uses_generic_split() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("opening a page".into())); + r.reduce(StreamEvent::ToolCall(web_search_call("ws1"))); + let out = r.reduce(StreamEvent::ToolCallUpdate(web_search_non_search("ws1"))); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + let content = assistant["message"]["content"].as_array().unwrap(); + assert!( + content + .iter() + .all(|b| b["type"] != "server_tool_use" && b["type"] != "web_search_tool_result"), + "no fabricated web-search blocks: {content:?}" + ); + let tu = content + .iter() + .find(|b| b["type"] == "tool_use") + .expect("generic client tool_use"); + assert_eq!(tu["id"], "ws1"); + assert_eq!(tu["name"], "web_search"); + let fin = r.finish(&end_turn()); + let user = fin + .iter() + .find(|m| m["type"] == "user") + .expect("generic user tool_result"); + assert_eq!(user["message"]["content"][0]["tool_use_id"], "ws1"); + let result = fin.last().unwrap(); + assert_eq!(result["usage"]["server_tool_use"]["web_search_requests"], 0); +} + +#[test] +fn messages_unresolved_backend_web_search_flushed_at_turn_end() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("searching".into())); + r.reduce(StreamEvent::ToolCall(web_search_call("ws1"))); + let out = r.finish(&end_turn()); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame carries the reconciled search"); + let content = assistant["message"]["content"].as_array().unwrap(); + let stu = content + .iter() + .find(|b| b["type"] == "server_tool_use") + .expect("server_tool_use for the observed invocation"); + assert_eq!(stu["id"], "ws1"); + let res = content + .iter() + .find(|b| b["type"] == "web_search_tool_result") + .expect("paired result"); + assert_eq!(res["tool_use_id"], "ws1"); + assert_eq!(res["content"]["type"], "web_search_tool_result_error"); + assert_eq!(res["content"]["error_code"], "unavailable"); + assert!(out.iter().all(|m| m["type"] != "user")); + let result = out.last().unwrap(); + assert_eq!(result["usage"]["server_tool_use"]["web_search_requests"], 0); +} + +#[test] +fn messages_unresolved_backend_web_search_flushed_partial() { + let mut r = messages(true); + r.reduce(StreamEvent::AgentMessage("searching".into())); + r.reduce(StreamEvent::ToolCall(web_search_call("ws1"))); + let out = r.finish(&end_turn()); + assert!( + out.iter() + .any(|m| m["event"]["type"] == "content_block_start" + && m["event"]["content_block"]["type"] == "server_tool_use"), + "partial server_tool_use framed: {out:?}" + ); + let assistant = out + .iter() + .find(|m| m["type"] == "assistant") + .expect("assistant frame"); + let content = assistant["message"]["content"].as_array().unwrap(); + assert!( + content.iter().any(|b| b["type"] == "web_search_tool_result" + && b["content"]["type"] == "web_search_tool_result_error"), + "error result in frame: {content:?}" + ); +} + +#[test] +fn messages_backend_web_searches_ordered_by_invocation_not_id() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("searching".into())); + r.reduce(StreamEvent::ToolCall(web_search_call("zzz"))); + r.reduce(StreamEvent::ToolCall(tool_call_ev())); + r.reduce(StreamEvent::ToolCall(web_search_call("aaa"))); + r.reduce(StreamEvent::ToolCallUpdate(tool_update( + "completed", + json!("done"), + ))); + let out = r.finish(&end_turn()); + let ids: Vec = out + .iter() + .filter(|m| m["type"] == "assistant") + .flat_map(|m| m["message"]["content"].as_array().unwrap().clone()) + .filter(|b| b["type"] == "server_tool_use") + .map(|b| b["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + ids, + vec!["zzz", "aaa"], + "backend searches emit in invocation order, not id-lexicographic order: {out:?}" + ); +} + +#[test] +fn messages_result_counts_web_search_requests() { + let mut r = messages(false); + r.reduce(StreamEvent::AgentMessage("searching".into())); + r.reduce(StreamEvent::ToolCall(web_search_call("ws1"))); + r.reduce(StreamEvent::ToolCallUpdate(web_search_done("ws1"))); + r.reduce(StreamEvent::ToolCall(web_search_call("ws2"))); + r.reduce(StreamEvent::ToolCallUpdate(web_search_done("ws2"))); + let out = r.finish(&end_turn()); + let result = out.last().expect("result line"); + assert_eq!(result["usage"]["server_tool_use"]["web_search_requests"], 2); + assert!(result["usage"]["server_tool_use"]["web_fetch_requests"].is_null()); +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/usage.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/usage.rs new file mode 100644 index 0000000..9c7e7fa --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/usage.rs @@ -0,0 +1,119 @@ +//! Terminal-usage projection for `streaming-messages-json`: reshaping the turn's +//! aggregate ledger into `result.usage` (`message.usage` shape) and the per-model +//! `modelUsage` map. Kept apart so the token/cost/model math is self-contained. + +use serde_json::{Value, json}; + +use crate::headless::attach_result_usage; +use crate::headless::reducer::to_line; + +use super::MessagesReducer; +use super::wire::{MessageUsage, ModelUsage, ServerToolUse}; + +/// The reshaped terminal usage: `message.usage`, `modelUsage`, turn count, cost, and API duration. +pub(super) struct ResultUsage { + pub(super) usage: MessageUsage, + pub(super) model_usage: Value, + pub(super) num_turns: u64, + pub(super) total_cost_usd: f64, + pub(super) duration_api_ms: u64, +} + +impl MessagesReducer { + /// The Messages `result` usage, reshaped from the shell's projection into the `message.usage` shape. + pub(super) fn messages_result_usage(&self, end_usage: Option<&Value>) -> ResultUsage { + let mut scratch = json!({}); + if let Some(u) = end_usage { + attach_result_usage(&mut scratch, u); + } + let field = |obj: Option<&Value>, key: &str| { + obj.and_then(|o| o.get(key)) + .and_then(Value::as_u64) + .unwrap_or(0) + }; + let u = scratch.get("usage"); + let usage_is_incomplete = scratch + .get("usage_is_incomplete") + .and_then(Value::as_bool) + .unwrap_or(false); + if end_usage.is_none() { + tracing::warn!( + "streaming-messages-json: no aggregate usage ledger at turn end; \ + `result.usage` token counts fall back to zero (the Messages API \ + schema has no absent-usage marker)" + ); + } else if usage_is_incomplete { + tracing::warn!( + "streaming-messages-json: usage is incomplete; `result.usage` token \ + counts may under-count or fall back to zero (the Messages API schema \ + has no incompleteness marker)" + ); + } + let usage = MessageUsage { + input_tokens: field(u, "input_tokens"), + output_tokens: field(u, "output_tokens"), + cache_read_input_tokens: field(u, "cache_read_input_tokens"), + cache_creation_input_tokens: field(u, "cache_creation_input_tokens"), + server_tool_use: Some(ServerToolUse { + web_search_requests: self.web_search_requests, + }), + }; + let num_turns = scratch + .get("num_turns") + .and_then(Value::as_u64) + .unwrap_or(self.completed_responses); + let total_cost_usd = scratch + .get("total_cost_usd") + .and_then(Value::as_f64) + .unwrap_or(0.0); + // `apiDurationMs` is dropped by the projection, so read it from `end_usage`. + let duration_api_ms = end_usage.map_or(0, |u| field(Some(u), "apiDurationMs")); + // Attribute the whole web-search count to the current model (only a global count is tracked). + let model_usage = messages_model_usage( + scratch.get("modelUsage"), + self.session.as_ref().and_then(|s| s.model.as_deref()), + self.web_search_requests, + self.session.as_ref().and_then(|s| s.context_window), + ); + ResultUsage { + usage, + model_usage, + num_turns, + total_cost_usd, + duration_api_ms, + } + } +} + +/// Map the ledger's per-model rows into `ModelUsage` entries; the web-search count +/// and `context_window` go to `current_model` only. `{}` when there is no breakdown. +pub(super) fn messages_model_usage( + rows: Option<&Value>, + current_model: Option<&str>, + web_search_requests: u64, + context_window: Option, +) -> Value { + let Some(Value::Object(map)) = rows else { + return json!({}); + }; + let out: serde_json::Map = map + .iter() + .map(|(model, row)| { + let n = |k: &str| row.get(k).and_then(Value::as_u64).unwrap_or(0); + let is_current = Some(model.as_str()) == current_model; + ( + model.clone(), + to_line(&ModelUsage { + input_tokens: n("inputTokens"), + output_tokens: n("outputTokens"), + cache_read_input_tokens: n("cacheReadInputTokens"), + cache_creation_input_tokens: n("cacheCreationInputTokens"), + web_search_requests: if is_current { web_search_requests } else { 0 }, + cost_usd: row.get("costUSD").and_then(Value::as_f64).unwrap_or(0.0), + context_window: if is_current { context_window } else { None }, + }), + ) + }) + .collect(); + Value::Object(out) +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/web_search.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/web_search.rs new file mode 100644 index 0000000..a4e7b0f --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/web_search.rs @@ -0,0 +1,111 @@ +//! Backend `web_search` reconciliation for `streaming-messages-json`: folding a +//! completed search inline (or the generic client split on failure), plus parsing +//! Grok's `WebSearchCall` output into the wire hit array. + +use agent_client_protocol as acp; +use serde_json::{Value, json}; + +use crate::headless::reducer::{ToolCallEvent, ToolCallUpdateEvent}; + +use super::MessagesReducer; +use super::wire::ContentBlock; + +impl MessagesReducer { + /// Resolve a completed backend `web_search`. A successful search folds inline and + /// counts; a failure pairs an error result (uncounted); a non-search action falls + /// back to the generic client split. + pub(super) fn finish_web_search( + &mut self, + out: &mut Vec, + tc: ToolCallEvent, + u: ToolCallUpdateEvent, + ) { + let failed = u.status == Some(acp::ToolCallStatus::Failed); + let (query, hits) = parse_web_search(&u.raw_output); + let has_hits = hits.as_array().is_some_and(|a| !a.is_empty()); + if failed { + let error = json!({ + "type": "web_search_tool_result_error", + "error_code": "unavailable", + }); + self.append_web_search_result(out, &u.tool_call_id, &query, &error); + return; + } + if query.is_empty() && !has_hits { + // Non-search action or unparseable output: keep the generic split, not a fake search. + self.emit_client_tool_call(out, tc); + self.close_and_flush(out, Some("tool_use")); + self.buffer_tool_result(u); + return; + } + self.web_search_requests += 1; + self.append_web_search_result(out, &u.tool_call_id, &query, &hits); + } + + /// Fold a `web_search` into the open frame as an adjacent `server_tool_use` + + /// `web_search_tool_result` pair. The frame is not flushed, so text around the + /// search stays in one message; the request counter is untouched. + pub(super) fn append_web_search_result( + &mut self, + out: &mut Vec, + id: &str, + query: &str, + content: &Value, + ) { + // Materialize any pending signature-only thinking block first so indices stay in sync. + self.partial_signature_only_block(out); + if self.include_partials() { + self.partial_close_block(out); + } + self.finalize_open(); + let server_tool_use_index = self.blocks.len(); + self.blocks.push(ContentBlock::ServerToolUse { + id: id.to_string(), + name: "web_search", + input: json!({ "query": query }), + }); + let result_index = self.blocks.len(); + self.blocks.push(ContentBlock::WebSearchToolResult { + tool_use_id: id.to_string(), + content: content.clone(), + }); + if self.include_partials() { + self.partial_server_tool_use(out, server_tool_use_index, id, query); + self.partial_web_search_result(out, result_index, id, content); + } + } +} + +/// Parse a `web_search` `raw_output` into the query and `web_search_result` hit array. +/// Grok nests query/sources under `action`, with a flat `{"query",...,"sources"}` fallback. +fn parse_web_search(raw_output: &Value) -> (String, Value) { + let query = raw_output + .pointer("/action/query") + .or_else(|| raw_output.get("query")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let hits = raw_output + .pointer("/action/sources") + .or_else(|| raw_output.get("sources")) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|s| { + let url = s.get("url").and_then(Value::as_str)?; + let title = s + .get("title") + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .unwrap_or(url); + Some(json!({ + "type": "web_search_result", + "url": url, + "title": title, + })) + }) + .collect::>() + }) + .unwrap_or_default(); + (query, Value::Array(hits)) +} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/messages/wire.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/wire.rs new file mode 100644 index 0000000..33af318 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/messages/wire.rs @@ -0,0 +1,338 @@ +//! The `streaming-messages-json` serde wire DTOs: the Messages API line shapes +//! plus their serialization helpers. Pure data; reducer logic lives elsewhere. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::headless::reducer::McpServer; +use xai_grok_shell::extensions::notification::ResponseUsage; + +pub(super) fn new_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Serialize an `f64` cost, substituting `0.0` for a non-finite value that would +/// otherwise make `serde_json` error and degrade the whole line to the `error` fallback. +pub(super) fn serialize_finite_cost( + value: &f64, + serializer: S, +) -> Result { + serializer.serialize_f64(if value.is_finite() { *value } else { 0.0 }) +} + +/// Map a Grok permission mode to the Messages `permissionMode` enum, clamping Grok-only values to `default`. +pub(super) fn messages_permission_mode(mode: Option<&str>) -> &'static str { + match mode { + Some("acceptEdits") => "acceptEdits", + Some("bypassPermissions") => "bypassPermissions", + Some("plan") => "plan", + Some("dontAsk") => "dontAsk", + _ => "default", + } +} + +/// A block in a Messages API `assistant.message.content[]`. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum ContentBlock { + Text { + text: String, + }, + Thinking { + thinking: String, + signature: String, + }, + ToolUse { + id: String, + name: String, + input: Value, + }, + /// A backend `web_search` folded inline as Messages API `server_tool_use`. + ServerToolUse { + id: String, + name: &'static str, + input: Value, + }, + /// The results of an inline web search (`web_search_tool_result`); `content` is the hit array. + WebSearchToolResult { + tool_use_id: String, + content: Value, + }, +} + +/// The wire fields of Messages API `message.usage` (reasoning tokens folded into `output_tokens`). +#[derive(Clone, Default, Serialize, Deserialize)] +pub(super) struct MessageUsage { + #[serde(default)] + pub(super) input_tokens: u64, + #[serde(default)] + pub(super) output_tokens: u64, + #[serde(default)] + pub(super) cache_read_input_tokens: u64, + #[serde(default)] + pub(super) cache_creation_input_tokens: u64, + /// The `usage.server_tool_use` counter; populated only on the terminal `result` usage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) server_tool_use: Option, +} + +/// The `usage.server_tool_use` sub-object (Grok counts backend `web_search` only). +#[derive(Clone, Default, Serialize, Deserialize)] +pub(super) struct ServerToolUse { + pub(super) web_search_requests: u64, +} + +impl From<&ResponseUsage> for MessageUsage { + /// Project the shell's per-response usage onto the four `message.usage` fields. + fn from(u: &ResponseUsage) -> Self { + Self { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + cache_read_input_tokens: u.cache_read_input_tokens, + cache_creation_input_tokens: u.cache_creation_input_tokens, + // Server-tool counts ride the terminal `result` usage only. + server_tool_use: None, + } + } +} + +#[derive(Serialize)] +pub(super) struct AssistantFrame { + pub(super) message: AssistantMessage, + pub(super) parent_tool_use_id: Option, + pub(super) session_id: String, + pub(super) uuid: String, +} + +#[derive(Serialize)] +pub(super) struct AssistantMessage { + pub(super) id: String, + #[serde(rename = "type")] + pub(super) kind: &'static str, + pub(super) role: &'static str, + pub(super) model: String, + pub(super) content: Vec, + // Nullable so a turn that ended abnormally reports `null`, not a misleading `end_turn`. + pub(super) stop_reason: Option, + pub(super) stop_sequence: Option, + pub(super) usage: MessageUsage, +} + +/// The Anthropic Messages API `system`/`init` line body (`stream-json` shape). +#[derive(Serialize)] +pub(super) struct SystemInitLine { + pub(super) session_id: String, + #[serde(rename = "apiKeySource")] + pub(super) api_key_source: &'static str, + pub(super) model: String, + pub(super) cwd: String, + #[serde(rename = "permissionMode")] + pub(super) permission_mode: &'static str, + pub(super) tools: Vec, + pub(super) slash_commands: Vec, + pub(super) mcp_servers: Vec, + pub(super) skills: Vec, + pub(super) uuid: String, +} + +/// The Messages API `stream_event` partial; `event` is an opaque raw stream event. +#[derive(Serialize)] +pub(super) struct PartialEventLine { + pub(super) event: Value, + pub(super) parent_tool_use_id: Option, + pub(super) session_id: String, + pub(super) uuid: String, +} + +/// The Messages API `user`/`tool_result` line body. +#[derive(Serialize)] +pub(super) struct ToolResultLine { + pub(super) message: ToolResultMessage, + pub(super) parent_tool_use_id: Option, + pub(super) session_id: String, + pub(super) uuid: String, +} + +#[derive(Serialize)] +pub(super) struct ToolResultMessage { + pub(super) role: &'static str, + pub(super) content: Vec, +} + +#[derive(Serialize)] +pub(super) struct ToolResultBlock { + #[serde(rename = "type")] + pub(super) kind: &'static str, + pub(super) tool_use_id: String, + pub(super) content: Value, + pub(super) is_error: bool, +} + +/// The Messages API `system`/`compact_boundary` line body. +#[derive(Serialize)] +pub(super) struct CompactBoundaryLine { + pub(super) compact_metadata: CompactMetadata, + pub(super) session_id: String, + pub(super) uuid: String, +} + +#[derive(Serialize)] +pub(super) struct CompactMetadata { + pub(super) trigger: &'static str, + pub(super) pre_tokens: u64, +} + +/// Every top-level `streaming-messages-json` NDJSON line. `#[serde(tag = "type")]` +/// derives the `type` discriminant from the variant, so a line can never carry a +/// mistyped tag; the two `system` subtypes ride the nested [`SystemLine`] `subtype`. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum MessagesLine { + System(SystemLine), + Assistant(AssistantFrame), + User(ToolResultLine), + StreamEvent(PartialEventLine), + Result(Box), +} + +/// The two `system` line subtypes, discriminated by `subtype`. +#[derive(Serialize)] +#[serde(tag = "subtype", rename_all = "snake_case")] +pub(super) enum SystemLine { + Init(SystemInitLine), + CompactBoundary(CompactBoundaryLine), +} + +#[derive(Serialize)] +pub(super) struct ModelUsage { + #[serde(rename = "inputTokens")] + pub(super) input_tokens: u64, + #[serde(rename = "outputTokens")] + pub(super) output_tokens: u64, + #[serde(rename = "cacheReadInputTokens")] + pub(super) cache_read_input_tokens: u64, + #[serde(rename = "cacheCreationInputTokens")] + pub(super) cache_creation_input_tokens: u64, + #[serde(rename = "webSearchRequests")] + pub(super) web_search_requests: u64, + #[serde(rename = "costUSD", serialize_with = "serialize_finite_cost")] + pub(super) cost_usd: f64, + #[serde(rename = "contextWindow", skip_serializing_if = "Option::is_none")] + pub(super) context_window: Option, +} + +/// The Messages API terminal `result` line body (success and error subtypes). +#[derive(Serialize)] +pub(super) struct ResultLine { + pub(super) subtype: &'static str, + pub(super) is_error: bool, + pub(super) duration_ms: u64, + pub(super) duration_api_ms: u64, + pub(super) num_turns: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) result: Option, + pub(super) stop_reason: Option, + #[serde(serialize_with = "serialize_finite_cost")] + pub(super) total_cost_usd: f64, + pub(super) usage: MessageUsage, + /// Per-model usage keyed by model id; `{}` when there is no per-model breakdown. + #[serde(rename = "modelUsage")] + pub(super) model_usage: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) structured_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) errors: Option>, + pub(super) session_id: String, + pub(super) uuid: String, +} + +/// Raw Messages API stream events serialized as the `event` body of a `stream_event` line. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum StreamEventBody { + MessageStart { + message: PartialMessage, + }, + ContentBlockStart { + index: usize, + content_block: PartialBlock, + }, + ContentBlockDelta { + index: usize, + delta: PartialDelta, + }, + ContentBlockStop { + index: usize, + }, + MessageDelta { + delta: MessageDeltaBody, + usage: MessageUsage, + }, + MessageStop, +} + +/// The `message` body of a partial `message_start`; carries the real id and +/// input-side usage, with `output_tokens` finalized later on `message_delta`. +#[derive(Serialize)] +pub(super) struct PartialMessage { + pub(super) id: String, + #[serde(rename = "type")] + pub(super) kind: &'static str, + pub(super) role: &'static str, + pub(super) model: String, + pub(super) content: Vec, + pub(super) stop_reason: Option, + pub(super) stop_sequence: Option, + pub(super) usage: MessageUsage, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum PartialBlock { + Text { + text: &'static str, + }, + Thinking { + thinking: &'static str, + signature: &'static str, + }, + ToolUse { + id: String, + name: String, + input: EmptyObject, + }, + /// Partial-stream open for a `server_tool_use` block; input rides a following `input_json_delta`. + ServerToolUse { + id: String, + name: &'static str, + input: EmptyObject, + }, + /// Partial-stream `web_search_tool_result` block; carries its full `content` at start. + WebSearchToolResult { + tool_use_id: String, + content: Value, + }, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum PartialDelta { + #[serde(rename = "text_delta")] + Text { text: String }, + #[serde(rename = "thinking_delta")] + Thinking { thinking: String }, + #[serde(rename = "signature_delta")] + Signature { signature: String }, + #[serde(rename = "input_json_delta")] + InputJson { partial_json: String }, +} + +#[derive(Serialize)] +pub(super) struct MessageDeltaBody { + pub(super) stop_reason: Option, + pub(super) stop_sequence: Option, +} + +/// Serializes to `{}` (the empty initial `tool_use.input`). +#[derive(Serialize)] +pub(super) struct EmptyObject {} diff --git a/crates/codegen/xai-grok-pager/src/headless/reducer/mod.rs b/crates/codegen/xai-grok-pager/src/headless/reducer/mod.rs new file mode 100644 index 0000000..e4f86e9 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless/reducer/mod.rs @@ -0,0 +1,373 @@ +//! Streaming reducers that fold the agent ACP event stream into NDJSON lines. +//! One `Reducer` impl per wire format, selected by [`reducer_for`]. This module +//! owns the shared [`StreamEvent`]/[`Reducer`] types; each format is a submodule. + +use agent_client_protocol as proto; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::headless::OutputFormat; + +mod acp; +mod messages; + +use self::acp::AcpReducer; +use self::messages::MessagesReducer; +use xai_grok_shell::extensions::notification::ResponseUsage; + +/// Serialize a wire line, degrading to an `error` line rather than panicking. +pub(crate) fn to_line(value: &T) -> Value { + serde_json::to_value(value) + .unwrap_or_else(|e| json!({"type": "error", "message": format!("serialize failed: {e}")})) +} + +/// Merge the camelCase `structuredOutput`/`structuredOutputError` fields into a +/// terminal wire line: `Ok` stamps the value, `Err` stamps null plus the error. +pub(crate) fn attach_structured_output( + target: &mut Value, + structured: Option>, +) { + match structured { + None => {} + Some(Ok(value)) => target["structuredOutput"] = value, + Some(Err(err)) => { + target["structuredOutput"] = Value::Null; + target["structuredOutputError"] = err.into(); + } + } +} + +/// Transport agnostic agent stream event, the single input every reducer folds. +pub(crate) enum StreamEvent { + AgentMessage(String), + AgentThought(String), + ToolCall(ToolCallEvent), + ToolCallUpdate(ToolCallUpdateEvent), + Plan(Value), + /// Session metadata; `skills` is the subset of `commands` that are skills. + AvailableCommands { + tools: Vec, + commands: Vec, + skills: Vec, + }, + Lifecycle(Lifecycle), + /// One model response opened (Messages backend); carries real id, model, and input-side token counts. + ResponseStarted { + message_id: Option, + model: Option, + input_tokens: u64, + cache_read_input_tokens: u64, + cache_creation_input_tokens: u64, + }, + /// One reasoning block finished (Messages backend); carries its signature for in-order `signature_delta`. + ReasoningCompleted { + signature: Option, + }, + /// One model response finished; carries its stop reason, id, usage, signature, and stop sequence. + ResponseCompleted { + message_id: Option, + stop_reason: Option, + usage: Option, + signature: Option, + /// Provider's matched stop sequence; set only by the Messages reducer. + stop_sequence: Option, + }, +} + +pub(crate) struct ToolCallEvent { + tool_call_id: String, + title: String, + tool_kind: Option, + status: Option, + tool_name: String, + raw_input: Value, + content: Value, + locations: Value, + /// True for Grok's backend `web_search`, which folds inline instead of the client split. + backend_web_search: bool, +} + +pub(crate) struct ToolCallUpdateEvent { + tool_call_id: String, + status: Option, + content: Value, + raw_output: Value, + locations: Value, +} + +pub(crate) enum Lifecycle { + CompactStarted { percentage: u8 }, + CompactCompleted { pre_tokens: u64 }, + CompactFailed { error: String }, + CompactCancelled, + AutoContinue { total_tokens: u64 }, + ImageCompressed { message: String }, +} + +impl Lifecycle { + /// The human-readable `plain` line for this lifecycle event. + pub(crate) fn plain_message(&self) -> String { + match self { + Lifecycle::CompactStarted { percentage } => { + format!("Auto-compacting conversation ({percentage}% full)...") + } + Lifecycle::CompactCompleted { .. } => "Conversation compacted.".to_string(), + Lifecycle::CompactFailed { error } if error.trim().is_empty() => { + "Auto-compact failed.".to_string() + } + Lifecycle::CompactFailed { error } => format!("Auto-compact failed: {error}"), + Lifecycle::CompactCancelled => "Auto-compact cancelled.".to_string(), + Lifecycle::AutoContinue { .. } => "Resumed after compaction.".to_string(), + Lifecycle::ImageCompressed { message } => message.clone(), + } + } +} + +/// The `streaming-json` wire token for an ACP [`proto::ToolKind`]. Explicit typed +/// match (not serde) so renaming a variant is a compile error, not a dropped `None`. +pub(crate) fn tool_kind_wire(kind: proto::ToolKind) -> Option { + let token = match kind { + proto::ToolKind::Read => "read", + proto::ToolKind::Edit => "edit", + proto::ToolKind::Delete => "delete", + proto::ToolKind::Move => "move", + proto::ToolKind::Search => "search", + proto::ToolKind::Execute => "execute", + proto::ToolKind::Think => "think", + proto::ToolKind::Fetch => "fetch", + proto::ToolKind::SwitchMode => "switch_mode", + proto::ToolKind::Other => "other", + _ => return serde_wire_token(&kind), + }; + Some(token.to_string()) +} + +/// The `streaming-json` wire token for an ACP [`proto::ToolCallStatus`] (see [`tool_kind_wire`]). +pub(crate) fn tool_call_status_wire(status: proto::ToolCallStatus) -> Option { + let token = match status { + proto::ToolCallStatus::Pending => "pending", + proto::ToolCallStatus::InProgress => "in_progress", + proto::ToolCallStatus::Completed => "completed", + proto::ToolCallStatus::Failed => "failed", + _ => return serde_wire_token(&status), + }; + Some(token.to_string()) +} + +/// Fallback wire token for a future `#[non_exhaustive]` ACP variant not covered above. +fn serde_wire_token(value: &T) -> Option { + match serde_json::to_value(value) { + Ok(Value::String(s)) => Some(s), + _ => None, + } +} + +/// Serialize an ACP value expected to be a JSON array, degrading to `[]` on failure. +fn json_array_or_empty(value: &T) -> Value { + match serde_json::to_value(value) { + Ok(v) => v, + Err(e) => { + tracing::error!(error = %e, "headless: failed to serialize ACP value; emitting []"); + json!([]) + } + } +} + +/// Canonical model-facing tool name from the `x.ai/tool` `_meta` envelope, else +/// the display title, else kind, else `"tool"`. The shell stamps the wire name +/// (`bash`, `x_search`, `read_file`) under `x.ai/tool.name`; without this the +/// name falls through to the human title (`Execute ...`, `X search:`). +fn tool_name_from(meta: Option<&proto::Meta>, title: &str, kind: Option<&str>) -> String { + if let Some(meta) = meta + && let Some(name) = meta + .get("x.ai/tool") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + && !name.is_empty() + { + return name.to_string(); + } + if !title.is_empty() { + return title.to_string(); + } + if let Some(kind) = kind + && !kind.is_empty() + { + return kind.to_string(); + } + "tool".to_string() +} + +/// True iff `tc` is Grok's backend `web_search` (`_meta.backend` and `raw_input.variant == "WebSearch"`). +fn is_backend_web_search(meta: Option<&proto::Meta>, raw_input: &Value) -> bool { + let backend = meta + .and_then(|m| m.get("backend")) + .and_then(Value::as_bool) + .unwrap_or(false); + let is_web_search = raw_input.get("variant").and_then(Value::as_str) == Some("WebSearch"); + backend && is_web_search +} + +/// Canonical tool kind from the `x.ai/tool` `_meta` envelope, else the ACP +/// `ToolCall.kind`. Client tools register as `ToolKind::Other` on the early +/// notification, so the real kind (`read`, `edit`, `execute`) rides +/// `x.ai/tool.kind`; without this every client tool reports `other`. +fn tool_kind_from(meta: Option<&proto::Meta>, kind: proto::ToolKind) -> Option { + if let Some(meta) = meta + && let Some(k) = meta + .get("x.ai/tool") + .and_then(|v| v.get("kind")) + .and_then(|v| v.as_str()) + && !k.is_empty() + { + return Some(k.to_string()); + } + tool_kind_wire(kind) +} + +pub(crate) fn tool_call_event(tc: &proto::ToolCall) -> ToolCallEvent { + let tool_kind = tool_kind_from(tc.meta.as_ref(), tc.kind); + let tool_name = tool_name_from(tc.meta.as_ref(), &tc.title, tool_kind.as_deref()); + let raw_input = tc.raw_input.clone().unwrap_or(Value::Null); + let backend_web_search = is_backend_web_search(tc.meta.as_ref(), &raw_input); + ToolCallEvent { + tool_call_id: tc.tool_call_id.0.to_string(), + title: tc.title.clone(), + tool_kind, + status: Some(tc.status), + tool_name, + raw_input, + content: json_array_or_empty(&tc.content), + locations: json_array_or_empty(&tc.locations), + backend_web_search, + } +} + +fn tool_call_update_event(tcu: &proto::ToolCallUpdate) -> ToolCallUpdateEvent { + ToolCallUpdateEvent { + tool_call_id: tcu.tool_call_id.0.to_string(), + status: tcu.fields.status, + content: tcu + .fields + .content + .as_ref() + .map_or_else(|| json!([]), json_array_or_empty), + raw_output: tcu.fields.raw_output.clone().unwrap_or(Value::Null), + locations: tcu + .fields + .locations + .as_ref() + .map_or_else(|| json!([]), json_array_or_empty), + } +} + +fn tools_from_meta(meta: Option<&proto::Meta>) -> Vec { + meta.and_then(|m| m.get("tools")) + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +fn command_names(commands: &[proto::AvailableCommand]) -> Vec { + commands.iter().map(|c| c.name.clone()).collect() +} + +/// Skill names from the `AvailableCommandsUpdate`: commands stamped with `_meta.scope` + `_meta.path`. +pub(crate) fn skill_names(commands: &[proto::AvailableCommand]) -> Vec { + commands + .iter() + .filter(|c| { + c.meta + .as_ref() + .is_some_and(|m| m.get("scope").is_some() && m.get("path").is_some()) + }) + .map(|c| c.name.clone()) + .collect() +} + +/// Map an ACP `SessionUpdate` to a [`StreamEvent`], or `None` for unsurfaced variants. +pub(crate) fn map_session_update(update: &proto::SessionUpdate) -> Option { + Some(match update { + proto::SessionUpdate::ToolCall(tc) => StreamEvent::ToolCall(tool_call_event(tc)), + proto::SessionUpdate::ToolCallUpdate(tcu) => { + StreamEvent::ToolCallUpdate(tool_call_update_event(tcu)) + } + proto::SessionUpdate::Plan(p) => StreamEvent::Plan(json_array_or_empty(&p.entries)), + proto::SessionUpdate::AvailableCommandsUpdate(u) => StreamEvent::AvailableCommands { + tools: tools_from_meta(u.meta.as_ref()), + commands: command_names(&u.available_commands), + skills: skill_names(&u.available_commands), + }, + _ => return None, + }) +} + +/// Session facts known once the ACP session is open. +pub(crate) struct SessionContext { + pub session_id: String, + pub model: Option, + pub cwd: String, + pub permission_mode: Option, + pub mcp_servers: Vec, + pub include_partial_messages: bool, + /// True when the session authenticated with an API key (vs OAuth). + pub api_key_auth: bool, + /// The current model's total context window in tokens, when known. + pub context_window: Option, +} + +pub(crate) struct TurnEnd<'a> { + pub stop_reason: &'a str, + pub session_id: &'a str, + pub request_id: &'a str, + pub usage: Option<&'a Value>, + pub structured_output: Option>, + pub result_text: &'a str, + /// Total wall-clock for the run (`duration_ms` on the Messages `result`). + pub duration_ms: u64, +} + +/// One entry of the Messages API `system`/`init` `mcp_servers` list. +#[derive(Clone, Serialize)] +pub(crate) struct McpServer { + pub name: String, + pub status: String, +} + +/// Folds the agent event stream into NDJSON lines for one wire format. +pub(crate) trait Reducer { + fn begin(&mut self, _ctx: SessionContext) -> Vec { + Vec::new() + } + + fn reduce(&mut self, event: StreamEvent) -> Vec; + + fn max_turns(&mut self) -> Vec { + Vec::new() + } + + fn finish(&mut self, end: &TurnEnd<'_>) -> Vec; + + /// Terminal error line(s). `stop_reason` is a Messages-only override (e.g. `max_tokens`); `None` keeps the fallback. + fn error( + &mut self, + message: &str, + usage: Option<&Value>, + duration_ms: u64, + stop_reason: Option<&str>, + ) -> Vec; +} + +/// The reducer for `format`, or `None` for `plain`/`json` (rendered directly). +pub(crate) fn reducer_for(format: OutputFormat) -> Option> { + match format { + OutputFormat::StreamingJson => Some(Box::new(AcpReducer)), + OutputFormat::StreamingMessagesJson => Some(Box::new(MessagesReducer::new())), + OutputFormat::Plain | OutputFormat::Json => None, + } +} diff --git a/crates/codegen/xai-grok-pager/src/headless_tests.rs b/crates/codegen/xai-grok-pager/src/headless_tests.rs new file mode 100644 index 0000000..56e08e7 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/headless_tests.rs @@ -0,0 +1,472 @@ +use pretty_assertions::assert_eq; + +#[test] +fn lifecycle_tracking_is_independent_of_wait_flag() { + let mut pending = std::collections::HashSet::new(); + let mut completed = std::collections::HashSet::new(); + super::track_background_lifecycle( + super::ExtEvent::TaskBackgrounded { + task_id: "t1".into(), + is_monitor: false, + }, + &mut pending, + &mut completed, + ); + super::track_background_lifecycle( + super::ExtEvent::SubagentSpawned { + subagent_id: "s1".into(), + }, + &mut pending, + &mut completed, + ); + assert!(pending.contains(&super::BackgroundWork::Task("t1".into()))); + assert!(pending.contains(&super::BackgroundWork::Subagent("s1".into()))); + + super::track_background_lifecycle( + super::ExtEvent::TaskCompleted { + task_id: "t1".into(), + }, + &mut pending, + &mut completed, + ); + super::track_background_lifecycle( + super::ExtEvent::SubagentFinished { + subagent_id: "s1".into(), + }, + &mut pending, + &mut completed, + ); + assert!(pending.is_empty()); +} + +#[test] +fn completion_before_backgrounded_never_rearms_pending() { + let mut pending = std::collections::HashSet::new(); + let mut completed = std::collections::HashSet::new(); + super::track_background_lifecycle( + super::ExtEvent::TaskCompleted { + task_id: "t1".into(), + }, + &mut pending, + &mut completed, + ); + super::track_background_lifecycle( + super::ExtEvent::TaskBackgrounded { + task_id: "t1".into(), + is_monitor: false, + }, + &mut pending, + &mut completed, + ); + assert!(pending.is_empty()); +} + +/// A late/duplicate `task_backgrounded` must not resurrect a completed task. +#[test] +fn duplicate_backgrounded_after_completion_stays_dead() { + let mut pending = std::collections::HashSet::new(); + let mut completed = std::collections::HashSet::new(); + let bg = || super::ExtEvent::TaskBackgrounded { + task_id: "t1".into(), + is_monitor: false, + }; + super::track_background_lifecycle(bg(), &mut pending, &mut completed); + assert!(pending.contains(&super::BackgroundWork::Task("t1".into()))); + super::track_background_lifecycle( + super::ExtEvent::TaskCompleted { + task_id: "t1".into(), + }, + &mut pending, + &mut completed, + ); + assert!(pending.is_empty()); + super::track_background_lifecycle(bg(), &mut pending, &mut completed); + assert!( + pending.is_empty(), + "a backgrounded for an already-completed id must not re-arm pending" + ); +} + +/// The same tombstone dedup applies to background subagents. +#[test] +fn duplicate_subagent_spawn_after_finish_stays_dead() { + let mut pending = std::collections::HashSet::new(); + let mut completed = std::collections::HashSet::new(); + let spawn = || super::ExtEvent::SubagentSpawned { + subagent_id: "s1".into(), + }; + super::track_background_lifecycle(spawn(), &mut pending, &mut completed); + super::track_background_lifecycle( + super::ExtEvent::SubagentFinished { + subagent_id: "s1".into(), + }, + &mut pending, + &mut completed, + ); + assert!(pending.is_empty()); + super::track_background_lifecycle(spawn(), &mut pending, &mut completed); + assert!( + pending.is_empty(), + "a spawn for an already-finished subagent id must not re-arm pending" + ); +} + +#[test] +fn reap_request_for_task_kills_with_session_scope() { + let session_id = acp::SessionId::new("sess-1"); + let work = super::BackgroundWork::Task("task-42".into()); + let request = super::reap_request_for_work(&work, &session_id).unwrap(); + assert_eq!(request.method.as_ref(), "x.ai/task/kill"); + let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap(); + assert_eq!(params["sessionId"], "sess-1"); + assert_eq!(params["taskId"], "task-42"); +} + +/// A numeric `task_id` is coerced to its string form, tracked, and reaped on exit. +#[test] +fn numeric_task_id_is_decoded_tracked_and_reaped() { + let payload = serde_json::json!({ + "sessionId": "sess-1", + "update": { "sessionUpdate": "task_backgrounded", "task_id": 4242 }, + }); + let raw = serde_json::value::to_raw_value(&payload).unwrap(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let notif = xai_acp_lib::AcpArgs { + request: acp::ExtNotification::new("x.ai/task_backgrounded", raw.into()), + response_tx: tx, + } + .boxed(); + let event = super::handle_ext_notification(¬if); + let mut pending = std::collections::HashSet::new(); + let mut completed = std::collections::HashSet::new(); + super::track_background_lifecycle(event, &mut pending, &mut completed); + let work = super::BackgroundWork::Task("4242".into()); + assert!( + pending.contains(&work), + "numeric task_id tracked as the coerced string id" + ); + let session_id = acp::SessionId::new("sess-1"); + let request = super::reap_request_for_work(&work, &session_id).unwrap(); + assert_eq!(request.method.as_ref(), "x.ai/task/kill"); + let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap(); + assert_eq!(params["taskId"], "4242"); + assert_eq!(params["sessionId"], "sess-1"); +} + +#[test] +fn reap_request_for_subagent_cancels_with_typed_id() { + let session_id = acp::SessionId::new("sess-1"); + let work = super::BackgroundWork::Subagent("sub-7".into()); + let request = super::reap_request_for_work(&work, &session_id).unwrap(); + assert_eq!(request.method.as_ref(), "x.ai/subagent/cancel"); + let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap(); + assert_eq!(params["subagentId"], "sub-7"); +} + +/// A `task_backgrounded` delivered right at prompt completion is still recorded by the drain. +#[test] +fn drain_records_task_backgrounded_delivered_at_exit() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let payload = serde_json::json!({ + "sessionId": "sess-1", + "update": { "sessionUpdate": "task_backgrounded", "task_id": "late-1" }, + }); + let raw = serde_json::value::to_raw_value(&payload).unwrap(); + let (resp_tx, _resp_rx) = tokio::sync::oneshot::channel(); + tx.send(xai_acp_lib::AcpClientMessage::ExtNotification( + xai_acp_lib::AcpArgs { + request: acp::ExtNotification::new("x.ai/task_backgrounded", raw.into()), + response_tx: resp_tx, + }, + )) + .unwrap(); + + let mut emitter = HeadlessEmitter::new(OutputFormat::Json, false); + let mut pending = std::collections::HashSet::new(); + let mut completed = std::collections::HashSet::new(); + let mut ttf_logged = false; + super::drain_pending_acp_messages( + &mut rx, + &mut emitter, + std::time::Instant::now(), + &mut ttf_logged, + false, + &mut pending, + &mut completed, + ); + assert!( + pending.contains(&super::BackgroundWork::Task("late-1".into())), + "drain-to-empty records a task_backgrounded buffered at exit" + ); +} + +/// `begin_session` before the model/effort apply lets a post-open error carry the real context. +#[test] +fn post_open_error_carries_real_session_context() { + let mut pre = reducer_for(OutputFormat::StreamingMessagesJson).unwrap(); + let pre_lines = pre.error("boom", None, 0, None); + let pre_result = pre_lines + .iter() + .find(|l| l["type"] == "result") + .expect("result line"); + assert_eq!( + pre_result["session_id"], "", + "pre-session error keeps the startup-error fallback" + ); + + let mut post = reducer_for(OutputFormat::StreamingMessagesJson).unwrap(); + post.begin(SessionContext { + session_id: "sess-real".into(), + model: Some("grok-4".into()), + cwd: "/work/dir".into(), + permission_mode: None, + mcp_servers: Vec::new(), + include_partial_messages: false, + api_key_auth: true, + context_window: None, + }); + let post_lines = post.error("boom", None, 0, None); + let post_result = post_lines + .iter() + .find(|l| l["type"] == "result") + .expect("result line"); + assert_eq!( + post_result["session_id"], "sess-real", + "post-open error carries the real session id" + ); + let init = post_lines + .iter() + .find(|l| l["type"] == "system" && l["subtype"] == "init") + .expect("system/init line"); + assert_eq!(init["session_id"], "sess-real"); + assert_eq!(init["cwd"], "/work/dir"); +} + +use super::*; +use xai_grok_workspace::permission::types::{RuleAction, ToolFilter}; + +fn s(v: &str) -> String { + v.to_owned() +} + +/// Headless materialization is never chat and carries the pre-sandbox pin flag through. +#[test] +fn headless_materialize_ctx_stays_non_chat() { + use crate::app::session_startup::TitleResolution; + for has_worktree in [false, true] { + for pinned in [false, true] { + let ctx = headless_materialize_ctx(has_worktree, pinned); + assert!(!ctx.chat_mode); + assert_eq!(ctx.has_worktree, has_worktree); + assert_eq!( + ctx.title_resolution, + if pinned { + TitleResolution::PinnedPreSandbox + } else { + TitleResolution::Allowed + } + ); + } + } +} + +#[test] +fn strict_valid_rules_parse_deny_before_allow() { + let allow = vec![s("Bash(npm*)")]; + let deny = vec![s("Bash(rm*)"), s("Edit(/etc/**)")]; + let rules = parse_permission_rules_strict(&allow, &deny).unwrap(); + assert_eq!(rules.len(), 3); + assert_eq!(rules[0].action, RuleAction::Deny); + assert!(matches!(rules[0].tool, ToolFilter::Bash)); + assert_eq!(rules[1].action, RuleAction::Deny); + assert!(matches!(rules[1].tool, ToolFilter::Edit)); + assert_eq!(rules[2].action, RuleAction::Allow); + assert!(matches!(rules[2].tool, ToolFilter::Bash)); +} + +#[test] +fn strict_invalid_rule_errors() { + let result = parse_permission_rules_strict(&[], &[s("EnterWorktree(foo)")]); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("--deny")); + assert!(msg.contains("EnterWorktree")); +} + +#[test] +fn strict_reports_all_invalid_rules() { + let result = parse_permission_rules_strict( + &[s("BadTool(x)")], + &[s("EnterWorktree(foo)"), s("Bash(rm*)")], + ); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("EnterWorktree"), + "should mention first bad deny" + ); + assert!(msg.contains("BadTool"), "should mention bad allow"); +} + +#[test] +fn lenient_skips_invalid_keeps_valid() { + let allow = vec![s("Bash(npm*)")]; + let deny = vec![s("EnterWorktree(foo)"), s("Bash(rm*)")]; + let rules = parse_permission_rules_lenient(&allow, &deny); + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].action, RuleAction::Deny); + assert_eq!(rules[0].pattern.as_deref(), Some("rm*")); + assert_eq!(rules[1].action, RuleAction::Allow); + assert_eq!(rules[1].pattern.as_deref(), Some("npm*")); +} + +#[test] +fn empty_inputs_produce_empty_rules() { + let rules = parse_permission_rules_strict(&[], &[]).unwrap(); + assert!(rules.is_empty()); + let rules = parse_permission_rules_lenient(&[], &[]); + assert!(rules.is_empty()); +} + +#[test] +fn domain_mode_web_fetch() { + let rules = parse_permission_rules_strict(&[], &[s("WebFetch(domain:evil.com)")]).unwrap(); + assert_eq!(rules.len(), 1); + assert!(matches!(rules[0].tool, ToolFilter::WebFetch)); + assert_eq!( + rules[0].pattern_mode, + xai_grok_workspace::permission::types::PatternMode::Domain + ); + assert_eq!(rules[0].pattern.as_deref(), Some("evil.com")); +} + +#[test] +fn bash_colon_wildcard_deny_translates_to_prefix() { + let rules = parse_permission_rules_strict(&[], &[s("Bash(sed:*)")]).unwrap(); + assert_eq!(rules.len(), 1); + assert!(matches!(rules[0].tool, ToolFilter::Bash)); + assert_eq!(rules[0].pattern.as_deref(), Some("sed")); +} + +#[test] +fn structured_output_without_meta_errors_never_parses_text() { + let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true); + emitter.text_buffer = r#"{"name":"alice","age":30}"#.into(); + emitter.set_structured_output_from_meta(serde_json::json!({}).as_object()); + let result = emitter.build_json_result("EndTurn", "sess-1", "req-1"); + assert!(result["structuredOutput"].is_null()); + assert_eq!( + result["structuredOutputError"], + "model did not produce structured output" + ); +} + +#[test] +fn structured_output_from_meta_wins_over_text_buffer() { + let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true); + emitter.text_buffer = "thinking out loud...".into(); + emitter.set_structured_output_from_meta( + serde_json::json!({"structuredOutput": {"name": "carol"}}).as_object(), + ); + let result = emitter.build_json_result("EndTurn", "sess-1", "req-1"); + assert_eq!(result["structuredOutput"]["name"], "carol"); + assert!(result.get("structuredOutputError").is_none()); + + let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true); + emitter.set_structured_output_from_meta( + serde_json::json!({ + "structuredOutputError": "output does not match the required schema" + }) + .as_object(), + ); + let result = emitter.build_json_result("EndTurn", "sess-1", "req-1"); + assert!(result["structuredOutput"].is_null()); + assert_eq!( + result["structuredOutputError"], + "output does not match the required schema" + ); +} + +#[test] +fn streaming_json_structured_output_emits_from_meta() { + let mut emitter = HeadlessEmitter::new(OutputFormat::StreamingJson, true); + emitter.on_text_chunk(r#"{"name":"#); + emitter.on_text_chunk(r#""bob"}"#); + assert!(emitter.text_buffer.is_empty()); + + emitter.set_structured_output_from_meta( + serde_json::json!({"structuredOutput": {"name": "bob"}}).as_object(), + ); + let mut target = serde_json::json!({}); + emitter.attach_structured_output(&mut target); + assert_eq!(target["structuredOutput"]["name"], "bob"); + assert!(target.get("structuredOutputError").is_none()); +} + +#[test] +fn broken_pipe_write_is_a_clean_latched_stop() { + let mut emitter = HeadlessEmitter::new(OutputFormat::StreamingMessagesJson, false); + let result = emitter.record_write_result(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "pipe", + ))); + assert!(result.is_ok(), "broken pipe is a clean stop"); + assert!(emitter.output_closed); + assert!(emitter.take_output_error().is_none()); +} + +#[test] +fn hard_write_error_is_latched_and_surfaced_once() { + let mut emitter = HeadlessEmitter::new(OutputFormat::StreamingMessagesJson, false); + let result = emitter.record_write_result(Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + ))); + assert!(result.is_err(), "hard error is surfaced to the caller"); + assert!(emitter.output_closed); + let latched = emitter.take_output_error().expect("hard error latched"); + assert_eq!(latched.kind(), std::io::ErrorKind::PermissionDenied); + assert!( + emitter.take_output_error().is_none(), + "taken once, then cleared" + ); +} + +#[test] +fn first_hard_write_error_wins_the_latch() { + let mut emitter = HeadlessEmitter::new(OutputFormat::Json, false); + let _ = emitter.record_write_result(Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "first", + ))); + let _ = emitter.record_write_result(Err(std::io::Error::other("second"))); + assert_eq!( + emitter.take_output_error().map(|e| e.kind()), + Some(std::io::ErrorKind::PermissionDenied) + ); +} + +#[test] +fn successful_write_leaves_no_latched_error() { + let mut emitter = HeadlessEmitter::new(OutputFormat::Plain, false); + assert!(emitter.record_write_result(Ok(())).is_ok()); + assert!(!emitter.output_closed); + assert!(emitter.take_output_error().is_none()); +} + +#[test] +fn parse_json_schema_rejects_non_objects_and_invalid_json() { + assert!(super::parse_json_schema(r#"{"type":"object"}"#).is_ok()); + assert!( + super::parse_json_schema(r#"[1,2,3]"#) + .unwrap_err() + .to_string() + .contains("must be a JSON object") + ); + assert!( + super::parse_json_schema(r#"{not json"#) + .unwrap_err() + .to_string() + .contains("invalid JSON") + ); +} diff --git a/crates/codegen/xai-grok-pager/src/notifications/hooks.rs b/crates/codegen/xai-grok-pager/src/notifications/hooks.rs index 6bbdfe3..25d48c0 100644 --- a/crates/codegen/xai-grok-pager/src/notifications/hooks.rs +++ b/crates/codegen/xai-grok-pager/src/notifications/hooks.rs @@ -1,4 +1,5 @@ use std::process::{Command, Stdio}; +use std::sync::Arc; use std::time::Duration; use crate::notifications::NotificationEvent; @@ -38,33 +39,56 @@ fn execute_hook( } } - match cmd.spawn() { - Ok(mut child) => { - use wait_timeout::ChildExt; - match child.wait_timeout(timeout) { - Ok(Some(_)) => {} - Ok(None) => { - // Kill the entire process group, not just the direct child. - #[cfg(unix)] - { - let pid = child.id() as i32; - let _ = nix::sys::signal::killpg( - nix::unistd::Pid::from_raw(pid), - nix::sys::signal::Signal::SIGKILL, - ); - } - #[cfg(not(unix))] - { - let _ = child.kill(); - } - let _ = child.wait(); - tracing::warn!("hook timed out"); - } - Err(e) => tracing::debug!(error = %e, command, "hook wait failed"), + #[allow(clippy::disallowed_methods)] // enrolled below, once the child exists + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + tracing::debug!(error = %e, command, "hook spawn failed"); + return; + } + }; + + // Enrolled so a hook still running when the pager exits is reaped with it. + let group = attach_to_global_scope(&child); + + use wait_timeout::ChildExt; + let waited = child.wait_timeout(timeout); + if !matches!(waited, Ok(Some(_))) { + // The enrollment ends with this function, so kill rather than orphan. + match &group { + Some(group) => { + let _ = group.kill(); + } + None => { + let _ = child.kill(); } } - Err(e) => tracing::debug!(error = %e, command, "hook spawn failed"), + let _ = child.wait(); } + // Reaped, so the pid is released and its pgid can be recycled: the group + // handle must not outlive the child it names. + drop(group); + match waited { + Ok(Some(_)) => {} + Ok(None) => tracing::warn!("hook timed out"), + Err(e) => tracing::debug!(error = %e, command, "hook wait failed"), + } +} + +/// `None` when the child could not be enrolled, which includes the scope +/// having already closed and killed it. +fn attach_to_global_scope(child: &std::process::Child) -> Option> { + let mut group = xai_tty_utils::ProcessGroup::new() + .inspect_err(|e| tracing::debug!(error = %e, "hook process group failed")) + .ok()?; + group + .attach_std(child) + .inspect_err(|e| tracing::debug!(error = %e, "hook process group attach failed")) + .ok()?; + let group = Arc::new(group); + xai_tty_utils::global_process_scope() + .register(&group) + .then_some(group) } pub fn run_hook(hook: &NotificationHook, event: &NotificationEvent) { diff --git a/crates/codegen/xai-grok-pager/src/notifications/sleep.rs b/crates/codegen/xai-grok-pager/src/notifications/sleep.rs index c075e62..9e3df21 100644 --- a/crates/codegen/xai-grok-pager/src/notifications/sleep.rs +++ b/crates/codegen/xai-grok-pager/src/notifications/sleep.rs @@ -118,6 +118,7 @@ impl SleepInhibitor { // `panic=abort` SIGABRT — no Drop runs) can't leave an immortal // inhibitor holding the lock and pid slots on shared hosts. xai_tty_utils::kill_on_parent_death_std(&mut cmd); + #[allow(clippy::disallowed_methods)] // bound by kill-on-parent-death; released each turn let result = cmd.spawn(); match result { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/block.rs b/crates/codegen/xai-grok-pager/src/scrollback/block.rs index 52ee1a8..3912610 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/block.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/block.rs @@ -3,6 +3,7 @@ use ratatui::style::Style; use ratatui::text::{Span, Text}; +use crate::appearance::AppearanceConfig; use crate::diff::DiffHunk; use crate::inline_media_ffmpeg::inline_media_reserved_rows; use crate::prompt_images::{InlineMediaInfo, ScrollbackImageRef, ScrollbackVideoRef}; @@ -84,10 +85,17 @@ pub trait BlockContent { } /// Vertical padding (blank line with accent top/bottom). - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + /// + /// Borrows the appearance rather than taking a [`BlockContext`] so the + /// O(history) height passes do not build one per entry. + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { true } + fn has_vpad(&self, ctx: &BlockContext) -> bool { + self.has_vpad_for(&ctx.appearance) + } + /// Whether block supports raw mode toggle. fn has_raw_mode(&self) -> bool { false @@ -490,8 +498,8 @@ impl BlockContent for RenderBlock { delegate_block!(self, background(ctx)) } - fn has_vpad(&self, ctx: &BlockContext) -> bool { - delegate_block!(self, has_vpad(ctx)) + fn has_vpad_for(&self, appearance: &AppearanceConfig) -> bool { + delegate_block!(self, has_vpad_for(appearance)) } fn has_raw_mode(&self) -> bool { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/agent.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/agent.rs index 9bf6b47..20d003a 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/agent.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/agent.rs @@ -5,6 +5,7 @@ use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput}; use super::markdown_content::MarkdownContent; use super::mermaid_content::{self, MermaidContent}; +use crate::appearance::AppearanceConfig; /// Block displaying an agent message with streaming markdown support. /// @@ -208,7 +209,7 @@ impl BlockContent for AgentMessageBlock { None } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/bg_task.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/bg_task.rs index a37e03b..de0029a 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/bg_task.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/bg_task.rs @@ -9,6 +9,7 @@ use std::time::Duration; use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; +use crate::appearance::AppearanceConfig; use crate::render::color::blend_color; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode}; @@ -224,7 +225,7 @@ impl BlockContent for BgTaskBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/btw.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/btw.rs index d5d7b2f..eb47c83 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/btw.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/btw.rs @@ -12,6 +12,7 @@ use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput use crate::theme::Theme; use super::markdown_content::MarkdownContent; +use crate::appearance::AppearanceConfig; /// Block displaying a /btw side-question and its response. #[derive(Debug, Clone)] @@ -80,7 +81,7 @@ impl BlockContent for BtwBlock { true } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/context_info.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/context_info.rs index 124aaf9..94dab61 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/context_info.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/context_info.rs @@ -10,6 +10,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::appearance::AppearanceConfig; use crate::render::wrapping::word_wrap_lines; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput}; @@ -633,7 +634,7 @@ impl BlockContent for ContextInfoBlock { None } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false // Compact like SystemMessageBlock } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/credit_limit.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/credit_limit.rs index 9ffc4e4..e8ba9ac 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/credit_limit.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/credit_limit.rs @@ -8,6 +8,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::appearance::AppearanceConfig; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, DisplayMode}; use crate::theme::Theme; @@ -93,7 +94,7 @@ impl BlockContent for CreditLimitBlock { Some(AccentStyle::static_color(theme.warning)) } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { true } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs index 07d1b96..ea80dc8 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs @@ -11,6 +11,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use super::tool::HookRunEntry; +use crate::appearance::AppearanceConfig; use crate::render::wrapping::word_wrap_lines; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -590,7 +591,7 @@ impl BlockContent for SessionEventBlock { self.accent(ctx) } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false // Compact like SystemMessageBlock } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/subagent.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/subagent.rs index cef3f54..ebe317c 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/subagent.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/subagent.rs @@ -18,6 +18,7 @@ use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::app::subagent::format_subagent_meta; +use crate::appearance::AppearanceConfig; use crate::render::color::blend_color; use crate::render::line_utils::truncate_str; use crate::scrollback::block::BlockContent; @@ -292,7 +293,7 @@ impl BlockContent for SubagentBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/system.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/system.rs index 6990004..96a3300 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/system.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/system.rs @@ -2,6 +2,7 @@ use ratatui::text::{Line, Span}; +use crate::appearance::AppearanceConfig; use crate::render::wrapping::word_wrap_lines; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, Selectable}; @@ -72,7 +73,7 @@ impl BlockContent for SystemMessageBlock { None // System messages have no accent } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false // System messages are compact } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/thinking.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/thinking.rs index 79ed0c3..08597b7 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/thinking.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/thinking.rs @@ -13,6 +13,7 @@ use crate::theme::Theme; use super::markdown_content::MarkdownContent; use super::quote_bar::QuoteBarStrip; +use crate::appearance::AppearanceConfig; /// TODO: hard-coded because `AppView::minimal_key_intercept` matches this chord /// literally instead of going through the keybinding registry. Resolve the @@ -468,7 +469,7 @@ impl BlockContent for ThinkingBlock { false } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs index 6ac1d51..d392699 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs @@ -34,6 +34,7 @@ use syntect::easy::HighlightLines; use syntect::highlighting::Style as SyntectStyle; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; use crate::diff::{DiffHunk, diff_hunks_to_patch}; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -1381,8 +1382,8 @@ impl BlockContent for EditToolCallBlock { ctx.appearance.scrollback.blocks.edit.accent_bg } - fn has_vpad(&self, ctx: &BlockContext) -> bool { - ctx.appearance.scrollback.blocks.edit.vpad + fn has_vpad_for(&self, appearance: &AppearanceConfig) -> bool { + appearance.scrollback.blocks.edit.vpad } fn background(&self, ctx: &BlockContext) -> BlockBackground { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs index e38ec2a..2befddb 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs @@ -4,6 +4,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; use crate::appearance::ExecuteHeaderStyle; use crate::render::wrapping::word_wrap_lines_with_joiners; use crate::scrollback::block::BlockContent; @@ -707,7 +708,7 @@ impl BlockContent for ExecuteToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/lifecycle.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/lifecycle.rs index 6ca994b..5fdbfd3 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/lifecycle.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/lifecycle.rs @@ -7,6 +7,7 @@ use ratatui::text::{Line, Span}; +use crate::appearance::AppearanceConfig; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode}; use crate::theme::Theme; @@ -41,7 +42,7 @@ impl BlockContent for LifecycleEventBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/list_dir.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/list_dir.rs index 9dd8303..96bd114 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/list_dir.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/list_dir.rs @@ -10,6 +10,7 @@ use crate::scrollback::types::{ use crate::theme::Theme; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; /// List directory tool call. #[derive(Debug, Clone)] @@ -207,7 +208,7 @@ impl BlockContent for ListDirToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/memory_search.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/memory_search.rs index 13c85d1..c8e6aa5 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/memory_search.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/memory_search.rs @@ -4,6 +4,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; use crate::render::line_utils::truncate_str; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -268,7 +269,7 @@ impl BlockContent for MemorySearchToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/mod.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/mod.rs index 7539ca5..c4ae580 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/mod.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/mod.rs @@ -36,6 +36,7 @@ pub use use_tool::UseToolCallBlock; pub use web_fetch::WebFetchToolCallBlock; pub use web_search::WebSearchToolCallBlock; +use crate::appearance::AppearanceConfig; use crate::scrollback::block::{BlockContent, join_searchable}; use crate::scrollback::types::{ AccentStyle, BlockBackground, BlockContext, BlockOutput, DisplayMode, @@ -228,8 +229,8 @@ impl BlockContent for ToolCallBlock { delegate_tool!(self, background(ctx)) } - fn has_vpad(&self, ctx: &BlockContext) -> bool { - delegate_tool!(self, has_vpad(ctx)) + fn has_vpad_for(&self, appearance: &AppearanceConfig) -> bool { + delegate_tool!(self, has_vpad_for(appearance)) } fn has_raw_mode(&self) -> bool { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/other.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/other.rs index 0a77fb1..93d3343 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/other.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/other.rs @@ -2,6 +2,7 @@ use ratatui::text::{Line, Span}; +use crate::appearance::AppearanceConfig; use crate::render::wrapping::word_wrap_lines; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -333,7 +334,7 @@ impl BlockContent for OtherToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs index 238f638..caeb2fa 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/read.rs @@ -18,6 +18,7 @@ use crate::theme::Theme; const FIRST_LINES: usize = 5; const LAST_LINES: usize = 3; +use crate::appearance::AppearanceConfig; use xai_grok_tools::implementations::skills::types::skill_name_from_path; /// What kind of non-text media this read produced. @@ -415,7 +416,7 @@ impl BlockContent for ReadToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search.rs index 19eaf20..47307c1 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search.rs @@ -10,6 +10,7 @@ use crate::scrollback::types::{ use crate::theme::Theme; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; /// A single line match from search results. #[derive(Debug, Clone)] @@ -531,7 +532,7 @@ impl BlockContent for SearchToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search_tool.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search_tool.rs index efd96a5..d6de8a0 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search_tool.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/search_tool.rs @@ -5,6 +5,7 @@ use ratatui::text::{Line, Span, Text}; use xai_grok_workspace::permission::mcp_titleize_segment; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; use crate::render::line_utils::truncate_str; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -285,7 +286,7 @@ impl BlockContent for SearchToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/use_tool.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/use_tool.rs index e78d2bd..3eb9168 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/use_tool.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/use_tool.rs @@ -4,6 +4,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; use xai_grok_workspace::permission::{MCP_TOOL_NAME_DELIMITER, mcp_titleize_segment}; +use crate::appearance::AppearanceConfig; use crate::render::line_utils::truncate_str; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -256,7 +257,7 @@ impl BlockContent for UseToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_fetch.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_fetch.rs index d90765a..8d84122 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_fetch.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_fetch.rs @@ -4,6 +4,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; use crate::render::line_utils::truncate_str; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -316,7 +317,7 @@ impl BlockContent for WebFetchToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_search.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_search.rs index 4afb807..6c0d268 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_search.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/web_search.rs @@ -6,6 +6,7 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; use super::TOOL_HEADER_RANGE; +use crate::appearance::AppearanceConfig; use crate::render::line_utils::truncate_str; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{ @@ -349,7 +350,7 @@ impl BlockContent for WebSearchToolCallBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/user.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/user.rs index aa8b53a..dccc7a8 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/user.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/user.rs @@ -15,6 +15,7 @@ use crate::scrollback::types::{ const USER_PROMPT_BODY_RANGE: u16 = 0; /// Max visible lines when a user prompt is collapsed. const COLLAPSED_MAX_LINES: usize = 3; +use crate::appearance::AppearanceConfig; use crate::theme::Theme; /// Drop invalid token ranges (replay meta is untrusted): out of bounds, not @@ -488,8 +489,8 @@ impl BlockContent for UserPromptBlock { ctx.appearance.scrollback.blocks.prompt.bg } - fn has_vpad(&self, ctx: &BlockContext) -> bool { - ctx.appearance.scrollback.blocks.prompt.vpad && !ctx.appearance.prompt.compact + fn has_vpad_for(&self, appearance: &AppearanceConfig) -> bool { + appearance.scrollback.blocks.prompt.vpad && !appearance.prompt.compact } fn has_raw_mode(&self) -> bool { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/workflow.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/workflow.rs index 60a4ade..726e715 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/workflow.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/workflow.rs @@ -3,6 +3,7 @@ use std::time::Duration; use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; +use crate::appearance::AppearanceConfig; use crate::render::color::blend_color; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode}; @@ -165,7 +166,7 @@ impl BlockContent for WorkflowBlock { } } - fn has_vpad(&self, _ctx: &BlockContext) -> bool { + fn has_vpad_for(&self, _appearance: &AppearanceConfig) -> bool { false } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/entry.rs b/crates/codegen/xai-grok-pager/src/scrollback/entry.rs index 70b758e..c19e928 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/entry.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/entry.rs @@ -55,6 +55,20 @@ impl EntryId { } } +/// Saturates at `u16::MAX` so a multi-MB block cannot overflow `virtual_y`. +fn wrapped_lines_from_widths(widths: &[u32], content_width: u16) -> u16 { + let cw = content_width.max(1) as usize; + let mut total: usize = 0; + for &w in widths { + let w = w as usize; + total += if w == 0 { 1 } else { w.div_ceil(cw) }; + if total >= u16::MAX as usize { + return u16::MAX; + } + } + total.max(1) as u16 +} + /// A scrollback entry: block content + display state. #[derive(Debug, Clone)] pub struct ScrollbackEntry { @@ -116,6 +130,11 @@ pub struct ScrollbackEntry { /// same-width rebuild reuse the estimate instead of re-cloning the block's /// source text. Cleared by `invalidate_cache`. cached_estimate_lines: RefCell>, + + /// Display width of each source line. Width-independent, so unlike every + /// other cache here it survives a resize — re-deriving it per width is what + /// made a resize cost O(total conversation bytes). + cached_line_widths: RefCell>>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -181,6 +200,7 @@ impl ScrollbackEntry { cached_output: RefCell::new(None), cached_truncated_height: RefCell::new(None), cached_estimate_lines: RefCell::new(None), + cached_line_widths: RefCell::new(None), } } @@ -212,6 +232,7 @@ impl ScrollbackEntry { cached_output: RefCell::new(None), cached_truncated_height: RefCell::new(None), cached_estimate_lines: RefCell::new(None), + cached_line_widths: RefCell::new(None), } } @@ -275,8 +296,14 @@ impl ScrollbackEntry { self.invalidate_cache(); } - /// Invalidate cached output. + /// Invalidate cached output after a content change. pub fn invalidate_cache(&mut self) { + self.invalidate_width_caches(); + *self.cached_line_widths.borrow_mut() = None; + } + + /// Invalidate only the caches keyed by terminal width — the resize path. + pub fn invalidate_width_caches(&mut self) { *self.cached_output.borrow_mut() = None; *self.cached_truncated_height.borrow_mut() = None; *self.cached_estimate_lines.borrow_mut() = None; @@ -310,6 +337,32 @@ impl ScrollbackEntry { *self.cached_estimate_lines.borrow_mut() = Some((content_width, lines)); } + /// Cheap wrapped-line estimate for the block's source text at + /// `content_width`. + /// + /// An APPROXIMATION: it ignores word boundaries, and for a markdown block + /// it reflects the last rendered view. On-screen entries are always + /// measured exactly, so nothing depends on it being right. + pub fn estimate_source_lines(&self, content_width: u16) -> u16 { + let mut slot = self.cached_line_widths.borrow_mut(); + let widths = slot.get_or_insert_with(|| { + let Some(text) = self.block.searchable_text() else { + return Vec::new(); + }; + // Renderers drop a single trailing newline. + let text = text.strip_suffix('\n').unwrap_or(&text); + text.split('\n') + .map(|line| unicode_width::UnicodeWidthStr::width(line) as u32) + .collect() + }); + wrapped_lines_from_widths(widths, content_width) + } + + #[cfg(test)] + pub(crate) fn has_cached_line_widths(&self) -> bool { + self.cached_line_widths.borrow().is_some() + } + /// Whether this entry's laid-out output is cached. Lazy-layout tests use this /// to assert off-screen entries aren't rendered: `desired_height` populates /// the cache, the cheap estimate does not. @@ -657,6 +710,45 @@ mod tests { assert!(!entry.display_mode_pinned); } + #[test] + fn estimate_source_lines_is_the_per_line_ceiling_sum() { + let entry = ScrollbackEntry::new(RenderBlock::user_prompt(format!( + "{}\n\n{}", + "a".repeat(10), + "b".repeat(25) + ))); + // width 10 → 1 + 1 + 3 = 5; width 5 → 2 + 1 + 5 = 8; width 100 → 3. + assert_eq!(entry.estimate_source_lines(10), 5); + assert_eq!(entry.estimate_source_lines(5), 8); + assert_eq!(entry.estimate_source_lines(100), 3); + } + + #[test] + fn width_invalidation_keeps_the_line_profile_content_invalidation_drops_it() { + let mut entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello world")); + entry.estimate_source_lines(20); + assert!(entry.has_cached_line_widths()); + + entry.invalidate_width_caches(); + assert!( + entry.has_cached_line_widths(), + "a width change must not drop the width-independent profile" + ); + + entry.invalidate_cache(); + assert!( + !entry.has_cached_line_widths(), + "a content change must drop the profile" + ); + } + + #[test] + fn estimate_source_lines_without_searchable_text_is_one_line() { + let entry = ScrollbackEntry::new(RenderBlock::user_prompt("")); + assert!(entry.block.searchable_text().is_none()); + assert_eq!(entry.estimate_source_lines(80), 1); + } + #[test] fn test_entry_running() { let entry = ScrollbackEntry::running(RenderBlock::stub("test", Color::Blue)); diff --git a/crates/codegen/xai-grok-pager/src/scrollback/render.rs b/crates/codegen/xai-grok-pager/src/scrollback/render.rs index 2e0eb3f..621ad33 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/render.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/render.rs @@ -431,7 +431,7 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries( None }; let renderer = EntryRenderer::new(entry, theme) - .with_appearance(appearance.clone()) + .with_appearance_ref(appearance) .with_tick(tick) .with_skip_rows(skip_rows) .with_groupable(entry.block.is_groupable()) @@ -1041,7 +1041,7 @@ mod tests { let mut layouts: Vec = entries .iter() .map(|e| { - let renderer = EntryRenderer::new(e, &theme).with_appearance(appearance.clone()); + let renderer = EntryRenderer::new(e, &theme).with_appearance_ref(appearance); let height = renderer.desired_height(content_width); EntryLayoutInfo { height, diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/layout.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/layout.rs index 9b3b337..3deeefc 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/layout.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/layout.rs @@ -445,7 +445,7 @@ impl ScrollbackState { let (_, entry) = self.entries.get_index(entry_idx)?; let theme = Theme::current(); let renderer = EntryRenderer::new(entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(self.cwd()); let rows = u16::try_from(rows_into_entry).unwrap_or(u16::MAX); let logical_line = renderer.logical_line_of_rendered_row(area_width, rows); @@ -482,7 +482,7 @@ impl ScrollbackState { }; let theme = Theme::current(); let renderer = EntryRenderer::new(entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(self.cwd()); let (starts, last_content_row) = renderer.logical_line_start_rows(area_width); let new_line_start = starts @@ -671,7 +671,7 @@ impl ScrollbackState { continue; }; let renderer = EntryRenderer::new(entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(cwd); cache.entries[idx].height = match inline_edit_height { Some((edit_id, h)) if edit_id == *entry_id => h, @@ -982,7 +982,7 @@ impl ScrollbackState { }; let renderer = EntryRenderer::new(entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(cwd); let new_height = match inline_edit_height { Some((edit_id, h)) if edit_id == id => h, @@ -1213,7 +1213,7 @@ impl ScrollbackState { }; let renderer = EntryRenderer::new(new_entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(cwd); let height = renderer.desired_height(entry_area_width); let is_prompt = new_entry.block.is_user_prompt(); @@ -1313,7 +1313,7 @@ impl ScrollbackState { // they scroll in. gap_after is a placeholder (1), fixed up in pass 2. for entry in self.entries.values() { let renderer = EntryRenderer::new(entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(self.cwd()); let height = renderer.estimate_height(entry_area_width); cache.entries.push(EntryLayoutInfo { @@ -2333,6 +2333,70 @@ mod tests { assert!(!measured_at(&state, 0), "far-above history left estimated"); } + #[test] + fn resize_defers_warm_above_until_the_width_settles() { + let _theme = pin_theme(); + let mut state = ScrollbackState::new(); + bulk_load_stubs(&mut state, 200); + state.begin_frame(); + state.prepare_layout(80, 20); + let visible_top = state.first_visible_entry().unwrap(); + assert!( + (0..visible_top).any(|i| measured_at(&state, i)), + "the initial layout still warms above the viewport" + ); + + for width in [79u16, 78, 77] { + state.begin_frame(); + state.prepare_layout(width, 20); + let top = state.first_visible_entry().unwrap(); + assert!( + !(0..top).any(|i| measured_at(&state, i)), + "width {width}: nothing above the viewport is measured mid-drag" + ); + } + + state.begin_frame(); + state.prepare_layout(77, 20); + let top = state.first_visible_entry().unwrap(); + assert!( + (0..top).any(|i| measured_at(&state, i)), + "the deferred warm-up runs once the width stops changing" + ); + } + + /// A fullscreen frame prepares layout twice whenever the timeline rail is + /// on, and the second pass sees an unchanged width. + #[test] + fn resize_defers_warm_above_across_a_frames_extra_layout_passes() { + let _theme = pin_theme(); + let mut state = ScrollbackState::new(); + bulk_load_stubs(&mut state, 200); + state.begin_frame(); + state.prepare_layout(80, 20); + state.prepare_layout(80, 20); + + for width in [79u16, 78, 77] { + state.begin_frame(); + state.prepare_layout(width, 20); + state.prepare_layout(width, 20); + let top = state.first_visible_entry().unwrap(); + assert!( + !(0..top).any(|i| measured_at(&state, i)), + "width {width}: the paint pass must not run the warm-up the \ + rail pass deferred" + ); + } + + state.begin_frame(); + state.prepare_layout(77, 20); + let top = state.first_visible_entry().unwrap(); + assert!( + (0..top).any(|i| measured_at(&state, i)), + "the deferred warm-up runs on the first frame after the drag" + ); + } + #[test] fn lazy_resume_scroll_up_lands_on_prewarmed_exact_entries() { let _theme = pin_theme(); diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs index 396d008..ada6670 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs @@ -37,6 +37,18 @@ use crate::appearance::AppearanceConfig; use crate::render::Renderable; use crate::theme::Theme; +/// Lifecycle of a scroll-up warm-up that a resize postponed until the width +/// settles. Settling is measured in FRAMES, not `prepare_layout` calls: one +/// frame prepares layout several times, so a call-based rule would run the +/// warm-up during the very resize that deferred it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum DeferredWarmAbove { + #[default] + Idle, + Deferred, + Armed, +} + /// Unified scrollback state for the v3 pager. #[derive(Debug)] pub struct ScrollbackState { @@ -174,6 +186,8 @@ pub struct ScrollbackState { /// of an O(n) full rebuild. gaps_may_be_dirty: bool, + warm_above: DeferredWarmAbove, + /// Last observed [`ffmpeg_available`](crate::inline_media_ffmpeg::ffmpeg_available). /// A false→true flip (user installs ffmpeg mid-session) must rebuild the /// layout so reserved heights match the now-full-size posters — otherwise a @@ -246,6 +260,7 @@ impl ScrollbackState { appearance: AppearanceConfig::default(), batch_depth: 0, gaps_may_be_dirty: false, + warm_above: DeferredWarmAbove::Idle, ffmpeg_available_snapshot: false, expanded_groups: HashSet::new(), generation: 0, @@ -1578,9 +1593,11 @@ impl ScrollbackState { None }; - if width != self.last_width { + let width_changed = width != self.last_width; + let resized = width_changed && self.last_width != 0; + if width_changed { for entry in self.entries.values_mut() { - entry.invalidate_cache(); + entry.invalidate_width_caches(); } self.last_width = width; } @@ -1599,7 +1616,16 @@ impl ScrollbackState { self.settle_visible_measurements(width); // Pre-measure a few pages above the bottom so the first scroll-up is // glitch-free (no-op unless bottom-pinned). - self.warm_measure_pages_above(width); + // + // Warming three off-screen pages on every event of a drag, only + // to throw the work away at the next width, profiled as the single + // largest cost of a resize — hence the deferral. + if resized { + self.warm_above = DeferredWarmAbove::Deferred; + } else { + self.warm_above = DeferredWarmAbove::Idle; + self.warm_measure_pages_above(width); + } self.dirty_heights.clear(); self.gaps_may_be_dirty = false; return true; @@ -1645,6 +1671,7 @@ impl ScrollbackState { // A scroll/content change may have brought estimated entries into // view (e.g. streaming while scrolled up); measure them exactly. self.settle_visible_measurements(width); + self.run_pending_warm_above(width); return !changes.is_empty(); } @@ -1662,9 +1689,26 @@ impl ScrollbackState { // Scroll-up (no dirty heights) reveals estimated off-screen entries — // this is the on-demand measurement path for plain scrolling. self.settle_visible_measurements(width); + self.run_pending_warm_above(width); false } + /// Mark the start of a frame that will draw this scrollback. Hosts must + /// call this once per frame; it is the only signal of a frame boundary + /// [`DeferredWarmAbove`] has. + pub fn begin_frame(&mut self) { + if self.warm_above == DeferredWarmAbove::Deferred { + self.warm_above = DeferredWarmAbove::Armed; + } + } + + fn run_pending_warm_above(&mut self, width: u16) { + if self.warm_above == DeferredWarmAbove::Armed { + self.warm_above = DeferredWarmAbove::Idle; + self.warm_measure_pages_above(width); + } + } + /// Invalidate caches if width changed. pub fn invalidate_if_width_changed(&mut self, width: u16) { if width != self.last_width { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs index 6644f09..c3155ee 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs @@ -1092,7 +1092,7 @@ impl ScrollbackState { let theme = Theme::current(); let entry_area_width = self.entry_area_width(self.last_width); EntryRenderer::new(entry, &theme) - .with_appearance(self.appearance.clone()) + .with_appearance_ref(&self.appearance) .with_cwd(self.cwd()) .rendered_row_of_logical_line(entry_area_width, line_in_entry) } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/wrappers/entry_renderer.rs b/crates/codegen/xai-grok-pager/src/scrollback/wrappers/entry_renderer.rs index d669ee2..bcb996e 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/wrappers/entry_renderer.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/wrappers/entry_renderer.rs @@ -1,5 +1,7 @@ //! EntryRenderer - renders a ScrollbackEntry using composed wrappers. +use std::borrow::Cow; +use std::cell::OnceCell; use std::path::Path; use ratatui::buffer::Buffer; @@ -23,7 +25,10 @@ const WAVE_SPEED: f32 = 0.15; pub struct EntryRenderer<'a> { entry: &'a ScrollbackEntry, theme: &'a Theme, - appearance: AppearanceConfig, + /// Deliberately NOT eagerly `AppearanceConfig::default()`: that conversion + /// reads `Theme::current()` and quantizes every color, and profiling a + /// resize showed it running once per entry only to be overwritten. + appearance: OnceCell>, tick: u64, /// Number of rows to skip from the top of the entry. /// @@ -82,7 +87,7 @@ impl<'a> EntryRenderer<'a> { Self { entry, theme, - appearance: AppearanceConfig::default(), + appearance: OnceCell::new(), tick: 0, skip_rows: 0, groupable: false, @@ -147,10 +152,23 @@ impl<'a> EntryRenderer<'a> { } pub fn with_appearance(mut self, appearance: AppearanceConfig) -> Self { - self.appearance = appearance; + self.appearance = OnceCell::from(Cow::Owned(appearance)); self } + /// + /// Preferred inside the O(history) layout loops, which would otherwise + /// clone the config once per entry. + pub fn with_appearance_ref(mut self, appearance: &'a AppearanceConfig) -> Self { + self.appearance = OnceCell::from(Cow::Borrowed(appearance)); + self + } + + fn appearance(&self) -> &AppearanceConfig { + self.appearance + .get_or_init(|| Cow::Owned(AppearanceConfig::default())) + } + pub fn with_tick(mut self, tick: u64) -> Self { self.tick = tick; self @@ -219,7 +237,7 @@ impl<'a> EntryRenderer<'a> { fn render_group_header(&self, area: Rect, buf: &mut Buffer) { use crate::scrollback::state::verb_group::GroupHeaderLabel; - let layout_cfg = &self.appearance.scrollback.layout; + let layout_cfg = &self.appearance().scrollback.layout; let accent_w = if self.hide_accent { 0 } else { @@ -233,7 +251,7 @@ impl<'a> EntryRenderer<'a> { ]) .areas(area); - let display_cfg = &self.appearance.scrollback.display; + let display_cfg = &self.appearance().scrollback.display; let bg = self.theme.bg_base; // Verb-group header: aggregated "Verb N noun" label with run-state @@ -254,7 +272,7 @@ impl<'a> EntryRenderer<'a> { let brightness = theme::wave_brightness( self.tick, self.skip_rows, - self.appearance.animation.wave_rows, + self.appearance().animation.wave_rows, WAVE_SPEED, ); let color = blend_color(bg, self.theme.accent_tool, brightness) @@ -337,8 +355,8 @@ impl<'a> EntryRenderer<'a> { /// When [`Self::hide_accent`] is set the accent column is reclaimed, so /// chrome is just the block pads (typically zeroed in minimal mode). pub fn chrome_width(&self) -> u16 { - let pads = self.appearance.scrollback.layout.block_pad_left - + self.appearance.scrollback.layout.block_pad_right; + let pads = self.appearance().scrollback.layout.block_pad_left + + self.appearance().scrollback.layout.block_pad_right; if self.hide_accent { pads } else { @@ -367,7 +385,7 @@ impl<'a> EntryRenderer<'a> { /// When > 0, content is wrapped at `content_width - reserved` so text /// never collides with the timestamp overlay. fn timestamp_reserved(&self) -> u16 { - if self.appearance.show_timestamps && self.should_show_timestamp() { + if self.appearance().show_timestamps && self.should_show_timestamp() { 10 // max short format: " 12:30 PM" } else { 0 @@ -377,7 +395,7 @@ impl<'a> EntryRenderer<'a> { fn accent(&self, content_width: u16) -> Option { let mut ctx = self .entry - .context(content_width, &self.appearance, self.cwd); + .context(content_width, self.appearance(), self.cwd); ctx.is_selected = self.is_selected; self.entry.block.accent(&ctx) } @@ -404,7 +422,7 @@ impl<'a> EntryRenderer<'a> { .saturating_sub(self.chrome_width()) .saturating_sub(self.timestamp_reserved()); self.entry - .ensure_truncated_height_cached(content_width, &self.appearance, self.cwd) + .ensure_truncated_height_cached(content_width, self.appearance(), self.cwd) } /// Extra rows to reserve for inline media preview (images/video poster). @@ -465,10 +483,7 @@ impl<'a> EntryRenderer<'a> { { 1 } else { - match self.entry.block.searchable_text() { - Some(text) => estimate_wrapped_line_count(&text, content_width), - None => 1, - } + self.entry.estimate_source_lines(content_width) }; self.entry.store_estimate_lines(content_width, lines); lines @@ -479,10 +494,7 @@ impl<'a> EntryRenderer<'a> { /// ceiling can't overflow and corrupt `virtual_y` / `total_height`. Shared by /// `desired_height` and `estimate_height` so the assembly stays canonical. fn assemble_height(&self, content_width: u16, content_lines: u16) -> u16 { - let ctx = self - .entry - .context(content_width, &self.appearance, self.cwd); - let vpad: u16 = if self.entry.block.has_vpad(&ctx) { + let vpad: u16 = if self.entry.block.has_vpad_for(self.appearance()) { 2 } else { 0 @@ -518,14 +530,14 @@ impl<'a> EntryRenderer<'a> { // `cached_output_ref` must come after it. let ctx = self .entry - .context(content_width, &self.appearance, self.cwd); + .context(content_width, self.appearance(), self.cwd); let vpad_top: u16 = if self.entry.block.has_vpad(&ctx) { 1 } else { 0 }; self.entry - .ensure_cached(content_width, &self.appearance, false, self.cwd); + .ensure_cached(content_width, self.appearance(), false, self.cwd); let output = self.entry.cached_output_ref(); let starts = output .lines @@ -605,32 +617,6 @@ fn fill_bg_spaces(buf: &mut Buffer, rect: Rect, bg: ratatui::style::Color) { } } -/// Estimate the wrapped line count for raw `text` at a given content width. -/// -/// Uses DISPLAY width (`unicode_width`), not byte length. A deliberately cheap -/// approximation: it ignores word boundaries and works from RAW source, so it may -/// be larger or smaller than the exact rendered height. Correctness never relies -/// on it — on-screen entries are always measured exactly. -fn estimate_wrapped_line_count(text: &str, content_width: u16) -> u16 { - let cw = content_width.max(1) as usize; - // Renderers drop a single trailing newline; match that so trailing-`\n` - // source doesn't estimate one row too many. - let text = text.strip_suffix('\n').unwrap_or(text); - let mut total: usize = 0; - for line in text.split('\n') { - let display_width = unicode_width::UnicodeWidthStr::width(line); - total += if display_width == 0 { - 1 - } else { - display_width.div_ceil(cw) - }; - if total >= u16::MAX as usize { - return u16::MAX; - } - } - total.max(1) as u16 -} - impl Renderable for EntryRenderer<'_> { fn desired_height(&self, width: u16) -> u16 { if self.thinking_hidden() { @@ -643,7 +629,7 @@ impl Renderable for EntryRenderer<'_> { // affects styling (e.g., UserPrompt prefix color), not line count, so // the non-selected cached output gives the correct height. self.entry - .ensure_cached(content_width, &self.appearance, false, self.cwd); + .ensure_cached(content_width, self.appearance(), false, self.cwd); // Clamp the line count: a pathologically large block could exceed u16. let content_lines = self.entry.cached_output_ref().len().min(u16::MAX as usize) as u16; self.assemble_height(content_width, content_lines) @@ -685,7 +671,7 @@ impl Renderable for EntryRenderer<'_> { (area, self.skip_rows) }; - let layout_cfg = &self.appearance.scrollback.layout; + let layout_cfg = &self.appearance().scrollback.layout; // Minimal (`hide_accent`): reclaim the accent column so content is // flush-left. Fullscreen keeps the 1-col gutter even when a block has // no painted accent (so columns stay aligned across entry types). @@ -708,7 +694,7 @@ impl Renderable for EntryRenderer<'_> { // for edit blocks) that was previously thrown away. let mut ctx = self .entry - .context(content_area.width, &self.appearance, self.cwd); + .context(content_area.width, self.appearance(), self.cwd); ctx.is_selected = self.is_selected; // Minimal mode blends committed/tail blocks with the real terminal // background; suppress the block's own band (keeps accents + per-line @@ -811,7 +797,7 @@ impl Renderable for EntryRenderer<'_> { .is_some_and(|hd| hd.has_content()); let use_collapsed_accent = self.groupable && self.entry.display_mode == DisplayMode::Collapsed && !has_hook_lines; - let display_cfg = &self.appearance.scrollback.display; + let display_cfg = &self.appearance().scrollback.display; if accent.is_none() { // No accent: clear the accent column so stale content from @@ -835,7 +821,7 @@ impl Renderable for EntryRenderer<'_> { } else if accent_style.animated { // Animated accents: wave effect (running blocks) let bg = bg_color.unwrap_or(self.fallback_bg()); - let wave_rows = self.appearance.animation.wave_rows; + let wave_rows = self.appearance().animation.wave_rows; for row in 0..accent_area.height { let y = accent_area.y + row; @@ -877,7 +863,7 @@ impl Renderable for EntryRenderer<'_> { let ts_reserved = self.timestamp_reserved(); let text_width = content_area.width.saturating_sub(ts_reserved); self.entry - .ensure_cached(text_width, &self.appearance, self.is_selected, self.cwd); + .ensure_cached(text_width, self.appearance(), self.is_selected, self.cwd); let cached_ref = self.entry.cached_output_ref(); let output: &BlockOutput = &cached_ref; let has_vpad = self.entry.block.has_vpad(&ctx); @@ -946,7 +932,7 @@ impl Renderable for EntryRenderer<'_> { // Short format (h:mm AM/PM) by default; expands to full format // (HH:mm:ss | MMM DD) when the mouse hovers over the timestamp area. // Gated on appearance.show_timestamps (toggled via /timestamps). - if self.appearance.show_timestamps + if self.appearance().show_timestamps && content_skip == 0 && !output.is_empty() && self.should_show_timestamp() @@ -1008,7 +994,7 @@ impl Renderable for EntryRenderer<'_> { if style.animated { // Animated bullet: wave effect synced with accent let bg = bg_color.unwrap_or(self.fallback_bg()); - let wave_rows = self.appearance.animation.wave_rows; + let wave_rows = self.appearance().animation.wave_rows; let brightness = theme::wave_brightness(self.tick, 0, wave_rows, WAVE_SPEED); let animated_color = blend_color(bg, style.color, brightness).unwrap_or(style.color); @@ -1188,7 +1174,7 @@ mod tests { /// for `width`, derived from the renderer geometry so tests self-adjust to /// the default layout padding instead of hard-coding column numbers. fn gutter_band(renderer: &EntryRenderer, width: u16) -> std::ops::Range { - let content_right = width - renderer.appearance.scrollback.layout.block_pad_right; + let content_right = width - renderer.appearance().scrollback.layout.block_pad_right; (content_right - renderer.timestamp_reserved())..content_right } @@ -1568,7 +1554,7 @@ mod tests { let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let content_left = - HorizontalLayout::ACCENT + renderer.appearance.scrollback.layout.block_pad_left; + HorizontalLayout::ACCENT + renderer.appearance().scrollback.layout.block_pad_left; let ghost_x = gutter_band(&renderer, width).start + 2; // Clean render: find the code row by its token, capture its background. @@ -1759,8 +1745,8 @@ mod tests { #[test] fn estimate_wrapped_line_count_saturates_at_u16_max() { // > u16::MAX source lines must cap, not overflow the running total. - let many = "\n".repeat(70_000); - assert_eq!(estimate_wrapped_line_count(&many, 80), u16::MAX); + let entry = ScrollbackEntry::new(RenderBlock::user_prompt("\n".repeat(70_000))); + assert_eq!(entry.estimate_source_lines(80), u16::MAX); } #[test] diff --git a/crates/codegen/xai-grok-pager/src/sessions_cmd.rs b/crates/codegen/xai-grok-pager/src/sessions_cmd.rs index 369ac4c..ff949ec 100644 --- a/crates/codegen/xai-grok-pager/src/sessions_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/sessions_cmd.rs @@ -62,6 +62,7 @@ pub async fn run(args: SessionsArgs, agent_config: &AgentConfig) -> Result<()> { let sessions = xai_grok_shell::session::merge::fetch_merged( Some(&client), cwd.to_str(), + xai_grok_shell::session::merge::CwdScope::WithSiblings, None, limit, ) diff --git a/crates/codegen/xai-grok-pager/src/share_cmd.rs b/crates/codegen/xai-grok-pager/src/share_cmd.rs index ebff036..99a73dd 100644 --- a/crates/codegen/xai-grok-pager/src/share_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/share_cmd.rs @@ -1,10 +1,5 @@ use anyhow::Result; -use tokio_util::sync::CancellationToken; use xai_grok_shell::agent::config::Config as AgentConfig; -use xai_grok_shell::session::share::{ShareSessionRequest, ShareSessionResponse}; - -use agent_client_protocol as acp; -use xai_acp_lib::acp_send; #[derive(Debug, clap::Args, Clone)] pub struct ShareArgs { @@ -13,39 +8,6 @@ pub struct ShareArgs { } pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> { - let cancel = CancellationToken::new(); - let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?; - // Cancel + join on every return path, including the `?`s below. - let _agent_guard = - crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle)); - - let _init: acp::InitializeResponse = acp_send( - acp::InitializeRequest::new(acp::ProtocolVersion::V1) - .client_capabilities( - acp::ClientCapabilities::new() - .fs(acp::FileSystemCapabilities::new()) - .terminal(false), - ) - .meta( - serde_json::json!({ - "clientType": crate::client_identity::HEADLESS_CLIENT_TYPE, - "clientVersion": crate::client_identity::PAGER_CLIENT_VERSION - }) - .as_object() - .cloned(), - ), - &spawned.channel.tx, - ) - .await?; - - let params = serde_json::value::to_raw_value(&ShareSessionRequest { - session_id: args.session_id.clone(), - })?; - let ext_req = acp::ExtRequest::new("x.ai/share_session", params.into()); - - let ext_resp: acp::ExtResponse = acp_send(ext_req, &spawned.channel.tx).await?; - let response: ShareSessionResponse = serde_json::from_str(ext_resp.0.get())?; - - println!("{}", response.share_url); - Ok(()) + let _ = (args, agent_config); + anyhow::bail!("Session sharing is temporarily disabled"); } diff --git a/crates/codegen/xai-grok-pager/src/slash/acp_command.rs b/crates/codegen/xai-grok-pager/src/slash/acp_command.rs index 8d55ffa..c00eb62 100644 --- a/crates/codegen/xai-grok-pager/src/slash/acp_command.rs +++ b/crates/codegen/xai-grok-pager/src/slash/acp_command.rs @@ -196,6 +196,7 @@ mod tests { bundle_state: &crate::app::bundle::BundleState::default(), screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), }; match acp_cmd.run(&mut ctx, "fix the branch") { @@ -322,6 +323,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, diff --git a/crates/codegen/xai-grok-pager/src/slash/command.rs b/crates/codegen/xai-grok-pager/src/slash/command.rs index 939fd28..6696a07 100644 --- a/crates/codegen/xai-grok-pager/src/slash/command.rs +++ b/crates/codegen/xai-grok-pager/src/slash/command.rs @@ -12,6 +12,7 @@ use crate::acp::model_state::ModelState; use crate::app::actions::Action; use crate::app::bundle::BundleState; +use crate::slash::mode_support::ModeSupport; use agent_client_protocol as acp; /// Provisional scheduled task info for immediate display in the tasks pane. @@ -110,6 +111,9 @@ pub struct AppCtx<'a> { pub has_session_announcements: bool, /// Consumer billing surface (`AppView::usage_visible`). Gates `/usage` subcommands. pub billing_surface_visible: bool, + /// Whether `/usage` is offered and executable. False for external-auth + /// deployments with no grok.com billing session. + pub usage_command_visible: bool, pub workflows_available: bool, /// Effective render mode of this process (gates `/minimal` and /// `/fullscreen` visibility). Same source of truth as @@ -129,6 +133,9 @@ pub struct CommandExecCtx<'a> { pub(crate) screen_mode: crate::app::ScreenMode, /// Consumer billing surface (`AppView::usage_visible`). Gates `/usage` subcommands. pub billing_surface_visible: bool, + /// Whether `/usage` is offered and executable. False for external-auth + /// deployments with no grok.com billing session. + pub usage_command_visible: bool, /// Snapshot of the active agent's PAGER-owned settings, built at /// command-build time by the dispatcher. Slash commands like /// `/multiline` read this to compute `!current` and dispatch a @@ -246,24 +253,26 @@ pub trait SlashCommand: Send + Sync { false } - /// Whether this command functions in the scrollback-native **minimal** - /// mode (`grok --minimal`). + /// Which render modes this command functions in. /// - /// Minimal mode deletes the interactive fullscreen scrollback pane, the - /// in-app mouse selection path, and the agent dashboard, handing scroll / - /// search / selection back to the terminal (K7). Commands that drive those - /// deleted surfaces — `/find`, `/dashboard` — have nothing to act on, so - /// the central dispatch gate refuses them with a "/ is not available in - /// minimal mode" message (committed as a system block). Clipboard helpers - /// like `/copy` stay available: they read scrollback state and do not need - /// the fullscreen pane. + /// Minimal mode (`grok --minimal`) deletes the interactive fullscreen + /// scrollback pane, the in-app mouse selection path, and the agent + /// dashboard, handing scroll / search / selection back to the terminal + /// (K7); a few commands exist only there, because the full TUI solves the + /// same problem with a pane or a chord. Declaring the mode here is the + /// single source for both behaviors: the command is hidden from every + /// completion surface in the modes it does not support (`command_offered`), + /// and a fully-typed invocation is refused with an actionable hint by the + /// central dispatch gate instead of running against a surface that does not + /// exist. /// - /// Defaults to `true` — a **denylist, not an allowlist**: the many - /// mode-agnostic commands keep working and new commands are available in - /// minimal by default (the mode is converging toward parity). Override to - /// `false` only for genuinely fullscreen-pane-dependent commands. - fn available_in_minimal(&self) -> bool { - true + /// Defaults to [`ModeSupport::Both`] — a **denylist, not an allowlist**: + /// the many mode-agnostic commands keep working and new commands are + /// available everywhere by default (minimal is converging toward parity). + /// Clipboard helpers like `/copy` stay `Both`: they read scrollback state + /// and do not need the fullscreen pane. + fn mode_support(&self) -> ModeSupport { + ModeSupport::Both } /// Placeholder text shown in the prompt when args are empty. diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/always_approve.rs b/crates/codegen/xai-grok-pager/src/slash/commands/always_approve.rs index 914f351..17f270d 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/always_approve.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/always_approve.rs @@ -50,6 +50,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot { multiline_mode: false, yolo_mode, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/announcements.rs b/crates/codegen/xai-grok-pager/src/slash/commands/announcements.rs index 7ffacc3..ac70b85 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/announcements.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/announcements.rs @@ -79,6 +79,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), }; AnnouncementsCommand.run(&mut ctx, args) @@ -122,6 +123,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: true, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -143,6 +145,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, })); @@ -151,6 +154,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: true, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, })); diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/auto.rs b/crates/codegen/xai-grok-pager/src/slash/commands/auto.rs index 1d98aa2..5cd5634 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/auto.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/auto.rs @@ -59,6 +59,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot { yolo_mode, auto_mode, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs b/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs index cfed6d6..14e8529 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs @@ -41,14 +41,3 @@ impl SlashCommand for BtwCommand { CommandResult::Action(Action::SendBtw(args.trim().to_string())) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::slash::command::SlashCommand; - - #[test] - fn available_in_minimal_by_default() { - assert!(BtwCommand.available_in_minimal()); - } -} diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/cd.rs b/crates/codegen/xai-grok-pager/src/slash/commands/cd.rs index 402357d..d20aaf1 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/cd.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/cd.rs @@ -68,6 +68,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs b/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs index 4d146fd..7ef1d30 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs @@ -100,6 +100,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), } } @@ -189,12 +190,4 @@ mod tests { other => panic!("expected Action(CopyAssistantMessage), got {other:?}"), } } - - #[test] - fn available_in_minimal_by_default() { - // Clipboard copy from scrollback does not need the fullscreen pane — - // same path as `/export` and useful when native selection is awkward - // for multi-page assistant messages. - assert!(CopyCommand.available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/dashboard.rs b/crates/codegen/xai-grok-pager/src/slash/commands/dashboard.rs index 2d5ef6c..88b3366 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/dashboard.rs @@ -17,7 +17,8 @@ //! independent of leader mode. use crate::app::actions::Action; -use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; /// Open the Agent Dashboard view. pub struct DashboardCommand; @@ -34,7 +35,7 @@ impl SlashCommand for DashboardCommand { /// dashboard is the replacement surface for switching, renaming, and /// closing active sessions, so old muscle memory redirects here. As an /// alias it inherits the feature-flag gate (`set_dashboard_visible` - /// hides by canonical name) and the minimal-mode gates below. + /// hides by canonical name) and the minimal-mode gate below. fn aliases(&self) -> &[&str] { &["agents-dashboard", "sessions"] } @@ -48,18 +49,11 @@ impl SlashCommand for DashboardCommand { } /// The agent dashboard is intentionally out of scope in minimal mode - /// (single-session standalone — K14/§6.15). Gated off with a message. - fn available_in_minimal(&self) -> bool { - false - } - - /// Hidden from the completion dropdown in minimal mode: the dashboard - /// (and its `/sessions` / `/agents-dashboard` spellings) has nothing to - /// open there, so offering it just to refuse at dispatch is noise. A - /// fully-typed invocation still resolves and hits the central - /// `available_in_minimal` dispatch gate (friendly refusal, fail-closed). - fn visible(&self, ctx: &AppCtx) -> bool { - !ctx.screen_mode.is_minimal() + /// (single-session standalone — K14/§6.15). + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "minimal is single-session", + }) } fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { @@ -72,7 +66,7 @@ mod tests { use super::*; use crate::acp::model_state::ModelState; use crate::app::bundle::BundleState; - use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult}; + use crate::slash::command::{CommandExecCtx, CommandResult}; #[test] fn run_returns_open_dashboard_action() { @@ -84,6 +78,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -97,31 +92,6 @@ mod tests { )); } - /// Feature-flag gating is applied externally by the registry - /// (`set_dashboard_visible`), not via `visible()` — `AppCtx` carries no - /// dashboard state. `visible()` only gates on screen mode: offered in - /// fullscreen/inline, hidden from the minimal-mode dropdown (where the - /// dashboard has nothing to open and dispatch would just refuse). - #[test] - fn visible_everywhere_except_minimal() { - let models = ModelState::default(); - let cmd = DashboardCommand; - let ctx = |screen_mode| AppCtx { - models: &models, - cwd: std::path::Path::new("."), - has_session_announcements: false, - billing_surface_visible: true, - workflows_available: true, - screen_mode, - }; - assert!(cmd.visible(&ctx(crate::app::ScreenMode::Fullscreen))); - assert!(cmd.visible(&ctx(crate::app::ScreenMode::Inline))); - assert!( - !cmd.visible(&ctx(crate::app::ScreenMode::Minimal)), - "the dashboard (and its /sessions alias) must not be offered in minimal mode" - ); - } - #[test] fn does_not_take_args() { let cmd = DashboardCommand; @@ -141,10 +111,4 @@ mod tests { let cmd = DashboardCommand; assert_eq!(cmd.aliases(), &["agents-dashboard", "sessions"]); } - - #[test] - fn not_available_in_minimal() { - // The dashboard is out of scope in scrollback-native minimal mode. - assert!(!DashboardCommand.available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/debug.rs b/crates/codegen/xai-grok-pager/src/slash/commands/debug.rs index 22eed88..acee229 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/debug.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/debug.rs @@ -105,6 +105,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/docs.rs b/crates/codegen/xai-grok-pager/src/slash/commands/docs.rs index 6d43e0c..eee91f9 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/docs.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/docs.rs @@ -125,6 +125,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -213,6 +214,7 @@ mod tests { cwd, has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs b/crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs index a867e36..5266253 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs @@ -130,6 +130,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), }; DoctorCommand.run(&mut context, args) @@ -189,6 +190,7 @@ mod tests { cwd: std::path::Path::new("/tmp"), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: false, screen_mode: crate::app::ScreenMode::Inline, }; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs b/crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs index 34c8398..82e01be 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs @@ -1,7 +1,8 @@ //! `/edit-prompt` -- edit the minimal-mode composer in an external editor. use crate::app::actions::Action; -use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; /// Minimal-only fallback for terminals that reserve `Ctrl+G`. pub struct EditPromptCommand; @@ -23,16 +24,13 @@ impl SlashCommand for EditPromptCommand { true } - fn visible(&self, ctx: &AppCtx) -> bool { - ctx.screen_mode.is_minimal() + fn mode_support(&self) -> ModeSupport { + ModeSupport::MinimalOnly(Remedy::SwitchMode { + why: "the full TUI has no external-editor path — Ctrl+G is the tasks pane there", + }) } fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { - if !ctx.screen_mode.is_minimal() { - return CommandResult::Error( - "/edit-prompt is only available in minimal mode".to_owned(), - ); - } if ctx.session_id.is_none() { return CommandResult::Error("No active session".to_owned()); } @@ -47,17 +45,6 @@ mod tests { use crate::app::bundle::BundleState; use crate::settings::PagerLocalSnapshot; - fn app_ctx<'a>(models: &'a ModelState, mode: crate::app::ScreenMode) -> AppCtx<'a> { - AppCtx { - models, - cwd: std::path::Path::new("."), - has_session_announcements: false, - billing_surface_visible: true, - screen_mode: mode, - workflows_available: true, - } - } - fn exec_ctx<'a>( models: &'a ModelState, bundle: &'a BundleState, @@ -70,19 +57,18 @@ mod tests { bundle_state: bundle, screen_mode: mode, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), } } #[test] - fn visible_and_executable_only_in_minimal() { + fn opens_the_external_editor() { let command = EditPromptCommand; let models = ModelState::default(); let bundle = BundleState::default(); let session_id = agent_client_protocol::SessionId::from("session".to_owned()); - assert!(command.visible(&app_ctx(&models, crate::app::ScreenMode::Minimal))); - assert!(!command.visible(&app_ctx(&models, crate::app::ScreenMode::Fullscreen))); assert!(matches!( command.run( &mut exec_ctx( @@ -95,18 +81,6 @@ mod tests { ), CommandResult::Action(Action::EditPromptExternal) )); - assert!(matches!( - command.run( - &mut exec_ctx( - &models, - &bundle, - Some(&session_id), - crate::app::ScreenMode::Fullscreen, - ), - "", - ), - CommandResult::Error(message) if message.contains("only available in minimal mode") - )); } #[test] diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/effort.rs b/crates/codegen/xai-grok-pager/src/slash/commands/effort.rs index 7992647..3b84c7d 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/effort.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/effort.rs @@ -136,6 +136,7 @@ mod tests { bundle_state: &EMPTY_BUNDLE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -323,6 +324,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -337,6 +339,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -357,6 +360,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/expand.rs b/crates/codegen/xai-grok-pager/src/slash/commands/expand.rs index 59b80d7..b8fa3f3 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/expand.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/expand.rs @@ -10,6 +10,7 @@ use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; /// Re-print the last collapsed/truncated block, fully expanded (minimal mode). pub struct ExpandCommand; @@ -31,15 +32,13 @@ impl SlashCommand for ExpandCommand { "/expand" } + fn mode_support(&self) -> ModeSupport { + ModeSupport::MinimalOnly(Remedy::UseInstead( + "press Tab to focus the scrollback, then → on the block", + )) + } + fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { - // Expansion is meaningful only in minimal mode — the full-TUI scrollback - // pane folds/unfolds blocks in place (the `e` / `Ctrl+E` chords) and has - // no print-once committed history to re-print. - if !ctx.screen_mode.is_minimal() { - return CommandResult::Message( - "/expand is only available in minimal mode (--minimal)".to_string(), - ); - } if ctx.session_id.is_none() { return CommandResult::Error("No active session".to_string()); } @@ -75,6 +74,7 @@ mod tests { session_id, bundle_state: &DEFAULT_BUNDLE_STATE, billing_surface_visible: true, + usage_command_visible: true, screen_mode, pager_state: PagerLocalSnapshot::default(), } @@ -91,17 +91,6 @@ mod tests { )); } - #[test] - fn non_minimal_returns_message() { - let models = ModelState::default(); - let sid = agent_client_protocol::SessionId::from("s1".to_string()); - let mut c = ctx(&models, Some(&sid), crate::app::ScreenMode::Fullscreen); - match ExpandCommand.run(&mut c, "") { - CommandResult::Message(msg) => assert!(msg.contains("minimal")), - other => panic!("expected Message, got {other:?}"), - } - } - #[test] fn minimal_without_session_errors() { let models = ModelState::default(); diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/export.rs b/crates/codegen/xai-grok-pager/src/slash/commands/export.rs index 8feeb64..2b92061 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/export.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/export.rs @@ -185,6 +185,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), } } @@ -210,6 +211,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; let cmd = ExportCommand; @@ -231,6 +233,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; let cmd = ExportCommand; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/find.rs b/crates/codegen/xai-grok-pager/src/slash/commands/find.rs index e6e8574..87ee0d0 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/find.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/find.rs @@ -6,6 +6,7 @@ use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; /// Open scrollback search via `/find`. pub struct FindCommand; @@ -35,10 +36,10 @@ impl SlashCommand for FindCommand { Some("[text]") } - /// Minimal mode has no interactive scrollback pane to search — the - /// terminal's own search covers it (K7/§6.13). Gated off with a message. - fn available_in_minimal(&self) -> bool { - false + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "minimal has no scrollback pane — use your terminal's own search", + }) } fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult { @@ -73,6 +74,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), } } @@ -124,10 +126,4 @@ mod tests { assert!(!cmd.args_required()); assert_eq!(cmd.arg_placeholder(), Some("[text]")); } - - #[test] - fn not_available_in_minimal() { - // Native terminal search replaces in-app scrollback search in minimal. - assert!(!FindCommand.available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/fork.rs b/crates/codegen/xai-grok-pager/src/slash/commands/fork.rs index 340dd57..23c266b 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/fork.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/fork.rs @@ -265,6 +265,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/help.rs b/crates/codegen/xai-grok-pager/src/slash/commands/help.rs index 03dfca2..1cd7915 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/help.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/help.rs @@ -55,6 +55,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; assert!(matches!( diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/history.rs b/crates/codegen/xai-grok-pager/src/slash/commands/history.rs index 91f9019..8eaab14 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/history.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/history.rs @@ -47,6 +47,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/jump.rs b/crates/codegen/xai-grok-pager/src/slash/commands/jump.rs index 716707f..00b62fc 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/jump.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/jump.rs @@ -1,5 +1,6 @@ use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; pub struct JumpCommand; @@ -16,10 +17,10 @@ impl SlashCommand for JumpCommand { true } - /// Minimal mode has no interactive scrollback pane to scroll — the - /// terminal's own scrollback covers it (same gate as `/find`). - fn available_in_minimal(&self) -> bool { - false + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "minimal scrolls with your terminal's native scrollback", + }) } fn usage(&self) -> &str { @@ -58,6 +59,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Fullscreen, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; let result = JumpCommand.run(&mut ctx, ""); @@ -66,10 +68,4 @@ mod tests { CommandResult::Action(Action::JumpShowPicker) )); } - - #[test] - fn not_available_in_minimal() { - // Native terminal scrollback replaces in-app scrolling in minimal. - assert!(!JumpCommand.available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/loop_cmd.rs b/crates/codegen/xai-grok-pager/src/slash/commands/loop_cmd.rs index e641415..c29a7a1 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/loop_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/loop_cmd.rs @@ -271,6 +271,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { scheduler_background_loops: background_loops, ..Default::default() diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs index 82fe20a..a67dc17 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs @@ -159,7 +159,7 @@ mod tests { use super::*; use crate::acp::model_state::ModelState; use crate::app::actions::Action; - use crate::slash::command::{CommandExecCtx, CommandResult}; + use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; use crate::slash::registry::CommandRegistry; use agent_client_protocol as acp; /// Build a ModelState with two models for testing. @@ -196,6 +196,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -338,6 +339,7 @@ mod tests { "transcript", "tutorial", "t", + "undo", "usage", "view-plan", "vim-mode", @@ -361,6 +363,7 @@ mod tests { assert!(reg.get("welcome").is_some()); assert!(reg.get("show-plan").is_some()); assert!(reg.get("plan-view").is_some()); + assert!(reg.get("undo").is_some()); } #[test] fn aliases_resolve_to_same_command() { @@ -374,6 +377,9 @@ mod tests { assert_eq!(reg.get(alias).unwrap().name(), doctor.name()); assert_eq!(reg.get(alias).unwrap().usage(), doctor.usage()); } + let rewind = reg.get("rewind").unwrap(); + assert_eq!(reg.get("undo").unwrap().name(), rewind.name()); + assert_eq!(reg.get("undo").unwrap().usage(), rewind.usage()); } #[test] fn exit_returns_quit_action() { @@ -536,6 +542,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -561,6 +568,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -603,9 +611,13 @@ mod tests { )); } fn run_usage(args: &str, billing: bool) -> CommandResult { + run_usage_gated(args, billing, true) + } + fn run_usage_gated(args: &str, billing: bool, usage_cmd: bool) -> CommandResult { let models = ModelState::default(); let mut ctx = make_ctx(&models); ctx.billing_surface_visible = billing; + ctx.usage_command_visible = usage_cmd; usage::UsageCommand.run(&mut ctx, args) } #[test] @@ -644,6 +656,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -660,6 +673,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: false, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -680,6 +694,26 @@ mod tests { ); } #[test] + fn usage_hidden_when_command_not_visible() { + let models = ModelState::default(); + let ctx = crate::slash::command::AppCtx { + models: &models, + cwd: std::path::Path::new("."), + has_session_announcements: false, + billing_surface_visible: true, + usage_command_visible: false, + workflows_available: false, + screen_mode: crate::app::ScreenMode::Fullscreen, + }; + assert!(!usage::UsageCommand.visible(&ctx)); + assert!(!usage::UsageCommand.takes_args_now(&ctx)); + assert!(usage::UsageCommand.suggest_args(&ctx, "").is_none()); + assert!(matches!( + run_usage_gated("", true, false), + CommandResult::Error(msg) if msg.contains("not available") + )); + } + #[test] fn cd_registered_in_builtin_commands() { let reg = CommandRegistry::new(builtin_commands()); assert!( @@ -726,6 +760,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/model.rs b/crates/codegen/xai-grok-pager/src/slash/commands/model.rs index f8ff8c5..4d82c80 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/model.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/model.rs @@ -242,6 +242,7 @@ mod tests { bundle_state: &EMPTY_BUNDLE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -278,6 +279,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -310,6 +312,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -341,6 +344,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -361,6 +365,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/multiline.rs b/crates/codegen/xai-grok-pager/src/slash/commands/multiline.rs index 3cc314b..30194b3 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/multiline.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/multiline.rs @@ -65,6 +65,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot { multiline_mode, yolo_mode: false, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/plan.rs b/crates/codegen/xai-grok-pager/src/slash/commands/plan.rs index c506f72..fc078ee 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/plan.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/plan.rs @@ -70,6 +70,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot { plan_mode_active: false, ..PagerLocalSnapshot::default() @@ -87,6 +88,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot { plan_mode_active: true, ..PagerLocalSnapshot::default() diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/privacy.rs b/crates/codegen/xai-grok-pager/src/slash/commands/privacy.rs index 7ea838b..83cb440 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/privacy.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/privacy.rs @@ -48,6 +48,7 @@ mod tests { bundle_state: &bundle, screen_mode: mode, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), }; PrivacyCommand.run(&mut ctx, args) diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/queue.rs b/crates/codegen/xai-grok-pager/src/slash/commands/queue.rs index 9a3094f..a9ffcff 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/queue.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/queue.rs @@ -62,6 +62,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; match (QueueCommand.run(&mut ctx, ""), sid.is_some()) { @@ -83,9 +84,4 @@ mod tests { let sid = agent_client_protocol::SessionId::from("s1".to_string()); ctx_with_session(&models, Some(&sid)); } - - #[test] - fn available_in_minimal_by_default() { - assert!(QueueCommand.available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/rewind.rs b/crates/codegen/xai-grok-pager/src/slash/commands/rewind.rs index 1013084..94a7df2 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/rewind.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/rewind.rs @@ -8,6 +8,10 @@ impl SlashCommand for RewindCommand { "rewind" } + fn aliases(&self) -> &[&str] { + &["undo"] + } + fn description(&self) -> &str { "Rewind to a previous turn" } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/screen_mode_switch.rs b/crates/codegen/xai-grok-pager/src/slash/commands/screen_mode_switch.rs index 15cc4a0..a4fe7f7 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/screen_mode_switch.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/screen_mode_switch.rs @@ -1,8 +1,8 @@ //! `/minimal` and `/fullscreen` — session-scoped re-exec of the active session. -use crate::app::ScreenMode; use crate::app::actions::Action; -use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; /// Reopen the active session in the other screen mode (`/minimal` ⇄ `/fullscreen`). pub struct ScreenModeSwitchCommand { @@ -12,7 +12,8 @@ pub struct ScreenModeSwitchCommand { } impl ScreenModeSwitchCommand { - /// `/minimal`: offered in fullscreen, relaunches with `--minimal`. + /// `/minimal`: offered in the full TUI (alt-screen or `--no-alt-screen` + /// inline), relaunches with `--minimal`. pub const fn minimal() -> Self { Self { to_minimal: true } } @@ -23,16 +24,6 @@ impl ScreenModeSwitchCommand { Self { to_minimal: false } } - /// The mode this command switches *away from* — the only mode it is - /// offered in (switching to the mode you are already in is meaningless). - fn source_mode_active(&self, mode: ScreenMode) -> bool { - if self.to_minimal { - mode.is_fullscreen() - } else { - mode.is_minimal() - } - } - fn target_label(&self) -> &'static str { if self.to_minimal { "minimal" @@ -40,14 +31,6 @@ impl ScreenModeSwitchCommand { "fullscreen" } } - - fn source_label(&self) -> &'static str { - if self.to_minimal { - "fullscreen" - } else { - "minimal" - } - } } impl SlashCommand for ScreenModeSwitchCommand { @@ -79,25 +62,15 @@ impl SlashCommand for ScreenModeSwitchCommand { true } - /// `/minimal` switches *away from* fullscreen, so it is pointless inside - /// minimal; `/fullscreen` is the way back out. - fn available_in_minimal(&self) -> bool { - !self.to_minimal - } - - /// Only offered while the mode being switched away from is active. - fn visible(&self, ctx: &AppCtx) -> bool { - self.source_mode_active(ctx.screen_mode) + fn mode_support(&self) -> ModeSupport { + if self.to_minimal { + ModeSupport::FullscreenOnly(Remedy::AlreadyInMode) + } else { + ModeSupport::MinimalOnly(Remedy::AlreadyInMode) + } } fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { - if !self.source_mode_active(ctx.screen_mode) { - return CommandResult::Error(format!( - "/{} is only available in {} mode", - self.target_label(), - self.source_label(), - )); - } if ctx.session_id.is_none() { return CommandResult::Error(format!( "No active session to reopen in {} mode", @@ -114,19 +87,9 @@ impl SlashCommand for ScreenModeSwitchCommand { mod tests { use super::*; use crate::acp::model_state::ModelState; + use crate::app::ScreenMode; use crate::app::bundle::BundleState; - fn app_ctx<'a>(models: &'a ModelState, mode: ScreenMode) -> AppCtx<'a> { - AppCtx { - models, - cwd: std::path::Path::new("."), - has_session_announcements: false, - billing_surface_visible: true, - workflows_available: true, - screen_mode: mode, - } - } - fn exec_ctx<'a>( models: &'a ModelState, bundle: &'a BundleState, @@ -139,39 +102,24 @@ mod tests { bundle_state: bundle, screen_mode: mode, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), } } - #[test] - fn minimal_visible_only_in_fullscreen() { - let models = ModelState::default(); - let cmd = ScreenModeSwitchCommand::minimal(); - assert!(cmd.visible(&app_ctx(&models, ScreenMode::Fullscreen))); - assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Minimal))); - assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Inline))); - } - - #[test] - fn fullscreen_visible_only_in_minimal() { - let models = ModelState::default(); - let cmd = ScreenModeSwitchCommand::fullscreen(); - assert!(cmd.visible(&app_ctx(&models, ScreenMode::Minimal))); - assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Fullscreen))); - assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Inline))); - } - #[test] fn run_returns_relaunch_action_with_session() { let models = ModelState::default(); let bundle = BundleState::default(); let sid = agent_client_protocol::SessionId::from("sess-abc".to_string()); - let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Fullscreen, Some(&sid)); - assert!(matches!( - ScreenModeSwitchCommand::minimal().run(&mut ctx, ""), - CommandResult::Action(Action::RelaunchInScreenMode { minimal: true }) - )); + for mode in [ScreenMode::Fullscreen, ScreenMode::Inline] { + let mut ctx = exec_ctx(&models, &bundle, mode, Some(&sid)); + assert!(matches!( + ScreenModeSwitchCommand::minimal().run(&mut ctx, ""), + CommandResult::Action(Action::RelaunchInScreenMode { minimal: true }) + )); + } let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Minimal, Some(&sid)); assert!(matches!( @@ -197,33 +145,4 @@ mod tests { CommandResult::Error(msg) if msg.contains("No active session") )); } - - #[test] - fn run_errors_outside_source_mode() { - let models = ModelState::default(); - let bundle = BundleState::default(); - let sid = agent_client_protocol::SessionId::from("sess-abc".to_string()); - - // `/minimal` outside fullscreen. - let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Inline, Some(&sid)); - assert!(matches!( - ScreenModeSwitchCommand::minimal().run(&mut ctx, ""), - CommandResult::Error(msg) if msg.contains("fullscreen") - )); - - // `/fullscreen` outside minimal. - let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Fullscreen, Some(&sid)); - assert!(matches!( - ScreenModeSwitchCommand::fullscreen().run(&mut ctx, ""), - CommandResult::Error(msg) if msg.contains("minimal mode") - )); - } - - #[test] - fn minimal_availability_mirrors_direction() { - // `/minimal` is a fullscreen-pane switcher; `/fullscreen` is the way - // back out of minimal. - assert!(!ScreenModeSwitchCommand::minimal().available_in_minimal()); - assert!(ScreenModeSwitchCommand::fullscreen().available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/settings_cmd.rs b/crates/codegen/xai-grok-pager/src/slash/commands/settings_cmd.rs index f758568..6bbfcd8 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/settings_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/settings_cmd.rs @@ -55,6 +55,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/share.rs b/crates/codegen/xai-grok-pager/src/slash/commands/share.rs index 247907e..9be7b56 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/share.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/share.rs @@ -1,6 +1,5 @@ //! `/share` -- share current session via URL. -use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; /// Share the current session via a public URL. @@ -24,11 +23,7 @@ impl SlashCommand for ShareCommand { } fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { - // Check if we have an active session - if ctx.session_id.is_none() { - return CommandResult::Error("No active session to share".to_string()); - } - - CommandResult::Action(Action::ShareSession) + let _ = ctx; + CommandResult::Error("Session sharing is temporarily disabled".to_string()) } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/tasks.rs b/crates/codegen/xai-grok-pager/src/slash/commands/tasks.rs index 1f1eef2..51147f8 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/tasks.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/tasks.rs @@ -63,6 +63,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; TasksCommand.run(&mut ctx, "") @@ -84,9 +85,4 @@ mod tests { CommandResult::Action(Action::ShowTasks) )); } - - #[test] - fn available_in_minimal_by_default() { - assert!(TasksCommand.available_in_minimal()); - } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/theme.rs b/crates/codegen/xai-grok-pager/src/slash/commands/theme.rs index c7b340e..689ce00 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/theme.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/theme.rs @@ -11,6 +11,7 @@ use crate::app::actions::Action; use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; use crate::theme::{Theme, ThemeKind, cache as theme_cache}; /// Switch the pager color theme. @@ -29,9 +30,10 @@ impl SlashCommand for ThemeCommand { "Switch the color theme" } - /// Minimal has no theming, so there is nothing for `/theme` to switch. - fn available_in_minimal(&self) -> bool { - false + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "minimal renders with your terminal's own palette", + }) } fn usage(&self) -> &str { @@ -163,11 +165,6 @@ mod tests { theme_cache::reset_for_test(); } - #[test] - fn theme_unavailable_in_minimal() { - assert!(!ThemeCommand.available_in_minimal()); - } - // -- suggest_args --------------------------------------------------------- #[test] @@ -180,6 +177,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -202,6 +200,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -225,6 +224,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -249,6 +249,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -277,6 +278,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -308,6 +310,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -350,6 +353,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -381,6 +385,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -410,6 +415,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -517,6 +523,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, @@ -545,6 +552,7 @@ mod tests { bundle_state: &bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: false, yolo_mode: false, diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/timeline.rs b/crates/codegen/xai-grok-pager/src/slash/commands/timeline.rs index 265a3e3..e0b511f 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/timeline.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/timeline.rs @@ -5,6 +5,7 @@ use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; pub struct TimelineCommand; @@ -17,9 +18,10 @@ impl SlashCommand for TimelineCommand { "Toggle the timeline sidebar" } - /// Minimal mode has no interactive scrollback pane for the rail. - fn available_in_minimal(&self) -> bool { - false + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "the timeline rail needs the interactive scrollback pane", + }) } fn usage(&self) -> &str { @@ -31,13 +33,3 @@ impl SlashCommand for TimelineCommand { CommandResult::Action(Action::SetTimeline(new)) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn not_available_in_minimal() { - assert!(!TimelineCommand.available_in_minimal()); - } -} diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/toggle_mouse_reporting.rs b/crates/codegen/xai-grok-pager/src/slash/commands/toggle_mouse_reporting.rs index 5713d73..f37295b 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/toggle_mouse_reporting.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/toggle_mouse_reporting.rs @@ -65,6 +65,7 @@ mod tests { bundle_state: bundle, screen_mode: crate::app::ScreenMode::Inline, billing_surface_visible: true, + usage_command_visible: true, pager_state: crate::settings::PagerLocalSnapshot::default(), } } @@ -105,6 +106,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: true, screen_mode: crate::app::ScreenMode::Fullscreen, }; diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/transcript.rs b/crates/codegen/xai-grok-pager/src/slash/commands/transcript.rs index e2e43a3..56cb83f 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/transcript.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/transcript.rs @@ -68,6 +68,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; match TranscriptCommand.run(&mut ctx, "") { @@ -86,6 +87,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; assert!(matches!( diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs b/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs index 7ca9deb..248efe1 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs @@ -5,6 +5,7 @@ use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; /// Open the onboarding tutorial. pub struct TutorialCommand; @@ -26,10 +27,12 @@ impl SlashCommand for TutorialCommand { "/tutorial" } - /// The tutorial overlay is full-TUI chrome; minimal mode has no modal - /// host, so the overlay would consume input invisibly. Gated off. - fn available_in_minimal(&self) -> bool { - false + /// Gated off rather than merely hidden: minimal has no modal host, so the + /// overlay's input intercept would freeze the session invisibly. + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "the tutorial overlay needs fullscreen", + }) } fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { @@ -55,13 +58,6 @@ mod tests { role_details: Vec::new(), }; - #[test] - fn not_available_in_minimal() { - // Minimal mode can't render the overlay; the command must be gated - // off or the input intercept would freeze the session invisibly. - assert!(!TutorialCommand.available_in_minimal()); - } - #[test] fn dispatches_open_tutorial() { let models = ModelState::default(); @@ -71,6 +67,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Fullscreen, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; assert!(matches!( diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/usage.rs b/crates/codegen/xai-grok-pager/src/slash/commands/usage.rs index 6944f89..a2ca618 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/usage.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/usage.rs @@ -1,10 +1,50 @@ //! `/usage` — session token/cost; consumer accounts can also manage billing. +//! +//! External-auth deployments (`auth_provider_command`) never reach grok.com +//! billing, so the command is hidden and refused via +//! [`AppCtx::usage_command_visible`]. use crate::app::actions::Action; use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand}; +use agent_client_protocol as acp; pub struct UsageCommand; +/// Detect external-auth installs once at pager startup. +pub(crate) fn detect_external_auth_provider(auth_methods: &[acp::AuthMethod]) -> bool { + auth_methods.iter().any(auth_method_is_external_provider) + || auth_provider_env_set() + || auth_provider_config_set() +} + +fn auth_method_is_external_provider(method: &acp::AuthMethod) -> bool { + method + .meta() + .as_ref() + .and_then(|v| v.get("external_provider")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +fn auth_provider_env_set() -> bool { + std::env::var("GROK_AUTH_PROVIDER_COMMAND") + .ok() + .is_some_and(|s| !s.trim().is_empty()) +} + +fn auth_provider_config_set() -> bool { + let Ok(raw) = xai_grok_shell::config::load_effective_config() else { + return false; + }; + let Ok(cfg) = xai_grok_shell::agent::config::Config::new_from_toml_cfg(&raw) else { + return false; + }; + cfg.grok_com_config + .auth_provider_command + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) +} + impl SlashCommand for UsageCommand { fn name(&self) -> &str { "usage" @@ -26,13 +66,17 @@ impl SlashCommand for UsageCommand { true } + fn visible(&self, ctx: &AppCtx) -> bool { + ctx.usage_command_visible + } + fn takes_args_now(&self, ctx: &AppCtx) -> bool { // Non-consumer: bare `/usage` only — Enter should send, not chain for args. - ctx.billing_surface_visible + ctx.usage_command_visible && ctx.billing_surface_visible } fn suggest_args(&self, ctx: &AppCtx, _args_query: &str) -> Option> { - if !ctx.billing_surface_visible { + if !ctx.usage_command_visible || !ctx.billing_surface_visible { return None; } Some(vec![ @@ -52,6 +96,9 @@ impl SlashCommand for UsageCommand { } fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult { + if !ctx.usage_command_visible { + return CommandResult::Error("/usage is not available.".into()); + } let arg = args.trim(); if !ctx.billing_surface_visible { return match arg { diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/workflows.rs b/crates/codegen/xai-grok-pager/src/slash/commands/workflows.rs index 6e9d975..6b0551c 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/workflows.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/workflows.rs @@ -1,5 +1,6 @@ use crate::app::actions::Action; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; +use crate::slash::{ModeSupport, Remedy}; pub struct WorkflowsCommand; @@ -20,6 +21,15 @@ impl SlashCommand for WorkflowsCommand { true } + /// The run pane is drawn from `AgentView::show_workflows` on the full-TUI + /// path only; minimal never reads it, so the toggle would flip a flag + /// nothing renders. + fn mode_support(&self) -> ModeSupport { + ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "the workflow run pane needs fullscreen", + }) + } + fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { CommandResult::Action(Action::ToggleWorkflows) } @@ -52,6 +62,7 @@ mod tests { cwd: std::path::Path::new("."), has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: available, screen_mode: crate::app::ScreenMode::Fullscreen, }; @@ -68,6 +79,7 @@ mod tests { bundle_state: &DEFAULT_BUNDLE_STATE, screen_mode: crate::app::ScreenMode::Minimal, billing_surface_visible: true, + usage_command_visible: true, pager_state: PagerLocalSnapshot::default(), }; assert!(matches!( diff --git a/crates/codegen/xai-grok-pager/src/slash/mod.rs b/crates/codegen/xai-grok-pager/src/slash/mod.rs index 7f23cae..d8dc205 100644 --- a/crates/codegen/xai-grok-pager/src/slash/mod.rs +++ b/crates/codegen/xai-grok-pager/src/slash/mod.rs @@ -12,6 +12,7 @@ pub mod acp_command; pub mod command; pub mod commands; pub mod matcher; +pub mod mode_support; pub mod mru; pub mod registry; @@ -27,6 +28,7 @@ use matcher::FuzzyMatcher; use registry::{CommandRegistry, CommandSource, CommandTrigger}; pub use command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand}; +pub use mode_support::{ModeSupport, Remedy}; /// Maximum number of visible rows in the dropdown (scroll beyond this). pub const MAX_VISIBLE_SUGGESTIONS: usize = 6; @@ -265,6 +267,8 @@ pub struct SlashController { has_session_announcements: bool, /// Consumer billing surface — gates `/usage` subcommands. Default `true`. billing_surface_visible: bool, + /// Whether `/usage` is offered. Default `true`; cleared for external auth. + usage_command_visible: bool, workflows_available: bool, /// Effective render mode of this process (immutable after startup — it only /// changes via a full `/minimal`-`/fullscreen` re-exec). Injected via @@ -307,6 +311,7 @@ impl SlashController { hide_session_scoped: false, has_session_announcements: false, billing_surface_visible: true, + usage_command_visible: true, workflows_available: false, screen_mode: crate::app::ScreenMode::Fullscreen, mru, @@ -349,6 +354,14 @@ impl SlashController { self.billing_surface_visible } + pub fn set_usage_command_visible(&mut self, visible: bool) { + self.usage_command_visible = visible; + } + + pub fn usage_command_visible(&self) -> bool { + self.usage_command_visible + } + pub fn set_workflows_available(&mut self, available: bool) { self.workflows_available = available; } @@ -372,6 +385,7 @@ impl SlashController { cwd: &self.cwd, has_session_announcements: self.has_session_announcements, billing_surface_visible: self.billing_surface_visible, + usage_command_visible: self.usage_command_visible, workflows_available: self.workflows_available, screen_mode: self.screen_mode, } @@ -677,7 +691,9 @@ impl SlashController { return snapshot; }; let ctx = self.app_ctx(models); - if !command.visible(&ctx) || !command.takes_args_now(&ctx) { + if !command_offered(command.as_ref(), &ctx, self.hide_session_scoped) + || !command.takes_args_now(&ctx) + { return snapshot; } @@ -1096,6 +1112,15 @@ impl SlashController { /// offered ONLY when `hide_session_scoped` is set (the dashboard surface) /// and suppressed on every session surface. /// +/// Commands are also filtered by the render mode they declare support for +/// ([`SlashCommand::mode_support`]): a fullscreen-only command +/// (`/find`, `/theme`, …) is not offered under `--minimal`, and a minimal-only +/// command (`/expand`, `/edit-prompt`) is not offered in the full TUI. Note +/// this gate is completion-only — [`registry::CommandRegistry::get_for_dispatch`] +/// still resolves such a command so a fully-typed invocation reaches the +/// central dispatch gate's [`ModeSupport::refusal`] instead of leaking to the +/// model as a raw prompt. +/// /// Callers that execute slash commands on a session-less surface (e.g. /// `dispatch_dashboard_dispatch_slash`) must consult this before /// `command.run` so typed tokens that were filtered from the dropdown @@ -1105,7 +1130,8 @@ pub(crate) fn command_offered( ctx: &AppCtx, hide_session_scoped: bool, ) -> bool { - command.visible(ctx) + command.mode_support().supports(ctx.screen_mode) + && command.visible(ctx) && !(hide_session_scoped && command.session_scoped() && !command.offered_when_session_less()) @@ -2955,4 +2981,66 @@ mod tests { "matches: {displays:?}" ); } + + /// A command's `mode_support()` declaration is the whole story for + /// completion: a fullscreen-only command must not be offered under + /// `--minimal`, a minimal-only one must not be offered in the full TUI, + /// and `Inline` (`--no-alt-screen`) counts as the full TUI. + #[test] + fn completion_offers_only_commands_that_support_the_mode() { + let models = ModelState::default(); + let offered = |mode, query: &str| { + let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from(".")); + ctrl.set_screen_mode(mode); + let state = SlashState::default(); + ctrl.refresh(&state, query, query.len(), &models); + state + .snapshot() + .matches + .iter() + .any(|row| row.display == query) + }; + + for full_tui in [ + crate::app::ScreenMode::Fullscreen, + crate::app::ScreenMode::Inline, + ] { + assert!(offered(full_tui, "/theme"), "{full_tui:?}"); + assert!(!offered(full_tui, "/expand"), "{full_tui:?}"); + } + + assert!(!offered(crate::app::ScreenMode::Minimal, "/theme")); + assert!(offered(crate::app::ScreenMode::Minimal, "/expand")); + } + + #[test] + fn mid_text_arg_suggestions_respect_the_mode() { + let models = ModelState::default(); + let arg_rows = |mode| { + let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from(".")); + ctrl.set_screen_mode(mode); + let state = SlashState::default(); + let text = "look at this /theme "; + ctrl.refresh(&state, text, text.len(), &models); + state.snapshot().matches.len() + }; + + assert!( + arg_rows(crate::app::ScreenMode::Fullscreen) > 0, + "themes should complete where /theme runs" + ); + assert_eq!(arg_rows(crate::app::ScreenMode::Minimal), 0); + } + + /// Hidden from completion, still resolvable: dispatch must reach the + /// central gate's refusal rather than let `/theme` fall through to the + /// model as a raw prompt. + #[test] + fn mode_gated_commands_still_resolve_for_dispatch() { + let reg = test_registry(); + assert_eq!( + reg.get_for_dispatch("theme").map(|cmd| cmd.name()), + Some("theme") + ); + } } diff --git a/crates/codegen/xai-grok-pager/src/slash/mode_support.rs b/crates/codegen/xai-grok-pager/src/slash/mode_support.rs new file mode 100644 index 0000000..13326a5 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/slash/mode_support.rs @@ -0,0 +1,62 @@ +//! Which render modes a slash command works in. + +use crate::app::ScreenMode; + +/// What to tell a user who typed a command the current mode cannot run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Remedy { + SwitchMode { + /// Sentence fragment, parenthesized in the refusal: + /// `"minimal is single-session"`. + why: &'static str, + }, + /// Imperative clause naming what to do in this mode instead. Two ways to + /// get this wrong: `Ctrl+G` is the external editor in minimal and the + /// tasks pane everywhere else, and a bare letter resolves only under vim + /// mode (off by default), so name arrows, `Tab`, or `Ctrl+`. + UseInstead(&'static str), + AlreadyInMode, +} + +/// Which render modes a slash command functions in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModeSupport { + Both, + FullscreenOnly(Remedy), + MinimalOnly(Remedy), +} + +impl ModeSupport { + pub(crate) fn supports(self, mode: ScreenMode) -> bool { + match self { + Self::Both => true, + Self::FullscreenOnly(_) => !mode.is_minimal(), + Self::MinimalOnly(_) => mode.is_minimal(), + } + } + + pub(crate) fn refusal(self, token: &str, mode: ScreenMode) -> Option { + if self.supports(mode) { + return None; + } + let (remedy, current, switch) = match self { + Self::Both => return None, + Self::FullscreenOnly(remedy) => (remedy, "minimal", "/fullscreen"), + Self::MinimalOnly(remedy) => (remedy, "fullscreen", "/minimal"), + }; + Some(match remedy { + Remedy::SwitchMode { why } => format!( + "/{token} isn't available in {current} mode ({why}). \ + Run {switch} to switch this session." + ), + Remedy::UseInstead(instead) => { + format!("/{token} isn't available in {current} mode — {instead}.") + } + Remedy::AlreadyInMode => format!("You're already in {current} mode."), + }) + } +} + +#[cfg(test)] +#[path = "mode_support_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-pager/src/slash/mode_support_tests.rs b/crates/codegen/xai-grok-pager/src/slash/mode_support_tests.rs new file mode 100644 index 0000000..8d029c1 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/slash/mode_support_tests.rs @@ -0,0 +1,176 @@ +use pretty_assertions::assert_eq; + +use super::{ModeSupport, Remedy}; +use crate::app::ScreenMode; + +const FULLSCREEN_ONLY: ModeSupport = ModeSupport::FullscreenOnly(Remedy::SwitchMode { + why: "minimal is single-session", +}); +const MINIMAL_ONLY: ModeSupport = + ModeSupport::MinimalOnly(Remedy::UseInstead("press → on the block")); + +#[test] +fn inline_counts_as_fullscreen() { + for mode in [ScreenMode::Fullscreen, ScreenMode::Inline] { + assert!(ModeSupport::Both.supports(mode)); + assert!(FULLSCREEN_ONLY.supports(mode)); + assert!(!MINIMAL_ONLY.supports(mode)); + } + + assert!(ModeSupport::Both.supports(ScreenMode::Minimal)); + assert!(!FULLSCREEN_ONLY.supports(ScreenMode::Minimal)); + assert!(MINIMAL_ONLY.supports(ScreenMode::Minimal)); +} + +#[test] +fn supported_modes_have_no_refusal() { + assert_eq!( + ModeSupport::Both.refusal("theme", ScreenMode::Minimal), + None + ); + assert_eq!( + FULLSCREEN_ONLY.refusal("theme", ScreenMode::Inline), + None, + "inline is not minimal, so a fullscreen-only command runs" + ); + assert_eq!(MINIMAL_ONLY.refusal("expand", ScreenMode::Minimal), None); +} + +#[test] +fn switch_mode_refusal_names_the_current_mode_and_the_way_out() { + assert_eq!( + FULLSCREEN_ONLY.refusal("theme", ScreenMode::Minimal), + Some( + "/theme isn't available in minimal mode (minimal is single-session). \ + Run /fullscreen to switch this session." + .to_string() + ) + ); + assert_eq!( + ModeSupport::MinimalOnly(Remedy::SwitchMode { + why: "the full TUI prints nothing to re-print" + }) + .refusal("expand", ScreenMode::Fullscreen), + Some( + "/expand isn't available in fullscreen mode \ + (the full TUI prints nothing to re-print). \ + Run /minimal to switch this session." + .to_string() + ) + ); +} + +#[test] +fn use_instead_refusal_names_the_substitute_not_a_relaunch() { + let refusal = MINIMAL_ONLY + .refusal("expand", ScreenMode::Fullscreen) + .expect("minimal-only command is refused in fullscreen"); + assert_eq!( + refusal, + "/expand isn't available in fullscreen mode — press → on the block." + ); + assert!( + !refusal.contains("/minimal"), + "suggesting a relaunch contradicts the substitute: {refusal:?}" + ); +} + +#[test] +fn already_in_mode_refusal_is_a_plain_statement() { + assert_eq!( + ModeSupport::FullscreenOnly(Remedy::AlreadyInMode).refusal("minimal", ScreenMode::Minimal), + Some("You're already in minimal mode.".to_string()) + ); + assert_eq!( + ModeSupport::MinimalOnly(Remedy::AlreadyInMode).refusal("fullscreen", ScreenMode::Inline), + Some("You're already in fullscreen mode.".to_string()) + ); +} + +/// Pinned on the composed sentence, not the variant, so a remedy that reads +/// wrong to a user lands in the diff rather than only in the code. +#[test] +fn mode_specific_builtin_refusals_are_pinned() { + let commands = crate::slash::commands::builtin_commands(); + let mut actual: Vec<(&str, String)> = commands + .iter() + .filter_map(|command| { + let refusal = [ScreenMode::Minimal, ScreenMode::Fullscreen] + .into_iter() + .find_map(|mode| command.mode_support().refusal(command.name(), mode))?; + Some((command.name(), refusal)) + }) + .collect(); + actual.sort_unstable(); + + assert_eq!( + actual, + vec![ + ( + "dashboard", + "/dashboard isn't available in minimal mode (minimal is single-session). \ + Run /fullscreen to switch this session." + .to_string() + ), + ( + "edit-prompt", + "/edit-prompt isn't available in fullscreen mode (the full TUI has no \ + external-editor path — Ctrl+G is the tasks pane there). \ + Run /minimal to switch this session." + .to_string() + ), + ( + "expand", + "/expand isn't available in fullscreen mode — press Tab to focus the \ + scrollback, then → on the block." + .to_string() + ), + ( + "find", + "/find isn't available in minimal mode (minimal has no scrollback pane — \ + use your terminal's own search). Run /fullscreen to switch this session." + .to_string() + ), + ( + "fullscreen", + "You're already in fullscreen mode.".to_string() + ), + ( + "jump", + "/jump isn't available in minimal mode \ + (minimal scrolls with your terminal's native scrollback). \ + Run /fullscreen to switch this session." + .to_string() + ), + ("minimal", "You're already in minimal mode.".to_string()), + ( + "theme", + "/theme isn't available in minimal mode \ + (minimal renders with your terminal's own palette). \ + Run /fullscreen to switch this session." + .to_string() + ), + ( + "timeline", + "/timeline isn't available in minimal mode \ + (the timeline rail needs the interactive scrollback pane). \ + Run /fullscreen to switch this session." + .to_string() + ), + ( + "tutorial", + "/tutorial isn't available in minimal mode \ + (the tutorial overlay needs fullscreen). \ + Run /fullscreen to switch this session." + .to_string() + ), + ( + "workflows", + "/workflows isn't available in minimal mode \ + (the workflow run pane needs fullscreen). \ + Run /fullscreen to switch this session." + .to_string() + ), + ] + ); +} diff --git a/crates/codegen/xai-grok-pager/src/slash/registry.rs b/crates/codegen/xai-grok-pager/src/slash/registry.rs index 0b50f03..4fb1cb3 100644 --- a/crates/codegen/xai-grok-pager/src/slash/registry.rs +++ b/crates/codegen/xai-grok-pager/src/slash/registry.rs @@ -14,6 +14,7 @@ use xai_grok_tools::implementations::skills::types::SkillScope; use super::acp_command::AcpSlashCommand; use super::command::SlashCommand; +use super::mode_support::ModeSupport; fn client_collision_qualified_name( cmd: &agent_client_protocol::AvailableCommand, @@ -154,13 +155,18 @@ impl CommandRegistry { hidden.insert("voice".to_string()); // `/auto` is fail-closed: hidden until `set_auto_mode_available(true)`. hidden.insert("auto".to_string()); + // `/share` starts menu-hidden (still dispatchable) until + // `set_share_visible(true)`. Menu-only so typed `/share` can + // surface a client disable message rather than PassThrough. + let mut menu_hidden = HashSet::new(); + menu_hidden.insert("share".to_string()); let mut reg = Self { commands: builtins, sources, key_to_index: HashMap::new(), triggers: Vec::new(), hidden, - menu_hidden: HashSet::new(), + menu_hidden, restricted: HashSet::new(), available_tools: None, }; @@ -214,6 +220,15 @@ impl CommandRegistry { .filter(|cmd| self.tools_satisfied(cmd)) } + /// Declared modes for `key` (canonical name or alias), unfiltered by any + /// runtime gate. + pub(crate) fn mode_support(&self, key: &str) -> ModeSupport { + self.commands + .iter() + .find(|cmd| cmd.name() == key || cmd.aliases().contains(&key)) + .map_or(ModeSupport::Both, |cmd| cmd.mode_support()) + } + /// Normalize a deny-list entry: trim, strip one leading `/`, lowercase. /// Lets callers write `usage`, `/usage`, or `Usage` interchangeably. fn normalize_deny_name(name: &str) -> String { @@ -368,10 +383,20 @@ impl CommandRegistry { self.available_tools = Some(tools); } - /// Show or hide the /share command. - /// When hidden, it won't appear in the dropdown or be executable. + /// Show or hide `/share` in the completion menu. + /// + /// Menu-only: when not visible the command is absent from dropdown / + /// triggers but still resolves via [`Self::get_for_dispatch`], so a + /// fully typed `/share` reaches the pager handler (e.g. temporary + /// client disable) instead of falling through as an unknown command. pub fn set_share_visible(&mut self, visible: bool) { - self.set_command_visible("share", visible); + self.hidden.remove("share"); + if visible { + self.menu_hidden.remove("share"); + } else { + self.menu_hidden.insert("share".to_string()); + } + self.rebuild_triggers(); } /// Show or hide the `/dashboard` command (feature-flag gating). @@ -733,21 +758,27 @@ mod tests { }); let mut registry = CommandRegistry::new(vec![share, other]); - // Default: /share is visible. - assert!(registry.get("share").is_some()); - assert!(registry.triggers().iter().any(|t| t.canonical == "share")); - - // Hiding /share removes it from lookup and triggers. - registry.set_share_visible(false); + // Default: /share is menu-hidden (offered nowhere) but still dispatchable. assert!(registry.get("share").is_none()); + assert!( + registry.get_for_dispatch("share").is_some(), + "typed /share must still resolve while menu-hidden" + ); assert!(!registry.triggers().iter().any(|t| t.canonical == "share")); + + // Revealing /share restores menu lookup and triggers. + registry.set_share_visible(true); + assert!(registry.get("share").is_some()); + assert!(registry.get_for_dispatch("share").is_some()); + assert!(registry.triggers().iter().any(|t| t.canonical == "share")); // Other commands are unaffected. assert!(registry.get("exit").is_some()); - // Re-enabling restores it. - registry.set_share_visible(true); - assert!(registry.get("share").is_some()); - assert!(registry.triggers().iter().any(|t| t.canonical == "share")); + // Hiding again is menu-only: no offer, typed path still works. + registry.set_share_visible(false); + assert!(registry.get("share").is_none()); + assert!(registry.get_for_dispatch("share").is_some()); + assert!(!registry.triggers().iter().any(|t| t.canonical == "share")); } #[test] @@ -1205,9 +1236,9 @@ mod tests { /// unresolvable for dispatch, exactly like `get()`. #[test] fn get_for_dispatch_respects_hard_gates() { - // Hard-hidden by name (e.g. /dashboard default, /share toggle). - let share: Arc = Arc::new(DummyCommand { - name: "share", + // Hard-hidden by name (e.g. /dashboard default). + let dashboard: Arc = Arc::new(DummyCommand { + name: "dashboard", aliases: &[], }); // Tier-restricted. @@ -1220,11 +1251,14 @@ mod tests { name: "loop", required: &["scheduler_create"], }); - let mut reg = CommandRegistry::new(vec![share, usage, gated]); - reg.set_share_visible(false); + let mut reg = CommandRegistry::new(vec![dashboard, usage, gated]); + reg.set_dashboard_visible(false); reg.set_restricted_commands(&["usage".to_string()]); - assert!(reg.get_for_dispatch("share").is_none(), "hidden stays hard"); + assert!( + reg.get_for_dispatch("dashboard").is_none(), + "hard-hidden stays hard" + ); assert!( reg.get_for_dispatch("usage").is_none(), "restricted stays blocked (upsell path owns it)" diff --git a/crates/codegen/xai-grok-pager/src/views/modal.rs b/crates/codegen/xai-grok-pager/src/views/modal.rs index c0d5191..a9312f4 100644 --- a/crates/codegen/xai-grok-pager/src/views/modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/modal.rs @@ -365,13 +365,11 @@ pub enum PaletteCommand { OpenAgentsModal, } /// Build the default set of palette entries with section grouping. -/// -/// `sharing_enabled` controls whether `/share` is included. `screen_mode` -/// exposes the draft-preserving external-editor row only in minimal mode. pub(crate) fn default_palette_entries( sharing_enabled: bool, - screen_mode: crate::app::ScreenMode, + slash: &crate::slash::SlashController, ) -> Vec { + let screen_mode = slash.screen_mode(); let mut entries = vec![ // ── Session ── PaletteEntry { @@ -574,6 +572,15 @@ pub(crate) fn default_palette_entries( { return false; } + if let PaletteCommand::SlashCommand(text) = &entry.command + && let Some(invocation) = crate::slash::parse_invocation(text.trim()) + && !slash + .registry() + .mode_support(invocation.token) + .supports(screen_mode) + { + return false; + } screen_mode.is_minimal() || !matches!(entry.command, PaletteCommand::EditPromptExternal) }); entries @@ -583,9 +590,9 @@ pub(crate) fn default_palette_entries( pub(crate) fn filter_palette_entries( query: &str, sharing_enabled: bool, - screen_mode: crate::app::ScreenMode, + slash: &crate::slash::SlashController, ) -> Vec { - let all = default_palette_entries(sharing_enabled, screen_mode); + let all = default_palette_entries(sharing_enabled, slash); let query_lower = query.to_lowercase(); if query_lower.is_empty() { return all; @@ -1305,9 +1312,15 @@ mod palette_sharing_tests { .iter() .any(|e| matches!(&e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share")) } + fn slash(mode: crate::app::ScreenMode) -> crate::slash::SlashController { + let mut controller = + crate::slash::SlashController::with_builtins(std::path::PathBuf::from(".")); + controller.set_screen_mode(mode); + controller + } #[test] fn default_palette_includes_share_when_enabled() { - let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen); + let entries = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen)); assert!( has_share(&entries), "/share should be present when sharing_enabled=true" @@ -1315,7 +1328,7 @@ mod palette_sharing_tests { } #[test] fn default_palette_includes_dashboard() { - let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen); + let entries = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen)); let has_dashboard = entries.iter().any( |e| matches!(&e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/dashboard"), ); @@ -1329,15 +1342,57 @@ mod palette_sharing_tests { "palette entry must use the 'Agent Dashboard' label" ); } + fn slash_rows(mode: crate::app::ScreenMode) -> Vec { + default_palette_entries(true, &slash(mode)) + .into_iter() + .filter_map(|entry| match entry.command { + PaletteCommand::SlashCommand(text) => Some(text.trim().to_string()), + _ => None, + }) + .collect() + } + #[test] + fn palette_drops_slash_rows_the_mode_cannot_run() { + let minimal = slash_rows(crate::app::ScreenMode::Minimal); + for gated in ["/theme", "/dashboard", "/tutorial"] { + assert!(!minimal.contains(&gated.to_string()), "{gated} in minimal"); + } + assert!( + minimal.contains(&"/compact".to_string()), + "mode-agnostic rows stay: {minimal:?}" + ); + let fullscreen = slash_rows(crate::app::ScreenMode::Fullscreen); + for offered in ["/theme", "/dashboard", "/tutorial"] { + assert!( + fullscreen.contains(&offered.to_string()), + "{offered} missing in fullscreen" + ); + } + } + #[test] + fn every_palette_slash_row_resolves_to_a_registered_command() { + let builtins = crate::slash::commands::builtin_commands(); + for row in slash_rows(crate::app::ScreenMode::Fullscreen) { + let invocation = crate::slash::parse_invocation(&row) + .unwrap_or_else(|| panic!("palette row {row:?} is not a slash invocation")); + assert!( + builtins + .iter() + .any(|command| command.name() == invocation.token + || command.aliases().contains(&invocation.token)), + "palette row {row:?} names no builtin command" + ); + } + } #[test] fn edit_prompt_palette_entry_is_minimal_only() { - let minimal = default_palette_entries(true, crate::app::ScreenMode::Minimal); + let minimal = default_palette_entries(true, &slash(crate::app::ScreenMode::Minimal)); assert!( minimal .iter() .any(|entry| matches!(entry.command, PaletteCommand::EditPromptExternal)) ); - let fullscreen = default_palette_entries(true, crate::app::ScreenMode::Fullscreen); + let fullscreen = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen)); assert!( !fullscreen .iter() @@ -1346,7 +1401,7 @@ mod palette_sharing_tests { } #[test] fn default_palette_omits_share_when_disabled() { - let entries = default_palette_entries(false, crate::app::ScreenMode::Fullscreen); + let entries = default_palette_entries(false, &slash(crate::app::ScreenMode::Fullscreen)); assert!( !has_share(&entries), "/share must not appear in palette when sharing_enabled=false" @@ -1354,12 +1409,13 @@ mod palette_sharing_tests { } #[test] fn filter_palette_omits_share_when_disabled() { - let entries = filter_palette_entries("", false, crate::app::ScreenMode::Fullscreen); + let entries = filter_palette_entries("", false, &slash(crate::app::ScreenMode::Fullscreen)); assert!( !has_share(&entries), "/share must not appear in unfiltered palette when sharing_enabled=false" ); - let entries = filter_palette_entries("share", false, crate::app::ScreenMode::Fullscreen); + let entries = + filter_palette_entries("share", false, &slash(crate::app::ScreenMode::Fullscreen)); assert!( !has_share(&entries), "/share must not appear when filtering for 'share' with sharing_enabled=false" @@ -1367,7 +1423,8 @@ mod palette_sharing_tests { } #[test] fn filter_palette_includes_share_when_enabled_and_matched() { - let entries = filter_palette_entries("share", true, crate::app::ScreenMode::Fullscreen); + let entries = + filter_palette_entries("share", true, &slash(crate::app::ScreenMode::Fullscreen)); assert!( has_share(&entries), "/share should match a 'share' query when sharing_enabled=true" @@ -1376,7 +1433,7 @@ mod palette_sharing_tests { #[test] fn palette_tools_section_routes_each_tab_to_itself() { use crate::views::extensions_modal::ExtensionsTab; - let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen); + let entries = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen)); for (label, expected) in [ ("Hooks", ExtensionsTab::Hooks), ("Plugins", ExtensionsTab::Plugins), diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs index 32f2f2f..7960570 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs @@ -142,41 +142,48 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin // `action_for_string` already knows how to resolve via // `snapshot.resolve_model_name` AND treats the empty // canonical as a `Clear*` sentinel. + let close = std::mem::take(&mut state.close_on_picker_exit); + if !close { + state.transition_to_browse(); + } let kind_is_dynamic = matches!( state.registry.find(setting_key).map(|m| &m.kind), Some(SettingKind::DynamicEnum { .. }) ); - state.transition_to_browse(); - if kind_is_dynamic { - let Some(canonical) = picker_choice_at_owned(state, setting_key, choices_idx) - else { - return SettingsKeyOutcome::Changed; - }; - if let Some(action) = + let commit = if kind_is_dynamic { + picker_choice_at_owned(state, setting_key, choices_idx).and_then(|canonical| { action_for_string(setting_key, canonical, &state.pager_snapshot) - { - return SettingsKeyOutcome::Action(action); - } - return SettingsKeyOutcome::Changed; - } - let Some(current_canonical) = picker_choice_at(state, setting_key, choices_idx) else { - return SettingsKeyOutcome::Changed; + }) + } else { + picker_choice_at(state, setting_key, choices_idx) + .and_then(|c| action_for_enum_commit(setting_key, c)) }; - if let Some(action) = action_for_enum_commit(setting_key, current_canonical) { - return SettingsKeyOutcome::Action(action); + match (close, commit) { + (true, Some(action)) => SettingsKeyOutcome::ActionThenClose(action), + (true, None) => SettingsKeyOutcome::Close, + (false, Some(action)) => SettingsKeyOutcome::Action(action), + (false, None) => SettingsKeyOutcome::Changed, } - SettingsKeyOutcome::Changed } KeyCode::Esc => { - // Revert preview and return to Browse. Non-preview Enums - // skip the revert (no live visual was applied). - state.transition_to_browse(); + let close = std::mem::take(&mut state.close_on_picker_exit); + if !close { + state.transition_to_browse(); + } if let SettingValue::Enum(orig) = &original_value && let Some(action) = action_for_enum(setting_key, orig) { - return SettingsKeyOutcome::Action(action); + return if close { + SettingsKeyOutcome::ActionThenClose(action) + } else { + SettingsKeyOutcome::Action(action) + }; + } + if close { + SettingsKeyOutcome::Close + } else { + SettingsKeyOutcome::Changed } - SettingsKeyOutcome::Changed } // `d` reset: close picker, revert preview if applicable, // then open the reset-confirm overlay. Consent choosers opt out of @@ -867,22 +874,16 @@ pub fn handle_settings_mouse( column: u16, row: u16, ) -> SettingsKeyOutcome { - // Clicking anywhere on the chrome - // breadcrumb (the full `Settings ›