diff --git a/Cargo.lock b/Cargo.lock index 124e421..7ee0410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5280,9 +5280,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -5293,8 +5293,8 @@ dependencies = [ "num-traits", "png", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", + "zune-core", + "zune-jpeg", ] [[package]] @@ -6267,9 +6267,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.8" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692af879e4d9383c0fd9dec15524af6b6977c8bf1c6b278a4526d5341347c574" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -10413,16 +10413,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -12047,6 +12047,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wildmatch" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d654e41fe05169e03e27b97e0c23716535da037c1652a31fd99c6b2fad84059" + [[package]] name = "winapi" version = "0.3.9" @@ -13389,7 +13395,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.110" +version = "0.2.111" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13479,7 +13485,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.110" +version = "0.2.111" dependencies = [ "anyhow", "clap", @@ -13743,7 +13749,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.110" +version = "0.2.111" dependencies = [ "agent-client-protocol", "anyhow", @@ -13934,12 +13940,15 @@ dependencies = [ name = "xai-grok-subagent-resolution" version = "0.1.0" dependencies = [ + "chrono", "serde", "serde_json", "tempfile", "thiserror 2.0.18", + "tokio", "toml", "tracing", + "xai-grok-agent", "xai-grok-sampling-types", "xai-grok-tools", "xai-tool-types", @@ -14079,6 +14088,7 @@ dependencies = [ "url", "uuid", "which", + "wildmatch", "windows 0.61.3", "wiremock", "xai-computer-hub-core", @@ -14139,7 +14149,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.110" +version = "0.2.111" dependencies = [ "semver", ] @@ -14804,34 +14814,19 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f5295d2..e1f0ed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,7 +160,7 @@ http = "1" http-body-util = "0.1" humantime-serde = "1" ignore = "0.4" -image = { version = "0.25.9", default-features = false } +image = { version = "0.25.10", default-features = false } indexmap = { version = "2", features = ["serde"] } indicatif = "0.18" infer = "0.19.0" @@ -273,6 +273,7 @@ walkdir = "2" webbrowser = { version = "1.0.4" } which = "8" whoami = "1.4" +wildmatch = "2" windows = { version = "0.61", features = ["Win32_Security", "Win32_Security_Authorization", "Win32_Foundation", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Pipes"] } wiremock = "0.6" wl-clipboard-rs = "0.9" diff --git a/SOURCE_REV b/SOURCE_REV index f4cc07c..bbe8643 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -30192d2eef5d91a8fff0e53957de5bd05b43398c +95d84f443eddcbed6cbfd6eed22e2eafe6b3939d diff --git a/crates/codegen/xai-chat-state/src/actor/state.rs b/crates/codegen/xai-chat-state/src/actor/state.rs index 6af3167..cde5e59 100644 --- a/crates/codegen/xai-chat-state/src/actor/state.rs +++ b/crates/codegen/xai-chat-state/src/actor/state.rs @@ -273,6 +273,8 @@ mod tests { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-chat-state/src/actor/tests.rs b/crates/codegen/xai-chat-state/src/actor/tests.rs index a494789..e858da6 100644 --- a/crates/codegen/xai-chat-state/src/actor/tests.rs +++ b/crates/codegen/xai-chat-state/src/actor/tests.rs @@ -25,6 +25,8 @@ fn test_config_with_window(context_window: u64) -> SamplingConfig { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, @@ -1170,6 +1172,8 @@ async fn update_sampling_config_is_queryable() { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -1555,6 +1559,8 @@ async fn build_request_uses_sampling_config() { top_p: Some(0.9), api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -3696,6 +3702,8 @@ async fn sampling_config_survives_compaction_replacement() { top_p: Some(0.95), api_backend: ApiBackend::Responses, extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -3779,6 +3787,8 @@ async fn model_metadata_lost_after_compaction_then_recovered_on_next_turn() { top_p: Some(0.95), api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -3867,6 +3877,8 @@ async fn context_window_downgrade_triggers_auto_compact() { top_p: Some(0.95), api_backend: ApiBackend::Responses, extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-chat-state/src/commands.rs b/crates/codegen/xai-chat-state/src/commands.rs index aaa1d82..625ad90 100644 --- a/crates/codegen/xai-chat-state/src/commands.rs +++ b/crates/codegen/xai-chat-state/src/commands.rs @@ -409,6 +409,8 @@ mod tests { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-chat-state/src/types.rs b/crates/codegen/xai-chat-state/src/types.rs index cb8de5c..1efa153 100644 --- a/crates/codegen/xai-chat-state/src/types.rs +++ b/crates/codegen/xai-chat-state/src/types.rs @@ -181,6 +181,8 @@ mod tests { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -224,6 +226,8 @@ mod tests { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-grok-agent/src/builder.rs b/crates/codegen/xai-grok-agent/src/builder.rs index ce50f4c..7c1ab83 100644 --- a/crates/codegen/xai-grok-agent/src/builder.rs +++ b/crates/codegen/xai-grok-agent/src/builder.rs @@ -1037,6 +1037,7 @@ impl AgentBuilder { session_env: self.session_env.unwrap_or_default(), notification_handle: self.notification_handle.clone(), owner_session_id: self.owner_session_id.clone(), + subagent: None, parent_scheduler_handle: self.parent_scheduler_handle.take(), skills: skill_info.clone(), state_path, diff --git a/crates/codegen/xai-grok-agent/src/prompt/context.rs b/crates/codegen/xai-grok-agent/src/prompt/context.rs index ac0a7fb..0b5326d 100644 --- a/crates/codegen/xai-grok-agent/src/prompt/context.rs +++ b/crates/codegen/xai-grok-agent/src/prompt/context.rs @@ -76,6 +76,7 @@ pub enum PromptAudience { Subagent, } use xai_grok_tools::bridge::ToolBridge; +use xai_grok_tools::types::template_renderer::TemplateRenderer; /// Agent-specific inputs for system prompt rendering. /// /// Serializable (JSON/YAML) so users can dump it and inspect fields. @@ -259,12 +260,21 @@ impl PromptContext { /// MiniJinja so that `${{ tools.by_kind.* }}` variables resolve /// correctly regardless of prompt mode. pub async fn render(&self, tool_bridge: &ToolBridge) -> Option { + let renderer = tool_bridge.template_renderer_snapshot().await?; + self.render_with_renderer(&renderer) + } + /// Render the full system prompt from a finalized tool-name renderer. + /// + /// Hosts that do not own a [`ToolBridge`] use this path so they still + /// consume the production base-template and prompt-body composition. + pub fn render_with_renderer(&self, renderer: &TemplateRenderer) -> Option { let placeholders = self.placeholders(); + let render = |template: &str| renderer.render_with_extra(template, &placeholders).ok(); let prompt = match self.prompt_mode { PromptMode::Extend => { let decrypted; let base = match &self.system_prompt { - TemplateOverride::Custom(s) => s.as_str(), + TemplateOverride::Custom(template) => template.as_str(), TemplateOverride::Codex => { decrypted = apply_patch_template(); &decrypted @@ -278,21 +288,14 @@ impl PromptContext { &decrypted } }; - let mut p = tool_bridge.render_prompt(base, &placeholders).await?; - if let Some(ref body) = self.prompt_body { - p.push_str("\n\n"); - let rendered_body = tool_bridge - .render_prompt(body, &placeholders) - .await - .unwrap_or_else(|| body.clone()); - p.push_str(&rendered_body); + let mut prompt = render(base)?; + if let Some(body) = &self.prompt_body { + prompt.push_str("\n\n"); + prompt.push_str(&render(body).unwrap_or_else(|| body.clone())); } - p - } - PromptMode::Full => { - let body = self.prompt_body.as_deref().unwrap_or(""); - tool_bridge.render_prompt(body, &placeholders).await? + prompt } + PromptMode::Full => render(self.prompt_body.as_deref().unwrap_or(""))?, }; Some(prompt) } diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index 41baacf..b753db6 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -412,6 +412,29 @@ where }, } } +/// Nested `slash_command_tags` map: present-but-malformed → `None` (warn) so one +/// bad value cannot fail the whole [`RemoteSettings`] parse. +fn deserialize_tolerant_slash_command_tags<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(v) => match serde_json::from_value::>(v) { + Ok(m) => Ok(Some(m)), + Err(e) => { + tracing::warn!( + error = %e, + "ignoring malformed remote slash_command_tags; falling through to local/none" + ); + Ok(None) + } + }, + } +} /// Remote settings fetched from cli-chat-proxy `GET /v1/settings`. /// /// All fields are `Option` with `#[serde(default)]` so that: @@ -715,6 +738,12 @@ pub struct RemoteSettings { /// `None` or `[]` = no tips shown. #[serde(default)] pub tips: Option>, + /// Free-form per-command tags (e.g. `new`, `beta`) rendered as a bracketed + /// label in the slash dropdown, keyed by canonical command name. Present-but- + /// malformed → `None` (does not fail the whole parse); local + /// `[slash_command_tags]` overrides per key. See `resolve_slash_command_tags`. + #[serde(default, deserialize_with = "deserialize_tolerant_slash_command_tags")] + pub slash_command_tags: Option>, /// When present, controls the non-Git-repo warning at session start. /// Controlled via remote settings (`non_git_warning` in `grok_build_settings`). /// Takes precedence over `[features] non_git_warning` in config.toml: @@ -790,6 +819,9 @@ pub struct RemoteSettings { /// (`grok-imagine-image-quality`). Absent/empty → default model. #[serde(default)] pub image_gen_model_override: Option, + /// Optional Imagine model override for `image_edit`. Absent/empty → default. + #[serde(default)] + pub image_edit_model_override: Option, /// Video tools / `/imagine-video`. `None` → env / `[features]` / default on. #[serde(default)] pub video_gen_enabled: Option, diff --git a/crates/codegen/xai-grok-config/src/global_hook_sources.rs b/crates/codegen/xai-grok-config/src/global_hook_sources.rs new file mode 100644 index 0000000..78abacf --- /dev/null +++ b/crates/codegen/xai-grok-config/src/global_hook_sources.rs @@ -0,0 +1,569 @@ +//! Grok-owned direct global hook paths shared by shell discovery and sandbox +//! write-deny: `$GROK_HOME/hooks`, `hooks-paths`, and absolute registry targets. +//! Relative registry lines, project hooks, and vendor compat are out of scope. + +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GlobalHookSourceKind { + /// `$GROK_HOME/hooks/` (discovered + protected). + HookDirectory, + /// `$GROK_HOME/hooks-paths` (protected; never loaded as hook JSON). + RegistryFile, + /// Absolute registry target (must exist before sandbox apply). + ConfiguredSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlobalHookSource { + pub path: PathBuf, + pub kind: GlobalHookSourceKind, +} + +impl GlobalHookSource { + pub fn is_dir(&self) -> bool { + match self.kind { + GlobalHookSourceKind::HookDirectory => true, + GlobalHookSourceKind::RegistryFile => false, + GlobalHookSourceKind::ConfiguredSource => { + if self.path.exists() { + self.path.is_dir() + } else { + true + } + } + } + } + + /// False for the registry file itself (not hook JSON / not a hook dir). + pub fn is_discovery_source(&self) -> bool { + !matches!(self.kind, GlobalHookSourceKind::RegistryFile) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum GlobalHookSourceError { + #[error("cannot read hooks-paths {path}: {source}")] + HooksPathsRead { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("symlinked GROK_HOME is not allowed under sandbox write-deny: {path}")] + SymlinkedGrokHome { path: PathBuf }, + #[error("hook source path contains a symlink component (retargetable): {path}")] + SymlinkedSource { path: PathBuf }, + #[error("hook JSON file has hard-link aliases (st_nlink={nlink}): {path}")] + HardLinkedHookFile { path: PathBuf, nlink: u64 }, + #[error("hook JSON path is not a regular file: {path}")] + InvalidHookJsonFile { path: PathBuf }, + #[error("Grok hooks directory has wrong type (expected real directory): {path}")] + InvalidHooksDir { path: PathBuf }, + #[error("Grok hooks-paths registry has wrong type (expected real file): {path}")] + InvalidRegistryFile { path: PathBuf }, + #[error("cannot create Grok hooks directory {path}: {source}")] + CreateHooksDir { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("cannot create Grok hooks-paths registry {path}: {source}")] + CreateRegistryFile { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +/// Hard-fail omits all sources. Soft `configured_error` keeps fixed slots and +/// omits configured targets (sandbox must fail closed; discovery may log). +#[derive(Debug)] +pub struct ResolvedGlobalHookSources { + pub sources: Vec, + pub configured_error: Option, +} + +impl ResolvedGlobalHookSources { + pub fn is_incomplete(&self) -> bool { + self.configured_error.is_some() + } + + pub fn discovery_sources(&self) -> impl Iterator { + self.sources.iter().filter(|s| s.is_discovery_source()) + } +} + +/// macOS firmlinks are not attacker-retargetable; ignore in symlink scans. +fn is_system_firmlink(path: &Path) -> bool { + matches!( + path.to_str(), + Some("/tmp") + | Some("/var") + | Some("/etc") + | Some("/private/tmp") + | Some("/private/var") + | Some("/private/etc") + ) +} + +/// True if any existing path component is a retargetable symlink (firmlinks skipped). +pub fn path_has_symlink_component(path: &Path) -> bool { + let mut cur = PathBuf::new(); + for c in path.components() { + cur.push(c.as_os_str()); + match std::fs::symlink_metadata(&cur) { + Ok(m) if m.file_type().is_symlink() => { + if is_system_firmlink(&cur) { + continue; + } + return true; + } + Ok(_) => {} + Err(_) => break, + } + } + false +} + +fn push_unique(out: &mut Vec, source: GlobalHookSource) { + if !out.iter().any(|s| s.path == source.path) { + out.push(source); + } +} + +/// Existing non-symlink directory ancestors (parent-first), excluding `/`. +pub fn existing_ancestor_chain(path: &Path) -> Vec { + let mut chain = Vec::new(); + let mut cur = path.parent().map(Path::to_path_buf); + while let Some(p) = cur { + if p.as_os_str().is_empty() || p == Path::new("/") { + break; + } + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => { + chain.push(p.clone()); + } + Ok(_) => break, + Err(_) => break, + } + cur = p.parent().map(Path::to_path_buf); + } + chain +} + +/// Linux: `st_dev` differs from parent, or listed in mountinfo. Else false. +pub(crate) fn is_filesystem_mountpoint(path: &Path) -> bool { + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + if path == Path::new("/") { + return true; + } + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if let Some(parent) = path.parent() + && let Ok(pm) = std::fs::metadata(parent) + && meta.dev() != pm.dev() + { + return true; + } + let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else { + return false; + }; + let path_s = path.to_string_lossy(); + for line in mountinfo.lines() { + let Some((left, _)) = line.split_once(" - ") else { + continue; + }; + let fields: Vec<&str> = left.split_whitespace().collect(); + if fields.len() < 5 { + continue; + } + if fields[4] == path_s.as_ref() { + return true; + } + } + false + } + #[cfg(not(target_os = "linux"))] + { + let _ = path; + false + } +} + +/// Ancestors to RW self-bind so rename is EBUSY: parent→root, skip already- +/// mounted nodes but keep pinning renameable ancestors above them (never `/`). +pub fn ancestors_to_pin_as_mountpoints(path: &Path) -> Vec { + ancestors_to_pin_as_mountpoints_with(path, is_filesystem_mountpoint) +} + +pub(crate) fn ancestors_to_pin_as_mountpoints_with( + path: &Path, + is_mountpoint: impl Fn(&Path) -> bool, +) -> Vec { + let mut chain = Vec::new(); + let mut cur = path.parent().map(Path::to_path_buf); + while let Some(p) = cur { + if p.as_os_str().is_empty() || p == Path::new("/") { + break; + } + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => { + if is_mountpoint(&p) { + cur = p.parent().map(Path::to_path_buf); + continue; + } + chain.push(p.clone()); + } + Ok(_) => break, + Err(_) => break, + } + cur = p.parent().map(Path::to_path_buf); + } + chain +} + +/// Unique ancestors, rootward-first (shallowest first). +pub fn unique_ancestors_rootward(sources: &[GlobalHookSource]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut all = Vec::new(); + for s in sources { + for anc in ancestors_to_pin_as_mountpoints(&s.path) { + if seen.insert(anc.clone()) { + all.push(anc); + } + } + } + all.sort_by_key(|p| p.components().count()); + all +} + +fn require_real_dir(path: &Path) -> Result<(), GlobalHookSourceError> { + let meta = std::fs::symlink_metadata(path).map_err(|source| { + GlobalHookSourceError::CreateHooksDir { + path: path.to_path_buf(), + source, + } + })?; + if meta.file_type().is_symlink() || !meta.file_type().is_dir() { + return Err(GlobalHookSourceError::InvalidHooksDir { + path: path.to_path_buf(), + }); + } + Ok(()) +} + +fn require_real_file(path: &Path) -> Result<(), GlobalHookSourceError> { + let meta = std::fs::symlink_metadata(path).map_err(|source| { + GlobalHookSourceError::CreateRegistryFile { + path: path.to_path_buf(), + source, + } + })?; + if meta.file_type().is_symlink() || !meta.file_type().is_file() { + return Err(GlobalHookSourceError::InvalidRegistryFile { + path: path.to_path_buf(), + }); + } + Ok(()) +} + +/// Ensure real `$GROK_HOME/hooks` dir + `hooks-paths` file (create if missing). +/// Race-resistant create (`create_dir` / `create_new`+`O_NOFOLLOW`); never +/// truncates an existing registry; rejects symlinks/wrong types. +pub fn ensure_grok_hook_slots(grok_home: &Path) -> Result<(), GlobalHookSourceError> { + if path_has_symlink_component(grok_home) { + return Err(GlobalHookSourceError::SymlinkedGrokHome { + path: grok_home.to_path_buf(), + }); + } + + match std::fs::create_dir(grok_home) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => { + std::fs::create_dir_all(grok_home).map_err(|source| { + GlobalHookSourceError::CreateHooksDir { + path: grok_home.to_path_buf(), + source, + } + })?; + } + Err(source) => { + return Err(GlobalHookSourceError::CreateHooksDir { + path: grok_home.to_path_buf(), + source, + }); + } + } + if path_has_symlink_component(grok_home) { + return Err(GlobalHookSourceError::SymlinkedGrokHome { + path: grok_home.to_path_buf(), + }); + } + let grok_meta = std::fs::symlink_metadata(grok_home).map_err(|source| { + GlobalHookSourceError::CreateHooksDir { + path: grok_home.to_path_buf(), + source, + } + })?; + if grok_meta.file_type().is_symlink() || !grok_meta.file_type().is_dir() { + return Err(GlobalHookSourceError::SymlinkedGrokHome { + path: grok_home.to_path_buf(), + }); + } + + let hooks = grok_home.join("hooks"); + match std::fs::create_dir(&hooks) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + require_real_dir(&hooks)?; + } + Err(source) => { + return Err(GlobalHookSourceError::CreateHooksDir { + path: hooks, + source, + }); + } + } + require_real_dir(&hooks)?; + if path_has_symlink_component(&hooks) { + return Err(GlobalHookSourceError::SymlinkedSource { path: hooks }); + } + + let registry = grok_home.join("hooks-paths"); + match open_registry_create_new(®istry) { + Ok(f) => drop(f), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + require_real_file(®istry)?; + } + Err(source) => { + return Err(GlobalHookSourceError::CreateRegistryFile { + path: registry, + source, + }); + } + } + require_real_file(®istry)?; + if path_has_symlink_component(®istry) { + return Err(GlobalHookSourceError::SymlinkedSource { path: registry }); + } + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +const O_NOFOLLOW: i32 = 0x20000; +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +))] +const O_NOFOLLOW: i32 = 0x0100; + +fn open_registry_create_new(path: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(O_NOFOLLOW) + .open(path) + } + #[cfg(not(unix))] + { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + } +} + +/// Resolve Grok-owned direct global hook sources (`reject_symlinks` for sandbox). +pub fn resolve_global_hook_sources( + grok_home: Option<&Path>, + reject_symlinks: bool, +) -> Result { + let mut out = Vec::new(); + let mut configured_error = None; + + if let Some(grok) = grok_home { + if reject_symlinks && path_has_symlink_component(grok) { + return Err(GlobalHookSourceError::SymlinkedGrokHome { + path: grok.to_path_buf(), + }); + } + + let hooks = grok.join("hooks"); + let hooks_paths = grok.join("hooks-paths"); + if reject_symlinks { + for p in [&hooks, &hooks_paths] { + if path_has_symlink_component(p) { + return Err(GlobalHookSourceError::SymlinkedSource { path: p.clone() }); + } + } + } + + push_unique( + &mut out, + GlobalHookSource { + path: hooks, + kind: GlobalHookSourceKind::HookDirectory, + }, + ); + push_unique( + &mut out, + GlobalHookSource { + path: hooks_paths.clone(), + kind: GlobalHookSourceKind::RegistryFile, + }, + ); + + match std::fs::read_to_string(&hooks_paths) { + Ok(content) => { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let path = PathBuf::from(trimmed); + if !path.is_absolute() { + continue; + } + if reject_symlinks && path_has_symlink_component(&path) { + return Err(GlobalHookSourceError::SymlinkedSource { path }); + } + push_unique( + &mut out, + GlobalHookSource { + path, + kind: GlobalHookSourceKind::ConfiguredSource, + }, + ); + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + configured_error = Some(GlobalHookSourceError::HooksPathsRead { + path: hooks_paths, + source: e, + }); + } + } + } + + Ok(ResolvedGlobalHookSources { + sources: out, + configured_error, + }) +} + +pub fn missing_configured_sources(sources: &[GlobalHookSource]) -> Vec { + sources + .iter() + .filter(|s| s.kind == GlobalHookSourceKind::ConfiguredSource && !s.path.exists()) + .map(|s| s.path.clone()) + .collect() +} + +/// Discovery filename filter: `*.json`, not hidden, not editor temps. +pub fn is_direct_hook_json_name(name: &str) -> bool { + if !name.ends_with(".json") || name.len() <= 5 { + return false; + } + if name.starts_with('.') { + return false; + } + if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") { + return false; + } + true +} + +/// Immediate discovery JSON files under `dir` (sorted, non-recursive). +pub fn list_direct_hook_json_files(dir: &Path) -> io::Result> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !is_direct_hook_json_name(name) { + continue; + } + out.push(path); + } + out.sort(); + Ok(out) +} + +/// Regular non-symlink file with `st_nlink == 1`. +#[cfg(unix)] +pub fn validate_direct_hook_json_file(path: &Path) -> Result<(), GlobalHookSourceError> { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::symlink_metadata(path).map_err(|source| { + GlobalHookSourceError::HooksPathsRead { + path: path.to_path_buf(), + source, + } + })?; + if meta.file_type().is_symlink() { + return Err(GlobalHookSourceError::SymlinkedSource { + path: path.to_path_buf(), + }); + } + if !meta.file_type().is_file() { + return Err(GlobalHookSourceError::InvalidHookJsonFile { + path: path.to_path_buf(), + }); + } + if meta.nlink() != 1 { + return Err(GlobalHookSourceError::HardLinkedHookFile { + path: path.to_path_buf(), + nlink: meta.nlink(), + }); + } + Ok(()) +} + +#[cfg(unix)] +pub fn validated_hook_json_files_for_sources( + sources: &[GlobalHookSource], +) -> Result, GlobalHookSourceError> { + let mut files = Vec::new(); + for s in sources { + if !s.is_dir() || !s.path.is_dir() { + continue; + } + let listed = list_direct_hook_json_files(&s.path).map_err(|source| { + GlobalHookSourceError::HooksPathsRead { + path: s.path.clone(), + source, + } + })?; + for f in listed { + validate_direct_hook_json_file(&f)?; + files.push(f); + } + } + files.sort(); + files.dedup(); + Ok(files) +} + +#[cfg(test)] +#[path = "global_hook_sources_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs b/crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs new file mode 100644 index 0000000..6c01f06 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs @@ -0,0 +1,282 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn absolute_hooks_paths_only_and_fixed_slots() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let nested = dir.join("extra"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + dir.join("hooks-paths"), + format!("{}\nrelative/x\n", nested.display()), + ) + .unwrap(); + + let resolved = resolve_global_hook_sources(Some(dir), false).unwrap(); + assert!(resolved.configured_error.is_none()); + let sources = &resolved.sources; + assert!( + sources.iter().any(|s| { + s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory + }) + ); + assert!(sources.iter().any(|s| { + s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile + })); + assert!( + sources + .iter() + .any(|s| { s.path == nested && s.kind == GlobalHookSourceKind::ConfiguredSource }) + ); + assert!(!sources.iter().any(|s| s.path.ends_with("relative/x"))); + assert!(missing_configured_sources(sources).is_empty()); + + // Discovery must never treat the registry file as a hook source. + let discovery: Vec<_> = resolved + .discovery_sources() + .map(|s| s.path.clone()) + .collect(); + assert!(!discovery.iter().any(|p| p == &dir.join("hooks-paths"))); + assert!(discovery.iter().any(|p| p == &dir.join("hooks"))); + assert!(discovery.iter().any(|p| p == &nested)); +} + +#[test] +fn missing_configured_is_reported() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let missing = dir.join("nope").join("hooks"); + std::fs::write(dir.join("hooks-paths"), format!("{}\n", missing.display())).unwrap(); + let resolved = resolve_global_hook_sources(Some(dir), false).unwrap(); + assert!(resolved.configured_error.is_none()); + let miss = missing_configured_sources(&resolved.sources); + assert!(miss.iter().any(|p| p == &missing)); + assert!(!miss.iter().any(|p| p == &dir.join("hooks"))); +} + +#[test] +fn hooks_paths_read_error_keeps_fixed_slots() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + // Directory named hooks-paths → read_to_string fails with IsADirectory. + std::fs::create_dir_all(dir.join("hooks-paths")).unwrap(); + let resolved = resolve_global_hook_sources(Some(dir), false).unwrap(); + assert!(resolved.is_incomplete()); + assert!(matches!( + resolved.configured_error, + Some(GlobalHookSourceError::HooksPathsRead { .. }) + )); + assert!( + resolved.sources.iter().any(|s| { + s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory + }) + ); + assert!(resolved.sources.iter().any(|s| { + s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile + })); + assert!( + !resolved + .sources + .iter() + .any(|s| s.kind == GlobalHookSourceKind::ConfiguredSource) + ); +} + +#[test] +#[cfg(unix)] +fn reject_symlinked_configured_source() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let real = tmp.path().join("real-hooks"); + std::fs::create_dir_all(&real).unwrap(); + let link = dir.join("link-hooks"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + std::fs::write(dir.join("hooks-paths"), format!("{}\n", link.display())).unwrap(); + let err = resolve_global_hook_sources(Some(dir), true).unwrap_err(); + assert!(matches!(err, GlobalHookSourceError::SymlinkedSource { .. })); +} + +#[test] +fn not_found_hooks_paths_is_ok_empty_configured() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let resolved = resolve_global_hook_sources(Some(dir), false).unwrap(); + assert!(resolved.configured_error.is_none()); + assert!(missing_configured_sources(&resolved.sources).is_empty()); + assert!( + resolved + .sources + .iter() + .any(|s| s.kind == GlobalHookSourceKind::HookDirectory) + ); + assert!( + resolved + .sources + .iter() + .any(|s| s.kind == GlobalHookSourceKind::RegistryFile) + ); +} + +#[test] +fn ensure_creates_hooks_dir_and_empty_registry() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("grok"); + std::fs::create_dir_all(&dir).unwrap(); + ensure_grok_hook_slots(&dir).unwrap(); + let hooks = dir.join("hooks"); + let reg = dir.join("hooks-paths"); + assert!(hooks.is_dir()); + assert!(reg.is_file()); + assert_eq!(std::fs::read(®).unwrap(), b""); + // Idempotent — does not truncate existing registry content. + std::fs::write(®, b"/abs/extra\n").unwrap(); + ensure_grok_hook_slots(&dir).unwrap(); + assert_eq!(std::fs::read(®).unwrap(), b"/abs/extra\n"); +} + +#[test] +#[cfg(unix)] +fn ensure_rejects_preexisting_symlink_hooks_dir() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("grok"); + std::fs::create_dir_all(&dir).unwrap(); + let real = tmp.path().join("real-hooks"); + std::fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, dir.join("hooks")).unwrap(); + let err = ensure_grok_hook_slots(&dir).unwrap_err(); + assert!(matches!( + err, + GlobalHookSourceError::InvalidHooksDir { .. } + | GlobalHookSourceError::SymlinkedSource { .. } + )); +} + +#[test] +#[cfg(unix)] +fn ensure_rejects_preexisting_symlink_registry() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("grok"); + std::fs::create_dir_all(&dir).unwrap(); + let target = tmp.path().join("evil-registry"); + std::fs::write(&target, b"attacker\n").unwrap(); + std::os::unix::fs::symlink(&target, dir.join("hooks-paths")).unwrap(); + let err = ensure_grok_hook_slots(&dir).unwrap_err(); + // create_new hits EEXIST on the symlink → require_real_file rejects it; + // or O_NOFOLLOW path — never write through the symlink. + assert!(matches!( + err, + GlobalHookSourceError::InvalidRegistryFile { .. } + | GlobalHookSourceError::SymlinkedSource { .. } + | GlobalHookSourceError::CreateRegistryFile { .. } + )); + // Attacker target must remain unchanged (no write-through). + assert_eq!(std::fs::read(&target).unwrap(), b"attacker\n"); +} + +#[test] +#[cfg(unix)] +fn ensure_rejects_directory_named_hooks_paths() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("grok"); + std::fs::create_dir_all(dir.join("hooks-paths")).unwrap(); + let err = ensure_grok_hook_slots(&dir).unwrap_err(); + assert!(matches!( + err, + GlobalHookSourceError::InvalidRegistryFile { .. } + )); +} + +#[test] +#[cfg(unix)] +fn ensure_rejects_file_named_hooks_dir() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("grok"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("hooks"), b"not-a-dir").unwrap(); + let err = ensure_grok_hook_slots(&dir).unwrap_err(); + assert!(matches!(err, GlobalHookSourceError::InvalidHooksDir { .. })); +} + +#[test] +fn existing_ancestor_chain_lists_parents() { + let tmp = TempDir::new().unwrap(); + let leaf = tmp.path().join("a").join("b").join("c"); + std::fs::create_dir_all(&leaf).unwrap(); + let chain = existing_ancestor_chain(&leaf); + assert_eq!(chain[0], tmp.path().join("a").join("b")); + assert!(chain.iter().any(|p| p == &tmp.path().join("a"))); +} + +#[test] +fn list_direct_hook_json_files_matches_discovery_filter() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("active.json"), b"{}").unwrap(); + std::fs::write(dir.join(".hidden.json"), b"{}").unwrap(); + std::fs::write(dir.join("backup.json~"), b"{}").unwrap(); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + let files = list_direct_hook_json_files(dir).unwrap(); + assert_eq!(files.len(), 1); + assert!(files[0].ends_with("active.json")); +} + +#[test] +#[cfg(unix)] +fn validate_direct_hook_json_rejects_hardlink_and_symlink() { + let tmp = TempDir::new().unwrap(); + let f = tmp.path().join("a.json"); + let hl = tmp.path().join("b.json"); + std::fs::write(&f, b"{}").unwrap(); + std::fs::hard_link(&f, &hl).unwrap(); + assert!(matches!( + validate_direct_hook_json_file(&f), + Err(GlobalHookSourceError::HardLinkedHookFile { .. }) + )); + let real = tmp.path().join("real.json"); + let link = tmp.path().join("link.json"); + std::fs::write(&real, b"{}").unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + assert!(matches!( + validate_direct_hook_json_file(&link), + Err(GlobalHookSourceError::SymlinkedSource { .. }) + )); +} + +#[test] +fn ancestors_to_pin_skips_mountpoints_but_continues_above() { + let tmp = TempDir::new().unwrap(); + let outer = tmp.path().join("outer"); + let mid = outer.join("preexisting-bind"); + let leaf = mid.join("hooks"); + std::fs::create_dir_all(&leaf).unwrap(); + + // Synthetic: treat `preexisting-bind` as already a mountpoint. + let pin = ancestors_to_pin_as_mountpoints_with(&leaf, |p| p == mid); + assert!( + pin.iter().any(|p| p == &outer), + "must pin renameable ancestor ABOVE an intermediate mountpoint: {pin:?}" + ); + assert!( + !pin.iter().any(|p| p == &mid), + "must NOT re-bind an already-mounted ancestor: {pin:?}" + ); + assert!( + !pin.iter().any(|p| p == Path::new("/")), + "must never pin /: {pin:?}" + ); + + // Immediate parent of leaf is mid (mountpoint) — skipped; outer still present. + let sources = [GlobalHookSource { + path: leaf, + kind: GlobalHookSourceKind::ConfiguredSource, + }]; + // With real mountpoint detector, under temp dirs nothing is a mount → full chain. + let rootward = unique_ancestors_rootward(&sources); + for w in rootward.windows(2) { + assert!( + w[0].components().count() <= w[1].components().count(), + "rootward order broken: {rootward:?}" + ); + } +} diff --git a/crates/codegen/xai-grok-config/src/lib.rs b/crates/codegen/xai-grok-config/src/lib.rs index 39940b3..d12b452 100644 --- a/crates/codegen/xai-grok-config/src/lib.rs +++ b/crates/codegen/xai-grok-config/src/lib.rs @@ -16,6 +16,7 @@ pub mod campaigns; pub mod config_override; pub mod fs_atomic; +pub mod global_hook_sources; mod loader; mod macos_managed; mod managed_cache; @@ -31,6 +32,17 @@ pub mod version_overrides; pub use campaigns::{ CampaignEntry, CampaignOverrides, filter_active_campaigns, ids_touching_paths, }; +pub use global_hook_sources::{ + GlobalHookSource, GlobalHookSourceError, GlobalHookSourceKind, ResolvedGlobalHookSources, + ensure_grok_hook_slots, existing_ancestor_chain, is_direct_hook_json_name, + list_direct_hook_json_files, missing_configured_sources, path_has_symlink_component, + resolve_global_hook_sources, unique_ancestors_rootward, +}; + +#[cfg(unix)] +pub use global_hook_sources::{ + validate_direct_hook_json_file, validated_hook_json_files_for_sources, +}; pub use loader::{ CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer, REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled, @@ -43,7 +55,7 @@ pub use macos_managed::MDM_REQUIREMENTS_SOURCE; pub use managed_cache::{ MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, bump_rollback_floor, bump_rollback_floor_with_now, confirmed_team_switch, confirmed_team_switch_at, - is_managed_config_hard_stale_for, is_managed_config_stale_for, + fail_closed_policy_armed_at, is_managed_config_hard_stale_for, is_managed_config_stale_for, managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for, mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity, }; diff --git a/crates/codegen/xai-grok-config/src/managed_cache.rs b/crates/codegen/xai-grok-config/src/managed_cache.rs index c8b1528..650f0cf 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache.rs @@ -175,6 +175,30 @@ fn write_marker_atomically(home: &Path, json: &str) { } } +/// Whether fail-closed managed policy is armed on disk for `home`. +/// +/// True when the sync marker records `fail_closed`, on-disk `requirements.toml` +/// parses as fail_closed, or `requirements.toml` exists but is unreadable +/// (cannot confirm it is disarmed — must not let `clear_orphan` wipe). +/// False only when neither the marker nor the file indicates fail_closed +/// (including when the file is absent / `NotFound`). +/// Companion to the signed session gate in [`managed_policy_compromised_for`]. +pub fn fail_closed_policy_armed_at(home: &Path) -> bool { + if read_managed_config_cache(home).is_some_and(|c| c.fail_closed) { + return true; + } + // Defense in depth: files remain after a stripped/corrupt marker. + match std::fs::read_to_string(home.join(crate::loader::REQUIREMENTS_FILENAME)) { + Ok(s) => prod_mc_cli_chat_proxy_types::fail_closed_flag_status(&s).is_enabled(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => { + // File present but unreadable: do not allow clear_orphan to wipe. + tracing::warn!("requirements.toml unreadable; treating as fail_closed armed: {e}"); + true + } + } +} + /// The sync marker, or `None` if absent / unreadable / corrupt. Allow-on-unreadable: /// a read blip or torn write mustn't lock out a managed user. Unreadable/corrupt are /// logged (a corruption-to-disarm isn't silent) and self-heal on the next sync. diff --git a/crates/codegen/xai-grok-config/src/managed_cache/tests.rs b/crates/codegen/xai-grok-config/src/managed_cache/tests.rs index 262304b..7b43495 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache/tests.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache/tests.rs @@ -1316,6 +1316,50 @@ fn managed_config_stale_for_far_future_sync() { ); } +/// Unreadable requirements (PermissionDenied) with no fail_closed marker must +/// still arm the gate so clear_orphan cannot wipe policy that may still be +/// fail_closed on disk. +#[test] +#[cfg(unix)] +fn unreadable_requirements_treats_fail_closed_as_armed() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let req = home.join(crate::loader::REQUIREMENTS_FILENAME); + std::fs::write(&req, "fail_closed = true\n").unwrap(); + assert!( + fail_closed_policy_armed_at(home), + "readable fail_closed requirements must arm the gate" + ); + + // Drop read perms so read_to_string fails with PermissionDenied (not NotFound). + std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Restore on drop so tempfile cleanup can remove the file. + struct RestorePerms<'a>(&'a std::path::Path); + impl Drop for RestorePerms<'_> { + fn drop(&mut self) { + let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600)); + } + } + let _restore = RestorePerms(&req); + + assert!( + fail_closed_policy_armed_at(home), + "unreadable requirements must treat fail_closed as armed (no wipe)" + ); +} + +/// Absent requirements + no fail_closed marker → not armed (safe to clear). +#[test] +fn missing_requirements_and_marker_not_armed() { + let dir = tempfile::tempdir().unwrap(); + assert!( + !fail_closed_policy_armed_at(dir.path()), + "NotFound requirements with no marker must not arm fail_closed" + ); +} + // The is-managed claim gate tests live in a sibling child module (this file is // past the 1k-line mark); same private access via the #[path] include below. #[path = "claim_tests.rs"] diff --git a/crates/codegen/xai-grok-config/src/managed_text/format.rs b/crates/codegen/xai-grok-config/src/managed_text/format.rs index 55522e3..5449e47 100644 --- a/crates/codegen/xai-grok-config/src/managed_text/format.rs +++ b/crates/codegen/xai-grok-config/src/managed_text/format.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem}; +use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem, ManagedItemState}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommentSyntax { @@ -94,6 +94,27 @@ pub(super) fn outer_block( .map(|(start, end)| text[start..end].trim_end_matches(['\r', '\n']).to_owned())) } +pub(super) fn item_state( + original: &str, + namespace: &str, + owned_item_prefix: &str, + item: &ManagedItem, + comments: &CommentSyntax, + path: &Path, +) -> Result { + let parsed = parse_block(original, namespace, owned_item_prefix, comments, path)?; + let Some(range) = parsed.items.get(&item.name) else { + return Ok(ManagedItemState::Absent); + }; + let expected = item_section(item, comments, parsed.newline); + let actual = original[range.start..range.end].trim_end_matches(['\r', '\n']); + Ok(if actual == expected { + ManagedItemState::Exact + } else { + ManagedItemState::NeedsUpdate + }) +} + pub(super) fn render_update( original: &str, namespace: &str, diff --git a/crates/codegen/xai-grok-config/src/managed_text/mod.rs b/crates/codegen/xai-grok-config/src/managed_text/mod.rs index fe56674..5dbcc92 100644 --- a/crates/codegen/xai-grok-config/src/managed_text/mod.rs +++ b/crates/codegen/xai-grok-config/src/managed_text/mod.rs @@ -45,6 +45,14 @@ pub struct ManagedConfigRequest { pub struct ManagedTextInspection { original_text: Option, unmanaged_text: String, + requested_items: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ManagedItemState { + Absent, + Exact, + NeedsUpdate, } impl ManagedTextInspection { @@ -56,6 +64,10 @@ impl ManagedTextInspection { pub fn unmanaged_text(&self) -> &str { &self.unmanaged_text } + + pub fn requested_item_state(&self, index: usize) -> Option { + self.requested_items.get(index).copied() + } } /// Immutable source and output state presented before application. @@ -206,6 +218,20 @@ impl ManagedConfig { let parent_plan = ParentPlan::capture(parent)?; let original = source::read_source(&target_path)?; let text = original.text(&target_path)?; + let requested_items = request + .items + .iter() + .map(|item| { + format::item_state( + text, + &request.namespace, + &request.owned_item_prefix, + item, + &request.comments, + &target_path, + ) + }) + .collect::, _>>()?; let rendered = format::render_update( text, &request.namespace, @@ -217,6 +243,7 @@ impl ManagedConfig { let inspection = ManagedTextInspection { original_text: original.bytes.as_ref().map(|_| text.to_owned()), unmanaged_text: rendered.unmanaged_text, + requested_items, }; let updated = rendered.updated.into_bytes(); let changes = @@ -244,6 +271,13 @@ impl ManagedConfig { transaction::apply(plan, &transaction::NoopObserver) } + /// Verify that the exact source path, parent identities, symlink target, + /// bytes, mode, and file identity captured by `plan` are unchanged without + /// publishing its proposed update. + pub fn verify_unchanged(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> { + source::revalidate(plan) + } + #[cfg(test)] fn apply_with_observer( plan: ManagedConfigPlan, diff --git a/crates/codegen/xai-grok-hooks/src/discovery.rs b/crates/codegen/xai-grok-hooks/src/discovery.rs index 5c34f43..2595995 100644 --- a/crates/codegen/xai-grok-hooks/src/discovery.rs +++ b/crates/codegen/xai-grok-hooks/src/discovery.rs @@ -271,6 +271,8 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { let mut specs = Vec::new(); let mut errors = Vec::new(); + // Best-effort listing: a bad dirent is recorded and skipped so sibling + // hooks still load. (Sandbox fail-closed listing lives in xai_grok_config.) let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(e) => { @@ -285,7 +287,7 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { } }; - let mut json_files: Vec = Vec::new(); + let mut json_files = Vec::new(); for entry in entries { let entry = match entry { Ok(e) => e, @@ -297,9 +299,11 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { continue; } }; - let path = entry.path(); - if !is_valid_hook_file(&path) { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !xai_grok_config::is_direct_hook_json_name(name) || !path.is_file() { continue; } json_files.push(path); @@ -330,21 +334,12 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec, Vec) { } /// Check whether a path is a valid hook file (*.json, not hidden/temp). +#[cfg(test)] fn is_valid_hook_file(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false; }; - - if path.extension().and_then(|e| e.to_str()) != Some("json") { - return false; - } - if name.starts_with('.') { - return false; - } - if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") { - return false; - } - path.is_file() + xai_grok_config::is_direct_hook_json_name(name) && path.is_file() } #[cfg(test)] diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index f6b888b..5cb2c00 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.110" +version = "0.2.111" 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 3ee5554..c824312 100644 --- a/crates/codegen/xai-grok-pager-bin/src/main.rs +++ b/crates/codegen/xai-grok-pager-bin/src/main.rs @@ -44,7 +44,7 @@ use xai_grok_shell::leader::{ use xai_grok_shell::leader::{ ControlPayload, LeaderClient, LeaderEnvUrls, connect_or_spawn, socket_path_for_ws_url, }; -use xai_grok_update::{UpdateConfig, auto_update, enforce_minimum_version_or_exit}; +use xai_grok_update::{UpdateConfig, auto_update, enforce_version_policy_or_exit}; /// Apply headless args to an existing config, only overriding values that are /// explicitly set. This allows environment defaults to be preserved when /// specific args are not provided. @@ -1771,6 +1771,7 @@ async fn async_main(args: PagerArgs) -> Result<()> { if let Some(Command::Wrap(ref wrap_args)) = args.command { return xai_grok_pager::wrap_cmd::run(wrap_args); } + args.pin_local_resume_target()?; let saved_profile = args.saved_resume_profile(); let sandbox_profile_arg = match args.startup_sandbox_profile(saved_profile.as_deref()) { xai_grok_pager::app::cli::SandboxStartup::Apply(profile) => profile, @@ -1828,7 +1829,7 @@ async fn async_main(args: PagerArgs) -> Result<()> { Use `grok-pager agent {flag}` instead." ); } - enforce_minimum_version_or_exit(&update_config).await; + enforce_version_policy_or_exit(); return run_agent_command( agent_args, args.permission_mode_flag.clone(), @@ -1992,7 +1993,7 @@ async fn async_main(args: PagerArgs) -> Result<()> { if let Some(prompt) = headless_prompt { init_tracing_simple(HEADLESS_ENTRYPOINT); let _otel_guard = xai_grok_telemetry::otel_layer::otel_guard(); - enforce_minimum_version_or_exit(&update_config).await; + enforce_version_policy_or_exit(); let launch_yolo = xai_grok_shell::util::config::effective_yolo_for_launch( args.yolo, args.permission_mode_flag.as_deref(), @@ -2017,6 +2018,7 @@ async fn async_main(args: PagerArgs) -> Result<()> { xai_grok_pager::headless::HeadlessOptions { session_id: args.session_id.clone(), resume: args.resume_session.or(args.load_session), + resume_title_pinned: args.resume_target_pinned, cwd: args.cwd, yolo: launch_yolo.yolo, trust: args.trust, @@ -2047,7 +2049,7 @@ async fn async_main(args: PagerArgs) -> Result<()> { ) .await; } - enforce_minimum_version_or_exit(&update_config).await; + enforce_version_policy_or_exit(); let _otel_guard = xai_grok_telemetry::otel_layer::otel_guard(); type UpdateWaitHandle = tokio::task::JoinHandle>; let bg_update_wait: std::sync::Arc>> = diff --git a/crates/codegen/xai-grok-pager-minimal/src/panel.rs b/crates/codegen/xai-grok-pager-minimal/src/panel.rs index 4a29163..8bbe09a 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/panel.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/panel.rs @@ -193,7 +193,12 @@ fn resume_body_rows(agent: &AgentView, width: u16) -> u16 { state, Some(current_repo.as_str()), ); - measure_entries(&picker_entries) + // Reserve a row for the pinned hidden-external hint when shown. + let hint_row = u16::from( + !agent.app_chat_mode + && minimal_api::hidden_external_hint(entries.as_deref(), *source_filter).is_some(), + ); + measure_entries(&picker_entries).saturating_add(hint_row) } fn render_resume( @@ -203,6 +208,7 @@ fn render_resume( theme: &Theme, ) -> Option<(u16, u16)> { let cwd = agent.session.cwd.to_string_lossy().to_string(); + let chat_mode = agent.app_chat_mode; let Some(ActiveModal::SessionPicker { entries, state, @@ -212,7 +218,7 @@ fn render_resume( else { return None; }; - let (title_row, search_row, divider_row, list_area, footer_row) = chrome_layout(area); + let (title_row, search_row, divider_row, mut list_area, footer_row) = chrome_layout(area); let entries_data = entries.as_deref().unwrap_or(&[]); let content_width = area.width.saturating_sub(2); @@ -238,6 +244,9 @@ fn render_resume( state, Some(current_repo.as_str()), ); + let hidden_hint = (!chat_mode) + .then(|| minimal_api::hidden_external_hint(entries.as_deref(), *source_filter)) + .flatten(); render_title(buf, title_row, theme, "Resume session"); // Focus-aware search bar (cursor only when search is focused). @@ -256,6 +265,21 @@ fn render_resume( ); render_divider(buf, divider_row, theme); + // Pinned above the list so it stays visible regardless of list scroll. + if let Some(hint) = hidden_hint.as_deref() { + render_dim_line( + buf, + Rect { + height: 1, + ..list_area + }, + theme, + hint, + ); + list_area.y += 1; + list_area.height = list_area.height.saturating_sub(1); + } + let nsc = vec![false; picker_entries.len()]; let hit = picker::render_picker_content( buf, @@ -493,14 +517,22 @@ fn render_mcps( // ─────────────────────────────── helpers ──────────────────────────────────── -/// Sum the display height of grouped picker entries: a header is one row; a row -/// is its label line plus its collapsed summary lines (what the picker draws -/// when the row is not expanded). +/// Sum the display height of grouped picker entries: a header is one row (plus +/// the blank spacer `render_picker_content` draws before non-first headers); a +/// row is its label line plus its collapsed summary lines (what the picker +/// draws when the row is not expanded). fn measure_entries(entries: &[PickerEntry<'_>]) -> u16 { entries .iter() - .map(|e| match e { - PickerEntry::Header { .. } => 1u16, + .enumerate() + .map(|(idx, e)| match e { + PickerEntry::Header { .. } => { + if idx == 0 { + 1u16 + } else { + 2u16 + } + } PickerEntry::Row(r) => { if r.expanded { 1u16.saturating_add(r.description_lines.len() as u16) @@ -678,6 +710,37 @@ mod tests { ); } + #[test] + fn resume_panel_pins_hidden_external_hint_above_scrolling_list() { + // More native rows than the panel fits: the hint must stay pinned + // above the list instead of scrolling away with it. + let mut entries: Vec<_> = (0..20) + .map(|i| session_entry(&format!("native-{i}"))) + .collect(); + let mut foreign = session_entry("claude-session"); + foreign.source = "claude".into(); + entries.push(foreign); + let mut a = with_resume(entries); + let theme = Theme::current(); + let area = Rect::new(0, 0, 80, 10); + let mut buf = Buffer::empty(area); + render(&mut buf, area, &mut a, ListPanel::Resume, &theme); + + let text = buffer_text(&buf); + assert!( + text.contains("1 external session hidden \u{b7} f to show"), + "hidden foreign rows must stay explained while the list scrolls:\n{text}" + ); + assert!( + text.find("external session hidden") < text.find("native-"), + "the hint must be pinned above the first list row:\n{text}" + ); + assert!( + !text.contains("claude-session"), + "foreign row stays hidden under the default filter:\n{text}" + ); + } + #[test] fn resume_search_uses_picker_grapheme_viewport_at_narrow_width() { let grapheme = "👩🏽\u{200d}💻"; diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs b/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs index 76289f5..a0c129d 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs @@ -78,6 +78,16 @@ pub fn inference_request_count(content: &ContentController) -> usize { /// `default_coding_data_retention_opt_out()`. The mock server accepts any /// bearer. Pair with [`oauth_credential_ops`]. pub fn seed_fake_oauth(content: &ContentController, user: &str) { + seed_fake_oauth_with_opt_out(content, user, false); +} + +/// Like [`seed_fake_oauth`], but with `coding_data_retention_opt_out: true` — +/// the auth-side precondition for the coding-data privacy upsell banner. +pub fn seed_fake_oauth_coding_data_opted_out(content: &ContentController, user: &str) { + seed_fake_oauth_with_opt_out(content, user, true); +} + +fn seed_fake_oauth_with_opt_out(content: &ContentController, user: &str, opted_out: bool) { let grok_home = content.home().join(".grok"); std::fs::create_dir_all(&grok_home).expect("create temp .grok"); std::fs::write( @@ -94,7 +104,7 @@ pub fn seed_fake_oauth(content: &ContentController, user: &str) { "refresh_token": "pty-test-refresh-token", "oidc_issuer": "https://auth.x.ai", "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828", - "coding_data_retention_opt_out": false + "coding_data_retention_opt_out": {opted_out} }} }}"# ), diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs index 59e3d16..599f163 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs @@ -41,8 +41,9 @@ pub use content::{ }; pub use env::pager_binary; pub use flows::{ - inference_request_count, oauth_credential_ops, seed_fake_oauth, submit_turn, - wait_for_labels_absent, wait_for_model_via_new_sessions, + inference_request_count, oauth_credential_ops, seed_fake_oauth, + seed_fake_oauth_coding_data_opted_out, submit_turn, wait_for_labels_absent, + wait_for_model_via_new_sessions, }; pub use host_clipboard::HostClipboardTextGuard; pub use leader::LeaderCluster; diff --git a/crates/codegen/xai-grok-pager-pty-harness/tests/privacy_banner_e2e.rs b/crates/codegen/xai-grok-pager-pty-harness/tests/privacy_banner_e2e.rs new file mode 100644 index 0000000..b464c14 --- /dev/null +++ b/crates/codegen/xai-grok-pager-pty-harness/tests/privacy_banner_e2e.rs @@ -0,0 +1,247 @@ +//! E2E: the coding-data privacy upsell banner — shown on the welcome screen +//! for an opted-out OAuth user under the `privacy_notice_rollout` flag, +//! persisting into the agent view, and acked (never re-shown) via both +//! buttons: `[Customize in settings]` opens the settings chooser and stamps +//! `[privacy].privacy_banner_acked`; `[Accept]` opts the user in through the +//! shell's `PUT /privacy/coding-data-retention` round trip before acking. +//! +//! Drives the real pager binary through a PTY against the shared mock +//! inference server (isolated `$HOME`), with a seeded opted-out OAuth entry +//! as the active auth (`XAI_API_KEY` removed) and the rollout forced on via +//! `GROK_PRIVACY_NOTICE_ROLLOUT=1`. +//! +//! ```bash +//! cargo test -p xai-grok-pager-pty-harness --test privacy_banner_e2e \ +//! -- --ignored --nocapture +//! ``` + +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use xai_grok_pager_pty_harness::{ + ContentController, EnvOp, PtyExitPoll, PtyHarness, keys, pager_binary, + seed_fake_oauth_coding_data_opted_out, +}; + +const ROWS: u16 = 50; +const COLS: u16 = 120; +const BANNER_TITLE: &str = "Help improve Grok"; +const CUSTOMIZE: &str = "[Customize in settings]"; +const ACCEPT: &str = "[Accept]"; +const ACK: &str = "BANNERACK"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored) +async fn privacy_banner_welcome_customize_ack_persists() { + run_customize().await.expect("privacy banner customize e2e"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored) +async fn privacy_banner_persists_into_agent_view_and_accept_opts_in() { + run_accept().await.expect("privacy banner accept e2e"); +} + +/// Rollout flag forced on (env override beats remote settings) and the +/// sandbox's fake `XAI_API_KEY` removed so the seeded opted-out OAuth entry +/// is the active auth — the banner's two preconditions. +fn banner_env_ops() -> [EnvOp<'static>; 2] { + [ + EnvOp::set("GROK_PRIVACY_NOTICE_ROLLOUT", "1"), + EnvOp::remove("XAI_API_KEY"), + ] +} + +async fn run_customize() -> Result<()> { + let content = ContentController::start() + .await + .context("start mock server")?; + seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user"); + + let project = tempfile::tempdir().context("project dir")?; + std::fs::create_dir_all(project.path().join(".git")).context("create .git")?; + let binary = pager_binary().context("resolve pager binary")?; + + let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?; + wait_for_banner(&mut pager)?; + assert!( + pager.contains_text(ACCEPT), + "welcome banner is missing {ACCEPT}:\n{}", + pager.screen_contents() + ); + + click_text(&mut pager, CUSTOMIZE).context("click Customize")?; + pager + .wait_for_text("Coding data sharing", Duration::from_secs(20)) + .context("settings chooser opened on Coding data sharing")?; + assert!( + pager.contains_text("Opt in") && pager.contains_text("Opt out"), + "chooser is missing the Opt in / Opt out choices:\n{}", + pager.screen_contents() + ); + + // Customize acks immediately; the config write is async — poll for it. + wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?; + + // Close the chooser, then the settings list, then quit gracefully. + pager.inject_keys(keys::ESC).context("close chooser")?; + pager.update(Duration::from_millis(300)); + pager.inject_keys(keys::ESC).context("close settings")?; + pager.update(Duration::from_millis(300)); + quit_via_double_ctrl_c(&mut pager)?; + drop(pager); + + // Relaunch with the same sandbox: the acked banner must not re-show. + // Sync on "New worktree" — rendered only on the authenticated welcome + // menu ("Quit" also appears while auth is still pending, where the + // banner is gated off regardless of the ack). + let mut relaunched = + spawn_pager(&binary, &content, project.path()).context("relaunch pager")?; + relaunched + .wait_for_text("New worktree", Duration::from_secs(20)) + .context("relaunched authenticated welcome screen")?; + relaunched.update(Duration::from_secs(2)); + assert!( + !relaunched.contains_text(BANNER_TITLE), + "acked banner re-showed after relaunch:\n{}", + relaunched.screen_contents() + ); + Ok(()) +} + +async fn run_accept() -> Result<()> { + let content = ContentController::start() + .await + .context("start mock server")?; + content.set_response(format!("{ACK} done.")); + seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user"); + + let project = tempfile::tempdir().context("project dir")?; + std::fs::create_dir_all(project.path().join(".git")).context("create .git")?; + let binary = pager_binary().context("resolve pager binary")?; + + let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?; + wait_for_banner(&mut pager)?; + + pager.inject_keys(b"hello").context("type prompt")?; + pager.inject_keys(keys::ENTER).context("submit prompt")?; + pager + .wait_for_text(ACK, Duration::from_secs(30)) + .context("turn response rendered")?; + pager.update(Duration::from_millis(1000)); + assert!( + pager.contains_text(BANNER_TITLE), + "banner did not persist into the agent view:\n{}", + pager.screen_contents() + ); + + click_text(&mut pager, ACCEPT).context("click Accept")?; + + // Ack only lands after the shell's PUT round trip confirms 2xx. + pager + .wait_for_text_absent(BANNER_TITLE, Duration::from_secs(20)) + .context("banner disappeared after Accept")?; + wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?; + + let put_bodies: Vec<_> = content + .requests() + .iter() + .filter(|e| e.method == "PUT" && e.path == "/v1/privacy/coding-data-retention") + .filter_map(|e| e.body.clone()) + .collect(); + assert!( + put_bodies + .iter() + .any(|b| b["codingDataRetentionOptOut"] == serde_json::json!(false)), + "mock server did not see the opt-in PUT; got: {put_bodies:?}" + ); + Ok(()) +} + +fn spawn_pager(binary: &Path, content: &ContentController, project: &Path) -> Result { + PtyHarness::spawn_with_content_env_ops_in_dir( + binary, + ROWS, + COLS, + content, + &[], + &banner_env_ops(), + Some(project), + ) +} + +/// Wait for the welcome menu first (auth resolved) so a missing banner is a +/// real failure rather than an early frame, then for the banner itself. +fn wait_for_banner(pager: &mut PtyHarness) -> Result<()> { + pager + .wait_for_text("Quit", Duration::from_secs(20)) + .context("welcome screen")?; + pager + .wait_for_text(BANNER_TITLE, Duration::from_secs(20)) + .context("privacy banner on screen") +} + +/// Click `needle` by injecting an SGR (DECSET 1006) press + release at its +/// first character. The wire encoding is 1-based `col;row` +/// (`screen_contents` line 0 = row 1); the banner region is ASCII-only, so +/// the byte offset within the line is the column. +fn click_text(pager: &mut PtyHarness, needle: &str) -> Result<()> { + let screen = pager.screen_contents(); + let (row0, col0) = screen + .lines() + .enumerate() + .find_map(|(row, line)| line.find(needle).map(|col| (row, col))) + .with_context(|| format!("{needle:?} not on screen:\n{screen}"))?; + let (row, col) = (row0 + 1, col0 + 1); + pager + .inject_keys(format!("\x1b[<0;{col};{row}M\x1b[<0;{col};{row}m").as_bytes()) + .context("inject SGR click")?; + pager.update(Duration::from_millis(250)); + Ok(()) +} + +/// Poll `/.grok/config.toml` for the async `privacy_banner_acked` +/// write, pumping PTY output between polls so the pager never blocks on a +/// full output buffer. +fn wait_for_ack_on_disk(pager: &mut PtyHarness, home: &Path, timeout: Duration) -> Result<()> { + let path = home.join(".grok").join("config.toml"); + let deadline = Instant::now() + timeout; + loop { + let body = std::fs::read_to_string(&path).unwrap_or_default(); + if body.contains("privacy_banner_acked") { + return Ok(()); + } + if Instant::now() >= deadline { + bail!( + "timed out after {timeout:?} waiting for privacy_banner_acked in {}\n\ + config contents:\n{body}\nscreen:\n{}", + path.display(), + pager.screen_contents() + ); + } + pager.update(Duration::from_millis(100)); + } +} + +/// First Ctrl+C arms the quit confirmation on the empty prompt, the second +/// confirms; retry the pair in case an overlay swallowed the first one. +fn quit_via_double_ctrl_c(pager: &mut PtyHarness) -> Result<()> { + for _ in 0..3 { + pager.inject_keys(keys::CTRL_C).context("ctrl-c arm")?; + pager.update(Duration::from_millis(250)); + pager.inject_keys(keys::CTRL_C).context("ctrl-c confirm")?; + pager.update(Duration::from_millis(250)); + match pager.wait_exit_code(Duration::from_secs(5))? { + PtyExitPoll::Exited(code) => { + assert_eq!(code, 0, "graceful quit should exit 0, got {code}"); + return Ok(()); + } + _ => continue, + } + } + bail!( + "pager did not exit after repeated double Ctrl+C\nscreen:\n{}", + pager.screen_contents() + ) +} 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 93f3f8b..89e4977 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 @@ -1,6 +1,14 @@ //! Shared tmux command protocol and result parsing. use std::process::{Command, Stdio}; +use std::time::Duration; + +const TMUX_QUERY_TIMEOUT: Duration = Duration::from_secs(2); +/// After the leader exits, allow this much additional time for process-group +/// teardown and concurrent pipe drains so a near-deadline success is not turned +/// into a drain timeout. The main process wait still uses only +/// [`TMUX_QUERY_TIMEOUT`]. +const POST_EXIT_CLEANUP_GRACE: Duration = Duration::from_millis(300); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TmuxCommand<'a> { @@ -25,17 +33,110 @@ struct LiveTmuxCommandRunner; impl TmuxCommandRunner for LiveTmuxCommandRunner { fn run(&self, command: TmuxCommand<'_>) -> Result { - let output = build_tmux_command(command) - .output() - .map_err(|error| format!("failed to run tmux: {error}"))?; - Ok(TmuxCommandOutput { - status_success: output.status.success(), - stdout: output.stdout, - stderr: output.stderr, - }) + run_tmux_bounded(command, TMUX_QUERY_TIMEOUT) } } +fn run_tmux_bounded( + command: TmuxCommand<'_>, + timeout: Duration, +) -> Result { + let mut command = build_tmux_command(command); + let mut child = command + .spawn() + .map_err(|error| format!("failed to run tmux: {error}"))?; + let group = xai_tty_utils::ProcessGroup::new() + .and_then(|mut group| { + group.attach_std(&child)?; + Ok(group) + }) + .map_err(|error| { + let _ = child.kill(); + let _ = child.wait(); + format!("failed to own tmux process tree: {error}") + })?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "tmux stdout pipe was not captured".to_owned())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "tmux stderr pipe was not captured".to_owned())?; + let stdout = spawn_pipe_drain(stdout, "stdout"); + let stderr = spawn_pipe_drain(stderr, "stderr"); + let deadline = std::time::Instant::now() + timeout; + + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(15)); + } + Ok(None) => { + terminate_tmux_tree(&group, &mut child); + return Err(format!("tmux query timed out after {timeout:?}")); + } + Err(error) => { + terminate_tmux_tree(&group, &mut child); + return Err(format!("failed to wait for tmux: {error}")); + } + } + }; + + // The leader may be reaped while descendants still exist or hold pipes. + // Use a fresh post-exit bound so near-deadline success still drains; the + // main process deadline is not extended for hung leaders. + let cleanup_deadline = std::time::Instant::now() + POST_EXIT_CLEANUP_GRACE; + terminate_owned_group(&group); + let stdout = recv_pipe_drain(stdout, cleanup_deadline, "stdout")?; + let stderr = recv_pipe_drain(stderr, cleanup_deadline, "stderr")?; + Ok(TmuxCommandOutput { + status_success: status.success(), + stdout, + stderr, + }) +} + +fn spawn_pipe_drain( + mut pipe: impl std::io::Read + Send + 'static, + label: &'static str, +) -> std::sync::mpsc::Receiver, String>> { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut output = Vec::new(); + let result = pipe + .read_to_end(&mut output) + .map(|_| output) + .map_err(|error| format!("failed to read tmux {label}: {error}")); + let _ = sender.send(result); + }); + receiver +} + +fn recv_pipe_drain( + receiver: std::sync::mpsc::Receiver, String>>, + deadline: std::time::Instant, + label: &'static str, +) -> Result, String> { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + receiver + .recv_timeout(remaining) + .map_err(|_| format!("tmux {label} did not close before the query deadline"))? +} + +fn terminate_tmux_tree(group: &xai_tty_utils::ProcessGroup, child: &mut std::process::Child) { + terminate_owned_group(group); + let _ = child.wait(); +} + +fn terminate_owned_group(group: &xai_tty_utils::ProcessGroup) { + let _ = group.terminate(); + std::thread::sleep(Duration::from_millis(100)); + // KILL is unconditional because leader state says nothing about descendants. + let _ = group.kill(); +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum TmuxQueryResult { Available(T), @@ -102,22 +203,22 @@ fn build_tmux_command(command: TmuxCommand<'_>) -> Command { let mut cmd = Command::new("tmux"); match command { TmuxCommand::Version => { - cmd.arg("-V").stdout(Stdio::piped()).stderr(Stdio::null()); + cmd.arg("-V").stdout(Stdio::piped()).stderr(Stdio::piped()); } TmuxCommand::OptionValue(option) => { cmd.args(["show-option", "-gqv", option]) .stdout(Stdio::piped()) - .stderr(Stdio::null()); + .stderr(Stdio::piped()); } TmuxCommand::OptionSupport(option) => { cmd.args(["show-option", "-gv", option]) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) .stderr(Stdio::piped()); } TmuxCommand::ControlMode => { cmd.args(["display-message", "-p", "#{client_flags}"]) .stdout(Stdio::piped()) - .stderr(Stdio::null()); + .stderr(Stdio::piped()); } } cmd.stdin(Stdio::null()).envs(xai_tty_utils::pager_env()); @@ -271,4 +372,67 @@ mod tests { TmuxQueryResult::Unavailable ); } + + /// A leader that exits successfully just under the process deadline must + /// still return captured output: post-exit TERM grace + pipe drain use a + /// separate bound and must not turn success into a drain timeout. + /// + /// A background descendant keeps the captured pipes open until process-group + /// teardown so the drain cannot finish during the wait loop. That makes the + /// post-exit cleanup window load-bearing once the main deadline is nearly + /// exhausted. + #[cfg(unix)] + #[test] + #[serial_test::serial(tmux_probe_path)] + fn successful_near_deadline_exit_still_returns_captured_output() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let tmux = bin.join("tmux"); + // Burn most of the process budget, then exit successfully while a + // descendant still holds the pipes. Remaining main-deadline time is + // intentionally below the fixed TERM grace sleep so a shared deadline + // would fail the drain; the separate post-exit cleanup grace must keep + // this a success. Perl select is used for subsecond precision. + let timeout = Duration::from_millis(1500); + std::fs::write( + &tmux, + "#!/bin/sh\n\ + /usr/bin/perl -e 'select(undef, undef, undef, 1.2)'\n\ + ( exec sleep 30 ) &\n\ + printf 'tmux 3.4\\n'\n\ + exit 0\n", + ) + .unwrap(); + std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let previous_path = std::env::var_os("PATH"); + let mut path = OsString::from(bin.as_os_str()); + path.push(":"); + if let Some(existing) = &previous_path { + path.push(existing); + } + // SAFETY: serialized on `tmux_probe_path`; restored before return. + unsafe { + std::env::set_var("PATH", &path); + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_tmux_bounded(TmuxCommand::Version, timeout) + })); + match previous_path { + Some(value) => unsafe { + std::env::set_var("PATH", value); + }, + None => unsafe { + std::env::remove_var("PATH"); + }, + } + let output = result + .expect("near-deadline probe must not panic") + .expect("near-deadline success must not become a drain error"); + assert!(output.status_success, "expected successful status"); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "tmux 3.4"); + } } diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml index 810edc8..3a65ecc 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.110" +version = "0.2.111" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/01-coming-from-another-tool.md b/crates/codegen/xai-grok-pager/docs/tutorial/01-coming-from-another-tool.md new file mode 100644 index 0000000..ff9e540 --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/01-coming-from-another-tool.md @@ -0,0 +1,40 @@ +# Coming from Claude, Cursor, or Codex? + +Fear not — your settings, rules, and skills come with you. Grok Build +reads the same project conventions other agents use, and imports the rest. + +## Picked up automatically + +- **Rules & instructions** — `AGENTS.md` (the Codex/OpenCode convention), + `CLAUDE.md` (including nested ones), and `*.md` rules under + `.claude/rules/` and `.cursor/rules/`. +- **Skills & custom commands** — `~/.claude/skills/`, `~/.claude/commands/`, + `~/.cursor/skills/`, and their project-level twins. Flat command `.md` + files become slash commands here too. +- **MCP servers** — from `~/.claude.json`, `.cursor/mcp.json`, and project + `.mcp.json`. +- **Hooks** — from `.claude/settings.json`, including matcher aliases like + `Bash`, so most hooks run unchanged. + +## One-step import + +**`/import-claude`** scans your `~/.claude` settings — permissions, env +vars, MCP servers, hooks — and shows a checkbox preview; confirming +writes the items you selected into your `.grok` config. Re-run it anytime. + +## Pick up where you left off + +The **`/resume-claude`**, **`/resume-codex`**, and **`/resume-cursor`** +skills continue a recent session from those tools right here. + +## Check what was discovered + +Run **`grok inspect`** in a repo to see every rules file, skill, and MCP +server Grok picked up, tagged with where it came from. Each compat source +can be toggled in `[compat.claude]` / `[compat.cursor]` config sections. + +And a few things you might have missed elsewhere: `/btw` asks a side +question without interrupting the current task, and `/rewind` restores +actual file snapshots, not just chat history. + +*Go deeper: `/docs Project Rules (AGENTS.md)`, `/docs Skills`, or `/docs MCP Servers`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/02-first-prompt.md b/crates/codegen/xai-grok-pager/docs/tutorial/02-first-prompt.md new file mode 100644 index 0000000..830db4c --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/02-first-prompt.md @@ -0,0 +1,25 @@ +# Your First Prompt + +Grok Build is a conversation with an agent that can read your code, run +commands, and edit files — right here in your terminal. + +Type what you want and press `Enter`. Grok streams its work into the +**scrollback** above the prompt: responses, shell commands, file edits. + +## Keep typing while Grok works + +While a turn is running, `Enter` **queues** your next message instead of +interrupting. Change your mind? Press `Enter` on the empty prompt to stop +the current turn and send the queued message right away. + +## You are always in control + +- **`Esc`** — cancel a running turn immediately (your draft is kept). +- **`Esc Esc`** while idle — clear the prompt; with an empty prompt, open + the rewind picker instead. Cleared something by accident? `Ctrl+Z` undoes. +- **`Ctrl+Q`** — quit (`Ctrl+D` in VS Code-family terminals), press twice. + +The **shortcuts bar** at the bottom always shows the keys relevant to what +you're doing right now — when in doubt, look down. + +*Go deeper: `/docs Getting Started`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/03-attach-and-paste.md b/crates/codegen/xai-grok-pager/docs/tutorial/03-attach-and-paste.md new file mode 100644 index 0000000..ecf9e7e --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/03-attach-and-paste.md @@ -0,0 +1,26 @@ +# Attach Files, Images & Paste + +The more precisely you point Grok at the right context, the better the +result. Three ways to get things into the prompt: + +## Mention files with `@` + +Type `@` for a fuzzy file picker — line ranges work too: + +``` +@src/main.rs attach a file +@src/main.rs:10-50 attach specific lines +@!.env reach hidden files with @! +``` + +## Paste images + +Paste a screenshot straight into the prompt: `Cmd+V` on macOS, `Ctrl+V` on +Linux, `Alt+V` on Windows. Great for error dialogs, designs, and diagrams. + +## Run shell commands yourself + +Type `!` on an empty prompt to run a shell command directly — the output +lands in the scrollback where Grok can see it too. + +*Go deeper: `/docs Getting Started`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/04-navigation.md b/crates/codegen/xai-grok-pager/docs/tutorial/04-navigation.md new file mode 100644 index 0000000..3a5ec1b --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/04-navigation.md @@ -0,0 +1,32 @@ +# Finding Your Way Around + +The screen has three parts: the **scrollback** (the conversation), the +**prompt** below it, and the **shortcuts bar** at the bottom. Panes for +todos and background tasks slide in when you need them. + +## Focus + +**`Tab`** switches focus between the prompt and the scrollback. Focused +scrollback gets a selection you can move with the arrow keys. + +## Moving through the conversation + +- **`↑`/`↓`** — select the previous/next entry. +- **`Shift+←`/`Shift+→`** — jump between turns (your prompts). +- **`PageUp`/`PageDown`** — scroll by page; this works straight from the + prompt, no focus change needed. +- **`←`/`→`** — collapse/expand the selected entry; long tool output stays + out of your way until you want it. +- **`Enter`** — open the selected entry in a fullscreen viewer. + +## Panes + +- **`Ctrl+T`** — toggle the **todos pane**: Grok's live plan for the + current task. +- **`Ctrl+G`** — toggle the **tasks pane**: everything running in the + background, with its status. + +Prefer vim keys? **`/vim-mode`** switches the scrollback to `j`/`k`, +`g`/`G`, and friends. + +*Go deeper: `/docs Keyboard Shortcuts`* 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 new file mode 100644 index 0000000..9ee8cde --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/05-slash-commands.md @@ -0,0 +1,36 @@ +# Slash Commands + +Type `/` on an empty prompt and a searchable dropdown of commands appears. +A few worth knowing on day one: + +| Command | What it does | +|---------|--------------| +| `/help` | Browse every command and keyboard shortcut | +| `/model` | Switch models or reasoning effort | +| `/resume` | Pick up a previous session where you left off | +| `/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 | +| `/docs` | Full How-to Guides, in the TUI or on the web | +| `/feedback` | Send feedback to the team | + +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. + +## The command palette + +Press **`Ctrl+P`** (or `?` from the scrollback) to open the command palette — +one searchable list of every command, shortcut, and skill. There's also a +full shortcuts cheatsheet on `Ctrl+.` (use `Ctrl+X` if your terminal +swallows it). + +You don't need to memorize anything: `/` and `Ctrl+P` will always show you +what's available. + +*Go deeper: `/docs Slash Commands`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/06-worktrees.md b/crates/codegen/xai-grok-pager/docs/tutorial/06-worktrees.md new file mode 100644 index 0000000..53e225c --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/06-worktrees.md @@ -0,0 +1,34 @@ +# Parallel Work: Worktrees + +Want Grok working on a feature while you (or another Grok session) work on +something else in the same repo? **Git worktrees** give each session its own +isolated checkout — no stepping on each other's changes, no stashing. + +## Start a session in a worktree + +- **From anywhere:** press `Ctrl+N` (twice to confirm) for a new session, + then choose the worktree option. +- **From the welcome screen:** press `Ctrl+W` (inside a git repo) to open + the New Worktree dialog. +- **From the shell:** + + ```bash + grok --worktree=my-feature "refactor the auth module" + ``` + + (Use `=` — otherwise the prompt is taken as the worktree name.) + +## Why this is great + +- Run two or three Grok sessions on the same repo simultaneously. +- Experiments stay isolated — if a change doesn't work out, your main + checkout is untouched. +- When the work is done, apply the changes back like any git branch. + +**`/fork`** copies your current conversation into a parallel session — +add a directive to point it at a task: `/fork try the async approach`. + +Running several agents? The **dashboard** (`/dashboard` or `Ctrl+\`) shows +every session grouped by state — who needs input, who's working, who's done. + +*Go deeper: `/docs Session Management`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/07-plan-and-permissions.md b/crates/codegen/xai-grok-pager/docs/tutorial/07-plan-and-permissions.md new file mode 100644 index 0000000..3dc137d --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/07-plan-and-permissions.md @@ -0,0 +1,39 @@ +# Plan Mode & Permissions + +Grok asks before doing anything risky — and can plan before it codes. + +## Permissions + +When Grok wants to run a risky command or edit a file, it pauses and asks: +allow once, always allow that kind of action, or deny. + +Reading is always free: file reads, searches, and safe read-only commands +(`ls`, `git status`, `grep`, …) never prompt. Chained commands are +checked piece by piece — `ls && rm -rf tmp` still prompts for the `rm`. + +Trust the session? `/always-approve` (or `Ctrl+O`) skips the prompts. + +## Plan mode + +For bigger or more ambiguous tasks, use **plan mode**: Grok explores the +codebase read-only, designs an approach, and presents a plan you approve +*before* any code is written. + +- **`Shift+Tab`** (prompt focused) cycles the mode: Normal → Plan → + Always-approve. +- **`/plan`** enters plan mode directly; `/plan ` plans that task in + one step. + +When the plan is ready: `a` approves, `c` comments on a specific line, +`s` requests changes — Grok iterates until you're happy, then implements. + +A good habit: plan mode for "how should we even do this?", normal mode for +"just do it". + +## Long-running commands + +A build or test run hogging the turn? **`Ctrl+B`** sends it to the +background — Grok keeps working and you're notified when it finishes +(`Ctrl+G` shows the tasks pane). + +*Go deeper: `/docs Plan Mode` or `/docs Permissions and Safety`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/08-make-it-yours.md b/crates/codegen/xai-grok-pager/docs/tutorial/08-make-it-yours.md new file mode 100644 index 0000000..140ecbb --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/08-make-it-yours.md @@ -0,0 +1,40 @@ +# Make It Yours + +## The easiest way: just ask + +Grok knows its own capabilities and can configure itself. Try: + +- *"add the Postgres MCP server for our staging db"* +- *"switch to a light theme"* +- *"write an AGENTS.md for this repo"* + +If you'd rather drive, everything below has a command too. + +## Teach Grok your project: AGENTS.md + +Drop an `AGENTS.md` file in your repo root with build commands, conventions, +and gotchas. Grok reads it automatically in every session — it's the single +highest-leverage customization: + +```markdown +# My Project +- Run tests with `pnpm test` +- Never edit files under generated/ +``` + +## Teach Grok your facts: memory + +Start a prompt with `#` (or use `/remember`) to save a note for future +sessions: `# the staging deploy uses eu-west`. + +## Looks, keys, and extensions + +- **`/theme`** — color themes (or `auto` to follow your OS); **`/settings`** + (or `F2`) for everything else; **`/vim-mode`** if that's your thing. +- **Skills** (`/skills`) — reusable prompt packages; user-invocable skills + become slash commands automatically. +- **MCP servers** (`/mcps`) and **plugins & hooks** (`/plugins`, `/hooks`). + +Start with `AGENTS.md` and a theme; add the rest when you need it. + +*Go deeper: `/docs Project Rules (AGENTS.md)`, `/docs Skills`, or `/docs MCP Servers`* diff --git a/crates/codegen/xai-grok-pager/docs/tutorial/09-where-next.md b/crates/codegen/xai-grok-pager/docs/tutorial/09-where-next.md new file mode 100644 index 0000000..5154feb --- /dev/null +++ b/crates/codegen/xai-grok-pager/docs/tutorial/09-where-next.md @@ -0,0 +1,29 @@ +# Where to Go Next + +You know enough to be productive. When you want more: + +## Built-in help + +- **`/help`** or **`Ctrl+P`** — every command, shortcut, and skill, searchable. +- **`/docs`** — the full How-to Guides inside the TUI (`/docs web` for the + online docs). Covers sessions, headless mode, subagents, sandboxing, + memory, and much more. +- **Ask Grok itself** — it can read its own user guide and set itself up. + Try: "How do I run you in CI?" or "add an MCP server for GitHub". + +## Good habits + +- Sessions save automatically. Resume the latest with `grok -c`, or pick + one with `/resume` (`Ctrl+S`). +- Long session getting slow? `/compact` frees context; `/context` shows + where it's going. +- Automate anything: `grok -p "summarize new TODOs" --output-format json` + runs headless — great for scripts and CI. +- Stay current with `grok update`; see what changed with `/release-notes`. +- Something feel off? `/feedback` goes straight to the team. + +## Reopen this tutorial + +Type **`/tutorial`** anytime. + +Now go build something. 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 336e17a..1696435 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 @@ -347,6 +347,16 @@ Browse the in-TUI How-to Guides, open the online Build docs, or jump straight to - `/docs web` opens https://docs.x.ai/build/overview in your browser. - `/docs ` opens a specific guide by case-insensitive title match. +### `/tutorial` + +Open the onboarding tutorial: a short list of topics (your first prompt, attaching context, navigation, slash commands, worktrees, plan mode, customization, switching from another agent tool) — each a ~30-second read, with `→` flowing straight to the next topic. Nothing auto-shows — this command (or the command palette) is the way in. + +``` +/tutorial +``` + +Aliases: `/tour`, `/onboarding` + ### `/import-claude` Open the Claude import modal to bring over `~/.claude` settings: permissions, environment variables, MCP servers, hooks, and paths. diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md b/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md index 8ed9e74..5695841 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md @@ -221,9 +221,11 @@ temperature = 0.7 # sampling temperature (0.0-2.0) top_p = 0.95 # nucleus sampling parameter max_completion_tokens = 8192 # max tokens per response context_window = 128000 # context window size (for auto-compact) +query_params = { api-version = "2026-07-22" } # query params appended to every request URL +env_http_headers = { "X-Tenant" = "TENANT_TOKEN" } # request headers from env vars, resolved at client build ``` -Credential resolution: `api_key` > `env_key` > signed-in session token > `XAI_API_KEY`. +Credential resolution: `api_key` > `env_key` > signed-in session token > `XAI_API_KEY`. See [Custom Models](11-custom-models.md#request-query-parameters) for `query_params` and `env_http_headers`, and [Sandbox Mode](18-sandbox.md#shell-environment-policy) for `[shell_environment_policy]`, which restricts the environment variables tool subprocesses inherit. To override a built-in model, use its name as the section key and set only the fields you need: @@ -513,6 +515,41 @@ otel_log_user_prompts = false # content gate (admins otel_log_tool_details = false # content gate (admins can pin via requirements) ``` +### Version pinning + +Control which versions the CLI may auto-update to and which versions may run. Set +these in `[cli]`, or in a managed layer for fleet-wide policy. Each has an +environment override that can only tighten the bound, for CI and testing. + +> **Changed:** `minimum_version` no longer blocks startup. It is now a soft +> anti-downgrade floor for the updater. For a hard floor that prevents old +> versions from starting, use `required_minimum_version`. + +```toml +[cli] +minimum_version = "0.2.109" # updater won't downgrade below this +maximum_version = "0.2.180" # updater won't install above this +required_minimum_version = "0.2.100" # refuse to start below this +required_maximum_version = "0.2.200" # refuse to start above this +``` + +- `minimum_version` (`GROK_MINIMUM_VERSION`) is a soft anti-downgrade floor. The + updater skips a target below it and keeps the current version. It never blocks + startup. +- `maximum_version` (`GROK_MAXIMUM_VERSION`) is a soft ceiling. The updater caps + its target at it and never installs above it. +- `required_minimum_version` (`GROK_REQUIRED_MINIMUM_VERSION`) and + `required_maximum_version` (`GROK_REQUIRED_MAXIMUM_VERSION`) are hard bounds. If + the running version is outside the range, the CLI exits at startup and instructs + the user to install an approved version. `grok update` and `grok --version` keep + working so an out-of-range install can recover. +- Bounds resolve across config layers by tightening only: a floor takes the + highest value and a ceiling the lowest, so a managed bound can't be loosened, + and a user or environment bound can't cancel a managed hard bound. An invalid + value is ignored so a bad policy can't block startup. +- An explicit `grok update --version X` is allowed above the ceiling, to recover + from a too-new install, and rejected below the hard floor. + ### Enterprise deployment A complete config for enterprise use: diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/11-custom-models.md b/crates/codegen/xai-grok-pager/docs/user-guide/11-custom-models.md index 0b21694..059dec7 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/11-custom-models.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/11-custom-models.md @@ -87,6 +87,8 @@ top_p = 0.95 # Nucleus sampling parameter max_completion_tokens = 8192 # Maximum tokens per response context_window = 128000 # Total context window in tokens extra_headers = { "x-api-key" = "sk-..." } # Extra request headers, sent verbatim (optional) +query_params = { api-version = "2026-07-22" } # Query params appended to every request URL (optional) +env_http_headers = { "X-Tenant" = "TENANT_TOKEN" } # Headers from env vars, resolved at client build (optional) ``` ### Credential Resolution @@ -131,6 +133,36 @@ This is a small, fixed set of environment-wide knobs. Settings that identify a s > **Note on `stream_tool_calls`:** this one affects request *shape*, not just sampling. A few endpoints (some BYOK providers) expect it left unset; if a global `stream_tool_calls = true` causes problems for such a model, opt that model out with `stream_tool_calls = false` in its `[model.<id>]` block. +### Request Query Parameters + +Some gateways route or version on the query string. `query_params` appends percent-encoded query parameters to every request Grok makes for a model. For example, a gateway that selects an API version this way: + +```toml +[model.my-gateway] +model = "my-model" +base_url = "https://gateway.example/v1" +api_backend = "responses" +env_key = "GATEWAY_API_KEY" +query_params = { api-version = "2026-07-22" } +``` + +A key that also appears in the `base_url` query string is overridden (last value wins) rather than duplicated. Query parameters are saved in the session, so do not put secrets in them: use `env_http_headers` for a secret. + +### Environment-Variable Headers + +`env_http_headers` maps a request header to the name of an environment variable that supplies its value, so a per-request secret never has to be written into `config.toml`: + +```toml +[model.gateway] +model = "my-model" +base_url = "https://gateway.example/v1" +env_http_headers = { "X-Tenant-Token" = "GATEWAY_TENANT_TOKEN" } +``` + +Grok reads each variable when it builds the client for a session and places the value in the request headers only, never on disk. A header is skipped when its variable is unset or blank, and a resolved value overrides an `extra_headers` entry of the same name. Use `extra_headers` for a static value and `env_http_headers` for one that comes from the environment. + +Both fields also work on a shared `[model_providers.<id>]` block. A model that points at a provider with `model_provider = "<id>"` inherits the provider's `query_params` and `env_http_headers` when it sets none of its own, matching how `extra_headers` is inherited. + --- ## Overriding Built-in Models 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 fbe9340..1a18e43 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 @@ -24,7 +24,7 @@ Grok processes the prompt, runs any necessary tools, and prints the result to st | `-m, --model <MODEL>` | Model to use (e.g., `grok-build`) | | `-s, --session-id <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 <ID>` | Resume an existing session (errors if not found) | +| `-r, --resume <ID_OR_TITLE>` | 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 <PATH>` | Set working directory | | `--output-format <FMT>` | Output format: `plain`, `json`, `streaming-json` | @@ -256,7 +256,7 @@ grok -p "hello" --session-id "$(uuidgen | tr '[:upper:]' '[:lower:]')" --output- ### Resume (`-r`) -The `-r/--resume` flag resumes a specific session by ID. 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 — scripts should prefer IDs). It errors if the session does not exist: ```bash # Get the session ID from a previous JSON response 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 4f4af6f..330d12c 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 @@ -82,13 +82,15 @@ To switch between, rename, or close the sessions that are currently active (the ### From the Command Line -Resume a specific session by ID: +Resume a specific session by ID or title: ```bash -grok --resume <session-id> +grok --resume <session-id-or-title> ``` -Run `grok --resume` without an ID to resume the most recent session for the current directory. +A value that is not a session ID is matched against session titles for the current directory, ignoring letter case (a simple lowercase comparison) — handy after `/rename`. If several sessions share the title, a single manually renamed session wins over auto-generated duplicates; otherwise the command errors and lists the matching IDs. UUID-shaped values are always treated as session IDs, never titles. Scripts should prefer IDs. + +Run `grok --resume` without a value to resume the most recent session for the current directory. ### From the Welcome Screen @@ -187,14 +189,14 @@ In headless mode, you manage sessions through command-line flags: # New session each time (default) grok -p "Hello" -# Resume an existing session by ID (errors if it does not exist) -grok -p "Continue where we left off" -r <session-id> +# Resume an existing session by ID or title (errors if it does not exist) +grok -p "Continue where we left off" -r <session-id-or-title> # Continue the most recent session in the current directory grok -p "What were we doing?" -c ``` -In headless mode, resume an existing session with `-r`/`--resume`, which errors if the session does not exist, or continue the most recent session in the current directory with `-c`/`--continue`. Pass the session ID from JSON output (see below) to `-r`. +In headless mode, resume an existing session with `-r`/`--resume`, which errors if the session does not exist, or continue the most recent session in the current directory with `-c`/`--continue`. A non-ID value is matched against session titles 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 pass the session ID from JSON output (see below) to `-r`. Use `-s`/`--session-id` only to **create** a new session with a **UUID** (errors if the value is not a UUID, or if that ID already has a session under the target session directory). It does **not** resume an existing session — that was the old hidden upsert behavior; use `-r`/`-c` instead. Combine `-s` with `-r`/`-c` only when also passing `--fork-session` (forks history into a new ID; optional `-s` names the child UUID). This matches Claude Code’s anti-overwrite model (client preflight under the write cwd; sequential use is reliable, concurrent same-ID is best-effort). diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/18-sandbox.md b/crates/codegen/xai-grok-pager/docs/user-guide/18-sandbox.md index e6ded98..c6b2e22 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/18-sandbox.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/18-sandbox.md @@ -45,6 +45,18 @@ To block specific files (e.g. `.env` or credential paths) on top of a profile, d **strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory and essential system paths. Writes are limited to CWD, `~/.grok/`, and temp directories. Child-process network access is blocked on Linux (no-op on macOS). +### Direct global hook write protection + +Under `workspace`, `read-only`, and `strict` (and custom profiles that extend those bases), the Grok state directory remains writable for session/runtime files, but the kernel **write-denies** the Grok-owned direct disk paths used as user-global hook sources (they stay readable): + +- `~/.grok/hooks/` (hook directory) +- `~/.grok/hooks-paths` (registry file; not loaded as hook JSON — only its absolute targets are) +- Absolute targets listed in `hooks-paths` (relative lines are ignored; missing targets refuse sandbox start) + +On first launch under these profiles, Grok creates a real empty `hooks/` directory and empty `hooks-paths` file when they are missing (never symlinks or wrong types). Claude/Cursor global settings are **not** covered by this write-deny; discovery of those vendors remains separately gated by compatibility settings. + +A symlinked `$GROK_HOME` or a `hooks-paths` entry with a symlink component is refused at sandbox start (prevents retargeting). Existing parent directories of protected paths are pinned so they cannot be renamed out from under the deny (siblings remain writable). On Linux, nested user namespaces are disabled inside bubblewrap so mount binds cannot be rearranged. Project hooks remain gated by folder trust. The `devbox` profile does not apply this protection (disposable VMs). Profiles that require it refuse to start if the kernel policy cannot be applied (including Linux without verified read-only mounts). + --- ## Custom Profiles @@ -191,6 +203,25 @@ In practice, on Linux this means: --- +## Shell Environment Policy + +The sandbox controls which files and network a subprocess can reach. The top-level `[shell_environment_policy]` table controls which environment variables it inherits, so a tool command the model runs cannot read a secret that happens to sit in your shell environment. + +```toml +[shell_environment_policy] +inherit = "core" # all (default) | core | none +ignore_default_excludes = false # also drop *KEY* / *SECRET* / *TOKEN* +exclude = ["ACME_*", "CI_*"] # drop these names +include_only = ["PATH", "HOME"] # if set, keep only these names +set = { MY_FLAG = "1" } # force these values +``` + +Grok builds the child environment in order: it starts from `inherit` (`all` keeps everything, `core` keeps a small platform set such as `PATH` and `HOME`, `none` starts empty); drops the built-in secret patterns `*KEY*`, `*SECRET*`, and `*TOKEN*` unless `ignore_default_excludes = true`; drops any `exclude` matches; applies `set`; and, when `include_only` is non-empty, keeps only the matching names. Patterns are case-insensitive globs (`*`, `?`). + +The default (`inherit = "all"`, `ignore_default_excludes = true`) leaves the environment untouched, so nothing changes until you configure a policy. On the non-persistent backend the policy also filters variables captured from your login shell, so an `.rc` file export cannot slip a secret past `exclude` or `include_only`. The persistent shell is one exception: it applies the policy to its base environment, but variables that an `.rc` file exports during login are replayed from a snapshot and are not re-filtered, so keep secrets out of shell startup files there. Enforcement covers the bash tool and terminals on macOS, Linux, and Windows. + +--- + ## Event Logging Sandbox events are logged to `~/.grok/sandbox-events.jsonl` for debugging. Events include: diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md b/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md index d675d06..f5076ea 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md @@ -23,6 +23,28 @@ that appear only as silence during capture. `/terminal-setup`, `/terminal-check`, and `/terminal-info` remain aliases for `/doctor`. +When Doctor finds an explicit unhealthy tmux setting, `/doctor fix` lists the +available automatic fixes. Apply one named fix at a time, for example +`/doctor fix tmux-clipboard` or `grok doctor fix dcs-passthrough --yes`. +Doctor can persist these three tmux options: + +- `terminal.tmux-clipboard` — `set -g set-clipboard on` +- `terminal.dcs-passthrough` — `set -wg allow-passthrough on` +- `terminal.tmux-extended-keys` — `set -g extended-keys on` + +A tmux fix edits only the persistent config on the computer hosting the affected +tmux server, including remote sessions. Plain tmux uses the real +`$HOME/.tmux.conf`; Byobu-tmux uses its effective `BYOBU_CONFIG_DIR` and refuses +to guess if that directory is unavailable or unsafe. Grok preserves the file's +line endings and mode, makes a backup when changing an existing file, and +refuses conflicting or ambiguous direct assignments. + +Grok deliberately does **not** run `tmux source-file` or change the live tmux +server. Reload with the exact command shown after apply, or detach and reattach, +then run `/doctor` again. Until reload, the live finding is expected to remain. +The conservative config scan checks direct global assignments only; review +sourced files, conditionals, plugins, and generated tmux setup yourself. + --- ## Detected Terminals 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 c602a29..2c424bb 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 @@ -115,11 +115,21 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App agent.set_sharing_enabled(v); } } + // Env overrides win over live updates too, mirroring the startup + // resolution in event_loop — otherwise the proxy's explicit `false` + // (sent for kill-switch semantics) clobbers a local test override + // moments after launch. if let Some(v) = update.privacy_notice_rollout { - app.privacy_notice_rollout = v; + app.privacy_notice_rollout = + xai_grok_config::env_bool("GROK_PRIVACY_NOTICE_ROLLOUT").unwrap_or(v); } if let Some(v) = update.privacy_banner_reshow_days { - app.privacy_banner_reshow_days = Some(v); + app.privacy_banner_reshow_days = Some( + std::env::var("GROK_PRIVACY_BANNER_RESHOW_DAYS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(v), + ); } // Tier before voice: same payload may set "API Key" and voice_mode_enabled=false. // Always recompute is_api_key_auth from the tier so a later Free/SuperGrok @@ -298,6 +308,19 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App } } + // Re-resolve dropdown tags only when the update carries the field. Some(None) = + // remote cleared (drop remote layer); Some(Some(map)) = set; outer None = field + // absent (older shell) → keep the tags resolved at startup. Env + local + // [slash_command_tags] always apply via resolve_slash_command_tags. + if let Some(remote_tags) = update.slash_command_tags.as_ref() { + use xai_grok_shell::util::config::resolve_slash_command_tags; + let effective_config = xai_grok_shell::config::load_effective_config().ok(); + let empty_toml = toml::Value::Table(Default::default()); + let tags_config = effective_config.as_ref().unwrap_or(&empty_toml); + *app.command_tags.borrow_mut() = + resolve_slash_command_tags(tags_config, remote_tags.as_ref()); + } + tracing::info!("settings updated via x.ai/settings/update"); true } @@ -490,6 +513,12 @@ pub(super) struct PagerSettingsUpdate { session_picker_grouped: Option<bool>, #[serde(default)] tips: Option<Vec<String>>, + /// Free-form per-command slash-dropdown tags (canonical name → tag). + /// Presence-aware and tolerant: omit = no update (older shell), `null` = + /// remote cleared, map = set, malformed = warn + treat as absent so a + /// bad value never fails the whole `PagerSettingsUpdate` parse. + #[serde(default, deserialize_with = "deserialize_settings_update_tags")] + slash_command_tags: Option<Option<std::collections::BTreeMap<String, String>>>, // `announcements` is deliberately NOT consumed here: every shell writer of // remote_settings also emits gen-ordered `x.ai/announcements/update` // (emit_announcements_if_changed), and a gen-less apply on this path could @@ -532,6 +561,33 @@ where Ok(Some(Option::<String>::deserialize(deserializer)?)) } +/// Presence-aware + tolerant tags map for live settings updates. +/// Only invoked when the field is present (`#[serde(default)]` covers omit). +/// - JSON null → `Some(None)` (explicit remote clear) +/// - valid object → `Some(Some(map))` +/// - malformed → warn + `Ok(None)` (leave tags alone; do not fail the struct) +fn deserialize_settings_update_tags<'de, D>( + deserializer: D, +) -> Result<Option<Option<std::collections::BTreeMap<String, String>>>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::Null => Ok(Some(None)), + v => match serde_json::from_value::<std::collections::BTreeMap<String, String>>(v) { + Ok(m) => Ok(Some(Some(m))), + Err(e) => { + tracing::warn!( + error = %e, + "malformed slash_command_tags in settings update; leaving tags unchanged" + ); + Ok(None) + } + }, + } +} + #[cfg(test)] mod presence_aware_dto_tests { use super::*; @@ -570,4 +626,61 @@ mod presence_aware_dto_tests { "string must be Some(Some(_))" ); } + + #[test] + fn slash_command_tags_dto_absent_null_map_and_malformed() { + // 1. field absent → outer None (leave tags alone) + let absent: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({ + "tips": ["hello"], + })) + .expect("absent slash_command_tags must not fail parse"); + assert_eq!(absent.slash_command_tags, None, "omit must be None"); + assert_eq!(absent.tips.as_deref(), Some(&["hello".to_string()][..])); + + // 2. explicit null → Some(None) (remote cleared) + let null_v: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({ + "slash_command_tags": null, + })) + .expect("null slash_command_tags must parse"); + assert_eq!( + null_v.slash_command_tags, + Some(None), + "explicit null must be Some(None)" + ); + + // 3. valid map → Some(Some(map)) + let map_v: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({ + "slash_command_tags": {"workflows": "new"}, + })) + .expect("valid slash_command_tags map must parse"); + let tags = map_v + .slash_command_tags + .as_ref() + .and_then(|inner| inner.as_ref()) + .expect("expected Some(Some(map))"); + assert_eq!(tags.get("workflows").map(String::as_str), Some("new")); + assert_eq!(tags.len(), 1); + + // 4. malformed must NOT fail the whole struct; sibling fields still apply + let bad: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({ + "slash_command_tags": ["oops"], + "tips": ["still-applied"], + "permission_mode": "always-approve", + })) + .expect("malformed slash_command_tags must not fail PagerSettingsUpdate parse"); + assert_eq!( + bad.slash_command_tags, None, + "malformed tags treated as absent" + ); + assert_eq!( + bad.tips.as_deref(), + Some(&["still-applied".to_string()][..]), + "sibling tips must still parse" + ); + assert_eq!( + bad.permission_mode, + Some(Some("always-approve".into())), + "sibling permission_mode must still parse" + ); + } } 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 dbe8ed1..433f98f 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 @@ -1837,6 +1837,7 @@ pub(super) fn task_completed_notif( block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }, will_wake, }, diff --git a/crates/codegen/xai-grok-pager/src/app/actions.rs b/crates/codegen/xai-grok-pager/src/app/actions.rs index 7abf365..1fb61a2 100644 --- a/crates/codegen/xai-grok-pager/src/app/actions.rs +++ b/crates/codegen/xai-grok-pager/src/app/actions.rs @@ -509,6 +509,10 @@ pub enum Action { SetHunkTrackerMode(String), /// Set default screen mode (`fullscreen` | `minimal`); restart-required. SetScreenMode(String), + /// Enable/disable the Ctrl+Space / F8 voice-dictation shortcut. SHELL-owned; + /// persisted to `[ui].voice_keybind_enabled`. Takes effect on the next + /// keypress; `/voice` is unaffected. + SetVoiceKeybindEnabled(bool), /// Set the voice capture mode (`toggle` | `hold`). SHELL-owned; persisted to /// `[ui].voice_capture_mode`. Takes effect for the next Ctrl+Space press. SetVoiceCaptureMode(String), @@ -605,6 +609,9 @@ pub enum Action { OpenCommandPalette, /// Open the in-TUI How-to Guides doc picker (`/docs`, palette "How-to Guides"). OpenHowtoGuides, + /// Open the onboarding tutorial overlay (`/tutorial` or the command + /// palette). + OpenTutorial, /// Open the reset-settings confirmation dialog for a specific key. /// Moves the Settings modal state into `ResetSettingsConfirm` so /// the underlying modal survives the confirm dialog. @@ -2838,7 +2845,6 @@ pub enum TaskResult { }, DoctorFixApplied { target: DoctorFixTarget, - shell: crate::diagnostics::ShellKind, result: Result<crate::diagnostics::FixOutcome, String>, }, } diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs index 3dda159..11ae62d 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs @@ -1712,10 +1712,7 @@ mod question_no_freeform_tests { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &bundle, false, &mut Vec::new(), diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs index ab9ec9e..7a2f93b 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs @@ -491,6 +491,16 @@ mod link_click_tests { announcements: &[xai_grok_announcements::RemoteAnnouncement], banner_height: u16, cols: u16, + ) -> Buffer { + draw_frame_privacy(agent, reg, announcements, banner_height, cols, false) + } + fn draw_frame_privacy( + agent: &mut AgentView, + reg: &ActionRegistry, + announcements: &[xai_grok_announcements::RemoteAnnouncement], + banner_height: u16, + cols: u16, + privacy_banner: bool, ) -> Buffer { let area = Rect::new(0, 0, cols, 30); let bundle = crate::app::bundle::BundleState::default(); @@ -503,10 +513,14 @@ mod link_click_tests { &mut scratch, None, false, - banner_height, - announcements, - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams { + height: banner_height, + announcements, + hidden_ids: &std::collections::BTreeSet::new(), + privacy_banner, + mouse_pos: None, + tip: None, + }, &bundle, false, &mut Vec::new(), @@ -556,6 +570,77 @@ mod link_click_tests { "click where [hide] used to be must not hide-and-persist under a dropdown" ); } + /// Privacy upsell banner: when the caller passes `privacy_banner: true`, + /// the render layer gives it the slot (even over an announcement — the + /// critical-outranks-privacy ranking lives in `AppView::draw`, which + /// never passes `true` while a critical announcement is live), arms its + /// three rects, and clicks dispatch the banner actions. Turning it off + /// clears the rects. + #[test] + fn privacy_banner_owns_slot_and_clicks_dispatch() { + let reg = ActionRegistry::defaults(); + let mut agent = make_agent(); + agent.last_terminal_size = (80, 30); + let critical = [xai_grok_announcements::RemoteAnnouncement { + severity: Some("critical".into()), + title: Some("ZZCRIT".into()), + message: Some("outage".into()), + ..Default::default() + }]; + let buf = draw_frame_privacy(&mut agent, ®, &critical, 2, 80, true); + let text: String = (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")) + .collect::<String>() + }) + .collect(); + assert!(text.contains("Help improve Grok"), "banner copy painted"); + assert!( + !text.contains("ZZCRIT"), + "critical announcement yields the slot to the privacy banner" + ); + assert!( + agent.hit_announcement_hide.rect.is_none(), + "announcement [hide] must not be clickable under the privacy banner" + ); + let rect = agent + .privacy_banner + .hit_accept + .rect + .expect("accept rect armed"); + let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), ®); + assert!(matches!( + outcome, + InputOutcome::Action(Action::PrivacyBannerAccept) + )); + let rect = agent + .privacy_banner + .hit_customize + .rect + .expect("customize rect armed"); + let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), ®); + assert!(matches!( + outcome, + InputOutcome::Action(Action::PrivacyBannerCustomize) + )); + let rect = agent + .privacy_banner + .hit_legal + .rect + .expect("legal rect armed"); + let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), ®); + assert!(matches!( + outcome, + InputOutcome::Action(Action::OpenUrl(ref url)) + if url == crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL + )); + draw_frame_privacy(&mut agent, ®, &critical, 2, 80, false); + assert!(agent.privacy_banner.hit_accept.rect.is_none()); + assert!(agent.privacy_banner.hit_customize.rect.is_none()); + assert!(agent.privacy_banner.hit_legal.rect.is_none()); + assert!(agent.hit_announcement_hide.rect.is_some()); + } /// Promo twin of the [hide] suppression test: the [label] CTA rect must /// also drop under an open dropdown so a dropdown click cannot open a URL /// from a button that is no longer on screen. @@ -2120,10 +2205,7 @@ mod link_click_tests { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &bundle, false, &mut Vec::new(), @@ -2221,10 +2303,10 @@ mod link_click_tests { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - Some("ZZSESSIONTIPZZ never shown in agent view"), + crate::app::agent_view::BannerSlotParams { + tip: Some("ZZSESSIONTIPZZ never shown in agent view"), + ..crate::app::agent_view::BannerSlotParams::none() + }, &bundle, false, &mut Vec::new(), @@ -2295,10 +2377,14 @@ mod link_click_tests { &mut scratch, None, false, - 2, - &critical, - &std::collections::BTreeSet::new(), - Some(long_tip.as_str()), + crate::app::agent_view::BannerSlotParams { + height: 2, + announcements: &critical, + hidden_ids: &std::collections::BTreeSet::new(), + privacy_banner: false, + mouse_pos: None, + tip: Some(long_tip.as_str()), + }, &bundle, false, &mut Vec::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 3d3f8f0..f62a639 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 @@ -272,6 +272,57 @@ pub struct HitArea { pub rect: Option<Rect>, pub hovered: bool, } +/// Privacy upsell banner state on the agent view: whether the banner owns +/// the banner slot this frame (`active`, set at draw start like +/// `session_banner_active`; persists until acted on, so it is a tip +/// occluder AND a tip-tick freezer) plus the three click targets. +#[derive(Debug, Default)] +pub struct PrivacyBannerState { + pub(crate) active: bool, + /// `[Accept]` (opt in; ack after ACP success). + pub(crate) hit_accept: HitArea, + /// `[Customize in settings]` (ack + open settings on coding_data_sharing). + pub(crate) hit_customize: HitArea, + /// Legal links line (opens the legal URL). + pub(crate) hit_legal: HitArea, +} +impl PrivacyBannerState { + /// Drop all click targets (slot not painted this frame). + pub fn clear_hits(&mut self) { + self.hit_accept.clear(); + self.hit_customize.clear(); + self.hit_legal.clear(); + } +} +/// Banner-slot inputs to [`AgentView::draw`]. Slot precedence is computed +/// by the caller (`AppView::draw`). +pub struct BannerSlotParams<'a> { + /// Reserved slot height (0 = no slot this frame). + pub(crate) height: u16, + pub(crate) announcements: &'a [xai_grok_announcements::RemoteAnnouncement], + pub(crate) hidden_ids: &'a std::collections::BTreeSet<String>, + /// Privacy upsell banner owns the slot (highest banner precedence + /// below critical announcements; gated by the caller). + pub(crate) privacy_banner: bool, + /// Last mouse position, for mouse-pos-driven hover styling. + pub(crate) mouse_pos: Option<(u16, u16)>, + /// Session tip, only when it owns the slot. + pub(crate) tip: Option<&'a str>, +} +impl BannerSlotParams<'static> { + /// No banner slot this frame. + pub fn none() -> Self { + static EMPTY_IDS: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); + Self { + height: 0, + announcements: &[], + hidden_ids: &EMPTY_IDS, + privacy_banner: false, + mouse_pos: None, + tip: None, + } + } +} impl HitArea { /// Update hover state for a mouse position. Returns `true` if changed. pub fn update_hover(&mut self, col: u16, row: u16) -> bool { @@ -1061,6 +1112,9 @@ pub struct AgentView { pub hit_announcement_hide: HitArea, /// `[label]` CTA button on the promo banner row (click opens its link). pub hit_announcement_cta: HitArea, + /// Privacy upsell banner state: slot ownership + click targets + /// (packaged like [`Self::plugin_cta`]). + pub privacy_banner: PrivacyBannerState, /// `[label]` upgrade CTA appended after the cwd path in the status bar /// (click opens its link; nulled under dropdowns / occluders like the /// banner CTA). @@ -2048,7 +2102,12 @@ fn resolve_action(action_id: Option<ActionId>) -> Option<InputOutcome> { ActionId::ToggleMultiline => return None, ActionId::InterjectPrompt => return None, ActionId::EnableVoiceMode => Action::EnableVoiceMode, - ActionId::VoiceToggle => Action::VoiceToggle, + ActionId::VoiceToggle => { + if !crate::app::voice_keybind_enabled() { + return None; + } + Action::VoiceToggle + } ActionId::ShortcutsHelp => return None, ActionId::OpenSettings => return None, ActionId::ToggleTodos @@ -3416,6 +3475,25 @@ mod dropdown_chrome_tests { } } #[cfg(test)] +mod voice_keybind_gate_tests { + use super::*; + /// The per-pane chord route drops `VoiceToggle` while the Voice shortcut + /// setting is off (the event-loop intercept skips the chord in that state, + /// so this route is what would otherwise leak it through). + #[test] + fn resolve_action_honors_voice_keybind_gate() { + let prev = crate::app::voice_keybind_enabled(); + crate::app::set_voice_keybind_enabled_for_test(false); + assert!(resolve_action(Some(ActionId::VoiceToggle)).is_none()); + crate::app::set_voice_keybind_enabled_for_test(true); + assert!(matches!( + resolve_action(Some(ActionId::VoiceToggle)), + Some(InputOutcome::Action(Action::VoiceToggle)) + )); + crate::app::set_voice_keybind_enabled_for_test(prev); + } +} +#[cfg(test)] mod prompt_input_mode_tests { use super::*; use crate::app::actions::Action; diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs index 584bae7..5ad390b 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs @@ -646,6 +646,7 @@ impl AgentView { }, filter_key_hint: if has_filter { Some("f") } else { None }, filter_active: filter != crate::views::extensions_modal::StatusFilter::All, + header_note: None, action_keys: &action_keys, disable_search: false, compact_bottom_bar: false, @@ -1028,6 +1029,7 @@ impl AgentView { }, filter_key_hint: if has_filter { Some("f") } else { None }, filter_active: filter != crate::views::extensions_modal::StatusFilter::All, + header_note: None, action_keys: &action_keys, disable_search: false, compact_bottom_bar: false, 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 52bb214..2c939b3 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 @@ -89,6 +89,7 @@ impl AgentView { pub(crate) fn ephemeral_tip_needs_tick(&self) -> bool { self.ephemeral_tip.is_active() && !self.session_banner_active + && !self.privacy_banner.active && (!self.ephemeral_tip.active_is_ambient() || self.ephemeral_tip_can_render()) } @@ -144,6 +145,9 @@ impl AgentView { let occluded = !self.permission_queue.is_empty() || self.question_view.is_some() || self.active_modal.is_some() + // Privacy upsell banner owns the slot until acted on — a + // session-long occluder like the session announcement banner. + || self.privacy_banner.active // Subagent fullscreen takeover: draw early-returns into // draw_subagent_fullscreen and never paints the parent banner. || self.active_subagent.is_some() diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs index 3e3ac0d..7b248d8 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs @@ -2072,10 +2072,7 @@ pub(super) mod paste_key_tests { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &bundle, false, &mut Vec::new(), 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 8f0b3e4..e094bcf 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 @@ -649,10 +649,7 @@ impl AgentView { scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + super::BannerSlotParams::none(), bundle_state, false, &mut Vec::new(), @@ -685,10 +682,7 @@ impl AgentView { scratch: &mut ScratchBuffer, pending_hint: Option<PendingHint>, overlay_focused: bool, - banner_height: u16, - banner_announcements: &[xai_grok_announcements::RemoteAnnouncement], - hidden_announcement_ids: &std::collections::BTreeSet<String>, - tip: Option<&str>, + banner: super::BannerSlotParams<'_>, bundle_state: &crate::app::bundle::BundleState, in_dashboard_overlay: bool, link_spans_out: &mut Vec<xai_ratatui_inline::LinkSpan>, @@ -704,11 +698,20 @@ impl AgentView { esc_owned_before_agent, } = app_params; self.in_dashboard_overlay = in_dashboard_overlay; + let super::BannerSlotParams { + height: banner_height, + announcements: banner_announcements, + hidden_ids: hidden_announcement_ids, + privacy_banner, + mouse_pos, + tip, + } = banner; self.session_banner_active = crate::views::announcements::first_session_announcement( banner_announcements, hidden_announcement_ids, ) .is_some(); + self.privacy_banner.active = privacy_banner; self.pinned_upgrade_cta_live = crate::views::announcements::promo_cta(banner_announcements, hidden_announcement_ids) .is_some_and(|(owner, _, _)| !crate::views::announcements::is_dismissible(owner)); @@ -756,6 +759,7 @@ impl AgentView { self.hit_announcement_hide.clear(); self.hit_announcement_cta.clear(); self.hit_upgrade_cta.clear(); + self.privacy_banner.clear_hits(); return self.draw_subagent_fullscreen( &child_sid.clone(), area, @@ -1121,6 +1125,7 @@ impl AgentView { let btw_height = crate::views::btw_overlay::btw_panel_height(self.btw_state.as_ref(), inner_width); let cta_height = match &self.plugin_cta.phase { + _ if privacy_banner => 0, CtaPhase::Hidden => 0, CtaPhase::Matched { .. } if self.prompt.text().trim().is_empty() => 0, _ => 1, @@ -2050,7 +2055,24 @@ impl AgentView { self.hit_bg_button.clear(); self.hit_plan_approval_status.clear(); } - if let Some((ref msg, remaining)) = self.mode_switch_banner { + let privacy_banner_owns_slot = privacy_banner && layout.banner.height >= 2; + if !privacy_banner_owns_slot { + self.privacy_banner.clear_hits(); + } + if privacy_banner_owns_slot { + self.hit_announcement_hide.clear(); + self.hit_announcement_cta.clear(); + let rects = crate::views::privacy_banner::render(layout.banner, buf, &theme, mouse_pos); + self.privacy_banner + .hit_accept + .set_unless_dropdown(Some(rects.accept), dropdown_open); + self.privacy_banner + .hit_customize + .set_unless_dropdown(Some(rects.customize), dropdown_open); + self.privacy_banner + .hit_legal + .set_unless_dropdown(Some(rects.legal), dropdown_open); + } else if let Some((ref msg, remaining)) = self.mode_switch_banner { self.hit_announcement_hide.clear(); self.hit_announcement_cta.clear(); if layout.banner.height > 0 && layout.banner.width > 4 { @@ -4364,10 +4386,7 @@ mod voice_recording_overlay_tests { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), @@ -4431,10 +4450,7 @@ mod overlay_post_flush_tests { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), 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 990595d..82a2218 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 @@ -198,6 +198,7 @@ impl AgentView { hit_cancel_button: Default::default(), hit_announcement_hide: Default::default(), hit_announcement_cta: Default::default(), + privacy_banner: Default::default(), hit_upgrade_cta: Default::default(), hit_voice_stop_button: Default::default(), hit_scrollbar: Default::default(), 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 a260bb0..622e443 100644 --- a/crates/codegen/xai-grok-pager/src/app/app_view.rs +++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs @@ -770,6 +770,13 @@ pub struct AppView { /// [`PromptWidget::adopt_slash_mru`] so command recency is shared across /// surfaces (single-threaded UI; no process-global singleton). pub(crate) slash_mru: std::rc::Rc<std::cell::RefCell<crate::slash::mru::SlashMru>>, + /// The single resolved per-command tag map (canonical name → free-form tag). + /// Owned here and injected into every agent prompt and the dashboard dispatch + /// via [`PromptWidget::adopt_command_tags`] so slash-dropdown tags are shared + /// across surfaces. Populated from remote settings + local config; updated + /// in place so adopters see refreshes without re-adopting. + pub(crate) command_tags: + std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>, /// Whether the welcome screen prompt is currently capturing focus (user typed in it). /// When true, menu shortcuts like n/w/q are disabled and Escape unfocuses the prompt. pub welcome_prompt_focused: bool, @@ -989,6 +996,11 @@ pub struct AppView { pub fork_worktree_mode: WorktreeMode, /// Restore code state on resume (`--restore-code`). pub restore_code: Option<bool>, + /// Startup resume target that missed local id/title resolution and was + /// deferred to the worktree resume handler (set from materialization). + /// Worktree failure messages append the no-match hint only for this + /// exact target. + pub resume_local_miss: Option<String>, pub agent_override: Option<serde_json::Value>, /// ACP-advertised commands seeded into every new `AgentSession` so /// autocomplete has shell builtins and skills before any runtime @@ -1115,6 +1127,10 @@ pub struct AppView { /// Whether the pager uses fullscreen (alt-screen) or inline mode. /// Set from the resolved terminal state at startup. pub(crate) screen_mode: super::ScreenMode, + /// Onboarding tutorial overlay, if open. Top-level (not per-agent) so it + /// works over both the welcome screen and an agent session. Opened by + /// `/tutorial` (also in the command palette). + pub tutorial: Option<crate::views::tutorial::TutorialState>, /// Agent Dashboard state. `Some(_)` only when the dashboard view /// is active (`active_view == AgentDashboard`) or recently closed. /// Held outside the `ActiveView` discriminant because `DashboardState` @@ -1339,8 +1355,11 @@ impl AppView { ) -> Self { let slash_mru = std::rc::Rc::new(std::cell::RefCell::new(crate::slash::mru::SlashMru::new())); + let command_tags = + std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())); let mut welcome_prompt = PromptWidget::new(); welcome_prompt.adopt_slash_mru(slash_mru.clone()); + welcome_prompt.adopt_command_tags(command_tags.clone()); Self { active_view: ActiveView::Welcome, auth_return_view: None, @@ -1381,6 +1400,7 @@ impl AppView { tip: None, welcome_prompt, slash_mru, + command_tags, welcome_prompt_focused: true, welcome_tip_typing_dismissed: false, pending_effects: Vec::new(), @@ -1457,6 +1477,7 @@ impl AppView { new_session_worktree_mode: WorktreeMode::Never, fork_worktree_mode: WorktreeMode::Ask, restore_code: None, + resume_local_miss: None, agent_override: None, bootstrap_acp_commands, auth_methods: Vec::new(), @@ -1525,6 +1546,7 @@ impl AppView { session_picker_grouped: false, cancel_rewind_enabled: true, session_recap_available: false, + tutorial: None, dashboard: None, dashboard_return: None, dashboard_persisted: None, @@ -2341,6 +2363,17 @@ impl AppView { ); if is_mouse_action {} } + if let Some(tutorial) = self.tutorial.as_mut() + && matches!(ev, Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) + { + match crate::views::tutorial::handle_tutorial_input(ev, tutorial) { + crate::views::tutorial::TutorialOutcome::Closed => { + self.tutorial = None; + } + crate::views::tutorial::TutorialOutcome::Consumed => {} + } + return InputOutcome::Changed; + } let zdr_blocked = self.is_zdr_blocked(); let has_access = self.has_access(); let welcome_pinned_upgrade_cta = crate::views::announcements::promo_cta( @@ -2349,6 +2382,12 @@ impl AppView { ) .is_some_and(|(owner, _, _)| !crate::views::announcements::is_dismissible(owner)); let has_foreign_resume = self.foreign_resume_hint().is_some(); + let sp_loading = crate::views::session_picker::loading_spinner_active( + self.session_picker_entries.as_deref(), + self.session_picker_source_filter, + self.session_picker_loading, + &self.session_picker_lanes, + ); let outcome = match self.active_view { ActiveView::Welcome => handle_welcome_input( ev, @@ -2398,6 +2437,7 @@ impl AppView { has_access, is_zdr_blocked: zdr_blocked, sp_entries: &mut self.session_picker_entries, + sp_loading, sp_state: &mut self.session_picker_state, sp_content_results: &self.session_picker_content_results, sp_content_loading: self.session_picker_content_loading, @@ -2867,7 +2907,12 @@ impl AppView { git_ref: None, }, ActionId::OpenDashboard => Action::OpenDashboard, - ActionId::VoiceToggle => Action::VoiceToggle, + ActionId::VoiceToggle => { + if !self.current_ui.voice_keybind_enabled.unwrap_or(true) { + return InputOutcome::Unchanged; + } + Action::VoiceToggle + } _ => return InputOutcome::Unchanged, }; if def.requires_confirmation { @@ -2990,6 +3035,9 @@ struct WelcomeInputCtx<'a> { has_access: bool, is_zdr_blocked: bool, sp_entries: &'a mut Option<Vec<SessionPickerEntry>>, + /// Mirrors the render's `session_picker_loading` param: the spinner-only + /// picker still owns input (Esc must dismiss it, not hit the hidden menu). + sp_loading: bool, sp_state: &'a mut crate::views::picker::PickerState, sp_content_results: &'a Option<Vec<xai_grok_shell::extensions::session_search::SearchSessionHit>>, @@ -3010,8 +3058,8 @@ struct WelcomeInputCtx<'a> { cwd_has_git_ancestor: bool, session_picker_grouped: bool, sp_source_filter: &'a mut crate::views::session_picker::SourceFilter, - /// Process-wide `--chat`: the session picker hides its Local/Remote - /// source filter (conversations-only list), so `f` must not cycle it. + /// Process-wide `--chat`: the session picker hides its source filter + /// (conversations-only list), so `f` must not cycle it. chat_mode: bool, } /// Welcome view input -- auth-state-aware routing. @@ -3153,7 +3201,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco } return InputOutcome::Unchanged; } - if ctx.sp_entries.is_some() && matches!(ctx.auth_state, AuthState::Done) { + if (ctx.sp_entries.is_some() || ctx.sp_loading) && matches!(ctx.auth_state, AuthState::Done) { use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input}; let source_filter = *ctx.sp_source_filter; let current_repo = @@ -3187,6 +3235,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco filter_label: (!ctx.chat_mode).then(|| source_filter.label()), filter_key_hint: (!ctx.chat_mode).then_some("f"), filter_active: !ctx.chat_mode && source_filter.is_active(), + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -3612,7 +3661,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco if let Some(rect) = ctx.privacy_banner_legal_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { - return InputOutcome::Action(Action::OpenUrl("https://x.ai/legal".to_string())); + return InputOutcome::Action(Action::OpenUrl( + crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(), + )); } if let Some(rect) = ctx.changelog_cta_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) @@ -4080,6 +4131,12 @@ impl AppView { let dev_fps_rows = self.dev_fps_rows(); let fps_overlay = self.fps_hud.overlay(dev_fps_rows); let foreign_resume_hint = self.foreign_resume_hint().cloned(); + let privacy_banner_agent = self.privacy_banner_should_show() + && !crate::views::announcements::has_critical_session_announcement( + &self.active_announcements, + &self.hidden_announcement_ids, + ); + let agent_mouse_pos = self.last_mouse_pos; let Self { active_view, agents, @@ -4190,9 +4247,13 @@ impl AppView { mouse_pos: self.last_mouse_pos, is_zdr_blocked: zdr_blocked_for_draw, session_picker: self.session_picker_entries.as_deref(), - session_picker_loading: self.session_picker_entries.is_none() - && (self.session_picker_loading - || self.session_picker_lanes.foreign_loading), + session_picker_loading: + crate::views::session_picker::loading_spinner_active( + self.session_picker_entries.as_deref(), + self.session_picker_source_filter, + self.session_picker_loading, + &self.session_picker_lanes, + ), compact, pending_hint, startup_warnings: &self.startup_warnings, @@ -4300,6 +4361,14 @@ impl AppView { }, ); } + if let Some(tutorial) = self.tutorial.as_mut() { + crate::views::tutorial::render_tutorial( + f.buffer_mut(), + view_area, + tutorial, + compact, + ); + } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } @@ -4307,7 +4376,7 @@ impl AppView { panel.render(full_area, f.buffer_mut()); } let has_cloud_modal = false; - let cursor = if has_cloud_modal { + let cursor = if has_cloud_modal || self.tutorial.is_some() { None } else { result.cursor_pos @@ -4414,9 +4483,13 @@ impl AppView { &self.active_announcements, &self.hidden_announcement_ids, ); - let show_session_tip = self.tip.is_some() && agent.should_show_tip(); + let privacy_banner = privacy_banner_agent; + let show_session_tip = + !privacy_banner && self.tip.is_some() && agent.should_show_tip(); let has_mode_banner = agent.mode_switch_banner.is_some(); - let banner_height = if has_mode_banner { + let banner_height = if privacy_banner { + 2 + } else if has_mode_banner { 1 } else if announcement_banner_h > 0 { announcement_banner_h @@ -4432,13 +4505,17 @@ impl AppView { scratch, pending_hint, overlay_focused, - banner_height, - &self.active_announcements, - &self.hidden_announcement_ids, - if show_session_tip { - self.tip.as_deref() - } else { - None + crate::app::agent_view::BannerSlotParams { + height: banner_height, + announcements: &self.active_announcements, + hidden_ids: &self.hidden_announcement_ids, + privacy_banner, + mouse_pos: agent_mouse_pos, + tip: if show_session_tip { + self.tip.as_deref() + } else { + None + }, }, &self.bundle_state, overlay_active, @@ -4460,6 +4537,14 @@ impl AppView { compact, ); } + if let Some(tutorial) = self.tutorial.as_mut() { + crate::views::tutorial::render_tutorial( + f.buffer_mut(), + view_area, + tutorial, + compact, + ); + } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } @@ -4468,10 +4553,17 @@ impl AppView { } let (cursor_pos, post_flush) = result; let has_cloud = false; - if has_cloud || self.import_claude_modal.is_some() { + if has_cloud + || self.import_claude_modal.is_some() + || self.tutorial.is_some() + { link_spans.clear(); } - let cursor = if has_cloud { None } else { cursor_pos }; + let cursor = if has_cloud || self.tutorial.is_some() { + None + } else { + cursor_pos + }; return (cursor, Self::merge_escapes(notif_escapes, post_flush)); } } @@ -4537,24 +4629,22 @@ impl AppView { |inner, buf| { if let Some(agent) = agents.get_mut(&agent_id) { agent.draw( - inner, - buf, - registry, - scratch, - None, - false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, - bundle_state, - false, - link_spans, - AppRenderParams { - esc_owned_before_agent, - ..Default::default() - }, - ) + inner, + buf, + registry, + scratch, + None, + false, + crate::app::agent_view::BannerSlotParams::none( + ), + bundle_state, + false, + link_spans, + AppRenderParams { + esc_owned_before_agent, + ..Default::default() + }, + ) } else { (None, None) } @@ -4568,13 +4658,24 @@ impl AppView { Self::dashboard_stale_image_clears(agents, drawn_popup_agent); let popup_post_flush = Self::merge_post_flush(stale_clears, popup_post_flush); + let tutorial_open = self.tutorial.is_some(); + if let Some(tutorial) = self.tutorial.as_mut() { + crate::views::tutorial::render_tutorial( + f.buffer_mut(), + view_area, + tutorial, + compact, + ); + } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } if let Some(panel) = &scroll_debug_panel { panel.render(full_area, f.buffer_mut()); } - let cursor = if dashboard.attached_agent.is_some() { + let cursor = if tutorial_open { + None + } else if dashboard.attached_agent.is_some() { popup_cursor } else { dash_cursor @@ -4704,6 +4805,7 @@ impl AppView { || self.import_claude_modal.is_some() || self.new_worktree_dialog.is_some() || self.welcome_doc_viewer.is_some() + || self.tutorial.is_some() || matches!(self.active_view, ActiveView::AgentDashboard if self.dashboard.as_ref().is_some_and(|d| d.shortcuts_modal.is_some())) || cloud_modal_open @@ -4900,7 +5002,14 @@ impl AppView { } needs_redraw = true; } - if self.session_picker_content_loading { + if self.session_picker_content_loading + || crate::views::session_picker::loading_spinner_active( + self.session_picker_entries.as_deref(), + self.session_picker_source_filter, + self.session_picker_loading, + &self.session_picker_lanes, + ) + { needs_redraw = true; } else { let frame = crate::views::welcome::shimmer_frame(); @@ -4967,6 +5076,21 @@ impl AppView { agent.btw_state, Some(crate::views::btw_overlay::BtwOverlayState::Loading { .. }) ) && spinner_frame_tick; + needs_redraw |= matches!( + agent.active_modal.as_ref(), + Some(crate::views::modal::ActiveModal::SessionPicker { + entries, + loading, + lanes, + source_filter, + .. + }) if crate::views::session_picker::loading_spinner_active( + entries.as_deref(), + *source_filter, + *loading, + lanes, + ) + ) && spinner_frame_tick; needs_redraw |= agent.drain_blocked(); agent.prompt.slash_controller.set_workflows_available( agent @@ -5277,6 +5401,21 @@ impl AppView { || agent.video_load_rx.is_some() || agent.mermaid_needs_tick() || !agent.permission_queue.is_empty() + || matches!( + agent.active_modal.as_ref(), + Some(crate::views::modal::ActiveModal::SessionPicker { + entries, + loading, + lanes, + source_filter, + .. + }) if crate::views::session_picker::loading_spinner_active( + entries.as_deref(), + *source_filter, + *loading, + lanes, + ) + ) || agent.subagent_views.iter().any(|(sid, child)| { child.toast.is_some() || child.ephemeral_tip_needs_tick() @@ -5518,6 +5657,7 @@ pub(crate) mod tests { new_session_worktree_mode: WorktreeMode::Never, fork_worktree_mode: WorktreeMode::Ask, restore_code: None, + resume_local_miss: None, agent_override: None, bootstrap_acp_commands: Vec::new(), auth_methods: Vec::new(), @@ -5563,6 +5703,9 @@ pub(crate) mod tests { slash_mru: std::rc::Rc::new(std::cell::RefCell::new( crate::slash::mru::SlashMru::new_in_memory(), )), + command_tags: std::rc::Rc::new(std::cell::RefCell::new( + std::collections::HashMap::new(), + )), welcome_prompt_focused: false, welcome_tip_typing_dismissed: false, welcome_menu_index: None, @@ -5644,6 +5787,7 @@ pub(crate) mod tests { session_picker_grouped: false, cancel_rewind_enabled: true, session_recap_available: false, + tutorial: None, dashboard: None, dashboard_return: None, dashboard_persisted: None, @@ -6055,6 +6199,72 @@ pub(crate) mod tests { app.session_picker_content_loading = true; assert_eq!(app.tick_demand(), TickDemand::Fast); } + /// An open modal session picker that is still fetching keeps fast ticks + /// alive on an otherwise-idle agent (its loading spinner must animate) — + /// including after the fast foreign scan lands rows the default Grok + /// filter hides; once the native list settles the demand parks again. + #[test] + fn tick_demand_fast_while_modal_session_picker_loads() { + let mut app = test_app_with_agent(); + let id = super::super::agent::AgentId(0); + assert_eq!(app.tick_demand(), TickDemand::None, "idle agent parks"); + app.agents.get_mut(&id).unwrap().active_modal = + Some(crate::views::modal::ActiveModal::SessionPicker { + state: crate::views::picker::PickerState::default(), + entries: None, + loading: true, + lanes: Default::default(), + previous_palette: None, + window: crate::views::modal_window::ModalWindowState::new(), + content_results: None, + content_loading: false, + deep_search_seq: 0, + entries_query: None, + source_filter: crate::views::session_picker::SourceFilter::default(), + pending_delete: None, + }); + assert_eq!( + app.tick_demand(), + TickDemand::Fast, + "loading modal picker must keep the spinner animating" + ); + let foreign_entry = SessionPickerEntry { + id: "claude-1".into(), + summary: "claude".into(), + updated_at: chrono::Utc::now(), + created_at: chrono::Utc::now(), + cwd: String::new(), + hostname: None, + source: "claude".into(), + model_id: None, + num_messages: 0, + last_active_at: None, + branch: None, + repo_name: "r".into(), + worktree_label: None, + card_detail: None, + }; + if let Some(crate::views::modal::ActiveModal::SessionPicker { entries, .. }) = + app.agents.get_mut(&id).unwrap().active_modal.as_mut() + { + *entries = Some(vec![foreign_entry]); + } + assert_eq!( + app.tick_demand(), + TickDemand::Fast, + "foreign rows hidden by the Grok filter must not end the loading spinner" + ); + if let Some(crate::views::modal::ActiveModal::SessionPicker { loading, .. }) = + app.agents.get_mut(&id).unwrap().active_modal.as_mut() + { + *loading = false; + } + assert_eq!( + app.tick_demand(), + TickDemand::None, + "settled picker must not keep demanding ticks" + ); + } /// An idle agent view demands no ticks at all; the macOS Cmd link-hover /// poll (when it is the only pending work) demands Slow, never Fast. #[test] @@ -9293,10 +9503,7 @@ pub(crate) mod tests { &mut crate::scrollback::render::ScratchBuffer::new(), None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), @@ -9342,10 +9549,7 @@ pub(crate) mod tests { &mut crate::scrollback::render::ScratchBuffer::new(), None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), @@ -9395,10 +9599,7 @@ pub(crate) mod tests { &mut crate::scrollback::render::ScratchBuffer::new(), None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), @@ -9820,6 +10021,46 @@ pub(crate) mod tests { assert!(scroll > 0, "wheel must advance doc scroll, got {scroll}"); } #[test] + fn tutorial_is_scroll_blocking_and_wheel_scrolls_topic() { + let mut app = test_app(); + app.active_view = ActiveView::Welcome; + let mut tut = crate::views::tutorial::TutorialState::new(); + let _ = crate::views::tutorial::handle_tutorial_input( + &Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut tut, + ); + app.tutorial = Some(tut); + assert!( + app.is_scroll_blocking_modal_open(), + "tutorial overlay must block background scroll", + ); + let outcome = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 40, 12)); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!( + app.last_scroll_pos.is_none(), + "wheel must not reach the background scroll path while the tutorial is open", + ); + let tut = app.tutorial.as_ref().expect("tutorial stays open"); + assert!( + tut.scroll > 0, + "wheel must advance topic scroll, got {}", + tut.scroll + ); + } + #[test] + fn tutorial_esc_on_list_closes_overlay() { + let mut app = test_app(); + app.active_view = ActiveView::Welcome; + app.tutorial = Some(crate::views::tutorial::TutorialState::new()); + let outcome = + app.handle_input(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!( + app.tutorial.is_none(), + "Esc on the list closes the tutorial" + ); + } + #[test] fn dashboard_shortcuts_modal_is_scroll_blocking() { let mut app = test_app(); app.active_view = ActiveView::AgentDashboard; @@ -9897,6 +10138,22 @@ pub(crate) mod tests { "Ctrl+Space on the dashboard must route to VoiceToggle, got {outcome:?}" ); } + /// With `[ui].voice_keybind_enabled = false` the global fallthrough must + /// swallow the chord — otherwise Ctrl+Space would still start dictation via + /// the registry route whenever the event-loop intercept skips it. + #[test] + fn ctrl_space_on_dashboard_ignored_when_keybind_disabled() { + let mut app = test_app(); + pin_non_vscode_registry(&mut app); + app.active_view = ActiveView::AgentDashboard; + app.dashboard = Some(crate::views::dashboard::DashboardState::new()); + app.current_ui.voice_keybind_enabled = Some(false); + let outcome = app.handle_input(&key_event(KeyCode::Char(' '), KeyModifiers::CONTROL)); + assert!( + !matches!(outcome, InputOutcome::Action(Action::VoiceToggle)), + "Ctrl+Space must be inert with the voice shortcut disabled, got {outcome:?}" + ); + } /// Esc while voice is recording on the dashboard must STOP voice (route to /// `VoiceToggle`) rather than fall into the dashboard's Esc cascade /// (clear filter / unfocus / deselect / exit). @@ -11361,7 +11618,7 @@ pub(crate) mod tests { let _ = app.handle_input(&f_key); assert_eq!( app.session_picker_source_filter, - crate::views::session_picker::SourceFilter::All, + crate::views::session_picker::SourceFilter::Grok, "f must not cycle the hidden source filter under chat mode" ); assert_eq!( diff --git a/crates/codegen/xai-grok-pager/src/app/cli.rs b/crates/codegen/xai-grok-pager/src/app/cli.rs index e34d0c9..b239348 100644 --- a/crates/codegen/xai-grok-pager/src/app/cli.rs +++ b/crates/codegen/xai-grok-pager/src/app/cli.rs @@ -538,11 +538,14 @@ pub struct PagerArgs { value_name = "PROMPT" )] pub system_prompt_override: Option<String>, - /// Resume a session by ID, or the most recent if omitted. + /// Resume a session by ID or title, or the most recent if omitted. + /// Non-ID values match session titles for the current directory + /// (ignoring letter case; a sole renamed match wins among duplicates, + /// otherwise ambiguity errors; UUID-shaped values always mean IDs). #[arg( long = "resume", short = 'r', - value_name = "SESSION_ID", + value_name = "SESSION_ID_OR_TITLE", num_args = 0..= 1, default_missing_value = "", conflicts_with_all = ["continue_last_session"] @@ -556,6 +559,17 @@ pub struct PagerArgs { conflicts_with_all = ["continue_last_session"] )] pub load_session: Option<String>, + /// Set by [`Self::pin_local_resume_target`]: the resume target was + /// resolved (or definitively missed) before the OS sandbox, so + /// materialization must not re-run local title selection. + #[clap(skip)] + pub resume_target_pinned: bool, + /// Sandbox profile of the title-pinned session, captured at pin time from + /// the selected summary (outer `None` = no title pin happened). The + /// id-based peek cannot re-derive it: a legacy id duplicated across cwd + /// dirs makes that lookup ambiguous. + #[clap(skip)] + pub(crate) pinned_resume_profile: Option<Option<String>>, /// Continue the most recent session for the current working directory. #[arg( short = 'c', @@ -858,13 +872,69 @@ impl PagerArgs { let explicit = self.sandbox.as_deref().filter(|s| !s.is_empty()); Self::resolve_startup_sandbox(explicit, saved.map(String::from)) } + /// Pin an explicit non-UUID, non-chat resume/load target to its canonical + /// local session id, before the (irreversible) OS sandbox is applied. + /// + /// Resolving once — recorded via `resume_target_pinned` so materialization + /// never re-runs local title selection — makes the saved-profile peek and + /// materialization consume the same immutable target; re-selecting after + /// the sandbox would race a concurrent rename/create. Listing failures + /// and ambiguity are hard errors here (fail closed / surfaced before the + /// sandbox); a definitive no-match keeps the raw arg for the legacy + /// remote/worktree id path. + pub fn pin_local_resume_target(&mut self) -> anyhow::Result<()> { + let cwd_buf = std::env::current_dir().ok(); + let cwd_str = cwd_buf.as_deref().map(|p| p.to_string_lossy()); + self.pin_local_resume_target_for_cwd(cwd_str.as_deref()) + } + /// Same as [`Self::pin_local_resume_target`] with an explicit cwd, so + /// tests never mutate the process cwd. + pub fn pin_local_resume_target_for_cwd(&mut self, cwd: Option<&str>) -> anyhow::Result<()> { + if self.chat() { + return Ok(()); + } + let Some(target) = self.session_to_resume().map(str::to_owned) else { + return Ok(()); + }; + use crate::app::session_title_resolve::{PinnedResumeTarget, presandbox_resume_target}; + let pinned = presandbox_resume_target(&target, cwd)?; + self.resume_target_pinned = true; + if let PinnedResumeTarget::Title { + ref id, + ref sandbox_profile, + } = pinned + { + eprintln!("Resuming session {} (matched by title)", id); + self.pinned_resume_profile = Some(sandbox_profile.clone()); + } + let Some(id) = pinned.id() else { + return Ok(()); + }; + if self + .resume_session + .as_deref() + .is_some_and(|s| !s.is_empty()) + { + self.resume_session = Some(id); + } else if self.load_session.as_deref().is_some_and(|s| !s.is_empty()) { + self.load_session = Some(id); + } + Ok(()) + } /// The sandbox profile persisted with the session being resumed, if any. /// Local, best-effort; `None` when not resuming or nothing is found. Read once /// for the profile resume resolution. pub fn saved_resume_profile(&self) -> Option<String> { let cwd_buf = std::env::current_dir().ok(); let cwd_str = cwd_buf.as_deref().map(|p| p.to_string_lossy()); - let cwd = cwd_str.as_deref(); + self.saved_resume_profile_for_cwd(cwd_str.as_deref()) + } + /// Same as [`Self::saved_resume_profile`] with an explicit cwd, so tests + /// never mutate the process cwd. + pub fn saved_resume_profile_for_cwd(&self, cwd: Option<&str>) -> Option<String> { + if let Some(pinned) = &self.pinned_resume_profile { + return pinned.clone(); + } match self.resume_target() { ResumeTarget::SessionId(id) => { xai_grok_shell::session::persistence::resumed_session_sandbox_profile( @@ -952,18 +1022,24 @@ mod tests { command: None, })) )); - let fix = - PagerArgs::try_parse_from(["grok", "doctor", "fix", "terminal.ssh-wrap", "--yes"]) + for id in [ + "terminal.ssh-wrap", + "tmux-clipboard", + "terminal.dcs-passthrough", + "tmux-extended-keys", + ] { + let fix = PagerArgs::try_parse_from(["grok", "doctor", "fix", id, "--yes"]) .expect("doctor fix parses"); - assert!(matches!( - fix.command, - Some(Command::Doctor(crate::doctor_cmd::DoctorArgs { - json: false, - command: Some(crate::doctor_cmd::DoctorCommand::Fix( - crate::doctor_cmd::FixArgs { ref id, yes: true } - )), - })) if id.as_deref() == Some("terminal.ssh-wrap") - )); + assert!(matches!( + fix.command, + Some(Command::Doctor(crate::doctor_cmd::DoctorArgs { + json: false, + command: Some(crate::doctor_cmd::DoctorCommand::Fix( + crate::doctor_cmd::FixArgs { id: Some(ref parsed), yes: true } + )), + })) if parsed == id + )); + } let list = PagerArgs::try_parse_from(["grok", "doctor", "fix"]) .expect("doctor fix without an ID lists applicable fixes"); assert!(matches!( 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 d00eb59..fe9c2cd 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs @@ -50,6 +50,7 @@ pub(super) fn ensure_dashboard_state(app: &mut AppView) { let mut state = dashboard_state_from_persisted(app); state.gc_stale_refs(&dashboard_alive_fn(&app.agents)); state.adopt_slash_mru(app.slash_mru.clone()); + state.adopt_command_tags(app.command_tags.clone()); state.set_screen_mode(app.screen_mode); state.set_recap_visible(app.session_recap_available); state.set_voice_visible(app.voice_mode_enabled); 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 8d2ed04..6ffcf62 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs @@ -55,12 +55,14 @@ pub(crate) fn dispatch_initial_prompt(app: &mut AppView, prompt: String) -> Vec< effects } -pub(super) fn collect_live_doctor_report( +pub(super) fn collect_live_doctor_report_for_terminal( app: &AppView, agent_id: AgentId, + terminal: &crate::terminal::TerminalContext, ) -> Option<crate::diagnostics::DiagnosticReport> { let agent = app.agents.get(&agent_id)?; - let mut report = crate::slash::commands::doctor::DoctorCommand::report( + let mut report = crate::slash::commands::doctor::DoctorCommand::report_for_terminal( + terminal, app.screen_mode, crate::diagnostics::TuiRuntimeRequest { workspace: &agent.session.cwd, @@ -88,7 +90,8 @@ pub(super) fn dispatch_doctor(request: DoctorRequest, app: &mut AppView) -> Vec< let ActiveView::Agent(agent_id) = app.active_view else { return vec![]; }; - let Some(report) = collect_live_doctor_report(app, agent_id) else { + let terminal = crate::terminal::terminal_context().clone(); + let Some(report) = collect_live_doctor_report_for_terminal(app, agent_id, &terminal) else { return vec![]; }; @@ -108,7 +111,7 @@ pub(super) fn dispatch_doctor(request: DoctorRequest, app: &mut AppView) -> Vec< return vec![Effect::PlanDoctorFix { target, report: Box::new(report), - terminal: crate::terminal::terminal_context().clone(), + terminal, request, }]; } @@ -136,9 +139,6 @@ pub(super) fn open_doctor_fix_question( return; } let preview = crate::diagnostics::format_fix_preview(&plan); - agent - .scrollback - .push_block(RenderBlock::system(preview.clone())); let question = Question { question: "Apply this fix?".to_owned(), options: vec![ @@ -150,7 +150,7 @@ pub(super) fn open_doctor_fix_question( }, QuestionOption { label: "Cancel".to_owned(), - description: "Do not change your shell configuration.".to_owned(), + description: "Do not change the configuration.".to_owned(), preview: None, id: None, }, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs index 00e850a..cfa1e56 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs @@ -85,7 +85,8 @@ use super::settings::setters::{ set_prompt_suggestions, set_remember_tool_approvals, set_render_mermaid, set_respect_manual_folds, set_screen_mode, set_scroll_lines, set_scroll_mode, set_scroll_speed, set_show_thinking_blocks, set_show_tips, set_simple_mode, set_theme, set_timeline, - set_timestamps, set_vim_mode, set_voice_capture_mode, set_voice_stt_language, + set_timestamps, set_vim_mode, set_voice_capture_mode, set_voice_keybind_enabled, + set_voice_stt_language, }; use super::settings::ui::{ dispatch_confirm_reset_setting, dispatch_open_command_palette, dispatch_open_howto_guides, @@ -94,7 +95,7 @@ use super::settings::ui::{ dispatch_toggle_vim_mode, }; use super::status::{ - dispatch_copy_session_id, dispatch_manage_billing, dispatch_open_gboom, + dispatch_copy_session_id, dispatch_manage_billing, dispatch_open_gboom, dispatch_open_tutorial, dispatch_privacy_banner_accept, dispatch_privacy_banner_customize, dispatch_share_session, dispatch_show_context_info, dispatch_show_privacy_info, dispatch_show_queue, dispatch_show_release_notes, dispatch_show_session_info, dispatch_show_tasks, @@ -932,6 +933,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> { Action::ShowReleaseNotes { title, content } => { dispatch_show_release_notes(app, title, content) } + Action::OpenTutorial => dispatch_open_tutorial(app), Action::RenameSession { title } => dispatch_rename_session(app, title), Action::ShowContextInfo => dispatch_show_context_info(app), Action::ShowUsage => dispatch_show_usage(app), @@ -972,6 +974,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> { Action::SetDefaultSelectedPermission(s) => set_default_selected_permission(app, s), Action::SetHunkTrackerMode(s) => set_hunk_tracker_mode(app, s), Action::SetScreenMode(s) => set_screen_mode(app, s), + Action::SetVoiceKeybindEnabled(v) => set_voice_keybind_enabled(app, v), Action::SetVoiceCaptureMode(s) => set_voice_capture_mode(app, s), Action::SetVoiceSttLanguage(s) => set_voice_stt_language(app, s), Action::ToggleTimestamps => dispatch_toggle_timestamps(app), @@ -1172,7 +1175,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> { .scrollback .push_block(crate::scrollback::block::RenderBlock::system(format!( "Applying {}…", - plan.id + plan.id() ))); } vec![Effect::ApplyDoctorFix { target, plan }] 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 efe13ee..37f721f 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 @@ -204,6 +204,7 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved( .expect("just-inserted agent missing"); agent.prompt.set_compact(app.appearance.prompt.compact); agent.prompt.adopt_slash_mru(app.slash_mru.clone()); + agent.prompt.adopt_command_tags(app.command_tags.clone()); agent .prompt .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode); @@ -241,6 +242,12 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved( .push_block(RenderBlock::system(parent_marker)); } switch_to_agent(app, new_id, SwitchCause::Fork); + if let Some(d) = app.dashboard.as_mut() + && d.attached_agent == Some(parent_id) + { + d.attached_agent = Some(new_id); + d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id)); + } if worktree { vec![Effect::CreateWorktreeSession { agent_id: new_id, 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 acc89b3..599900a 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 @@ -331,6 +331,7 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id( let agent = app.agents.get_mut(&agent_id).unwrap(); agent.prompt.set_compact(app.appearance.prompt.compact); agent.prompt.adopt_slash_mru(app.slash_mru.clone()); + agent.prompt.adopt_command_tags(app.command_tags.clone()); agent .prompt .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode); @@ -671,6 +672,7 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session( let agent = app.agents.get_mut(&agent_id).unwrap(); agent.prompt.set_compact(app.appearance.prompt.compact); agent.prompt.adopt_slash_mru(app.slash_mru.clone()); + agent.prompt.adopt_command_tags(app.command_tags.clone()); agent .prompt .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode); 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 6805290..45de1da 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 @@ -187,6 +187,9 @@ fn dispatch_load_session_ungated( agent_mut.loading_placeholder_id = Some(loading_placeholder_id); agent_mut.prompt.set_compact(app.appearance.prompt.compact); agent_mut.prompt.adopt_slash_mru(app.slash_mru.clone()); + agent_mut + .prompt + .adopt_command_tags(app.command_tags.clone()); agent_mut .prompt .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode); @@ -837,6 +840,7 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore( agent.begin_replay_window(); agent.prompt.set_compact(app.appearance.prompt.compact); agent.prompt.adopt_slash_mru(app.slash_mru.clone()); + agent.prompt.adopt_command_tags(app.command_tags.clone()); agent .prompt .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode); @@ -1229,13 +1233,21 @@ pub(in crate::app::dispatch) fn dispatch_session_picker_closed(app: &mut AppView /// Fetch invalidation shared by EVERY picker-dismissal path: /// modal Esc/mouse close, modal and welcome picks (all variants), and the /// welcome-screen Esc. Only chat mode can have a query-stamped search in -/// flight; Build mode must NOT bump — only the plain list fetch exists there -/// and its responses keep their pre-existing last-write-wins behavior. +/// flight; a Build-mode MODAL close must NOT bump — only the plain list +/// fetch exists there and its response lands on the hidden welcome fields +/// (pre-existing last-write-wins behavior). A WELCOME dismissal must bump +/// and drop the loading flag: the welcome view survives the close, so a +/// still-loading flag holds `show_picker` in a spinner limbo that ignores +/// input until the late response lands and resurrects the picker. fn invalidate_picker_fetch_on_dismiss(app: &mut AppView) { invalidate_foreign_picker(app); - if app.chat_mode { + let welcome_dismissal = matches!(app.active_view, crate::app::app_view::ActiveView::Welcome); + if app.chat_mode || welcome_dismissal { app.session_picker_list_seq += 1; } + if welcome_dismissal { + app.session_picker_loading = false; + } app.session_picker_deep_search_seq += 1; app.session_picker_content_loading = false; } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs index 8f07661..5422ac5 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs @@ -193,6 +193,39 @@ pub(in crate::app::dispatch) fn set_voice_capture_mode( }] } +/// Mirror the voice-shortcut gate into `app.current_ui` (read live by the +/// event-loop chord intercept) and the process-global mirror (read by key +/// routing / view code without an `AppView`). Called by the commit path AND by +/// [`apply_setting_rollback`](super::ui::apply_setting_rollback). +pub(super) fn set_voice_keybind_enabled_inner(app: &mut AppView, new: bool) { + app.current_ui.voice_keybind_enabled = Some(new); + crate::app::VOICE_KEYBIND_ENABLED.store(new, std::sync::atomic::Ordering::Release); +} + +/// Enable/disable the Ctrl+Space / F8 voice shortcut. SHELL-owned; persists to +/// `[ui].voice_keybind_enabled` via `Effect::PersistSetting`. Applies on the +/// next keypress (no restart). Only the chord is gated — `/voice`, Esc while +/// listening, and the recording-row `[stop]` keep working. +pub(in crate::app::dispatch) fn set_voice_keybind_enabled( + app: &mut AppView, + new: bool, +) -> Vec<Effect> { + let prev_state = app.current_ui.voice_keybind_enabled; + let prev_effective = prev_state.unwrap_or(true); + if prev_effective == new && prev_state.is_some() { + return vec![]; + } + set_voice_keybind_enabled_inner(app, new); + refresh_open_settings_modals(app); + tracing::info!(target: "settings", key = "voice_keybind_enabled", value = new, "setting changed"); + app.show_toast(&save_success_toast("Voice shortcut", new)); + vec![Effect::PersistSetting { + key: "voice_keybind_enabled", + value: crate::settings::SettingValue::Bool(new), + rollback_value: crate::settings::SettingValue::Bool(prev_effective), + }] +} + /// Mirror the STT language preference into `app.current_ui` and /// `app.voice_config.language` (may be the client-only `"auto"` sentinel; the /// voice crate resolves it at connect time). Called by the commit path AND by 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 8fe127e..78e6aef 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 @@ -13,7 +13,7 @@ use super::setters::{ set_scroll_lines_inner, set_scroll_mode_inner, set_scroll_speed_inner, set_show_thinking_blocks_inner, set_show_tips_inner, set_simple_mode_inner, set_theme_inner, set_timeline_inner, set_timestamps, set_timestamps_inner, set_vim_mode_inner, - set_voice_capture_mode_inner, set_voice_stt_language_inner, + set_voice_capture_mode_inner, set_voice_keybind_enabled_inner, set_voice_stt_language_inner, }; use crate::app::actions::{Action, Effect}; use crate::app::app_view::{ActiveView, AppView}; @@ -872,6 +872,9 @@ pub(in crate::app::dispatch) fn action_for_reset( Some(Action::SetHunkTrackerMode((*s).to_string())) } ("screen_mode", SettingValue::Enum(s)) => Some(Action::SetScreenMode((*s).to_string())), + ("voice_keybind_enabled", SettingValue::Bool(b)) => { + Some(Action::SetVoiceKeybindEnabled(*b)) + } ("voice_capture_mode", SettingValue::Enum(s)) => { Some(Action::SetVoiceCaptureMode((*s).to_string())) } @@ -1132,6 +1135,9 @@ pub(in crate::app::dispatch) fn apply_setting_rollback( ("screen_mode", SettingValue::Enum(s)) => { set_screen_mode_inner(app, crate::settings::canonical_screen_mode(Some(s))); } + ("voice_keybind_enabled", SettingValue::Bool(b)) => { + set_voice_keybind_enabled_inner(app, *b) + } ("voice_capture_mode", SettingValue::Enum(s)) => { set_voice_capture_mode_inner( app, 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 5ca7770..6c3ec9c 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs @@ -575,6 +575,23 @@ pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<E vec![] } +/// Open the onboarding tutorial overlay (top-level modal — works over both +/// the welcome screen and an agent session). Toggles: dispatching while +/// open closes instead of stacking. +pub(super) fn dispatch_open_tutorial(app: &mut AppView) -> Vec<Effect> { + // Minimal mode has no modal host: the overlay would render nothing + // while the app-level intercept swallowed all input. + if app.screen_mode.is_minimal() { + return vec![]; + } + if app.tutorial.is_some() { + app.tutorial = None; + return vec![]; + } + app.tutorial = Some(crate::views::tutorial::TutorialState::new()); + vec![] +} + pub(super) fn dispatch_show_release_notes( app: &mut AppView, title: String, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs index fc5c3f7..151aeac 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs @@ -216,13 +216,6 @@ pub(crate) fn current_doctor_target( _ => None, } } -pub(crate) fn doctor_target_is_current(app: &AppView, target: &DoctorFixTarget) -> bool { - app.agents.get(&target.agent_id).is_some_and(|agent| { - agent.session.session_id == target.session_id - && agent.session_binding_epoch == target.session_binding_epoch - && agent.session.cwd == target.cwd - }) -} pub(crate) fn deliver_doctor_message(app: &mut AppView, preferred: AgentId, message: String) { let destination = app .agents @@ -632,81 +625,9 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec } vec![] } - TaskResult::DoctorFixApplied { - target, - shell, - result, - } => { + TaskResult::DoctorFixApplied { target, result } => { let message = match result { - Ok(outcome) => { - let report_agent = doctor_target_is_current(app, &target) - .then_some(target.agent_id) - .or_else(|| match app.active_view { - ActiveView::Agent(id) if app.agents.contains_key(&id) => Some(id), - _ => app.agents.keys().next().copied(), - }); - let Some(report_agent) = report_agent else { - let message = match outcome.status { - crate::diagnostics::FixStatus::Applied => { - format!( - "Set up SSH wrapping in {}.", - outcome.changed_path.display() - ) - } - crate::diagnostics::FixStatus::AlreadyConfigured => { - format!( - "SSH wrapping is already set up in {}.", - outcome.changed_path.display() - ) - } - }; - deliver_doctor_message(app, target.agent_id, message); - return vec![]; - }; - let Some(mut report) = - super::prompt::collect_live_doctor_report(app, report_agent) - else { - unreachable!("report destination came from app.agents") - }; - report = crate::diagnostics::configured_report( - report, - crate::diagnostics::managed_alias_configured(&outcome.changed_path, shell), - ); - if report - .findings - .iter() - .any(|finding| finding.id == outcome.id) - { - format!( - "The change was applied, but Doctor still reports `{}`.", - outcome.id - ) - } else { - let status = match outcome.status { - crate::diagnostics::FixStatus::Applied => { - format!( - "Set up SSH wrapping in {}.", - outcome.changed_path.display() - ) - } - crate::diagnostics::FixStatus::AlreadyConfigured => { - format!( - "SSH wrapping is already set up in {}.", - outcome.changed_path.display() - ) - } - }; - let backup = outcome - .backup_path - .as_ref() - .map(|path| format!("\nBackup: {}", path.display())) - .unwrap_or_default(); - format!( - "{status}{backup}\nStart a new shell to use the alias.\n\n{}", - crate::diagnostics::format_doctor(&report) - ) - } - } + Ok(outcome) => crate::diagnostics::format_fix_success(&outcome), Err(error) if error.starts_with("Could not apply the fix:") => error, Err(error) => format!("Could not apply the fix: {error}"), }; 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 9f479ef..7c4871d 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 @@ -2439,10 +2439,7 @@ fn dashboard_attach_subagent_switches_to_parent_with_subagent_focused() { &mut scratch, None, false, - 0, - &[], - &std::collections::BTreeSet::new(), - None, + crate::app::agent_view::BannerSlotParams::none(), &crate::app::bundle::BundleState::default(), false, &mut Vec::new(), 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 72ed3ca..5131813 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 @@ -132,6 +132,7 @@ fn test_app() -> AppView { new_session_worktree_mode: crate::app::app_view::WorktreeMode::Never, fork_worktree_mode: crate::app::app_view::WorktreeMode::Ask, restore_code: None, + resume_local_miss: None, agent_override: None, bootstrap_acp_commands: Vec::new(), auth_methods: vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new( @@ -180,6 +181,7 @@ fn test_app() -> AppView { slash_mru: std::rc::Rc::new(std::cell::RefCell::new( crate::slash::mru::SlashMru::new_in_memory(), )), + command_tags: std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())), welcome_prompt_focused: false, welcome_tip_typing_dismissed: false, welcome_menu_index: None, @@ -260,6 +262,7 @@ fn test_app() -> AppView { session_picker_grouped: false, cancel_rewind_enabled: true, session_recap_available: false, + tutorial: None, dashboard: None, dashboard_return: None, dashboard_persisted: None, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs index ef83889..b4ac48a 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs @@ -361,6 +361,8 @@ fn welcome_selection_survives_foreign_insertion_with_viewport_offset() { app.session_picker_grouped = false; app.foreign_session_scan_seq = 8; app.session_picker_lanes.foreign_loading = true; + // Pin All: the insertion only shifts rows when foreign entries are visible. + app.session_picker_source_filter = SourceFilter::All; app.session_picker_entries = Some(vec![ at(make_picker_entry("a", "/repo"), 20), at(make_picker_entry("b", "/repo"), 10), @@ -397,7 +399,12 @@ fn modal_selection_survives_native_and_foreign_completion_races() { at(make_picker_entry("b", "/repo"), 10), ], ); - if let Some(ActiveModal::SessionPicker { state, lanes, .. }) = get_active_agent_mut(&mut app) + if let Some(ActiveModal::SessionPicker { + state, + lanes, + source_filter, + .. + }) = get_active_agent_mut(&mut app) .unwrap() .active_modal .as_mut() @@ -405,6 +412,8 @@ fn modal_selection_survives_native_and_foreign_completion_races() { state.selected = 2; state.scroll_offset = Some(1); lanes.foreign_loading = true; + // Pin All: the insertion only shifts rows when foreign entries are visible. + *source_filter = SourceFilter::All; } let _ = dispatch( @@ -479,7 +488,8 @@ fn external_filter_clears_and_suppresses_native_content_state() { app.session_picker_content_results = Some(vec![content_hit("native-hit")]); app.session_picker_content_loading = true; app.session_picker_state.expanded.insert(0); - app.session_picker_source_filter = SourceFilter::Remote; + // Grok cycles straight into External. + app.session_picker_source_filter = SourceFilter::Grok; let old_detail_generation = app.session_picker_detail_generation; let effects = dispatch(Action::CycleSessionSourceFilter, &mut app); @@ -573,7 +583,8 @@ fn modal_external_filter_clears_native_content_and_blocks_forced_search() { .active_modal .as_mut() { - *source_filter = SourceFilter::Remote; + // Grok cycles straight into External. + *source_filter = SourceFilter::Grok; *content_results = Some(vec![content_hit("native-hit")]); *content_loading = true; state.set_query("native"); @@ -599,6 +610,27 @@ fn modal_external_filter_clears_native_content_and_blocks_forced_search() { assert!(dispatch(Action::ForceDeepSearch, &mut app).is_empty()); } +#[test] +fn cycle_reaches_every_filter_with_foreign_present() { + // One press from the default reveals externals, and Local/Remote stay + // reachable on the same plain cycle even with foreign rows loaded. + let mut app = test_app(); + app.session_picker_entries = Some(vec![ + make_picker_entry("native", "/repo"), + make_foreign_entry("foreign", "claude", "/repo"), + ]); + for expected in [ + SourceFilter::External, + SourceFilter::All, + SourceFilter::Local, + SourceFilter::Remote, + SourceFilter::Grok, + ] { + let _ = dispatch(Action::CycleSessionSourceFilter, &mut app); + assert_eq!(app.session_picker_source_filter, expected); + } +} + #[test] fn active_modal_owns_stale_and_external_deep_search_results() { for external in [false, true] { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/fork.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/fork.rs index a83391a..94ebe65 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/fork.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/fork.rs @@ -551,6 +551,64 @@ fn dispatch_fork_sets_forked_from_on_new_agent() { assert_eq!(new_agent.session.forked_from, Some(AgentId(0))); } +/// GBT-4789 — dashboard attach follows the forked child. +#[test] +fn dispatch_fork_repoints_dashboard_attached_agent_to_child() { + let mut app = fork_test_app(); + ensure_dashboard_state(&mut app); + app.dashboard.as_mut().unwrap().attached_agent = Some(AgentId(0)); + + dispatch(Action::Fork(fork_args(Some(false), None)), &mut app); + + assert!( + matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(1)), + "fork must switch active view to the child" + ); + assert_eq!( + app.dashboard.as_ref().unwrap().attached_agent, + Some(AgentId(1)), + "attached_agent must re-point to the forked child so overlay \ + back-out (Left/Esc/Ctrl+\\) keeps working", + ); +} + +/// Fork must not invent dashboard attach when none was set. +#[test] +fn dispatch_fork_without_dashboard_attach_leaves_attached_none() { + let mut app = fork_test_app(); + ensure_dashboard_state(&mut app); + assert!(app.dashboard.as_ref().unwrap().attached_agent.is_none()); + + dispatch(Action::Fork(fork_args(Some(false), None)), &mut app); + + assert_eq!( + app.dashboard.as_ref().unwrap().attached_agent, + None, + "fork must not enable overlay chrome when the parent was not attached", + ); +} + +#[test] +fn dispatch_fork_keeps_stale_attach_on_other_agent() { + let mut app = fork_test_app(); + insert_placeholder_agent(&mut app, AgentId(1)); + app.next_agent_id = 2; + ensure_dashboard_state(&mut app); + app.dashboard.as_mut().unwrap().attached_agent = Some(AgentId(1)); + + dispatch(Action::Fork(fork_args(Some(false), None)), &mut app); + + assert!( + matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(2)), + "fork must switch active view to the child" + ); + assert_eq!( + app.dashboard.as_ref().unwrap().attached_agent, + Some(AgentId(1)), + "attach on a different agent must not be re-pointed to the fork child", + ); +} + #[test] fn dispatch_fork_pushes_parent_marker_with_directive() { let mut app = fork_test_app(); diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs index f335948..2d95f60 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs @@ -2189,6 +2189,77 @@ fn welcome_esc_drops_in_flight_fetch_response() { "in-flight fetch must not repopulate the closed welcome picker" ); } +/// Build-mode sibling of the chat Esc test, pinning Esc-during-load: with the +/// fast foreign lane landed (hidden by the Grok default → CTA) and the native +/// fetch still in flight, Esc must really dismiss the picker — drop the +/// loading flag (a lingering flag holds `show_picker` in a spinner limbo that +/// ignores input) and stale the fetch so its late response cannot resurrect +/// the picker. +#[test] +fn build_welcome_esc_during_load_dismisses_without_resurrection() { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + let mut app = test_app(); + assert!(!app.chat_mode); + let _ = dispatch(Action::FetchSessionList, &mut app); + let seq = app.session_picker_list_seq; + assert!(app.session_picker_loading); + let mut foreign = make_picker_entry("claude-1", "/repo"); + foreign.source = "claude".into(); + app.session_picker_entries = Some(vec![foreign]); + let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let out = app.handle_input(&esc); + assert!( + matches!( + out, + crate::app::app_view::InputOutcome::Action(Action::SessionPickerClosed) + ), + "welcome Esc must surface SessionPickerClosed, got {out:?}" + ); + assert!( + app.session_picker_entries.is_none(), + "Esc clears the welcome picker" + ); + let _ = dispatch(Action::SessionPickerClosed, &mut app); + assert!( + !app.session_picker_loading, + "dismissal must end the loading limbo (`show_picker` keys off it)" + ); + let _ = dispatch( + Action::TaskComplete(TaskResult::SessionListLoaded { + scope: ListScope::Cwd, + sessions: vec![make_picker_entry("native-late", "/repo")], + partial: None, + seq, + query: None, + }), + &mut app, + ); + assert!( + app.session_picker_entries.is_none(), + "late native response must not resurrect the closed picker" + ); +} +/// The spinner-only loading picker (nothing landed yet) still owns Esc: it +/// must dismiss the picker instead of dead-keying into the menu it covers. +#[test] +fn build_welcome_esc_dismisses_spinner_only_loading_picker() { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + let mut app = test_app(); + let _ = dispatch(Action::FetchSessionList, &mut app); + assert!(app.session_picker_loading); + assert!(app.session_picker_entries.is_none()); + let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let out = app.handle_input(&esc); + assert!( + matches!( + out, + crate::app::app_view::InputOutcome::Action(Action::SessionPickerClosed) + ), + "Esc on the loading picker must close it, got {out:?}" + ); + let _ = dispatch(Action::SessionPickerClosed, &mut app); + assert!(!app.session_picker_loading, "picker fully dismissed"); +} /// Build-mode canary: modal close must not bump the list seq — an in-flight /// plain fetch keeps its pre-existing land-after-close behavior. #[test] 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 f57426a..8089906 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 @@ -1404,6 +1404,9 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti "screen_mode" => { let _ = dispatch(Action::SetScreenMode("minimal".to_string()), app); } + "voice_keybind_enabled" => { + let _ = dispatch(Action::SetVoiceKeybindEnabled(false), app); + } "voice_capture_mode" => { let _ = dispatch(Action::SetVoiceCaptureMode("toggle".to_string()), app); } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs index 8694851..1ca5cdb 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs @@ -1084,3 +1084,19 @@ fn minimal_update_notice_no_active_agent_is_noop() { // Must not panic and must not require an agent. commit_minimal_update_notice(&mut app, "9.9.9"); } + +// ── Tutorial dispatch tests ────────────────────────────────────────── + +/// `/tutorial` (and the palette entry) open the overlay; dispatching again +/// while open toggles it closed. No side effects either way. +#[test] +fn open_tutorial_toggles_overlay_without_effects() { + let mut app = test_app(); + let effects = dispatch(Action::OpenTutorial, &mut app); + assert!(app.tutorial.is_some(), "tutorial opens"); + assert!(effects.is_empty(), "open emits nothing, got: {effects:?}"); + + let effects = dispatch(Action::OpenTutorial, &mut app); + assert!(app.tutorial.is_none(), "toggle closes"); + assert!(effects.is_empty(), "close emits nothing, got: {effects:?}"); +} diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs index 3503f0a..4300587 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs @@ -92,6 +92,7 @@ fn doctor_planning_opens_refuses_remote_and_rejects_stale_identity() { let target = doctor_target(&app, id); app.agents.get_mut(&id).unwrap().prompt.set_text("draft"); + let scrollback_len = app.agents[&id].scrollback.len(); dispatch_task_result( TaskResult::DoctorFixPlanned { target: target.clone(), @@ -102,6 +103,25 @@ fn doctor_planning_opens_refuses_remote_and_rejects_stale_identity() { &mut app, ); assert_eq!(app.agents[&id].prompt.text(), ""); + assert_eq!( + app.agents[&id].scrollback.len(), + scrollback_len, + "the confirmation preview belongs only in the question modal" + ); + let question = app.agents[&id] + .question_view + .as_ref() + .expect("doctor question") + .questions + .first() + .expect("doctor question contents"); + assert!( + question.options[0] + .preview + .as_deref() + .is_some_and(|preview| preview.contains("Doctor Fix")), + "the modal must retain the exact fix preview" + ); app.agents.get_mut(&id).unwrap().question_view = None; dispatch_task_result( @@ -146,7 +166,6 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback() dispatch_task_result( TaskResult::DoctorFixApplied { target: target.clone(), - shell: crate::diagnostics::ShellKind::Bash, result: Err("stale plan".to_owned()), }, &mut app, @@ -160,7 +179,6 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback() dispatch_task_result( TaskResult::DoctorFixApplied { target: target.clone(), - shell: crate::diagnostics::ShellKind::Bash, result: Err("apply failed".to_owned()), }, &mut app, @@ -175,7 +193,6 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback() dispatch_task_result( TaskResult::DoctorFixApplied { target, - shell: crate::diagnostics::ShellKind::Bash, result: Err("validator failed".to_owned()), }, &mut app, @@ -187,33 +204,68 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback() } #[test] -fn doctor_apply_success_renders_refreshed_report() { +fn doctor_apply_reload_success_does_not_claim_live_finding_disappeared() { let mut app = test_app_with_agent(); let id = AgentId(0); let target = doctor_target(&app, id); let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join(".bashrc"); - std::fs::write( - &path, - "# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\nalias ssh='grok wrap ssh'\n# <<< terminal.ssh-wrap <<<\n# <<< grok doctor <<<\n", - ) - .unwrap(); + let path = temp.path().join(".tmux.conf"); dispatch_task_result( TaskResult::DoctorFixApplied { target, - shell: crate::diagnostics::ShellKind::Bash, - result: Ok(crate::diagnostics::FixOutcome { - id: crate::diagnostics::SSH_WRAP_ID, - status: crate::diagnostics::FixStatus::Applied, - changed_path: path, - backup_path: None, - }), + result: Ok(crate::diagnostics::FixOutcome::new_for_test( + crate::diagnostics::TMUX_CLIPBOARD_ID, + crate::diagnostics::FixStatus::Applied, + path.clone(), + None, + crate::diagnostics::FixActivation::RequiresReload, + None, + )), + }, + &mut app, + ); + let output = last_system_text(&app, id); + assert!( + output.starts_with(&format!( + "Added `set -g set-clipboard on` to `{}`.", + path.display() + )), + "{output}" + ); + assert!( + output.contains("Reload tmux with `tmux source-file"), + "{output}" + ); + assert!(output.contains("Run /doctor again to verify"), "{output}"); + assert!(!output.contains("0 issues"), "{output}"); + assert!(!output.contains("Environment\n"), "{output}"); +} + +#[test] +fn doctor_apply_success_only_renders_resolution_instructions() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + let target = doctor_target(&app, id); + let temp = tempfile::tempdir().unwrap(); + dispatch_task_result( + TaskResult::DoctorFixApplied { + target, + result: Ok(crate::diagnostics::FixOutcome::new_for_test( + crate::diagnostics::SSH_WRAP_ID, + crate::diagnostics::FixStatus::Applied, + temp.path().join(".bashrc"), + None, + crate::diagnostics::FixActivation::SatisfiedNow, + Some(crate::diagnostics::ShellKind::Bash), + )), }, &mut app, ); let output = last_system_text(&app, id); assert!(output.starts_with("Set up SSH wrapping in"), "{output}"); - assert!(output.contains("Environment\n"), "{output}"); + assert!(output.contains("Start a new shell"), "{output}"); + assert!(!output.contains("Environment\n"), "{output}"); + assert!(!output.contains("Findings\n"), "{output}"); } #[test] 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 755fd87..fbc7ba2 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs @@ -260,6 +260,10 @@ pub(crate) struct SessionFlags { /// Active auth is API key (not OAuth/session). Drives rate-limit copy in /// `format_acp_error`. Default `false` (OAuth copy) for tests. pub is_api_key_auth: bool, + /// Startup resume target deferred to the worktree handler after missing + /// local id/title resolution. Worktree failure messages append the + /// no-match hint only when the failing target equals this value. + pub resume_local_miss: Option<String>, } impl SessionFlags { /// Resolve the agent profile name from the flags. @@ -1110,6 +1114,14 @@ pub(crate) async fn persist_setting( .await .map_err(|e| e.to_string()) } + "voice_keybind_enabled" => { + let SettingValue::Bool(b) = value else { + return Err(kind_mismatch("voice_keybind_enabled", "Bool", &value)); + }; + xai_grok_shell::util::config::set_voice_keybind_enabled(b) + .await + .map_err(|e| e.to_string()) + } "voice_capture_mode" => { let SettingValue::Enum(s) = value else { return Err(kind_mismatch("voice_capture_mode", "Enum", &value)); 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 7d02eda..d9f63f6 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs @@ -7,6 +7,7 @@ //! back through dispatch. mod helpers; use super::actions; +use super::session_title_resolve::worktree_resume_failure_message; #[allow(unused_imports)] use super::{agent, dispatch}; pub use helpers::ConversationsPartial; @@ -248,6 +249,7 @@ pub(crate) fn execute( .insert("sessionId".into(), serde_json::json!(sid)); } let restore_code = session_flags.restore_code; + let resume_local_miss = session_flags.resume_local_miss.clone(); tracing::info!( ?restore_code, ?load_session_id, @@ -257,6 +259,9 @@ pub(crate) fn execute( tasks .spawn(async move { if let Some(sid) = load_session_id { + let local_miss = resume_local_miss + .as_deref() + .filter(|t| *t == sid); let resume_started = std::time::Instant::now(); let wt_type = xai_grok_shell::util::config::worktree_type(); let copy_mode = if git_ref.is_some() { @@ -300,10 +305,9 @@ pub(crate) fn execute( ); return TaskResult::WorktreeSessionFailed { agent_id, - error: sanitize_user_error( - &format!( - "couldn't resume worktree session: {e}" - ), + error: worktree_resume_failure_message( + local_miss, + &sanitize_user_error(&e.to_string()), ), }; } @@ -315,10 +319,9 @@ pub(crate) fn execute( Err(e) => { return TaskResult::WorktreeSessionFailed { agent_id, - error: sanitize_user_error( - &format!( - "couldn't resume worktree session: {e}" - ), + error: worktree_resume_failure_message( + local_miss, + &sanitize_user_error(&e.to_string()), ), }; } @@ -333,10 +336,9 @@ pub(crate) fn execute( .unwrap_or_else(|| err.to_string()); return TaskResult::WorktreeSessionFailed { agent_id, - error: sanitize_user_error( - &format!( - "couldn't resume worktree session: {msg}" - ), + error: worktree_resume_failure_message( + local_miss, + &sanitize_user_error(&msg), ), }; } @@ -1867,7 +1869,6 @@ pub(crate) fn execute( Effect::ApplyDoctorFix { target, plan } => { tasks .spawn(async move { - let shell = plan.shell; let result = tokio::task::spawn_blocking(move || crate::diagnostics::apply_fix( *plan, )) @@ -1876,7 +1877,6 @@ pub(crate) fn execute( .and_then(|result| result.map_err(|error| error.to_string())); TaskResult::DoctorFixApplied { target, - shell, result, } }); 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 7d090d3..a6ae275 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs @@ -2295,6 +2295,27 @@ fn sanitize_user_error_collapses_disk_full() { "couldn't create worktree: failed to get HEAD commit from source" ); } +/// Production ordering of the deferred worktree resume failure: the +/// detail is sanitized FIRST, then composed — sanitizing the composed +/// message would collapse a disk-full chain whole and erase the title +/// hint for a deferred local-miss target. +#[test] +fn worktree_resume_failure_sanitizes_detail_before_hint() { + let raw = "failed to copy index: No space left on device (os error 28)"; + let msg = worktree_resume_failure_message( + Some("typo title"), + &sanitize_user_error(raw), + ); + assert_eq!( + msg, + format!( + "couldn't resume worktree session: No space left on device; {}", + crate::app::session_title_resolve::title_miss_hint("typo title") + ) + ); + let id_msg = worktree_resume_failure_message(None, &sanitize_user_error(raw)); + assert_eq!(id_msg, "couldn't resume worktree session: No space left on device"); +} /// A resume-picker entry converts to a **dormant** dashboard roster row /// (the non-leader idle source) preserving title, cwd, model, worktree /// flag, origin, and last-change time. 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 39e1795..f9e858a 100644 --- a/crates/codegen/xai-grok-pager/src/app/event_loop.rs +++ b/crates/codegen/xai-grok-pager/src/app/event_loop.rs @@ -1132,7 +1132,9 @@ pub(crate) async fn run( } { - use xai_grok_shell::util::config::{resolve_announcements, resolve_tips}; + use xai_grok_shell::util::config::{ + resolve_announcements, resolve_slash_command_tags, resolve_tips, + }; let remote_announcements = remote_settings .as_ref() @@ -1163,6 +1165,15 @@ pub(crate) async fn run( 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()); + let empty_toml = toml::Value::Table(Default::default()); + 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( @@ -1328,6 +1339,13 @@ pub(crate) async fn run( 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. @@ -1573,12 +1591,19 @@ pub(crate) async fn run( // chokepoints self-gate when auth + folder trust is closed. use crate::app::session_startup::MaterializedStartup; let startup_action = match &materialized { - MaterializedStartup::Resume { session_id, .. } if args.worktree.is_some() => { + MaterializedStartup::Resume { + session_id, + deferred_local_miss, + .. + } if args.worktree.is_some() => { tracing::info!( session_id, 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()), label: args.worktree.as_ref().filter(|s| !s.is_empty()).cloned(), @@ -3109,12 +3134,19 @@ async fn drain_and_process( // Hold-to-talk under Kitty (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. + // 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 && is_voice_chord(ke) - && (ke.kind != KeyEventKind::Release || app.voice_hold_owned()) + && voice_chord_claims_event( + ke.kind, + app.current_ui.voice_keybind_enabled.unwrap_or(true), + 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. @@ -3365,6 +3397,22 @@ fn voice_chord_action( } } +/// Whether the event-loop intercept claims a voice-chord key event (pure for +/// unit tests). +/// +/// An active hold session owns its chord events end-to-end regardless of the +/// Voice shortcut setting — its release only ever stops capture, so flipping +/// the setting off mid-hold must not orphan it and wedge the mic open. +/// Outside a hold, a bare release is never ours (normal typing) and a press +/// honors the setting; an unclaimed press falls through to normal routing, +/// where `ActionId::VoiceToggle` resolution is gated on the same setting. +fn voice_chord_claims_event(kind: KeyEventKind, keybind_enabled: bool, hold_owned: bool) -> bool { + if hold_owned { + return true; + } + 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 @@ -3581,6 +3629,7 @@ fn process_effects( chat_mode: app.chat_mode, screen_mode_label: Some(app.screen_mode.meta_label()), is_api_key_auth: app.is_api_key_auth, + resume_local_miss: app.resume_local_miss.clone(), }; for eff in effs { let (quit, meta) = effects::execute(eff, tasks, &app.acp_tx, &app.cwd, &flags, progress_tx); @@ -3698,6 +3747,39 @@ 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. + #[test] + fn voice_chord_claims_event_cases() { + 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), + ]; + for ((kind, enabled, owned), want) in cases { + assert_eq!( + voice_chord_claims_event(kind, enabled, owned), + want, + "voice_chord_claims_event({kind:?},{enabled},{owned})" + ); + } + } + // ── plan_reconnect_load ────────────────────────────────────────────── #[test] 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 58e85df..8ca720b 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 @@ -152,6 +152,7 @@ impl ClusterClient { chat_mode: self.app.chat_mode, screen_mode_label: Some(self.app.screen_mode.meta_label()), is_api_key_auth: self.app.is_api_key_auth, + resume_local_miss: self.app.resume_local_miss.clone(), }; for eff in effs { let (_quit, _meta) = effects::execute( diff --git a/crates/codegen/xai-grok-pager/src/app/mod.rs b/crates/codegen/xai-grok-pager/src/app/mod.rs index 212f614..18d70c9 100644 --- a/crates/codegen/xai-grok-pager/src/app/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/mod.rs @@ -29,6 +29,7 @@ mod display_refresh_startup; mod effects; pub mod roster; pub mod session_startup; +pub(crate) mod session_title_resolve; pub mod status_blocks; pub mod subagent; pub mod subscription; @@ -178,6 +179,19 @@ pub(crate) fn voice_mode_enabled() -> bool { pub fn set_voice_mode_enabled_for_test(on: bool) { VOICE_MODE_ENABLED.store(on, Ordering::Release); } +/// Process-global gate for the Ctrl+Space / F8 voice chord, for key-routing +/// and view code without an `AppView` (`resolve_action`, the cheatsheet). +/// Default ON. Seeded at startup from `[ui].voice_keybind_enabled` and +/// updated live by the settings setter; unlike [`VOICE_MODE_ENABLED`] it only +/// silences the keybinding — `/voice` and the other voice surfaces stay up. +pub(crate) static VOICE_KEYBIND_ENABLED: AtomicBool = AtomicBool::new(true); +pub(crate) fn voice_keybind_enabled() -> bool { + VOICE_KEYBIND_ENABLED.load(Ordering::Acquire) +} +/// Test helper for the process-global voice-keybind gate. +pub fn set_voice_keybind_enabled_for_test(on: bool) { + VOICE_KEYBIND_ENABLED.store(on, Ordering::Release); +} /// `[features] voice_mode` from merged `requirements.toml`. pub(crate) fn voice_mode_requirement_pin() -> Option<bool> { xai_grok_config::load_merged_requirements().and_then(|req| { diff --git a/crates/codegen/xai-grok-pager/src/app/modals.rs b/crates/codegen/xai-grok-pager/src/app/modals.rs index 9086e69..a924e82 100644 --- a/crates/codegen/xai-grok-pager/src/app/modals.rs +++ b/crates/codegen/xai-grok-pager/src/app/modals.rs @@ -573,6 +573,7 @@ impl AgentView { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -732,6 +733,7 @@ impl AgentView { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -989,8 +991,8 @@ impl AgentView { _ => false, }; - // Chat-mode picker lists conversations only: the Local/Remote - // source filter and local-disk delete are dead weight there. + // Chat-mode picker lists conversations only: the source + // filter and local-disk delete are dead weight there. let chat_mode = self.app_chat_mode; let config = PickerConfig { title: Some("Resume session"), @@ -1007,6 +1009,7 @@ impl AgentView { filter_label: (!chat_mode).then(|| source_filter.label()), filter_key_hint: (!chat_mode).then_some("f"), filter_active: !chat_mode && source_filter.is_active(), + header_note: None, action_keys: if chat_mode || focused_is_foreign { &[] } else { @@ -1324,6 +1327,7 @@ impl AgentView { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -2107,7 +2111,16 @@ impl AgentView { non_sel_flags.push(false); } - let entries_area = Rect { + let hidden_hint = if chat_mode { + None + } else { + crate::views::session_picker::hidden_external_hint( + entries.as_deref(), + *source_filter, + ) + }; + + let mut entries_area = Rect { x: content_area.x, y: entries_start_y, width: content_area.width, @@ -2115,6 +2128,22 @@ impl AgentView { .height .saturating_sub(entries_start_y.saturating_sub(content_area.y)), }; + // Pinned above the list so it stays visible regardless of scroll. + if let Some(hint) = hidden_hint.as_deref() + && entries_area.height > 0 + { + buf.set_stringn( + entries_area.x + 1, + entries_area.y, + hint, + entries_area.width.saturating_sub(1) as usize, + ratatui::style::Style::default() + .fg(theme.gray_dim) + .bg(theme.bg_base), + ); + entries_area.y += 1; + entries_area.height -= 1; + } let content_hit = picker::render_picker_content_with_scrollbar_x( buf, entries_area, @@ -2124,7 +2153,13 @@ impl AgentView { &non_sel_flags, &[], Some(theme.bg_base), - entries.is_none() && (*loading || lanes.foreign_loading), + crate::views::session_picker::loading_spinner_active( + entries.as_deref(), + *source_filter, + *loading, + lanes, + ), + self.scrollback.tick_count(), mca.inner_x + mca.inner_width - 1, ); state.hit_areas = Some(picker::PickerHitAreas { @@ -2524,7 +2559,7 @@ mod session_picker_delete_tests { }; assert_eq!( filter, - crate::views::session_picker::SourceFilter::All, + crate::views::session_picker::SourceFilter::Grok, "f must not cycle the hidden source filter under chat mode" ); } @@ -2553,6 +2588,11 @@ mod session_picker_delete_tests { let mut foreign = entry("codex-session"); foreign.source = "codex".into(); open_picker(&mut agent, vec![foreign]); + // Pin All: the refusals only fire when the foreign row is focusable. + if let Some(ActiveModal::SessionPicker { source_filter, .. }) = agent.active_modal.as_mut() + { + *source_filter = crate::views::session_picker::SourceFilter::All; + } let delete = agent.handle_palette_or_arg_input(&key('d')); assert!(matches!(delete, InputOutcome::Changed)); diff --git a/crates/codegen/xai-grok-pager/src/app/mouse.rs b/crates/codegen/xai-grok-pager/src/app/mouse.rs index 2252559..706a556 100644 --- a/crates/codegen/xai-grok-pager/src/app/mouse.rs +++ b/crates/codegen/xai-grok-pager/src/app/mouse.rs @@ -9,8 +9,8 @@ use super::actions::Action; use super::agent_view::{ AgentPane, AgentView, CONTEXT_CLICK_DEBOUNCE_MS, CtaPhase, MULTI_CLICK_TIMEOUT_MS, - PromptInputMode, TextClickState, app_should_open_link_on_click, has_native_link_hover, - is_link_modifier_held, is_text_selection_on_double_click, + PromptInputMode, PromptMode, TextClickState, app_should_open_link_on_click, + has_native_link_hover, is_link_modifier_held, is_text_selection_on_double_click, }; use super::app_view::InputOutcome; use crate::scrollback::block::BlockContent; @@ -125,6 +125,32 @@ impl AgentView { self.cancel_trigger_hint = Some(crate::app::actions::CancelTrigger::Mouse); return InputOutcome::Action(Action::CancelTurn); } + if self + .privacy_banner + .hit_accept + .contains(mouse.column, mouse.row) + && !self.pos_occluded(mouse.column, mouse.row) + { + return InputOutcome::Action(Action::PrivacyBannerAccept); + } + if self + .privacy_banner + .hit_customize + .contains(mouse.column, mouse.row) + && !self.pos_occluded(mouse.column, mouse.row) + { + return InputOutcome::Action(Action::PrivacyBannerCustomize); + } + if self + .privacy_banner + .hit_legal + .contains(mouse.column, mouse.row) + && !self.pos_occluded(mouse.column, mouse.row) + { + return InputOutcome::Action(Action::OpenUrl( + crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(), + )); + } if self.hit_announcement_hide.contains(mouse.column, mouse.row) && !self.pos_occluded(mouse.column, mouse.row) { @@ -433,6 +459,18 @@ impl AgentView { { return InputOutcome::Action(action); } + if let Some(id) = self.queue.edit_click(mouse.column, mouse.row) + && (!matches!(self.prompt_mode, PromptMode::EditingQueued { .. }) + || self.set_active_pane(AgentPane::Queue, false)) + { + let row = self.queue.row_ref(id); + let is_server = matches!( + row.as_ref().map(|r| r.origin), + Some(crate::views::queue_pane::QueueRowOrigin::Server) + ); + self.enter_queue_edit(id, is_server, row); + return InputOutcome::Changed; + } self.set_active_pane(AgentPane::Queue, false); self.queue.handle_mouse( mouse.kind, @@ -1013,13 +1051,12 @@ impl AgentView { ) { changed |= self.queue.update_delete_hover(mouse.column, mouse.row); changed |= self.queue.update_send_now_hover(mouse.column, mouse.row); + changed |= self.queue.update_edit_hover(mouse.column, mouse.row); changed |= self.queue.update_row_hover(mouse.column, mouse.row); } else { - if self.queue.hovered_delete_id.is_some() { - self.queue.clear_delete_hover(); - changed = true; - } + changed |= self.queue.clear_delete_hover(); changed |= self.queue.clear_send_now_hover(); + changed |= self.queue.clear_edit_hover(); changed |= self.queue.clear_row_hover(); } changed |= self.hit_plan_button.update_hover(mouse.column, mouse.row); @@ -1037,6 +1074,18 @@ impl AgentView { changed |= self .hit_announcement_cta .update_hover(mouse.column, mouse.row); + changed |= self + .privacy_banner + .hit_accept + .update_hover(mouse.column, mouse.row); + changed |= self + .privacy_banner + .hit_customize + .update_hover(mouse.column, mouse.row); + changed |= self + .privacy_banner + .hit_legal + .update_hover(mouse.column, mouse.row); changed |= self .plugin_cta .hit_connect @@ -1252,6 +1301,10 @@ mod tests { fn click_delete(agent: &mut AgentView, selected_id: u64) -> InputOutcome { click_queue_button(agent, selected_id, |a, c, r| a.queue.delete_click(c, r)) } + /// Left-click the row's `[edit]` button. + fn click_edit(agent: &mut AgentView, selected_id: u64) -> InputOutcome { + click_queue_button(agent, selected_id, |a, c, r| a.queue.edit_click(c, r)) + } /// Mouse "Send now" (interject) on the last local row keeps the pane open /// when a server row remains — the third sibling site of the same fix. #[test] @@ -1377,6 +1430,190 @@ mod tests { assert!(agent.active_modal.is_none()); assert_eq!(agent.prompt.text(), "draft"); } + /// Mouse `[edit]` on a queued row enters the same queued-edit flow as the + /// keyboard `e` (`QueueEvent::EditSelected` → `enter_queue_edit`): the + /// composer loads the row text and the prompt pane takes focus in + /// `EditingQueued` mode, leaving the row itself queued. + #[test] + fn mouse_edit_click_enters_queued_edit_mode() { + let mut agent = running_agent_local_only(); + let ids = agent.queue.entry_ids(); + let outcome = click_edit(&mut agent, ids[0]); + assert!( + matches!(outcome, InputOutcome::Changed), + "edit click redraws without dispatching an action, got {outcome:?}" + ); + match &agent.prompt_mode { + PromptMode::EditingQueued { + id, + original, + server_id, + .. + } => { + assert_eq!(*id, ids[0]); + assert_eq!(original, "local one"); + assert!( + server_id.is_none(), + "local row must not take the server edit path" + ); + } + other => panic!("expected EditingQueued, got {other:?}"), + } + assert_eq!(agent.prompt.text(), "local one"); + assert_eq!(agent.active_pane, AgentPane::Prompt); + assert_eq!(agent.session.pending_prompts.len(), 1); + } + /// Clicking another row's `[edit]` while a DIRTY queued edit is active + /// must not re-enter `enter_queue_edit` — that would bypass the + /// dirty-edit lock and overwrite `stashed_prompt`, so Esc would restore + /// the edit text instead of the user's original draft. The click falls + /// through to the pane switch, which the lock blocks. + #[test] + fn mouse_edit_click_during_dirty_edit_preserves_first_edit_and_stash() { + let mut agent = make_running_agent(); + let ids = agent.queue.entry_ids(); + agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt { + text: "draft".into(), + cursor: 0, + images: Vec::new(), + chip_elements: Vec::new(), + image_counter: 0, + image_undo_stash: Vec::new(), + }); + agent.prompt_mode = PromptMode::EditingQueued { + id: ids[1], + original: "local one".into(), + server_id: None, + kind: crate::app::agent::QueueEntryKind::Prompt, + }; + agent.prompt.set_text("local one EDITED"); + agent.active_pane = AgentPane::Prompt; + let outcome = click_edit(&mut agent, ids[0]); + assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}"); + match &agent.prompt_mode { + PromptMode::EditingQueued { id, original, .. } => { + assert_eq!(*id, ids[1], "the first edit's target row must survive"); + assert_eq!(original, "local one"); + } + other => panic!("expected the first edit to stay active, got {other:?}"), + } + assert_eq!(agent.prompt.text(), "local one EDITED"); + assert_eq!( + agent.stashed_prompt.as_ref().map(|s| s.text.as_str()), + Some("draft"), + "the pre-edit draft must survive for Esc-restore" + ); + assert!( + agent.pending_effects.is_empty(), + "no hold effect may be emitted for the clicked row" + ); + } + /// Same guard for a dirty SERVER-row edit: clicking another row's + /// `[edit]` must not replace the edit (which would strand the first + /// row's combine hold) nor emit a second `QueueHoldEdit`. + #[test] + fn mouse_edit_click_during_dirty_server_edit_keeps_hold_target() { + let mut agent = make_running_agent(); + let ids = agent.queue.entry_ids(); + agent.prompt_mode = PromptMode::EditingQueued { + id: ids[0], + original: "server one".into(), + server_id: Some("p1".into()), + kind: crate::app::agent::QueueEntryKind::Prompt, + }; + agent.prompt.set_text("server one EDITED"); + agent.active_pane = AgentPane::Prompt; + let outcome = click_edit(&mut agent, ids[1]); + assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}"); + match &agent.prompt_mode { + PromptMode::EditingQueued { id, server_id, .. } => { + assert_eq!(*id, ids[0], "the held server row must stay the edit target"); + assert_eq!(server_id.as_deref(), Some("p1")); + } + other => panic!("expected the server edit to stay active, got {other:?}"), + } + assert_eq!(agent.prompt.text(), "server one EDITED"); + assert!( + agent.pending_effects.is_empty(), + "no second QueueHoldEdit may be emitted while one row is held" + ); + } + /// Clicking another row's `[edit]` while a CLEAN (unchanged) edit is + /// active must open the clicked row's edit on the SAME click: the + /// canonical pane switch exits the clean edit — releasing its server + /// combine hold — and the arm then enters the clicked row instead of + /// letting the click die on the pane switch. + #[test] + fn mouse_edit_click_during_clean_edit_switches_to_clicked_row() { + let mut agent = make_running_agent(); + let ids = agent.queue.entry_ids(); + agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt { + text: "draft".into(), + cursor: 0, + images: Vec::new(), + chip_elements: Vec::new(), + image_counter: 0, + image_undo_stash: Vec::new(), + }); + agent.prompt_mode = PromptMode::EditingQueued { + id: ids[0], + original: "server one".into(), + server_id: Some("p1".into()), + kind: crate::app::agent::QueueEntryKind::Prompt, + }; + agent.prompt.set_text("server one"); + agent.active_pane = AgentPane::Prompt; + let outcome = click_edit(&mut agent, ids[1]); + assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}"); + match &agent.prompt_mode { + PromptMode::EditingQueued { + id, + original, + server_id, + .. + } => { + assert_eq!(*id, ids[1], "one click must open the clicked row's edit"); + assert_eq!(original, "local one"); + assert!(server_id.is_none()); + } + other => panic!("expected EditingQueued for the clicked row, got {other:?}"), + } + assert_eq!(agent.prompt.text(), "local one"); + assert_eq!(agent.active_pane, AgentPane::Prompt); + assert!( + agent.pending_effects.iter().any(|e| matches!( + e, + crate::app::actions::Effect::QueueReleaseEdit { id, .. } if id == "p1" + )), + "clean exit must release the held server row, effects = {:?}", + agent.pending_effects + ); + assert_eq!( + agent.stashed_prompt.as_ref().map(|s| s.text.as_str()), + Some("draft") + ); + } + /// Clicking `[edit]` on a server row still awaiting its enqueue + /// confirmation (an optimistic echo) is ignored: the shell has no row to + /// hold yet, so the `hold_edit` would no-op and the later-confirmed row + /// could be absorbed mid-edit. + #[test] + fn mouse_edit_click_on_optimistic_server_row_is_ignored() { + let mut agent = make_running_agent(); + agent.optimistic_queue_ids.insert("p1".into()); + let ids = agent.queue.entry_ids(); + let outcome = click_edit(&mut agent, ids[0]); + assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}"); + assert!( + matches!(agent.prompt_mode, PromptMode::Normal), + "an unconfirmed echo must not be editable" + ); + assert_eq!(agent.prompt.text(), ""); + assert!( + agent.pending_effects.is_empty(), + "no QueueHoldEdit may be emitted for a row the shell doesn't have" + ); + } /// A synthetic left-click on a rendered follow-up chip yields the LITERAL /// `SubmitFollowUp` action (never a slash-command path). #[test] 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 826f536..a122a56 100644 --- a/crates/codegen/xai-grok-pager/src/app/queue_edit.rs +++ b/crates/codegen/xai-grok-pager/src/app/queue_edit.rs @@ -217,6 +217,16 @@ impl AgentView { /// `QueueEvent::EditSelected` (called from `handle_queue_key`). pub(super) fn enter_queue_edit(&mut self, id: u64, is_server: bool, row: Option<QueueRowRef>) { use crate::app::agent::QueueEntryKind; + // Still an optimistic echo: its `session/prompt` RPC is in flight, so + // the shell has no row to hold yet — the `hold_edit` would no-op and + // the later-confirmed row could be absorbed while the composer edits + // it. Ignore until the confirming `x.ai/queue/changed` lands (mirrors + // the send-now park gate in `force_interject_queue_row`). + if let Some(sid) = row.as_ref().and_then(|r| r.server_id.as_deref()) + && self.optimistic_queue_ids.contains(sid) + { + return; + } type QueueEditEntryData = ( String, QueueEntryKind, 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 c38ca2f..4b87d7a 100644 --- a/crates/codegen/xai-grok-pager/src/app/session_startup.rs +++ b/crates/codegen/xai-grok-pager/src/app/session_startup.rs @@ -360,6 +360,10 @@ pub enum MaterializedStartup { session_id: String, original_cwd: Option<PathBuf>, title: Option<String>, + /// The target missed local id/title resolution and was deferred to + /// the worktree resume handler; worktree failure messages append the + /// no-match hint only for this outcome (never inferred from shape). + deferred_local_miss: bool, }, /// Fork from a resolved parent, then load the child. Fork { @@ -369,6 +373,19 @@ pub enum MaterializedStartup { new_session_id: Option<String>, }, } +/// Whether materialization may resolve a non-id resume arg by title locally. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TitleResolution { + /// No pre-sandbox pin ran (direct callers, tests): materialization owns + /// title selection. + Allowed, + /// The composition root already pinned — or definitively missed — the + /// target before the irreversible OS sandbox. Re-selecting by title here + /// would race a concurrent rename/create and resume a session whose + /// persisted profile was never checked; a pinned id that vanished must + /// also never be reinterpreted as a title. + PinnedPreSandbox, +} /// Context for [`materialize_startup`] (interactive vs headless share this). #[derive(Debug, Clone, Copy)] pub struct MaterializeCtx { @@ -381,6 +398,8 @@ pub struct MaterializeCtx { /// the local disk store. Always `false` without the optional feature; /// setting it anyway errors rather than silently falling back to disk. pub chat_mode: bool, + /// See [`TitleResolution`]; carried from the pre-sandbox pin outcome. + pub title_resolution: TitleResolution, } impl MaterializeCtx { /// `--resume` miss bails fast. @@ -392,6 +411,11 @@ impl MaterializeCtx { has_worktree: args.worktree.is_some(), allow_remote_restore: Self::default_allow_remote_restore(), chat_mode: args.chat(), + title_resolution: if args.resume_target_pinned { + TitleResolution::PinnedPreSandbox + } else { + TitleResolution::Allowed + }, } } } @@ -493,6 +517,7 @@ pub async fn materialize_startup_for_cwd( session_id: id, original_cwd: None, title, + deferred_local_miss: false, }) } SessionStartupIntent::ForkFrom { @@ -523,6 +548,7 @@ pub async fn materialize_startup_for_cwd( session_id, original_cwd: None, title: None, + deferred_local_miss: false, }); } let r = resolve_existing_session(ctx, &session_id, cwd).await?; @@ -530,6 +556,7 @@ pub async fn materialize_startup_for_cwd( session_id: r.id, original_cwd: r.original_cwd, title: r.title, + deferred_local_miss: r.deferred_local_miss, }) } SessionStartupIntent::ForkFrom { @@ -566,6 +593,9 @@ struct ResolvedExisting { id: String, original_cwd: Option<PathBuf>, title: Option<String>, + /// True only for the worktree-defer arm: the target missed local + /// id/title resolution. + deferred_local_miss: bool, } /// Resolve an existing session for strict resume (local / any-cwd / remote / worktree defer). async fn resolve_existing_session( @@ -579,6 +609,7 @@ async fn resolve_existing_session( id: local_id, original_cwd: None, title: None, + deferred_local_miss: false, }); } if let Some(original_cwd) = xai_grok_shell::session::resolve_local_session_any_cwd(session_id) { @@ -595,26 +626,58 @@ async fn resolve_existing_session( id: session_id.to_string(), original_cwd: Some(PathBuf::from(original_cwd)), title: None, + deferred_local_miss: false, }); } + let arg_is_uuid = super::session_title_resolve::is_uuid_shaped(session_id); + if !arg_is_uuid + && ctx.title_resolution == TitleResolution::Allowed + && let Some(resolved) = resolve_session_by_title(session_id, cwd).await? + { + return Ok(resolved); + } if ctx.has_worktree { tracing::info!( session_id = %session_id, "Session not found locally; deferring restore to worktree resume handler" ); eprintln!( - "Session {} not found locally; it will be restored into the new worktree.", + "Session {:?} not found locally; it will be restored into the new worktree.", session_id ); return Ok(ResolvedExisting { id: session_id.to_string(), original_cwd: None, title: None, + deferred_local_miss: !arg_is_uuid, }); } if !ctx.allow_remote_restore { + if !arg_is_uuid { + anyhow::bail!( + "Session does not exist: {}", + super::session_title_resolve::title_miss_hint(session_id) + ); + } anyhow::bail!("Session does not exist"); } + let restored = restore_session_from_remote(session_id, cwd).await; + if arg_is_uuid { + return restored; + } + restored.map_err(|e| { + anyhow::anyhow!( + "{e:#}; {}", + super::session_title_resolve::title_miss_hint(session_id) + ) + }) +} +/// Remote-restore tail of [`resolve_existing_session`], split out so non-id +/// targets can wrap every failure with the title-miss hint. +async fn restore_session_from_remote( + session_id: &str, + cwd: &str, +) -> anyhow::Result<ResolvedExisting> { let raw_config = xai_grok_shell::config::load_effective_config() .map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?; if let Some((false, source)) = @@ -626,7 +689,7 @@ async fn resolve_existing_session( ); } eprintln!( - "Session {} not found locally, restoring from remote...", + "Session {:?} not found locally, restoring from remote...", session_id ); let agent_config = xai_grok_shell::agent::config::Config::new_from_toml_cfg(&raw_config) @@ -683,8 +746,33 @@ async fn resolve_existing_session( id: effective_id, original_cwd: None, title: None, + deferred_local_miss: false, }) } +/// Resolve a non-id resume arg as a session title among local sessions for `cwd`. +/// +/// Matching/disambiguation rules live in [`super::session_title_resolve`] +/// (shared with the pre-sandbox saved-profile peek); this adds the cwd-scoped +/// listing and the resolved-id announcement. The arg is matched in memory and +/// never used as a filesystem path. +async fn resolve_session_by_title( + arg: &str, + cwd: &str, +) -> anyhow::Result<Option<ResolvedExisting>> { + let summaries = xai_grok_shell::session::persistence::list_summaries(Some(cwd)).await?; + let Some(chosen) = super::session_title_resolve::select_by_title(arg, &summaries)? else { + return Ok(None); + }; + let id = chosen.info.id.to_string(); + tracing::info!(session_id = %id, "Session resolved by title"); + eprintln!("Resuming session {} (matched by title)", id); + Ok(Some(ResolvedExisting { + id, + original_cwd: None, + title: chosen.display_title_opt(), + deferred_local_miss: false, + })) +} #[cfg(test)] mod tests { use super::*; @@ -898,6 +986,7 @@ mod tests { has_worktree: false, allow_remote_restore: true, chat_mode: true, + title_resolution: TitleResolution::Allowed, } } #[test] @@ -948,6 +1037,7 @@ mod tests { session_id, original_cwd, title, + .. } => { assert_eq!(session_id, "conv-e2f1"); assert!(original_cwd.is_none()); @@ -1005,6 +1095,7 @@ mod tests { has_worktree: false, allow_remote_restore: false, chat_mode: false, + title_resolution: TitleResolution::Allowed, }; let err = materialize_startup_for_cwd( ctx, @@ -1086,4 +1177,157 @@ mod tests { other => panic!("expected Resume, got {other:?}"), } } + mod resume_by_title { + use super::*; + use crate::test_util::GrokHomeFixture; + fn local_ctx() -> MaterializeCtx { + MaterializeCtx { + has_worktree: false, + allow_remote_restore: false, + chat_mode: false, + title_resolution: TitleResolution::Allowed, + } + } + async fn resume(arg: &str, cwd: &str) -> anyhow::Result<MaterializedStartup> { + materialize_startup_for_cwd( + local_ctx(), + SessionStartupIntent::Resume { + session_id: Some(arg.into()), + most_recent_for_cwd: false, + }, + cwd, + ) + .await + } + /// Also covers letter-case insensitivity: the query case differs from + /// the stored title. + #[serial_test::serial(GROK_HOME)] + #[tokio::test] + async fn title_fallback_resumes_single_match_case_insensitively() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + let id = "bbbbbbbb-1111-2222-3333-444444444444"; + fx.write_summary( + &cwd_str, + id, + serde_json::json!({ "generated_title": "Fix Login Bug", "title_is_manual": true }), + ); + fx.write_summary( + &cwd_str, + "bbbbbbbb-1111-2222-3333-555555555555", + serde_json::json!({ "generated_title": "Other Work" }), + ); + match resume("fix login bug", &cwd_str).await.unwrap() { + MaterializedStartup::Resume { + session_id, + original_cwd, + title, + .. + } => { + assert_eq!(session_id, id); + assert!(original_cwd.is_none()); + assert_eq!(title.as_deref(), Some("Fix Login Bug")); + } + other => panic!("expected Resume, got {other:?}"), + } + } + /// Id resolution stays authoritative: when the arg is an on-disk + /// session id, the title fallback is never consulted even though + /// another session carries that exact title. + #[serial_test::serial(GROK_HOME)] + #[tokio::test] + async fn id_hit_beats_title_fallback() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + fx.write_summary( + &cwd_str, + "release-notes", + serde_json::json!({ "generated_title": "id-owner" }), + ); + fx.write_summary( + &cwd_str, + "cccccccc-1111-2222-3333-444444444444", + serde_json::json!({ "generated_title": "release-notes", "title_is_manual": true }), + ); + match resume("release-notes", &cwd_str).await.unwrap() { + MaterializedStartup::Resume { + session_id, title, .. + } => { + assert_eq!(session_id, "release-notes"); + assert!(title.is_none()); + } + other => panic!("expected Resume, got {other:?}"), + } + } + /// Provenance for the worktree failure hint: only the defer arm (a + /// local id/title miss under `--worktree`) flags the target; a + /// resolved local id — even a legacy non-UUID one — never does. + #[serial_test::serial(GROK_HOME)] + #[tokio::test] + async fn worktree_defer_flags_local_miss_and_local_hit_does_not() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + fx.write_summary(&cwd_str, "release-notes", serde_json::json!({})); + let worktree_ctx = MaterializeCtx { + has_worktree: true, + ..local_ctx() + }; + let resume_intent = |arg: &str| SessionStartupIntent::Resume { + session_id: Some(arg.into()), + most_recent_for_cwd: false, + }; + let hit = + materialize_startup_for_cwd(worktree_ctx, resume_intent("release-notes"), &cwd_str) + .await + .unwrap(); + match hit { + MaterializedStartup::Resume { + session_id, + deferred_local_miss, + .. + } => { + assert_eq!(session_id, "release-notes"); + assert!(!deferred_local_miss, "resolved id must not flag a miss"); + } + other => panic!("expected Resume, got {other:?}"), + } + let miss = materialize_startup_for_cwd( + worktree_ctx, + resume_intent("no such target"), + &cwd_str, + ) + .await + .unwrap(); + match miss { + MaterializedStartup::Resume { + session_id, + deferred_local_miss, + .. + } => { + assert_eq!(session_id, "no such target"); + assert!(deferred_local_miss, "defer must flag the local miss"); + } + other => panic!("expected Resume, got {other:?}"), + } + let uuid_miss = materialize_startup_for_cwd( + worktree_ctx, + resume_intent("99999999-9999-4999-8999-999999999999"), + &cwd_str, + ) + .await + .unwrap(); + match uuid_miss { + MaterializedStartup::Resume { + deferred_local_miss, + .. + } => { + assert!( + !deferred_local_miss, + "UUID defer must not flag a title-capable miss" + ); + } + other => panic!("expected Resume, got {other:?}"), + } + } + } } diff --git a/crates/codegen/xai-grok-pager/src/app/session_title_resolve.rs b/crates/codegen/xai-grok-pager/src/app/session_title_resolve.rs new file mode 100644 index 0000000..0bbfe73 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/app/session_title_resolve.rs @@ -0,0 +1,169 @@ +//! Resume-by-title selection shared by startup paths. +//! +//! The composition root pins an explicit non-id resume target to its +//! canonical session id BEFORE the irreversible OS sandbox +//! ([`super::cli::PagerArgs::pin_local_resume_target`]), so the saved-profile +//! peek and materialization act on one immutable target instead of racing a +//! concurrent rename between two title lookups. Materialization keeps +//! [`select_by_title`] as the authoritative error source (ambiguity / +//! no-match) and as a fallback for callers that bypass pinning. + +use xai_grok_shell::session::persistence::Summary; + +/// UUID-shaped resume args always take the id path, even when no such id +/// exists and a session is titled with that exact UUID. +pub(crate) fn is_uuid_shaped(arg: &str) -> bool { + uuid::Uuid::try_parse(arg).is_ok() +} + +/// Canonical key for title equality: trimmed `str::to_lowercase`. Plain +/// case-insensitive equality, not full Unicode caseless matching. +fn title_key(s: &str) -> String { + s.trim().to_lowercase() +} + +/// Hint appended to every terminal failure for a non-id resume target, so the +/// title miss stays visible even when remote restore produces the final error. +/// Debug formatting: the arg is arbitrary user text. +pub(crate) fn title_miss_hint(arg: &str) -> String { + format!( + "no session id or title matched {arg:?} for this directory; \ + try `grok sessions search {arg:?}`" + ) +} + +/// Select the local session a resume arg names by title. +/// +/// - `Ok(None)`: UUID-shaped or blank arg, or no title matched — the caller +/// keeps id-miss behavior. +/// - `Ok(Some)`: exactly one match, or a sole manual `/rename` among +/// duplicates (explicit user intent beats colliding auto titles). +/// - `Err`: ambiguous — never silently pick one, headless scripts need +/// determinism. Candidate titles are Debug-escaped: `/rename` accepts +/// arbitrary text, and raw control characters would corrupt the listing. +pub(crate) fn select_by_title<'a>( + arg: &str, + summaries: &'a [Summary], +) -> anyhow::Result<Option<&'a Summary>> { + if is_uuid_shaped(arg) { + return Ok(None); + } + let needle = title_key(arg); + if needle.is_empty() { + return Ok(None); + } + let matches: Vec<&Summary> = summaries + .iter() + .filter(|s| title_key(s.display_title()) == needle) + .collect(); + match matches.as_slice() { + [] => Ok(None), + [only] => Ok(Some(*only)), + _ => { + let manual: Vec<&&Summary> = matches + .iter() + .filter(|s| { + s.manual_title_opt() + .is_some_and(|t| title_key(&t) == needle) + }) + .collect(); + if let [only] = manual.as_slice() { + return Ok(Some(**only)); + } + let listing = matches + .iter() + .map(|s| format!(" {} {:?}", s.info.id, s.display_title())) + .collect::<Vec<_>>() + .join("\n"); + anyhow::bail!( + "Multiple sessions match title {:?}:\n{listing}\n\ + Resume by session id instead: grok --resume <session-id>", + arg.trim() + ); + } + } +} + +/// Outcome of the pre-sandbox resolution of an explicit resume arg. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum PinnedResumeTarget { + /// Nothing local resolved (UUID-shaped, no cwd, junk, or ambiguous + /// title): leave the raw arg alone — materialization owns the + /// authoritative error / remote path. + Unresolved, + /// Resolved as a local id (possibly the restored child of a remote id). + Id(String), + /// Resolved by title to this session. The selected summary's persisted + /// sandbox profile rides along: re-deriving it from the id is ambiguous + /// when a legacy id is duplicated across cwd dirs. + Title { + id: String, + sandbox_profile: Option<String>, + }, +} + +impl PinnedResumeTarget { + pub(crate) fn id(self) -> Option<String> { + match self { + Self::Unresolved => None, + Self::Id(id) | Self::Title { id, .. } => Some(id), + } + } +} + +/// Resolve an explicit resume arg to a pinned local session id before the +/// (irreversible) OS sandbox: the saved-profile peek and materialization must +/// consume one immutable target, not re-run title selection against mutable +/// summaries. Id lookups stay authoritative (same order as +/// `resolve_existing_session`), preserving the restored-child id so the peek +/// cannot drift to a same-id session in another cwd. Errs on a listing +/// failure (fail closed instead of guessing) and on ambiguity, which must +/// surface before the sandbox rather than after it. +pub(crate) fn presandbox_resume_target( + arg: &str, + cwd: Option<&str>, +) -> anyhow::Result<PinnedResumeTarget> { + if is_uuid_shaped(arg) { + return Ok(PinnedResumeTarget::Unresolved); + } + let Some(cwd) = cwd else { + return Ok(PinnedResumeTarget::Unresolved); + }; + if let Some(local_id) = xai_grok_shell::session::resolve_local_session(arg, cwd) { + return Ok(PinnedResumeTarget::Id(local_id)); + } + if xai_grok_shell::session::resolve_local_session_any_cwd(arg).is_some() { + return Ok(PinnedResumeTarget::Id(arg.to_string())); + } + let summaries = xai_grok_shell::session::persistence::local_summaries_for_cwd_sync(cwd) + .map_err(|e| { + anyhow::anyhow!("failed to list local sessions while resolving --resume {arg:?}: {e}") + })?; + Ok(select_by_title(arg, &summaries)? + .map(|s| PinnedResumeTarget::Title { + id: s.info.id.to_string(), + sandbox_profile: s.sandbox_profile.clone(), + }) + .unwrap_or(PinnedResumeTarget::Unresolved)) +} + +/// Failure message for a worktree resume. `local_miss_target` is `Some(arg)` +/// only when materialization deferred exactly this target after missing local +/// id/title resolution — provenance is threaded, never inferred from id +/// shape, so a resolved legacy non-UUID id gets no false no-match hint. +/// `detail` must already be user-sanitized: sanitizing the composed message +/// instead would collapse disk-full chains whole and erase the appended hint. +pub(crate) fn worktree_resume_failure_message( + local_miss_target: Option<&str>, + detail: &str, +) -> String { + let msg = format!("couldn't resume worktree session: {detail}"); + match local_miss_target { + Some(target) => format!("{msg}; {}", title_miss_hint(target)), + None => msg, + } +} + +#[cfg(test)] +#[path = "session_title_resolve_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-pager/src/app/session_title_resolve_tests.rs b/crates/codegen/xai-grok-pager/src/app/session_title_resolve_tests.rs new file mode 100644 index 0000000..871d764 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/app/session_title_resolve_tests.rs @@ -0,0 +1,428 @@ +use super::*; +use crate::test_util::GrokHomeFixture; +use clap::Parser; + +/// In-memory `Summary` via serde: every field without `#[serde(default)]` +/// must be present, and a struct literal would break on each new field. +fn summary(id: &str, title: Option<&str>, manual: bool) -> Summary { + serde_json::from_value(serde_json::json!({ + "info": { "id": id, "cwd": "/ws" }, + "session_summary": "auto summary", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-01T00:00:00Z", + "num_messages": 1, + "current_model_id": "grok-build", + "generated_title": title, + "title_is_manual": manual, + })) + .expect("valid Summary JSON") +} + +fn id_of(s: Option<&Summary>) -> String { + s.expect("expected a selected summary").info.id.to_string() +} + +#[test] +fn blank_or_unmatched_arg_selects_none() { + let s = [summary("a", Some("Fix Login"), false)]; + assert!(select_by_title("nope", &s).unwrap().is_none()); + assert!(select_by_title(" ", &s).unwrap().is_none()); +} + +#[test] +fn single_match_is_case_insensitive_and_trimmed() { + let s = [ + summary("a", Some("Fix Login Bug"), false), + summary("b", Some("Other"), false), + ]; + assert_eq!(id_of(select_by_title(" FIX login bug ", &s).unwrap()), "a"); +} + +/// The contract is a simple lowercase comparison: accented letters match +/// across case, but one-to-many case folds do not (`to_lowercase` maps +/// U+00DF to itself, so "STRASSE" never equals a stored "straße"). +#[test] +fn non_ascii_case_matching_contract() { + let s = [summary("a", Some("Café Löschen"), false)]; + assert_eq!(id_of(select_by_title("CAFÉ LÖSCHEN", &s).unwrap()), "a"); + let sharp = [summary("b", Some("straße"), false)]; + assert!(select_by_title("STRASSE", &sharp).unwrap().is_none()); +} + +#[test] +fn duplicate_auto_titles_error_lists_ids_with_escaped_titles() { + // A title with a newline would corrupt the one-match-per-line listing if + // rendered raw. + let s = [ + summary("id-a", Some("Dup\nTitle"), false), + summary("id-b", Some("Dup\nTitle"), false), + ]; + let msg = select_by_title("dup\ntitle", &s).unwrap_err().to_string(); + assert!( + msg.contains("id-a") && msg.contains("id-b"), + "both ids must be listed: {msg}" + ); + assert!( + msg.contains("Dup\\nTitle"), + "titles must be Debug-escaped: {msg}" + ); +} + +#[test] +fn sole_manual_rename_wins_among_duplicates() { + let s = [ + summary("auto1", Some("Dup"), false), + summary("man1", Some("Dup"), true), + summary("auto2", Some("Dup"), false), + ]; + assert_eq!(id_of(select_by_title("dup", &s).unwrap()), "man1"); +} + +#[test] +fn two_manual_renames_stay_ambiguous() { + let s = [ + summary("man1", Some("Dup"), true), + summary("man2", Some("Dup"), true), + ]; + let msg = select_by_title("Dup", &s).unwrap_err().to_string(); + assert!( + msg.contains("man1") && msg.contains("man2"), + "both manual ids must be listed: {msg}" + ); +} + +#[test] +fn uuid_shaped_arg_never_matches_titles() { + let uuid = "12345678-1234-1234-1234-123456789abc"; + let s = [summary("a", Some(uuid), true)]; + assert!(select_by_title(uuid, &s).unwrap().is_none()); +} + +#[test] +fn title_miss_hint_escapes_arg_and_suggests_search() { + let hint = title_miss_hint("evil\ntitle"); + assert!(hint.contains("evil\\ntitle"), "arg must be escaped: {hint}"); + assert!( + hint.contains("grok sessions search"), + "missing hint: {hint}" + ); +} + +/// The worktree defer drops the local zero-match context; the failure +/// message restores it only for a threaded deferred-miss target. A resolved +/// legacy non-UUID id (no threaded miss) must not get a false no-match hint. +#[test] +fn worktree_failure_message_hint_follows_threaded_provenance() { + let msg = worktree_resume_failure_message(Some("typo title"), "restore failed"); + assert!(msg.contains("couldn't resume worktree session: restore failed")); + assert!(msg.contains("no session id or title matched"), "{msg}"); + assert!(msg.contains("grok sessions search"), "{msg}"); + let resolved_msg = worktree_resume_failure_message(None, "restore failed"); + assert_eq!( + resolved_msg, + "couldn't resume worktree session: restore failed" + ); +} + +/// Regression (production wiring): pinning rewrites the `-r` title to the +/// canonical id, the profile peek sees the saved profile, and a conflicting +/// explicit profile is refused exactly like id resume. +#[serial_test::serial(GROK_HOME)] +#[test] +fn pin_title_resume_finds_saved_profile_and_conflicts() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + let id = "abcdabcd-1111-2222-3333-444444444444"; + fx.write_summary( + &cwd_str, + id, + serde_json::json!({ + "generated_title": "Locked Down", + "title_is_manual": true, + "sandbox_profile": "strict", + }), + ); + let mut args = crate::app::cli::PagerArgs::try_parse_from([ + "grok", + "-r", + "locked down", + "--sandbox", + "off", + ]) + .unwrap(); + args.pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap(); + assert_eq!(args.session_to_resume(), Some(id)); + + let saved = args.saved_resume_profile_for_cwd(Some(&cwd_str)); + assert_eq!(saved.as_deref(), Some("strict")); + match args.startup_sandbox_profile(saved.as_deref()) { + crate::app::cli::SandboxStartup::Conflict { requested, saved } => { + assert_eq!(requested, "off"); + assert_eq!(saved, "strict"); + } + other => panic!("expected Conflict, got {other:?}"), + } +} + +/// Regression: a non-UUID remote id with a restored local child pins to the +/// child, so the peek reads the child's profile instead of an exact same-id +/// session in another cwd. +#[serial_test::serial(GROK_HOME)] +#[test] +fn pin_prefers_restored_child_over_same_id_in_other_cwd() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + let child = "cafecafe-1111-2222-3333-444444444444"; + fx.write_summary( + &cwd_str, + child, + serde_json::json!({ + "parent_session_id": "legacy-remote-7", + "sandbox_profile": "strict", + }), + ); + let other_cwd = tempfile::tempdir().expect("other cwd tempdir"); + let other_str = other_cwd.path().to_string_lossy().to_string(); + fx.write_summary( + &other_str, + "legacy-remote-7", + serde_json::json!({ "sandbox_profile": "off" }), + ); + + let mut args = + crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "legacy-remote-7"]).unwrap(); + args.pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap(); + assert_eq!(args.session_to_resume(), Some(child)); + assert_eq!( + args.saved_resume_profile_for_cwd(Some(&cwd_str)).as_deref(), + Some("strict") + ); +} + +/// Regression: materialization consumes the pinned id via the ordinary id +/// path. A rename/create between the pre-sandbox pin and materialization +/// must not re-select by title. +#[serial_test::serial(GROK_HOME)] +#[tokio::test] +async fn materialization_consumes_pinned_id_after_concurrent_rename() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + let pinned = "dadadada-1111-2222-3333-444444444444"; + fx.write_summary( + &cwd_str, + pinned, + serde_json::json!({ "generated_title": "Alpha", "title_is_manual": true }), + ); + + let mut args = crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "alpha"]).unwrap(); + args.pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap(); + assert_eq!(args.session_to_resume(), Some(pinned)); + + // Concurrent rename/create after the pin: the pinned session loses the + // title and a decoy gains it. + fx.write_summary( + &cwd_str, + pinned, + serde_json::json!({ "generated_title": "Beta", "title_is_manual": true }), + ); + fx.write_summary( + &cwd_str, + "dadadada-1111-2222-3333-555555555555", + serde_json::json!({ "generated_title": "Alpha", "title_is_manual": true }), + ); + + use crate::app::session_startup::{MaterializedStartup, materialize_startup_for_cwd}; + let intent = args.session_startup_intent().unwrap(); + let out = materialize_startup_for_cwd(pinned_local_ctx(), intent, &cwd_str) + .await + .unwrap(); + match out { + MaterializedStartup::Resume { session_id, .. } => assert_eq!(session_id, pinned), + other => panic!("expected Resume, got {other:?}"), + } +} + +/// Local-only ctx carrying the composition root's pin outcome +/// (`resume_target_pinned` maps to `PinnedPreSandbox` in production). +fn pinned_local_ctx() -> crate::app::session_startup::MaterializeCtx { + crate::app::session_startup::MaterializeCtx { + has_worktree: false, + allow_remote_restore: false, + chat_mode: false, + title_resolution: crate::app::session_startup::TitleResolution::PinnedPreSandbox, + } +} + +/// Regression: an ambiguous title now fails at the pin, before the +/// irreversible sandbox, instead of deferring to materialization. +#[serial_test::serial(GROK_HOME)] +#[test] +fn pin_ambiguous_title_errors_before_sandbox() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + fx.write_summary( + &cwd_str, + "e0e0e0e0-1111-2222-3333-444444444444", + serde_json::json!({ "generated_title": "Dup" }), + ); + fx.write_summary( + &cwd_str, + "e0e0e0e0-1111-2222-3333-555555555555", + serde_json::json!({ "generated_title": "Dup" }), + ); + + let mut args = crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "Dup"]).unwrap(); + let msg = args + .pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap_err() + .to_string(); + assert!( + msg.contains("Multiple sessions match title"), + "unexpected message: {msg}" + ); +} + +/// Regression: a definitive pre-sandbox no-match must not be re-selected by +/// title at materialization — a session created/renamed into the title after +/// the sandbox would resume under an unverified profile. +#[serial_test::serial(GROK_HOME)] +#[tokio::test] +async fn pinned_no_match_does_not_retry_title_after_sandbox() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + + let mut args = crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "ghost"]).unwrap(); + args.pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap(); + assert!(args.resume_target_pinned); + assert_eq!(args.session_to_resume(), Some("ghost")); + + // The title appears only after the pin (and the sandbox). + let late = "f0f0f0f0-1111-2222-3333-444444444444"; + fx.write_summary( + &cwd_str, + late, + serde_json::json!({ "generated_title": "ghost", "title_is_manual": true }), + ); + + use crate::app::session_startup::{ + MaterializeCtx, TitleResolution, materialize_startup_for_cwd, + }; + let intent = args.session_startup_intent().unwrap(); + let msg = materialize_startup_for_cwd(pinned_local_ctx(), intent.clone(), &cwd_str) + .await + .unwrap_err() + .to_string(); + assert!( + msg.contains("no session id or title matched"), + "must not resume the late title match: {msg}" + ); + // Contrast: an unpinned caller (no pre-sandbox pin ran) may still select + // the title — the gate, not the data, decides. + let allowed_ctx = MaterializeCtx { + title_resolution: TitleResolution::Allowed, + ..pinned_local_ctx() + }; + let out = materialize_startup_for_cwd(allowed_ctx, intent, &cwd_str) + .await + .unwrap(); + match out { + crate::app::session_startup::MaterializedStartup::Resume { session_id, .. } => { + assert_eq!(session_id, late); + } + other => panic!("expected Resume, got {other:?}"), + } +} + +/// Regression: a pinned non-UUID id that vanishes before materialization +/// must not be reinterpreted as another session's title. +#[serial_test::serial(GROK_HOME)] +#[tokio::test] +async fn pinned_non_uuid_id_is_not_reinterpreted_as_title() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + fx.write_summary(&cwd_str, "legacy-remote-7", serde_json::json!({})); + + let mut args = + crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "legacy-remote-7"]).unwrap(); + args.pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap(); + assert!(args.resume_target_pinned); + assert_eq!(args.session_to_resume(), Some("legacy-remote-7")); + + // The pinned id vanishes; a decoy session gains it as a title. + fx.remove_session(&cwd_str, "legacy-remote-7"); + fx.write_summary( + &cwd_str, + "f1f1f1f1-1111-2222-3333-444444444444", + serde_json::json!({ "generated_title": "legacy-remote-7", "title_is_manual": true }), + ); + + use crate::app::session_startup::materialize_startup_for_cwd; + let intent = args.session_startup_intent().unwrap(); + let msg = materialize_startup_for_cwd(pinned_local_ctx(), intent, &cwd_str) + .await + .unwrap_err() + .to_string(); + assert!( + msg.contains("no session id or title matched"), + "must not resume the decoy titled with the pinned id: {msg}" + ); +} + +/// Regression: a legacy id duplicated across cwd dirs is ambiguous to the +/// session listings (`RelocationView::select` drops multi-path journal-less +/// ids before the cwd filter), so its title never reaches selection: the pin +/// stays unresolved, the profile peek finds nothing, and materialization +/// fails closed with the hint instead of resuming under an unverified +/// profile. The carried-profile path for unique ids is pinned by +/// `pin_title_resume_finds_saved_profile_and_conflicts`. +#[serial_test::serial(GROK_HOME)] +#[tokio::test] +async fn duplicate_legacy_id_is_not_title_addressable() { + let mut fx = GrokHomeFixture::new(); + let cwd_str = fx.cwd_str(); + fx.write_summary( + &cwd_str, + "legacy-twin", + serde_json::json!({ + "generated_title": "Locked Down", + "title_is_manual": true, + "sandbox_profile": "strict", + }), + ); + let other_cwd = tempfile::tempdir().expect("other cwd tempdir"); + let other_str = other_cwd.path().to_string_lossy().to_string(); + fx.write_summary( + &other_str, + "legacy-twin", + serde_json::json!({ "sandbox_profile": "off" }), + ); + + let mut args = + crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "locked down"]).unwrap(); + args.pin_local_resume_target_for_cwd(Some(&cwd_str)) + .unwrap(); + assert!(args.resume_target_pinned); + assert_eq!(args.session_to_resume(), Some("locked down")); + assert!(args.saved_resume_profile_for_cwd(Some(&cwd_str)).is_none()); + + use crate::app::session_startup::{ + MaterializeCtx, TitleResolution, materialize_startup_for_cwd, + }; + let ctx = MaterializeCtx::from_pager_args(&args); + assert_eq!(ctx.title_resolution, TitleResolution::PinnedPreSandbox); + + let intent = args.session_startup_intent().unwrap(); + let msg = materialize_startup_for_cwd(pinned_local_ctx(), intent, &cwd_str) + .await + .unwrap_err() + .to_string(); + assert!( + msg.contains("no session id or title matched"), + "duplicate-id session must fail closed, not resume: {msg}" + ); +} diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/doctor_format_tests.rs b/crates/codegen/xai-grok-pager/src/diagnostics/doctor_format_tests.rs index f73f91b..0871a61 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/doctor_format_tests.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/doctor_format_tests.rs @@ -166,7 +166,7 @@ fn tmux_config_and_reload_notes_output_is_stable() { &terminal, TmuxProbeFacts { version: TmuxProbeResult::Unavailable, - extended_keys: TmuxProbeResult::Unavailable, + extended_keys: TmuxProbeResult::Available("off".to_owned()), set_clipboard: TmuxProbeResult::Available("off".to_owned()), allow_passthrough_support: TmuxProbeResult::Available(()), allow_passthrough: TmuxProbeResult::Available("off".to_owned()), @@ -200,14 +200,17 @@ fn tmux_config_and_reload_notes_output_is_stable() { "Issues (3)\n", "\n", " ! terminal.tmux-clipboard `set-clipboard` is off in tmux, so OSC 52 clipboard copies are blocked\n", + " Automatic setup: `grok doctor fix tmux-clipboard`\n", " Add `set -g set-clipboard on` to ~/.byobu/.tmux.conf\n", " Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n", "\n", " ! terminal.dcs-passthrough `allow-passthrough` is off in tmux, which can block clipboard copies in nested sessions\n", - " Add `set -g allow-passthrough on` to ~/.byobu/.tmux.conf\n", + " Automatic setup: `grok doctor fix dcs-passthrough`\n", + " Add `set -wg allow-passthrough on` to ~/.byobu/.tmux.conf\n", " Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n", "\n", " ! terminal.tmux-extended-keys `extended-keys` is off in tmux, so some shortcuts may not work\n", + " Automatic setup: `grok doctor fix tmux-extended-keys`\n", " Add `set -g extended-keys on` to ~/.byobu/.tmux.conf\n", " Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n", ) @@ -503,11 +506,12 @@ fn runtime_merge_does_not_duplicate_view_findings() { for id in [ "terminal.tmux-clipboard", "terminal.dcs-passthrough", + "terminal.tmux-extended-keys", "terminal.limited-color", ] { assert_eq!(output.matches(id).count(), 1, "{id}:\n{output}"); } - assert!(output.contains("Issues (3)"), "{output}"); + assert!(output.contains("Issues (4)"), "{output}"); } #[test] @@ -611,6 +615,12 @@ fn keyboard_fact_formats_from_explicit_target_evidence() { multiplexer: MultiplexerKind::Undetected, byobu: None, ssh: false, + tmux: crate::diagnostics::TmuxFacts { + extended_keys: crate::diagnostics::TmuxOptionFact::Unavailable, + set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable, + allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable, + allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable, + }, color: ColorFacts { level: RuntimeFact::Available(ColorLevel::TrueColor), available_themes: crate::theme::ThemeKind::ALL.to_vec(), diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/fix.rs b/crates/codegen/xai-grok-pager/src/diagnostics/fix.rs index 1891715..a9f6eda 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/fix.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/fix.rs @@ -4,22 +4,24 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use xai_grok_config::managed_text::{ - CommentSyntax, ManagedConfig, ManagedConfigPlan, ManagedConfigRequest, ManagedConfigStatus, - ManagedItem, SyntaxValidator, + CommentSyntax, ManagedConfig, ManagedConfigOutcome, ManagedConfigPlan, ManagedConfigRequest, + ManagedConfigStatus, ManagedItem, ManagedItemState, SyntaxValidator, }; -use crate::diagnostics::{DiagnosticId, DiagnosticReport}; -use crate::terminal::TerminalContext; +use crate::diagnostics::{DiagnosticId, DiagnosticReport, TmuxOptionFact, TmuxSupportFact}; +use crate::terminal::{ByobuBackend, TerminalContext}; pub const SSH_WRAP_ID: DiagnosticId = DiagnosticId::new("terminal", "ssh-wrap"); +pub const TMUX_CLIPBOARD_ID: DiagnosticId = DiagnosticId::new("terminal", "tmux-clipboard"); +pub const DCS_PASSTHROUGH_ID: DiagnosticId = DiagnosticId::new("terminal", "dcs-passthrough"); +pub const TMUX_EXTENDED_KEYS_ID: DiagnosticId = DiagnosticId::new("terminal", "tmux-extended-keys"); pub const SSH_WRAP_FIX_COMMAND: &str = "grok doctor fix terminal.ssh-wrap"; pub const SSH_WRAP_ONE_OFF: &str = "grok wrap ssh <host>"; -const SSH_WRAP_FIX_HANDLE: &str = "ssh-wrap"; - const MANAGED_NAMESPACE: &str = "grok doctor"; const SSH_WRAP_ALIAS_POSIX: &str = "alias ssh='grok wrap ssh'"; const SSH_WRAP_ALIAS_FISH: &str = "alias ssh 'grok wrap ssh'"; +const TMUX_SCANNER_CAVEAT: &str = "Grok checks this file for direct global assignments of this option. Review sourced files, conditionals, plugins, and generated tmux setup yourself."; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AutomaticRemediation { @@ -29,22 +31,68 @@ pub struct AutomaticRemediation { #[derive(Clone, Debug, Eq, PartialEq)] pub struct FixRequest { - pub id: DiagnosticId, - pub home: PathBuf, - pub shell: Option<PathBuf>, - pub validator: Option<PathBuf>, + id: DiagnosticId, + home: SafeAbsoluteDirectory, + shell: Option<PathBuf>, + validator: Option<PathBuf>, + byobu_config_dir: Option<PathBuf>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SafeAbsoluteDirectory(PathBuf); + +impl SafeAbsoluteDirectory { + fn parse(path: PathBuf, label: &'static str) -> Result<Self, FixError> { + use std::path::Component; + + let is_root_only = path.parent().is_none(); + let has_unsafe_component = path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)); + let is_renderable = path + .to_str() + .is_some_and(|value| !value.chars().any(char::is_control) && !value.contains('~')); + if !path.is_absolute() || is_root_only || has_unsafe_component || !is_renderable { + return Err(FixError::UnsafeDirectory { label, path }); + } + Ok(Self(path)) + } + + fn join(&self, path: &str) -> PathBuf { + self.0.join(path) + } } impl FixRequest { + #[cfg(test)] + pub(crate) fn new_for_test( + id: DiagnosticId, + home: &Path, + shell: Option<PathBuf>, + validator: Option<PathBuf>, + byobu_config_dir: Option<PathBuf>, + ) -> Result<Self, FixError> { + Ok(Self { + id, + home: SafeAbsoluteDirectory::parse(home.to_path_buf(), "HOME")?, + shell, + validator, + byobu_config_dir, + }) + } + pub fn from_environment(id: DiagnosticId) -> Result<Self, FixError> { - let home = actual_home().ok_or(FixError::HomeUnavailable)?; + let home = + SafeAbsoluteDirectory::parse(actual_home().ok_or(FixError::HomeUnavailable)?, "HOME")?; let shell = std::env::var_os("SHELL").map(PathBuf::from); let validator = shell.as_deref().and_then(resolve_validator_program); + let byobu_config_dir = std::env::var_os("BYOBU_CONFIG_DIR").map(PathBuf::from); Ok(Self { id, home, shell, validator, + byobu_config_dir, }) } } @@ -96,29 +144,145 @@ pub struct PlannedChange { pub target_path: PathBuf, pub block: String, pub backup_path_hint: Option<PathBuf>, + pub will_write: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FixActivation { + SatisfiedNow, + RequiresReload, } #[derive(Clone, Debug)] pub struct FixPlan { - pub id: DiagnosticId, - pub shell: ShellKind, - pub changes: Vec<PlannedChange>, - pub caveats: Vec<&'static str>, + id: DiagnosticId, + change: PlannedChange, + caveats: Vec<&'static str>, + payload: FixPayload, +} + +impl FixPlan { + pub fn id(&self) -> DiagnosticId { + self.id + } + + pub fn change(&self) -> &PlannedChange { + &self.change + } + + pub fn caveats(&self) -> &[&'static str] { + &self.caveats + } +} + +#[derive(Clone, Debug)] +enum FixPayload { + SshWrap(SshWrapPlan), + TmuxOption(TmuxOptionPlan), +} + +#[derive(Clone, Debug)] +struct SshWrapPlan { + shell: ShellKind, managed: ManagedConfigPlan, } +#[derive(Clone, Debug)] +struct TmuxOptionPlan { + spec: &'static TmuxOptionSpec, + managed: ManagedConfigPlan, + direct_state: DirectOptionState, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FixStatus { Applied, AlreadyConfigured, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct ChangedFile { + path: PathBuf, + backup_path: Option<PathBuf>, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct FixOutcome { - pub id: DiagnosticId, - pub status: FixStatus, - pub changed_path: PathBuf, - pub backup_path: Option<PathBuf>, + id: DiagnosticId, + status: FixStatus, + changed_file: ChangedFile, + activation: FixActivation, + /// Shell used to plan/apply SSH-wrap. Post-apply verification must use this + /// rather than re-reading `$SHELL`, which may be missing or different. + shell: Option<ShellKind>, +} + +impl FixOutcome { + #[cfg(test)] + pub(crate) fn new_for_test( + id: DiagnosticId, + status: FixStatus, + path: PathBuf, + backup_path: Option<PathBuf>, + activation: FixActivation, + shell: Option<ShellKind>, + ) -> Self { + Self::new( + id, + status, + ChangedFile { path, backup_path }, + activation, + shell, + ) + } + + fn new( + id: DiagnosticId, + status: FixStatus, + changed_file: ChangedFile, + activation: FixActivation, + shell: Option<ShellKind>, + ) -> Self { + Self { + id, + status, + changed_file, + activation, + shell, + } + } + + pub fn id(&self) -> DiagnosticId { + self.id + } + + pub fn status(&self) -> FixStatus { + self.status + } + + pub fn activation(&self) -> FixActivation { + self.activation + } + + pub fn changed_path(&self) -> &Path { + &self.changed_file.path + } + + pub fn backup_path(&self) -> Option<&Path> { + self.changed_file.backup_path.as_deref() + } + + /// Shell that planned and applied this fix, when the fix is shell-scoped. + pub fn shell(&self) -> Option<ShellKind> { + self.shell + } + + /// Whether the SSH-wrap managed alias is present for the shell that applied + /// this outcome. Uses the planned shell, not the current `$SHELL`. + pub fn managed_alias_is_configured(&self) -> bool { + self.shell + .is_some_and(|shell| managed_alias_configured(&self.changed_file.path, shell)) + } } #[derive(Debug)] @@ -127,11 +291,16 @@ pub enum FixError { PlatformUnsupported, HomeUnavailable, NotApplicable, + TmuxNotApplicable, RemoteSession, UnsupportedShell, + ByobuConfigUnavailable, + UnsafeDirectory { label: &'static str, path: PathBuf }, ExistingCustomization { path: PathBuf, detail: String }, Managed(xai_grok_config::managed_text::ManagedConfigError), + TmuxManaged(xai_grok_config::managed_text::ManagedConfigError), PostconditionFailed, + TmuxPostconditionFailed, } impl std::fmt::Display for FixError { @@ -145,30 +314,52 @@ impl std::fmt::Display for FixError { formatter, "Automatic SSH setup is not available on Windows. Run `{SSH_WRAP_ONE_OFF}` when needed." ), - Self::HomeUnavailable => { - formatter.write_str("Grok could not find your home directory.") - } - Self::NotApplicable => { - formatter.write_str("This fix does not apply to VS Code Remote sessions.") - } - Self::RemoteSession => { - formatter.write_str("Run this fix on your local computer, not in the SSH session.") - } + Self::HomeUnavailable => formatter.write_str("Grok could not find your home directory."), + Self::NotApplicable => formatter + .write_str("This fix does not apply to VS Code Remote sessions."), + Self::TmuxNotApplicable => formatter + .write_str("This fix is not applicable to the current report."), + Self::RemoteSession => formatter + .write_str("Run this fix on your local computer, not in the SSH session."), Self::UnsupportedShell => write!( formatter, "Automatic setup supports Bash, zsh, and fish. For another shell, run `{SSH_WRAP_ONE_OFF}` when needed." ), + Self::ByobuConfigUnavailable => formatter.write_str( + "Grok could not determine Byobu's effective config directory. Keep `BYOBU_CONFIG_DIR` set in this session, then run the fix again.", + ), + Self::UnsafeDirectory { label, path } => write!( + formatter, + "Grok refused unsafe {label} `{}`. Use a non-root absolute directory without control characters, `~`, `.` or `..` components.", + path.display() + ), + Self::ExistingCustomization { path, detail } + if detail.starts_with("existing `alias ssh") + || detail.contains("`ssh` fish function") => + { + write!( + formatter, + "Grok found an existing SSH alias or function in {} and did not change it: {detail}", + path.display() + ) + } Self::ExistingCustomization { path, detail } => write!( formatter, - "Grok found an existing SSH alias or function in {} and did not change it: {detail}", + "Grok found an existing customization in {} and did not change it: {detail}", path.display() ), Self::Managed(error) => write!( formatter, "Could not update your shell configuration: {error}" ), + Self::TmuxManaged(error) => { + write!(formatter, "Could not update your tmux configuration: {error}") + } Self::PostconditionFailed => formatter .write_str("The configuration changed, but Grok could not verify the SSH alias."), + Self::TmuxPostconditionFailed => formatter.write_str( + "The configuration changed, but Grok could not verify the managed tmux option.", + ), } } } @@ -176,7 +367,7 @@ impl std::fmt::Display for FixError { impl std::error::Error for FixError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Managed(error) => Some(error), + Self::Managed(error) | Self::TmuxManaged(error) => Some(error), _ => None, } } @@ -188,29 +379,147 @@ impl From<xai_grok_config::managed_text::ManagedConfigError> for FixError { } } -pub fn resolve_fix_id(value: &str) -> Result<DiagnosticId, FixError> { - match value { - "terminal.ssh-wrap" | SSH_WRAP_FIX_HANDLE => Ok(SSH_WRAP_ID), - other => Err(FixError::UnknownId(other.to_owned())), - } -} - -pub(crate) fn human_fix_command(id: DiagnosticId) -> Option<String> { - fix_handle(id).map(|handle| format!("grok doctor fix {handle}")) -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum AutomaticFixAvailability { Here, RunLocally, } +#[derive(Clone, Copy)] +enum FixKind { + SshWrap, + TmuxOption(&'static TmuxOptionSpec), +} + +#[derive(Clone, Copy)] +struct FixSpec { + id: DiagnosticId, + handle: &'static str, + label: &'static str, + command: &'static str, + kind: FixKind, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TmuxEvidence { + Clipboard, + DcsPassthrough, + ExtendedKeys, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TmuxOptionSpec { + id: DiagnosticId, + option: &'static str, + line: &'static str, + healthy_values: &'static [&'static str], + evidence: TmuxEvidence, + scope: TmuxOptionScope, + label: &'static str, +} + +const TMUX_CLIPBOARD_SPEC: TmuxOptionSpec = TmuxOptionSpec { + id: TMUX_CLIPBOARD_ID, + option: "set-clipboard", + line: "set -g set-clipboard on", + healthy_values: &["on", "external"], + evidence: TmuxEvidence::Clipboard, + scope: TmuxOptionScope::Server, + label: "Enable tmux clipboard forwarding", +}; +const DCS_PASSTHROUGH_SPEC: TmuxOptionSpec = TmuxOptionSpec { + id: DCS_PASSTHROUGH_ID, + option: "allow-passthrough", + line: "set -wg allow-passthrough on", + healthy_values: &["on", "all"], + evidence: TmuxEvidence::DcsPassthrough, + scope: TmuxOptionScope::Window, + label: "Enable tmux DCS passthrough", +}; +const TMUX_EXTENDED_KEYS_SPEC: TmuxOptionSpec = TmuxOptionSpec { + id: TMUX_EXTENDED_KEYS_ID, + option: "extended-keys", + line: "set -g extended-keys on", + healthy_values: &["on"], + evidence: TmuxEvidence::ExtendedKeys, + scope: TmuxOptionScope::Server, + label: "Enable tmux extended keys", +}; + +const FIX_REGISTRY: &[FixSpec] = &[ + FixSpec { + id: SSH_WRAP_ID, + handle: "ssh-wrap", + label: "Set up local SSH wrapping", + command: SSH_WRAP_FIX_COMMAND, + kind: FixKind::SshWrap, + }, + FixSpec { + id: TMUX_CLIPBOARD_ID, + handle: "tmux-clipboard", + label: TMUX_CLIPBOARD_SPEC.label, + command: "grok doctor fix terminal.tmux-clipboard", + kind: FixKind::TmuxOption(&TMUX_CLIPBOARD_SPEC), + }, + FixSpec { + id: DCS_PASSTHROUGH_ID, + handle: "dcs-passthrough", + label: DCS_PASSTHROUGH_SPEC.label, + command: "grok doctor fix terminal.dcs-passthrough", + kind: FixKind::TmuxOption(&DCS_PASSTHROUGH_SPEC), + }, + FixSpec { + id: TMUX_EXTENDED_KEYS_ID, + handle: "tmux-extended-keys", + label: TMUX_EXTENDED_KEYS_SPEC.label, + command: "grok doctor fix terminal.tmux-extended-keys", + kind: FixKind::TmuxOption(&TMUX_EXTENDED_KEYS_SPEC), + }, +]; + +fn fix_spec(id: DiagnosticId) -> Option<&'static FixSpec> { + FIX_REGISTRY.iter().find(|spec| spec.id == id) +} + +pub fn resolve_fix_id(value: &str) -> Result<DiagnosticId, FixError> { + FIX_REGISTRY + .iter() + .find(|spec| value == spec.handle || value == spec.id.to_string()) + .map(|spec| spec.id) + .ok_or_else(|| FixError::UnknownId(value.to_owned())) +} + +pub(crate) fn human_fix_command(id: DiagnosticId) -> Option<String> { + fix_spec(id).map(|spec| format!("grok doctor fix {}", spec.handle)) +} + +pub(crate) fn automatic_fix_choices() +-> impl Iterator<Item = (DiagnosticId, &'static str, &'static str)> { + FIX_REGISTRY + .iter() + .map(|spec| (spec.id, spec.handle, spec.label)) +} + +pub(crate) fn automatic_remediation_for(id: DiagnosticId) -> Option<AutomaticRemediation> { + fix_spec(id).map(|spec| AutomaticRemediation { + fix_id: id, + command: spec.command, + }) +} + +pub fn ssh_wrap_automatic_remediation() -> AutomaticRemediation { + automatic_remediation_for(SSH_WRAP_ID).expect("registered SSH wrap fix") +} + pub(crate) fn select_fix_plan( id: DiagnosticId, report: &DiagnosticReport, terminal: &TerminalContext, ) -> Result<Option<FixPlan>, FixError> { - if terminal.is_ssh || terminal.is_official_vscode_remote || report.facts.ssh { + let spec = fix_spec(id).ok_or_else(|| FixError::UnknownId(id.to_string()))?; + if matches!(spec.kind, FixKind::SshWrap) + && (terminal.is_ssh || terminal.is_official_vscode_remote || report.facts.ssh) + { return Ok(None); } plan_fix(FixRequest::from_environment(id)?, report, terminal).map(Some) @@ -233,15 +542,16 @@ fn applicable_automatic_fixes_with( .iter() .filter_map(|finding| { let automatic = finding.automatic_remediation?; - let handle = fix_handle(automatic.fix_id)?; - let availability = - if terminal.is_ssh || terminal.is_official_vscode_remote || report.facts.ssh { - AutomaticFixAvailability::RunLocally - } else { - plan_fix(request_for(automatic.fix_id).ok()?, report, terminal).ok()?; - AutomaticFixAvailability::Here - }; - Some((automatic.fix_id, handle, availability)) + let spec = fix_spec(automatic.fix_id)?; + let availability = if matches!(spec.kind, FixKind::SshWrap) + && (terminal.is_ssh || terminal.is_official_vscode_remote || report.facts.ssh) + { + AutomaticFixAvailability::RunLocally + } else { + plan_fix(request_for(automatic.fix_id).ok()?, report, terminal).ok()?; + AutomaticFixAvailability::Here + }; + Some((automatic.fix_id, spec.handle, availability)) }) .collect() } @@ -256,17 +566,16 @@ pub(crate) fn format_applicable_automatic_fixes( } let mut output = String::from("Automatic fixes:\n"); - for (_id, handle, availability) in fixes { - output.push_str(&format!(" {handle:<16} Set up local SSH wrapping\n")); + for (id, handle, availability) in fixes { + let label = fix_spec(id).map_or("Apply automatic fix", |spec| spec.label); + output.push_str(&format!(" {handle:<20} {label}\n")); match availability { AutomaticFixAvailability::Here => output.push_str(&format!( " Run: grok doctor fix {handle}\n In Grok: /doctor fix {handle}\n" )), - AutomaticFixAvailability::RunLocally => { - output.push_str(&format!( - " On your local computer, run: grok doctor fix {handle}\n" - )); - } + AutomaticFixAvailability::RunLocally => output.push_str(&format!( + " On your local computer, run: grok doctor fix {handle}\n" + )), } } output @@ -277,35 +586,52 @@ pub(crate) fn format_fix_preview(plan: &FixPlan) -> String { let mut output = String::from("Doctor Fix\n\n"); let _ = writeln!(output, "Fix: {}", plan.id); - let _ = writeln!(output, "Shell: {}", plan.shell.name()); - for change in &plan.changes { - let _ = writeln!(output, "File: {}", change.requested_path.display()); - if change.target_path != change.requested_path { + if let FixPayload::SshWrap(payload) = &plan.payload { + let _ = writeln!(output, "Shell: {}", payload.shell.name()); + } + let change = &plan.change; + let _ = writeln!(output, "File: {}", preview_path(&change.requested_path)); + if change.target_path != change.requested_path { + let _ = writeln!( + output, + "Actual file: {} (symlink target)", + preview_path(&change.target_path) + ); + } + if change.will_write { + let _ = writeln!(output, "\nText to add:\n{}", change.block); + } else { + output.push_str("\nText to add: None. The requested setting is already configured.\n"); + } + match &change.backup_path_hint { + Some(path) => { let _ = writeln!( output, - "Actual file: {} (symlink target)", - change.target_path.display() + "\nBackup will be saved to: {}\nIf that file exists, Grok will choose a unique name.", + preview_path(path) ); } - let _ = writeln!(output, "\nText to add:\n{}", change.block); - match &change.backup_path_hint { - Some(path) => { - let _ = writeln!( - output, - "\nBackup will be saved to: {}\nIf that file exists, Grok will choose a unique name.", - path.display() - ); - } - None => output.push_str("\nBackup: None. The file is new or no changes are needed.\n"), + None => output.push_str("\nBackup: None. The file is new or no changes are needed.\n"), + } + match &plan.payload { + FixPayload::SshWrap(_) => { + output.push_str( + "\nWhat this changes:\n In new interactive shells, `ssh ...` runs as `grok wrap ssh ...`.\n", + ); + let _ = writeln!( + output, + " To use once without changing config: `{SSH_WRAP_ONE_OFF}`." + ); + } + FixPayload::TmuxOption(payload) => { + let instruction = reload_instruction(&plan.change.requested_path); + let _ = writeln!( + output, + "\nWhat this changes:\n Persists `{}`.\n Grok does not reload or modify the live tmux server.\n After applying, {instruction}\n Run /doctor again to verify the live setting.", + payload.spec.line, + ); } } - output.push_str( - "\nWhat this changes:\n In new interactive shells, `ssh ...` runs as `grok wrap ssh ...`.\n", - ); - let _ = writeln!( - output, - " To use once without changing config: `{SSH_WRAP_ONE_OFF}`." - ); output.push_str("Caveats:\n"); for caveat in &plan.caveats { let _ = writeln!(output, " - {caveat}"); @@ -313,18 +639,23 @@ pub(crate) fn format_fix_preview(plan: &FixPlan) -> String { output } -fn fix_handle(id: DiagnosticId) -> Option<&'static str> { - (id == SSH_WRAP_ID).then_some(SSH_WRAP_FIX_HANDLE) -} - pub fn plan_fix( request: FixRequest, report: &DiagnosticReport, terminal: &TerminalContext, ) -> Result<FixPlan, FixError> { - if request.id != SSH_WRAP_ID { - return Err(FixError::UnknownId(request.id.to_string())); + let spec = fix_spec(request.id).ok_or_else(|| FixError::UnknownId(request.id.to_string()))?; + match spec.kind { + FixKind::SshWrap => plan_ssh_wrap(request, report, terminal), + FixKind::TmuxOption(tmux) => plan_tmux_option(request, report, terminal, tmux), } +} + +fn plan_ssh_wrap( + request: FixRequest, + report: &DiagnosticReport, + terminal: &TerminalContext, +) -> Result<FixPlan, FixError> { if cfg!(windows) { return Err(FixError::PlatformUnsupported); } @@ -340,15 +671,13 @@ pub fn plan_fix( .as_deref() .and_then(ShellKind::from_shell_path) .ok_or(FixError::UnsupportedShell)?; - let path = shell.config_path(&request.home); - let validator = validator_for(shell, request.validator); let managed = ManagedConfig::plan(ManagedConfigRequest { - path, + path: shell.config_path(&request.home.0), namespace: MANAGED_NAMESPACE.to_owned(), owned_item_prefix: "terminal.".to_owned(), items: vec![ManagedItem::new(request.id.to_string(), shell.alias())], comments: CommentSyntax::hash(), - validator, + validator: validator_for(shell, request.validator), })?; if let Some(detail) = detect_ssh_customization(managed.inspection().unmanaged_text(), shell) { return Err(FixError::ExistingCustomization { @@ -356,19 +685,10 @@ pub fn plan_fix( detail, }); } - let block = managed - .managed_block() - .ok_or(FixError::PostconditionFailed)?; - let change = PlannedChange { - requested_path: managed.requested_path().to_path_buf(), - target_path: managed.target_path().to_path_buf(), - block, - backup_path_hint: managed.backup_path_hint().map(Path::to_path_buf), - }; + let change = planned_change(&managed)?; Ok(FixPlan { id: request.id, - shell, - changes: vec![change], + change, caveats: vec![ "The alias loads only in new interactive shells.", "Use `command ssh ...` to bypass the alias.", @@ -376,35 +696,292 @@ pub fn plan_fix( "`grok wrap` starts the SSH process directly, so the alias does not loop.", "Grok checks this file for direct SSH aliases and functions. Review sourced files, plugins, and generated shell setup yourself.", ], - managed, + payload: FixPayload::SshWrap(SshWrapPlan { shell, managed }), + }) +} + +fn plan_tmux_option( + request: FixRequest, + report: &DiagnosticReport, + terminal: &TerminalContext, + spec: &'static TmuxOptionSpec, +) -> Result<FixPlan, FixError> { + if !terminal.is_tmux_backed() + || terminal.byobu == Some(ByobuBackend::Screen) + || report.facts.multiplexer != crate::terminal::MultiplexerKind::Tmux + || !report.findings.iter().any(|finding| finding.id == spec.id) + || !tmux_evidence_is_applicable(report, spec) + { + return Err(FixError::TmuxNotApplicable); + } + let managed = ManagedConfig::plan(ManagedConfigRequest { + path: tmux_config_path(&request, terminal)?, + namespace: MANAGED_NAMESPACE.to_owned(), + owned_item_prefix: "terminal.".to_owned(), + items: vec![ManagedItem::new(spec.id.to_string(), spec.line)], + comments: CommentSyntax::hash(), + validator: None, + }) + .map_err(FixError::TmuxManaged)?; + let direct = scan_direct_tmux_option( + managed.inspection().unmanaged_text(), + managed.target_path(), + spec, + )?; + let item_state = managed + .inspection() + .requested_item_state(0) + .ok_or(FixError::TmuxPostconditionFailed)?; + let direct_noop = direct == DirectOptionState::Healthy + && matches!( + item_state, + ManagedItemState::Absent | ManagedItemState::Exact + ); + let mut change = planned_tmux_change(&managed)?; + if direct_noop { + change.will_write = false; + change.backup_path_hint = None; + } + Ok(FixPlan { + id: request.id, + change, + caveats: vec![ + "The live tmux server is unchanged until you reload this config or detach and reattach.", + TMUX_SCANNER_CAVEAT, + ], + payload: FixPayload::TmuxOption(TmuxOptionPlan { + spec, + managed, + direct_state: if direct_noop { + DirectOptionState::Healthy + } else { + DirectOptionState::Absent + }, + }), + }) +} + +fn tmux_evidence_is_applicable(report: &DiagnosticReport, spec: &TmuxOptionSpec) -> bool { + match spec.evidence { + TmuxEvidence::Clipboard => matches!( + &report.facts.tmux.set_clipboard, + TmuxOptionFact::Available(value) + if !spec.healthy_values.contains(&value.as_str()) + ), + TmuxEvidence::DcsPassthrough => { + report.facts.tmux.allow_passthrough_support == TmuxSupportFact::Supported + && matches!( + &report.facts.tmux.allow_passthrough, + TmuxOptionFact::Available(value) + if !spec.healthy_values.contains(&value.as_str()) + ) + } + TmuxEvidence::ExtendedKeys => matches!( + &report.facts.tmux.extended_keys, + TmuxOptionFact::Available(value) if value == "off" + ), + } +} + +fn tmux_config_path(request: &FixRequest, terminal: &TerminalContext) -> Result<PathBuf, FixError> { + if terminal.byobu != Some(ByobuBackend::Tmux) { + return Ok(request.home.join(".tmux.conf")); + } + Ok(SafeAbsoluteDirectory::parse( + request + .byobu_config_dir + .as_ref() + .ok_or(FixError::ByobuConfigUnavailable)? + .to_path_buf(), + "BYOBU_CONFIG_DIR", + )? + .join(".tmux.conf")) +} + +fn planned_change(managed: &ManagedConfigPlan) -> Result<PlannedChange, FixError> { + planned_change_with_error(managed, FixError::PostconditionFailed) +} + +fn planned_tmux_change(managed: &ManagedConfigPlan) -> Result<PlannedChange, FixError> { + planned_change_with_error(managed, FixError::TmuxPostconditionFailed) +} + +fn planned_change_with_error( + managed: &ManagedConfigPlan, + missing_block: FixError, +) -> Result<PlannedChange, FixError> { + Ok(PlannedChange { + requested_path: managed.requested_path().to_path_buf(), + target_path: managed.target_path().to_path_buf(), + block: managed.managed_block().ok_or(missing_block)?, + backup_path_hint: managed.backup_path_hint().map(Path::to_path_buf), + will_write: managed.changes_file(), }) } pub fn apply_fix(plan: FixPlan) -> Result<FixOutcome, FixError> { let id = plan.id; - let shell = plan.shell; - let outcome = ManagedConfig::apply(plan.managed)?; - if !managed_alias_configured(&outcome.target_path, shell) { - return Err(FixError::PostconditionFailed); + match plan.payload { + FixPayload::SshWrap(payload) => { + let shell = payload.shell; + let outcome = ManagedConfig::apply(payload.managed)?; + if !managed_alias_configured(&outcome.target_path, shell) { + return Err(FixError::PostconditionFailed); + } + Ok(fix_outcome( + id, + outcome, + FixActivation::SatisfiedNow, + Some(shell), + )) + } + FixPayload::TmuxOption(payload) => { + if payload.direct_state == DirectOptionState::Healthy { + ManagedConfig::verify_unchanged(&payload.managed).map_err(FixError::TmuxManaged)?; + let path = payload.managed.requested_path().to_path_buf(); + if !tmux_option_configured(&path, payload.spec) { + return Err(FixError::TmuxPostconditionFailed); + } + return Ok(FixOutcome::new( + id, + FixStatus::AlreadyConfigured, + ChangedFile { + path, + backup_path: None, + }, + FixActivation::RequiresReload, + None, + )); + } + let outcome = ManagedConfig::apply(payload.managed).map_err(FixError::TmuxManaged)?; + if !tmux_option_configured(&outcome.target_path, payload.spec) { + return Err(FixError::TmuxPostconditionFailed); + } + Ok(fix_outcome( + id, + outcome, + FixActivation::RequiresReload, + None, + )) + } } - Ok(FixOutcome { +} + +fn fix_outcome( + id: DiagnosticId, + outcome: ManagedConfigOutcome, + activation: FixActivation, + shell: Option<ShellKind>, +) -> FixOutcome { + FixOutcome::new( id, - status: match outcome.status { + match outcome.status { ManagedConfigStatus::Applied => FixStatus::Applied, ManagedConfigStatus::NoChange => FixStatus::AlreadyConfigured, }, - changed_path: outcome.requested_path, - backup_path: outcome.backup_path, - }) + ChangedFile { + path: outcome.requested_path, + backup_path: outcome.backup_path, + }, + activation, + shell, + ) } -pub fn ssh_wrap_automatic_remediation() -> AutomaticRemediation { - AutomaticRemediation { - fix_id: SSH_WRAP_ID, - command: SSH_WRAP_FIX_COMMAND, +pub(crate) fn format_fix_success(outcome: &FixOutcome) -> String { + let path = markdown_code_path(outcome.changed_path()); + let kind = match outcome.id { + SSH_WRAP_ID => FixKind::SshWrap, + TMUX_CLIPBOARD_ID => FixKind::TmuxOption(&TMUX_CLIPBOARD_SPEC), + DCS_PASSTHROUGH_ID => FixKind::TmuxOption(&DCS_PASSTHROUGH_SPEC), + TMUX_EXTENDED_KEYS_ID => FixKind::TmuxOption(&TMUX_EXTENDED_KEYS_SPEC), + _ => return "Applied the Doctor fix.".to_owned(), + }; + let status = match (kind, outcome.status) { + (FixKind::SshWrap, FixStatus::Applied) => format!("Set up SSH wrapping in {path}."), + (FixKind::SshWrap, FixStatus::AlreadyConfigured) => { + format!("SSH wrapping is already set up in {path}.") + } + (FixKind::TmuxOption(tmux), FixStatus::Applied) => { + format!("Added `{}` to {path}.", tmux.line) + } + (FixKind::TmuxOption(tmux), FixStatus::AlreadyConfigured) => { + format!("`{}` is already configured in {path}.", tmux.line) + } + }; + let backup = outcome + .backup_path() + .map(|path| format!("\nBackup: {}", path.display())) + .unwrap_or_default(); + let activation = match (kind, outcome.activation) { + (FixKind::SshWrap, FixActivation::SatisfiedNow) => { + "\nStart a new shell to use the alias.".to_owned() + } + (FixKind::TmuxOption(_), FixActivation::RequiresReload) => format!( + "\n{}\nRun /doctor again to verify the live setting.", + reload_instruction(outcome.changed_path()) + ), + _ => String::new(), + }; + format!("{status}{backup}{activation}") +} + +pub fn verify_persistent_fix(outcome: &FixOutcome) -> bool { + let Some(spec) = fix_spec(outcome.id) else { + return false; + }; + match spec.kind { + FixKind::SshWrap => false, + FixKind::TmuxOption(tmux) => tmux_option_configured(outcome.changed_path(), tmux), } } +fn preview_path(path: &Path) -> String { + path.to_str() + .filter(|value| !value.chars().any(char::is_control)) + .map(commonmark_code_span) + .unwrap_or_else(|| "[path cannot be rendered safely]".to_owned()) +} + +fn markdown_code_path(path: &Path) -> String { + path.to_str() + .map(commonmark_code_span) + .unwrap_or_else(|| "the configured tmux file".to_owned()) +} + +fn commonmark_code_span(value: &str) -> String { + let delimiter_len = value + .split(|character| character != '`') + .map(str::len) + .max() + .unwrap_or(0) + .saturating_add(1); + let delimiter = "`".repeat(delimiter_len); + format!("{delimiter}{value}{delimiter}") +} + +fn shell_quote_path(path: &Path) -> Option<String> { + let value = path.to_str()?; + if value + .chars() + .any(|character| matches!(character, '\n' | '\r' | '\0')) + { + return None; + } + Some(format!("'{}'", value.replace('\'', "'\\''"))) +} + +fn reload_instruction(path: &Path) -> String { + let Some(shell_path) = shell_quote_path(path) else { + return "Detach and reattach to activate the persistent tmux setting.".to_owned(); + }; + let command = format!("tmux source-file {shell_path}"); + format!( + "Reload tmux with {}, or detach and reattach.", + commonmark_code_span(&command) + ) +} + pub fn managed_alias_configured(path: &Path, shell: ShellKind) -> bool { let request = ManagedConfigRequest { path: path.to_path_buf(), @@ -420,6 +997,375 @@ pub fn managed_alias_configured(path: &Path, shell: ShellKind) -> bool { }) } +fn tmux_option_configured(path: &Path, spec: &'static TmuxOptionSpec) -> bool { + let request = ManagedConfigRequest { + path: path.to_path_buf(), + namespace: MANAGED_NAMESPACE.to_owned(), + owned_item_prefix: "terminal.".to_owned(), + items: vec![ManagedItem::new(spec.id.to_string(), spec.line)], + comments: CommentSyntax::hash(), + validator: None, + }; + ManagedConfig::plan(request).is_ok_and(|plan| { + let direct = + scan_direct_tmux_option(plan.inspection().unmanaged_text(), plan.target_path(), spec); + matches!(direct, Ok(DirectOptionState::Healthy)) + || !plan.changes_file() && matches!(direct, Ok(DirectOptionState::Absent)) + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DirectOptionState { + Absent, + Healthy, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TmuxOptionScope { + Server, + Window, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TmuxCommandToken<'a> { + value: &'a str, + quoted: bool, +} + +fn scan_direct_tmux_option( + text: &str, + path: &Path, + spec: &TmuxOptionSpec, +) -> Result<DirectOptionState, FixError> { + let commands = tmux_top_level_commands(text, path, spec)?; + let mut saw_healthy = false; + for command in commands { + let tokens = tokenize_tmux_command(&command, path, spec)?; + if tokens.is_empty() { + continue; + } + match classify_tmux_assignment(&tokens, spec) { + TmuxAssignment::NotTarget => {} + TmuxAssignment::Healthy => saw_healthy = true, + TmuxAssignment::Conflict(detail) | TmuxAssignment::Ambiguous(detail) => { + return Err(tmux_customization_error(path, spec, &detail)); + } + } + } + Ok(if saw_healthy { + DirectOptionState::Healthy + } else { + DirectOptionState::Absent + }) +} + +fn tmux_top_level_commands( + text: &str, + path: &Path, + spec: &TmuxOptionSpec, +) -> Result<Vec<String>, FixError> { + let mut commands = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + let mut conditional_depth = 0usize; + let mut brace_depth = 0usize; + let mut line_start = true; + let chars = text.chars().collect::<Vec<_>>(); + let mut index = 0; + while index < chars.len() { + let character = chars[index]; + if escaped { + if character == '\n' { + // tmux removes escaped newlines exactly; it does not insert a space. + } else { + current.push(character); + } + escaped = false; + line_start = character == '\n'; + index += 1; + continue; + } + if character == '\\' && quote != Some('\'') { + escaped = true; + index += 1; + continue; + } + if let Some(active_quote) = quote { + current.push(character); + if character == active_quote { + quote = None; + } + line_start = character == '\n'; + index += 1; + continue; + } + if matches!(character, '\'' | '"') { + quote = Some(character); + current.push(character); + line_start = false; + index += 1; + continue; + } + if character == '#' + && (line_start || current.chars().last().is_some_and(char::is_whitespace)) + { + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + continue; + } + if line_start && character == '%' { + let directive = chars[index..] + .iter() + .take_while(|character| **character != '\n') + .collect::<String>(); + let directive = directive.trim(); + if directive.starts_with("%if") { + conditional_depth = conditional_depth.saturating_add(1); + } else if directive.starts_with("%endif") { + conditional_depth = conditional_depth.saturating_sub(1); + } + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + line_start = true; + continue; + } + if character == '{' { + brace_depth = brace_depth.saturating_add(1); + } else if character == '}' { + brace_depth = brace_depth.saturating_sub(1); + } + if matches!(character, ';' | '\n') { + if conditional_depth == 0 && brace_depth == 0 && !current.trim().is_empty() { + commands.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + line_start = true; + } else { + current.push(character); + line_start = false; + } + index += 1; + } + if (escaped || quote.is_some() || conditional_depth != 0 || brace_depth != 0) + && text.contains(spec.option) + { + return Err(tmux_customization_error( + path, + spec, + "unterminated or ambiguous tmux syntax", + )); + } + if conditional_depth == 0 && brace_depth == 0 && !current.trim().is_empty() { + commands.push(current); + } + Ok(commands) +} + +fn tokenize_tmux_command<'a>( + command: &'a str, + path: &Path, + spec: &TmuxOptionSpec, +) -> Result<Vec<TmuxCommandToken<'a>>, FixError> { + let bytes = command.as_bytes(); + let mut tokens = Vec::new(); + let mut index = 0; + while index < bytes.len() { + while bytes[index..].first().is_some_and(u8::is_ascii_whitespace) { + index += 1; + if index == bytes.len() { + return Ok(tokens); + } + } + let start = index; + let mut quote = None; + let mut quoted = false; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(active) = quote { + if byte == active { + quote = None; + } + index += 1; + continue; + } + if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + quoted = true; + index += 1; + continue; + } + if byte.is_ascii_whitespace() { + break; + } + index += 1; + } + if quote.is_some() { + return Err(tmux_customization_error( + path, + spec, + "unterminated quoted tmux token", + )); + } + let raw = &command[start..index]; + let value = raw + .strip_prefix(['\'', '"']) + .and_then(|value| value.strip_suffix(['\'', '"'])) + .unwrap_or(raw); + tokens.push(TmuxCommandToken { value, quoted }); + } + Ok(tokens) +} + +enum TmuxAssignment { + NotTarget, + Healthy, + Conflict(String), + Ambiguous(String), +} + +fn classify_tmux_assignment( + tokens: &[TmuxCommandToken<'_>], + spec: &TmuxOptionSpec, +) -> TmuxAssignment { + let mut index = 0; + while tokens.get(index).is_some_and(|token| { + !token.quoted && token.value.contains('=') && !token.value.starts_with('-') + }) { + index += 1; + } + let Some(command) = tokens.get(index) else { + return TmuxAssignment::NotTarget; + }; + if command.quoted { + return TmuxAssignment::NotTarget; + } + let command_scope = match command.value { + "set" | "set-option" | "seto" => None, + "setw" | "set-window-option" => Some(TmuxOptionScope::Window), + value + if "set-option".starts_with(value) + || "set".starts_with(value) + || "set-window-option".starts_with(value) => + { + if command_may_target(tokens, spec) { + return TmuxAssignment::Ambiguous(format!( + "ambiguous tmux command prefix `{value}` may target `{}`", + spec.option + )); + } + return TmuxAssignment::NotTarget; + } + _ => return TmuxAssignment::NotTarget, + }; + index += 1; + let mut explicit_scope = command_scope; + let mut is_global = false; + let mut has_target = false; + while let Some(token) = tokens.get(index) { + if token.quoted || !token.value.starts_with('-') || token.value == "-" { + break; + } + if token.value == "--" { + index += 1; + break; + } + let flags = &token.value[1..]; + is_global |= flags.contains('g'); + if flags.contains('s') { + explicit_scope = Some(TmuxOptionScope::Server); + } + if flags.contains('w') || flags.contains('p') { + explicit_scope = Some(TmuxOptionScope::Window); + } + if flags.contains('t') { + has_target = true; + index += 1; + if tokens.get(index).is_none() { + return TmuxAssignment::Ambiguous("missing tmux target argument".to_owned()); + } + } + // -F, -f, -t and similar flags take one following argument. Unknown + // flags on a possible target fail closed instead of shifting tokens. + if flags.chars().any(|flag| matches!(flag, 'F' | 'f')) { + index += 1; + if tokens.get(index).is_none() { + return TmuxAssignment::Ambiguous("missing tmux flag argument".to_owned()); + } + } + index += 1; + } + let Some(option) = tokens.get(index) else { + return TmuxAssignment::NotTarget; + }; + if option.quoted || option.value.starts_with('@') { + return TmuxAssignment::NotTarget; + } + if option.value != spec.option { + if spec.option.starts_with(option.value) { + return TmuxAssignment::Ambiguous(format!( + "option prefix `{}` may target `{}`", + option.value, spec.option + )); + } + return TmuxAssignment::NotTarget; + } + + let effective_scope = explicit_scope.unwrap_or(spec.scope); + match spec.scope { + TmuxOptionScope::Server => { + // tmux resolves known server options by option scope even when a + // window flag is supplied. A target is nonsensical/ambiguous here. + if has_target { + return TmuxAssignment::Ambiguous(format!( + "targeted server assignment may affect `{}`", + spec.option + )); + } + } + TmuxOptionScope::Window => { + // Only the global window value is persistent for future windows. + // Local/targeted forms neither satisfy nor override that value. + if effective_scope != TmuxOptionScope::Window || !is_global || has_target { + return TmuxAssignment::NotTarget; + } + } + } + if tokens.len() != index + 2 || tokens[index + 1].quoted { + return TmuxAssignment::Ambiguous(format!( + "ambiguous direct assignment of `{}`", + spec.option + )); + } + let value = tokens[index + 1].value; + if spec.healthy_values.contains(&value) { + TmuxAssignment::Healthy + } else { + TmuxAssignment::Conflict(format!( + "direct `{} {value}` conflicts with `{}`", + spec.option, spec.line + )) + } +} + +fn command_may_target(tokens: &[TmuxCommandToken<'_>], spec: &TmuxOptionSpec) -> bool { + tokens.iter().skip(1).any(|token| { + !token.quoted + && !token.value.starts_with('@') + && (token.value == spec.option || spec.option.starts_with(token.value)) + }) +} + +fn tmux_customization_error(path: &Path, spec: &TmuxOptionSpec, detail: &str) -> FixError { + FixError::ExistingCustomization { + path: path.to_path_buf(), + detail: format!("{detail} for `{}`", spec.option), + } +} + fn validator_for(_shell: ShellKind, override_path: Option<PathBuf>) -> Option<SyntaxValidator> { let program = override_path?; Some(SyntaxValidator { diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/fix_tests.rs b/crates/codegen/xai-grok-pager/src/diagnostics/fix_tests.rs index 8de9180..dd2e980 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/fix_tests.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/fix_tests.rs @@ -12,6 +12,12 @@ pub(super) fn report() -> DiagnosticReport { multiplexer: MultiplexerKind::Undetected, byobu: None, ssh: false, + tmux: crate::diagnostics::TmuxFacts { + extended_keys: crate::diagnostics::TmuxOptionFact::Unavailable, + set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable, + allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable, + allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable, + }, color: crate::diagnostics::ColorFacts { level: crate::diagnostics::RuntimeFact::Unavailable, available_themes: Vec::new(), @@ -71,12 +77,7 @@ fn terminal() -> TerminalContext { } pub(super) fn request(home: &Path, shell: &str) -> FixRequest { - FixRequest { - id: SSH_WRAP_ID, - home: home.to_path_buf(), - shell: Some(PathBuf::from(shell)), - validator: None, - } + FixRequest::new_for_test(SSH_WRAP_ID, home, Some(PathBuf::from(shell)), None, None).unwrap() } #[test] @@ -101,10 +102,13 @@ fn applicable_fix_listing_uses_report_metadata_and_planner_availability() { let report = report(); let local = terminal(); let local_fixes = applicable_automatic_fixes_with(&report, &local, |id| { - Ok(FixRequest { + FixRequest::new_for_test( id, - ..request(temp.path(), "/bin/bash") - }) + temp.path(), + Some(PathBuf::from("/bin/bash")), + None, + None, + ) }); assert_eq!( local_fixes, @@ -132,6 +136,557 @@ fn applicable_fix_listing_uses_report_metadata_and_planner_availability() { ); } +fn tmux_terminal(byobu: bool) -> TerminalContext { + TerminalContext { + multiplexer: MultiplexerKind::Tmux, + byobu: byobu.then_some(crate::terminal::ByobuBackend::Tmux), + tmux_version: Some("tmux 3.4".to_owned()), + tmux_extended_keys: Some("off".to_owned()), + ..terminal() + } +} + +fn tmux_report(id: DiagnosticId, evidence: TmuxEvidence) -> DiagnosticReport { + let mut report = report(); + report.findings.clear(); + report.facts.multiplexer = MultiplexerKind::Tmux; + report.facts.tmux = crate::diagnostics::TmuxFacts { + extended_keys: crate::diagnostics::TmuxOptionFact::Available( + if evidence == TmuxEvidence::ExtendedKeys { + "off" + } else { + "on" + } + .to_owned(), + ), + set_clipboard: crate::diagnostics::TmuxOptionFact::Available( + if evidence == TmuxEvidence::Clipboard { + "off" + } else { + "on" + } + .to_owned(), + ), + allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Supported, + allow_passthrough: crate::diagnostics::TmuxOptionFact::Available( + if evidence == TmuxEvidence::DcsPassthrough { + "off" + } else { + "on" + } + .to_owned(), + ), + }; + report.findings.push(DiagnosticFinding { + id, + disposition: FindingDisposition::Issue, + message: "tmux option disabled".to_owned(), + remediation: None, + automatic_remediation: automatic_remediation_for(id), + note: None, + }); + report +} + +fn tmux_request(home: &Path, id: DiagnosticId) -> FixRequest { + FixRequest::new_for_test(id, home, None, None, None).unwrap() +} + +#[test] +fn tmux_fix_registry_resolves_every_short_and_canonical_id() { + for (id, handle, _) in automatic_fix_choices() { + assert_eq!(resolve_fix_id(handle).unwrap(), id); + assert_eq!(resolve_fix_id(&id.to_string()).unwrap(), id); + assert_eq!( + human_fix_command(id).unwrap(), + format!("grok doctor fix {handle}") + ); + } +} + +#[test] +fn tmux_fix_is_available_here_in_remote_sessions_while_ssh_wrap_stays_local_only() { + let temp = tempfile::tempdir().unwrap(); + let mut terminal = tmux_terminal(false); + terminal.is_ssh = true; + let mut report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard); + report.facts.ssh = true; + assert_eq!( + applicable_automatic_fixes_with(&report, &terminal, |id| { + Ok(tmux_request(temp.path(), id)) + }), + vec![( + TMUX_CLIPBOARD_ID, + "tmux-clipboard", + AutomaticFixAvailability::Here, + )] + ); + assert!( + plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &terminal, + ) + .is_ok() + ); +} + +#[test] +fn tmux_specs_plan_exact_independent_managed_items() { + let temp = tempfile::tempdir().unwrap(); + for (id, evidence, line) in [ + ( + TMUX_CLIPBOARD_ID, + TmuxEvidence::Clipboard, + "set -g set-clipboard on", + ), + ( + DCS_PASSTHROUGH_ID, + TmuxEvidence::DcsPassthrough, + "set -wg allow-passthrough on", + ), + ( + TMUX_EXTENDED_KEYS_ID, + TmuxEvidence::ExtendedKeys, + "set -g extended-keys on", + ), + ] { + let plan = plan_fix( + tmux_request(temp.path(), id), + &tmux_report(id, evidence), + &tmux_terminal(false), + ) + .unwrap(); + assert_eq!(plan.change().requested_path, temp.path().join(".tmux.conf")); + assert!( + plan.change() + .block + .contains(&format!("# >>> {id} >>>\n{line}\n# <<< {id} <<<")) + ); + assert!(!plan.change().block.contains("terminal.ssh-wrap")); + let preview = format_fix_preview(&plan); + assert!(preview.contains("does not reload or modify the live tmux server")); + assert!(preview.contains("Run /doctor again to verify the live setting")); + } +} + +#[test] +fn safe_absolute_directory_rejects_hostile_home_and_byobu_values() { + for value in [ + ".", + "..", + "/", + "relative", + "/tmp/../escape", + "/tmp/bad\nname", + "~/x", + ] { + assert!( + matches!( + SafeAbsoluteDirectory::parse(PathBuf::from(value), "HOME"), + Err(FixError::UnsafeDirectory { .. }) + ), + "{value:?}" + ); + } +} + +#[test] +fn reload_instruction_shell_quotes_and_markdown_escapes_paths() { + assert_eq!( + reload_instruction(Path::new("/tmp/a b/q'v.conf")), + "Reload tmux with `tmux source-file '/tmp/a b/q'\\''v.conf'`, or detach and reattach." + ); + assert_eq!( + reload_instruction(Path::new("/tmp/a`b.conf")), + "Reload tmux with ``tmux source-file '/tmp/a`b.conf'``, or detach and reattach." + ); + assert_eq!( + shell_quote_path(Path::new("/tmp/a`b.conf")).unwrap(), + "'/tmp/a`b.conf'" + ); + assert_eq!( + reload_instruction(Path::new("/tmp/bad\npath")), + "Detach and reattach to activate the persistent tmux setting." + ); + assert_eq!(markdown_code_path(Path::new("/tmp/a`b")), "``/tmp/a`b``"); +} + +#[cfg(unix)] +#[test] +fn full_preview_safely_renders_backtick_requested_symlink_target_and_backup_paths() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let home = root.join("home`dir"); + let target_dir = root.join("target`dir"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&target_dir).unwrap(); + let target = target_dir.join("tmux`target.conf"); + std::fs::write(&target, "set -g mouse on\n").unwrap(); + symlink(&target, home.join(".tmux.conf")).unwrap(); + let plan = plan_fix( + tmux_request(&home, TMUX_CLIPBOARD_ID), + &tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard), + &tmux_terminal(false), + ) + .unwrap(); + let preview = format_fix_preview(&plan); + assert!(preview.contains("File: ``"), "{preview}"); + assert!(preview.contains("Actual file: ``"), "{preview}"); + assert!(preview.contains("Backup will be saved to: ``"), "{preview}"); + assert!(preview.contains("home`dir/.tmux.conf"), "{preview}"); + assert!(preview.contains("tmux`target.conf"), "{preview}"); +} + +#[test] +fn tmux_plain_byobu_and_custom_config_paths_are_physical() { + let temp = tempfile::tempdir().unwrap(); + let report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard); + let plain = plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &tmux_terminal(false), + ) + .unwrap(); + assert_eq!( + plain.change().requested_path, + temp.path().join(".tmux.conf") + ); + assert!( + !plain + .change() + .requested_path + .to_string_lossy() + .contains('~') + ); + + let custom = FixRequest::new_for_test( + TMUX_CLIPBOARD_ID, + temp.path(), + None, + None, + Some(temp.path().join("custom-byobu")), + ) + .unwrap(); + let byobu = plan_fix(custom, &report, &tmux_terminal(true)).unwrap(); + assert_eq!( + byobu.change().requested_path, + temp.path().join("custom-byobu/.tmux.conf") + ); + + assert!(matches!( + plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &tmux_terminal(true) + ), + Err(FixError::ByobuConfigUnavailable) + )); +} + +#[test] +fn tmux_managed_items_coexist_and_each_apply_is_one_transaction() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".tmux.conf"); + for (id, evidence, line) in [ + ( + TMUX_CLIPBOARD_ID, + TmuxEvidence::Clipboard, + "set -g set-clipboard on", + ), + ( + DCS_PASSTHROUGH_ID, + TmuxEvidence::DcsPassthrough, + "set -wg allow-passthrough on", + ), + ( + TMUX_EXTENDED_KEYS_ID, + TmuxEvidence::ExtendedKeys, + "set -g extended-keys on", + ), + ] { + let plan = plan_fix( + tmux_request(temp.path(), id), + &tmux_report(id, evidence), + &tmux_terminal(false), + ) + .unwrap(); + let outcome = apply_fix(plan).unwrap(); + assert_eq!(outcome.activation(), FixActivation::RequiresReload); + assert_eq!(outcome.changed_path(), path); + assert!(format_fix_success(&outcome).contains("Run /doctor again")); + assert!(std::fs::read_to_string(&path).unwrap().contains(line)); + } + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content.matches("# >>> grok doctor >>>").count(), 1); + for id in [TMUX_CLIPBOARD_ID, DCS_PASSTHROUGH_ID, TMUX_EXTENDED_KEYS_ID] { + assert_eq!(content.matches(&format!("# >>> {id} >>>")).count(), 1); + } +} + +#[test] +fn tmux_scanner_handles_server_scopes_separators_prefixes_and_native_blocks() { + let path = Path::new("/tmp/tmux.conf"); + for spec in [&TMUX_CLIPBOARD_SPEC, &TMUX_EXTENDED_KEYS_SPEC] { + let healthy = spec.healthy_values[0]; + for assignment in [ + format!("set {} {healthy}\n", spec.option), + format!("set -s {} {healthy}\n", spec.option), + format!("set-option -gq {} {healthy}\n", spec.option), + format!("set -w {} {healthy}\n", spec.option), + format!("FOO=bar set -g {} {healthy}\n", spec.option), + format!("set -g mouse on; set -g {} {healthy}\n", spec.option), + ] { + assert_eq!( + scan_direct_tmux_option(&assignment, path, spec).unwrap(), + DirectOptionState::Healthy, + "{assignment:?}" + ); + } + for conflict in [ + format!("set {} off\n", spec.option), + format!("set -s {} off\n", spec.option), + format!("set-option -g {} off\n", spec.option), + format!("set -w {} off\n", spec.option), + format!("set -g mouse on; set -g {} off\n", spec.option), + format!("set -g {} o\\\nff\n", spec.option), + ] { + assert!( + matches!( + scan_direct_tmux_option(&conflict, path, spec), + Err(FixError::ExistingCustomization { .. }) + ), + "{conflict:?}" + ); + } + } + + let spec = &DCS_PASSTHROUGH_SPEC; + for healthy in [ + "setw -g allow-passthrough on\n", + "set-window-option -g allow-passthrough all\n", + "set -wg allow-passthrough on\n", + ] { + assert_eq!( + scan_direct_tmux_option(healthy, path, spec).unwrap(), + DirectOptionState::Healthy, + "{healthy:?}" + ); + } + for conflict in [ + "setw -g allow-passthrough off\n", + "set-window-option -g allow-passthrough off\n", + "set -wg allow-passthrough off\n", + ] { + assert!( + matches!( + scan_direct_tmux_option(conflict, path, spec), + Err(FixError::ExistingCustomization { .. }) + ), + "{conflict:?}" + ); + } + for local in [ + "set allow-passthrough on\n", + "setw allow-passthrough on\n", + "setw -t:1 allow-passthrough off\n", + ] { + assert_eq!( + scan_direct_tmux_option(local, path, spec).unwrap(), + DirectOptionState::Absent, + "{local:?}" + ); + } + + for spec in [ + &TMUX_CLIPBOARD_SPEC, + &DCS_PASSTHROUGH_SPEC, + &TMUX_EXTENDED_KEYS_SPEC, + ] { + for ignored in [ + format!("# set -g {} off\n", spec.option), + format!("set -g @{} off\n", spec.option), + format!("set -g {}-copy off\n", spec.option), + format!("%if 1\nset -g {} off\n%endif\n", spec.option), + format!("if-shell true {{ set -g {} off }}\n", spec.option), + ] { + assert_eq!( + scan_direct_tmux_option(&ignored, path, spec).unwrap(), + DirectOptionState::Absent, + "{ignored:?}" + ); + } + for ambiguous in [ + format!("se -g {} off\n", spec.option), + format!("set -g {} off extra\n", spec.option), + format!("set -g {}\n", spec.option), + format!("set -g {} 'unterminated\n", spec.option), + format!("set -g {} \\\n", spec.option), + format!("set -t target\nset -g {} off\n", spec.option), + ] { + assert!( + matches!( + scan_direct_tmux_option(&ambiguous, path, spec), + Err(FixError::ExistingCustomization { .. }) + ), + "{ambiguous:?}" + ); + } + } +} + +#[test] +fn conflicting_direct_form_after_managed_block_fails_persistent_verification() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".tmux.conf"); + for conflict in [ + "set set-clipboard off", + "set -s set-clipboard off", + "set-option -g set-clipboard off", + "set -g mouse on; set -g set-clipboard off", + "se -g set-clipboard off", + ] { + std::fs::write( + &path, + format!( + "# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard on\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<\n{conflict}\n" + ), + ) + .unwrap(); + assert!( + !tmux_option_configured(&path, &TMUX_CLIPBOARD_SPEC), + "{conflict}" + ); + } +} + +#[test] +fn healthy_direct_does_not_suppress_repair_of_noncanonical_managed_item() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".tmux.conf"); + let report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard); + for content in [ + "set -g set-clipboard on\n# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard off\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<\n", + "# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard off\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<\nset -g set-clipboard on\n", + ] { + std::fs::write(&path, content).unwrap(); + let plan = plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &tmux_terminal(false), + ) + .unwrap(); + assert!(format_fix_preview(&plan).contains("Text to add:\n")); + let outcome = apply_fix(plan).unwrap(); + assert_eq!(outcome.status(), FixStatus::Applied); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard on\n") + ); + } +} + +#[test] +fn tmux_applicability_uses_exact_positive_probe_gates() { + let temp = tempfile::tempdir().unwrap(); + let terminal = tmux_terminal(false); + let mut clipboard = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard); + clipboard.facts.tmux.set_clipboard = + crate::diagnostics::TmuxOptionFact::Available("external".to_owned()); + assert!(matches!( + plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &clipboard, + &terminal + ), + Err(FixError::TmuxNotApplicable) + )); + + let mut dcs = tmux_report(DCS_PASSTHROUGH_ID, TmuxEvidence::DcsPassthrough); + for support in [ + crate::diagnostics::TmuxSupportFact::Unsupported, + crate::diagnostics::TmuxSupportFact::Unavailable, + crate::diagnostics::TmuxSupportFact::Error, + ] { + dcs.facts.tmux.allow_passthrough_support = support; + assert!(matches!( + plan_fix( + tmux_request(temp.path(), DCS_PASSTHROUGH_ID), + &dcs, + &terminal + ), + Err(FixError::TmuxNotApplicable) + )); + } + + let mut extended = tmux_report(TMUX_EXTENDED_KEYS_ID, TmuxEvidence::ExtendedKeys); + extended.facts.tmux.extended_keys = crate::diagnostics::TmuxOptionFact::Unavailable; + assert!(matches!( + plan_fix( + tmux_request(temp.path(), TMUX_EXTENDED_KEYS_ID), + &extended, + &terminal + ), + Err(FixError::TmuxNotApplicable) + )); +} + +#[test] +fn tmux_stale_plan_and_idempotence_reuse_managed_writer_safety() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".tmux.conf"); + std::fs::write(&path, "set -g mouse on\n").unwrap(); + let report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard); + let plan = plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &tmux_terminal(false), + ) + .unwrap(); + std::fs::write(&path, "set -g mouse off\n").unwrap(); + assert!(matches!( + apply_fix(plan), + Err(FixError::TmuxManaged( + xai_grok_config::managed_text::ManagedConfigError::StalePlan(_) + )) + )); + + std::fs::write(&path, "set -g set-clipboard on\n").unwrap(); + let plan = plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &tmux_terminal(false), + ) + .unwrap(); + let preview = format_fix_preview(&plan); + assert!(preview.contains("Text to add: None"), "{preview}"); + assert!(!preview.contains("Backup will be saved"), "{preview}"); + let outcome = apply_fix(plan).unwrap(); + assert_eq!(outcome.status(), FixStatus::AlreadyConfigured); + assert!(verify_persistent_fix(&outcome)); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "set -g set-clipboard on\n" + ); + + let stale = plan_fix( + tmux_request(temp.path(), TMUX_CLIPBOARD_ID), + &report, + &tmux_terminal(false), + ) + .unwrap(); + std::fs::write(&path, "set -g set-clipboard off\n").unwrap(); + assert!(matches!( + apply_fix(stale), + Err(FixError::TmuxManaged( + xai_grok_config::managed_text::ManagedConfigError::StalePlan(_) + )) + )); +} + #[test] fn bash_zsh_and_fish_plans_use_exact_paths_and_aliases() { let temp = tempfile::tempdir().unwrap(); @@ -145,22 +700,26 @@ fn bash_zsh_and_fish_plans_use_exact_paths_and_aliases() { ), ] { let plan = plan_fix(request(temp.path(), shell), &report(), &terminal()).unwrap(); - assert_eq!(plan.id, SSH_WRAP_ID); - assert_eq!(plan.changes[0].requested_path, temp.path().join(relative)); + assert_eq!(plan.id(), SSH_WRAP_ID); + assert_eq!(plan.change().requested_path, temp.path().join(relative)); assert_eq!( - plan.changes[0].block, + plan.change().block, format!( "# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\n{alias}\n# <<< terminal.ssh-wrap <<<\n# <<< grok doctor <<<" ) ); - assert!(plan.caveats.iter().any(|line| line.contains("command ssh"))); - assert!(plan.caveats.iter().any(|line| line.contains("ssh -f"))); assert!( - plan.caveats + plan.caveats() + .iter() + .any(|line| line.contains("command ssh")) + ); + assert!(plan.caveats().iter().any(|line| line.contains("ssh -f"))); + assert!( + plan.caveats() .iter() .any(|line| line.contains("ControlPersist")) ); - assert!(plan.caveats.iter().any(|line| line.contains("~^Z"))); + assert!(plan.caveats().iter().any(|line| line.contains("~^Z"))); } } @@ -367,8 +926,8 @@ fn comments_and_managed_alias_do_not_create_false_conflicts() { .unwrap(); let plan = plan_fix(request(temp.path(), "/bin/zsh"), &report(), &terminal()).unwrap(); let outcome = apply_fix(plan).unwrap(); - assert_eq!(outcome.status, FixStatus::AlreadyConfigured); - assert!(outcome.backup_path.is_none()); + assert_eq!(outcome.status(), FixStatus::AlreadyConfigured); + assert!(outcome.backup_path().is_none()); } #[test] @@ -409,9 +968,42 @@ fn stale_plan_is_rejected_and_apply_verifies_postcondition() { let plan = plan_fix(request(temp.path(), "/bin/bash"), &report(), &terminal()).unwrap(); let outcome = apply_fix(plan).unwrap(); - assert_eq!(outcome.status, FixStatus::Applied); - assert_eq!(outcome.id, SSH_WRAP_ID); + assert_eq!(outcome.status(), FixStatus::Applied); + assert_eq!(outcome.id(), SSH_WRAP_ID); + assert_eq!(outcome.shell(), Some(ShellKind::Bash)); assert!(managed_alias_configured(&path, ShellKind::Bash)); + assert!(outcome.managed_alias_is_configured()); +} + +#[test] +fn ssh_wrap_outcome_verifies_with_planned_shell_not_process_shell() { + // Post-apply verification must use the shell stored on the outcome. Even if + // `$SHELL` is missing or points at a different shell family, a successful + // apply against bash must still report the managed alias as configured. + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".bashrc"); + let plan = plan_fix(request(temp.path(), "/bin/bash"), &report(), &terminal()).unwrap(); + let outcome = apply_fix(plan).unwrap(); + assert_eq!(outcome.shell(), Some(ShellKind::Bash)); + assert_eq!(outcome.changed_path(), path); + assert!(outcome.managed_alias_is_configured()); + + // Fish uses a different alias syntax; checking the bash-written path with + // fish must not count as configured. The outcome keeps bash regardless. + assert!(!managed_alias_configured(&path, ShellKind::Fish)); + assert!( + outcome.managed_alias_is_configured(), + "outcome must keep the planned bash shell, not re-derive from $SHELL" + ); + + let filtered = configured_report(report(), outcome.managed_alias_is_configured()); + assert!( + !filtered + .findings + .iter() + .any(|finding| finding.id == SSH_WRAP_ID), + "configured_report must drop ssh-wrap when outcome shell matches the write" + ); } #[test] @@ -437,7 +1029,8 @@ fn configured_report_reaches_pass_state_only_for_exact_managed_alias() { healthy.findings.clear(); let plan = plan_fix(request(temp.path(), "/bin/bash"), &healthy, &terminal()).unwrap(); assert_eq!( - plan.id, SSH_WRAP_ID, + plan.id(), + SSH_WRAP_ID, "healthy reports can plan idempotent setup" ); } diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/mod.rs b/crates/codegen/xai-grok-pager/src/diagnostics/mod.rs index f8c3d08..1fb9108 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/mod.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/mod.rs @@ -20,12 +20,15 @@ pub use doctor_format::format_doctor; #[cfg(test)] pub(crate) use fix::test_fix_plan; pub use fix::{ - AutomaticRemediation, FixError, FixOutcome, FixPlan, FixRequest, FixStatus, PlannedChange, - SSH_WRAP_FIX_COMMAND, SSH_WRAP_ID, SSH_WRAP_ONE_OFF, ShellKind, apply_fix, configured_report, + AutomaticRemediation, DCS_PASSTHROUGH_ID, FixActivation, FixError, FixOutcome, FixPlan, + FixRequest, FixStatus, PlannedChange, SSH_WRAP_FIX_COMMAND, SSH_WRAP_ID, SSH_WRAP_ONE_OFF, + ShellKind, TMUX_CLIPBOARD_ID, TMUX_EXTENDED_KEYS_ID, apply_fix, configured_report, managed_alias_configured, plan_fix, resolve_fix_id, ssh_wrap_automatic_remediation, + verify_persistent_fix, }; pub(crate) use fix::{ - format_applicable_automatic_fixes, format_fix_preview, human_fix_command, select_fix_plan, + automatic_fix_choices, automatic_remediation_for, format_applicable_automatic_fixes, + format_fix_preview, format_fix_success, human_fix_command, select_fix_plan, }; pub(crate) use model::probe_requires_live_tui; pub(crate) use model::{ @@ -37,7 +40,7 @@ pub(crate) use model::{ pub use model::{ ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId, DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote, - ProbeStatus, RuntimeFact, VoiceFacts, + ProbeStatus, RuntimeFact, TmuxFacts, TmuxOptionFact, TmuxSupportFact, VoiceFacts, }; pub use view::{DiagnosticSnapshot, view}; @@ -298,7 +301,12 @@ pub(crate) fn collect_startup_warnings_from( warnings.extend(diagnose_clipboard_from_facts(tmux, &config_path)); } - if ctx.kitty_skip_reason() == Some("tmux_extended_keys_off") { + if ctx.is_tmux_backed() + && matches!( + &tmux.extended_keys, + probes::TmuxProbeResult::Available(value) if value == "off" + ) + { let mut warning = TerminalWarning::new( WarningCategory::TmuxExtendedKeysOff, "`extended-keys` is off in tmux, so some shortcuts may not work", @@ -619,7 +627,7 @@ pub(crate) fn collect_notification_warnings_with_method( let mut warning = TerminalWarning::new( WarningCategory::DcsPassthrough, "`allow-passthrough` is off in tmux, so terminal notifications are blocked", - Some("set -g allow-passthrough on"), + Some("set -wg allow-passthrough on"), Some(&config_path), ); warning.note = Some(tmux_reload_note(&config_path)); @@ -774,7 +782,7 @@ pub fn diagnose_clipboard_from_values( let mut warning = TerminalWarning::new( WarningCategory::DcsPassthrough, "`allow-passthrough` is off in tmux, which can block clipboard copies in nested sessions", - Some("set -g allow-passthrough on"), + Some("set -wg allow-passthrough on"), Some(config_path), ); warning.note = Some(tmux_reload_note(config_path)); @@ -1430,7 +1438,7 @@ mod tests { let w = diagnose_clipboard_from_values(Some("on"), true, Some("off"), "~/.tmux.conf"); assert_eq!(w.len(), 1); assert_eq!(w[0].category, WarningCategory::DcsPassthrough); - assert_eq!(w[0].fix.as_deref(), Some("set -g allow-passthrough on")); + assert_eq!(w[0].fix.as_deref(), Some("set -wg allow-passthrough on")); } #[test] @@ -2128,7 +2136,7 @@ mod tests { TerminalWarning::new( WarningCategory::DcsPassthrough, "DCS passthrough is disabled", - Some("set -g allow-passthrough on"), + Some("set -wg allow-passthrough on"), Some("~/.tmux.conf"), ), TerminalWarning::new( @@ -2306,7 +2314,13 @@ mod tests { fn collect_extended_keys_warnings(ctx: &TerminalContext) -> Vec<TerminalWarning> { let query = FakeTmuxQuery::healthy_modern(); - collect_startup_warnings(ctx, &query, false, true) + let mut snapshot = test_snapshot(ctx, &query, false, true, false, None); + snapshot.tmux.extended_keys = ctx + .tmux_extended_keys + .clone() + .map(probes::TmuxProbeResult::Available) + .unwrap_or(probes::TmuxProbeResult::Unavailable); + super::collect_startup_warnings(&snapshot) .into_iter() .filter(|w| w.category == WarningCategory::TmuxExtendedKeysOff) .collect() @@ -2666,7 +2680,7 @@ mod tests { assert_eq!(w.len(), 1); assert_eq!(w[0].category, WarningCategory::DcsPassthrough); assert!(w[0].message.contains("notification")); - assert_eq!(w[0].fix.as_deref(), Some("set -g allow-passthrough on")); + assert_eq!(w[0].fix.as_deref(), Some("set -wg allow-passthrough on")); } #[test] diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/model.rs b/crates/codegen/xai-grok-pager/src/diagnostics/model.rs index 7a55697..0f9880a 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/model.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/model.rs @@ -89,6 +89,7 @@ pub struct DiagnosticFacts { pub multiplexer: MultiplexerKind, pub byobu: Option<ByobuBackend>, pub ssh: bool, + pub tmux: TmuxFacts, pub color: ColorFacts, pub keyboard: Option<KeyboardFact>, pub newline: Option<NewlineFact>, @@ -107,6 +108,30 @@ pub enum VoiceFacts { Missing { error: String }, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TmuxFacts { + pub extended_keys: TmuxOptionFact, + pub set_clipboard: TmuxOptionFact, + pub allow_passthrough_support: TmuxSupportFact, + pub allow_passthrough: TmuxOptionFact, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TmuxOptionFact { + Available(String), + Unsupported, + Unavailable, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TmuxSupportFact { + Supported, + Unsupported, + Unavailable, + Error, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct ColorFacts { pub level: RuntimeFact<ColorLevel>, diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/probes/mod.rs b/crates/codegen/xai-grok-pager/src/diagnostics/probes/mod.rs index c9aa12d..39fa87c 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/probes/mod.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/probes/mod.rs @@ -138,6 +138,63 @@ pub fn collect_doctor_tui<'a>( /// Collect standalone evidence without running live tmux subprocesses; skipped /// tmux evidence is reported unavailable so a stuck server cannot block doctor. pub fn collect_standalone<'a>(terminal: &'a TerminalContext) -> StandaloneDiagnosticSnapshot<'a> { + collect_standalone_with_tmux(terminal, unavailable_tmux()) +} + +/// Collect bounded live tmux facts for explicit fix planning. +pub fn collect_standalone_fix<'a>( + terminal: &'a TerminalContext, + id: Option<crate::diagnostics::DiagnosticId>, +) -> StandaloneDiagnosticSnapshot<'a> { + collect_standalone_with_tmux(terminal, collect_tmux_fix(terminal, id, &LiveTmuxProbe)) +} + +fn collect_tmux_fix( + terminal: &TerminalContext, + id: Option<crate::diagnostics::DiagnosticId>, + tmux: &dyn TmuxOptionQuery, +) -> TmuxProbeFacts { + if !terminal.is_tmux_backed() { + return unavailable_tmux(); + } + let wants = |candidate| id.is_none() || id == Some(candidate); + let set_clipboard = if wants(crate::diagnostics::TMUX_CLIPBOARD_ID) { + tmux.show_option("set-clipboard") + } else { + TmuxProbeResult::Unavailable + }; + let extended_keys = if wants(crate::diagnostics::TMUX_EXTENDED_KEYS_ID) { + tmux.show_option("extended-keys") + } else { + TmuxProbeResult::Unavailable + }; + let (allow_passthrough_support, allow_passthrough) = + if wants(crate::diagnostics::DCS_PASSTHROUGH_ID) { + let support = tmux.option_support("allow-passthrough"); + let value = match &support { + TmuxProbeResult::Available(()) => tmux.show_option("allow-passthrough"), + TmuxProbeResult::Unsupported => TmuxProbeResult::Unsupported, + TmuxProbeResult::Unavailable => TmuxProbeResult::Unavailable, + TmuxProbeResult::Error(error) => TmuxProbeResult::Error(error.clone()), + }; + (support, value) + } else { + (TmuxProbeResult::Unavailable, TmuxProbeResult::Unavailable) + }; + TmuxProbeFacts { + version: TmuxProbeResult::Unavailable, + extended_keys, + set_clipboard, + allow_passthrough_support, + allow_passthrough, + control_mode: TmuxProbeResult::Unavailable, + } +} + +fn collect_standalone_with_tmux<'a>( + terminal: &'a TerminalContext, + tmux: TmuxProbeFacts, +) -> StandaloneDiagnosticSnapshot<'a> { let host_os = crate::host::HostOs::current(); let display_server = crate::host::DisplayServer::current(); let is_wayland = display_server == crate::host::DisplayServer::Wayland; @@ -146,7 +203,7 @@ pub fn collect_standalone<'a>(terminal: &'a TerminalContext) -> StandaloneDiagno let container_no_display = xai_grok_shell::util::clipboard::is_containerized_without_display(); collect_standalone_from( terminal, - unavailable_tmux(), + tmux, WaylandProbeFacts { is_wayland, data_control, @@ -292,7 +349,7 @@ fn collect_tmux( .tmux_extended_keys .clone() .map(TmuxProbeResult::Available) - .unwrap_or(TmuxProbeResult::Unavailable), + .unwrap_or_else(|| tmux.show_option("extended-keys")), set_clipboard: tmux.show_option("set-clipboard"), allow_passthrough_support, allow_passthrough, @@ -387,7 +444,11 @@ mod tests { ); assert_eq!( fake.calls.into_inner(), - ["support:allow-passthrough", "set-clipboard"] + [ + "support:allow-passthrough", + "extended-keys", + "set-clipboard" + ] ); } @@ -406,7 +467,12 @@ mod tests { assert_eq!(snapshot.tmux.control_mode, TmuxProbeResult::Available(true)); assert_eq!( fake.calls.into_inner(), - ["support:allow-passthrough", "set-clipboard", "control-mode"] + [ + "support:allow-passthrough", + "extended-keys", + "set-clipboard", + "control-mode", + ] ); } diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/view.rs b/crates/codegen/xai-grok-pager/src/diagnostics/view.rs index dd5c9cd..b14e0ee 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/view.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/view.rs @@ -7,7 +7,8 @@ use crate::diagnostics::probes::{ use crate::diagnostics::{ ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId, DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote, - ProbeStatus, RuntimeFact, TerminalWarning, WarningCategory, + ProbeStatus, RuntimeFact, TerminalWarning, TmuxFacts, TmuxOptionFact, TmuxSupportFact, + WarningCategory, }; use crate::terminal::TerminalName; @@ -227,6 +228,14 @@ fn facts( multiplexer: ctx.multiplexer, byobu: ctx.byobu, ssh: ctx.is_ssh, + tmux: TmuxFacts { + extended_keys: tmux_option_fact(&snapshot.common.tmux.extended_keys), + set_clipboard: tmux_option_fact(&snapshot.common.tmux.set_clipboard), + allow_passthrough_support: tmux_support_fact( + &snapshot.common.tmux.allow_passthrough_support, + ), + allow_passthrough: tmux_option_fact(&snapshot.common.tmux.allow_passthrough), + }, color: ColorFacts { level: match snapshot.color_level { RuntimeEvidence::Available(level) => RuntimeFact::Available(level), @@ -508,8 +517,7 @@ fn finding(warning: TerminalWarning, disposition: FindingDisposition) -> Option< fix, config_path: warning.config_path, }), - automatic_remediation: (id == crate::diagnostics::SSH_WRAP_ID) - .then(crate::diagnostics::ssh_wrap_automatic_remediation), + automatic_remediation: crate::diagnostics::automatic_remediation_for(id), note: warning.note, }) } @@ -596,6 +604,24 @@ fn probe_notes(snapshot: &DiagnosticSnapshot<'_>) -> Vec<ProbeNote> { notes } +fn tmux_option_fact(result: &TmuxProbeResult<String>) -> TmuxOptionFact { + match result { + TmuxProbeResult::Available(value) => TmuxOptionFact::Available(value.to_owned()), + TmuxProbeResult::Unsupported => TmuxOptionFact::Unsupported, + TmuxProbeResult::Unavailable => TmuxOptionFact::Unavailable, + TmuxProbeResult::Error(_) => TmuxOptionFact::Error, + } +} + +fn tmux_support_fact(result: &TmuxProbeResult<()>) -> TmuxSupportFact { + match result { + TmuxProbeResult::Available(()) => TmuxSupportFact::Supported, + TmuxProbeResult::Unsupported => TmuxSupportFact::Unsupported, + TmuxProbeResult::Unavailable => TmuxSupportFact::Unavailable, + TmuxProbeResult::Error(_) => TmuxSupportFact::Error, + } +} + fn probe_note<T>(notes: &mut Vec<ProbeNote>, probe: &'static str, result: &TmuxProbeResult<T>) { let (status, message) = match result { TmuxProbeResult::Available(_) => return, diff --git a/crates/codegen/xai-grok-pager/src/diagnostics/view_tests.rs b/crates/codegen/xai-grok-pager/src/diagnostics/view_tests.rs index ef656c7..3348582 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics/view_tests.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics/view_tests.rs @@ -222,7 +222,83 @@ fn findings_have_stable_semantic_ids_and_dispositions() { ssh_wrap.automatic_remediation, Some(crate::diagnostics::ssh_wrap_automatic_remediation()) ); - assert!(report.findings[0].automatic_remediation.is_none()); + assert_eq!( + report.findings[0].automatic_remediation, + crate::diagnostics::automatic_remediation_for(DiagnosticId::new( + "terminal", + "tmux-clipboard" + )) + ); +} + +#[test] +fn all_tmux_finding_metadata_uses_stable_automatic_fix_ids_without_schema_changes() { + let mut terminal = TerminalContext { + brand: TerminalName::Iterm2, + env_brand: TerminalName::Iterm2, + multiplexer: MultiplexerKind::Tmux, + tmux_version: Some("tmux 3.4".to_owned()), + tmux_extended_keys: Some("off".to_owned()), + ..Default::default() + }; + let report = view(snapshot( + &terminal, + TmuxProbeFacts { + version: TmuxProbeResult::Available("tmux 3.4".to_owned()), + extended_keys: TmuxProbeResult::Available("off".to_owned()), + set_clipboard: TmuxProbeResult::Available("off".to_owned()), + allow_passthrough_support: TmuxProbeResult::Available(()), + allow_passthrough: TmuxProbeResult::Available("off".to_owned()), + control_mode: TmuxProbeResult::Available(false), + }, + available_runtime(), + false, + )); + + assert_eq!( + report + .findings + .iter() + .filter_map(|finding| finding.automatic_remediation) + .map(|automatic| (automatic.fix_id, automatic.command)) + .collect::<Vec<_>>(), + [ + ( + crate::diagnostics::TMUX_CLIPBOARD_ID, + "grok doctor fix terminal.tmux-clipboard", + ), + ( + crate::diagnostics::DCS_PASSTHROUGH_ID, + "grok doctor fix terminal.dcs-passthrough", + ), + ( + crate::diagnostics::TMUX_EXTENDED_KEYS_ID, + "grok doctor fix terminal.tmux-extended-keys", + ), + ] + ); + + terminal.tmux_extended_keys = Some("on".to_owned()); + let healthy = view(snapshot( + &terminal, + TmuxProbeFacts { + version: TmuxProbeResult::Available("tmux 3.4".to_owned()), + extended_keys: TmuxProbeResult::Available("on".to_owned()), + set_clipboard: TmuxProbeResult::Available("external".to_owned()), + allow_passthrough_support: TmuxProbeResult::Available(()), + allow_passthrough: TmuxProbeResult::Available("all".to_owned()), + control_mode: TmuxProbeResult::Available(false), + }, + available_runtime(), + false, + )); + for id in [ + crate::diagnostics::TMUX_CLIPBOARD_ID, + crate::diagnostics::DCS_PASSTHROUGH_ID, + crate::diagnostics::TMUX_EXTENDED_KEYS_ID, + ] { + assert!(healthy.findings.iter().all(|finding| finding.id != id)); + } } #[test] diff --git a/crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs b/crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs index e816311..e7dd511 100644 --- a/crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs +++ b/crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::Result; -use crate::diagnostics::{DiagnosticReport, FixPlan, FixStatus, ShellKind}; +use crate::diagnostics::{DiagnosticReport, FixActivation, FixPlan, ShellKind}; mod human; mod json; @@ -28,7 +28,7 @@ pub enum DoctorCommand { #[derive(Clone, Debug, Eq, PartialEq, clap::Args)] pub struct FixArgs { - /// Fix to apply. Use `ssh-wrap` or `terminal.ssh-wrap`. Omit it to list available automatic fixes. + /// Named fix to apply. Omit it to list available automatic fixes. pub id: Option<String>, /// Apply the displayed changes without confirmation. #[arg(long, requires = "id")] @@ -106,11 +106,13 @@ fn run_fix( writer: &mut impl Write, ) -> Result<()> { let terminal = crate::terminal::standalone_terminal_context(); - let report = configured_report_for_terminal( - collect_report_with(crate::diagnostics::probes::collect_standalone(&terminal)), - &terminal, - ); let Some(value) = args.id.as_deref() else { + let report = configured_report_for_terminal( + collect_report_with(crate::diagnostics::probes::collect_standalone_fix( + &terminal, None, + )), + &terminal, + ); write!( writer, "{}", @@ -119,6 +121,13 @@ fn run_fix( return Ok(()); }; let id = crate::diagnostics::resolve_fix_id(value)?; + let report = configured_report_for_terminal( + collect_report_with(crate::diagnostics::probes::collect_standalone_fix( + &terminal, + Some(id), + )), + &terminal, + ); let request = crate::diagnostics::FixRequest::from_environment(id)?; let plan = crate::diagnostics::plan_fix(request, &report, &terminal)?; apply_fix_plan(args, stdin_is_terminal, input, writer, &terminal, plan) @@ -150,39 +159,36 @@ fn apply_fix_plan( } } - let shell = plan.shell; let outcome = crate::diagnostics::apply_fix(plan)?; - let post_report = crate::diagnostics::configured_report( - collect_report_with(crate::diagnostics::probes::collect_standalone(terminal)), - crate::diagnostics::managed_alias_configured(&outcome.changed_path, shell), - ); - if post_report - .findings - .iter() - .any(|finding| finding.id == outcome.id) - { + if outcome.activation() == FixActivation::SatisfiedNow { + // Use the shell stored on the outcome (from planning), not `$SHELL`. + // `$SHELL` may be missing or no longer match the shell the plan targeted. + let post_report = crate::diagnostics::configured_report( + collect_report_with(crate::diagnostics::probes::collect_standalone(terminal)), + outcome.managed_alias_is_configured(), + ); + if post_report + .findings + .iter() + .any(|finding| finding.id == outcome.id()) + { + anyhow::bail!( + "The change was applied, but Doctor still reports `{}`.", + outcome.id() + ); + } + } else if !crate::diagnostics::verify_persistent_fix(&outcome) { anyhow::bail!( - "The change was applied, but Doctor still reports `{}`.", - outcome.id + "The change was applied, but Doctor could not verify `{}` in persistent configuration.", + outcome.id() ); } - match outcome.status { - FixStatus::Applied => writeln!( - writer, - "\nSet up SSH wrapping in {}.", - outcome.changed_path.display() - )?, - FixStatus::AlreadyConfigured => writeln!( - writer, - "\nSSH wrapping is already set up in {}.", - outcome.changed_path.display() - )?, - } - if let Some(backup) = outcome.backup_path { - writeln!(writer, "Backup: {}", backup.display())?; - } - writeln!(writer, "Start a new shell to use the alias.")?; + writeln!( + writer, + "\n{}", + crate::diagnostics::format_fix_success(&outcome) + )?; Ok(()) } diff --git a/crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs b/crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs index bf1a775..84e638d 100644 --- a/crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs +++ b/crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs @@ -41,12 +41,14 @@ fn local_terminal() -> TerminalContext { } fn ssh_wrap_fix_request(home: &std::path::Path) -> crate::diagnostics::FixRequest { - crate::diagnostics::FixRequest { - id: crate::diagnostics::SSH_WRAP_ID, - home: home.to_path_buf(), - shell: Some(std::path::PathBuf::from("/bin/bash")), - validator: None, - } + crate::diagnostics::FixRequest::new_for_test( + crate::diagnostics::SSH_WRAP_ID, + home, + Some(std::path::PathBuf::from("/bin/bash")), + None, + None, + ) + .unwrap() } static TMUX_ROUTE: ClipboardRoute = ClipboardRoute { @@ -96,6 +98,12 @@ fn healthy_report() -> DiagnosticReport { multiplexer: MultiplexerKind::Undetected, byobu: None, ssh: false, + tmux: crate::diagnostics::TmuxFacts { + extended_keys: crate::diagnostics::TmuxOptionFact::Unavailable, + set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable, + allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable, + allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable, + }, color: ColorFacts { level: RuntimeFact::Available(ColorLevel::TrueColor), available_themes: ThemeKind::ALL.to_vec(), @@ -156,7 +164,9 @@ fn mixed_report() -> DiagnosticReport { fix: "set -g set-clipboard on".to_owned(), config_path: Some("~/.tmux.conf".to_owned()), }), - automatic_remediation: None, + automatic_remediation: crate::diagnostics::automatic_remediation_for( + DiagnosticId::new("terminal", "tmux-clipboard"), + ), note: Some("Reload tmux after editing.".to_owned()), }, DiagnosticFinding { @@ -487,6 +497,7 @@ fn human_mixed_fixture_is_exact() { "\n", "Findings\n", " ! terminal.tmux-clipboard OSC 52 clipboard passthrough is disabled\n", + " → Automatic setup: `grok doctor fix tmux-clipboard`\n", " → Add `set -g set-clipboard on` to ~/.tmux.conf\n", " Reload tmux after editing.\n", " i terminal.ssh-wrap Use local SSH wrapping\n", @@ -749,7 +760,10 @@ fn json_contract_is_structural_stable_ordered_and_ansi_free() { "fix": "set -g set-clipboard on", "configPath": "~/.tmux.conf" }, - "automaticRemediation": null, + "automaticRemediation": { + "fixId": "terminal.tmux-clipboard", + "command": "grok doctor fix terminal.tmux-clipboard" + }, "note": "Reload tmux after editing." }, { diff --git a/crates/codegen/xai-grok-pager/src/headless.rs b/crates/codegen/xai-grok-pager/src/headless.rs index 4e84cfb..12514da 100644 --- a/crates/codegen/xai-grok-pager/src/headless.rs +++ b/crates/codegen/xai-grok-pager/src/headless.rs @@ -158,6 +158,9 @@ fn parse_prompt_json(json_str: &str) -> anyhow::Result<Vec<acp::ContentBlock>> { pub struct HeadlessOptions { pub session_id: Option<String>, pub resume: Option<String>, + /// The composition root pinned (or definitively missed) `resume` before + /// the OS sandbox; materialization must not re-run local title selection. + pub resume_title_pinned: bool, pub cwd: Option<PathBuf>, pub yolo: bool, pub trust: bool, @@ -805,12 +808,20 @@ async fn apply_headless_model_and_effort( /// Startup-materialization context for headless (`-p`) runs. Never chat: /// `HeadlessOptions` carries no chat flag, so headless resume targets are /// always disk/GCS Build sessions. -fn headless_materialize_ctx(has_worktree: bool) -> crate::app::session_startup::MaterializeCtx { +fn headless_materialize_ctx( + has_worktree: bool, + resume_title_pinned: bool, +) -> crate::app::session_startup::MaterializeCtx { crate::app::session_startup::MaterializeCtx { has_worktree, allow_remote_restore: crate::app::session_startup::MaterializeCtx::default_allow_remote_restore(), chat_mode: false, + title_resolution: if resume_title_pinned { + crate::app::session_startup::TitleResolution::PinnedPreSandbox + } else { + crate::app::session_startup::TitleResolution::Allowed + }, } } @@ -983,7 +994,7 @@ pub async fn run_single_turn( let cwd_str = cwd.to_string_lossy().to_string(); let materialized = session_startup::materialize_startup_for_cwd( - headless_materialize_ctx(options.worktree.is_some()), + headless_materialize_ctx(options.worktree.is_some(), options.resume_title_pinned), intent, &cwd_str, ) @@ -1850,13 +1861,25 @@ mod tests { } /// Headless materialization is never chat, regardless of worktree flag — - /// resume targets stay disk/GCS Build sessions. + /// 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] { - let ctx = headless_materialize_ctx(has_worktree); - assert!(!ctx.chat_mode); - assert_eq!(ctx.has_worktree, has_worktree); + 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 + } + ); + } } } diff --git a/crates/codegen/xai-grok-pager/src/lib.rs b/crates/codegen/xai-grok-pager/src/lib.rs index daa0aae..ea22f78 100644 --- a/crates/codegen/xai-grok-pager/src/lib.rs +++ b/crates/codegen/xai-grok-pager/src/lib.rs @@ -52,6 +52,7 @@ pub mod share_cmd; pub mod slash; pub mod startup; pub mod tips; +pub mod tutorial_docs; pub mod wrap_clipboard_image; pub mod wrap_cmd; pub(crate) mod wrap_filter; diff --git a/crates/codegen/xai-grok-pager/src/minimal/api.rs b/crates/codegen/xai-grok-pager/src/minimal/api.rs index 35a70ee..194a623 100644 --- a/crates/codegen/xai-grok-pager/src/minimal/api.rs +++ b/crates/codegen/xai-grok-pager/src/minimal/api.rs @@ -747,6 +747,14 @@ pub fn build_session_entry_data( ) } +/// [`crate::views::session_picker::hidden_external_hint`]. +pub fn hidden_external_hint( + entries: Option<&[SessionPickerEntry]>, + source_filter: SourceFilter, +) -> Option<String> { + crate::views::session_picker::hidden_external_hint(entries, source_filter) +} + /// [`crate::views::session_picker::build_grouped_picker_entries`]. pub fn build_grouped_picker_entries<'a>( entries_data: &'a [SessionPickerEntry], diff --git a/crates/codegen/xai-grok-pager/src/plugin_cmd.rs b/crates/codegen/xai-grok-pager/src/plugin_cmd.rs index 3090928..fc291f4 100644 --- a/crates/codegen/xai-grok-pager/src/plugin_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/plugin_cmd.rs @@ -178,6 +178,9 @@ pub enum MarketplaceCommand { Add { /// Git URL, GitHub shorthand (e.g. user/repo), or local directory path. url: String, + /// Skip the reachability probe (e.g. for hosts only reachable on VPN). + #[arg(long)] + force: bool, }, /// Remove a marketplace source and uninstall its plugins Remove { @@ -794,7 +797,7 @@ async fn run_marketplace(cmd: MarketplaceCommand) -> Result<()> { match cmd { MarketplaceCommand::List { json } => marketplace_list(&sources, json), - MarketplaceCommand::Add { url } => marketplace_add(&sources, &url), + MarketplaceCommand::Add { url, force } => marketplace_add(&sources, &url, force), MarketplaceCommand::Remove { source } => marketplace_remove(&sources, &source), MarketplaceCommand::Update { name } => marketplace_update(&sources, name.as_deref()), } @@ -845,6 +848,7 @@ fn marketplace_list( fn marketplace_add( sources: &[xai_grok_plugin_marketplace::MarketplaceSource], url: &str, + force: bool, ) -> Result<()> { use xai_grok_shell::plugin::MarketplaceAddInput; @@ -896,6 +900,15 @@ fn marketplace_add( bail!("Marketplace source already configured: {identity}"); } + if !force && let MarketplaceAddInput::GitUrl(git_url) = &input { + xai_grok_plugin_marketplace::git::probe_git_remote(git_url).map_err(|e| { + anyhow::anyhow!( + "{e}\nNot adding \"{url}\": it doesn't look like a reachable git repository. \ + Re-run with --force to add it anyway (e.g. a host only reachable on VPN)." + ) + })?; + } + let name = match &input { MarketplaceAddInput::GitUrl(u) => plugin::name_from_url(u), MarketplaceAddInput::LocalPath(p) => plugin::name_from_path(p), diff --git a/crates/codegen/xai-grok-pager/src/settings/defs.rs b/crates/codegen/xai-grok-pager/src/settings/defs.rs index c6036a2..b561daf 100644 --- a/crates/codegen/xai-grok-pager/src/settings/defs.rs +++ b/crates/codegen/xai-grok-pager/src/settings/defs.rs @@ -1351,6 +1351,36 @@ pub fn default_settings() -> Vec<SettingMeta> { restart_required: true, hidden_in_minimal: false, }, + // SHELL-owned, persisted to `[ui].voice_keybind_enabled`. Default ON — + // `None` (inherit) reads as `true`. Disables only the Ctrl+Space / F8 + // chord; `/voice` (and Esc / the recording-row `[stop]`) keep working. + SettingMeta { + key: "voice_keybind_enabled", + category: SettingCategory::Editor, + owner: SettingOwner::Shell, + label: "Voice shortcut", + description: "Enable the Ctrl+Space / F8 shortcut for voice dictation. \ + When off, the keys are ignored; /voice still starts \ + dictation.", + keywords: &[ + "voice", + "dictation", + "mic", + "microphone", + "speech", + "stt", + "keybinding", + "hotkey", + "ctrl+space", + "f8", + "disable", + ], + kind: SettingKind::Bool { + default: ui_default.voice_keybind_enabled.unwrap_or(true), + }, + restart_required: false, + hidden_in_minimal: false, + }, // SHELL-owned, persisted to `[ui].voice_capture_mode`. The `hold` choice // is hidden on terminals without key-release reporting (see // `effective_enum_choices`) and falls back to `toggle` at runtime. diff --git a/crates/codegen/xai-grok-pager/src/settings/registry.rs b/crates/codegen/xai-grok-pager/src/settings/registry.rs index cd96f92..3c0fd16 100644 --- a/crates/codegen/xai-grok-pager/src/settings/registry.rs +++ b/crates/codegen/xai-grok-pager/src/settings/registry.rs @@ -566,6 +566,10 @@ pub fn current_value_for( "screen_mode" => Some(SettingValue::Enum(canonical_screen_mode( ui.screen_mode.as_deref(), ))), + // SHELL — whether the Ctrl+Space / F8 chord is active; None → true. + "voice_keybind_enabled" => { + Some(SettingValue::Bool(ui.voice_keybind_enabled.unwrap_or(true))) + } // SHELL — canonicalized from `[ui].voice_capture_mode`; None → "hold". "voice_capture_mode" => Some(SettingValue::Enum(canonical_voice_capture_mode( ui.voice_capture_mode.as_deref(), @@ -979,6 +983,14 @@ mod tests { }; assert_eq!(*default, expected); } + // voice_keybind_enabled: Option<bool>; None → true. + ("voice_keybind_enabled", SettingKind::Bool { default }) => { + assert_eq!( + *default, + ui.voice_keybind_enabled.unwrap_or(true), + "voice_keybind_enabled default drifts from UiConfig::default()", + ); + } // voice_capture_mode: Option<String>; None → "hold". ("voice_capture_mode", SettingKind::Enum { default, .. }) => { assert_eq!( 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 fc24acc..a867e36 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs @@ -7,16 +7,17 @@ use crate::slash::command::{ AppCtx, ArgItem, CommandExecCtx, CommandResult, DoctorRequest, SlashCommand, }; -const USAGE: &str = "Usage: /doctor [fix [ssh-wrap]]"; +const USAGE: &str = + "Usage: /doctor [fix [ssh-wrap|tmux-clipboard|dcs-passthrough|tmux-extended-keys]]"; pub struct DoctorCommand; impl DoctorCommand { - pub(crate) fn report( + pub(crate) fn report_for_terminal( + terminal: &crate::terminal::TerminalContext, screen_mode: crate::app::ScreenMode, runtime: crate::diagnostics::TuiRuntimeRequest<'_>, ) -> crate::diagnostics::DiagnosticReport { - let terminal = crate::terminal::terminal_context(); let query = crate::diagnostics::probes::LiveTmuxProbe; let snapshot = crate::diagnostics::probes::collect_doctor_tui( terminal, @@ -54,7 +55,7 @@ impl SlashCommand for DoctorCommand { } fn usage(&self) -> &str { - "/doctor [fix [ssh-wrap]]" + "/doctor [fix [FIX]]" } fn takes_args(&self) -> bool { @@ -62,30 +63,38 @@ impl SlashCommand for DoctorCommand { } fn arg_placeholder(&self) -> Option<&str> { - Some("[fix [ssh-wrap]]") + Some("[fix [FIX]]") } fn suggest_args(&self, _ctx: &AppCtx, args_query: &str) -> Option<Vec<ArgItem>> { let query = args_query.trim(); - if query.is_empty() || matches!(query, "fix ssh-wrap" | "fix terminal.ssh-wrap") { + if query.is_empty() { return None; } - let item = if query == "fix" || query.starts_with("fix ") { - ArgItem { - display: "ssh-wrap".into(), - match_text: "fix ssh-wrap terminal.ssh-wrap".into(), - insert_text: "fix ssh-wrap".into(), - description: "Set up SSH wrapping on this computer".into(), + if query == "fix" || query.starts_with("fix ") { + let value = query.strip_prefix("fix").unwrap_or_default().trim(); + if !value.is_empty() && crate::diagnostics::resolve_fix_id(value).is_ok() { + return None; } - } else { - ArgItem { - display: "fix".into(), - match_text: "fix".into(), - insert_text: "fix".into(), - description: "Show automatic fixes available here".into(), - } - }; - Some(vec![item]) + let items = crate::diagnostics::automatic_fix_choices() + .filter(|(id, handle, _)| { + value.is_empty() || handle.contains(value) || id.to_string().starts_with(value) + }) + .map(|(id, handle, label)| ArgItem { + display: handle.into(), + match_text: format!("fix {handle} {id}"), + insert_text: format!("fix {handle}"), + description: label.into(), + }) + .collect::<Vec<_>>(); + return (!items.is_empty()).then_some(items); + } + Some(vec![ArgItem { + display: "fix".into(), + match_text: "fix".into(), + insert_text: "fix".into(), + description: "Show automatic fixes available here".into(), + }]) } fn session_scoped(&self) -> bool { @@ -136,10 +145,31 @@ mod tests { run("fix"), CommandResult::Doctor(DoctorRequest::ListFixes) )); - for value in ["ssh-wrap", "terminal.ssh-wrap"] { + for (value, id) in [ + ("ssh-wrap", crate::diagnostics::SSH_WRAP_ID), + ("terminal.ssh-wrap", crate::diagnostics::SSH_WRAP_ID), + ("tmux-clipboard", crate::diagnostics::TMUX_CLIPBOARD_ID), + ( + "terminal.tmux-clipboard", + crate::diagnostics::TMUX_CLIPBOARD_ID, + ), + ("dcs-passthrough", crate::diagnostics::DCS_PASSTHROUGH_ID), + ( + "terminal.dcs-passthrough", + crate::diagnostics::DCS_PASSTHROUGH_ID, + ), + ( + "tmux-extended-keys", + crate::diagnostics::TMUX_EXTENDED_KEYS_ID, + ), + ( + "terminal.tmux-extended-keys", + crate::diagnostics::TMUX_EXTENDED_KEYS_ID, + ), + ] { assert!(matches!( run(&format!("fix {value}")), - CommandResult::Doctor(DoctorRequest::Fix(crate::diagnostics::SSH_WRAP_ID)) + CommandResult::Doctor(DoctorRequest::Fix(parsed)) if parsed == id )); } } @@ -180,6 +210,12 @@ mod tests { " fix ssh-wrap ", "fix terminal.ssh-wrap", " fix terminal.ssh-wrap ", + "fix tmux-clipboard", + "fix terminal.tmux-clipboard", + "fix dcs-passthrough", + "fix terminal.dcs-passthrough", + "fix tmux-extended-keys", + "fix terminal.tmux-extended-keys", ] { assert!(command.suggest_args(&context, query).is_none(), "{query:?}"); } 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 c81ebb5..c3f3e0e 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs @@ -63,6 +63,7 @@ pub mod timeline; pub mod timestamps; pub mod toggle_mouse_reporting; pub mod transcript; +pub mod tutorial; pub mod usage; pub mod view_plan; pub mod vim_mode; @@ -140,6 +141,7 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> { Arc::new(queue::QueueCommand), Arc::new(tasks::TasksCommand), Arc::new(release_notes::ReleaseNotesCommand), + Arc::new(tutorial::TutorialCommand), Arc::new(config_agents::ConfigAgentsCommand), Arc::new(personas::PersonasCommand), // Hidden easter egg: never listed, runs on bare `/gboom`. @@ -296,6 +298,7 @@ mod tests { "model", "multiline", "new", + "onboarding", "personas", "plan", "plan-view", @@ -328,7 +331,9 @@ mod tests { "timestamps", "title", "toggle-mouse-reporting", + "tour", "transcript", + "tutorial", "t", "usage", "view-plan", @@ -361,7 +366,7 @@ mod tests { let quit_cmd = reg.get("quit").unwrap(); assert_eq!(exit_cmd.name(), quit_cmd.name()); let doctor = reg.get("doctor").unwrap(); - assert_eq!(doctor.usage(), "/doctor [fix [ssh-wrap]]"); + assert_eq!(doctor.usage(), "/doctor [fix [FIX]]"); for alias in ["terminal-setup", "terminal-check", "terminal-info"] { assert_eq!(reg.get(alias).unwrap().name(), doctor.name()); assert_eq!(reg.get(alias).unwrap().usage(), doctor.usage()); diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs b/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs new file mode 100644 index 0000000..7ca9deb --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/slash/commands/tutorial.rs @@ -0,0 +1,81 @@ +//! `/tutorial` -- open the onboarding tutorial overlay. +//! +//! Purely opt-in: this command (also listed in the command palette) is the +//! only way the tutorial opens — it never auto-shows. + +use crate::app::actions::Action; +use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; + +/// Open the onboarding tutorial. +pub struct TutorialCommand; + +impl SlashCommand for TutorialCommand { + fn name(&self) -> &str { + "tutorial" + } + + fn aliases(&self) -> &[&str] { + &["tour", "onboarding"] + } + + fn description(&self) -> &str { + "Quick tips to get the most out of Grok Build" + } + + fn usage(&self) -> &str { + "/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 + } + + fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult { + CommandResult::Action(Action::OpenTutorial) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::model_state::ModelState; + use crate::app::bundle::BundleState; + use crate::settings::PagerLocalSnapshot; + + static DEFAULT_BUNDLE_STATE: BundleState = BundleState { + has_cache: false, + version: String::new(), + personas: Vec::new(), + roles: Vec::new(), + agents: Vec::new(), + skills: Vec::new(), + persona_details: Vec::new(), + 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(); + let mut ctx = CommandExecCtx { + models: &models, + session_id: None, + bundle_state: &DEFAULT_BUNDLE_STATE, + screen_mode: crate::app::ScreenMode::Fullscreen, + billing_surface_visible: true, + pager_state: PagerLocalSnapshot::default(), + }; + assert!(matches!( + TutorialCommand.run(&mut ctx, ""), + CommandResult::Action(Action::OpenTutorial) + )); + } +} diff --git a/crates/codegen/xai-grok-pager/src/slash/mod.rs b/crates/codegen/xai-grok-pager/src/slash/mod.rs index 56c1ae0..7f23cae 100644 --- a/crates/codegen/xai-grok-pager/src/slash/mod.rs +++ b/crates/codegen/xai-grok-pager/src/slash/mod.rs @@ -46,6 +46,9 @@ pub struct SuggestionRow { pub insert_text: String, /// Character positions for fuzzy match highlighting. pub indices: Vec<u32>, + /// Free-form bracketed tag (e.g. "new") from the resolved tag map. `None` + /// for untagged command rows and always `None` for arg rows. + pub tag: Option<String>, } impl SuggestionRow { @@ -59,6 +62,7 @@ impl SuggestionRow { description: trigger.description.clone(), insert_text, indices: Vec::new(), + tag: None, } } @@ -68,6 +72,7 @@ impl SuggestionRow { description: item.description.clone(), insert_text: item.insert_text.clone(), indices: Vec::new(), + tag: None, } } @@ -272,6 +277,11 @@ pub struct SlashController { /// defaults to an isolated in-memory store (no disk I/O) for tests and any /// surface that has not been wired up. mru: std::rc::Rc<std::cell::RefCell<mru::SlashMru>>, + /// Resolved per-command tag map (canonical name → free-form tag). Owned by + /// `AppView` and injected via [`Self::set_command_tags`] so agent prompts + /// and the dashboard share one map; defaults to empty for tests and any + /// surface that has not been wired up. + command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>, } impl SlashController { @@ -300,6 +310,9 @@ impl SlashController { workflows_available: false, screen_mode: crate::app::ScreenMode::Fullscreen, mru, + command_tags: std::rc::Rc::new(std::cell::RefCell::new( + std::collections::HashMap::new(), + )), } } @@ -309,6 +322,16 @@ impl SlashController { self.mru = mru; } + /// Replace the per-command tag map with a shared one. Used by `AppView` to + /// inject the resolved (remote + local) tag map into agent prompts and the + /// dashboard dispatch input. + pub fn set_command_tags( + &mut self, + command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>, + ) { + self.command_tags = command_tags; + } + /// Gate `/announcements` on presence of session announcements (critical or promo). pub fn set_has_session_announcements(&mut self, has: bool) { self.has_session_announcements = has; @@ -837,6 +860,9 @@ impl SlashController { // No cap here -- the dropdown renderer handles scrolling. let mut seen = HashSet::new(); let mut rows = Vec::new(); + // Retain canonicals so tags are set in a second pass, keeping the + // `takes_args_now` command callback outside any tag-map borrow. + let mut canonicals: Vec<&str> = Vec::new(); for (i, trigger) in triggers.iter().enumerate() { if !visible_indices.contains(&i) { continue; @@ -848,8 +874,20 @@ impl SlashController { .map(|cmd| cmd.takes_args_now(&ctx)) .unwrap_or(false); rows.push(SuggestionRow::from_command(trigger, takes)); + canonicals.push(trigger.canonical.as_str()); } } + // Tag from the data map in one scoped borrow; key off canonical + // (never the alias/display). + { + let command_tags = self.command_tags.borrow(); + for (row, canonical) in rows.iter_mut().zip(canonicals.iter()) { + row.tag = command_tags.get(*canonical).cloned(); + } + } + // Surface tagged commands (curated new/beta) at the top of the bare "/" menu; + // stable so registry order is preserved within the tagged and untagged groups. + rows.sort_by_key(|r| r.tag.is_none()); return rows; } @@ -929,6 +967,14 @@ impl SlashController { .iter() .map(|t| (t.canonical.clone(), t.source)) .collect(); + // Tag each candidate from the data map (canonical key); one shared + // borrow, dropped before the scoring borrow below. + { + let command_tags = self.command_tags.borrow(); + for (row, (canonical, _)) in rows.iter_mut().zip(sort_meta.iter()) { + row.tag = command_tags.get(canonical.as_str()).cloned(); + } + } // Resolve all recency scores under a single borrow (one keystroke = // one borrow, not one per candidate). let mru_scores: Vec<u64> = { @@ -2157,6 +2203,7 @@ mod tests { description: String::new(), insert_text: "/Privacy ".to_string(), indices: Vec::new(), + tag: None, }; // Without smart-case, starts_with("p") fails on "Privacy" and ghost disappears // while the dropdown still highlights the row via CaseMatching::Smart. @@ -2398,6 +2445,116 @@ mod tests { assert_eq!(ctrl.mru_last_used("", "exit"), 0); } + /// Inject a per-command tag map into a controller (test seam). + fn set_tags(ctrl: &mut SlashController, entries: &[(&str, &str)]) { + let map: std::collections::HashMap<String, String> = entries + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + ctrl.set_command_tags(std::rc::Rc::new(std::cell::RefCell::new(map))); + } + + /// Tag of the row whose display equals `name` in the current snapshot. + fn row_tag(snap: &SlashSnapshot, name: &str) -> Option<String> { + snap.matches + .iter() + .find(|r| r.display == name) + .unwrap_or_else(|| panic!("row {name} missing")) + .tag + .clone() + } + + /// A command with a tag-map entry (keyed by canonical) carries that tag in + /// both the empty-query and the typed-query branches; one without has `None`. + #[test] + fn command_row_tag_from_map_keyed_by_canonical() { + let mut ctrl = tie_controller(&["alpha", "bravo"], &[]); + set_tags(&mut ctrl, &[("alpha", "new")]); + let state = SlashState::default(); + let models = ModelState::default(); + + ctrl.refresh(&state, "/", 1, &models); + let snap = state.snapshot(); + assert_eq!(row_tag(&snap, "/alpha"), Some("new".to_string())); + assert_eq!(row_tag(&snap, "/bravo"), None); + + // Typed-query branch tags the same way. + ctrl.refresh(&state, "/al", 3, &models); + let typed = state.snapshot(); + assert_eq!( + row_tag(&typed, "/alpha"), + Some("new".to_string()), + "typed-query rows carry tags too" + ); + } + + /// The bare "/" picker surfaces tagged commands first, preserving registry + /// order within the tagged and untagged groups (stable; not alphabetized). + #[test] + fn empty_query_sorts_tagged_commands_first_stably() { + // Registry order: alpha, bravo, charlie, delta. Tag the 2nd and 4th. + let mut ctrl = tie_controller(&["alpha", "bravo", "charlie", "delta"], &[]); + set_tags(&mut ctrl, &[("bravo", "new"), ("delta", "beta")]); + let state = SlashState::default(); + let models = ModelState::default(); + + ctrl.refresh(&state, "/", 1, &models); + let order: Vec<String> = state + .snapshot() + .matches + .iter() + .map(|r| r.display.clone()) + .collect(); + assert_eq!( + order, + vec!["/bravo", "/delta", "/alpha", "/charlie"], + "tagged-first, stable registry order within each group" + ); + } + + /// ACP commands — including bundled skills that arrive as skill-shaped ACP + /// commands — tag from the map the same way as builtins. + #[test] + fn acp_and_skill_commands_tag_from_map() { + let mut ctrl = SlashController::new( + CommandRegistry::new(vec![ + Arc::new(TieCmd("builtin-cmd")) as Arc<dyn SlashCommand> + ]), + std::path::PathBuf::from("."), + ); + // A skill arrives as an ACP command carrying skill meta (scope + path). + let skill_meta = serde_json::json!({ + "scope": "local", + "path": "/home/user/.grok/skills/skill-cmd/SKILL.md", + }) + .as_object() + .cloned() + .expect("skill meta is an object"); + let skill_cmd = + agent_client_protocol::AvailableCommand::new("skill-cmd".to_string(), String::new()) + .meta(skill_meta); + ctrl.registry_mut().set_acp_commands(&[ + agent_client_protocol::AvailableCommand::new("acp-command".to_string(), String::new()), + skill_cmd, + ]); + assert!( + ctrl.registry() + .get("skill-cmd") + .expect("skill command present") + .is_skill(), + "skill-shaped ACP command must classify as a skill" + ); + + set_tags(&mut ctrl, &[("acp-command", "beta"), ("skill-cmd", "new")]); + let state = SlashState::default(); + let models = ModelState::default(); + ctrl.refresh(&state, "/", 1, &models); + let snap = state.snapshot(); + assert_eq!(row_tag(&snap, "/acp-command"), Some("beta".to_string())); + assert_eq!(row_tag(&snap, "/skill-cmd"), Some("new".to_string())); + assert_eq!(row_tag(&snap, "/builtin-cmd"), None); + } + #[test] fn flat_mru_boosts_recent_command_regardless_of_typed_prefix() { // Flat schema (hermetic): using `plan` recently boosts it even when @@ -2746,6 +2903,17 @@ mod tests { ("/doctor fix s", "fix ssh-wrap", vec![0]), ("/doctor fix ssh", "fix ssh-wrap", vec![0, 1, 2]), ("/doctor fix terminal.s", "fix ssh-wrap", vec![0]), + ( + "/doctor fix tmux-c", + "fix tmux-clipboard", + vec![0, 1, 2, 3, 4, 5], + ), + ("/doctor fix dcs", "fix dcs-passthrough", vec![0, 1, 2]), + ( + "/doctor fix tmux-e", + "fix tmux-extended-keys", + vec![0, 1, 2, 3, 4, 5], + ), ("/terminal-setup f", "fix", vec![0]), ("/terminal-setup fix s", "fix ssh-wrap", vec![0]), ] { @@ -2759,6 +2927,12 @@ mod tests { for text in [ "/doctor fix ssh-wrap", "/doctor fix terminal.ssh-wrap", + "/doctor fix tmux-clipboard", + "/doctor fix terminal.tmux-clipboard", + "/doctor fix dcs-passthrough", + "/doctor fix terminal.dcs-passthrough", + "/doctor fix tmux-extended-keys", + "/doctor fix terminal.tmux-extended-keys", "/terminal-setup fix ssh-wrap", "/terminal-setup fix terminal.ssh-wrap", ] { diff --git a/crates/codegen/xai-grok-pager/src/test_util.rs b/crates/codegen/xai-grok-pager/src/test_util.rs index 2afe39c..ea39bb5 100644 --- a/crates/codegen/xai-grok-pager/src/test_util.rs +++ b/crates/codegen/xai-grok-pager/src/test_util.rs @@ -78,3 +78,86 @@ impl Drop for EnvVarGuard { } } } +/// Shared GROK_HOME boundary fixture for the resume-by-title startup and +/// pre-sandbox tests. +/// +/// `grok_home()` is OnceLock-cached process-wide, so summaries land under the +/// *resolved* home (possibly the real `~/.grok` when another test pinned the +/// cache first); cwd-encoded dirnames are tempdir-unique, and cleanup runs on +/// drop so it survives assertion panics. Callers must hold +/// `#[serial_test::serial(GROK_HOME)]`. +pub struct GrokHomeFixture { + _home: tempfile::TempDir, + cwd: tempfile::TempDir, + cleanup: Vec<std::path::PathBuf>, +} +impl Drop for GrokHomeFixture { + fn drop(&mut self) { + for dir in &self.cleanup { + let _ = std::fs::remove_dir_all(dir); + } + } +} +impl Default for GrokHomeFixture { + fn default() -> Self { + Self::new() + } +} +impl GrokHomeFixture { + pub fn new() -> Self { + let home = tempfile::tempdir().expect("home tempdir"); + unsafe { std::env::set_var("GROK_HOME", home.path()) }; + let cwd = tempfile::tempdir().expect("cwd tempdir"); + Self { + _home: home, + cwd, + cleanup: Vec::new(), + } + } + /// Canonicalized so the summary cwd encoding matches what production + /// path resolution sees (macOS tempdirs are symlinked). Tests pass this + /// through the explicit `*_for_cwd` seams; the process cwd is never + /// mutated. + pub fn cwd_str(&self) -> String { + self.cwd + .path() + .canonicalize() + .expect("canonicalize cwd") + .to_string_lossy() + .to_string() + } + /// Write a minimal valid summary.json (every non-defaulted `Summary` + /// field) for `id` under `cwd`, merging `extra` fields on top. + pub fn write_summary(&mut self, cwd: &str, id: &str, extra: serde_json::Value) { + let sessions_cwd_dir = Self::sessions_cwd_dir(cwd); + if !self.cleanup.contains(&sessions_cwd_dir) { + self.cleanup.push(sessions_cwd_dir.clone()); + } + let dir = sessions_cwd_dir.join(id); + std::fs::create_dir_all(&dir).unwrap(); + let mut v = serde_json::json!({ + "info": { "id": id, "cwd": cwd }, + "session_summary": "auto summary", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-01T00:00:00Z", + "num_messages": 1, + "current_model_id": "grok-build", + }); + if let Some(map) = extra.as_object() { + for (k, val) in map { + v[k.as_str()] = val.clone(); + } + } + std::fs::write(dir.join("summary.json"), serde_json::to_vec(&v).unwrap()).unwrap(); + } + /// Delete a previously written session dir (concurrent-delete simulation). + pub fn remove_session(&self, cwd: &str, id: &str) { + let _ = std::fs::remove_dir_all(Self::sessions_cwd_dir(cwd).join(id)); + } + fn sessions_cwd_dir(cwd: &str) -> std::path::PathBuf { + let encoded = xai_grok_shell::util::grok_home::encode_cwd_dirname(cwd); + xai_grok_shell::util::grok_home::grok_home() + .join("sessions") + .join(&encoded) + } +} diff --git a/crates/codegen/xai-grok-pager/src/tutorial_docs.rs b/crates/codegen/xai-grok-pager/src/tutorial_docs.rs new file mode 100644 index 0000000..f8df494 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/tutorial_docs.rs @@ -0,0 +1,149 @@ +//! Onboarding tutorial content (embedded markdown). +//! +//! Short, curated topics shown by the `/tutorial` overlay (strictly opt-in — +//! nothing auto-shows). Deliberately separate from [`crate::docs`] (the full how-to +//! guides): these pages are bite-size intros that point at the guides for +//! depth. + +/// A compile-time tutorial topic. All fields are `&'static str`. +#[derive(Debug)] +pub struct TutorialTopic { + /// Row title in the topic list. + pub title: &'static str, + /// Short right-column blurb in the topic list. + pub blurb: &'static str, + /// Embedded markdown page content. + pub content: &'static str, + /// Title of the primary how-to guide this page's "Go deeper" points at + /// (must match a [`crate::docs`] title); `d` opens it in the overlay. + pub go_deeper: Option<&'static str>, +} + +macro_rules! topic { + ($file:literal, $title:literal, $blurb:literal, $go_deeper:expr) => { + TutorialTopic { + title: $title, + blurb: $blurb, + content: include_str!(concat!("../docs/tutorial/", $file)), + go_deeper: $go_deeper, + } + }; +} + +/// The tutorial topics, in display order. Ordered as a linear flow (the +/// topic screen's `→` advances through them): what carries over from other +/// tools, send a prompt, feed it context, learn the screen, then the +/// bigger features. +pub static TUTORIAL_TOPICS: &[TutorialTopic] = &[ + topic!( + "01-coming-from-another-tool.md", + "Coming from Claude, Cursor, or Codex?", + "your settings, rules & skills carry over", + Some("Project Rules (AGENTS.md)") + ), + topic!( + "02-first-prompt.md", + "Your First Prompt", + "send, queue, cancel", + Some("Getting Started") + ), + topic!( + "03-attach-and-paste.md", + "Attach Files, Images & Paste", + "@files, line ranges, screenshots", + Some("Getting Started") + ), + topic!( + "04-navigation.md", + "Finding Your Way Around", + "focus, scrollback, panes", + Some("Keyboard Shortcuts") + ), + topic!( + "05-slash-commands.md", + "Slash Commands", + "/help /model /resume and Ctrl+P", + Some("Slash Commands") + ), + topic!( + "06-worktrees.md", + "Parallel Work: Worktrees", + "isolated sessions on one repo", + Some("Session Management") + ), + topic!( + "07-plan-and-permissions.md", + "Plan Mode & Permissions", + "review the approach before it acts", + Some("Plan Mode") + ), + topic!( + "08-make-it-yours.md", + "Make It Yours", + "just ask — AGENTS.md, memory, themes", + Some("Project Rules (AGENTS.md)") + ), + topic!( + "09-where-next.md", + "Where to Go Next", + "guides, feedback, and good habits", + None + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn topics_are_valid() { + for t in TUTORIAL_TOPICS { + assert!(!t.title.is_empty(), "topic has empty title"); + assert!(!t.blurb.is_empty(), "topic {} has empty blurb", t.title); + assert!(!t.content.is_empty(), "topic {} is empty", t.title); + assert!( + t.content.starts_with('#'), + "topic {} should start with a markdown header", + t.title + ); + } + } + + #[test] + fn go_deeper_titles_resolve_to_real_guides() { + // `d` on a topic page opens this guide; a typo'd title would turn + // the shortcut into a silent no-op. + for t in TUTORIAL_TOPICS { + if let Some(title) = t.go_deeper { + assert!( + crate::docs::find_doc(title).is_some(), + "topic {}: go_deeper {title:?} matches no how-to guide", + t.title + ); + } + } + } + + #[test] + fn topics_have_unique_titles() { + let mut seen = std::collections::HashSet::new(); + for t in TUTORIAL_TOPICS { + assert!(seen.insert(t.title), "duplicate topic title: {}", t.title); + } + } + + #[test] + fn topics_stay_bite_size() { + // The tutorial promises quick reads — keep each page short. Bump this + // limit only after re-checking a page still reads in under a minute. + for t in TUTORIAL_TOPICS { + let lines = t.content.lines().count(); + assert!( + lines <= 50, + "topic {} is {} lines; keep tutorial pages bite-size (≤50)", + t.title, + lines + ); + } + } +} diff --git a/crates/codegen/xai-grok-pager/src/views/announcements.rs b/crates/codegen/xai-grok-pager/src/views/announcements.rs index 57e04ba..97d8bca 100644 --- a/crates/codegen/xai-grok-pager/src/views/announcements.rs +++ b/crates/codegen/xai-grok-pager/src/views/announcements.rs @@ -274,6 +274,16 @@ pub fn first_session_announcement<'a>( first_session_announcement_at(announcements, hidden_ids, chrono::Utc::now()) } +/// Whether a live critical session announcement exists. Used by the banner +/// slot ranking: critical outranks the privacy upsell banner (an outage +/// notice must not be hidden by a persistent nag), promo does not. +pub fn has_critical_session_announcement( + announcements: &[xai_grok_announcements::RemoteAnnouncement], + hidden_ids: &BTreeSet<String>, +) -> bool { + first_critical_session_announcement_at(announcements, hidden_ids, chrono::Utc::now()).is_some() +} + /// [`first_session_announcement`] with an injectable clock. pub fn first_session_announcement_at<'a>( announcements: &'a [xai_grok_announcements::RemoteAnnouncement], diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs index ad400b1..b5b6f67 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs @@ -1144,6 +1144,7 @@ fn location_picker_config<'a>() -> crate::views::picker::PickerConfig<'a> { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -1411,6 +1412,17 @@ impl DashboardState { self.peek_reply.adopt_slash_mru(mru); } + /// Adopt the shared per-command tag map (owned by `AppView`) into both the + /// dispatch input and the peek-reply input so dashboard slash completion + /// renders the same tags as agent prompts. + pub(crate) fn adopt_command_tags( + &mut self, + command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>, + ) { + self.dispatch.adopt_command_tags(command_tags.clone()); + self.peek_reply.adopt_command_tags(command_tags); + } + pub(crate) fn set_screen_mode(&mut self, mode: crate::app::ScreenMode) { self.dispatch.set_screen_mode(mode); self.peek_reply.set_screen_mode(mode); diff --git a/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs b/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs index 424833e..339f0fe 100644 --- a/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs @@ -3568,6 +3568,7 @@ pub fn render_extensions_modal( &non_selectable_clickable, Some(theme.bg_base), loading, + 0, inner_x + inner_width - 1, ); (content_hit.item_rects, content_hit.entry_indices) diff --git a/crates/codegen/xai-grok-pager/src/views/mod.rs b/crates/codegen/xai-grok-pager/src/views/mod.rs index dee7c6c..26ec1bc 100644 --- a/crates/codegen/xai-grok-pager/src/views/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/mod.rs @@ -29,6 +29,7 @@ pub mod permission_view; pub mod persona_detail; pub mod picker; pub mod plan_approval_view; +pub mod privacy_banner; pub mod progress_bar; pub mod prompt_suggestion; pub mod prompt_widget; @@ -49,5 +50,6 @@ pub mod tasks_pane; pub mod timeline; pub mod todo_pane; pub mod turn_status; +pub mod tutorial; pub mod welcome; pub mod workflows; diff --git a/crates/codegen/xai-grok-pager/src/views/modal.rs b/crates/codegen/xai-grok-pager/src/views/modal.rs index 718d182..bb0f52f 100644 --- a/crates/codegen/xai-grok-pager/src/views/modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/modal.rs @@ -552,6 +552,11 @@ pub(crate) fn default_palette_entries( shortcut: "/docs".into(), command: PaletteCommand::HowTo, }, + PaletteEntry { + label: "Tutorial".into(), + shortcut: "/tutorial".into(), + command: PaletteCommand::SlashCommand("/tutorial".into()), + }, PaletteEntry { label: "Quit".into(), shortcut: "Ctrl+Q".into(), @@ -1160,8 +1165,7 @@ pub fn render_doc_viewer_overlay( compact: bool, theme: &Theme, ) { - use ratatui::widgets::{Paragraph, Widget, Wrap}; - let doc_shortcuts = vec![ + let doc_shortcuts = [ super::modal_window::Shortcut { label: "\u{2191}/\u{2193} scroll", clickable: false, @@ -1173,10 +1177,39 @@ pub fn render_doc_viewer_overlay( id: 0, }, ]; + render_doc_viewer_overlay_with_shortcuts( + buf, + area, + window, + title, + content, + scroll, + cached_lines, + compact, + theme, + &doc_shortcuts, + ); +} +/// [`render_doc_viewer_overlay`] with caller-supplied footer shortcuts (the +/// tutorial adds a next-topic hint). +#[allow(clippy::too_many_arguments)] +pub fn render_doc_viewer_overlay_with_shortcuts( + buf: &mut ratatui::buffer::Buffer, + area: Rect, + window: &mut super::modal_window::ModalWindowState, + title: &str, + content: &str, + scroll: &mut u16, + cached_lines: &mut Option<(u16, Vec<ratatui::text::Line<'static>>)>, + compact: bool, + theme: &Theme, + shortcuts: &[super::modal_window::Shortcut<'_>], +) { + use ratatui::widgets::{Paragraph, Widget, Wrap}; let modal_config = super::modal_window::ModalWindowConfig { title, tabs: None, - shortcuts: &doc_shortcuts, + shortcuts, sizing: super::modal_window::ModalSizing { width_pct: 0.80, max_width: 120, diff --git a/crates/codegen/xai-grok-pager/src/views/picker.rs b/crates/codegen/xai-grok-pager/src/views/picker.rs index 51b0b07..4856901 100644 --- a/crates/codegen/xai-grok-pager/src/views/picker.rs +++ b/crates/codegen/xai-grok-pager/src/views/picker.rs @@ -1736,8 +1736,12 @@ pub struct PickerConfig<'a> { pub filter_label: Option<&'a str>, /// Key hint for the filter (e.g., "f"). pub filter_key_hint: Option<&'a str>, - /// Whether the filter is active (not in default/All state). + /// Whether the filter is active (not in its default state). pub filter_active: bool, + /// Pinned single-line note rendered between the search/filter chrome and + /// the first entry (e.g. the hidden-external sessions hint). Render-only: + /// never part of the entry list, hit areas, or scrolling. + pub header_note: Option<&'a str>, /// Custom action keys that produce `PickerOutcome::Action`. /// Each entry is `(key_char, description)` shown in shortcuts. pub action_keys: &'a [(char, &'a str)], @@ -1864,6 +1868,7 @@ pub fn render_picker_content( non_selectable_clickable, bg, loading, + 0, None, ) } @@ -1872,6 +1877,7 @@ pub fn render_picker_content( /// x-position. When `scrollbar_x` is `Some(x)`, the scrollbar is /// rendered at that column instead of `content_area.x + content_area.width - 1`. /// Used by modals with h_pad to place the scrollbar flush against the border. +/// `loading_tick` animates the loading spinner (pass 0 for a static frame). #[allow(clippy::too_many_arguments)] pub fn render_picker_content_with_scrollbar_x( buf: &mut Buffer, @@ -1883,6 +1889,7 @@ pub fn render_picker_content_with_scrollbar_x( non_selectable_clickable: &[bool], bg: Option<ratatui::style::Color>, loading: bool, + loading_tick: u64, scrollbar_x: u16, ) -> PickerContentHitAreas { render_picker_content_inner( @@ -1895,6 +1902,7 @@ pub fn render_picker_content_with_scrollbar_x( non_selectable_clickable, bg, loading, + loading_tick, Some(scrollbar_x), ) } @@ -1977,6 +1985,7 @@ pub fn render_picker_in_modal_inner( &[], Some(theme.bg_base), loading, + 0, inner_x + inner_width - 1, ); state.hit_areas = Some(PickerHitAreas { @@ -2000,6 +2009,7 @@ fn render_picker_content_inner( non_selectable_clickable: &[bool], bg: Option<ratatui::style::Color>, loading: bool, + loading_tick: u64, scrollbar_x_override: Option<u16>, ) -> PickerContentHitAreas { // Cleared each paint; set below if a row underlines its last description line. @@ -2014,11 +2024,13 @@ fn render_picker_content_inner( return empty_hit; } - // Loading state — centered in the content area. + // Loading state — animated dot spinner centered in the content area. if loading { - let msg = "Loading..."; + let spinner_frames = crate::glyphs::dot_spinner_frames(); + let frame = spinner_frames[(loading_tick / 4) as usize % spinner_frames.len()]; + let msg = format!("{frame} Loading\u{2026}"); let msg_style = Style::default().fg(theme.gray); - let cx = content_area.x + content_area.width.saturating_sub(msg.len() as u16) / 2; + let cx = content_area.x + content_area.width.saturating_sub(msg.width() as u16) / 2; let cy = content_area.y + content_area.height / 2; buf.set_string(cx, cy, msg, msg_style); return empty_hit; @@ -2198,6 +2210,7 @@ fn render_picker_content_inner( /// /// Returns hit areas for mouse interaction. The caller stores these in /// `state.hit_areas` for use by `handle_picker_input`. +/// `loading_tick` animates the loading spinner (pass 0 for a static frame). #[allow(clippy::too_many_arguments)] pub fn render_picker( buf: &mut Buffer, @@ -2207,6 +2220,7 @@ pub fn render_picker( entries: &[PickerEntry<'_>], config: &PickerConfig<'_>, loading: bool, + loading_tick: u64, ) -> PickerHitAreas { let empty_hit = PickerHitAreas { close_button: Rect::default(), @@ -2420,7 +2434,28 @@ pub fn render_picker( render_divider(buf, content.x, sep_y, content.width, theme, bg); } - let entries_start_y = sep_y + 1; + let mut entries_start_y = sep_y + 1; + + // Pinned header note: reserve the first list row so it stays visible + // regardless of list scroll. + if let Some(note) = config.header_note + && entries_start_y < content.y + content.height + { + let note_style = Style::default().fg(theme.gray_dim); + let note_style = if let Some(c) = bg { + note_style.bg(c) + } else { + note_style + }; + buf.set_stringn( + content.x + 1, + entries_start_y, + note, + content.width.saturating_sub(1) as usize, + note_style, + ); + entries_start_y += 1; + } // Delegate entry rendering + scrollbar to render_picker_content. let entries_area = Rect { @@ -2429,7 +2464,7 @@ pub fn render_picker( width: content.width, height: (content.y + content.height).saturating_sub(entries_start_y), }; - let content_hit = render_picker_content( + let content_hit = render_picker_content_inner( buf, entries_area, theme, @@ -2439,6 +2474,8 @@ pub fn render_picker( config.non_selectable_clickable, bg, loading, + loading_tick, + None, ); let item_rects = content_hit.item_rects; let entry_indices = content_hit.entry_indices; @@ -3222,6 +3259,7 @@ mod tests { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -3339,7 +3377,7 @@ mod tests { let mut state = PickerState::with_mode(PickerMode::FullScreen); state.search_active = search_active; let mut buf = Buffer::empty(area); - let hit = render_picker(&mut buf, area, &theme, &mut state, &[], &config, false); + let hit = render_picker(&mut buf, area, &theme, &mut state, &[], &config, false, 0); let y = hit.search_bar.y; let mut has_cursor = false; let mut text = String::new(); diff --git a/crates/codegen/xai-grok-pager/src/views/privacy_banner.rs b/crates/codegen/xai-grok-pager/src/views/privacy_banner.rs new file mode 100644 index 0000000..1259279 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/views/privacy_banner.rs @@ -0,0 +1,169 @@ +//! Coding-data sharing upsell banner (Figma "Data Sharing Upsell", +//! node 8698:3690). Shared by the welcome tip slot and the agent-view +//! banner slot; visibility is gated by `AppView::privacy_banner_should_show`. + +use crate::theme::Theme; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; + +/// Legal line copy — used for both render spans and mouse hit width. +const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy."; + +/// Click target for the legal line links. +pub(crate) const PRIVACY_BANNER_LEGAL_URL: &str = "https://x.ai/legal"; + +/// Hit rects returned by [`render`] for mouse handling. +pub(crate) struct PrivacyBannerRects { + pub accept: Rect, + pub customize: Rect, + pub legal: Rect, +} + +/// Render the banner: copy left, `[Customize in settings]` / `[Accept]` +/// right, legal links on the second row. Needs `area.height >= 2`. +/// Hover styling mirrors the plugin CTA buttons. +pub(crate) fn render( + area: Rect, + buf: &mut Buffer, + theme: &Theme, + mouse_pos: Option<(u16, u16)>, +) -> PrivacyBannerRects { + let customize_label = "[Customize in settings]"; + let accept_label = "[Accept]"; + let right_w = (customize_label.len() + 1 + accept_label.len()) as u16; + // Buttons render whole or not at all: a clipped/overflowing [Accept] + // must never leave a click target in the blank margin (a stray click + // there would silently opt the user in). + let buttons_fit = area.width > right_w; + let left_w = if buttons_fit { + area.width - right_w - 1 + } else { + area.width + }; + + let left = Rect { + x: area.x, + y: area.y, + width: left_w, + height: area.height.min(2), + }; + let right = Rect { + x: area.x + left_w + 1, + y: area.y, + width: right_w, + height: 1, + }; + + let hovered = |r: Rect| { + mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my))) + }; + + let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() { + PRIVACY_BANNER_LEGAL.len() + } else { + "Learn more".len().min(left.width as usize) + }; + // The legal line only exists when the slot really has a second row — + // otherwise its rect would make the blank row below clickable. + let legal_rect = if area.height >= 2 { + Rect { + x: left.x, + y: left.y.saturating_add(1), + width: legal_w as u16, + height: 1, + } + } else { + Rect::default() + }; + + // Figma node 8698:3806: title fg/primary, description fg/secondary, + // legal line fg/tertiary with underlined links in the same color. + // The whole legal line is one click target, so its links brighten together. + let link_fg = if hovered(legal_rect) { + theme.gray_bright + } else { + theme.gray + }; + let link = Style::default() + .fg(link_fg) + .add_modifier(Modifier::UNDERLINED); + let gray = Style::default().fg(theme.gray); + let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary)); + let desc = "Allow your sessions to improve SpaceXAI's models."; + // Drop trailing spans whole rather than clipping mid-word when narrow. + let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() { + Line::from(vec![ + title, + Span::raw(" "), + Span::styled(desc, Style::default().fg(theme.gray_bright)), + ]) + } else { + Line::from(title) + }; + // Span pieces must reassemble to PRIVACY_BANNER_LEGAL. + let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() { + Line::from(vec![ + Span::styled("Learn more", link), + Span::styled(" and read ", gray), + Span::styled("Terms", link), + Span::styled(" and ", gray), + Span::styled("Privacy Policy", link), + Span::styled(".", gray), + ]) + } else { + Line::from(Span::styled("Learn more", link)) + }; + Paragraph::new(vec![line1, line2]).render(left, buf); + + if !buttons_fit { + return PrivacyBannerRects { + accept: Rect::default(), + customize: Rect::default(), + legal: legal_rect, + }; + } + let customize_rect = Rect { + x: right.x, + y: right.y, + width: customize_label.len() as u16, + height: 1, + }; + let accept_rect = Rect { + x: right.x + customize_label.len() as u16 + 1, + y: right.y, + width: accept_label.len() as u16, + height: 1, + }; + let customize_style = if hovered(customize_rect) { + Style::default().fg(theme.text_primary).bg(theme.bg_hover) + } else { + Style::default().fg(theme.gray_bright) + }; + let accept_style = if hovered(accept_rect) { + Style::default().fg(theme.link_fg).bg(theme.bg_hover) + } else { + Style::default().fg(theme.text_primary) + }; + buf.set_stringn( + customize_rect.x, + customize_rect.y, + customize_label, + customize_rect.width as usize, + customize_style, + ); + buf.set_stringn( + accept_rect.x, + accept_rect.y, + accept_label, + accept_rect.width as usize, + accept_style, + ); + PrivacyBannerRects { + accept: accept_rect, + customize: customize_rect, + legal: legal_rect, + } +} diff --git a/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs b/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs index 9ff8790..74c91b4 100644 --- a/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs @@ -1092,6 +1092,16 @@ impl PromptWidget { self.slash_controller.set_mru(mru); } + /// Adopt the shared per-command tag map so this prompt's slash dropdown + /// renders the same tags as other agent prompts and the dashboard dispatch. + /// Injected by `AppView`, which owns the single process map. + pub(crate) fn adopt_command_tags( + &mut self, + command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>, + ) { + self.slash_controller.set_command_tags(command_tags); + } + pub(crate) fn set_recap_visible(&mut self, visible: bool) { self.slash_controller .registry_mut() diff --git a/crates/codegen/xai-grok-pager/src/views/queue_pane.rs b/crates/codegen/xai-grok-pager/src/views/queue_pane.rs index 7325187..276c2df 100644 --- a/crates/codegen/xai-grok-pager/src/views/queue_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/queue_pane.rs @@ -373,6 +373,79 @@ pub enum QueueEvent { /// Maximum height (in lines) the queue pane will request. const MAX_QUEUE_HEIGHT: u16 = 3; +/// Hit-test + hover state for one per-row action button (`[edit]`, +/// `[Send now]`, `[cancel]`). Rect and row binding are rebound on every +/// `QueuePane::render`; hover persists across frames so a stationary +/// pointer keeps its highlight. +#[derive(Default)] +struct RowActionButton { + /// Screen rect from the last render; `None` when the button didn't render. + rect: Option<Rect>, + /// Entry id the rendered button acts on. + entry_id: Option<u64>, + /// Entry id whose button is under the mouse, if any. Drives the button's + /// hover fg color. + hovered_id: Option<u64>, +} + +impl RowActionButton { + /// Drop the previous frame's rect/row binding (start of each render). + fn reset(&mut self) { + self.rect = None; + self.entry_id = None; + } + + /// Bind the just-rendered button to its screen rect and row. + fn bind(&mut self, rect: Rect, id: u64) { + self.rect = Some(rect); + self.entry_id = Some(id); + } + + /// Row id the button acts on when `(col, row)` hits its rect. `fallback` + /// is the pane's selected row id. + fn hit(&self, col: u16, row: u16, fallback: Option<u64>) -> Option<u64> { + let rect = self.rect?; + if rect.contains((col, row).into()) { + self.entry_id.or(fallback) + } else { + None + } + } + + /// Update the hover from the mouse position. Returns `true` if it + /// changed (caller redraws). + fn update_hover(&mut self, col: u16, row: u16, fallback: Option<u64>) -> bool { + let over = self.rect.is_some_and(|r| r.contains((col, row).into())); + let new_id = if over { + self.entry_id.or(fallback) + } else { + None + }; + if new_id != self.hovered_id { + self.hovered_id = new_id; + true + } else { + false + } + } + + /// Clear the hover (mouse left the queue pane). Returns `true` if it was + /// previously hovered (caller should redraw). + fn clear_hover(&mut self) -> bool { + if self.hovered_id.is_some() { + self.hovered_id = None; + true + } else { + false + } + } + + /// Whether the button bound to row `id` is under the mouse. + fn is_hovered_for(&self, id: u64) -> bool { + self.hovered_id == Some(id) + } +} + /// Self-contained queue pane component. /// /// Does NOT own the queue data — entries are rebuilt each frame from @@ -395,14 +468,12 @@ pub struct QueuePane { pub overlay: OverlayState, /// Previous queue length — used for auto-show detection. prev_len: usize, - send_now_rect: Option<Rect>, - send_now_entry_id: Option<u64>, - /// Entry id whose `[Interject]` button is under the mouse, if any. Drives - /// the button's hover fg color (brightens like the `[Dashboard]` button). - hovered_send_now_id: Option<u64>, - delete_button_rect: Option<Rect>, - delete_button_entry_id: Option<u64>, - pub(crate) hovered_delete_id: Option<u64>, + /// `[Send now]` (force-interject) action button. + send_now: RowActionButton, + /// `[cancel]` (row delete) action button. + delete_button: RowActionButton, + /// `[edit]` (queued-row edit) action button. + edit_button: RowActionButton, /// Entry id of the row currently under the mouse cursor, if any. Drives /// the hover affordance: the hovered row reveals its action buttons just /// like the selected row does when the pane is focused. @@ -440,12 +511,9 @@ impl QueuePane { last_theme: Theme::current_kind(), overlay: OverlayState::hidden(), prev_len: 0, - send_now_rect: None, - send_now_entry_id: None, - hovered_send_now_id: None, - delete_button_rect: None, - delete_button_entry_id: None, - hovered_delete_id: None, + send_now: RowActionButton::default(), + delete_button: RowActionButton::default(), + edit_button: RowActionButton::default(), hovered_row_id: None, last_inner: None, } @@ -687,73 +755,51 @@ impl QueuePane { /// Hit-test the action-row `[Interject]` button. Returns the id of the row /// the button belongs to (the hovered row, else the selected row). pub fn send_now_click(&self, col: u16, row: u16) -> Option<u64> { - let rect = self.send_now_rect?; - if rect.contains((col, row).into()) { - self.send_now_entry_id.or_else(|| self.selected_id()) - } else { - None - } + self.send_now.hit(col, row, self.selected_id()) } /// Hit-test the action-row `[cancel]` button (removes the row). pub fn delete_click(&self, col: u16, row: u16) -> Option<u64> { - let rect = self.delete_button_rect?; - if rect.contains((col, row).into()) { - self.delete_button_entry_id.or_else(|| self.selected_id()) - } else { - None - } + self.delete_button.hit(col, row, self.selected_id()) } + /// Hit-test the action-row `[edit]` button (opens the queued-row edit). + pub fn edit_click(&self, col: u16, row: u16) -> Option<u64> { + self.edit_button.hit(col, row, self.selected_id()) + } + + /// Update the `[cancel]` hover. Returns `true` if it changed (caller redraws). pub fn update_delete_hover(&mut self, col: u16, row: u16) -> bool { - let over = self - .delete_button_rect - .is_some_and(|r| r.contains((col, row).into())); - let new_id = if over { - self.delete_button_entry_id.or_else(|| self.selected_id()) - } else { - None - }; - if new_id != self.hovered_delete_id { - self.hovered_delete_id = new_id; - true - } else { - false - } + let selected = self.selected_id(); + self.delete_button.update_hover(col, row, selected) } - pub fn clear_delete_hover(&mut self) { - self.hovered_delete_id = None; + /// Clear the `[cancel]` hover. Returns `true` if it was previously hovered. + pub fn clear_delete_hover(&mut self) -> bool { + self.delete_button.clear_hover() } - /// Update whether the mouse is over the `[Interject]` button. Drives the - /// button's hover fg color. Returns `true` if it changed (caller redraws). + /// Update the `[Interject]` hover (drives the brighten-on-hover fg, same + /// as the `[Dashboard]` button). Returns `true` if it changed. pub fn update_send_now_hover(&mut self, col: u16, row: u16) -> bool { - let over = self - .send_now_rect - .is_some_and(|r| r.contains((col, row).into())); - let new_id = if over { - self.send_now_entry_id.or_else(|| self.selected_id()) - } else { - None - }; - if new_id != self.hovered_send_now_id { - self.hovered_send_now_id = new_id; - true - } else { - false - } + let selected = self.selected_id(); + self.send_now.update_hover(col, row, selected) } - /// Clear the `[Interject]` hover (mouse left the queue pane). Returns - /// `true` if it was previously hovered (caller should redraw). + /// Clear the `[Interject]` hover. Returns `true` if it was previously hovered. pub fn clear_send_now_hover(&mut self) -> bool { - if self.hovered_send_now_id.is_some() { - self.hovered_send_now_id = None; - true - } else { - false - } + self.send_now.clear_hover() + } + + /// Update the `[edit]` hover. Returns `true` if it changed (caller redraws). + pub fn update_edit_hover(&mut self, col: u16, row: u16) -> bool { + let selected = self.selected_id(); + self.edit_button.update_hover(col, row, selected) + } + + /// Clear the `[edit]` hover. Returns `true` if it was previously hovered. + pub fn clear_edit_hover(&mut self) -> bool { + self.edit_button.clear_hover() } /// Update which row the mouse is hovering over (the row, not just its @@ -910,14 +956,14 @@ impl QueuePane { } } - // Action buttons (optional [Send now] then [cancel], right-aligned). They - // render for the row under the mouse (hover affordance) or, when the - // pane is focused, the selected row. Hover takes precedence so mouse - // users can act on any row without focusing/selecting it first. - self.send_now_rect = None; - self.send_now_entry_id = None; - self.delete_button_rect = None; - self.delete_button_entry_id = None; + // Action buttons (optional [Send now], then [edit], then [cancel], + // right-aligned). They render for the row under the mouse (hover + // affordance) or, when the pane is focused, the selected row. Hover + // takes precedence so mouse users can act on any row without + // focusing/selecting it first. + self.send_now.reset(); + self.delete_button.reset(); + self.edit_button.reset(); let action_idx = self .hovered_row_id .and_then(|id| self.entries.iter().position(|e| e.id == id)) @@ -941,42 +987,69 @@ impl QueuePane { && rel < inner.height as usize { let screen_y = inner.y + rel as u16; + let btn_style = Style::default().fg(theme.gray); + // Right-to-left walk. A button renders only when its whole + // label fits at or right of `inner.x`: saturating toward 0 + // would paint into the left gutter and overlap already-placed + // buttons (checked_sub underflow = "doesn't fit", not x = 0). + let mut right = inner.x + inner.width; + let fits = |right: u16, w: u16| right.checked_sub(w).filter(|&x| x >= inner.x); + let cancel_label = "[cancel]"; let cancel_w = cancel_label.len() as u16; - // Compact action wording; same mouse hit-test as before (force-interject). - let interject_label = "[Send now]"; - let interject_w = interject_label.len() as u16; - let btn_style = Style::default().fg(theme.gray); - let mut right = inner.x + inner.width; - - right = right.saturating_sub(cancel_w); - let cancel_x = right; - let hovered = self.hovered_delete_id == Some(entry.id); - let cancel_style = if hovered { - Style::default().fg(theme.accent_error) - } else { - btn_style - }; - buf.set_string_safe(cancel_x, screen_y, cancel_label, cancel_style); - self.delete_button_rect = Some(Rect::new(cancel_x, screen_y, cancel_w, 1)); - self.delete_button_entry_id = Some(entry.id); - - if is_turn_running { - // Place [Send now] flush against [cancel] (no gap) — a gap - // would let the queued message behind the row leak through - // the seam between the two buttons. - right = right.saturating_sub(interject_w); - let interject_x = right; - // Brighten the fg on hover (same hover color as the - // [Dashboard] button) so it reads as clickable. - let interject_style = if self.hovered_send_now_id == Some(entry.id) { - Style::default().fg(theme.text_primary) + if let Some(cancel_x) = fits(right, cancel_w) { + right = cancel_x; + let cancel_style = if self.delete_button.is_hovered_for(entry.id) { + Style::default().fg(theme.accent_error) } else { btn_style }; - buf.set_string_safe(interject_x, screen_y, interject_label, interject_style); - self.send_now_rect = Some(Rect::new(interject_x, screen_y, interject_w, 1)); - self.send_now_entry_id = Some(entry.id); + buf.set_string_safe(cancel_x, screen_y, cancel_label, cancel_style); + self.delete_button + .bind(Rect::new(cancel_x, screen_y, cancel_w, 1), entry.id); + + // [edit] sits flush against [cancel] (no gap) — a gap + // would let the queued message behind the row leak through + // the seam. Unlike [Send now] it renders regardless of + // turn state — the keyboard `e` edit works either way. + let edit_label = "[edit]"; + let edit_w = edit_label.len() as u16; + if let Some(edit_x) = fits(right, edit_w) { + right = edit_x; + let edit_style = if self.edit_button.is_hovered_for(entry.id) { + Style::default().fg(theme.text_primary) + } else { + btn_style + }; + buf.set_string_safe(edit_x, screen_y, edit_label, edit_style); + self.edit_button + .bind(Rect::new(edit_x, screen_y, edit_w, 1), entry.id); + } + + if is_turn_running { + // Compact action wording; same mouse hit-test as before + // (force-interject). Leftmost in the chain, flush + // against [edit] for the same no-seam reason. + let interject_label = "[Send now]"; + let interject_w = interject_label.len() as u16; + if let Some(interject_x) = fits(right, interject_w) { + // Brighten the fg on hover (same hover color as the + // [Dashboard] button) so it reads as clickable. + let interject_style = if self.send_now.is_hovered_for(entry.id) { + Style::default().fg(theme.text_primary) + } else { + btn_style + }; + buf.set_string_safe( + interject_x, + screen_y, + interject_label, + interject_style, + ); + self.send_now + .bind(Rect::new(interject_x, screen_y, interject_w, 1), entry.id); + } + } } } } @@ -1521,9 +1594,9 @@ mod tests { // -- Action-button rendering (hover + layout) ---------------------------- - /// The `[Interject]` and `[cancel]` buttons render flush against each other - /// so the queued message behind the row can't leak through a seam between - /// them (no gap). + /// The `[Interject]`, `[edit]`, and `[cancel]` buttons render flush + /// against each other so the queued message behind the row can't leak + /// through a seam between them (no gap). #[test] fn action_buttons_render_flush_with_no_gap() { let mut pane = QueuePane::new(); @@ -1539,16 +1612,122 @@ mod tests { let area = Rect::new(0, 0, 80, 1); let mut buf = Buffer::empty(area); let layout_cfg = crate::appearance::LayoutConfig::default(); - // Focused + turn running → both buttons render for the selected row. + // Focused + turn running → all three buttons render for the selected row. pane.render(area, &mut buf, true, &layout_cfg, None, true); - let interject = pane.send_now_rect.expect("interject button renders"); - let delete = pane.delete_button_rect.expect("delete button renders"); + let edit = pane.edit_button.rect.expect("edit button renders"); + let interject = pane.send_now.rect.expect("interject button renders"); + let delete = pane.delete_button.rect.expect("delete button renders"); assert_eq!( interject.x + interject.width, - delete.x, - "[Interject] must sit flush against [cancel] (no gap to leak through)" + edit.x, + "[Interject] must sit flush against [edit] (no gap to leak through)" ); + assert_eq!( + edit.x + edit.width, + delete.x, + "[edit] must sit flush against [cancel] (no gap to leak through)" + ); + } + + /// `[edit]` renders even when no turn is running — the keyboard `e` edit + /// works regardless of turn state — while `[Send now]` stays hidden, and + /// the chain stays flush: [edit][cancel]. + #[test] + fn edit_button_renders_when_turn_not_running() { + let mut pane = QueuePane::new(); + let mut local = std::collections::VecDeque::new(); + local.push_back(local_prompt(1, "msg")); + pane.sync_from_merged(&local, &[], None, None, &Default::default()); + let ids = pane.entry_ids(); + pane.list_state.select_by_id(ids[0]); + + let area = Rect::new(0, 0, 80, 1); + let mut buf = Buffer::empty(area); + let layout_cfg = crate::appearance::LayoutConfig::default(); + // Focused + turn NOT running → [edit] and [cancel], no [Send now]. + pane.render(area, &mut buf, true, &layout_cfg, None, false); + + assert!( + pane.send_now.rect.is_none(), + "[Send now] only renders mid-turn" + ); + let edit = pane + .edit_button + .rect + .expect("edit button renders while idle"); + let cancel = pane.delete_button.rect.expect("cancel button renders"); + assert_eq!(pane.edit_button.entry_id, Some(ids[0])); + assert_eq!( + edit.x + edit.width, + cancel.x, + "[edit] must sit flush against [cancel] when [Send now] is hidden" + ); + } + + /// On panes too narrow for the full `[Send now][edit][cancel]` chain, a + /// button that can't fully fit at or right of the content area's left + /// edge is dropped instead of saturating toward x = 0 — otherwise rects + /// land outside `inner` and overlap, mis-routing clicks (send-now is + /// hit-tested before edit, so overlapped cells would fire it). + #[test] + fn narrow_pane_drops_buttons_that_do_not_fit() { + let layout_cfg = crate::appearance::LayoutConfig::default(); + // Probe the left padding once so the width sweep spans inner widths + // from 1 (nothing fits) past 24 (the full chain fits). + let pad_left = { + let mut pane = QueuePane::new(); + let mut local = std::collections::VecDeque::new(); + local.push_back(local_prompt(1, "msg")); + pane.sync_from_merged(&local, &[], None, None, &Default::default()); + let area = Rect::new(0, 0, 80, 1); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf, true, &layout_cfg, None, true); + pane.last_inner.expect("inner recorded").x + }; + + for is_running in [true, false] { + for width in (pad_left + 1)..=(pad_left + 26) { + let mut pane = QueuePane::new(); + let mut local = std::collections::VecDeque::new(); + local.push_back(local_prompt(1, "msg")); + pane.sync_from_merged(&local, &[], None, None, &Default::default()); + let ids = pane.entry_ids(); + pane.list_state.select_by_id(ids[0]); + + let area = Rect::new(0, 0, width, 1); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf, true, &layout_cfg, None, is_running); + + let inner = pane.last_inner.expect("inner recorded"); + let rects = [ + ("edit", pane.edit_button.rect), + ("send_now", pane.send_now.rect), + ("cancel", pane.delete_button.rect), + ]; + let mut placed: Vec<(&str, Rect)> = rects + .iter() + .filter_map(|&(name, rect)| rect.map(|r| (name, r))) + .collect(); + for (name, r) in &placed { + assert!( + r.x >= inner.x && r.x + r.width <= inner.x + inner.width, + "{name} rect {r:?} must stay inside inner {inner:?} \ + at width {width} (running={is_running})" + ); + } + placed.sort_by_key(|(_, r)| r.x); + for pair in placed.windows(2) { + let (an, a) = pair[0]; + let (bn, b) = pair[1]; + assert!( + a.x + a.width <= b.x, + "{an} and {bn} rects must not overlap at width {width} \ + (running={is_running}): {a:?} vs {b:?}" + ); + } + } + } } /// Hovering a row reveals that row's action buttons even when the pane is @@ -1567,8 +1746,9 @@ mod tests { // Unfocused with no hover → no action buttons at all. pane.render(area, &mut buf, false, &layout_cfg, None, true); - assert!(pane.delete_button_rect.is_none()); - assert!(pane.send_now_rect.is_none()); + assert!(pane.delete_button.rect.is_none()); + assert!(pane.send_now.rect.is_none()); + assert!(pane.edit_button.rect.is_none()); // Hover the second row → its buttons appear (still unfocused). let inner = pane.last_inner.expect("inner area recorded during render"); @@ -1579,16 +1759,19 @@ mod tests { pane.render(area, &mut buf, false, &layout_cfg, None, true); let ids = pane.entry_ids(); - assert_eq!(pane.delete_button_entry_id, Some(ids[1])); - assert_eq!(pane.send_now_entry_id, Some(ids[1])); - assert!(pane.delete_button_rect.is_some()); - assert!(pane.send_now_rect.is_some()); + assert_eq!(pane.delete_button.entry_id, Some(ids[1])); + assert_eq!(pane.send_now.entry_id, Some(ids[1])); + assert_eq!(pane.edit_button.entry_id, Some(ids[1])); + assert!(pane.delete_button.rect.is_some()); + assert!(pane.send_now.rect.is_some()); + assert!(pane.edit_button.rect.is_some()); // Moving off the rows clears the hover and hides the buttons again. assert!(pane.clear_row_hover()); pane.render(area, &mut buf, false, &layout_cfg, None, true); - assert!(pane.delete_button_rect.is_none()); - assert!(pane.send_now_rect.is_none()); + assert!(pane.delete_button.rect.is_none()); + assert!(pane.send_now.rect.is_none()); + assert!(pane.edit_button.rect.is_none()); } /// Hovering a row paints the dim hover bg across that row (matching the @@ -1649,14 +1832,14 @@ mod tests { // Focused + turn running → [Interject] renders for the selected row. pane.render(area, &mut buf, true, &layout_cfg, None, true); - let rect = pane.send_now_rect.expect("interject button renders"); + let rect = pane.send_now.rect.expect("interject button renders"); let non_hover_fg = buf[(rect.x, rect.y)].fg; assert_eq!(non_hover_fg, theme.gray, "plain button uses gray fg"); // Hover the [Interject] button → fg becomes text_primary. assert!(pane.update_send_now_hover(rect.x, rect.y)); pane.render(area, &mut buf, true, &layout_cfg, None, true); - let rect = pane.send_now_rect.expect("interject button still renders"); + let rect = pane.send_now.rect.expect("interject button still renders"); for x in rect.x..rect.x + rect.width { assert_eq!(buf[(x, rect.y)].fg, theme.text_primary, "col {x}"); } @@ -1695,15 +1878,20 @@ mod tests { pane.render(area, &mut buf, true, &layout_cfg, None, true); assert!( - pane.delete_button_rect.is_none(), + pane.delete_button.rect.is_none(), "off-screen hovered row must not bind the cancel button to row 0" ); assert!( - pane.send_now_rect.is_none(), + pane.send_now.rect.is_none(), "off-screen hovered row must not bind the interject button to row 0" ); - assert_eq!(pane.delete_button_entry_id, None); - assert_eq!(pane.send_now_entry_id, None); + assert!( + pane.edit_button.rect.is_none(), + "off-screen hovered row must not bind the edit button to row 0" + ); + assert_eq!(pane.delete_button.entry_id, None); + assert_eq!(pane.send_now.entry_id, None); + assert_eq!(pane.edit_button.entry_id, None); } /// The `[cancel]` button's right edge reaches the full content-area width @@ -1723,7 +1911,7 @@ mod tests { let layout_cfg = crate::appearance::LayoutConfig::default(); pane.render(area, &mut buf, true, &layout_cfg, None, true); - let cancel = pane.delete_button_rect.expect("cancel button renders"); + let cancel = pane.delete_button.rect.expect("cancel button renders"); assert_eq!( cancel.x + cancel.width, area.x + area.width, @@ -1756,7 +1944,7 @@ mod tests { .list_state .scrollbar_area() .expect("scrollbar shown for 5 rows in a height-3 pane"); - let cancel = pane.delete_button_rect.expect("cancel button renders"); + let cancel = pane.delete_button.rect.expect("cancel button renders"); assert!( sb.x >= cancel.x + cancel.width, "scrollbar (x={}) must sit clear of [cancel] (right edge {})", diff --git a/crates/codegen/xai-grok-pager/src/views/session_picker.rs b/crates/codegen/xai-grok-pager/src/views/session_picker.rs index 63257cb..3279ef4 100644 --- a/crates/codegen/xai-grok-pager/src/views/session_picker.rs +++ b/crates/codegen/xai-grok-pager/src/views/session_picker.rs @@ -117,54 +117,90 @@ impl SessionPickerLanes { } } +/// Loading gate for a session picker surface's spinner: nothing to show yet — +/// no loaded entry passes the source filter — while the native fetch or +/// foreign scan is still in flight. The filter check (not `entries.is_none()`) +/// matters because the fast foreign scan can land rows the default Grok view +/// hides before the native list arrives; the empty state must wait until both +/// lanes settle. Shared by rendering, redraw forcing, and tick demand so the +/// three cannot drift (a spinner that renders without demanding ticks parks +/// on its first frame). +pub(crate) fn loading_spinner_active( + entries: Option<&[SessionPickerEntry]>, + source_filter: SourceFilter, + loading: bool, + lanes: &SessionPickerLanes, +) -> bool { + let nothing_visible = entries.is_none_or(|entries| { + !entries + .iter() + .any(|entry| source_filter.matches(&entry.source)) + }); + nothing_visible && (loading || lanes.foreign_loading) +} + // --------------------------------------------------------------------------- // Source filter // --------------------------------------------------------------------------- /// Filter session entries by native, remote, or external source. +/// +/// Default is [`Self::Grok`]: native Grok sessions only (local / remote / +/// conversation), so `/resume` does not mix Claude/Codex/Cursor foreign +/// sessions into the list. `f` cycles Grok → External → All → Local → +/// Remote — External first so one press from the default reveals foreign +/// sessions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SourceFilter { + /// Native Grok sessions only — excludes Claude/Codex/Cursor foreign rows. #[default] - All, + Grok, Local, Remote, External, + /// Every source, including foreign agent sessions. + All, } impl SourceFilter { pub fn label(self) -> &'static str { match self { - Self::All => "All", + Self::Grok => "Grok", Self::Local => "Local", Self::Remote => "Remote", Self::External => "External", + Self::All => "All", } } pub fn next(self) -> Self { match self { + Self::Grok => Self::External, + Self::External => Self::All, Self::All => Self::Local, Self::Local => Self::Remote, - Self::Remote => Self::External, - Self::External => Self::All, + Self::Remote => Self::Grok, } } /// Returns `true` when a non-default filter is selected. pub fn is_active(self) -> bool { - self != Self::All + self != Self::Grok } /// Returns `true` if a session with the given `source` string passes the filter. /// /// grok.com conversations carry `source == "conversation"` and live remotely, - /// so they pass the `Remote` filter (and `All`) but not `Local`. + /// so they pass the `Remote` filter (and `Grok` / `All`) but not `Local`. + /// Foreign sources (`claude` / `codex` / `cursor`) only pass `External` and + /// `All`. pub fn matches(self, source: &str) -> bool { match self { - Self::All => true, + Self::Grok => !crate::app::is_foreign_picker_source(source), Self::Local => source == "local" || source == "both", Self::Remote => source == "remote" || source == "both" || source == "conversation", Self::External => crate::app::is_foreign_picker_source(source), + Self::All => true, } } } @@ -818,6 +854,28 @@ pub(crate) fn build_content_header_label( } } +/// Hint shown on the default `Grok` view when the foreign-session scan loaded +/// Claude/Codex/Cursor entries it hides. Grok-only: `next(Grok) == External` +/// makes the copy literally true, and reaching Local/Remote already cycles +/// through External/All, so the discovery hint is only needed on the default +/// state. +pub(crate) fn hidden_external_hint( + entries: Option<&[SessionPickerEntry]>, + source_filter: SourceFilter, +) -> Option<String> { + if source_filter != SourceFilter::Grok { + return None; + } + let hidden = entries? + .iter() + .filter(|entry| crate::app::is_foreign_picker_source(&entry.source)) + .count(); + (hidden > 0).then(|| { + let plural = if hidden == 1 { "" } else { "s" }; + format!("{hidden} external session{plural} hidden \u{b7} f to show") + }) +} + // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- @@ -1283,6 +1341,15 @@ mod tests { #[test] fn source_filter_matches() { + // Default Grok filter: native only (not Claude/Codex/Cursor). + assert!(SourceFilter::Grok.matches("local")); + assert!(SourceFilter::Grok.matches("remote")); + assert!(SourceFilter::Grok.matches("both")); + assert!(SourceFilter::Grok.matches("conversation")); + assert!(!SourceFilter::Grok.matches("claude")); + assert!(!SourceFilter::Grok.matches("codex")); + assert!(!SourceFilter::Grok.matches("cursor")); + assert!(SourceFilter::All.matches("local")); assert!(SourceFilter::All.matches("remote")); assert!(SourceFilter::All.matches("both")); @@ -1300,7 +1367,7 @@ mod tests { assert!(!SourceFilter::Remote.matches("local")); assert!(!SourceFilter::Remote.matches("cursor")); - // grok.com conversations are remote: visible under All + Remote, not Local. + // grok.com conversations are remote: visible under Grok + All + Remote, not Local. assert!(SourceFilter::All.matches("conversation")); assert!(SourceFilter::Remote.matches("conversation")); assert!(!SourceFilter::Local.matches("conversation")); @@ -1316,11 +1383,15 @@ mod tests { #[test] fn source_filter_cycles() { + // External first: one press from the default reveals foreign sessions. + assert_eq!(SourceFilter::Grok.next(), SourceFilter::External); + assert_eq!(SourceFilter::External.next(), SourceFilter::All); assert_eq!(SourceFilter::All.next(), SourceFilter::Local); assert_eq!(SourceFilter::Local.next(), SourceFilter::Remote); - assert_eq!(SourceFilter::Remote.next(), SourceFilter::External); - assert_eq!(SourceFilter::External.next(), SourceFilter::All); + assert_eq!(SourceFilter::Remote.next(), SourceFilter::Grok); + assert_eq!(SourceFilter::Grok.label(), "Grok"); assert_eq!(SourceFilter::External.label(), "External"); + assert_eq!(SourceFilter::default(), SourceFilter::Grok); } #[test] @@ -1339,6 +1410,9 @@ mod tests { entry_with_source("s5", "cursor"), ]; + let grok = filter_session_entries(Some(&entries), "", SourceFilter::Grok); + assert_eq!(grok, vec![0, 1, 2]); // local + remote + both, no foreign + let all = filter_session_entries(Some(&entries), "", SourceFilter::All); assert_eq!(all, vec![0, 1, 2, 3, 4, 5]); @@ -1354,24 +1428,69 @@ mod tests { #[test] fn source_filter_empty_and_unknown_source() { - // Empty source string (e.g. from old data or test fixtures) should - // only pass the All filter, never Local or Remote. + // Empty / unknown source (e.g. from old data or test fixtures) is not + // foreign, so it passes Grok + All but never Local, Remote, or External. + assert!(SourceFilter::Grok.matches("")); assert!(SourceFilter::All.matches("")); assert!(!SourceFilter::Local.matches("")); assert!(!SourceFilter::Remote.matches("")); + assert!(!SourceFilter::External.matches("")); - // Unknown source values are also rejected by Local/Remote. + assert!(SourceFilter::Grok.matches("unknown")); assert!(SourceFilter::All.matches("unknown")); assert!(!SourceFilter::Local.matches("unknown")); assert!(!SourceFilter::Remote.matches("unknown")); + assert!(!SourceFilter::External.matches("unknown")); } #[test] fn source_filter_is_active() { - assert!(!SourceFilter::All.is_active()); + assert!(!SourceFilter::Grok.is_active()); assert!(SourceFilter::Local.is_active()); assert!(SourceFilter::Remote.is_active()); assert!(SourceFilter::External.is_active()); + assert!(SourceFilter::All.is_active()); + } + + #[test] + fn hidden_external_hint_visibility() { + fn entry_with_source(id: &str, source: &str) -> SessionPickerEntry { + let mut e = make_entry(id, "r"); + e.source = source.into(); + e + } + let entries = vec![ + entry_with_source("s0", "local"), + entry_with_source("s1", "claude"), + entry_with_source("s2", "codex"), + ]; + + // Only the default Grok view surfaces the hint (with the count). + assert_eq!( + hidden_external_hint(Some(&entries), SourceFilter::Grok).as_deref(), + Some("2 external sessions hidden \u{b7} f to show") + ); + assert!(hidden_external_hint(Some(&entries), SourceFilter::Local).is_none()); + assert!(hidden_external_hint(Some(&entries), SourceFilter::Remote).is_none()); + + // Singular count. + let one = vec![ + entry_with_source("s0", "local"), + entry_with_source("s1", "cursor"), + ]; + assert_eq!( + hidden_external_hint(Some(&one), SourceFilter::Grok).as_deref(), + Some("1 external session hidden \u{b7} f to show") + ); + + // External / All show foreign rows — no hint. + assert!(hidden_external_hint(Some(&entries), SourceFilter::External).is_none()); + assert!(hidden_external_hint(Some(&entries), SourceFilter::All).is_none()); + + // No foreign entries loaded (native-only or no scan) — no hint. + let native = vec![entry_with_source("s0", "local")]; + assert!(hidden_external_hint(Some(&native), SourceFilter::Grok).is_none()); + assert!(hidden_external_hint(None, SourceFilter::Grok).is_none()); } #[test] diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs index 5a03bba..b6be0b9c 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs @@ -788,7 +788,12 @@ pub(super) fn setting_row_visible( minimal: bool, voice_mode: bool, ) -> bool { - if !voice_mode && matches!(meta.key, "voice_capture_mode" | "voice_stt_language") { + if !voice_mode + && matches!( + meta.key, + "voice_keybind_enabled" | "voice_capture_mode" | "voice_stt_language" + ) + { return false; } if meta.key == "voice_capture_mode" && !kitty_releases { @@ -858,6 +863,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> { "contextual_hints.ssh_wrap" => Some(Action::SetContextualHintSshWrap(new)), "multiline_mode" => Some(Action::SetMultilineMode(new)), "vim_mode" => Some(Action::SetVimMode(new)), + "voice_keybind_enabled" => Some(Action::SetVoiceKeybindEnabled(new)), "remember_tool_approvals" => Some(Action::SetRememberToolApprovals(new)), "toolset.ask_user_question.timeout_enabled" => { Some(Action::SetAskUserQuestionTimeoutEnabled(new)) diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs index e7473fd..9f8e1c2 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs @@ -191,17 +191,22 @@ fn setting_row_visible_gates_voice_capture_on_key_releases() { #[test] fn setting_row_visible_hides_voice_rows_when_voice_mode_off() { let reg = SettingsRegistry::defaults(); + let keybind = meta_for(®, "voice_keybind_enabled"); let capture = meta_for(®, "voice_capture_mode"); let language = meta_for(®, "voice_stt_language"); let vim = meta_for(®, "vim_mode"); - // Gate off: both voice rows gone even with kitty releases + full TUI. + // Gate off: all voice rows gone even with kitty releases + full TUI. + assert!(!setting_row_visible(keybind, true, false, false)); assert!(!setting_row_visible(capture, true, false, false)); assert!(!setting_row_visible(language, true, false, false)); // Non-voice rows unaffected. assert!(setting_row_visible(vim, true, false, false)); - // Gate on: both visible (kitty releases for capture). + // Gate on: all visible (kitty releases for capture). + assert!(setting_row_visible(keybind, true, false, true)); assert!(setting_row_visible(capture, true, false, true)); assert!(setting_row_visible(language, true, false, true)); + // The keybind row (unlike capture) doesn't need key-release reporting. + assert!(setting_row_visible(keybind, false, false, true)); } #[test] @@ -657,7 +662,8 @@ fn rows_contain_categories_and_settings_through_pr_14() { // SHELL-owned prompt_suggestions (Editor; tab autocomplete // ghost text, live cache). "prompt_suggestions", - // voice_capture_mode + voice_stt_language hidden when gate is off. + // voice_keybind_enabled + voice_capture_mode + voice_stt_language + // hidden when the voice gate is off. // SHELL-owned permission_mode (Agent category). "permission_mode", // SHELL-owned remember_tool_approvals (Agent category, diff --git a/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs b/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs index 477b2e7..5873a8e 100644 --- a/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs +++ b/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs @@ -178,12 +178,14 @@ pub fn build_entries( continue; } // The voice chord (`Ctrl+Space`) is hidden when the voice gate is - // off (remote kill switch / `GROK_VOICE_MODE=0`). Unlike the old - // `Ctrl+Shift+M`, `Ctrl+Space` decodes the same with or without the - // Kitty keyboard protocol (it just toggles instead of hold-to-talk - // without it), so it's shown on every terminal once the gate is on. + // off (remote kill switch / `GROK_VOICE_MODE=0`) or the user turned + // the Voice shortcut setting off — don't advertise keys that do + // nothing. `Ctrl+Space` decodes the same with or without the Kitty + // keyboard protocol (it just toggles instead of hold-to-talk), so + // it's shown on every terminal once the gates are on. // EnableVoiceMode is slash-only and already dropped above. - if def.id == crate::actions::ActionId::VoiceToggle && !crate::app::voice_mode_enabled() + if def.id == crate::actions::ActionId::VoiceToggle + && (!crate::app::voice_mode_enabled() || !crate::app::voice_keybind_enabled()) { continue; } @@ -494,6 +496,7 @@ fn picker_config(non_sel: &[bool]) -> PickerConfig<'_> { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -1350,6 +1353,7 @@ pub fn render_modal( &[], Some(theme.bg_base), false, + 0, inner_x + inner_width - 1, ); state.hit_areas = Some(PickerHitAreas { diff --git a/crates/codegen/xai-grok-pager/src/views/slash_dropdown.rs b/crates/codegen/xai-grok-pager/src/views/slash_dropdown.rs index 6a62c17..d8ca025 100644 --- a/crates/codegen/xai-grok-pager/src/views/slash_dropdown.rs +++ b/crates/codegen/xai-grok-pager/src/views/slash_dropdown.rs @@ -45,16 +45,38 @@ pub fn desired_item_rows(items: &[SuggestionRow], items_width: u16) -> u16 { flat_line_count(items, items_width as usize, MAX_DROPDOWN_ROWS as usize) as u16 } +/// The rendered `" [tag]"` suffix for a row, or `None` when untagged. +fn tag_suffix(row: &SuggestionRow) -> Option<String> { + row.tag.as_ref().map(|t| format!(" [{t}]")) +} + +/// Rendered width of a row's `" [tag]"` suffix (0 when untagged). Measured +/// without allocating: space + `[` + tag + `]` = tag width + 3. The tag shares +/// the label column so descriptions stay aligned across tagged/untagged rows. +fn tag_suffix_width(row: &SuggestionRow) -> usize { + row.tag.as_ref().map(|t| t.width() + 3).unwrap_or(0) +} + /// Compute the aligned label column width from all visible items. /// /// The label column gets up to 60% of the available width (capped at `LABEL_CAP`). -/// This prioritises showing the full command name over the description. +/// This prioritises showing the full command name over the description. The tag +/// suffix is folded in so a `/cmd [tag]` row and a plain `/cmd` row share the +/// same description column. Untagged rows keep origin/main behavior (overlong +/// commands are ignored); tagged rows always contribute a `LABEL_CAP`-clamped +/// width so a long tag can never zero out the column. fn compute_label_column_w(items: &[SuggestionRow], content_w: usize) -> usize { let budget = (content_w * 3 / 5).min(LABEL_CAP); let max_display_w = items .iter() - .map(|r| r.display.width()) - .filter(|&w| w <= LABEL_CAP) + .filter_map(|r| { + let base = r.display.width(); + if r.tag.is_none() { + (base <= LABEL_CAP).then_some(base) + } else { + Some((base + tag_suffix_width(r)).min(LABEL_CAP)) + } + }) .max() .unwrap_or(0); max_display_w.min(budget) @@ -291,6 +313,7 @@ fn build_item_lines( let match_style = Style::default().fg(match_fg).bg(row_bg).add_modifier(bold); let desc_style = Style::default().fg(desc_fg).bg(row_bg); let bg_style = Style::default().bg(row_bg); + let tag_style = Style::default().fg(theme.accent_system).bg(row_bg); // 1. Build prefix + label spans with fuzzy match highlighting. let prefix = if is_selected { @@ -303,14 +326,22 @@ fn build_item_lines( if is_selected { normal_style } else { bg_style }, ); - let label = truncate_str(&item.display, label_col_w); + // Optional " [tag]" suffix, right-aligned at the end of the label column + // (just left of the description). Truncated/reserved so the name never + // overruns it at narrow widths. Leading space in the suffix separates + // label and tag when padding is 0 (longest command+tag row). + let tag_text = tag_suffix(item).map(|s| truncate_str(&s, label_col_w)); + let tag_w = tag_text.as_deref().map(|s| s.width()).unwrap_or(0); + + let label = truncate_str(&item.display, label_col_w.saturating_sub(tag_w)); let label_w = label.width(); - let padding = label_col_w.saturating_sub(label_w); + let padding = label_col_w.saturating_sub(label_w + tag_w); // Build per-character spans for the label with fuzzy highlight. let label_spans = build_highlighted_spans(&label, &item.indices, normal_style, match_style); - // Description column indent (prefix + label + gap). + // Description column indent (prefix + label + gap). `label_col_w` already + // includes the tag suffix, so descriptions align across tagged/untagged rows. let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP; let desc_w = total_w.saturating_sub(desc_indent).max(1); @@ -321,13 +352,18 @@ fn build_item_lines( simple_word_wrap(&item.description, desc_w) }; - // 2. First line: prefix + label(padded) + gap + first desc line. + // 2. First line: prefix + label + padding + [tag] + gap + first desc line. + // Padding comes before the tag so the tag is right-aligned at the end of + // the label column (`]` sits just left of the description gap). { let mut spans = vec![prefix_span]; spans.extend(label_spans); if padding > 0 { spans.push(Span::styled(" ".repeat(padding), bg_style)); } + if let Some(tag_text) = tag_text { + spans.push(Span::styled(tag_text, tag_style)); + } if let Some(first_desc) = desc_lines.first() { spans.push(Span::styled(" ".to_string(), bg_style)); spans.push(Span::styled(first_desc.clone(), desc_style)); @@ -466,6 +502,7 @@ mod tests { description: String::new(), insert_text: format!("/cmd{i}"), indices: vec![], + tag: None, }) .collect(); assert_eq!(desired_item_rows(&matches, 80), MAX_DROPDOWN_ROWS); @@ -486,6 +523,7 @@ mod tests { description: format!("description for command {i}"), insert_text: format!("/cmd{i}"), indices: vec![], + tag: None, }) .collect(); let snap = SlashSnapshot { @@ -520,6 +558,7 @@ mod tests { description: description.into(), insert_text: display.into(), indices: vec![], + tag: None, } } @@ -648,4 +687,125 @@ mod tests { assert_eq!(rendered.row_items.len(), rows as usize); assert_eq!(rendered.row_items[0], 0, "scroll starts at the top"); } + + /// A tagged row renders "[tag]" (system-accent) between the command name and + /// the description; untagged rows and arg rows render no bracket. + #[test] + fn tagged_command_row_renders_bracketed_tag() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let theme = Theme::default(); + let mut tagged = row("/tagged", "does work"); + tagged.tag = Some("new".to_string()); + let untagged = row("/plain", "no tag here"); + let arg = row("argrow", "an argument"); // arg rows always have tag = None + + let width: u16 = 60; + let snap = SlashSnapshot { + open: true, + // Select row 1 so the tagged + arg rows stay unselected. + matches: vec![tagged, untagged, arg], + selected: 1, + ..Default::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, width, 3)); + let area = Rect::new(0, 0, width, 3); + render_dropdown(&mut buf, area, &snap, None, &theme); + + let row_text = |y: u16| -> String { + (0..width) + .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string())) + .collect() + }; + + // True buffer column of `needle`'s first cell. Do not use `str::find` on + // `row_text`: the selected-row prefix is multi-byte (`❯`), so byte + // offsets drift from display columns and falsely report misalignment. + let desc_col = |y: u16, needle: &str| -> u16 { + let needle_chars: Vec<char> = needle.chars().collect(); + (0..width) + .find(|&start| { + needle_chars.iter().enumerate().all(|(i, ch)| { + let x = start + i as u16; + x < width + && buf + .cell((x, y)) + .is_some_and(|c| c.symbol() == ch.to_string()) + }) + }) + .unwrap_or_else(|| panic!("row {y} missing {needle:?}: {}", row_text(y))) + }; + + // Row 0 (tagged): "[new]" present, and the open-bracket cell uses accent. + assert!( + row_text(0).contains("[new]"), + "tagged row shows [new]: {}", + row_text(0) + ); + let bracket_x = (0..width) + .find(|&x| buf.cell((x, 0)).map(|c| c.symbol()) == Some("[")) + .expect("open bracket in tagged row"); + assert_eq!( + buf.cell((bracket_x, 0)).unwrap().fg, + theme.accent_system, + "tag renders in the system accent" + ); + + // Row 1 (untagged) and row 2 (arg): no bracket at all. + assert!( + !row_text(1).contains('['), + "untagged row has no bracket: {}", + row_text(1) + ); + assert!( + !row_text(2).contains('['), + "arg row has no bracket: {}", + row_text(2) + ); + + // Shared-column invariant: the description starts at the same buffer + // column on the tagged row and the untagged row (the tag folds into + // the label column, so it never shifts the description). + let desc0_x = desc_col(0, "does work"); + let desc1_x = desc_col(1, "no tag here"); + assert_eq!( + desc0_x, + desc1_x, + "tagged and untagged descriptions must share the same column (row0={}, row1={})", + row_text(0), + row_text(1) + ); + + // Tag is right-aligned: closing `]` sits at the label-column right edge, + // immediately before the first-line gap space and then the description. + // First-line gap is one space (see build_item_lines), so `]` column == + // desc_col - 1 - 1. (Do not use str::find — multi-byte selected prefix.) + let close_bracket_x = (0..width) + .rev() + .find(|&x| buf.cell((x, 0)).map(|c| c.symbol()) == Some("]")) + .expect("closing ] on tagged row"); + assert_eq!( + close_bracket_x, + desc0_x - 1 - 1, + "tag right-aligned: ] should sit just left of the desc gap (row0={})", + row_text(0) + ); + + // A long tag at narrow widths must truncate without panicking (zero-width + // / non-char-boundary math), including the width < 4 early-return path. + let mut long_tagged = row("/x", "d"); + long_tagged.tag = Some("superlongtagname".to_string()); + let narrow = SlashSnapshot { + open: true, + matches: vec![long_tagged], + selected: 0, + ..Default::default() + }; + for w in 0..=12u16 { + let mut nb = Buffer::empty(Rect::new(0, 0, w.max(1), 1)); + let na = Rect::new(0, 0, w, 1); + let _ = render_dropdown(&mut nb, na, &narrow, None, &theme); + } + } } diff --git a/crates/codegen/xai-grok-pager/src/views/tutorial.rs b/crates/codegen/xai-grok-pager/src/views/tutorial.rs new file mode 100644 index 0000000..df8c931 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/views/tutorial.rs @@ -0,0 +1,693 @@ +//! Onboarding tutorial overlay (`/tutorial`). +//! +//! A top-level modal (works over both the welcome screen and an agent +//! session) with two screens: +//! +//! - **List** — the tutorial topics from [`crate::tutorial_docs`] with ✓ +//! marks for explored topics. Enter opens a topic; Esc closes. +//! - **Topic** — a scrollable markdown page (same chrome as the release-notes +//! viewer); `→`/`←` flow through the topics in order, Esc returns to the +//! list. +//! +//! Opened on demand via `/tutorial` (also listed in the command palette). +//! Never auto-shows. + +use std::collections::HashSet; + +use crossterm::event::{Event, KeyEventKind}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Paragraph, Widget}; + +use crate::theme::Theme; +use crate::tutorial_docs::TUTORIAL_TOPICS; +use crate::views::modal_window::{ + self as mw, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut, +}; +use crate::views::picker::{ + self, PickerConfig, PickerEntry, PickerHitAreas, PickerOutcome, PickerRow, PickerState, + handle_picker_input, +}; + +/// Which tutorial screen is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TutorialScreen { + /// The topic list. + List, + /// A single topic page (index into [`TUTORIAL_TOPICS`]). + Topic { index: usize }, + /// The full how-to guide a topic's "Go deeper" points at (`d` from the + /// topic page); Esc returns to that topic. + Guide { topic: usize }, +} + +/// State for the tutorial overlay. +pub struct TutorialState { + pub screen: TutorialScreen, + /// Topic indices the user has opened this launch (✓ marks). + pub viewed: HashSet<usize>, + /// List-screen navigation state. + pub picker: PickerState, + /// Shared modal chrome state (close button, shortcut hits). + pub window: ModalWindowState, + /// Topic-screen scroll offset. + pub scroll: u16, + /// Cached pre-rendered markdown lines for the topic screen, keyed by + /// the width they were rendered at (invalidated on resize). + pub cached_lines: Option<(u16, Vec<Line<'static>>)>, +} + +impl TutorialState { + pub fn new() -> Self { + Self { + screen: TutorialScreen::List, + viewed: HashSet::new(), + picker: PickerState::default(), + window: ModalWindowState::new(), + scroll: 0, + cached_lines: None, + } + } + + /// Switch to the topic page at `index`, marking it viewed. + fn open_topic(&mut self, index: usize) { + if index >= TUTORIAL_TOPICS.len() { + return; + } + self.viewed.insert(index); + self.screen = TutorialScreen::Topic { index }; + self.scroll = 0; + self.cached_lines = None; + self.window = ModalWindowState::new(); + } + + /// Return from a topic page to the list. + fn back_to_list(&mut self) { + self.screen = TutorialScreen::List; + self.scroll = 0; + self.cached_lines = None; + self.window = ModalWindowState::new(); + } + + /// Open the "Go deeper" guide for the topic at `index`, if it has one. + fn open_guide(&mut self, index: usize) { + let has_guide = TUTORIAL_TOPICS + .get(index) + .and_then(|t| t.go_deeper) + .and_then(crate::docs::find_doc) + .is_some(); + if has_guide { + self.screen = TutorialScreen::Guide { topic: index }; + self.scroll = 0; + self.cached_lines = None; + self.window = ModalWindowState::new(); + } + } +} + +impl Default for TutorialState { + fn default() -> Self { + Self::new() + } +} + +/// Outcome of routing an input event to the tutorial overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TutorialOutcome { + /// The overlay consumed the event (it owns all input while open). + Consumed, + /// The user closed the tutorial — the host should drop the state. + Closed, +} + +/// List-screen picker config. Search is disabled: six fixed topics don't +/// need filtering, and letter keys would otherwise start a query. +fn list_picker_config() -> PickerConfig<'static> { + PickerConfig { + title: None, + show_search_hint: false, + expandable: false, + esc_clears_query: false, + shortcuts: None, + pending_hint: None, + shortcuts_area: None, + non_selectable: &[], + non_selectable_clickable: &[], + tabs: None, + active_tab: 0, + filter_label: None, + filter_key_hint: None, + filter_active: false, + action_keys: &[], + disable_search: true, + compact_bottom_bar: false, + search_only_on_slash: false, + vim_normal_first: false, + header_note: None, + } +} + +/// Route an input event to the tutorial overlay. The overlay consumes all +/// key and mouse input while open (top-level modal semantics). +pub fn handle_tutorial_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome { + match st.screen { + TutorialScreen::Topic { .. } => handle_topic_input(ev, st), + TutorialScreen::Guide { .. } => handle_guide_input(ev, st), + TutorialScreen::List => handle_list_input(ev, st), + } +} + +/// Guide screen: scroll like a topic page; Esc (or the close button) +/// returns to the topic it came from. +fn handle_guide_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome { + let TutorialScreen::Guide { topic } = st.screen else { + return TutorialOutcome::Consumed; + }; + let chrome_cfg = ModalWindowConfig { + title: "", + tabs: None, + shortcuts: &[], + sizing: ModalSizing::default(), + fold_info: None, + }; + match ev { + Event::Key(key) => { + if key.kind == KeyEventKind::Release { + return TutorialOutcome::Consumed; + } + match mw::handle_modal_key(&mut st.window, key, &chrome_cfg) { + mw::ModalWindowOutcome::CloseRequested => { + st.open_topic(topic); + return TutorialOutcome::Consumed; + } + mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed, + _ => {} + } + crate::views::modal::apply_doc_scroll(key.code, &mut st.scroll); + TutorialOutcome::Consumed + } + Event::Mouse(mouse) => { + match mw::handle_modal_mouse(&mut st.window, mouse.kind, mouse.column, mouse.row) { + mw::ModalWindowOutcome::CloseRequested => { + st.open_topic(topic); + return TutorialOutcome::Consumed; + } + mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed, + _ => {} + } + crate::views::modal::apply_doc_mouse_scroll(mouse.kind, &mut st.scroll); + TutorialOutcome::Consumed + } + _ => TutorialOutcome::Consumed, + } +} + +fn handle_topic_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome { + let chrome_cfg = ModalWindowConfig { + title: "", + tabs: None, + shortcuts: &[], + sizing: ModalSizing::default(), + fold_info: None, + }; + match ev { + Event::Key(key) => { + if key.kind == KeyEventKind::Release { + return TutorialOutcome::Consumed; + } + match mw::handle_modal_key(&mut st.window, key, &chrome_cfg) { + mw::ModalWindowOutcome::CloseRequested => { + st.back_to_list(); + return TutorialOutcome::Consumed; + } + mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed, + _ => {} + } + // Linear flow: `→` reads on to the next topic (the list, once + // the tour is done); `←` steps back; `d` opens the "Go deeper" + // guide. + if let TutorialScreen::Topic { index } = st.screen { + match key.code { + crossterm::event::KeyCode::Right => { + if index + 1 < TUTORIAL_TOPICS.len() { + st.open_topic(index + 1); + } else { + st.back_to_list(); + } + return TutorialOutcome::Consumed; + } + crossterm::event::KeyCode::Left => { + if let Some(prev) = index.checked_sub(1) { + st.open_topic(prev); + } + return TutorialOutcome::Consumed; + } + crossterm::event::KeyCode::Char('d') => { + st.open_guide(index); + return TutorialOutcome::Consumed; + } + _ => {} + } + } + crate::views::modal::apply_doc_scroll(key.code, &mut st.scroll); + TutorialOutcome::Consumed + } + Event::Mouse(mouse) => { + match mw::handle_modal_mouse(&mut st.window, mouse.kind, mouse.column, mouse.row) { + mw::ModalWindowOutcome::CloseRequested => { + st.back_to_list(); + return TutorialOutcome::Consumed; + } + mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed, + _ => {} + } + crate::views::modal::apply_doc_mouse_scroll(mouse.kind, &mut st.scroll); + TutorialOutcome::Consumed + } + _ => TutorialOutcome::Consumed, + } +} + +fn handle_list_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome { + // Chrome first: close button clicks and Esc. `handle_picker_input` also + // maps Esc to `Closed`, but the close button lives on the ModalWindow. + if let Event::Mouse(mouse) = ev + && matches!( + mw::handle_modal_mouse(&mut st.window, mouse.kind, mouse.column, mouse.row), + mw::ModalWindowOutcome::CloseRequested + ) + { + return TutorialOutcome::Closed; + } + if let Event::Key(key) = ev + && key.kind == KeyEventKind::Release + { + return TutorialOutcome::Consumed; + } + // Search is disabled on the fixed topic list, but the picker's paste + // path fills the query regardless of `disable_search` — swallow paste + // here so it can't start an invisible filter. + if matches!(ev, Event::Paste(_)) { + return TutorialOutcome::Consumed; + } + + let config = list_picker_config(); + match handle_picker_input(ev, &mut st.picker, TUTORIAL_TOPICS.len(), &config) { + PickerOutcome::Selected(i) => { + st.open_topic(i); + TutorialOutcome::Consumed + } + PickerOutcome::Closed => TutorialOutcome::Closed, + _ => TutorialOutcome::Consumed, + } +} + +/// Intro copy shown above the topic list. No time promises — just what it +/// is and how to leave. +const INTRO_LINES: [&str; 2] = [ + "Quick tips to get the most out of Grok Build.", + "Pick a topic. Esc when you're done.", +]; + +/// Topic page body: the embedded markdown minus its leading `# ` heading — +/// the modal window chrome already shows the title, so rendering the H1 +/// would double it. +fn topic_body(content: &str) -> &str { + match content.split_once('\n') { + Some((first, rest)) if first.starts_with("# ") => rest.trim_start_matches('\n'), + _ => content, + } +} + +/// Render the tutorial overlay (list or topic screen) over `area`. +pub fn render_tutorial(buf: &mut Buffer, area: Rect, st: &mut TutorialState, compact: bool) { + let theme = Theme::current(); + match st.screen { + TutorialScreen::Topic { index } => { + // `Topic` is only constructed via `open_topic`, which bounds-checks. + let Some(topic) = TUTORIAL_TOPICS.get(index) else { + return; + }; + let next_hint = match TUTORIAL_TOPICS.get(index + 1) { + Some(next) => format!("\u{2192} next: {}", next.title), + None => "\u{2192} done".to_owned(), + }; + let mut shortcuts = vec![ + Shortcut { + label: "\u{2191}/\u{2193} scroll", + clickable: false, + id: 0, + }, + Shortcut { + label: &next_hint, + clickable: false, + id: 0, + }, + ]; + if topic.go_deeper.is_some() { + shortcuts.push(Shortcut { + label: "d go deeper", + clickable: false, + id: 0, + }); + } + shortcuts.push(Shortcut { + label: "Esc list", + clickable: false, + id: 0, + }); + crate::views::modal::render_doc_viewer_overlay_with_shortcuts( + buf, + area, + &mut st.window, + topic.title, + topic_body(topic.content), + &mut st.scroll, + &mut st.cached_lines, + compact, + &theme, + &shortcuts, + ); + } + TutorialScreen::Guide { topic } => { + let Some(doc) = TUTORIAL_TOPICS + .get(topic) + .and_then(|t| t.go_deeper) + .and_then(crate::docs::find_doc) + else { + return; + }; + crate::views::modal::render_doc_viewer_overlay( + buf, + area, + &mut st.window, + doc.title, + doc.content, + &mut st.scroll, + &mut st.cached_lines, + compact, + &theme, + ); + } + TutorialScreen::List => render_list(buf, area, st, compact, &theme), + } +} + +fn render_list(buf: &mut Buffer, area: Rect, st: &mut TutorialState, compact: bool, theme: &Theme) { + let progress = format!("{}/{} explored", st.viewed.len(), TUTORIAL_TOPICS.len()); + let shortcuts = [ + Shortcut { + label: &progress, + clickable: false, + id: 0, + }, + Shortcut { + label: "\u{2191}/\u{2193} navigate", + clickable: false, + id: 0, + }, + Shortcut { + label: "Enter open", + clickable: false, + id: 0, + }, + Shortcut { + label: "Esc done", + clickable: false, + id: 0, + }, + ]; + let modal_config = ModalWindowConfig { + title: "Welcome to Grok Build", + tabs: None, + shortcuts: &shortcuts, + sizing: ModalSizing { + width_pct: 0.60, + max_width: 100, + min_width: 44, + v_margin: 4, + h_pad: 2, + v_pad: 1, + footer_lines: 2, + } + .with_compact(compact), + fold_info: None, + }; + let Some(mca) = mw::render_modal_window(buf, area, &mut st.window, &modal_config, theme) else { + return; + }; + + // Intro copy, then a blank row, then the topic rows. + let intro_style = Style::default().fg(theme.gray_bright); + let mut y = mca.content.y; + for line in INTRO_LINES { + if y >= mca.content.y + mca.content.height { + break; + } + Paragraph::new(Line::styled(line, intro_style)).render( + Rect { + x: mca.content.x, + y, + width: mca.content.width, + height: 1, + }, + buf, + ); + y += 1; + } + y = y.saturating_add(1); // gap + + let entries_area = Rect { + x: mca.content.x, + y, + width: mca.content.width, + height: (mca.content.y + mca.content.height).saturating_sub(y), + }; + if entries_area.height == 0 { + return; + } + + // Narrow modals can't fit title + blurb on one row; stack the blurb below. + const NARROW_THRESHOLD: u16 = 64; + let narrow = entries_area.width < NARROW_THRESHOLD; + let blurb_slices: Vec<[&str; 1]> = TUTORIAL_TOPICS.iter().map(|t| [t.blurb]).collect(); + + let picker_entries: Vec<PickerEntry<'_>> = TUTORIAL_TOPICS + .iter() + .enumerate() + .map(|(i, t)| { + let viewed = st.viewed.contains(&i); + PickerEntry::Row(PickerRow { + label: t.title, + right_label: if narrow { "" } else { t.blurb }, + selected: i == st.picker.selected, + expanded: narrow, + fields: &[], + description_lines: if narrow { &blurb_slices[i][..] } else { &[] }, + summary_lines: &[], + dimmed: false, + indent: 0, + badge: if viewed { "\u{2713}" } else { "" }, + badge_color: Some(theme.accent_success), + collapsible: false, + underline_last_desc: false, + }) + }) + .collect(); + + let non_sel = vec![false; picker_entries.len()]; + let content_hit = picker::render_picker_content_with_scrollbar_x( + buf, + entries_area, + theme, + &mut st.picker, + &picker_entries, + &non_sel, + &[], + Some(theme.bg_base), + false, + 0, + mca.inner_x + mca.inner_width.saturating_sub(1), + ); + st.picker.hit_areas = Some(PickerHitAreas { + close_button: Rect::default(), + search_bar: Rect::default(), + item_rects: content_hit.item_rects, + entry_indices: content_hit.entry_indices, + tab_rects: vec![], + filter_rect: None, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(code: KeyCode) -> Event { + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn enter_opens_selected_topic_and_marks_viewed() { + let mut st = TutorialState::new(); + assert_eq!(st.screen, TutorialScreen::List); + assert!(st.viewed.is_empty()); + + let outcome = handle_tutorial_input(&key(KeyCode::Down), &mut st); + assert_eq!(outcome, TutorialOutcome::Consumed); + assert_eq!(st.picker.selected, 1); + + let outcome = handle_tutorial_input(&key(KeyCode::Enter), &mut st); + assert_eq!(outcome, TutorialOutcome::Consumed); + assert_eq!(st.screen, TutorialScreen::Topic { index: 1 }); + assert!(st.viewed.contains(&1)); + } + + #[test] + fn esc_pops_topic_to_list_then_closes() { + let mut st = TutorialState::new(); + handle_tutorial_input(&key(KeyCode::Enter), &mut st); + assert!(matches!(st.screen, TutorialScreen::Topic { .. })); + + let outcome = handle_tutorial_input(&key(KeyCode::Esc), &mut st); + assert_eq!(outcome, TutorialOutcome::Consumed); + assert_eq!(st.screen, TutorialScreen::List); + assert!(st.viewed.contains(&0), "opened topic stays ✓-marked"); + + let outcome = handle_tutorial_input(&key(KeyCode::Esc), &mut st); + assert_eq!(outcome, TutorialOutcome::Closed); + } + + #[test] + fn d_opens_the_go_deeper_guide_and_esc_returns_to_the_topic() { + let mut st = TutorialState::new(); + st.open_topic(0); + assert!( + TUTORIAL_TOPICS[0].go_deeper.is_some(), + "topic 0 has a guide" + ); + + handle_tutorial_input(&key(KeyCode::Char('d')), &mut st); + assert_eq!(st.screen, TutorialScreen::Guide { topic: 0 }); + + // Esc returns to the topic the guide came from, not the list. + handle_tutorial_input(&key(KeyCode::Esc), &mut st); + assert_eq!(st.screen, TutorialScreen::Topic { index: 0 }); + } + + #[test] + fn d_is_a_noop_on_a_topic_without_a_guide() { + let last = TUTORIAL_TOPICS.len() - 1; + assert!( + TUTORIAL_TOPICS[last].go_deeper.is_none(), + "the closing topic intentionally has no single guide" + ); + let mut st = TutorialState::new(); + st.open_topic(last); + handle_tutorial_input(&key(KeyCode::Char('d')), &mut st); + assert_eq!(st.screen, TutorialScreen::Topic { index: last }); + } + + #[test] + fn topic_body_strips_the_duplicated_h1() { + // The window title already names the topic; the H1 must not render + // a second time inside the page. + assert_eq!(topic_body("# Title\n\nBody text.\n"), "Body text.\n"); + // Every real topic starts with an H1, so every body drops it. + for t in TUTORIAL_TOPICS { + assert!(!topic_body(t.content).starts_with("# "), "{}", t.title); + } + // Content without a leading H1 passes through untouched. + assert_eq!(topic_body("plain text"), "plain text"); + } + + #[test] + fn right_flows_through_topics_and_back_to_list() { + let mut st = TutorialState::new(); + handle_tutorial_input(&key(KeyCode::Enter), &mut st); + assert_eq!(st.screen, TutorialScreen::Topic { index: 0 }); + + // → walks the whole tour, marking each topic viewed… + for expected in 1..TUTORIAL_TOPICS.len() { + handle_tutorial_input(&key(KeyCode::Right), &mut st); + assert_eq!(st.screen, TutorialScreen::Topic { index: expected }); + assert!(st.viewed.contains(&expected)); + } + // …and lands back on the list after the last page. + let outcome = handle_tutorial_input(&key(KeyCode::Right), &mut st); + assert_eq!(outcome, TutorialOutcome::Consumed); + assert_eq!(st.screen, TutorialScreen::List); + assert_eq!(st.viewed.len(), TUTORIAL_TOPICS.len(), "full tour ✓-marked"); + } + + #[test] + fn left_steps_back_and_stops_at_first_topic() { + let mut st = TutorialState::new(); + st.open_topic(1); + handle_tutorial_input(&key(KeyCode::Left), &mut st); + assert_eq!(st.screen, TutorialScreen::Topic { index: 0 }); + // At the first topic, ← is a no-op (Esc returns to the list). + handle_tutorial_input(&key(KeyCode::Left), &mut st); + assert_eq!(st.screen, TutorialScreen::Topic { index: 0 }); + } + + #[test] + fn topic_page_scrolls_and_ignores_typing() { + let mut st = TutorialState::new(); + handle_tutorial_input(&key(KeyCode::Enter), &mut st); + handle_tutorial_input(&key(KeyCode::Down), &mut st); + assert!(st.scroll > 0, "Down scrolls the topic page"); + handle_tutorial_input(&key(KeyCode::Up), &mut st); + assert_eq!(st.scroll, 0); + // Printable chars are consumed without effect (no search on topics). + let outcome = handle_tutorial_input(&key(KeyCode::Char('x')), &mut st); + assert_eq!(outcome, TutorialOutcome::Consumed); + assert!(matches!(st.screen, TutorialScreen::Topic { .. })); + } + + #[test] + fn list_typing_does_not_start_a_query() { + // Search is disabled: letters must not filter the fixed topic list. + let mut st = TutorialState::new(); + handle_tutorial_input(&key(KeyCode::Char('w')), &mut st); + assert!(st.picker.query().is_empty()); + assert_eq!(st.screen, TutorialScreen::List); + } + + #[test] + fn list_paste_does_not_start_a_query() { + // The picker's paste path ignores `disable_search`; the list screen + // must swallow paste so it can't start an invisible filter. + let mut st = TutorialState::new(); + let outcome = handle_tutorial_input(&Event::Paste("worktrees".to_owned()), &mut st); + assert_eq!(outcome, TutorialOutcome::Consumed); + assert!(st.picker.query().is_empty()); + assert_eq!(st.screen, TutorialScreen::List); + } + + #[test] + fn render_list_populates_hit_areas() { + let mut st = TutorialState::new(); + let area = Rect::new(0, 0, 100, 40); + let mut buf = Buffer::empty(area); + render_tutorial(&mut buf, area, &mut st, false); + let hit = st.picker.hit_areas.as_ref().expect("hit areas populated"); + assert_eq!( + hit.item_rects.len(), + TUTORIAL_TOPICS.len(), + "one click rect per topic" + ); + } + + #[test] + fn render_topic_screen_smoke() { + let mut st = TutorialState::new(); + st.open_topic(0); + let area = Rect::new(0, 0, 100, 30); + let mut buf = Buffer::empty(area); + render_tutorial(&mut buf, area, &mut st, false); + } +} diff --git a/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs b/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs index af5fbe0..16f2920 100644 --- a/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs @@ -626,10 +626,10 @@ pub struct WelcomeRenderParams<'a> { pub gate: Option<&'a xai_grok_shell::auth::GateInfo>, pub subscription_tier: Option<&'a str>, pub session_picker_grouped: bool, - /// Source filter (local/remote/all) for the session picker. + /// Source filter for the session picker. pub session_picker_source_filter: crate::views::session_picker::SourceFilter, /// Process-wide `--chat`: the picker lists backend conversations only, so - /// the Local/Remote source filter and local deep search are hidden. + /// the source filter and local deep search are hidden. pub chat_mode: bool, /// Live working directory (tracks `Effect::SetWorkingDir`), used to pin /// the current repo's session group to the top of the picker. @@ -1794,7 +1794,16 @@ fn render_welcome_done( if p.session_picker_loading { 1 } else { - (picker_count as u16).min(15) + 3 // +3 for title + search + gap + // Reserve a row for the pinned hidden-external hint when shown. + let hint_row = u16::from( + !p.chat_mode + && crate::views::session_picker::hidden_external_hint( + p.session_picker, + p.session_picker_source_filter, + ) + .is_some(), + ); + (picker_count as u16).min(15) + 3 + hint_row // +3 for title + search + gap } } else { 0 @@ -2061,11 +2070,10 @@ fn render_welcome_done( width: tip_centered.width.saturating_sub(inset * 2), height: tip_centered.height, }; - let (accept_r, customize_r, legal_r) = - render_privacy_banner(tip_inset, buf, theme, p.mouse_pos); - privacy_banner_accept_rect = Some(accept_r); - privacy_banner_customize_rect = Some(customize_r); - privacy_banner_legal_rect = Some(legal_r); + let rects = crate::views::privacy_banner::render(tip_inset, buf, theme, p.mouse_pos); + privacy_banner_accept_rect = Some(rects.accept); + privacy_banner_customize_rect = Some(rects.customize); + privacy_banner_legal_rect = Some(rects.legal); } else if let Some(ver) = p.pending_update_version && layout.tip.height > 0 { @@ -2210,147 +2218,6 @@ fn render_welcome_done( } } -/// Legal line copy — used for both render spans and mouse hit width. -const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy."; - -/// Welcome privacy banner: copy left, `[Customize in settings]` / `[Accept]` right. -/// Returns (accept_rect, customize_rect, legal_rect) for mouse hit-testing. -fn render_privacy_banner( - area: Rect, - buf: &mut Buffer, - theme: &Theme, - mouse_pos: Option<(u16, u16)>, -) -> (Rect, Rect, Rect) { - let customize_label = "[Customize in settings]"; - let accept_label = "[Accept]"; - let right_w = (customize_label.len() + 1 + accept_label.len()) as u16; - // Buttons render whole or not at all: a clipped/overflowing [Accept] - // must never leave a click target in the blank margin (a stray click - // there would silently opt the user in). - let buttons_fit = area.width > right_w; - let left_w = if buttons_fit { - area.width - right_w - 1 - } else { - area.width - }; - - let left = Rect { - x: area.x, - y: area.y, - width: left_w, - height: area.height.min(2), - }; - let right = Rect { - x: area.x + left_w + 1, - y: area.y, - width: right_w, - height: 1, - }; - - let hovered = |r: Rect| { - mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my))) - }; - - let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() { - PRIVACY_BANNER_LEGAL.len() - } else { - "Learn more".len().min(left.width as usize) - }; - // The legal line only exists when the slot really has a second row — - // otherwise its rect would make the blank row below clickable. - let legal_rect = if area.height >= 2 { - Rect { - x: left.x, - y: left.y.saturating_add(1), - width: legal_w as u16, - height: 1, - } - } else { - Rect::default() - }; - - // Figma node 8698:3806: title fg/primary, description fg/secondary, - // legal line fg/tertiary with underlined links in the same color. - // The whole legal line is one click target, so its links brighten together. - let link_fg = if hovered(legal_rect) { - theme.gray_bright - } else { - theme.gray - }; - let link = Style::default() - .fg(link_fg) - .add_modifier(Modifier::UNDERLINED); - let gray = Style::default().fg(theme.gray); - let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary)); - let desc = "Allow your sessions to improve SpaceXAI's models."; - // Drop trailing spans whole rather than clipping mid-word when narrow. - let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() { - Line::from(vec![ - title, - Span::raw(" "), - Span::styled(desc, Style::default().fg(theme.gray_bright)), - ]) - } else { - Line::from(title) - }; - // Span pieces must reassemble to PRIVACY_BANNER_LEGAL. - let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() { - Line::from(vec![ - Span::styled("Learn more", link), - Span::styled(" and read ", gray), - Span::styled("Terms", link), - Span::styled(" and ", gray), - Span::styled("Privacy Policy", link), - Span::styled(".", gray), - ]) - } else { - Line::from(Span::styled("Learn more", link)) - }; - Paragraph::new(vec![line1, line2]).render(left, buf); - - if !buttons_fit { - return (Rect::default(), Rect::default(), legal_rect); - } - let customize_rect = Rect { - x: right.x, - y: right.y, - width: customize_label.len() as u16, - height: 1, - }; - let accept_rect = Rect { - x: right.x + customize_label.len() as u16 + 1, - y: right.y, - width: accept_label.len() as u16, - height: 1, - }; - // Hover treatment mirrors the plugin CTA buttons. - let customize_style = if hovered(customize_rect) { - Style::default().fg(theme.text_primary).bg(theme.bg_hover) - } else { - Style::default().fg(theme.gray_bright) - }; - let accept_style = if hovered(accept_rect) { - Style::default().fg(theme.link_fg).bg(theme.bg_hover) - } else { - Style::default().fg(theme.text_primary) - }; - buf.set_stringn( - customize_rect.x, - customize_rect.y, - customize_label, - customize_rect.width as usize, - customize_style, - ); - buf.set_stringn( - accept_rect.x, - accept_rect.y, - accept_label, - accept_rect.width as usize, - accept_style, - ); - (accept_rect, customize_rect, legal_rect) -} - /// Context for session picker rendering. pub(crate) struct SessionPickerRenderCtx<'a> { pub(crate) state: &'a mut crate::views::picker::PickerState, @@ -2370,7 +2237,7 @@ pub(crate) struct SessionPickerRenderCtx<'a> { pub(crate) tick: u64, /// When true, entries are grouped by `repo_name` with non-selectable headers. pub(crate) grouped: bool, - /// Source filter (local/remote/all) for filtering session entries. + /// Source filter for filtering session entries. pub(crate) source_filter: crate::views::session_picker::SourceFilter, /// Process-wide `--chat`: hides the source-filter chip and the /// deep-search/filter footer hints (see `WelcomeRenderParams::chat_mode`). @@ -2538,6 +2405,12 @@ pub(crate) fn render_session_picker( })); } + let hidden_hint = if ctx.chat_mode { + None + } else { + crate::views::session_picker::hidden_external_hint(ctx.sessions, ctx.source_filter) + }; + // Build shortcuts for fullscreen mode. Chat mode drops the worktree / // deep-search / filter hints (local-Build-row actions). let worktree_shortcut: &'static str = "ctrl+w"; @@ -2587,6 +2460,7 @@ pub(crate) fn render_session_picker( filter_label: (!ctx.chat_mode).then(|| ctx.source_filter.label()), filter_key_hint: (!ctx.chat_mode).then_some("f"), filter_active: !ctx.chat_mode && ctx.source_filter.is_active(), + header_note: hidden_hint.as_deref(), action_keys: &[], disable_search: false, compact_bottom_bar: false, @@ -2602,6 +2476,7 @@ pub(crate) fn render_session_picker( &picker_entries, &config, ctx.loading, + ctx.tick, ) } @@ -2900,7 +2775,7 @@ mod tests { gate: None, subscription_tier: None, session_picker_grouped: false, - session_picker_source_filter: crate::views::session_picker::SourceFilter::All, + session_picker_source_filter: crate::views::session_picker::SourceFilter::default(), chat_mode: false, cwd: std::path::Path::new("/repo"), credit_balance: None, @@ -3075,7 +2950,7 @@ mod tests { entries_query, tick: 0, grouped: false, - source_filter: crate::views::session_picker::SourceFilter::All, + source_filter: crate::views::session_picker::SourceFilter::default(), chat_mode: true, }, ); @@ -3111,6 +2986,83 @@ mod tests { ); } + /// The hidden-external hint stays pinned on the welcome picker's default + /// Grok view when scanned foreign rows exist — even when the native list + /// overflows the viewport — and never renders under `--chat` (foreign + /// scanning is disabled there, so the hint is dead weight). + #[test] + fn hidden_external_hint_renders_outside_chat_mode() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let theme = crate::theme::Theme::default(); + let area = Rect::new(0, 0, 80, 20); + // More native rows than the viewport fits: a trailing list row would + // scroll out of view, a pinned row must not. + let mut entries: Vec<SessionPickerEntry> = (0..30) + .map(|i| make_entry(&format!("s{i}"), &format!("native session {i}"), "repo")) + .collect(); + let mut foreign = make_entry("f1", "Claude work", "repo"); + foreign.source = "claude".into(); + entries.push(foreign); + + let render = |chat_mode: bool| -> String { + let mut buf = Buffer::empty(area); + let mut state = PickerState::default(); + render_session_picker( + area, + &mut buf, + &theme, + &mut SessionPickerRenderCtx { + state: &mut state, + sessions: Some(&entries), + cwd: std::path::Path::new("/repo"), + loading: false, + pending_hint: None, + shortcuts_area: None, + content_results: None, + content_loading: false, + entries_query: None, + tick: 0, + grouped: false, + source_filter: crate::views::session_picker::SourceFilter::default(), + chat_mode, + }, + ); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + buf.cell((x, y)) + .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' ')) + }) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + }; + + let build_mode = render(false); + assert!( + build_mode.contains("1 external session hidden \u{b7} f to show"), + "default Grok filter must pin the hidden-external hint:\n{build_mode}" + ); + assert!( + build_mode.find("external session hidden") < build_mode.find("native session 0"), + "the hint must be pinned above the first list row:\n{build_mode}" + ); + assert!( + !build_mode.contains("Claude work"), + "the foreign row itself stays hidden under the default filter:\n{build_mode}" + ); + + let chat = render(true); + assert!( + !chat.contains("external session"), + "chat mode must not render the hidden-external hint:\n{chat}" + ); + } + #[test] fn grouped_entries_insert_headers() { let entries = vec![ @@ -3253,6 +3205,7 @@ mod tests { filter_label: None, filter_key_hint: None, filter_active: false, + header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, diff --git a/crates/codegen/xai-grok-pager/tests/doctor_early_dispatch.rs b/crates/codegen/xai-grok-pager/tests/doctor_early_dispatch.rs index 47c4d9b..4c0468d 100644 --- a/crates/codegen/xai-grok-pager/tests/doctor_early_dispatch.rs +++ b/crates/codegen/xai-grok-pager/tests/doctor_early_dispatch.rs @@ -69,6 +69,281 @@ fn doctor_json_bypasses_unrelated_startup_state() { } } +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_fix_without_id_lists_tmux_fixes_from_current_probe_evidence() { + let binary = pager_binary().expect("real pager binary is required when selected"); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let grok_home = temp.path().join("qhome"); + let fake_bin = temp.path().join("bin"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + std::fs::create_dir_all(&fake_bin).unwrap(); + let tmux = fake_bin.join("tmux"); + std::fs::write( + &tmux, + "#!/bin/sh\ncase \"$*\" in\n *\"show-option -gv allow-passthrough\"*) exit 0;;\n *\"show-option -gqv extended-keys\"*) printf off;;\n *\"show-option -gqv allow-passthrough\"*) printf off;;\n *\"show-option -gqv set-clipboard\"*) printf off;;\n *\"display-message\"*) printf x;;\n *) exit 1;;\nesac\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let output = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &["doctor", "fix"], + &[ + ("TMUX", "/tmp/tmux/default,1,0"), + ("PATH", fake_bin.to_str().unwrap()), + ], + ); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + for handle in ["tmux-clipboard", "dcs-passthrough"] { + assert!(stdout.contains(handle), "{stdout}"); + } + assert!(!stdout.contains("Set up local SSH wrapping"), "{stdout}"); +} + +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_tmux_fix_probes_are_bounded_and_never_write_on_timeout() { + let binary = pager_binary().expect("real pager binary is required when selected"); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let grok_home = temp.path().join("qhome"); + let fake_bin = temp.path().join("bin"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + std::fs::create_dir_all(&fake_bin).unwrap(); + let tmux = fake_bin.join("tmux"); + std::fs::write(&tmux, "#!/bin/sh\nsleep 30\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for args in [ + ["doctor", "fix", "", ""], + ["doctor", "fix", "tmux-clipboard", "--yes"], + ] { + let actual = args + .iter() + .copied() + .filter(|value| !value.is_empty()) + .collect::<Vec<_>>(); + let started = std::time::Instant::now(); + let output = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &actual, + &[ + ("TMUX", "/tmp/tmux/default,1,0"), + ("PATH", fake_bin.to_str().unwrap()), + ], + ); + assert!(started.elapsed() < std::time::Duration::from_secs(12)); + if actual.len() == 2 { + assert!(output.status.success()); + assert_eq!(output.stdout, b"No automatic fixes are available here.\n"); + } else { + assert_eq!(output.status.code(), Some(1)); + } + assert!(!home.join(".tmux.conf").exists()); + } +} + +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_tmux_fix_kills_background_pipe_holders_after_leader_exit() { + let binary = pager_binary().expect("real pager binary is required when selected"); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let grok_home = temp.path().join("qhome"); + let fake_bin = temp.path().join("bin"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + std::fs::create_dir_all(&fake_bin).unwrap(); + let tmux = fake_bin.join("tmux"); + std::fs::write(&tmux, "#!/bin/sh\nsleep 30 &\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for args in [ + vec!["doctor", "fix"], + vec!["doctor", "fix", "tmux-clipboard", "--yes"], + ] { + let started = std::time::Instant::now(); + let output = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &args, + &[ + ("TMUX", "/tmp/tmux/default,1,0"), + ("PATH", fake_bin.to_str().unwrap()), + ], + ); + assert!(started.elapsed() < std::time::Duration::from_secs(12)); + if args.len() == 2 { + assert!(output.status.success()); + } else { + assert_eq!(output.status.code(), Some(1)); + } + assert!(!home.join(".tmux.conf").exists()); + } +} + +#[cfg(unix)] +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_tmux_fix_kills_term_ignoring_redirected_descendants() { + use std::os::unix::fs::PermissionsExt as _; + + let binary = pager_binary().expect("real pager binary is required when selected"); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let grok_home = temp.path().join("qhome"); + let fake_bin = temp.path().join("bin"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + std::fs::create_dir_all(&fake_bin).unwrap(); + let tmux = fake_bin.join("tmux"); + let pid_file = temp.path().join("descendant.pid"); + std::fs::write( + &tmux, + format!( + "#!/bin/sh\n( trap '' TERM; echo $$ > '{}'; exec sleep 30 ) >/dev/null 2>&1 &\nexit 0\n", + pid_file.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap(); + for args in [ + vec!["doctor", "fix"], + vec!["doctor", "fix", "tmux-clipboard", "--yes"], + ] { + let _ = std::fs::remove_file(&pid_file); + let output = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &args, + &[ + ("TMUX", "/tmp/tmux/default,1,0"), + ("PATH", fake_bin.to_str().unwrap()), + ], + ); + assert!(output.status.success() || output.status.code() == Some(1)); + let pid: i32 = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + // SAFETY: kill(pid, 0) only probes liveness for the positive child PID. + if unsafe { libc::kill(pid, 0) } != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + // SAFETY: same liveness probe; ESRCH is the expected result. + assert_ne!( + unsafe { libc::kill(pid, 0) }, + 0, + "descendant {pid} survived" + ); + assert!(!home.join(".tmux.conf").exists()); + } +} + +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_irrelevant_unsafe_byobu_does_not_break_ssh_or_plain_tmux() { + let binary = pager_binary().expect("real pager binary is required when selected"); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let grok_home = temp.path().join("qhome"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + + let ssh = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &["doctor", "fix", "ssh-wrap", "--yes"], + &[("BYOBU_CONFIG_DIR", "relative")], + ); + assert!( + ssh.status.success(), + "{}", + String::from_utf8_lossy(&ssh.stderr) + ); + assert!(home.join(".bashrc").exists()); + + let plain = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &["doctor", "fix"], + &[ + ("BYOBU_CONFIG_DIR", "relative"), + ("TMUX", "/tmp/tmux/default,1,0"), + ], + ); + assert!( + plain.status.success(), + "{}", + String::from_utf8_lossy(&plain.stderr) + ); +} + +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_hostile_home_and_byobu_create_no_config_files() { + let binary = pager_binary() + .expect("real pager binary is required when selected") + .canonicalize() + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let grok_home = temp.path().join("qhome"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + for (key, value) in [("HOME", "."), ("BYOBU_CONFIG_DIR", "relative")] { + let mut command = base_pager_command(&binary, &home, &grok_home, "/bin/bash"); + command + .current_dir(temp.path()) + .env(key, value) + .env("TMUX", "/tmp/tmux/default,1,0") + .env("BYOBU_BACKEND", "tmux") + .args(["doctor", "fix", "tmux-clipboard", "--yes"]); + let output = command.output().unwrap(); + assert_eq!( + output.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!temp.path().join(".tmux.conf").exists()); + assert!(!temp.path().join("relative/.tmux.conf").exists()); + } +} + #[test] #[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] fn doctor_fix_without_id_lists_only_applicable_automatic_fixes() { @@ -119,6 +394,66 @@ fn doctor_fix_without_id_lists_only_applicable_automatic_fixes() { ); } +#[test] +#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] +fn doctor_tmux_fix_yes_writes_only_actual_home_tmux_config() { + let binary = pager_binary().expect("real pager binary is required when this test is selected"); + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path().join("home"); + let grok_home = temp.path().join("grok-home"); + let fake_bin = temp.path().join("bin"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&grok_home).unwrap(); + std::fs::create_dir_all(&fake_bin).unwrap(); + let tmux = fake_bin.join("tmux"); + std::fs::write( + &tmux, + "#!/bin/sh\ncase \"$*\" in\n *\"show-option -gv allow-passthrough\"*) exit 0;;\n *\"show-option -gqv allow-passthrough\"*) printf off;;\n *\"show-option -gqv set-clipboard\"*) printf off;;\n *\"display-message\"*) printf x;;\n *) exit 1;;\nesac\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let output = run_pager( + &binary, + &home, + &grok_home, + "/bin/bash", + &["doctor", "fix", "tmux-clipboard", "--yes"], + &[ + ("TMUX", "/tmp/tmux/default,1,0"), + ("PATH", fake_bin.to_str().unwrap()), + ], + ); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("Added `set -g set-clipboard on`"), + "{stdout}" + ); + assert!( + stdout.contains("Reload tmux with `tmux source-file"), + "{stdout}" + ); + assert!( + stdout.contains("Run /doctor again to verify the live setting"), + "{stdout}" + ); + assert_eq!( + std::fs::read_to_string(home.join(".tmux.conf")).unwrap(), + "# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard on\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<" + ); + assert!(!grok_home.join(".tmux.conf").exists()); +} + #[test] #[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"] fn doctor_fix_yes_writes_only_actual_home_shell_rc() { diff --git a/crates/codegen/xai-grok-pager/tests/settings_e2e.rs b/crates/codegen/xai-grok-pager/tests/settings_e2e.rs index b8c16f9..53f0424 100644 --- a/crates/codegen/xai-grok-pager/tests/settings_e2e.rs +++ b/crates/codegen/xai-grok-pager/tests/settings_e2e.rs @@ -64,6 +64,7 @@ const ALL_SETTINGS_EXERCISED: &[&str] = &[ "collapsed_edit_blocks", "respect_manual_folds", "hunk_tracker_mode", + "voice_keybind_enabled", "voice_capture_mode", "voice_stt_language", // Contextual-hints group + its per-tip child toggles (exercised via the @@ -235,6 +236,12 @@ fn assert_set_bool_action(outcome: SettingsKeyOutcome, key: &str, expected: bool "SetRememberToolApprovals value differs from expected" ) } + ("voice_keybind_enabled", Action::SetVoiceKeybindEnabled(b)) => { + assert_eq!( + b, expected, + "SetVoiceKeybindEnabled value differs from expected" + ) + } ( "toolset.ask_user_question.timeout_enabled", Action::SetAskUserQuestionTimeoutEnabled(b), @@ -1797,6 +1804,7 @@ fn registry_kind_membership_through_pr_14() { "toolset.ask_user_question.timeout_enabled", "auto_update", "show_tips", + "voice_keybind_enabled", // Per-tip contextual-hint children (hidden from the top-level list, // toggled inside the group sub-sheet) are still Bool settings. "contextual_hints.undo", @@ -1961,6 +1969,7 @@ fn defaults_round_trip_through_registry() { "coding_data_sharing" => SettingValue::Enum("opt-out"), "default_selected_permission" => SettingValue::Enum("always_allow_all_sessions"), "hunk_tracker_mode" => SettingValue::Enum("agent_only"), + "voice_keybind_enabled" => SettingValue::Bool(true), "voice_capture_mode" => SettingValue::Enum("hold"), "voice_stt_language" => SettingValue::Enum("en"), "plan_mode" => SettingValue::Enum("off"), @@ -2052,7 +2061,8 @@ fn settings_value_payload_matches_kind() { | SettingsKeyOutcome::Action(Action::SetGroupToolVerbs(_)) | SettingsKeyOutcome::Action(Action::SetCollapsedEditBlocks(_)) | SettingsKeyOutcome::Action(Action::SetInvertScroll(_)) - | SettingsKeyOutcome::Action(Action::SetDisplayRefreshAutoCadence(_)) => {} + | SettingsKeyOutcome::Action(Action::SetDisplayRefreshAutoCadence(_)) + | SettingsKeyOutcome::Action(Action::SetVoiceKeybindEnabled(_)) => {} other => panic!( "expected a typed bool setter for `{}`, got {:?}", meta.key, other @@ -6277,6 +6287,31 @@ fn voice_stt_language_picker_enter_dispatches_set_commit() { ); } +/// Space-toggle on `voice_keybind_enabled` dispatches the typed setter. +/// Default is ON (the chord works out of the box), so toggling flips it off. +#[test] +fn space_on_voice_keybind_enabled_dispatches_typed_setter() { + let mut s = make_state(); + navigate_to(&mut s, "voice_keybind_enabled"); + let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' '))); + assert_set_bool_action(outcome, "voice_keybind_enabled", false); +} + +/// Value-column click toggles `voice_keybind_enabled` in one click. +#[test] +fn mouse_click_on_voice_keybind_enabled_indicator_toggles_in_one_click() { + let mut s = make_state(); + synth_rects(&mut s); + let row_y = row_idx_for(&s, "voice_keybind_enabled") as u16; + let outcome = handle_settings_mouse( + &mut s, + MouseEventKind::Down(crossterm::event::MouseButton::Left), + 72, + row_y, + ); + assert_set_bool_action(outcome, "voice_keybind_enabled", false); +} + /// Value-column click on the voice_stt_language row opens the picker in ONE /// click (mouse ↔ keyboard parity). #[test] diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/git.rs b/crates/codegen/xai-grok-plugin-marketplace/src/git.rs index 39ecdd0..663d2ba 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/git.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/git.rs @@ -266,6 +266,17 @@ fn clone_cli_command(url: &str, branch: Option<&str>, dest: &Path) -> std::proce cmd } +/// Probe whether `url` is a reachable git repository via a timed +/// `git ls-remote`, without touching any cache. Used to reject non-git URLs +/// (e.g. MCP endpoints) at add time instead of persisting a source that +/// fails on every scan. +pub fn probe_git_remote(url: &str) -> Result<(), String> { + let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?; + let mut cmd = git_command(); + cmd.args(["ls-remote", "--", url, "HEAD"]); + run_git_timed(&mut cmd, "ls-remote", NETWORK_OP_TIMEOUT) +} + fn fetch_cli_command(repo_dir: &Path, branch: Option<&str>) -> std::process::Command { let mut cmd = git_command(); cmd.current_dir(repo_dir).args([ @@ -513,6 +524,30 @@ mod tests { ); } + #[test] + fn probe_git_remote_accepts_git_repo() { + if !git_available() { + eprintln!("skipping git-dependent test: git binary not available"); + return; + } + let remote = tempfile::tempdir().unwrap(); + init_remote_repo(remote.path()); + let url = remote.path().to_string_lossy().to_string(); + probe_git_remote(&url).unwrap(); + } + + #[test] + fn probe_git_remote_rejects_non_repo() { + if !git_available() { + eprintln!("skipping git-dependent test: git binary not available"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let url = dir.path().to_string_lossy().to_string(); + let err = probe_git_remote(&url).unwrap_err(); + assert!(err.contains("ls-remote failed"), "{err}"); + } + #[test] fn run_git_timed_kills_hung_process() { let mut cmd = Command::new("sleep"); diff --git a/crates/codegen/xai-grok-sampler/src/actor/state.rs b/crates/codegen/xai-grok-sampler/src/actor/state.rs index 10e49f7..f083d71 100644 --- a/crates/codegen/xai-grok-sampler/src/actor/state.rs +++ b/crates/codegen/xai-grok-sampler/src/actor/state.rs @@ -90,6 +90,8 @@ mod tests { api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: 8192, force_http1: false, max_retries: None, diff --git a/crates/codegen/xai-grok-sampler/src/client.rs b/crates/codegen/xai-grok-sampler/src/client.rs index 95ebc27..ffb5253 100644 --- a/crates/codegen/xai-grok-sampler/src/client.rs +++ b/crates/codegen/xai-grok-sampler/src/client.rs @@ -15,6 +15,7 @@ use eventsource_stream::Eventsource; use futures_util::StreamExt; use futures_util::stream::BoxStream; +use indexmap::IndexMap; use reqwest::header::{ ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT, }; @@ -272,9 +273,38 @@ struct StreamOptions { include_usage: bool, } +/// Resolve `env_http_headers` (`header -> env var`) into `headers` via `getenv`, skipping unset/blank/invalid entries and trimming values. +fn apply_env_http_headers( + env_http_headers: &IndexMap<String, String>, + getenv: impl Fn(&str) -> Option<String>, + headers: &mut HeaderMap, +) { + for (key, env_var) in env_http_headers { + let Some(value) = getenv(env_var) else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + let (Ok(name), Ok(header_value)) = ( + HeaderName::try_from(key.as_str()), + HeaderValue::from_str(value), + ) else { + tracing::warn!( + header = %key, + env_var = %env_var, + "skipping env_http_header with an invalid header name or value" + ); + continue; + }; + headers.insert(name, header_value); + } +} + /// HTTP client for sampling. Cheap to clone; carries an `Arc`-backed -/// `reqwest::Client` and the default headers/request-defaults computed -/// from a [`SamplerConfig`] at construction time. +/// `reqwest::Client` and the default headers/request-defaults computed from a +/// [`SamplerConfig`] at construction time. #[derive(Clone)] pub struct SamplingClient { http: reqwest::Client, @@ -290,6 +320,8 @@ pub struct SamplingClient { bearer_resolver: Option<crate::config::SharedBearerResolver>, /// Per-request header injection (OTel traceparent). header_injector: Option<crate::config::SharedHeaderInjector>, + /// Endpoint URL builder, resolved once from `base_url` + `query_params`. + endpoint: EndpointTemplate, } impl std::fmt::Debug for SamplingClient { @@ -318,6 +350,74 @@ struct ClientDefaults { doom_loop_recovery: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>, } +/// Endpoint URL builder, resolved once at client construction so each request +/// only appends its path. +#[derive(Clone, Debug)] +enum EndpointTemplate { + /// No query params and no query on the base URL (or an unparseable base): + /// append the path to the base verbatim. + Plain(String), + /// Query params configured: `{prefix}/{path}{suffix}`. `suffix` starts with + /// `?` and folds any base-URL params, with a configured key winning over the + /// same key in `base_url` (percent-encoded, no duplicates). + WithQuery { prefix: String, suffix: String }, +} + +impl EndpointTemplate { + fn new(base_url: &str, query_params: &IndexMap<String, String>) -> Self { + let base = base_url.trim_end_matches('/').to_string(); + // The fast path is safe only when there is nothing to fold: no configured + // params and no query already on the base (which would otherwise land + // before the appended path). + if query_params.is_empty() && !base.contains('?') { + return Self::Plain(base); + } + let mut url = match reqwest::Url::parse(&base) { + Ok(url) => url, + Err(error) => { + tracing::warn!( + url = %base, + %error, + "failed to parse base URL for endpoint; sending without folded query" + ); + return Self::Plain(base); + } + }; + let overridden: std::collections::HashSet<&str> = + query_params.keys().map(String::as_str).collect(); + let kept: Vec<(String, String)> = url + .query_pairs() + .filter(|(k, _)| !overridden.contains(k.as_ref())) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + let prefix = { + let mut prefix_url = url.clone(); + prefix_url.set_query(None); + prefix_url.as_str().trim_end_matches('/').to_string() + }; + { + let mut pairs = url.query_pairs_mut(); + pairs.clear(); + for (key, value) in &kept { + pairs.append_pair(key, value); + } + for (key, value) in query_params { + pairs.append_pair(key, value); + } + } + let suffix = url.query().map(|q| format!("?{q}")).unwrap_or_default(); + Self::WithQuery { prefix, suffix } + } + + fn url_for_path(&self, path: &str) -> String { + let path = path.trim_start_matches('/'); + match self { + Self::Plain(base) => format!("{base}/{path}"), + Self::WithQuery { prefix, suffix } => format!("{prefix}/{path}{suffix}"), + } + } +} + // ============================================================================= // User-Agent helpers // ============================================================================= @@ -444,6 +544,14 @@ impl SamplingClient { headers.insert(header_name, header_value); } + // Resolve here, not into `extra_headers`, so an env-sourced secret stays + // out of persisted state. + apply_env_http_headers( + &config.env_http_headers, + |var| std::env::var(var).ok(), + &mut headers, + ); + // Add x-grok-client-version header for version gating at the proxy. if let Some(client_version) = config.client_version.as_ref() && let Ok(header_value) = HeaderValue::from_str(client_version) @@ -530,6 +638,8 @@ impl SamplingClient { doom_loop_recovery: config.doom_loop_recovery, }; + let endpoint = EndpointTemplate::new(&config.base_url, &config.query_params); + Ok(Self { http, default_headers: headers, @@ -538,6 +648,7 @@ impl SamplingClient { attribution_callback: config.attribution_callback, bearer_resolver: config.bearer_resolver, header_injector: config.header_injector, + endpoint, }) } @@ -701,9 +812,7 @@ impl SamplingClient { } fn endpoint(&self, path: &str) -> String { - let base = self.base_url.trim_end_matches('/'); - let path = path.trim_start_matches('/'); - format!("{base}/{path}") + self.endpoint.url_for_path(path) } fn apply_defaults(&self, mut request: ChatCompletionRequest) -> Result<ChatCompletionRequest> { @@ -1907,6 +2016,8 @@ mod tests { api_backend: ApiBackend::ChatCompletions, auth_scheme: AuthScheme::Bearer, extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: 8192, force_http1: false, max_retries: None, @@ -2079,6 +2190,56 @@ mod tests { let _client = SamplingClient::new(cfg).expect("client with extra headers should construct"); } + #[test] + fn apply_env_http_headers_resolves_trims_skips_and_overrides() { + let mut map = IndexMap::new(); + map.insert("x-tenant-token".to_string(), "TENANT".to_string()); + map.insert("x-blank".to_string(), "BLANK".to_string()); + map.insert("x-missing".to_string(), "MISSING".to_string()); + map.insert("x-override".to_string(), "OVERRIDE".to_string()); + map.insert("x invalid".to_string(), "INVALID".to_string()); + + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-override"), + HeaderValue::from_static("static"), + ); + + apply_env_http_headers( + &map, + |var| match var { + // Leading space + trailing newline exercises trimming. + "TENANT" => Some(" tenant-secret\n".to_string()), + "BLANK" => Some(" ".to_string()), + "OVERRIDE" => Some("from-env".to_string()), + "INVALID" => Some("value".to_string()), + _ => None, + }, + &mut headers, + ); + + assert_eq!(headers.get("x-tenant-token").unwrap(), "tenant-secret"); + assert!(headers.get("x-blank").is_none()); + assert!(headers.get("x-missing").is_none()); + // A resolved env value overrides an existing header of the same name. + assert_eq!(headers.get("x-override").unwrap(), "from-env"); + // An invalid header name is skipped rather than panicking. + assert!(headers.get("x invalid").is_none()); + } + + #[test] + fn endpoint_appends_path_before_a_base_url_query_without_configured_params() { + let template = + EndpointTemplate::new("https://gateway.example/v1?api-version=x", &IndexMap::new()); + let url = template.url_for_path("responses"); + assert!( + url.starts_with("https://gateway.example/v1/responses?"), + "url: {url}" + ); + assert!(url.contains("api-version=x"), "url: {url}"); + assert!(!url.contains("x/responses"), "url: {url}"); + } + #[test] fn messages_plus_anthropic_api_key_uses_x_api_key_and_not_authorization() { let cfg = SamplerConfig { diff --git a/crates/codegen/xai-grok-sampler/src/config.rs b/crates/codegen/xai-grok-sampler/src/config.rs index f99f952..8f8bd8d 100644 --- a/crates/codegen/xai-grok-sampler/src/config.rs +++ b/crates/codegen/xai-grok-sampler/src/config.rs @@ -60,6 +60,13 @@ pub struct SamplerConfig { /// the URL to derive headers; callers (the session) inject proxy auth /// and other access headers here before constructing the config. pub extra_headers: IndexMap<String, String>, + /// Query parameters folded into every request URL (percent-encoded). + #[serde(default)] + pub query_params: IndexMap<String, String>, + /// Header name to environment variable, resolved into request headers at + /// client build and never persisted. + #[serde(default)] + pub env_http_headers: IndexMap<String, String>, /// Total context window size in tokens. The sampler does not enforce /// it; it is informational metadata used by the session for compaction /// decisions. @@ -140,6 +147,8 @@ impl Default for SamplerConfig { api_backend: ApiBackend::default(), auth_scheme: AuthScheme::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: 0, force_http1: false, max_retries: None, diff --git a/crates/codegen/xai-grok-sampler/tests/request_query_and_headers.rs b/crates/codegen/xai-grok-sampler/tests/request_query_and_headers.rs new file mode 100644 index 0000000..aa9ce07 --- /dev/null +++ b/crates/codegen/xai-grok-sampler/tests/request_query_and_headers.rs @@ -0,0 +1,64 @@ +//! Checks that provider `query_params` and `env_http_headers` reach the +//! outgoing request. + +mod support; + +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::http::{HeaderMap, Uri}; +use axum::routing::post; +use tokio::net::TcpListener; +use xai_grok_sampler::SamplingClient; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_carries_query_params_and_env_http_headers() { + // A unique name avoids clashing with other tests that read the process + // environment; the surrounding whitespace exercises value trimming. + let env_var = "XAI_SAMPLER_TEST_TENANT_TOKEN"; + unsafe { std::env::set_var(env_var, " tenant-secret\n") }; + + let captured: Arc<Mutex<Option<(String, HeaderMap)>>> = Arc::new(Mutex::new(None)); + let sink = Arc::clone(&captured); + let app = Router::new().route( + "/v1/chat/completions", + post(move |uri: Uri, headers: HeaderMap| { + let sink = Arc::clone(&sink); + async move { + *sink.lock().unwrap() = + Some((uri.query().unwrap_or_default().to_string(), headers)); + "{}" + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + // The base URL already carries `api-version`; a configured value must + // replace it (not duplicate), keep unrelated keys, and percent-encode. + let base_url = format!("http://{addr}/v1?api-version=old&keep=1"); + let mut cfg = support::test_config(&base_url, "test-key"); + cfg.query_params + .insert("api-version".into(), "2026-07-22".into()); + cfg.query_params.insert("tenant".into(), "a b".into()); + cfg.env_http_headers + .insert("x-tenant-token".into(), env_var.into()); + + let client = SamplingClient::new(cfg).expect("client builds"); + support::send_one(&client).await; + unsafe { std::env::remove_var(env_var) }; + + let (query, headers) = captured.lock().unwrap().take().expect("request captured"); + assert_eq!(query.matches("api-version=").count(), 1, "query: {query}"); + assert!(query.contains("api-version=2026-07-22"), "query: {query}"); + assert!(!query.contains("api-version=old"), "query: {query}"); + assert!(query.contains("keep=1"), "query: {query}"); + assert!( + query.contains("tenant=a%20b") || query.contains("tenant=a+b"), + "query: {query}" + ); + assert_eq!(headers.get("x-tenant-token").unwrap(), "tenant-secret"); +} diff --git a/crates/codegen/xai-grok-sampler/tests/test_actor.rs b/crates/codegen/xai-grok-sampler/tests/test_actor.rs index f037d75..20ac5db 100644 --- a/crates/codegen/xai-grok-sampler/tests/test_actor.rs +++ b/crates/codegen/xai-grok-sampler/tests/test_actor.rs @@ -79,6 +79,8 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig { api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: 128_000, force_http1: false, // Keep retries minimal so tests don't take forever. diff --git a/crates/codegen/xai-grok-sampling-types/src/types.rs b/crates/codegen/xai-grok-sampling-types/src/types.rs index 6a93804..ade02eb 100644 --- a/crates/codegen/xai-grok-sampling-types/src/types.rs +++ b/crates/codegen/xai-grok-sampling-types/src/types.rs @@ -1043,6 +1043,13 @@ pub struct SamplingConfig { /// Extra headers to send with requests (e.g., for BYOK scenarios). #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")] pub extra_headers: indexmap::IndexMap<String, String>, + /// Query parameters folded into every request URL (percent-encoded). + #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")] + pub query_params: indexmap::IndexMap<String, String>, + /// Header name to environment variable; only the mapping persists, not the + /// resolved secret. + #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")] + pub env_http_headers: indexmap::IndexMap<String, String>, /// Total context window size in tokens. Used for auto-compact thresholds. pub context_window: NonZeroU64, /// Reasoning effort level for reasoning models. diff --git a/crates/codegen/xai-grok-sandbox/src/child_net.rs b/crates/codegen/xai-grok-sandbox/src/child_net.rs index 2162229..8569091 100644 --- a/crates/codegen/xai-grok-sandbox/src/child_net.rs +++ b/crates/codegen/xai-grok-sandbox/src/child_net.rs @@ -1,10 +1,145 @@ -//! Per-child seccomp network filter. No-op on non-Linux. +//! Seccomp: child network filter (pre_exec) and process-wide namespace lockdown. + +#[cfg(target_os = "linux")] +mod ns_lockdown { + use libc::sock_filter; + + pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; + pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; + pub(super) const EPERM_VAL: u32 = 1; + /// ENOSYS: libc treats clone3 as unavailable and falls back to legacy clone. + pub(super) const ENOSYS_VAL: u32 = libc::ENOSYS as u32; + #[cfg(target_arch = "x86_64")] + pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000; + + pub(super) const OFF_NR: u32 = 0; + pub(super) const OFF_ARCH: u32 = 4; + pub(super) const OFF_ARGS0_LO: u32 = 16; // LE low half of args[0] + + #[cfg(target_arch = "x86_64")] + pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64 + #[cfg(target_arch = "aarch64")] + pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64 + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + pub(super) const EXPECTED_ARCH: u32 = 0; + + pub(super) const CLONE_NAMESPACE_BITS: u32 = (libc::CLONE_NEWNS as u32) + | (libc::CLONE_NEWCGROUP as u32) + | (libc::CLONE_NEWUTS as u32) + | (libc::CLONE_NEWIPC as u32) + | (libc::CLONE_NEWUSER as u32) + | (libc::CLONE_NEWPID as u32) + | (libc::CLONE_NEWNET as u32) + | (libc::CLONE_NEWTIME as u32); + + /// Linux `clone3` (arch-portable number; not always exported by libc). + pub(super) const SYS_CLONE3: u32 = 435; + + fn stmt(code: u32, k: u32) -> sock_filter { + sock_filter { + code: code as u16, + jt: 0, + jf: 0, + k, + } + } + + fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter { + sock_filter { + code: code as u16, + jt, + jf, + k, + } + } + + /// Classic BPF namespace lockdown. + /// + /// - `unshare` / `setns` / legacy `clone(CLONE_NEW*)` → EPERM + /// - `clone3` → ENOSYS (flags live in a pointed-to struct classic BPF cannot + /// inspect; ENOSYS makes libc fall back to legacy clone for ordinary + /// spawn, while direct malicious clone3 cannot create namespaces) + pub fn build_namespace_lockdown_filter() -> Vec<sock_filter> { + use libc::{ + BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W, SYS_clone, + SYS_setns, SYS_unshare, + }; + + let mut f = Vec::with_capacity(22); + f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH)); + f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR)); + #[cfg(target_arch = "x86_64")] + { + f.push(jump(BPF_JMP | BPF_JSET | BPF_K, X32_SYSCALL_BIT, 0, 1)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + } + for sys in [SYS_unshare as u32, SYS_setns as u32] { + f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, 0, 1)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + } + f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, SYS_CLONE3, 0, 1)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | ENOSYS_VAL)); + f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, SYS_clone as u32, 0, 3)); + f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARGS0_LO)); + f.push(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NAMESPACE_BITS, 0, 1)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); + f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + f + } + + #[cfg(test)] + pub fn filter_jeq_immediates(filter: &[sock_filter]) -> Vec<u32> { + use libc::{BPF_JEQ, BPF_JMP, BPF_K}; + let jeq = (BPF_JMP | BPF_JEQ | BPF_K) as u16; + filter + .iter() + .filter(|i| i.code == jeq) + .map(|i| i.k) + .collect() + } + + pub fn install(filter: &mut [sock_filter]) -> std::io::Result<()> { + use libc::{ + PR_SET_NO_NEW_PRIVS, SECCOMP_FILTER_FLAG_TSYNC, SECCOMP_SET_MODE_FILTER, SYS_seccomp, + prctl, sock_fprog, + }; + + let prog = sock_fprog { + len: filter.len() as u16, + filter: filter.as_mut_ptr(), + }; + + // SAFETY: standard NO_NEW_PRIVS before seccomp. + if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(std::io::Error::last_os_error()); + } + + // SAFETY: prog valid for the duration of the syscall. + // rc: 0 ok; >0 TSYNC failing TID; -1 errno. + let rc = unsafe { + libc::syscall( + SYS_seccomp, + SECCOMP_SET_MODE_FILTER as libc::c_long, + SECCOMP_FILTER_FLAG_TSYNC as libc::c_long, + &prog as *const sock_fprog as *const libc::c_void, + ) + }; + if rc == 0 { + return Ok(()); + } + if rc > 0 { + return Err(std::io::Error::other(format!( + "seccomp TSYNC failed: thread {rc} could not install filter" + ))); + } + Err(std::io::Error::last_os_error()) + } +} -/// Install seccomp BPF filter blocking network syscalls. -/// /// # Safety -/// -/// Must be called in a `pre_exec` context (after `fork`, before `exec`). +/// After fork / before exec. #[cfg(target_os = "linux")] pub unsafe fn install_child_network_filter() -> std::io::Result<()> { use libc::{ @@ -15,33 +150,9 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> { const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; - const EPERM_VAL: u32 = 1; // libc::EPERM + const EPERM_VAL: u32 = 1; - macro_rules! bpf_stmt { - ($code:expr, $k:expr) => { - sock_filter { - code: $code as u16, - jt: 0, - jf: 0, - k: $k as u32, - } - }; - } - - macro_rules! bpf_jump { - ($code:expr, $k:expr, $jt:expr, $jf:expr) => { - sock_filter { - code: $code as u16, - jt: $jt, - jf: $jf, - k: $k as u32, - } - }; - } - - const NR_OFFSET: u32 = 0; // seccomp_data.nr offset - - let blocked_syscalls: &[i64] = &[ + let blocked: &[i64] = &[ SYS_connect, SYS_bind, SYS_sendto, @@ -50,42 +161,42 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> { SYS_accept, SYS_accept4, ]; - let mut filter: Vec<sock_filter> = Vec::new(); - let total_checks = blocked_syscalls.len(); - - // 1. Load syscall number - filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET)); - - // 2. Check each blocked syscall - for (i, &syscall) in blocked_syscalls.iter().enumerate() { - let remaining = total_checks - i - 1; - filter.push(bpf_jump!( - BPF_JMP | BPF_JEQ | BPF_K, - syscall, - remaining as u8 + 1, // match: jump to ERRNO - 0 // no match: check next - )); + filter.push(sock_filter { + code: (BPF_LD | BPF_W | BPF_ABS) as u16, + jt: 0, + jf: 0, + k: 0, + }); + let n = blocked.len(); + for (i, &sys) in blocked.iter().enumerate() { + let remaining = n - i - 1; + filter.push(sock_filter { + code: (BPF_JMP | BPF_JEQ | BPF_K) as u16, + jt: remaining as u8 + 1, + jf: 0, + k: sys as u32, + }); } - - // 3. Default: ALLOW - filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); - - // 4. Blocked: ERRNO(EPERM) - filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL)); - + filter.push(sock_filter { + code: (BPF_RET | BPF_K) as u16, + jt: 0, + jf: 0, + k: SECCOMP_RET_ALLOW, + }); + filter.push(sock_filter { + code: (BPF_RET | BPF_K) as u16, + jt: 0, + jf: 0, + k: SECCOMP_RET_ERRNO | EPERM_VAL, + }); let prog = sock_fprog { len: filter.len() as u16, filter: filter.as_mut_ptr(), }; - - // Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter - // SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context. if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { return Err(std::io::Error::last_os_error()); } - - // SAFETY: prog is a valid sock_fprog pointing to our filter array. if unsafe { prctl( PR_SET_SECCOMP, @@ -98,14 +209,155 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> { { return Err(std::io::Error::last_os_error()); } - Ok(()) } -/// # Safety +/// Deny nested namespace creation on all threads (TSYNC). +/// Ordinary process creation uses legacy clone after clone3 returns ENOSYS. /// -/// No-op on non-Linux. +/// # Safety +/// Process-wide; call after bwrap re-exec / at apply. +#[cfg(target_os = "linux")] +pub unsafe fn install_namespace_lockdown_filter() -> std::io::Result<()> { + let mut filter = ns_lockdown::build_namespace_lockdown_filter(); + ns_lockdown::install(&mut filter) +} + #[cfg(not(target_os = "linux"))] pub unsafe fn install_child_network_filter() -> std::io::Result<()> { Ok(()) } + +#[cfg(not(target_os = "linux"))] +pub unsafe fn install_namespace_lockdown_filter() -> std::io::Result<()> { + Ok(()) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::ns_lockdown::*; + use libc::{SYS_clone, SYS_setns, SYS_unshare, sock_filter}; + + /// Minimal classic-BPF interpreter over synthetic seccomp_data fields. + fn eval(filter: &[sock_filter], arch: u32, nr: u32, arg0_lo: u32) -> u32 { + use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W}; + let mut pc = 0usize; + let mut a = 0u32; + for _ in 0..filter.len().saturating_mul(2) { + let insn = &filter[pc]; + let op = insn.code as u32; + if op == (BPF_LD | BPF_W | BPF_ABS) { + a = match insn.k { + OFF_NR => nr, + OFF_ARCH => arch, + OFF_ARGS0_LO => arg0_lo, + _ => 0, + }; + pc += 1; + } else if op == (BPF_JMP | BPF_JEQ | BPF_K) { + pc = if a == insn.k { + pc + 1 + insn.jt as usize + } else { + pc + 1 + insn.jf as usize + }; + } else if op == (BPF_JMP | BPF_JSET | BPF_K) { + pc = if a & insn.k != 0 { + pc + 1 + insn.jt as usize + } else { + pc + 1 + insn.jf as usize + }; + } else if op == (BPF_RET | BPF_K) { + return insn.k; + } else { + panic!("unsupported opcode {:#x} at {pc}", insn.code); + } + if pc >= filter.len() { + panic!("pc out of range"); + } + } + panic!("filter did not RET"); + } + + fn is_allow(r: u32) -> bool { + r == SECCOMP_RET_ALLOW + } + fn is_eperm(r: u32) -> bool { + r == (SECCOMP_RET_ERRNO | EPERM_VAL) + } + fn is_enosys(r: u32) -> bool { + r == (SECCOMP_RET_ERRNO | ENOSYS_VAL) + } + + #[test] + fn namespace_filter_targets_unshare_setns_clone3_and_clone() { + let f = build_namespace_lockdown_filter(); + let jeqs = filter_jeq_immediates(&f); + assert!(jeqs.contains(&(SYS_unshare as u32)), "{jeqs:?}"); + assert!(jeqs.contains(&(SYS_setns as u32)), "{jeqs:?}"); + assert!(jeqs.contains(&SYS_CLONE3), "{jeqs:?}"); + assert!(jeqs.contains(&(SYS_clone as u32)), "{jeqs:?}"); + assert!(jeqs.contains(&EXPECTED_ARCH), "{jeqs:?}"); + } + + #[test] + fn bpf_eval_ordinary_clone_allowed_namespace_clone_denied() { + let f = build_namespace_lockdown_filter(); + // Ordinary clone/fork flags (no NEW*) + assert!(is_allow(eval( + &f, + EXPECTED_ARCH, + SYS_clone as u32, + 0x11 /* SIGCHLD | CLONE_VM-ish low bits without NEW* */ + ))); + assert!(is_eperm(eval( + &f, + EXPECTED_ARCH, + SYS_clone as u32, + libc::CLONE_NEWUSER as u32 + ))); + assert!(is_eperm(eval( + &f, + EXPECTED_ARCH, + SYS_clone as u32, + libc::CLONE_NEWNS as u32 + ))); + } + + #[test] + fn bpf_eval_clone3_enosys_unshare_setns_eperm_read_allowed() { + let f = build_namespace_lockdown_filter(); + assert!(is_enosys(eval(&f, EXPECTED_ARCH, SYS_CLONE3, 0))); + assert!(is_eperm(eval(&f, EXPECTED_ARCH, SYS_unshare as u32, 0))); + assert!(is_eperm(eval(&f, EXPECTED_ARCH, SYS_setns as u32, 0))); + assert!(is_allow(eval(&f, EXPECTED_ARCH, 0, 0))); + } + + #[test] + fn bpf_eval_wrong_arch_and_x32_denied() { + let f = build_namespace_lockdown_filter(); + assert!(is_eperm(eval(&f, 0xdead_beef, SYS_clone as u32, 0))); + #[cfg(target_arch = "x86_64")] + { + // x32: nr has high bit set + assert!(is_eperm(eval( + &f, + EXPECTED_ARCH, + (SYS_unshare as u32) | X32_SYSCALL_BIT, + 0 + ))); + } + } + + #[test] + fn namespace_bits_cover_user_ns_and_mount_ns() { + assert_ne!(CLONE_NAMESPACE_BITS & (libc::CLONE_NEWUSER as u32), 0); + assert_ne!(CLONE_NAMESPACE_BITS & (libc::CLONE_NEWNS as u32), 0); + assert_ne!(CLONE_NAMESPACE_BITS & (libc::CLONE_NEWNET as u32), 0); + } + + #[test] + fn filter_ends_with_allow() { + let f = build_namespace_lockdown_filter(); + assert_eq!(f.last().unwrap().k, SECCOMP_RET_ALLOW); + } +} diff --git a/crates/codegen/xai-grok-sandbox/src/deny/mod.rs b/crates/codegen/xai-grok-sandbox/src/deny/mod.rs index 0abeec9..f916179 100644 --- a/crates/codegen/xai-grok-sandbox/src/deny/mod.rs +++ b/crates/codegen/xai-grok-sandbox/src/deny/mod.rs @@ -112,6 +112,112 @@ fn emit_seatbelt_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result< Ok(()) } +/// Emit write-only Seatbelt deny rules (hook sources stay readable). +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn emit_seatbelt_write_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> { + caps.add_platform_rule(format!("(deny file-write* {filter})"))?; + for action in SEATBELT_WRITE_DENY_ACTIONS { + caps.add_platform_rule(format!("(deny {action} {filter})"))?; + } + Ok(()) +} + +// Unlink blocks rename of the node; create blocks replacement. Specific +// sub-actions (not bare file-write*) win against later allow-write* grants. +#[cfg(all(feature = "enforce", target_os = "macos"))] +const SEATBELT_ANCESTOR_NODE_DENY_ACTIONS: &[&str] = &["file-write-unlink", "file-write-create"]; + +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn emit_seatbelt_ancestor_node_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> { + for action in SEATBELT_ANCESTOR_NODE_DENY_ACTIONS { + caps.add_platform_rule(format!("(deny {action} {filter})"))?; + } + Ok(()) +} + +/// Leaf parent up to deepest containing writable root; outside all roots → empty. +#[cfg(all(feature = "enforce", target_os = "macos"))] +pub(crate) fn ancestors_within_writable_roots( + path: &Path, + writable_roots: &[PathBuf], +) -> Vec<PathBuf> { + let root = writable_roots + .iter() + .filter(|r| path == r.as_path() || path.starts_with(r)) + .max_by_key(|r| r.components().count()); + let Some(root) = root else { + return Vec::new(); + }; + let mut out = Vec::new(); + for anc in xai_grok_config::existing_ancestor_chain(path) { + if anc == *root || anc.starts_with(root) { + out.push(anc); + } + } + if path != root.as_path() && root.exists() && !out.iter().any(|p| p == root) { + out.push(root.clone()); + } + out +} + +/// Write-only deny for hook sources. Linux is a no-op (bwrap). +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn apply_write_deny_paths_to_capability_set( + caps: &mut CapabilitySet, + entries: &[(PathBuf, bool)], + writable_roots: &[PathBuf], +) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } + #[cfg(target_os = "macos")] + { + let mut rule_paths = Vec::new(); + let mut ancestor_seen = std::collections::HashSet::new(); + for (path, is_dir) in entries { + let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone()); + let use_subpath = *is_dir || deny_path_is_dir(&canonical); + for form in macos_deny_aliases(path, &canonical) { + let Some(escaped) = escape_seatbelt_path(&form) else { + anyhow::bail!("cannot escape write-deny path {form:?} for Seatbelt"); + }; + if use_subpath { + emit_seatbelt_write_deny(caps, &format!("(literal \"{escaped}\")"))?; + emit_seatbelt_write_deny(caps, &format!("(subpath \"{escaped}\")"))?; + } else { + emit_seatbelt_write_deny(caps, &format!("(literal \"{escaped}\")"))?; + } + rule_paths.push(form); + } + for anc in ancestors_within_writable_roots(path, writable_roots) { + if !ancestor_seen.insert(anc.clone()) { + continue; + } + let anc_canon = dunce::canonicalize(&anc).unwrap_or_else(|_| anc.clone()); + for form in macos_deny_aliases(&anc, &anc_canon) { + let Some(escaped) = escape_seatbelt_path(&form) else { + anyhow::bail!( + "cannot escape ancestor write-deny path {form:?} for Seatbelt" + ); + }; + emit_seatbelt_ancestor_node_deny(caps, &format!("(literal \"{escaped}\")"))?; + rule_paths.push(form); + } + } + } + let _ = caps.remove_exact_file_caps_for_paths(&rule_paths); + tracing::info!( + count = entries.len(), + "Applied Seatbelt write-deny for Grok-owned direct hook sources" + ); + } + #[cfg(target_os = "linux")] + { + let _ = (caps, writable_roots); + } + Ok(()) +} + /// Apply kernel-level deny rules for the given paths. /// /// On macOS, adds Seatbelt read-deny + write-deny (incl. specific write @@ -238,6 +344,49 @@ mod tests { #[cfg(all(feature = "enforce", unix))] use super::*; + #[test] + #[cfg(all(feature = "enforce", target_os = "macos"))] + fn ancestors_pin_under_writable_root_not_home() { + let tmp = std::env::temp_dir().join(format!( + "grok-anc-policy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let grok = tmp.join("grok"); + let sessions = grok.join("sessions"); + let leaf = sessions.join("extra-hooks"); + std::fs::create_dir_all(&leaf).unwrap(); + let ws = tmp.join("ws"); + std::fs::create_dir_all(&ws).unwrap(); + + let roots = [grok.clone(), ws.clone()]; + let pin = ancestors_within_writable_roots(&leaf, &roots); + assert!( + pin.iter().any(|p| p == &sessions), + "must pin sessions under GROK_HOME: {pin:?}" + ); + assert!( + pin.iter().any(|p| p == &grok), + "must pin GROK_HOME grant root: {pin:?}" + ); + assert!( + !pin.iter().any(|p| p == &tmp), + "must not pin above writable roots: {pin:?}" + ); + + let outside = tmp.join("outside").join("hooks"); + std::fs::create_dir_all(&outside).unwrap(); + let pin_out = ancestors_within_writable_roots(&outside, &roots); + assert!( + pin_out.is_empty(), + "source outside writable roots: leaf-only: {pin_out:?}" + ); + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] #[cfg(all(feature = "enforce", unix))] fn resolve_deny_paths_relative() { diff --git a/crates/codegen/xai-grok-sandbox/src/hook_write_deny.rs b/crates/codegen/xai-grok-sandbox/src/hook_write_deny.rs new file mode 100644 index 0000000..94f3c9f --- /dev/null +++ b/crates/codegen/xai-grok-sandbox/src/hook_write_deny.rs @@ -0,0 +1,437 @@ +//! Grok-owned hook write-deny: plan, identity revalidation, and post-reexec checks. +//! Namespace lockdown is in [`crate::child_net`]. + +use std::path::{Path, PathBuf}; + +use xai_grok_config::{ + GlobalHookSource, ensure_grok_hook_slots, missing_configured_sources, + resolve_global_hook_sources, +}; + +#[cfg(target_os = "linux")] +use xai_grok_config::unique_ancestors_rootward; +#[cfg(unix)] +use xai_grok_config::validated_hook_json_files_for_sources; + +use crate::paths::grok_home; +use crate::profiles::ProfileName; + +pub fn profile_enforces_hook_write_deny(profile: &ProfileName) -> bool { + !matches!(profile, ProfileName::Devbox | ProfileName::Off) +} + +#[derive(Debug, thiserror::Error)] +pub enum HookWriteDenyError { + #[error("{0}")] + Resolve(String), + #[error( + "configured absolute hooks-paths target(s) do not exist: {0}. \ + Create them outside the sandbox or remove them from hooks-paths." + )] + MissingConfigured(String), + #[error("required hook write-deny path is not effectively read-only: {path}")] + NotReadOnly { path: PathBuf }, + #[error("cannot verify hook write-deny path {path}: {detail}")] + VerifyIo { path: PathBuf, detail: String }, + #[error("hook write-deny path identity changed before apply (possible rename race): {path}")] + IdentityChanged { path: PathBuf }, + #[error("hook write-deny path is a symlink (retargetable): {path}")] + Symlink { path: PathBuf }, + #[error( + "protected regular file has hard-link aliases (st_nlink={nlink}): {path}; \ + refuse sandbox rather than leave a writable alias" + )] + HardLink { path: PathBuf, nlink: u64 }, + #[error("hook directory JSON snapshot changed before apply: {dir}")] + JsonSnapshotChanged { dir: PathBuf }, +} + +impl From<xai_grok_config::GlobalHookSourceError> for HookWriteDenyError { + fn from(e: xai_grok_config::GlobalHookSourceError) -> Self { + Self::Resolve(e.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathIdentity { + pub path: PathBuf, + pub dev: u64, + pub ino: u64, + pub is_dir: bool, + /// Regular files must stay `1` (no hard-link aliases). + pub nlink: u64, +} + +/// No-follow identity; regular files require `st_nlink == 1`. +#[cfg(unix)] +pub fn capture_path_identity(path: &Path) -> Result<PathIdentity, HookWriteDenyError> { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::symlink_metadata(path).map_err(|e| HookWriteDenyError::VerifyIo { + path: path.to_path_buf(), + detail: e.to_string(), + })?; + if meta.file_type().is_symlink() { + return Err(HookWriteDenyError::Symlink { + path: path.to_path_buf(), + }); + } + let is_dir = meta.file_type().is_dir(); + let nlink = meta.nlink(); + if !is_dir && nlink != 1 { + return Err(HookWriteDenyError::HardLink { + path: path.to_path_buf(), + nlink, + }); + } + Ok(PathIdentity { + path: path.to_path_buf(), + dev: meta.dev(), + ino: meta.ino(), + is_dir, + nlink, + }) +} + +#[cfg(unix)] +pub fn revalidate_path_identity(id: &PathIdentity) -> Result<(), HookWriteDenyError> { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::symlink_metadata(&id.path).map_err(|e| HookWriteDenyError::VerifyIo { + path: id.path.clone(), + detail: e.to_string(), + })?; + if meta.file_type().is_symlink() { + return Err(HookWriteDenyError::Symlink { + path: id.path.clone(), + }); + } + let is_dir = meta.file_type().is_dir(); + let nlink = meta.nlink(); + if !is_dir && nlink != 1 { + return Err(HookWriteDenyError::HardLink { + path: id.path.clone(), + nlink, + }); + } + if meta.dev() != id.dev || meta.ino() != id.ino || is_dir != id.is_dir || nlink != id.nlink { + return Err(HookWriteDenyError::IdentityChanged { + path: id.path.clone(), + }); + } + Ok(()) +} + +#[cfg(unix)] +fn reject_hardlinked_files(sources: &[GlobalHookSource]) -> Result<(), HookWriteDenyError> { + use std::os::unix::fs::MetadataExt; + use xai_grok_config::GlobalHookSourceKind; + for s in sources { + let is_file_slot = matches!( + s.kind, + GlobalHookSourceKind::RegistryFile | GlobalHookSourceKind::ConfiguredSource + ); + if !is_file_slot || !s.path.exists() || s.path.is_dir() { + continue; + } + let meta = + std::fs::symlink_metadata(&s.path).map_err(|e| HookWriteDenyError::VerifyIo { + path: s.path.clone(), + detail: e.to_string(), + })?; + if meta.file_type().is_file() && meta.nlink() != 1 { + return Err(HookWriteDenyError::HardLink { + path: s.path.clone(), + nlink: meta.nlink(), + }); + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn reject_hardlinked_files(_sources: &[GlobalHookSource]) -> Result<(), HookWriteDenyError> { + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct DirJsonSnapshot { + pub dir: PathBuf, + pub files: Vec<PathIdentity>, +} + +#[derive(Debug, Clone)] +pub struct HookWriteDenyBwrapPlan { + pub ancestor_rw_binds: Vec<PathBuf>, + pub leaves: Vec<PathIdentity>, + pub dir_json_snapshots: Vec<DirJsonSnapshot>, +} + +#[derive(Debug, Clone)] +pub enum HookWriteDenyPrepare { + NotRequired, + #[cfg(target_os = "linux")] + Plan(HookWriteDenyBwrapPlan), + #[cfg(not(target_os = "linux"))] + Ensured, +} + +pub fn resolve_hook_write_deny_snapshot() -> Result<Vec<GlobalHookSource>, HookWriteDenyError> { + let grok = grok_home(); + let resolved = + resolve_global_hook_sources(Some(grok.as_path()), /* reject_symlinks */ true)?; + if let Some(e) = resolved.configured_error { + return Err(HookWriteDenyError::Resolve(e.to_string())); + } + let missing = missing_configured_sources(&resolved.sources); + if !missing.is_empty() { + return Err(HookWriteDenyError::MissingConfigured( + missing + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + .join(", "), + )); + } + reject_hardlinked_files(&resolved.sources)?; + #[cfg(unix)] + { + validated_hook_json_files_for_sources(&resolved.sources)?; + } + Ok(resolved.sources) +} + +pub fn prepare_hook_write_deny( + profile: &ProfileName, +) -> Result<HookWriteDenyPrepare, HookWriteDenyError> { + if !profile_enforces_hook_write_deny(profile) { + return Ok(HookWriteDenyPrepare::NotRequired); + } + let grok = grok_home(); + ensure_grok_hook_slots(grok.as_path())?; + let sources = resolve_hook_write_deny_snapshot()?; + + #[cfg(target_os = "linux")] + { + let plan = build_bwrap_plan(&sources)?; + Ok(HookWriteDenyPrepare::Plan(plan)) + } + #[cfg(not(target_os = "linux"))] + { + let _ = sources; + Ok(HookWriteDenyPrepare::Ensured) + } +} + +pub fn profile_hook_write_deny(profile: &ProfileName) -> anyhow::Result<Vec<GlobalHookSource>> { + if !profile_enforces_hook_write_deny(profile) { + return Ok(Vec::new()); + } + resolve_hook_write_deny_snapshot().map_err(|e| anyhow::anyhow!("{e}")) +} + +/// Top-level sources plus validated immediate discovery JSON under directories. +#[cfg(unix)] +pub fn enforcement_leaf_paths( + sources: &[GlobalHookSource], +) -> Result<Vec<PathBuf>, HookWriteDenyError> { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for s in sources { + if seen.insert(s.path.clone()) { + out.push(s.path.clone()); + } + } + for f in validated_hook_json_files_for_sources(sources)? { + if seen.insert(f.clone()) { + out.push(f); + } + } + Ok(out) +} + +#[cfg(unix)] +fn capture_dir_json_snapshot(dir: &Path) -> Result<DirJsonSnapshot, HookWriteDenyError> { + use xai_grok_config::{list_direct_hook_json_files, validate_direct_hook_json_file}; + let listed = list_direct_hook_json_files(dir).map_err(|e| HookWriteDenyError::VerifyIo { + path: dir.to_path_buf(), + detail: e.to_string(), + })?; + let mut files = Vec::new(); + for f in listed { + validate_direct_hook_json_file(&f)?; + files.push(capture_path_identity(&f)?); + } + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(DirJsonSnapshot { + dir: dir.to_path_buf(), + files, + }) +} + +#[cfg(target_os = "linux")] +pub fn build_bwrap_plan( + sources: &[GlobalHookSource], +) -> Result<HookWriteDenyBwrapPlan, HookWriteDenyError> { + let mut leaves = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut dir_json_snapshots = Vec::new(); + + for src in sources { + if !src.path.exists() { + return Err(HookWriteDenyError::Resolve(format!( + "required hook write-deny path is missing: {}", + src.path.display() + ))); + } + if seen.insert(src.path.clone()) { + leaves.push(capture_path_identity(&src.path)?); + } + if src.is_dir() && src.path.is_dir() { + let snap = capture_dir_json_snapshot(&src.path)?; + for f in &snap.files { + if seen.insert(f.path.clone()) { + leaves.push(f.clone()); + } + } + dir_json_snapshots.push(snap); + } + } + + let leaf_paths: Vec<PathBuf> = leaves.iter().map(|l| l.path.clone()).collect(); + let ancestor_rw_binds = unique_ancestors_rootward(sources) + .into_iter() + .filter(|a| !leaf_paths.iter().any(|l| l == a)) + .collect(); + Ok(HookWriteDenyBwrapPlan { + ancestor_rw_binds, + leaves, + dir_json_snapshots, + }) +} + +#[cfg(target_os = "linux")] +pub fn revalidate_plan(plan: &HookWriteDenyBwrapPlan) -> Result<(), HookWriteDenyError> { + for leaf in &plan.leaves { + revalidate_path_identity(leaf)?; + } + for snap in &plan.dir_json_snapshots { + let now = capture_dir_json_snapshot(&snap.dir)?; + if now.files.len() != snap.files.len() { + return Err(HookWriteDenyError::JsonSnapshotChanged { + dir: snap.dir.clone(), + }); + } + for (a, b) in snap.files.iter().zip(now.files.iter()) { + if a.path != b.path || a.dev != b.dev || a.ino != b.ino || a.nlink != b.nlink { + return Err(HookWriteDenyError::JsonSnapshotChanged { + dir: snap.dir.clone(), + }); + } + } + } + for anc in &plan.ancestor_rw_binds { + let meta = std::fs::symlink_metadata(anc).map_err(|e| HookWriteDenyError::VerifyIo { + path: anc.clone(), + detail: e.to_string(), + })?; + if meta.file_type().is_symlink() || !meta.file_type().is_dir() { + return Err(HookWriteDenyError::IdentityChanged { path: anc.clone() }); + } + if !anc.exists() { + return Err(HookWriteDenyError::Resolve(format!( + "required ancestor for hook write-deny is missing: {}", + anc.display() + ))); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn append_hook_plan_binds( + cmd: &mut std::process::Command, + plan: &HookWriteDenyBwrapPlan, +) -> Result<(), HookWriteDenyError> { + revalidate_plan(plan)?; + for anc in &plan.ancestor_rw_binds { + cmd.arg("--bind").arg(anc).arg(anc); + } + for leaf in &plan.leaves { + cmd.arg("--ro-bind").arg(&leaf.path).arg(&leaf.path); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn path_is_effectively_readonly(path: &Path) -> Result<bool, HookWriteDenyError> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let c_path = + CString::new(path.as_os_str().as_bytes()).map_err(|_| HookWriteDenyError::VerifyIo { + path: path.to_path_buf(), + detail: "path contains interior NUL".into(), + })?; + let mut buf: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut buf) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + return Err(HookWriteDenyError::VerifyIo { + path: path.to_path_buf(), + detail: err.to_string(), + }); + } + Ok(buf.f_flag & libc::ST_RDONLY != 0) +} + +#[cfg(target_os = "linux")] +pub fn verify_required_hook_write_denies(paths: &[PathBuf]) -> Result<(), HookWriteDenyError> { + for path in paths { + if !path_is_effectively_readonly(path)? { + return Err(HookWriteDenyError::NotReadOnly { path: path.clone() }); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn ensure_namespace_lockdown() -> Result<(), String> { + use std::sync::OnceLock; + static INSTALLED: OnceLock<Result<(), String>> = OnceLock::new(); + INSTALLED + .get_or_init(|| { + // SAFETY: after bwrap re-exec / at apply; TSYNC covers all threads. + unsafe { crate::child_net::install_namespace_lockdown_filter() } + .map_err(|e| format!("namespace lockdown seccomp failed: {e}")) + }) + .clone() +} + +#[cfg(target_os = "linux")] +pub fn verify_hook_write_deny_enforced() -> Result<(), String> { + ensure_namespace_lockdown()?; + let sources = resolve_hook_write_deny_snapshot().map_err(|e| e.to_string())?; + let paths = enforcement_leaf_paths(&sources).map_err(|e| e.to_string())?; + verify_required_hook_write_denies(&paths).map_err(|e| e.to_string()) +} + +#[cfg(not(target_os = "linux"))] +pub fn verify_hook_write_deny_enforced() -> Result<(), String> { + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn maybe_install_namespace_lockdown_inside_bwrap(profile: &ProfileName) -> Result<(), String> { + if profile_enforces_hook_write_deny(profile) && crate::is_inside_bwrap() { + ensure_namespace_lockdown()?; + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn maybe_install_namespace_lockdown_inside_bwrap(_profile: &ProfileName) -> Result<(), String> { + Ok(()) +} + +#[cfg(all(test, unix))] +#[path = "hook_write_deny_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-sandbox/src/hook_write_deny_tests.rs b/crates/codegen/xai-grok-sandbox/src/hook_write_deny_tests.rs new file mode 100644 index 0000000..31832d1 --- /dev/null +++ b/crates/codegen/xai-grok-sandbox/src/hook_write_deny_tests.rs @@ -0,0 +1,176 @@ +use super::*; + +#[test] +fn revalidate_refuses_replaced_directory() { + let root = std::env::temp_dir().join(format!( + "grok-id-race-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let hooks = root.join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + let id = capture_path_identity(&hooks).unwrap(); + revalidate_path_identity(&id).unwrap(); + + let moved = root.join("hooks-old"); + std::fs::rename(&hooks, &moved).unwrap(); + std::fs::create_dir_all(&hooks).unwrap(); + + let err = revalidate_path_identity(&id).unwrap_err(); + assert!( + matches!(err, HookWriteDenyError::IdentityChanged { .. }), + "expected IdentityChanged, got {err:?}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn revalidate_refuses_symlink_swap() { + let root = std::env::temp_dir().join(format!( + "grok-id-symlink-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let hooks = root.join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + let id = capture_path_identity(&hooks).unwrap(); + + let moved = root.join("hooks-old"); + std::fs::rename(&hooks, &moved).unwrap(); + std::os::unix::fs::symlink(&moved, &hooks).unwrap(); + + let err = revalidate_path_identity(&id).unwrap_err(); + assert!( + matches!( + err, + HookWriteDenyError::Symlink { .. } | HookWriteDenyError::IdentityChanged { .. } + ), + "expected symlink/identity error, got {err:?}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn capture_refuses_hardlinked_regular_file() { + let root = std::env::temp_dir().join(format!( + "grok-hardlink-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let reg = root.join("hooks-paths"); + let alias = root.join("hooks-paths-alias"); + std::fs::write(®, b"").unwrap(); + std::fs::hard_link(®, &alias).unwrap(); + + let err = capture_path_identity(®).unwrap_err(); + assert!( + matches!(err, HookWriteDenyError::HardLink { nlink, .. } if nlink >= 2), + "expected HardLink, got {err:?}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +#[cfg(target_os = "linux")] +fn revalidate_rejects_late_json_file_after_plan_capture() { + let root = std::env::temp_dir().join(format!( + "grok-late-json-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let hooks = root.join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + std::fs::write(hooks.join("keep.json"), b"{}").unwrap(); + let sources = [GlobalHookSource { + path: hooks.clone(), + kind: xai_grok_config::GlobalHookSourceKind::HookDirectory, + }]; + let plan = build_bwrap_plan(&sources).expect("plan"); + revalidate_plan(&plan).expect("stable"); + + // Late insert after capture (hardlinked alias also exercises nlink). + let late = hooks.join("late.json"); + let alias = root.join("late-alias.json"); + std::fs::write(&late, b"{}").unwrap(); + std::fs::hard_link(&late, &alias).unwrap(); + + let err = revalidate_plan(&plan).unwrap_err(); + // Late hardlinked JSON may surface as Resolve (config validation wrapped via From) + // before a typed HardLink/JsonSnapshotChanged, depending on check order. + assert!( + matches!( + err, + HookWriteDenyError::JsonSnapshotChanged { .. } + | HookWriteDenyError::HardLink { .. } + | HookWriteDenyError::Resolve(_) + ), + "expected snapshot/hardlink/resolve failure, got {err:?}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn hardlinked_discovery_json_under_hooks_dir_refused() { + let root = std::env::temp_dir().join(format!( + "grok-hl-json-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let hooks = root.join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + let active = hooks.join("active.json"); + let alias = hooks.join("alias.json"); + std::fs::write(&active, b"{}").unwrap(); + std::fs::hard_link(&active, &alias).unwrap(); + let sources = [GlobalHookSource { + path: hooks, + kind: xai_grok_config::GlobalHookSourceKind::HookDirectory, + }]; + let err = xai_grok_config::validated_hook_json_files_for_sources(&sources).unwrap_err(); + assert!(matches!( + err, + xai_grok_config::GlobalHookSourceError::HardLinkedHookFile { .. } + )); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn reject_hardlinked_files_on_registry_source() { + let root = std::env::temp_dir().join(format!( + "grok-hl-src-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(root.join("hooks")).unwrap(); + let reg = root.join("hooks-paths"); + let alias = root.join("alias"); + std::fs::write(®, b"").unwrap(); + std::fs::hard_link(®, &alias).unwrap(); + + let sources = [GlobalHookSource { + path: reg, + kind: xai_grok_config::GlobalHookSourceKind::RegistryFile, + }]; + let err = reject_hardlinked_files(&sources).unwrap_err(); + assert!(matches!(err, HookWriteDenyError::HardLink { .. })); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/codegen/xai-grok-sandbox/src/lib.rs b/crates/codegen/xai-grok-sandbox/src/lib.rs index 84c0100..7f85d93 100644 --- a/crates/codegen/xai-grok-sandbox/src/lib.rs +++ b/crates/codegen/xai-grok-sandbox/src/lib.rs @@ -28,27 +28,42 @@ //! ``` pub mod child_net; mod deny; +mod hook_write_deny; mod logging; mod network_policy; mod paths; mod profiles; mod types; +pub use hook_write_deny::{profile_enforces_hook_write_deny, verify_hook_write_deny_enforced}; pub use logging::SandboxLogger; pub use network_policy::{ ChildNetworkPolicy, NETWORK_POLICY_SNAPSHOT_VERSION, NetworkPolicySnapshot, NetworkPolicySnapshotError, WebsiteAction, WebsiteOrigin, WebsiteOriginError, WebsitePolicy, }; -#[cfg(all(feature = "enforce", unix))] -use nono::Sandbox; pub use profiles::{ ProfileName, SandboxConfig, SandboxProfile, load_sandbox_config, sandbox_profile_conflicts, }; -use std::path::Path; -#[cfg(any(target_os = "linux", all(feature = "enforce", test)))] -use std::path::PathBuf; +pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics}; +/// Whether this profile requires direct-hook write protection (non-devbox +/// enforcing profiles). Shell fails closed when protection cannot be applied. +pub fn requires_hook_write_deny(profile: &ProfileName, workspace: &Path) -> bool { + if !profile_enforces_hook_write_deny(profile) || *profile == ProfileName::Off { + return false; + } + let config = profiles::load_sandbox_config(workspace); + match profile { + ProfileName::Custom(name) => { + config.profiles.get(name).and_then(|p| p.extends.as_deref()) != Some("devbox") + } + ProfileName::Devbox => false, + _ => true, + } +} +#[cfg(all(feature = "enforce", unix))] +use nono::Sandbox; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; -pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics}; static SANDBOX: OnceLock<GlobalSandboxState> = OnceLock::new(); static CONFIGURED_PROFILE: OnceLock<String> = OnceLock::new(); static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false); @@ -150,6 +165,12 @@ impl SandboxManager { tracing::info!("Sandbox disabled (profile: off)"); return Ok(()); } + if requires_hook_write_deny(&self.profile, workspace) { + xai_grok_config::ensure_grok_hook_slots(paths::grok_home().as_path()) + .map_err(|e| anyhow::anyhow!("hook write-deny ensure failed: {e}"))?; + hook_write_deny::maybe_install_namespace_lockdown_inside_bwrap(&self.profile) + .map_err(|e| anyhow::anyhow!("{e}"))?; + } let config = profiles::load_sandbox_config(workspace); let mut resolved = self.profile.resolve_profile(workspace, &config)?; self.net_restricted = resolved.restrict_network; @@ -251,6 +272,23 @@ impl SandboxManager { pub fn bwrap_reexec_command( deny_write: &[&str], deny_read: &[&str], +) -> Option<std::process::Command> { + #[cfg(target_os = "linux")] + { + bwrap_reexec_command_ex(deny_write, None, deny_read) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (deny_write, deny_read); + None + } +} +/// Like [`bwrap_reexec_command`] plus optional hook plan (via `append_hook_plan_binds`). +#[cfg(target_os = "linux")] +pub(crate) fn bwrap_reexec_command_ex( + deny_write_optional: &[&str], + hook_plan: Option<&hook_write_deny::HookWriteDenyBwrapPlan>, + deny_read: &[&str], ) -> Option<std::process::Command> { if is_inside_bwrap() { return None; @@ -258,13 +296,19 @@ pub fn bwrap_reexec_command( let self_exe = std::env::current_exe().ok()?; let args: Vec<String> = std::env::args().skip(1).collect(); let mut cmd = std::process::Command::new("bwrap"); + cmd.arg("--cap-drop").arg("ALL"); cmd.arg("--bind").arg("/").arg("/"); - for path in deny_write { + for path in deny_write_optional { if Path::new(path).exists() { cmd.arg("--ro-bind").arg(path).arg(path); } } - #[cfg(target_os = "linux")] + if let Some(plan) = hook_plan + && let Err(e) = hook_write_deny::append_hook_plan_binds(&mut cmd, plan) + { + eprintln!("error: hook write-deny plan materialization failed: {e}"); + return None; + } if !deny_read.is_empty() { for path in deny_read { let Some(blocked) = bwrap_blocked_source_for_path(Path::new(path)) else { @@ -277,8 +321,6 @@ pub fn bwrap_reexec_command( cmd.arg("--ro-bind").arg(&blocked).arg(path); } } - #[cfg(not(target_os = "linux"))] - let _ = deny_read; cmd.arg("--dev-bind").arg("/dev").arg("/dev"); cmd.arg("--proc").arg("/proc"); cmd.env(BWRAP_ENV_VAR, "1"); @@ -384,41 +426,56 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool { pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool { false } -/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`), -/// bound-over unreadable placeholders (`deny_read`), and whether the profile -/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero -/// current matches — globs are best-effort on Linux). +/// A profile's resolved bwrap deny plan. #[cfg(target_os = "linux")] struct BwrapDenyPlan { - deny_write: Vec<String>, + deny_write_optional: Vec<String>, + hook_plan: Option<hook_write_deny::HookWriteDenyBwrapPlan>, deny_read: Vec<String>, has_globs: bool, } -/// Resolve a profile's full [`BwrapDenyPlan`] in ONE config read: the `/data` -/// write-deny (devbox and devbox-extending customs), the exact read-deny paths, -/// and the launch-time glob expansion. Returns `None` (fail closed) if a deny -/// glob blows past the expansion caps or is invalid, so -/// [`bwrap_reexec_for_profile`] refuses to start. -/// -/// Best-effort on Linux: a mount namespace can't glob at runtime, so globs are -/// expanded once here at launch — files matching them that are created LATER are -/// NOT covered (macOS Seatbelt enforces the same globs as runtime regexes). #[cfg(all(feature = "enforce", target_os = "linux"))] fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> { let config = profiles::load_sandbox_config(workspace); - let deny_write: Vec<String> = if is_devbox_based(profile, &config) { + let deny_write_optional: Vec<String> = if is_devbox_based(profile, &config) { vec!["/data".to_string()] } else { Vec::new() }; - let entries = if *profile == ProfileName::Off { - Vec::new() + let resolved = if *profile == ProfileName::Off { + None } else { - profile - .resolve_profile(workspace, &config) - .map(|r| r.deny) - .unwrap_or_default() + match profile.resolve_profile(workspace, &config) { + Ok(r) => Some(r), + Err(e) => { + if requires_hook_write_deny(profile, workspace) { + eprintln!("error: sandbox profile resolve failed: {e}"); + return None; + } + None + } + } }; + let entries = resolved + .as_ref() + .map(|r| r.deny.clone()) + .unwrap_or_default(); + let needs_hooks = requires_hook_write_deny(profile, workspace); + let hook_plan = if needs_hooks { + match hook_write_deny::prepare_hook_write_deny(profile) { + Ok(hook_write_deny::HookWriteDenyPrepare::NotRequired) => None, + Ok(hook_write_deny::HookWriteDenyPrepare::Plan(plan)) => Some(plan), + Err(e) => { + eprintln!("error: hook write-deny plan failed: {e}"); + return None; + } + } + } else { + None + }; + if needs_hooks && hook_plan.is_none() { + return None; + } let (exact, globs) = deny::partition_deny_entries(&entries); let mut deny_read = deny::exact_deny_path_strings(workspace, &exact); let has_globs = !globs.is_empty(); @@ -437,55 +494,56 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP )?); } Some(BwrapDenyPlan { - deny_write, + deny_write_optional, + hook_plan, deny_read, has_globs, }) } -/// Stub when `enforce` is unavailable on Linux: read-deny needs nono, so there is -/// none — but the devbox `/data` write-deny is a plain bwrap mount and MUST still -/// apply (devbox `/data` is always sandboxed), so it is preserved here. #[cfg(all(not(feature = "enforce"), target_os = "linux"))] fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> { let config = profiles::load_sandbox_config(workspace); - let deny_write: Vec<String> = if is_devbox_based(profile, &config) { + let deny_write_optional: Vec<String> = if is_devbox_based(profile, &config) { vec!["/data".to_string()] } else { Vec::new() }; + let hook_plan = if requires_hook_write_deny(profile, workspace) { + match hook_write_deny::prepare_hook_write_deny(profile) { + Ok(hook_write_deny::HookWriteDenyPrepare::NotRequired) => None, + Ok(hook_write_deny::HookWriteDenyPrepare::Plan(plan)) => Some(plan), + Err(e) => { + eprintln!("error: hook write-deny plan failed: {e}"); + return None; + } + } + } else { + None + }; Some(BwrapDenyPlan { - deny_write, + deny_write_optional, + hook_plan, deny_read: Vec::new(), has_globs: false, }) } -/// Build the bwrap re-exec command needed on Linux, or `None` if no mount-namespace -/// enforcement is needed (or we are already inside bwrap). Canonical routing: -/// devbox — and a custom profile that `extends = "devbox"` — gets write-deny on -/// `/data`; any profile gets read-deny on its own `deny` set. These compose, so a -/// devbox-based custom profile with a `deny` list write-denies `/data` AND -/// read-denies its deny paths in one re-exec. -/// -/// Glob deny entries are expanded to concrete existing matches at launch and -/// bound over too (best-effort; post-launch matches are not covered on Linux). -/// Returns `None` (fail closed) if a glob blows past the expansion caps, so the -/// shell's startup refuses to run with a broad glob under-enforced. #[cfg(target_os = "linux")] pub fn bwrap_reexec_for_profile( profile: &ProfileName, workspace: &Path, ) -> Option<std::process::Command> { let BwrapDenyPlan { - deny_write, + deny_write_optional, + hook_plan, deny_read, has_globs, } = bwrap_deny_plan(profile, workspace)?; - if deny_write.is_empty() && deny_read.is_empty() && !has_globs { + if deny_write_optional.is_empty() && hook_plan.is_none() && deny_read.is_empty() && !has_globs { return None; } - let write_refs: Vec<&str> = deny_write.iter().map(String::as_str).collect(); + let write_opt: Vec<&str> = deny_write_optional.iter().map(String::as_str).collect(); let read_refs: Vec<&str> = deny_read.iter().map(String::as_str).collect(); - bwrap_reexec_command(&write_refs, &read_refs) + bwrap_reexec_command_ex(&write_opt, hook_plan.as_ref(), &read_refs) } #[cfg(test)] mod tests { @@ -528,6 +586,7 @@ mod tests { } #[test] #[serial(bwrap_env)] + #[cfg(target_os = "linux")] fn bwrap_reexec_returns_some_outside_bwrap() { let _g = EnvGuard::remove(BWRAP_ENV_VAR); let result = bwrap_reexec_command(&["/tmp"], &[]); @@ -553,6 +612,7 @@ mod tests { } #[test] #[serial(bwrap_env)] + #[cfg(target_os = "linux")] fn bwrap_reexec_skips_nonexistent_paths() { let _g = EnvGuard::remove(BWRAP_ENV_VAR); let result = bwrap_reexec_command(&["/nonexistent-test-path-xyz-12345"], &[]); @@ -588,6 +648,7 @@ mod tests { } #[test] #[serial(bwrap_env)] + #[cfg(target_os = "linux")] fn bwrap_reexec_mounts_existing_paths_read_only() { let _g = EnvGuard::remove(BWRAP_ENV_VAR); let result = bwrap_reexec_command(&["/tmp"], &[]); @@ -602,8 +663,95 @@ mod tests { "should mount existing paths as --ro-bind, got args: {args:?}" ); } + /// Hook plan: rootward ancestor RW self-binds precede leaf RO; no bwrap + /// version flags required. Identity revalidation is part of append. #[test] #[serial(bwrap_env)] + #[cfg(target_os = "linux")] + fn bwrap_hook_plan_binds_ancestors_then_leaves() { + let _g = EnvGuard::remove(BWRAP_ENV_VAR); + let root = std::env::temp_dir().join(format!( + "grok-bwrap-hook-plan-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let parent = root.join("sessions"); + let leaf = parent.join("extra-hooks"); + std::fs::create_dir_all(&leaf).unwrap(); + let sources = [xai_grok_config::GlobalHookSource { + path: leaf.clone(), + kind: xai_grok_config::GlobalHookSourceKind::ConfiguredSource, + }]; + let plan = hook_write_deny::build_bwrap_plan(&sources).expect("plan"); + assert!( + !plan.ancestor_rw_binds.iter().any(|p| p == Path::new("/")), + "must not RW-bind /: {:?}", + plan.ancestor_rw_binds + ); + assert!( + plan.ancestor_rw_binds.iter().any(|p| p == &parent), + "immediate parent must be pinned: {:?}", + plan.ancestor_rw_binds + ); + for w in plan.ancestor_rw_binds.windows(2) { + assert!( + w[0].components().count() <= w[1].components().count(), + "ancestors not rootward: {:?}", + plan.ancestor_rw_binds + ); + } + let moved = root.join("extra-hooks-old"); + std::fs::rename(&leaf, &moved).unwrap(); + std::fs::create_dir_all(&leaf).unwrap(); + let mut refuse = std::process::Command::new("bwrap"); + let err = hook_write_deny::append_hook_plan_binds(&mut refuse, &plan); + assert!(err.is_err(), "must refuse replaced leaf identity"); + let _ = std::fs::remove_dir_all(&leaf); + std::fs::rename(&moved, &leaf).unwrap(); + let plan = hook_write_deny::build_bwrap_plan(&sources).expect("plan2"); + let cmd = bwrap_reexec_command_ex(&[], Some(&plan), &[]).expect("bwrap command"); + let args: Vec<String> = cmd + .get_args() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + assert!( + !args.iter().any(|a| a == "--disable-userns"), + "must not require --disable-userns: {args:?}" + ); + assert!( + args.windows(2).any(|w| w == ["--cap-drop", "ALL"]), + "expected --cap-drop ALL: {args:?}" + ); + let parent_s = parent.to_string_lossy().to_string(); + let leaf_s = leaf.to_string_lossy().to_string(); + let anc_parent = args + .windows(3) + .position(|w| w[0] == "--bind" && w[1] == parent_s && w[2] == parent_s); + let leaf_pos = args + .windows(3) + .position(|w| w[0] == "--ro-bind" && w[1] == leaf_s && w[2] == leaf_s); + assert!(anc_parent.is_some(), "expected RW bind of parent: {args:?}"); + assert!(leaf_pos.is_some(), "expected RO bind of leaf: {args:?}"); + assert!( + anc_parent.unwrap() < leaf_pos.unwrap(), + "ancestor RW must precede leaf RO; args: {args:?}" + ); + for anc in &plan.ancestor_rw_binds { + let a = anc.to_string_lossy().to_string(); + let pos = args + .windows(3) + .position(|w| w[0] == "--bind" && w[1] == a && w[2] == a); + assert!(pos.is_some(), "missing RW bind for {a}: {args:?}"); + assert!(pos.unwrap() < leaf_pos.unwrap()); + } + let _ = std::fs::remove_dir_all(&root); + } + #[test] + #[serial(bwrap_env)] + #[cfg(target_os = "linux")] fn bwrap_reexec_uses_dev_bind() { let _g = EnvGuard::remove(BWRAP_ENV_VAR); let result = bwrap_reexec_command(&[], &[]); @@ -745,8 +893,8 @@ mod tests { "[profiles.wsempty]\nextends = \"workspace\"\n", ); assert!( - bwrap_reexec_for_profile(&ProfileName::Custom("wsempty".to_string()), &ws_ws).is_none(), - "non-devbox custom with no deny needs no re-exec" + bwrap_reexec_for_profile(&ProfileName::Custom("wsempty".to_string()), &ws_ws).is_some(), + "non-devbox custom must re-exec for direct-hook write-deny" ); let _ = std::fs::remove_dir_all(&ws_ws); } diff --git a/crates/codegen/xai-grok-sandbox/src/paths.rs b/crates/codegen/xai-grok-sandbox/src/paths.rs index 2d65310..bed8c52 100644 --- a/crates/codegen/xai-grok-sandbox/src/paths.rs +++ b/crates/codegen/xai-grok-sandbox/src/paths.rs @@ -1,8 +1,6 @@ //! Filesystem path tables for sandbox profiles. //! -//! Collects device files, temp directories, sensitive deny-paths, and -//! ecosystem (package-manager / toolchain) writable paths into helpers -//! consumed by [`super::profiles`]. +//! Collects device files, temp directories, and essential writable paths. use std::path::{Path, PathBuf}; diff --git a/crates/codegen/xai-grok-sandbox/src/profiles.rs b/crates/codegen/xai-grok-sandbox/src/profiles.rs index 0b72dd6..9dd8414 100644 --- a/crates/codegen/xai-grok-sandbox/src/profiles.rs +++ b/crates/codegen/xai-grok-sandbox/src/profiles.rs @@ -11,13 +11,15 @@ use std::path::{Path, PathBuf}; #[cfg(all(feature = "enforce", unix))] use crate::deny::{ - apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, effective_deny_paths, - partition_deny_entries, + apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, + apply_write_deny_paths_to_capability_set, effective_deny_paths, partition_deny_entries, }; +use crate::hook_write_deny::profile_hook_write_deny; use crate::paths::grok_home; #[cfg(all(feature = "enforce", unix))] use crate::paths::{DEVICE_DIRS, DEVICE_FILES}; use crate::paths::{essential_writable_paths, essential_writable_paths_minimal}; +use xai_grok_config::GlobalHookSource; /// A resolved sandbox profile ready to be converted to a `CapabilitySet`. #[derive(Debug, Clone)] @@ -30,12 +32,18 @@ pub struct SandboxProfile { pub read_write: Vec<PathBuf>, /// Paths denied entirely (overrides read_only/read_write) pub deny: Vec<PathBuf>, + /// Typed direct global hook sources (write-denied, still readable). + pub write_deny: Vec<GlobalHookSource>, /// Whether to grant read access to the entire filesystem by default pub default_read: bool, /// Whether child processes should have network blocked pub restrict_network: bool, } +fn resolve_write_deny(profile: &ProfileName) -> anyhow::Result<Vec<GlobalHookSource>> { + profile_hook_write_deny(profile) +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ProfileConfig { #[serde(default)] @@ -280,6 +288,27 @@ impl ProfileName { } } + // Direct global-hook write-deny (macOS Seatbelt; Linux via bwrap). + if !profile.write_deny.is_empty() { + let mut pairs: Vec<(PathBuf, bool)> = profile + .write_deny + .iter() + .map(|s| (s.path.clone(), s.is_dir())) + .collect(); + #[cfg(unix)] + { + let files = + xai_grok_config::validated_hook_json_files_for_sources(&profile.write_deny) + .map_err(|e| anyhow::anyhow!("hook JSON alias validation failed: {e}"))?; + for f in files { + if !pairs.iter().any(|(p, _)| p == &f) { + pairs.push((f, false)); + } + } + } + apply_write_deny_paths_to_capability_set(&mut caps, &pairs, &profile.read_write)?; + } + // Kernel deny (read+write): macOS Seatbelt rules; Linux via bwrap bind-over. // The effective deny set is the profile's own `deny` (custom profiles only; // built-ins carry an empty `deny`). An empty set means there is nothing to @@ -325,6 +354,7 @@ impl ProfileName { read_only: vec![], read_write: essential_writable_paths(workspace), deny: vec![], + write_deny: resolve_write_deny(self)?, default_read: true, restrict_network: false, }), @@ -363,6 +393,7 @@ impl ProfileName { read_only: vec![], read_write, deny: vec![], + write_deny: vec![], default_read: true, restrict_network: false, }) @@ -373,6 +404,7 @@ impl ProfileName { read_only: vec![], read_write: essential_writable_paths_minimal(), deny: vec![], + write_deny: resolve_write_deny(self)?, default_read: true, restrict_network: true, }), @@ -398,6 +430,7 @@ impl ProfileName { .chain(std::iter::once(home.join("Library"))) .filter(|p| p.exists()) .chain(std::iter::once(workspace.to_path_buf())) + .chain(std::iter::once(grok_home())) .collect(); Ok(SandboxProfile { @@ -405,6 +438,7 @@ impl ProfileName { read_only: system_read, read_write: essential_writable_paths(workspace), deny: vec![], + write_deny: resolve_write_deny(self)?, default_read: false, restrict_network: true, }) @@ -422,7 +456,7 @@ impl ProfileName { })?; // Start from the base profile if `extends` is set - let mut profile = if let Some(base_name) = &profile_config.extends { + let (base, mut profile) = if let Some(base_name) = &profile_config.extends { let base: ProfileName = base_name.parse().map_err(|e: String| { anyhow::anyhow!("Profile '{name}' extends invalid base: {e}") })?; @@ -438,10 +472,10 @@ impl ProfileName { cannot extend other custom profiles (only built-ins)" ); } - base.resolve(workspace, config)? + let resolved = base.resolve(workspace, config)?; + (base, resolved) } else { - // Default: start from workspace - Self::Workspace.resolve(workspace, config)? + (Self::Workspace, Self::Workspace.resolve(workspace, config)?) }; profile.name = name.clone(); @@ -466,6 +500,10 @@ impl ProfileName { profile.deny.push(PathBuf::from(path_str)); } + if matches!(base, Self::Devbox) { + profile.write_deny.clear(); + } + Ok(profile) } } @@ -528,8 +566,26 @@ mod tests { assert_eq!(p.to_string(), "my-custom"); } + /// Hosts with a retargetable `$GROK_HOME/hooks` symlink (fail-closed under + /// write-deny) cannot resolve enforcing profiles against the real home. + fn skip_if_host_hook_write_deny_unresolvable() -> bool { + if !crate::hook_write_deny::profile_enforces_hook_write_deny(&ProfileName::Workspace) { + return false; + } + match crate::hook_write_deny::resolve_hook_write_deny_snapshot() { + Ok(_) => false, + Err(e) => { + eprintln!("skipping profile resolve test: host hook write-deny unresolvable ({e})"); + true + } + } + } + #[test] fn built_in_network_restriction_values() { + if skip_if_host_hook_write_deny_unresolvable() { + return; + } let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig::default(); @@ -593,6 +649,9 @@ mod tests { #[test] fn custom_network_restriction_inherits_and_overrides_base() { + if skip_if_host_hook_write_deny_unresolvable() { + return; + } let workspace = std::env::current_dir().unwrap(); let config = network_inheritance_config(); @@ -611,6 +670,9 @@ mod tests { #[test] #[cfg(all(feature = "enforce", unix))] fn strict_allowlist_includes_run_and_var_when_present() { + if skip_if_host_hook_write_deny_unresolvable() { + return; + } // Regression: /run (resolv realpath) + /var (NSS/SSSD) when present. let workspace = std::env::temp_dir(); let profile = ProfileName::Strict @@ -636,6 +698,9 @@ mod tests { #[test] #[cfg(all(feature = "enforce", unix))] fn base_profile_capability_set_builds() { + if skip_if_host_hook_write_deny_unresolvable() { + return; + } // A base profile with no `deny` builds a CapabilitySet without erroring. let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig::default(); @@ -646,6 +711,9 @@ mod tests { #[test] #[cfg(all(feature = "enforce", unix))] fn custom_profile_from_config() { + if skip_if_host_hook_write_deny_unresolvable() { + return; + } let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig { profiles: HashMap::from([( @@ -901,6 +969,9 @@ read_write = ["/tmp/ci-artifacts"] #[test] #[cfg(all(feature = "enforce", unix))] fn strict_capability_set_builds_without_openable_dev_tty() { + if skip_if_host_hook_write_deny_unresolvable() { + return; + } let workspace = std::env::current_dir().unwrap(); let result = ProfileName::Strict.to_capability_set(&workspace); assert!( diff --git a/crates/codegen/xai-grok-sandbox/tests/deny_paths_e2e.rs b/crates/codegen/xai-grok-sandbox/tests/deny_paths_e2e.rs index 0ea90ec..29a9351 100644 --- a/crates/codegen/xai-grok-sandbox/tests/deny_paths_e2e.rs +++ b/crates/codegen/xai-grok-sandbox/tests/deny_paths_e2e.rs @@ -1,36 +1,38 @@ -//! E2E enforcement tests for kernel-enforced profile `deny` paths. -//! -//! Drives the GENERIC path-deny primitive via a custom sandbox profile whose -//! `deny` list names concrete files. `SandboxManager::apply` is process-wide and -//! irreversible, so kernel enforcement is verified in an isolated subprocess. -//! -//! On Linux, read-deny requires bwrap bind-over; the subprocess re-execs inside -//! bwrap when `bwrap` is available. macOS uses Seatbelt platform rules directly -//! via `SandboxManager::apply`. +//! E2E path-deny and Grok hook write-deny (subprocess; arm64-tagged). +//! Soft-skips when enforcement is unavailable; only +//! `SANDBOX_E2E_REQUIRE_ENFORCEMENT` hard-requires a usable backend. #![cfg(all(unix, feature = "enforce"))] use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; const SCENARIO_ENV: &str = "SANDBOX_E2E_SCENARIO"; const WORKSPACE_ENV: &str = "SANDBOX_E2E_WORKSPACE"; -/// Custom profile name, comma-joined deny targets, and comma-joined control -/// files, passed to the subprocess so one entry point drives every deny case -/// (exact paths and globs alike). +const GROK_HOME_ENV: &str = "SANDBOX_E2E_GROK_HOME"; +const HOME_ENV: &str = "SANDBOX_E2E_HOME"; const PROFILE_ENV: &str = "SANDBOX_E2E_PROFILE"; const TARGETS_ENV: &str = "SANDBOX_E2E_TARGETS"; const CONTROLS_ENV: &str = "SANDBOX_E2E_CONTROLS"; -/// Paths NOT present at apply time that match a deny glob; the macOS runtime -/// regex must deny creating them post-launch (the differentiator vs exact paths). const POSTLAUNCH_ENV: &str = "SANDBOX_E2E_POSTLAUNCH"; const MARKER: &str = "deny-paths-e2e-marker-9f3c1a"; +const REQUIRE_ENV: &str = "SANDBOX_E2E_REQUIRE_ENFORCEMENT"; + +fn apply_fixture_env(cmd: &mut Command, home: &Path, grok_home: &Path, workspace: &Path) { + cmd.env(WORKSPACE_ENV, workspace.as_os_str()) + .env(HOME_ENV, home.as_os_str()) + .env(GROK_HOME_ENV, grok_home.as_os_str()) + .env("HOME", home.as_os_str()) + .env("GROK_HOME", grok_home.as_os_str()); +} /// Re-invoke this test binary as a subprocess driving `profile` over `targets` /// (denied) and `controls` (must stay readable). `postlaunch` paths are created /// AFTER apply to exercise the macOS runtime-regex (post-launch) coverage. fn run_scenario( + home: &Path, + grok_home: &Path, workspace: &Path, profile: &str, targets: &[&str], @@ -38,9 +40,10 @@ fn run_scenario( postlaunch: &[&str], ) -> (std::process::ExitStatus, String) { let exe = std::env::current_exe().expect("current_exe"); - let output = Command::new(exe) + let mut cmd = Command::new(exe); + apply_fixture_env(&mut cmd, home, grok_home, workspace); + let output = cmd .env(SCENARIO_ENV, "block_deny") - .env(WORKSPACE_ENV, workspace.as_os_str()) .env(PROFILE_ENV, profile) .env(TARGETS_ENV, targets.join(",")) .env(CONTROLS_ENV, controls.join(",")) @@ -51,13 +54,82 @@ fn run_scenario( .arg("subprocess_entry") .output() .expect("failed to spawn subprocess"); - // All assertions read stderr; the subprocess prints only diagnostics there. ( output.status, String::from_utf8_lossy(&output.stderr).into_owned(), ) } +/// Re-invoke as a subprocess for the direct-hook write-deny scenarios. +fn run_hook_write_deny_scenario( + home: &Path, + grok_home: &Path, + workspace: &Path, + scenario: &str, +) -> (std::process::ExitStatus, String) { + let exe = std::env::current_exe().expect("current_exe"); + let mut cmd = Command::new(exe); + apply_fixture_env(&mut cmd, home, grok_home, workspace); + let output = cmd + .env(SCENARIO_ENV, scenario) + .arg("--ignored") + .arg("--exact") + .arg("--nocapture") + .arg("subprocess_entry") + .output() + .expect("failed to spawn subprocess"); + ( + output.status, + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +/// Soft-skip when the platform cannot enforce kernel denials. +/// Only `SANDBOX_E2E_REQUIRE_ENFORCEMENT` hard-requires enforcement; generic +/// CI/`GITHUB_ACTIONS` alone must not (remote arm64 may lack usable bwrap). +fn skip_if_enforcement_unavailable() -> bool { + let require = std::env::var(REQUIRE_ENV).is_ok(); + + let support = xai_grok_sandbox::SandboxManager::support_info(); + if !support.is_supported { + if require { + panic!( + "enforcement required ({REQUIRE_ENV}) but sandbox unsupported: {}", + support.details + ); + } + eprintln!("skipping: sandbox not supported ({})", support.details); + return true; + } + + #[cfg(target_os = "linux")] + if !bwrap_available() { + if require { + panic!( + "enforcement required ({REQUIRE_ENV}) but bwrap unavailable \ + (required for Linux path / hook write-deny)" + ); + } + eprintln!("skipping: bwrap not installed (required for Linux path / hook write-deny)"); + return true; + } + + false +} + +fn unique_temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "grok-sandbox-e2e-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dunce::canonicalize(&dir).expect("canonicalize temp dir") +} + /// Decode a comma-joined env list (empty/missing -> empty vec). fn list_from_env(key: &str) -> Vec<String> { std::env::var(key) @@ -80,6 +152,18 @@ fn is_permission_denied(e: &std::io::Error) -> bool { ) } +/// Unlink of a read-only bind-mounted leaf can return EBUSY (ResourceBusy) on +/// Linux bubblewrap rather than EACCES/EPERM — still an effective denial. +fn is_unlink_denied(e: &std::io::Error) -> bool { + is_permission_denied(e) || e.raw_os_error() == Some(libc::EBUSY) +} + +/// Rename of a RO bind-mount leaf/mountpoint can return EXDEV or EBUSY — still +/// an effective denial (no destination created). +fn is_rename_denied(e: &std::io::Error) -> bool { + is_permission_denied(e) || matches!(e.raw_os_error(), Some(libc::EXDEV) | Some(libc::EBUSY)) +} + /// Spawn a child command and `exit(1)` if its stdout exposes the secret MARKER. /// Asserts marker-absence rather than a non-zero exit: a root reader of the /// mode-000 placeholder gets empty output, which still means the path is shadowed. @@ -162,7 +246,8 @@ fn profile_from_env() -> xai_grok_sandbox::ProfileName { // ── Subprocess entry point ────────────────────────────────────────────── -/// `#[ignore]`d — only runs when invoked by the parent test via `run_scenario`. +/// `#[ignore]`d — only runs when invoked by the parent test via `run_scenario` +/// / `run_hook_write_deny_scenario`. #[test] #[ignore] fn subprocess_entry() { @@ -173,24 +258,46 @@ fn subprocess_entry() { let workspace = std::env::var(WORKSPACE_ENV).expect(WORKSPACE_ENV); let workspace = dunce::canonicalize(&workspace).expect("canonicalize workspace"); let workspace = workspace.as_path(); - let targets = list_from_env(TARGETS_ENV); - let controls = list_from_env(CONTROLS_ENV); + // Isolate HOME/GROK_HOME before any config OnceLock init. + let home = PathBuf::from(std::env::var(HOME_ENV).expect(HOME_ENV)); + let grok_home = PathBuf::from(std::env::var(GROK_HOME_ENV).expect(GROK_HOME_ENV)); + // SAFETY: isolated subprocess; set before sandbox/config first use. + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("GROK_HOME", &grok_home); + } + + match scenario.as_str() { + "block_deny" => subprocess_block_deny(workspace), + "hook_write_deny" => subprocess_hook_write_deny(workspace, /* first_run */ false), + "hook_write_deny_first_run" => { + subprocess_hook_write_deny(workspace, /* first_run */ true) + } + "hook_write_deny_marker_spoof" => subprocess_hook_write_deny_marker_spoof(&grok_home), + other => { + eprintln!("unknown scenario: {other}"); + std::process::exit(99); + } + } +} + +fn subprocess_profile_and_bwrap_reexec(profile: &xai_grok_sandbox::ProfileName, workspace: &Path) { #[cfg(target_os = "linux")] { if !xai_grok_sandbox::is_inside_bwrap() { // Drive the REAL routing the shell uses at startup — computing the - // custom profile's deny set (exact paths AND launch-time glob - // expansion), building placeholders, and failing closed on a partial - // bind — rather than hand-rolling a single-path `bwrap_reexec_command`. - match xai_grok_sandbox::bwrap_reexec_for_profile(&profile_from_env(), workspace) { + // profile's deny / write-deny set, building the plan, and failing + // closed on a partial bind — rather than hand-rolling a single-path + // `bwrap_reexec_command`. + match xai_grok_sandbox::bwrap_reexec_for_profile(profile, workspace) { Some(mut cmd) => { use std::os::unix::process::CommandExt; let err = cmd.exec(); // returns only if exec failed eprintln!("bwrap re-exec failed: {err}"); std::process::exit(2); } - // Outside bwrap with no command means the read-deny set could not + // Outside bwrap with no command means the deny set could not // be secured. The shell fails closed here; mirror that. None => { eprintln!("FAIL: bwrap_reexec_for_profile returned None outside bwrap"); @@ -199,89 +306,396 @@ fn subprocess_entry() { } } } + #[cfg(not(target_os = "linux"))] + { + let _ = (profile, workspace); + } +} - match scenario.as_str() { - "block_deny" => { - let mut sandbox = xai_grok_sandbox::SandboxManager::new(profile_from_env(), workspace); - if let Err(e) = sandbox.apply(workspace) { - eprintln!("sandbox apply failed: {e}"); - std::process::exit(3); - } - if !sandbox.is_applied() { - eprintln!("sandbox was not applied (unsupported platform?)"); - std::process::exit(4); - } +fn subprocess_block_deny(workspace: &Path) { + let targets = list_from_env(TARGETS_ENV); + let controls = list_from_env(CONTROLS_ENV); + let profile = profile_from_env(); + subprocess_profile_and_bwrap_reexec(&profile, workspace); - // Each denied target must be read-, write-, and rename-denied — via the - // read_file tool (in-process), `bash`/`grep` (cat child), and the shell - // a subagent uses (sh -c child). Targets exercise nested glob matches - // (`sub/dir/key.pem`) and the denied-directory (subpath) path alike. - for rel in &targets { - let path = workspace.join(rel); - assert_read_blocked(rel, &path); - assert_write_denied(rel, &path); - assert_rename_bypass_blocked(rel, &path, workspace); - } + let mut sandbox = xai_grok_sandbox::SandboxManager::new(profile, workspace); + if let Err(e) = sandbox.apply(workspace) { + eprintln!("sandbox apply failed: {e}"); + std::process::exit(3); + } + if !sandbox.is_applied() { + eprintln!("sandbox was not applied (unsupported platform?)"); + std::process::exit(4); + } - // Non-denied control files (incl. a sibling of a glob match) stay readable. - for rel in &controls { - match fs::read_to_string(workspace.join(rel)) { - Ok(c) if c.contains("hello") => eprintln!("OK: {rel} control readable"), - Ok(_) => { - eprintln!("FAIL: control {rel} readable but missing marker"); - std::process::exit(1); - } - Err(e) => { - eprintln!("FAIL: control {rel} should stay readable: {e}"); - std::process::exit(1); - } - } - } + for rel in &targets { + let path = workspace.join(rel); + assert_read_blocked(rel, &path); + assert_write_denied(rel, &path); + assert_rename_bypass_blocked(rel, &path, workspace); + } - // macOS-only: the runtime regex denies paths that match a glob even - // when created AFTER apply — the differentiator vs the exact-path flow - // (and the macOS-airtight half of the documented asymmetry). On Linux - // post-launch matches are best-effort and NOT covered, so skip there. - #[cfg(target_os = "macos")] - for rel in list_from_env(POSTLAUNCH_ENV) { - match fs::write(workspace.join(&rel), MARKER) { - Err(e) if is_permission_denied(&e) => { - eprintln!("OK: {rel} post-launch write denied") - } - Err(e) => { - eprintln!("FAIL: unexpected {rel} post-launch write error: {e}"); - std::process::exit(1); - } - Ok(()) => { - eprintln!("FAIL: {rel} post-launch matching path was writable"); - std::process::exit(1); - } - } + for rel in &controls { + match fs::read_to_string(workspace.join(rel)) { + Ok(c) if c.contains("hello") => eprintln!("OK: {rel} control readable"), + Ok(_) => { + eprintln!("FAIL: control {rel} readable but missing marker"); + std::process::exit(1); } - // A NON-matching post-launch path must still be writable — proves the - // denial above is the glob, not a blanket workspace write-deny. - #[cfg(target_os = "macos")] - if !list_from_env(POSTLAUNCH_ENV).is_empty() { - match fs::write(workspace.join("late-control.txt"), "hello") { - Ok(()) => eprintln!("OK: post-launch control writable"), - Err(e) => { - eprintln!("FAIL: non-matching post-launch path should be writable: {e}"); - std::process::exit(1); - } - } + Err(e) => { + eprintln!("FAIL: control {rel} should stay readable: {e}"); + std::process::exit(1); } - - std::process::exit(0); } - other => { - eprintln!("unknown scenario: {other}"); - std::process::exit(99); + } + + #[cfg(target_os = "macos")] + for rel in list_from_env(POSTLAUNCH_ENV) { + match fs::write(workspace.join(&rel), MARKER) { + Err(e) if is_permission_denied(&e) => { + eprintln!("OK: {rel} post-launch write denied") + } + Err(e) => { + eprintln!("FAIL: unexpected {rel} post-launch write error: {e}"); + std::process::exit(1); + } + Ok(()) => { + eprintln!("FAIL: {rel} post-launch matching path was writable"); + std::process::exit(1); + } + } + } + #[cfg(target_os = "macos")] + if !list_from_env(POSTLAUNCH_ENV).is_empty() { + match fs::write(workspace.join("late-control.txt"), "hello") { + Ok(()) => eprintln!("OK: post-launch control writable"), + Err(e) => { + eprintln!("FAIL: non-matching post-launch path should be writable: {e}"); + std::process::exit(1); + } + } + } + + std::process::exit(0); +} + +/// Assert a path cannot be created via `create_dir` (mkdir denied). +fn assert_mkdir_denied(label: &str, path: &Path) { + match fs::create_dir(path) { + Err(e) if is_permission_denied(&e) => eprintln!("OK: {label} mkdir denied"), + Err(e) => { + eprintln!("FAIL: unexpected {label} mkdir error: {e}"); + std::process::exit(1); + } + Ok(()) => { + eprintln!("FAIL: {label} mkdir was permitted"); + let _ = fs::remove_dir(path); + std::process::exit(1); } } } +/// Assert a path cannot be unlinked. +fn assert_unlink_denied(label: &str, path: &Path) { + match fs::remove_file(path) { + Err(e) if is_unlink_denied(&e) => eprintln!("OK: {label} unlink denied"), + other => { + eprintln!("FAIL: {label} unlink expected denial, got {other:?}"); + std::process::exit(1); + } + } +} + +/// Assert a rename of `from` out of the deny set fails. +fn assert_rename_denied(label: &str, from: &Path, to: &Path) { + match fs::rename(from, to) { + Err(e) if is_rename_denied(&e) => eprintln!("OK: {label} rename denied"), + other => { + eprintln!("FAIL: {label} rename expected denial, got {other:?}"); + std::process::exit(1); + } + } +} + +/// Assert a non-denied sibling path is writable. +fn assert_write_ok(label: &str, path: &Path) { + match fs::write(path, "ok") { + Ok(()) => eprintln!("OK: {label} writable"), + Err(e) => { + eprintln!("FAIL: {label} should be writable: {e}"); + std::process::exit(1); + } + } +} + +/// Marker spoof: claim to be inside bwrap without real RO mounts — verify must fail. +/// Linux-only (verify is a no-op on macOS). Isolated subprocess; no shared env mutation. +fn subprocess_hook_write_deny_marker_spoof(_grok_home: &Path) { + #[cfg(not(target_os = "linux"))] + { + eprintln!("OK: marker spoof N/A on non-linux"); + std::process::exit(0); + } + #[cfg(target_os = "linux")] + { + // Fixture already has hooks/ + hooks-paths from parent. + // SAFETY: isolated subprocess. + unsafe { + std::env::set_var("__GROK_INSIDE_BWRAP", "1"); + } + match xai_grok_sandbox::verify_hook_write_deny_enforced() { + Ok(()) => { + eprintln!("FAIL: marker alone must not satisfy write-deny verification"); + std::process::exit(1); + } + Err(msg) => { + if msg.contains("read-only") + || msg.contains("NotReadOnly") + || msg.contains("hook write-deny") + || msg.contains("effectively read-only") + { + eprintln!("OK: marker spoof refused ({msg})"); + std::process::exit(0); + } + eprintln!("FAIL: unexpected verify error: {msg}"); + std::process::exit(1); + } + } + } +} + +/// Workspace-profile Grok-owned hook write-deny probes (existing sources + first-run). +fn subprocess_hook_write_deny(workspace: &Path, first_run: bool) { + let home = PathBuf::from(std::env::var(GROK_HOME_ENV).expect(GROK_HOME_ENV)); + + let profile = xai_grok_sandbox::ProfileName::Workspace; + subprocess_profile_and_bwrap_reexec(&profile, workspace); + + let mut sandbox = xai_grok_sandbox::SandboxManager::new(profile, workspace); + if let Err(e) = sandbox.apply(workspace) { + eprintln!("sandbox apply failed: {e}"); + std::process::exit(3); + } + // Seatbelt is the macOS enforcement path; on Linux the write-denies are + // primarily the bwrap ro-binds established above. + #[cfg(target_os = "macos")] + if !sandbox.is_applied() { + eprintln!("sandbox was not applied"); + std::process::exit(4); + } + + let hooks_dir = home.join("hooks"); + let hooks_paths = home.join("hooks-paths"); + + if first_run { + // Fixed slots are ensured as real host paths before apply; they exist + // and must be write-denied (not private placeholders). + if !hooks_dir.is_dir() { + eprintln!("FAIL: first-run expected real hooks dir to be ensured"); + std::process::exit(1); + } + if !hooks_paths.is_file() { + eprintln!("FAIL: first-run expected real hooks-paths file to be ensured"); + std::process::exit(1); + } + assert_write_denied("hooks-paths (first-run)", &hooks_paths); + assert_mkdir_denied("hooks nested (first-run)", &hooks_dir.join("nested")); + assert_write_denied( + "hooks nested file (first-run)", + &hooks_dir.join("planted.json"), + ); + eprintln!("OK: first-run Grok hook slots denied"); + } else { + // Existing hook content stays readable. + let keep = hooks_dir.join("keep.json"); + match fs::read_to_string(&keep) { + Ok(c) if c.contains("keep-me") => eprintln!("OK: hooks readable"), + other => { + eprintln!("FAIL: expected readable hook, got {other:?}"); + std::process::exit(1); + } + } + + assert_write_denied("hooks file", &hooks_dir.join("planted.json")); + assert_write_denied("hooks-paths", &hooks_paths); + let dynamic = home.join("sessions").join("extra-hooks"); + assert_write_denied("dynamic target", &dynamic.join("x.json")); + + assert_unlink_denied("hooks-paths", &hooks_paths); + assert_rename_denied("hooks", &keep, &home.join("keep.exfil")); + assert_mkdir_denied("hooks nested dir", &hooks_dir.join("nested-deny")); + + // Parent-rename bypass: renaming `sessions` must fail; leaf stays protected. + let sessions = home.join("sessions"); + let sessions_old = home.join("sessions-old"); + match fs::rename(&sessions, &sessions_old) { + Err(e) if is_rename_denied(&e) => { + eprintln!("OK: parent rename denied"); + } + other => { + // If rename somehow succeeded, the lexical target must still + // not be a writable fresh tree — but success is a hard fail. + let _ = fs::rename(&sessions_old, &sessions); + eprintln!("FAIL: parent rename expected denial, got {other:?}"); + std::process::exit(1); + } + } + // Sibling under sessions still writable (ancestor pin is node-only on macOS; + // on Linux the sessions dir is a RW mountpoint so creates inside still work). + assert_write_ok( + "sessions sibling", + &sessions.join(format!("runtime-{}.lock", std::process::id())), + ); + + // Configured source under workspace (writable grant root): parent rename + // denied; sibling under the same parent remains writable. + let ws_parent = workspace.join("extra-parent"); + let ws_hooks = ws_parent.join("vendor-hooks"); + if ws_hooks.is_dir() { + assert_write_denied("ws configured", &ws_hooks.join("x.json")); + let renamed = workspace.join("extra-parent-old"); + match fs::rename(&ws_parent, &renamed) { + Err(e) if is_rename_denied(&e) => { + eprintln!("OK: workspace parent rename denied"); + } + other => { + let _ = fs::rename(&renamed, &ws_parent); + eprintln!("FAIL: workspace parent rename expected denial, got {other:?}"); + std::process::exit(1); + } + } + assert_write_ok( + "workspace sibling under parent", + &ws_parent.join(format!("sib-{}.lock", std::process::id())), + ); + } + } + + // Nested userns: exploit must run *inside* unshare; seccomp must make + // unshare fail (non-success). Host hooks must stay unchanged. + #[cfg(target_os = "linux")] + if !first_run { + let planted = hooks_dir.join("userns-plant.json"); + let alias = home.join("userns-alias"); + let inner = format!( + "mkdir -p '{alias}' && mount --bind '{home}' '{alias}' && \ + echo nested > '{alias}/hooks/userns-plant.json'", + alias = alias.display(), + home = home.display(), + ); + let sh = format!("unshare -Ur -m sh -c {inner:?}"); + // Confirm `unshare` exists so failure is not a missing binary. + let which = Command::new("sh") + .args(["-c", "command -v unshare"]) + .output() + .expect("command -v unshare"); + if !which.status.success() { + eprintln!("FAIL: unshare binary missing; cannot assert seccomp denial"); + std::process::exit(1); + } + let out = Command::new("sh") + .args(["-c", &sh]) + .output() + .expect("spawn unshare probe"); + if out.status.success() { + eprintln!( + "FAIL: unshare exploit succeeded (seccomp should EPERM); stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + std::process::exit(1); + } + let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); + // Kernel/seccomp typically surfaces EPERM; also accept "not permitted". + if !(err.contains("not permitted") + || err.contains("operation not permitted") + || err.contains("eperm") + || out.status.code() == Some(1)) + { + eprintln!( + "FAIL: expected seccomp EPERM-style denial, got status={:?} stderr={err}", + out.status + ); + std::process::exit(1); + } + if planted.exists() + && let Ok(c) = fs::read_to_string(&planted) + && c.contains("nested") + { + eprintln!("FAIL: nested userns rewrote host hooks"); + std::process::exit(1); + } + eprintln!("OK: nested userns did not rewrite hooks"); + } + + // Root-only: even if CAP_SYS_ADMIN were present, --cap-drop ALL should deny + // mount; skip when not uid 0. + #[cfg(target_os = "linux")] + if !first_run { + let uid = unsafe { libc::getuid() }; + if uid == 0 { + let m = Command::new("mount") + .args([ + "-o", + "bind", + "/", + &home.join("cap-drop-probe").display().to_string(), + ]) + .output(); + if let Ok(o) = m + && o.status.success() + { + eprintln!("FAIL: mount succeeded despite --cap-drop ALL"); + std::process::exit(1); + } + eprintln!("OK: cap-drop mount denied as root"); + } else { + eprintln!("OK: cap-drop root probe skipped (non-root)"); + } + } + + // Parent grants remain creatable: Grok runtime sibling, workspace, temp. + assert_write_ok( + "grok runtime sibling", + &home.join(format!("leader-{}.lock", std::process::id())), + ); + assert_write_ok("workspace sibling", &workspace.join("fresh.rs")); + let tmp = std::env::temp_dir().join(format!("hook-wd-tmp-{}", std::process::id())); + assert_write_ok("temp sibling", &tmp); + let _ = fs::remove_file(&tmp); + + eprintln!("OK: hook write-deny e2e passed"); + std::process::exit(0); +} + // ── Parent test cases ─────────────────────────────────────────────────── +/// Create isolated HOME + GROK_HOME fixture dirs for a scenario. +fn fixture_homes( + tag: &str, +) -> ( + PathBuf, + PathBuf, + PathBuf, + TempDirGuard, + TempDirGuard, + TempDirGuard, +) { + let home = unique_temp_dir(&format!("{tag}-home")); + let grok = unique_temp_dir(&format!("{tag}-grok")); + let workspace = unique_temp_dir(&format!("{tag}-ws")); + // Empty global sandbox config under fixture GROK_HOME so generic tests do + // not inherit the developer/runner's ~/.grok/sandbox.toml. + fs::write(grok.join("sandbox.toml"), "").expect("empty global sandbox.toml"); + ( + home.clone(), + grok.clone(), + workspace.clone(), + TempDirGuard(home), + TempDirGuard(grok), + TempDirGuard(workspace), + ) +} + /// Drive one deny case end-to-end: define a custom profile whose `deny` list is /// `deny_entries` (exact paths and/or globs), create each `target` (with the /// MARKER) and each `control` (readable), then assert in an isolated subprocess @@ -295,46 +709,12 @@ fn run_deny_case( controls: &[&str], postlaunch: &[&str], ) { - // When set, missing prerequisites must FAIL loudly instead of skipping, so a - // CI lane can guarantee the deny enforcement is actually exercised. - let require = std::env::var("SANDBOX_E2E_REQUIRE_ENFORCEMENT").is_ok(); - - let support = xai_grok_sandbox::SandboxManager::support_info(); - if !support.is_supported { - if require { - panic!( - "SANDBOX_E2E_REQUIRE_ENFORCEMENT set but sandbox unsupported: {}", - support.details - ); - } - eprintln!("skipping: sandbox not supported ({})", support.details); + if skip_if_enforcement_unavailable() { return; } - #[cfg(target_os = "linux")] - if !bwrap_available() { - if require { - panic!( - "SANDBOX_E2E_REQUIRE_ENFORCEMENT set but bwrap unavailable (required for Linux read-deny)" - ); - } - eprintln!("skipping: bwrap not installed (required for Linux read-deny)"); - return; - } + let (home, grok, tmp, _ch, _cg, _cw) = fixture_homes(tag); - let tmp = std::env::temp_dir().join(format!( - "grok-sandbox-e2e-{tag}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&tmp).expect("create temp workspace"); - let tmp = dunce::canonicalize(&tmp).expect("canonicalize temp workspace"); - let _cleanup = TempDirGuard(tmp.clone()); - - // Define the custom profile whose `deny` list holds the entries under test. let deny_list = deny_entries .iter() .map(|p| format!("\"{p}\"")) @@ -347,9 +727,11 @@ fn run_deny_case( ) .expect("write sandbox.toml"); - // Create each denied target with the MARKER (parents created as needed, e.g. - // `sub/dir/` for a nested glob match, `secretdir/` for a denied directory) - // plus each readable control. + // Ensure Grok fixed slots exist so workspace-based custom profiles can + // resolve hook write-deny without depending on the real user tree. + fs::create_dir_all(grok.join("hooks")).expect("mkdir fixture hooks"); + fs::write(grok.join("hooks-paths"), b"").expect("write fixture hooks-paths"); + for rel in targets { let path = tmp.join(rel); if let Some(parent) = path.parent() { @@ -365,7 +747,7 @@ fn run_deny_case( fs::write(&path, "hello workspace").expect("write control"); } - let (status, stderr) = run_scenario(&tmp, profile, targets, controls, postlaunch); + let (status, stderr) = run_scenario(&home, &grok, &tmp, profile, targets, controls, postlaunch); assert!( status.success(), "[{tag}] custom-profile deny should block read/write/rename\nstderr: {stderr}" @@ -390,8 +772,6 @@ fn run_deny_case( "[{tag}] expected non-denied control '{rel}' to stay readable\nstderr: {stderr}" ); } - // The post-launch (runtime-regex) coverage is macOS-only; Linux best-effort - // expansion does not cover files created after launch. #[cfg(target_os = "macos")] for rel in postlaunch { assert!( @@ -406,28 +786,32 @@ fn run_deny_case( "[{tag}] expected non-matching post-launch path to stay writable\nstderr: {stderr}" ); } + + // Generic harness must not leave vendor stubs under fixture HOME. + assert!( + !home.join(".claude").exists(), + "generic deny must not create ~/.claude under fixture HOME" + ); + assert!( + !home.join(".cursor").exists(), + "generic deny must not create ~/.cursor under fixture HOME" + ); } #[test] fn deny_exact_paths_block_read_write_rename() { - // Exact-path entries: two files plus a directory (exercised via a file inside - // it), covering the literal-file and the subpath / Linux dir-placeholder paths. run_deny_case( "exact", "denytest", &[".env", "src/server.pem", "secretdir"], &[".env", "src/server.pem", "secretdir/inner.pem"], &["readable.txt"], - &[], // exact paths have no runtime/post-launch coverage to assert + &[], ); } #[test] fn deny_globs_block_read_write_rename() { - // Glob entries exercising: nested `*.pem`, a `.env` at root AND nested, and a - // trailing-`**` prefix dir. The control inside a matched directory - // (`sub/dir/keep.txt`) proves the glob denies only matches, not the whole tree. - // `postlaunch` (`late.pem`) pins the macOS runtime-regex post-launch coverage. run_deny_case( "glob", "denyglob", @@ -438,6 +822,232 @@ fn deny_globs_block_read_write_rename() { ); } +/// Hard-linked registry file must refuse sandbox startup (writable alias). +#[test] +fn hardlinked_hooks_paths_refuses_startup() { + if skip_if_enforcement_unavailable() { + return; + } + let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-hl"); + fs::create_dir_all(grok.join("hooks")).unwrap(); + let reg = grok.join("hooks-paths"); + let alias = grok.join("hooks-paths-alias"); + fs::write(®, b"").unwrap(); + fs::hard_link(®, &alias).unwrap(); + + let (status, stderr) = + run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny"); + assert!( + !status.success(), + "hard-linked hooks-paths must refuse startup\nstderr: {stderr}" + ); + // Plan/materialization path should surface hard-link or identity failure. + assert!( + stderr.contains("hard-link") + || stderr.contains("HardLink") + || stderr.contains("hook write-deny") + || stderr.contains("nlink"), + "expected hard-link refusal signal\nstderr: {stderr}" + ); +} + +/// Workspace profile: Grok-owned direct hook sources are write-denied but readable; +/// create / overwrite / unlink / rename / mkdir fail; absolute hooks-paths +/// targets are denied; parent rename is blocked; Grok/CWD/temp siblings stay writable. +#[test] +fn workspace_protects_direct_hook_sources() { + if skip_if_enforcement_unavailable() { + return; + } + + let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook"); + + fs::create_dir_all(grok.join("hooks")).expect("mkdir hooks"); + fs::write(grok.join("hooks").join("keep.json"), r#"{"keep-me":true}"#) + .expect("write keep.json"); + let dynamic = grok.join("sessions").join("extra-hooks"); + fs::create_dir_all(&dynamic).expect("mkdir dynamic hooks target"); + fs::write(dynamic.join("x.json"), r#"{"x":1}"#).expect("write dynamic hook"); + // Configured target under workspace (absolute) for grant-root ancestor pins. + let ws_hooks = workspace.join("extra-parent").join("vendor-hooks"); + fs::create_dir_all(&ws_hooks).expect("mkdir ws vendor hooks"); + fs::write(ws_hooks.join("x.json"), r#"{"x":1}"#).expect("write ws hook"); + fs::write( + grok.join("hooks-paths"), + format!("{}\n{}\n", dynamic.display(), ws_hooks.display()), + ) + .expect("write hooks-paths"); + + let (status, stderr) = + run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny"); + assert!( + status.success(), + "hook write-deny e2e failed: {status}\nstderr: {stderr}" + ); + assert!( + stderr.contains("OK: hook write-deny e2e passed"), + "missing pass marker\nstderr: {stderr}" + ); + for needle in [ + "OK: hooks readable", + "OK: hooks file write denied", + "OK: hooks-paths write denied", + "OK: dynamic target write denied", + "OK: hooks-paths unlink denied", + "OK: hooks rename denied", + "OK: hooks nested dir mkdir denied", + "OK: parent rename denied", + "OK: sessions sibling writable", + "OK: workspace parent rename denied", + "OK: workspace sibling under parent writable", + "OK: grok runtime sibling writable", + "OK: workspace sibling writable", + "OK: temp sibling writable", + ] { + assert!( + stderr.contains(needle), + "expected '{needle}'\nstderr: {stderr}" + ); + } + #[cfg(target_os = "linux")] + assert!( + stderr.contains("OK: nested userns did not rewrite hooks"), + "expected nested userns check\nstderr: {stderr}" + ); +} + +/// Hard-linked or symlinked discovery JSON under hooks/ must refuse startup. +#[test] +fn hardlinked_hooks_json_refuses_startup() { + if skip_if_enforcement_unavailable() { + return; + } + let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-json-hl"); + fs::create_dir_all(grok.join("hooks")).unwrap(); + fs::write(grok.join("hooks-paths"), b"").unwrap(); + let active = grok.join("hooks").join("active.json"); + let alias = grok.join("hooks").join("active-alias.json"); + fs::write(&active, r#"{"hooks":{}}"#).unwrap(); + fs::hard_link(&active, &alias).unwrap(); + + let (status, stderr) = + run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny"); + assert!( + !status.success(), + "hard-linked hooks JSON must refuse startup\nstderr: {stderr}" + ); +} + +#[test] +#[cfg(unix)] +fn symlinked_hooks_json_refuses_startup() { + if skip_if_enforcement_unavailable() { + return; + } + let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-json-sym"); + fs::create_dir_all(grok.join("hooks")).unwrap(); + fs::write(grok.join("hooks-paths"), b"").unwrap(); + let real = grok.join("real-active.json"); + let active = grok.join("hooks").join("active.json"); + fs::write(&real, r#"{"hooks":{}}"#).unwrap(); + std::os::unix::fs::symlink(&real, &active).unwrap(); + + let (status, stderr) = + run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny"); + assert!( + !status.success(), + "symlinked hooks JSON must refuse startup\nstderr: {stderr}" + ); +} + +/// First-run: missing fixed slots are created as real Grok state before apply, +/// then write-denied. Parent asserts post-exit host tree is valid (no vendor stubs). +#[test] +fn workspace_protects_direct_hook_sources_first_run() { + if skip_if_enforcement_unavailable() { + return; + } + + let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-fr"); + // Intentionally leave hooks/ and hooks-paths absent (first-run ensure path). + + let (status, stderr) = + run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny_first_run"); + assert!( + status.success(), + "hook write-deny first-run e2e failed: {status}\nstderr: {stderr}" + ); + assert!( + stderr.contains("OK: hook write-deny e2e passed"), + "missing pass marker\nstderr: {stderr}" + ); + for needle in [ + "OK: first-run Grok hook slots denied", + "OK: hooks-paths (first-run) write denied", + "OK: hooks nested (first-run) mkdir denied", + "OK: hooks nested file (first-run) write denied", + "OK: grok runtime sibling writable", + "OK: workspace sibling writable", + "OK: temp sibling writable", + ] { + assert!( + stderr.contains(needle), + "expected '{needle}'\nstderr: {stderr}" + ); + } + + // Post-exit host: Grok slots exist and are valid; no vendor artifacts. + assert!( + grok.join("hooks").is_dir(), + "post-exit: hooks dir must exist as a real directory" + ); + assert!( + grok.join("hooks-paths").is_file(), + "post-exit: hooks-paths must exist as a real file" + ); + assert_eq!( + fs::read(grok.join("hooks-paths")).expect("read hooks-paths"), + b"", + "post-exit: first-run hooks-paths must be empty" + ); + assert!( + !home.join(".claude").exists(), + "post-exit: must not create ~/.claude" + ); + assert!( + !home.join(".cursor").exists(), + "post-exit: must not create ~/.cursor" + ); +} + +/// Marker spoof in an isolated subprocess (no env-mutating unit test). +#[test] +fn hook_write_deny_refuses_marker_spoof() { + // Always runnable on Linux unit path via soft-skip only when not requiring + // kernel enforcement — marker spoof only needs the verify API. + #[cfg(not(target_os = "linux"))] + { + return; + } + #[cfg(target_os = "linux")] + { + let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-spoof"); + fs::create_dir_all(grok.join("hooks")).unwrap(); + fs::write(grok.join("hooks").join("x.json"), b"{}").unwrap(); + fs::write(grok.join("hooks-paths"), b"").unwrap(); + let (status, stderr) = + run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny_marker_spoof"); + assert!( + status.success(), + "marker spoof e2e failed: {status}\nstderr: {stderr}" + ); + assert!( + stderr.contains("OK: marker spoof refused"), + "expected spoof refusal\nstderr: {stderr}" + ); + } +} + struct TempDirGuard(std::path::PathBuf); impl Drop for TempDirGuard { diff --git a/crates/codegen/xai-grok-shared/src/ui_config.rs b/crates/codegen/xai-grok-shared/src/ui_config.rs index 1b5f6c7..c005b2f 100644 --- a/crates/codegen/xai-grok-shared/src/ui_config.rs +++ b/crates/codegen/xai-grok-shared/src/ui_config.rs @@ -92,6 +92,11 @@ pub struct UiConfig { /// `[voice].language` for the session. #[serde(default, skip_serializing_if = "Option::is_none")] pub voice_stt_language: Option<String>, + /// Whether the Ctrl+Space / F8 voice-dictation shortcut is active. Written + /// by the settings modal; unset defaults to `true` (shortcut on). When + /// `false` the chord is ignored — `/voice` still starts dictation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub voice_keybind_enabled: Option<bool>, /// When `true`, registers `Ctrl+R` (while scrollback is focused) to toggle /// terminal mouse reporting (mouse capture) so users can hand selection back /// to the terminal for native click-drag copy/paste. Opt-in only; unset/false @@ -263,6 +268,7 @@ impl Default for UiConfig { hunk_tracker_mode: None, voice_capture_mode: None, voice_stt_language: None, + voice_keybind_enabled: None, mouse_reporting_toggle: None, remember_tool_approvals: None, cancel_subagents_on_turn_cancel: None, diff --git a/crates/codegen/xai-grok-shell/CHANGELOG.md b/crates/codegen/xai-grok-shell/CHANGELOG.md index 2887b79..8318cc2 100644 --- a/crates/codegen/xai-grok-shell/CHANGELOG.md +++ b/crates/codegen/xai-grok-shell/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +# 0.2.111 — 2026-07-22 + +## Features + +- Users can now disable image generation and video generation tools (and their slash commands) via config.toml or environment variables. +- `/session-info` now displays whether the session uses OAuth or an API key and where to manage the account. +- You can now run `grok doctor fix` commands directly from inside the TUI instead of only from the CLI. + +## Bug Fixes + +- **`!cmd` commands** now allow up to one hour before timing out. +- **npm package** now installs the native binary under `$GROK_HOME/bin` (honoring the same override as the Rust CLI). +- **Startup warnings** now point to `/doctor` for details and fixes. +- **Dashboard hover and clicks** no longer miss the gaps between items in wide mode. +- **Shift/Alt+Enter** now inserts a newline while editing a queued prompt. +- **Queued prompt edits** under combine mode no longer lose changes due to premature hold release. +- Forking a session that used compaction no longer causes later rewinds to fail with missing checkpoint errors. +- When a permission prompt appears while viewing scrollback, focus now correctly moves to the prompt so you can answer. +- Pressing Esc once now cancels the current agent turn (except in fullscreen vim scrollback mode). +- Grok now automatically stops a turn that keeps repeating the exact same tool call many times in a row. +- Configs using either spelling of the workspace teleport disable flag now load and save correctly. +- Background subagent completion messages no longer leak into unrelated sessions when multiple sessions are active. +- When the auto-permission classifier times out or fails, Grok now shows a normal permission prompt instead of silently denying. +- **Managed MCP tools** no longer time out prematurely on slow operations like Notion updates. + +## Performance + +- Voice dictation on macOS now uses less memory by running capture in a temporary helper process. + + # 0.2.110 — 2026-07-21 ## Features diff --git a/crates/codegen/xai-grok-shell/Cargo.toml b/crates/codegen/xai-grok-shell/Cargo.toml index 7860b54..fce5aa6 100644 --- a/crates/codegen/xai-grok-shell/Cargo.toml +++ b/crates/codegen/xai-grok-shell/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-shell" -version = "0.2.110" +version = "0.2.111" edition.workspace = true [features] diff --git a/crates/codegen/xai-grok-shell/README.md b/crates/codegen/xai-grok-shell/README.md index 493e5ac..f77fd02 100644 --- a/crates/codegen/xai-grok-shell/README.md +++ b/crates/codegen/xai-grok-shell/README.md @@ -559,7 +559,7 @@ grok -p "Your prompt here" | `-p, --single <PROMPT>` | The prompt to send (required) | | `-m, --model <MODEL>` | Model to use (e.g., `grok-build`) | | `-s, --session-id <ID>` | Create or resume a headless session with this ID | -| `-r, --resume <ID>` | Resume an existing session (errors if not found) | +| `-r, --resume <ID_OR_TITLE>` | Resume an existing session by ID, or by title for the current directory, ignoring letter case (a sole explicitly renamed title wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values are always treated as IDs) | | `-c, --continue` | Continue the most recent session in current directory | | `--cwd <PATH>` | Working directory | | `--output-format <FMT>` | Output format: `plain`, `json`, `streaming-json` | diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.111.json b/crates/codegen/xai-grok-shell/changelogs/0.2.111.json new file mode 100644 index 0000000..d38ea0e --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.111.json @@ -0,0 +1,92 @@ +[ + { + "category": "fixes", + "description": "**`!cmd` commands** now allow up to one hour before timing out.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**npm package** now installs the native binary under `$GROK_HOME/bin` (honoring the same override as the Rust CLI).", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Startup warnings** now point to `/doctor` for details and fixes.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Dashboard hover and clicks** no longer miss the gaps between items in wide mode.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Shift/Alt+Enter** now inserts a newline while editing a queued prompt.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Queued prompt edits** under combine mode no longer lose changes due to premature hold release.", + "breaking_change": false + }, + { + "category": "features", + "description": "Users can now disable image generation and video generation tools (and their slash commands) via config.toml or environment variables.", + "breaking_change": false + }, + { + "category": "features", + "description": "`/session-info` now displays whether the session uses OAuth or an API key and where to manage the account.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "Forking a session that used compaction no longer causes later rewinds to fail with missing checkpoint errors.", + "breaking_change": false + }, + { + "category": "features", + "description": "You can now run `grok doctor fix` commands directly from inside the TUI instead of only from the CLI.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "When a permission prompt appears while viewing scrollback, focus now correctly moves to the prompt so you can answer.", + "breaking_change": false + }, + { + "category": "performance", + "description": "Voice dictation on macOS now uses less memory by running capture in a temporary helper process.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "Pressing Esc once now cancels the current agent turn (except in fullscreen vim scrollback mode).", + "breaking_change": false + }, + { + "category": "fixes", + "description": "Grok now automatically stops a turn that keeps repeating the exact same tool call many times in a row.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "Configs using either spelling of the workspace teleport disable flag now load and save correctly.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "Background subagent completion messages no longer leak into unrelated sessions when multiple sessions are active.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "When the auto-permission classifier times out or fails, Grok now shows a normal permission prompt instead of silently denying.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Managed MCP tools** no longer time out prematurely on slow operations like Notion updates.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.111.md b/crates/codegen/xai-grok-shell/changelogs/0.2.111.md new file mode 100644 index 0000000..55cfac8 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.111.md @@ -0,0 +1,29 @@ +# 0.2.111 — 2026-07-22 + +## Features + +- Users can now disable image generation and video generation tools (and their slash commands) via config.toml or environment variables. +- `/session-info` now displays whether the session uses OAuth or an API key and where to manage the account. +- You can now run `grok doctor fix` commands directly from inside the TUI instead of only from the CLI. + +## Bug Fixes + +- **`!cmd` commands** now allow up to one hour before timing out. +- **npm package** now installs the native binary under `$GROK_HOME/bin` (honoring the same override as the Rust CLI). +- **Startup warnings** now point to `/doctor` for details and fixes. +- **Dashboard hover and clicks** no longer miss the gaps between items in wide mode. +- **Shift/Alt+Enter** now inserts a newline while editing a queued prompt. +- **Queued prompt edits** under combine mode no longer lose changes due to premature hold release. +- Forking a session that used compaction no longer causes later rewinds to fail with missing checkpoint errors. +- When a permission prompt appears while viewing scrollback, focus now correctly moves to the prompt so you can answer. +- Pressing Esc once now cancels the current agent turn (except in fullscreen vim scrollback mode). +- Grok now automatically stops a turn that keeps repeating the exact same tool call many times in a row. +- Configs using either spelling of the workspace teleport disable flag now load and save correctly. +- Background subagent completion messages no longer leak into unrelated sessions when multiple sessions are active. +- When the auto-permission classifier times out or fails, Grok now shows a normal permission prompt instead of silently denying. +- **Managed MCP tools** no longer time out prematurely on slow operations like Notion updates. + +## Performance + +- Voice dictation on macOS now uses less memory by running capture in a temporary helper process. + diff --git a/crates/codegen/xai-grok-shell/src/agent/activity.rs b/crates/codegen/xai-grok-shell/src/agent/activity.rs index 68d6d23..2f222e8 100644 --- a/crates/codegen/xai-grok-shell/src/agent/activity.rs +++ b/crates/codegen/xai-grok-shell/src/agent/activity.rs @@ -73,7 +73,7 @@ struct ActivityInner { /// (see module docs), and are purged whenever the list is locked. sessions: Mutex<Vec<SessionActivityEntry>>, /// Subagents currently initializing or running; kept in sync by - /// `SubagentCoordinator::sync_running_gauge`. + /// the shared coordinator's `running_count_changed` callback. subagents: Arc<AtomicUsize>, } @@ -95,8 +95,8 @@ impl AgentActivity { }); } - /// Shared gauge of initializing + running subagents; handed to the - /// `SubagentCoordinator`, which recomputes it on every state change. + /// Shared gauge of initializing + running subagents; updated from the + /// shared coordinator's lifecycle callback. pub(crate) fn subagent_gauge(&self) -> Arc<AtomicUsize> { self.inner.subagents.clone() } diff --git a/crates/codegen/xai-grok-shell/src/agent/config.rs b/crates/codegen/xai-grok-shell/src/agent/config.rs index ea3e2d9..ed20d9f 100644 --- a/crates/codegen/xai-grok-shell/src/agent/config.rs +++ b/crates/codegen/xai-grok-shell/src/agent/config.rs @@ -1002,10 +1002,20 @@ pub struct CliConfig { pub worktree_type: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub session_registry: Option<bool>, - /// User-layer value; use [`crate::util::config::resolve_minimum_version`] - /// for enforcement (semver-max across layers; managed floors can't be lowered). + /// Env `GROK_MINIMUM_VERSION`. See [`crate::util::config::VersionPolicy`] for + /// the version-policy knobs. (Unrelated to + /// `version_overrides[].maximum_version`, which gates config patches.) #[serde(skip_serializing_if = "Option::is_none")] pub minimum_version: Option<String>, + /// Env `GROK_MAXIMUM_VERSION`. See [`crate::util::config::VersionPolicy`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum_version: Option<String>, + /// Env `GROK_REQUIRED_MINIMUM_VERSION`. See [`crate::util::config::VersionPolicy`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub required_minimum_version: Option<String>, + /// Env `GROK_REQUIRED_MAXIMUM_VERSION`. See [`crate::util::config::VersionPolicy`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub required_maximum_version: Option<String>, /// Group sessions by repo in the picker and CLI listings. #[serde(skip_serializing_if = "Option::is_none")] pub session_picker_grouped: Option<bool>, @@ -1317,6 +1327,19 @@ pub struct PermissionKnownKeys { /// Verbose `[[permission.rules]]` form. pub rules: Option<toml::Value>, } +/// `[shell_environment_policy]` known keys, for the unrecognized-key scan only; +/// the value is parsed at spawn by [`crate::util::config::resolve_shell_env_policy`]. +/// `Option<toml::Value>` (no `deny_unknown_fields`) keeps a typo a warning, not a +/// load failure, like [`PermissionKnownKeys`]. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct ShellEnvironmentPolicyKnownKeys { + pub inherit: Option<toml::Value>, + pub ignore_default_excludes: Option<toml::Value>, + pub exclude: Option<toml::Value>, + pub set: Option<toml::Value>, + pub include_only: Option<toml::Value>, +} #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Config { pub features: Features, @@ -1358,6 +1381,9 @@ pub struct Config { pub ui: UiConfig, #[serde(default)] pub toolset: ShellToolsetConfig, + /// Validation only; the value is parsed at spawn by `resolve_shell_env_policy`. + #[serde(default, skip_serializing)] + pub shell_environment_policy: ShellEnvironmentPolicyKnownKeys, #[serde(default)] pub endpoints: EndpointsConfig, #[serde(default)] @@ -1782,6 +1808,7 @@ impl Default for Config { hints: None, ui: UiConfig::default(), toolset: ShellToolsetConfig::default(), + shell_environment_policy: ShellEnvironmentPolicyKnownKeys::default(), endpoints, telemetry: TelemetryConfig::default(), session: SessionConfig::default(), @@ -2607,6 +2634,17 @@ impl Config { ) .map(|r| r.value) } + pub(crate) fn resolve_image_edit_model_override(&self) -> Option<String> { + resolve_string_flag( + None, + "GROK_IMAGE_EDIT_MODEL_OVERRIDE", + self.features.image_edit_model_override.as_deref(), + self.remote_settings + .as_ref() + .and_then(|s| s.image_edit_model_override.as_deref()), + ) + .map(|r| r.value) + } /// Goal mode (`/goal`) master switch. Default ON: deployments that can't /// reach cli-chat-proxy `/v1/settings` (custom `models_base_url`, external /// `auth_provider_command`, air-gapped proxies) never receive the @@ -3938,6 +3976,10 @@ pub struct ConfigModelOverride { pub api_backend: Option<ApiBackend>, #[serde(default)] pub extra_headers: IndexMap<String, String>, + #[serde(default)] + pub query_params: IndexMap<String, String>, + #[serde(default)] + pub env_http_headers: IndexMap<String, String>, pub context_window: Option<u64>, /// Per-model auto-compact threshold override (0-100) from `[model.<id>]`. /// Read directly by `resolve_auto_compact_threshold_percent`; intentionally @@ -4002,6 +4044,12 @@ impl ConfigModelOverride { if !self.extra_headers.is_empty() { entry.info.extra_headers = self.extra_headers.clone(); } + if !self.query_params.is_empty() { + entry.info.query_params = self.query_params.clone(); + } + if !self.env_http_headers.is_empty() { + entry.info.env_http_headers = self.env_http_headers.clone(); + } if let Some(cw) = self.context_window.and_then(NonZeroU64::new) { entry.info.context_window = cw; } @@ -4093,6 +4141,10 @@ pub struct ModelInfo { pub api_backend: ApiBackend, pub auth_scheme: AuthScheme, pub extra_headers: IndexMap<String, String>, + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub query_params: IndexMap<String, String>, + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub env_http_headers: IndexMap<String, String>, pub context_window: NonZeroU64, /// Per-model auto-compact threshold (0-100). `None` defers to the /// global / default tiers in `resolve_auto_compact_threshold_percent`. @@ -4158,6 +4210,8 @@ impl ModelInfo { api_backend: ApiBackend::default(), auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: NonZeroU64::new(200_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, @@ -4193,6 +4247,8 @@ impl ModelInfo { api_backend: entry.api_backend.clone(), auth_scheme: entry.auth_scheme.unwrap_or_default(), extra_headers: entry.extra_headers.clone(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: entry.context_window, auto_compact_threshold_percent: entry.auto_compact_threshold_percent, system_prompt_label: entry.system_prompt_label.clone(), @@ -4548,6 +4604,8 @@ pub struct Features { /// (`image_gen_model_override`) / env / default (`grok-imagine-image-quality`). #[serde(default, skip_serializing_if = "Option::is_none")] pub image_gen_model_override: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_edit_model_override: Option<String>, /// Write file tool. `None` = defer to remote settings / env / default (true). #[serde(default, skip_serializing_if = "Option::is_none")] pub write_file: Option<bool>, @@ -4931,6 +4989,8 @@ pub fn resolve_aux_model_sampling_config( api_backend: ApiBackend::Responses, auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: NonZeroU64::new(200_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, @@ -5066,6 +5126,8 @@ pub fn sampling_config_for_model( api_backend, auth_scheme: credentials.auth_scheme, extra_headers, + query_params: info.query_params.clone(), + env_http_headers: info.env_http_headers.clone(), context_window: info.context_window.get(), client_version, reasoning_effort: info.reasoning_effort, @@ -5160,6 +5222,8 @@ fn resolve_hidden_default_web_search_sampling_config( api_backend: ApiBackend::Responses, auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: NonZeroU64::new(200_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, @@ -5776,6 +5840,7 @@ reasoning_effort = "low" args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None); @@ -5856,6 +5921,7 @@ reasoning_effort = "low" args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None); @@ -6014,6 +6080,35 @@ reasoning_effort = "low" ); } #[test] + fn shell_environment_policy_typo_does_not_fail_config() { + let cfg: toml::Value = toml::from_str( + r#" + [shell_environment_policy] + inhert = "core" + exclude = 123 + "#, + ) + .unwrap(); + Config::new_from_toml_cfg(&cfg).expect("a policy typo must not fail the config"); + } + #[test] + fn shell_environment_policy_known_keys_track_the_policy_struct() { + let xai_grok_tools::util::ShellEnvironmentPolicy { + inherit: _, + ignore_default_excludes: _, + exclude: _, + set: _, + include_only: _, + } = xai_grok_tools::util::ShellEnvironmentPolicy::default(); + let ShellEnvironmentPolicyKnownKeys { + inherit: _, + ignore_default_excludes: _, + exclude: _, + set: _, + include_only: _, + } = ShellEnvironmentPolicyKnownKeys::default(); + } + #[test] fn web_search_disable_api_key_auth_swaps_first_party_key_for_session() { let endpoints = EndpointsConfig::default(); let mut models = IndexMap::new(); @@ -6088,6 +6183,7 @@ reasoning_effort = "low" args: Some(vec!["--scope".into(), "corp".into()]), token_ttl_secs: Some(3600), timeout_secs: Some(10), + cwd: None, }) ); let resolved = resolve_model_list(&cfg, None); @@ -6179,6 +6275,7 @@ reasoning_effort = "low" args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); model.auth_provider = Some(provider.clone()); @@ -6205,6 +6302,7 @@ reasoning_effort = "low" args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); model.auth_provider = Some(provider.clone()); @@ -6249,6 +6347,7 @@ reasoning_effort = "low" args: None, token_ttl_secs: None, timeout_secs: None, + cwd: None, }, ); let resolved = resolve_model_list(&cfg, Some(prefetched)); @@ -6302,6 +6401,8 @@ reasoning_effort = "low" api_backend: ApiBackend::default(), auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: NonZeroU64::new(200_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, @@ -9347,6 +9448,40 @@ reasoning_effort = "low" } #[test] #[serial] + fn resolve_image_edit_model_override_remote_settings_or_config() { + unsafe { std::env::remove_var("GROK_IMAGE_EDIT_MODEL_OVERRIDE") }; + let with = |config: Option<&str>, gb: Option<&str>| Config { + features: Features { + image_edit_model_override: config.map(String::from), + ..Default::default() + }, + remote_settings: Some(crate::util::config::RemoteSettings { + image_edit_model_override: gb.map(String::from), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(Config::default().resolve_image_edit_model_override(), None); + assert_eq!( + with(None, Some("grok-imagine-image")).resolve_image_edit_model_override(), + Some("grok-imagine-image".to_owned()) + ); + assert_eq!( + with(Some("grok-imagine-image-pro"), Some("grok-imagine-image")) + .resolve_image_edit_model_override(), + Some("grok-imagine-image-pro".to_owned()) + ); + let gen_only = Config { + remote_settings: Some(crate::util::config::RemoteSettings { + image_gen_model_override: Some("grok-imagine-image".to_owned()), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(gen_only.resolve_image_edit_model_override(), None); + } + #[test] + #[serial] fn imagine_tools_disabled_gates_image_edit() { unsafe { std::env::remove_var("GROK_IMAGE_EDIT") }; let with_list = |tools: Vec<&str>| Config { @@ -11668,6 +11803,8 @@ default = "grok-4.5" api_backend, auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: NonZeroU64::new(context_window).unwrap(), use_concise: false, agent_type: default_agent_type(), diff --git a/crates/codegen/xai-grok-shell/src/agent/config_model_override_parse.rs b/crates/codegen/xai-grok-shell/src/agent/config_model_override_parse.rs index 34ab649..926f548 100644 --- a/crates/codegen/xai-grok-shell/src/agent/config_model_override_parse.rs +++ b/crates/codegen/xai-grok-shell/src/agent/config_model_override_parse.rs @@ -682,6 +682,12 @@ mod tests { extra_headers: [("x-team".to_owned(), "codegen".to_owned())] .into_iter() .collect(), + query_params: [("api-version".to_owned(), "2026-07-22".to_owned())] + .into_iter() + .collect(), + env_http_headers: [("x-tenant-token".to_owned(), "TENANT_TOKEN_VAR".to_owned())] + .into_iter() + .collect(), context_window: Some(200_000), auto_compact_threshold_percent: Some(80), system_prompt_label: Some("label".into()), diff --git a/crates/codegen/xai-grok-shell/src/agent/model_providers.rs b/crates/codegen/xai-grok-shell/src/agent/model_providers.rs index 16426ec..a1f6d0d 100644 --- a/crates/codegen/xai-grok-shell/src/agent/model_providers.rs +++ b/crates/codegen/xai-grok-shell/src/agent/model_providers.rs @@ -13,6 +13,11 @@ pub struct ModelProviderConfig { pub api_key: Option<String>, pub api_backend: Option<ApiBackend>, pub extra_headers: IndexMap<String, String>, + /// Query parameters folded into every request URL; inherited by models. + pub query_params: IndexMap<String, String>, + /// Header name to environment variable; inherited by models, resolved at + /// client build. + pub env_http_headers: IndexMap<String, String>, pub auth_provider: Option<String>, pub auth: Option<crate::auth::AuthProviderConfig>, pub context_window: Option<u64>, @@ -175,6 +180,8 @@ impl ConfigModelOverride { api_key, api_backend, extra_headers, + query_params, + env_http_headers, auth_provider, auth, context_window, @@ -186,9 +193,16 @@ impl ConfigModelOverride { merged.api_base_url = merged.api_base_url.or_else(|| api_base_url.clone()); merged.api_backend = merged.api_backend.or_else(|| api_backend.clone()); merged.context_window = merged.context_window.or(*context_window); + // Inherited wholesale only when the model sets none of its own. if merged.extra_headers.is_empty() { merged.extra_headers = extra_headers.clone(); } + if merged.query_params.is_empty() { + merged.query_params = query_params.clone(); + } + if merged.env_http_headers.is_empty() { + merged.env_http_headers = env_http_headers.clone(); + } let model_sets_own_api_key = self .api_key .as_deref() @@ -914,4 +928,83 @@ mod tests { assert_eq!(provider.name.as_str(), "model_provider:gateway"); assert!(!provider.is_fail_closed()); } + + #[test] + fn model_inherits_provider_query_params_and_env_http_headers() { + let toml_cfg: toml::Value = toml::from_str( + r#" + [model_providers.gateway] + base_url = "https://gateway.example/v1" + api_key = "sk-provider" + + [model_providers.gateway.query_params] + api-version = "2026-07-22" + + [model_providers.gateway.env_http_headers] + X-Tenant-Token = "GATEWAY_TENANT_TOKEN" + + [model.via-gateway] + model = "m" + model_provider = "gateway" + "#, + ) + .unwrap(); + + let cfg = Config::new_from_toml_cfg(&toml_cfg).expect("config should parse"); + let resolved = resolve_model_list(&cfg, None); + let model = resolved.get("via-gateway").expect("model should exist"); + assert_eq!( + model + .info + .query_params + .get("api-version") + .map(String::as_str), + Some("2026-07-22"), + "the model inherits the provider's query params" + ); + assert_eq!( + model + .info + .env_http_headers + .get("X-Tenant-Token") + .map(String::as_str), + Some("GATEWAY_TENANT_TOKEN"), + "the model inherits the provider's env_http_headers mapping (unresolved names)" + ); + } + + #[test] + fn model_query_params_shadow_provider_query_params() { + let toml_cfg: toml::Value = toml::from_str( + r#" + [model_providers.gateway] + base_url = "https://gateway.example/v1" + api_key = "sk-provider" + + [model_providers.gateway.query_params] + api-version = "provider" + + [model.via-gateway] + model = "m" + model_provider = "gateway" + + [model.via-gateway.query_params] + api-version = "model" + "#, + ) + .unwrap(); + + let cfg = Config::new_from_toml_cfg(&toml_cfg).expect("config should parse"); + let resolved = resolve_model_list(&cfg, None); + let model = resolved.get("via-gateway").expect("model should exist"); + assert_eq!( + model + .info + .query_params + .get("api-version") + .map(String::as_str), + Some("model"), + "a model that sets its own query params inherits none of the provider's" + ); + } } diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs index ce33274..fd9501c 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs @@ -1834,26 +1834,30 @@ impl acp::Agent for MvpAgent { cwd.as_path(), remote_settings.as_ref(), ); - if let Some((parent_cmd_tx, session_cwd)) = self - .sessions - .borrow() - .get(&session_id) - .map(|h| (h.cmd_tx.clone(), h.info.cwd.clone())) - { + let orphan_parent = { + let sessions = self.sessions.borrow(); + sessions + .get(&session_id) + .map(|handle| (handle.cmd_tx.clone(), handle.info.cwd.clone())) + }; + if let Some((parent_cmd_tx, session_cwd)) = orphan_parent { let session_dir = crate::session::persistence::session_dir( &SessionInfo { id: session_id.clone(), cwd: session_cwd, }, ); - crate::agent::subagent::reconcile_orphaned_subagents( - &unfinished_subagents, - &self.subagent_coordinator.borrow(), - &session_dir, - session_id.0.as_ref(), - &self.gateway, - Some(&parent_cmd_tx), - ); + crate::agent::subagent::reconcile_orphaned_subagents_with_backend( + &unfinished_subagents, + &xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ), + &session_dir, + session_id.0.as_ref(), + &self.gateway, + Some(&parent_cmd_tx), + ) + .await; } let persisted_model = summary.current_model_id.clone(); let models = self.models_manager.models(); @@ -2638,9 +2642,11 @@ impl acp::Agent for MvpAgent { tool_overrides: _, } = turn_ok; let subagent_refs = self - .subagent_coordinator - .borrow() - .spawned_refs_for_prompt(&prompt_id); + .spawned_subagent_refs_for_prompt( + arguments.session_id.0.as_ref(), + &prompt_id, + ) + .await; let permission_events = self .collect_permission_events(&arguments.session_id); let turn_messages: Option<xai_chat_state::TurnCapture> = { @@ -3091,9 +3097,11 @@ impl acp::Agent for MvpAgent { } Err(err) => { let subagent_refs = self - .subagent_coordinator - .borrow() - .spawned_refs_for_prompt(&prompt_id); + .spawned_subagent_refs_for_prompt( + arguments.session_id.0.as_ref(), + &prompt_id, + ) + .await; let turn_messages: Option<xai_chat_state::TurnCapture> = { let (tx, rx) = oneshot::channel(); if handle diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs index f68f664..a2f4b58 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs @@ -3,6 +3,7 @@ //! Inherent [`MvpAgent`] helpers (MCP/clients/gateway, settings/models, session ops, spawn). //! Co-located child of `mvp_agent` (`use super::*`). use super::*; +use xai_grok_tools::implementations::grok_build::task::backend::SubagentBackend; /// `preferred` model, else catalog `current`, else first with own credentials. fn byok_from_models( models: &indexmap::IndexMap<String, ModelEntry>, @@ -411,9 +412,6 @@ impl MvpAgent { /// Must be called right after construction: entries registered on the /// constructor-created default instance are NOT migrated. pub fn set_activity(&mut self, activity: crate::agent::activity::AgentActivity) { - self.subagent_coordinator - .borrow_mut() - .set_running_gauge(activity.subagent_gauge()); self.activity = activity; } /// Install the channel that fans new session cwds into the leader's @@ -1339,6 +1337,7 @@ impl MvpAgent { image_gen_enabled: cfg.resolve_image_gen().value, image_edit_enabled: cfg.resolve_image_edit().value, model_override: cfg.resolve_image_gen_model_override(), + edit_model_override: cfg.resolve_image_edit_model_override(), tier_restricted, } } @@ -1534,8 +1533,6 @@ impl MvpAgent { } let (subagent_event_tx, subagent_event_rx) = tokio::sync::mpsc::unbounded_channel(); let activity = crate::agent::activity::AgentActivity::default(); - let mut subagent_coordinator = crate::agent::subagent::SubagentCoordinator::new(); - subagent_coordinator.set_running_gauge(activity.subagent_gauge()); let instance = Self { sessions: RefCell::new(HashMap::new()), activity, @@ -1604,7 +1601,9 @@ impl MvpAgent { model_unavailable_sessions: RefCell::new(std::collections::HashMap::new()), subagent_event_tx, subagent_event_rx: RefCell::new(Some(subagent_event_rx)), - subagent_coordinator: RefCell::new(subagent_coordinator), + subagent_presentation: RefCell::new( + crate::agent::subagent::SubagentPresentation::new(), + ), monitor_event_buffer: xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer::default(), bundle_sync_in_flight: Arc::new(std::sync::atomic::AtomicBool::new(false)), post_unblock_jwt_retry_in_flight: Arc::new( @@ -1895,46 +1894,74 @@ impl MvpAgent { /// Cancel a subagent by id, returning a typed outcome that backs the pager's /// `x.ai/subagent/cancel`. Active/pending → cancelled (a finish follows); /// already-finished → its terminal status; unknown id → `NotFound`. - pub fn cancel_subagent( + pub async fn cancel_subagent( &self, subagent_id: &str, ) -> xai_grok_tools::implementations::grok_build::task::types::SubagentCancelOutcome { - self.subagent_coordinator.borrow_mut().cancel_with_outcome(subagent_id) + xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ) + .cancel(subagent_id) + .await } - /// List running subagent seeds for a given parent session. - /// - /// Synchronously collects seeds from the coordinator, suitable for - /// async resolution via `resolve_running_list()` after the borrow is - /// dropped. - pub(crate) fn list_running_subagents( + pub(crate) async fn list_running_subagents( &self, parent_session_id: &str, - ) -> Vec<crate::agent::subagent::RunningSubagentListSeed> { - self.subagent_coordinator.borrow().list_running_for_parent(parent_session_id) + ) -> Vec< + xai_grok_tools::implementations::grok_build::task::types::SubagentInspection, + > { + xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ) + .list_running(parent_session_id) + .await } - /// Return fork provenance metadata for a subagent. - pub(crate) fn provenance_for_subagent( + pub(crate) async fn inspect_subagent( &self, subagent_id: &str, - ) -> crate::agent::subagent::SubagentProvenance { - self.subagent_coordinator.borrow().provenance_for(subagent_id) + ) -> Option< + xai_grok_tools::implementations::grok_build::task::types::SubagentInspection, + > { + xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ) + .inspect(subagent_id) + .await } - /// Return `(parent_session_id, child_session_id)` for a subagent. - pub(crate) fn session_ids_for_subagent( + pub(crate) async fn query_subagent( &self, subagent_id: &str, - ) -> Option<(String, String)> { - self.subagent_coordinator.borrow().session_ids_for(subagent_id) + block: bool, + timeout_ms: Option<u64>, + ) -> Option< + xai_grok_tools::implementations::grok_build::task::types::SubagentSnapshot, + > { + xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ) + .query(subagent_id, block, timeout_ms) + .await } - /// Synchronous lookup of a single subagent by ID. - /// - /// Returns `Option<SnapshotLookup>` which must be resolved - /// asynchronously via `resolve_snapshot()` after the borrow is dropped. - pub(crate) fn lookup_subagent( + pub(super) async fn spawned_subagent_refs_for_prompt( &self, - subagent_id: &str, - ) -> Option<crate::agent::subagent::SnapshotLookup> { - self.subagent_coordinator.borrow().lookup(subagent_id) + parent_session_id: &str, + prompt_id: &str, + ) -> Vec<crate::upload::trace::SubagentSpawnedRef> { + xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ) + .spawned_refs_for_prompt(parent_session_id, prompt_id) + .await + .into_iter() + .map(|child| crate::upload::trace::SubagentSpawnedRef { + subagent_id: child.subagent_id, + child_session_id: child.child_session_id, + subagent_type: child.subagent_type, + description: child.description, + persona: child.persona, + resumed_from: child.resumed_from, + }) + .collect() } /// List all background tasks for a session. /// Routes through the session's tool bridge to the TerminalBackend. @@ -3153,19 +3180,19 @@ impl MvpAgent { })?; tool_ctx.subagent_event_tx = Some(self.subagent_event_tx.clone()); tool_ctx.synthetic_trace_tx = self - .subagent_coordinator + .subagent_presentation .borrow() .synthetic_trace_tx .clone(); if let Some(ref shared) = tool_ctx.synthetic_trace_tx_shared { *shared.lock().unwrap_or_else(|e| e.into_inner()) = self - .subagent_coordinator + .subagent_presentation .borrow() .synthetic_trace_tx .clone(); } tool_ctx.is_turn_active = Some( - self.subagent_coordinator.borrow().turn_active_flag(), + self.subagent_presentation.borrow().turn_active_flag(), ); tool_ctx.monitor_event_buffer = Some(self.monitor_event_buffer.clone()); tool_ctx.subagent_depth = 0; diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs index e44f905..1c7e1fb 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs @@ -500,6 +500,7 @@ struct SettingsUpdateNotification { privacy_banner_reshow_days: Option<u64>, session_picker_grouped: Option<bool>, tips: Option<Vec<String>>, + slash_command_tags: Option<std::collections::BTreeMap<String, String>>, announcements: Option<Vec<xai_grok_announcements::RemoteAnnouncement>>, gate_message: Option<String>, gate_url: Option<String>, @@ -797,9 +798,8 @@ pub struct MvpAgent { >, >, >, - /// Active subagent tracking — owns all subagent lifecycle state. - /// LEADER-SAFE(per-session): keyed by subagent_id, no cross-session iteration. - subagent_coordinator: RefCell<crate::agent::subagent::SubagentCoordinator>, + /// Shell-only presentation state; lifecycle lives in the channel actor. + subagent_presentation: RefCell<crate::agent::subagent::SubagentPresentation>, /// Shared buffer for mid-turn monitor event notifications. /// Pushed by the `InjectNotification` handler when a turn is active and the /// notification has `Next` priority. Drained by the session turn loop @@ -1687,6 +1687,7 @@ impl MvpAgent { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let notification = crate::extensions::notification::SessionNotification { session_id: session_id.clone(), @@ -2092,6 +2093,7 @@ impl MvpAgent { .and_then(|s| s.privacy_banner_reshow_days), session_picker_grouped: rs.and_then(|s| s.session_picker_grouped), tips: rs.and_then(|s| s.tips.clone()), + slash_command_tags: rs.and_then(|s| s.slash_command_tags.clone()), announcements: rs.and_then(|s| s.announcements.clone()), gate_message: rs.and_then(|s| s.gate_message.clone()), gate_url: rs.and_then(|s| s.gate_url.clone()), diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs index 2167d40..9c9638a 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs @@ -44,9 +44,11 @@ impl MvpAgent { if let Some(ops) = self.workspace_ops.borrow().as_ref() { ops.end_local_session(id.0.as_ref()); } - self.subagent_coordinator - .borrow_mut() - .discard_pending_completions_for(id.0.as_ref()); + let _ = self + .subagent_event_tx + .send(xai_grok_tools::implementations::grok_build::task::types::SubagentEvent::DiscardSessionCompletions { + parent_session_id: id.0.to_string(), + }); } /// Get-or-create the per-session dispatch lock (see /// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`. @@ -406,10 +408,14 @@ impl MvpAgent { .unwrap_or(true) } /// Entry counts for every collection [`Self::remove_session`] drains, - /// plus the workspace binding and subagent maps. - pub(crate) fn registry_snapshot(&self) -> RegistrySnapshot { - let (subagent_pending, subagent_active, subagent_completed) = - self.subagent_coordinator.borrow().registry_snapshot(); + /// plus workspace bindings and shared coordinator state. + pub(crate) async fn registry_snapshot(&self) -> RegistrySnapshot { + let subagents = + xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new( + self.subagent_event_tx.clone(), + ) + .registry_counts() + .await; RegistrySnapshot { sessions: self.sessions.borrow().len(), session_threads: self.session_threads.borrow().len(), @@ -420,9 +426,9 @@ impl MvpAgent { session_live_state: self.session_live_state.borrow().len(), session_index_claims: self.session_index_claims.borrow().len(), require_gateway_sessions: self.require_gateway_sessions.borrow().len(), - subagent_pending, - subagent_active, - subagent_completed, + subagent_pending: subagents.pending, + subagent_active: subagents.active, + subagent_completed: subagents.completed, workspace_bindings: self .workspace_ops .borrow() diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs index 860d2fc..3762d70 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs @@ -1,348 +1,204 @@ -//! Subagent coordinator drain task and spawn-context construction for [`MvpAgent`]. -//! Co-located child of `mvp_agent` (`use super::*`); tested by `tests/subagent_spawn_context_tests.rs`. +//! Shell runner adapter and spawn-context construction for [`MvpAgent`]. +//! The shared coordinator actor lives in `xai-grok-tools`; this module plugs +//! its `!Send` local-session runner into `spawn_local`. use super::*; +use crate::session::repo_changes::UploadMethod; +struct ShellChildRunner { + agent_ref: LocalRef<MvpAgent>, +} +impl xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunner + for ShellChildRunner +{ + type Control = crate::agent::subagent::ShellChildRuntime; + type CompletionData = crate::agent::subagent::ShellCompletionData; + type RunFuture = xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture< + xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunOutput< + Self::CompletionData, + >, + >; + type ValidateFuture = + xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture< + xai_grok_tools::implementations::grok_build::task::types::SubagentValidateTypeOutcome, + >; + type DescribeFuture = + xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture< + xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome, + >; + fn run( + &self, + run: xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunRequest< + Self::Control, + >, + ) -> Self::RunFuture { + let agent_ref = self.agent_ref.clone(); + Box::pin(async move { + let xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunRequest { + request, + cancellation, + reporter, + } = run; + let this = agent_ref.get(); + let parent_sid = request.parent_session_id.clone(); + let Some(mut ctx) = this.try_build_subagent_spawn_context(&parent_sid) else { + tracing::warn!( + parent_session_id = %parent_sid, + subagent_id = %request.id, + "Spawn for unknown or evicted parent session" + ); + return xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunOutput { + result: xai_grok_tools::implementations::grok_build::task::types::SubagentResult { + success: false, + error: Some( + "Parent session not found (evicted or torn down); cannot spawn subagent." + .to_owned(), + ), + subagent_id: request.id.clone(), + child_session_id: request.id, + ..Default::default() + }, + completion_data: Default::default(), + snapshot_ref: None, + }; + }; + let parent_handle = { + let parent_sid = acp::SessionId::new(parent_sid); + this.sessions.borrow().get(&parent_sid).cloned() + }; + if let Some(handle) = parent_handle { + ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await; + ctx.client_hooks = handle.snapshot_client_hooks().await; + let definitions = handle.snapshot_tool_definitions().await; + ctx.parent_tool_definitions = (!definitions.is_empty()).then_some(definitions); + } + crate::agent::subagent::run_shell_child( + request, + ctx, + cancellation, + reporter, + &this.gateway, + ) + .await + }) + } + fn validate_type( + &self, + subagent_type: String, + parent_session_id: String, + ) -> Self::ValidateFuture { + let agent_ref = self.agent_ref.clone(); + Box::pin(async move { + let this = agent_ref.get(); + let ctx = this.build_subagent_validation_context(&parent_session_id); + crate::agent::subagent::validate_subagent_type(&subagent_type, &ctx) + }) + } + fn describe_type( + &self, + subagent_type: String, + harness_agent_type: Option<String>, + parent_session_id: String, + ) -> Self::DescribeFuture { + let agent_ref = self.agent_ref.clone(); + Box::pin(async move { + let this = agent_ref.get(); + match this.try_build_subagent_spawn_context(&parent_session_id) { + Some(ctx) => crate::agent::subagent::describe_subagent_type( + &subagent_type, + harness_agent_type.as_deref(), + &ctx, + ), + None => { + tracing::warn!( + parent_session_id, + subagent_type, + "DescribeType for unknown/evicted parent session, replying Unavailable", + ); + xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome::Unavailable + } + } + }) + } + fn on_completed( + &self, + completion: xai_grok_tools::implementations::grok_build::task::coordinator::ChildCompletion< + Self::CompletionData, + >, + ) { + let gateway = self.agent_ref.get().gateway.clone(); + crate::agent::subagent::present_child_completion(completion, &gateway); + } + fn running_count_changed(&self, running: usize) { + self.agent_ref + .get() + .activity + .subagent_gauge() + .store(running, std::sync::atomic::Ordering::Relaxed); + } + fn persisted_output_ref(&self, completion_data: &Self::CompletionData) -> Option<String> { + completion_data + .persisted_output_dir() + .map(|path| path.to_string_lossy().into_owned()) + } + fn load_persisted_output(&self, reference: &str) -> Option<std::sync::Arc<str>> { + crate::agent::subagent::read_subagent_output(std::path::Path::new(reference)) + .map(std::sync::Arc::from) + } +} impl MvpAgent { - /// Start the subagent coordinator drain task. + /// Start the shared subagent coordinator actor. /// - /// Takes the `subagent_event_rx` receiver (once) and spawns a `spawn_local` task - /// that receives `SubagentRequest`s and delegates each to - /// `handle_subagent_request()` on its own `spawn_local` task. + /// Takes `subagent_event_rx` once and `spawn_local`s one + /// [`SubagentCoordinator`](xai_grok_tools::implementations::grok_build::task::coordinator::SubagentCoordinator) + /// that drains `ChannelBackend` events (`Spawn` / await / cancel / inspect) + /// through [`ShellChildRunner`]. The actor owns pending/active/completed + /// state, waiters, deadlines, and completion disposition; the runner only + /// builds shell child sessions via `run_shell_child`. /// - /// Uses `LocalRef` to reference `self` from - /// `spawn_local` closures. Idempotent: subsequent calls are no-ops. + /// Uses `LocalRef` so the `!Send` runner can touch `self` from the + /// `LocalSet`. Idempotent: subsequent calls are no-ops. pub(super) fn start_subagent_coordinator(&self) { - let Some(mut rx) = self.subagent_event_rx.borrow_mut().take() else { + let Some(rx) = self.subagent_event_rx.borrow_mut().take() else { return; }; let agent_ref = LocalRef::new(self); - use crate::agent::subagent::{BlockWaitSlot, is_running, resolve_snapshot}; - use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentCancelOutcome, SubagentCancelTarget, SubagentEvent, + let runner = ShellChildRunner { + agent_ref: agent_ref.clone(), }; + let config = + xai_grok_tools::implementations::grok_build::task::coordinator::CoordinatorConfig { + foreground_budget: + xai_grok_tools::implementations::grok_build::task::backend::env_duration_or( + "GROK_SUBAGENT_AWAIT_BUDGET_MS", + std::time::Duration::from_secs(600), + ), + buffer_completions: true, + buffered_completion_output_cap: None, + }; + tokio::task::spawn_local( + xai_grok_tools::implementations::grok_build::task::coordinator::SubagentCoordinator::new( + rx, + runner, + config, + ) + .run(), + ); + let (trace_tx, mut trace_rx) = tokio::sync::mpsc::unbounded_channel::< + crate::upload::turn::SyntheticTurnTraceRequest, + >(); + self.subagent_presentation.borrow_mut().synthetic_trace_tx = Some(trace_tx); tokio::task::spawn_local({ let agent_ref = agent_ref.clone(); async move { - while let Some(event) = rx.recv().await { - match event { - SubagentEvent::Spawn(boxed) => { - let mut request = *boxed; - { - let this = agent_ref.get(); - let parent_is_session = this.sessions.borrow().contains_key( - &acp::SessionId::new(request.parent_session_id.clone()), - ); - if !parent_is_session { - let child_sess = request.parent_session_id.clone(); - let reparent = { - let coord = this.subagent_coordinator.borrow(); - coord.parent_of_child_session(&child_sess).map(|root| { - (root, coord.loop_task_id_of_child_session(&child_sess)) - }) - }; - if let Some((root, inherited_loop)) = reparent { - tracing::info!( - child_session_id = %child_sess, - root_session_id = %root, - subagent_id = %request.id, - "Re-parenting child-session spawn to root session" - ); - request.parent_session_id = root; - request.surface_completion = false; - if request.runtime_overrides.loop_task_id.is_none() { - request.runtime_overrides.loop_task_id = inherited_loop; - } - } - } - if let Some(task_id) = - request.runtime_overrides.loop_task_id.clone() - { - this.subagent_coordinator - .borrow_mut() - .record_loop_owner(&request.id, &task_id); - } - } - let agent_ref = agent_ref.clone(); - tokio::task::spawn_local(async move { - let this = agent_ref.get(); - let parent_sid = request.parent_session_id.clone(); - let Some(mut ctx) = - this.try_build_subagent_spawn_context(&parent_sid) - else { - tracing::warn!( - parent_session_id = %parent_sid, - subagent_id = %request.id, - "Spawn for unknown/evicted parent session, failing request" - ); - this.subagent_coordinator - .borrow_mut() - .remove_loop_owner(&request.id); - crate::agent::subagent::send_failure( - request, - "Parent session not found (evicted or torn down); cannot spawn subagent.", - ); - return; - }; - let parent_handle = { - let parent_sid_acp = acp::SessionId::new(parent_sid.clone()); - this.sessions.borrow().get(&parent_sid_acp).cloned() - }; - if let Some(handle) = parent_handle { - ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await; - ctx.client_hooks = handle.snapshot_client_hooks().await; - let parent_tools = handle.snapshot_tool_definitions().await; - ctx.parent_tool_snapshot = - (!parent_tools.is_empty()).then_some(parent_tools); - } - crate::agent::subagent::handle_subagent_request( - request, - ctx, - &this.subagent_coordinator, - &this.gateway, - ) - .await; - }); + while let Some(request) = trace_rx.recv().await { + tokio::task::spawn_local({ + let agent_ref = agent_ref.clone(); + async move { + handle_synthetic_turn_trace(agent_ref, request).await; } - SubagentEvent::Query(query) => { - let agent_ref = agent_ref.clone(); - tokio::task::spawn_local(async move { - let subagent_id = query.subagent_id; - let block = query.block; - let timeout_ms = query.timeout_ms; - let slot: BlockWaitSlot = std::rc::Rc::new( - std::cell::RefCell::new(Some(query.respond_to)), - ); - let send_via_slot = - |slot: &BlockWaitSlot, snap| match slot.borrow_mut().take() { - Some(tx) => tx.send(snap).is_ok(), - None => false, - }; - let lookup = { - let this = agent_ref.get(); - let result = - this.subagent_coordinator.borrow().lookup(&subagent_id); - if block && result.is_some() { - this.subagent_coordinator - .borrow_mut() - .register_block_wait(&subagent_id, slot.clone()); - } - result - }; - let snapshot = resolve_snapshot(lookup).await; - let should_block = - block && snapshot.as_ref().is_some_and(is_running); - if should_block { - let timeout_ms = timeout_ms.unwrap_or(30_000); - let deadline = tokio::time::Instant::now() - + tokio::time::Duration::from_millis(timeout_ms); - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(200)) - .await; - let receiver_gone = - slot.borrow().as_ref().is_none_or(|tx| tx.is_closed()); - if receiver_gone { - let this = agent_ref.get(); - let mut coord = this.subagent_coordinator.borrow_mut(); - coord.clear_block_waited(&subagent_id); - coord.unregister_block_wait(&subagent_id, &slot); - return; - } - let lookup = { - let this = agent_ref.get(); - this.subagent_coordinator.borrow().lookup(&subagent_id) - }; - let snap = resolve_snapshot(lookup).await; - let still_running = snap.as_ref().is_some_and(is_running); - if !still_running || tokio::time::Instant::now() >= deadline - { - { - let this = agent_ref.get(); - let mut coord = - this.subagent_coordinator.borrow_mut(); - if still_running { - coord.clear_block_waited(&subagent_id); - } - coord.unregister_block_wait(&subagent_id, &slot); - } - if !send_via_slot(&slot, snap) && !still_running { - let this = agent_ref.get(); - this.subagent_coordinator - .borrow_mut() - .clear_block_waited(&subagent_id); - } - return; - } - } - } else { - let delivered = send_via_slot(&slot, snapshot); - if block { - let this = agent_ref.get(); - let mut coord = this.subagent_coordinator.borrow_mut(); - coord.unregister_block_wait(&subagent_id, &slot); - if !delivered { - coord.clear_block_waited(&subagent_id); - } - } - } - }); - } - SubagentEvent::Cancel(request) => match request.target { - SubagentCancelTarget::WorkflowRunId(run_id) => { - let agent_ref = agent_ref.clone(); - tokio::task::spawn_local(async move { - let notify = { - let this = agent_ref.get(); - let mut coord = this.subagent_coordinator.borrow_mut(); - coord.cancel_workflow_children(&run_id); - coord.completion_notify() - }; - loop { - let notified = notify.notified(); - let outstanding = { - let this = agent_ref.get(); - this.subagent_coordinator - .borrow() - .outstanding_for_workflow(&run_id) - }; - if outstanding == 0 { - let _ = request - .respond_to - .send(SubagentCancelOutcome::Cancelled); - break; - } - notified.await; - } - }); - } - target => { - let this = agent_ref.get(); - let outcome = { - let mut coord = this.subagent_coordinator.borrow_mut(); - match target { - SubagentCancelTarget::SubagentId(ref subagent_id) => { - coord.mark_explicitly_killed(subagent_id); - coord.cancel_with_outcome(subagent_id) - } - SubagentCancelTarget::ParentPromptId( - ref parent_prompt_id, - ) => { - coord.cancel_by_parent_prompt_id(parent_prompt_id); - SubagentCancelOutcome::Cancelled - } - SubagentCancelTarget::WorkflowRunId(_) => { - unreachable!("handled above") - } - } - }; - let _ = request.respond_to.send(outcome); - } - }, - SubagentEvent::ListActive(request) => { - let this = agent_ref.get(); - let summaries = this - .subagent_coordinator - .borrow() - .active_summaries_for(&request.parent_session_id); - let _ = request.respond_to.send(summaries); - } - SubagentEvent::Completions(request) => { - let this = agent_ref.get(); - let mut completions = this - .subagent_coordinator - .borrow_mut() - .drain_pending_completions_for(&request.session_id); - completions.retain(|c| !request.suppress_ids.contains(&c.subagent_id)); - let _ = request.respond_to.send(completions); - } - SubagentEvent::Outstanding(request) => { - let this = agent_ref.get(); - let reply = this - .subagent_coordinator - .borrow() - .outstanding_reply_for_prompt(&request.prompt_id); - let _ = request.respond_to.send(reply); - } - SubagentEvent::ClearUsageNotApplied(request) => { - let this = agent_ref.get(); - this.subagent_coordinator - .borrow_mut() - .clear_subagent_usage_not_applied(&request.prompt_id); - } - SubagentEvent::MarkUsageNotApplied(request) => { - let this = agent_ref.get(); - this.subagent_coordinator - .borrow_mut() - .mark_subagent_usage_not_applied(&request.prompt_id); - let _ = request.respond_to.send(()); - } - SubagentEvent::ValidateType(request) => { - let agent_ref = agent_ref.clone(); - tokio::task::spawn_local(async move { - let this = agent_ref.get(); - let ctx = this - .build_subagent_validation_context(&request.parent_session_id); - let outcome = crate::agent::subagent::validate_subagent_type( - &request.subagent_type, - &ctx, - ); - let _ = request.respond_to.send(outcome); - }); - } - SubagentEvent::DescribeType(request) => { - let agent_ref = agent_ref.clone(); - tokio::task::spawn_local(async move { - use xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome; - let this = agent_ref.get(); - let outcome = match this - .try_build_subagent_spawn_context(&request.parent_session_id) - { - Some(ctx) => crate::agent::subagent::describe_subagent_type( - &request.subagent_type, - request.harness_agent_type.as_deref(), - &ctx, - ), - None => { - tracing::warn!( - parent_session_id = %request.parent_session_id, - subagent_type = %request.subagent_type, - "DescribeType for unknown/evicted parent session, replying Unavailable", - ); - SubagentDescribeOutcome::Unavailable - } - }; - let _ = request.respond_to.send(outcome); - }); - } - SubagentEvent::LoopUnitActive(request) => { - let this = agent_ref.get(); - let active = this - .subagent_coordinator - .borrow() - .loop_unit_active(&request.task_id); - let _ = request.respond_to.send(active); - } - } + }); } } }); - { - let (trace_tx, mut trace_rx) = tokio::sync::mpsc::unbounded_channel::< - crate::upload::turn::SyntheticTurnTraceRequest, - >(); - self.subagent_coordinator.borrow_mut().synthetic_trace_tx = Some(trace_tx); - tokio::task::spawn_local({ - let agent_ref = agent_ref.clone(); - async move { - while let Some(request) = trace_rx.recv().await { - tokio::task::spawn_local({ - let agent_ref = agent_ref.clone(); - async move { - handle_synthetic_turn_trace(agent_ref, request).await; - } - }); - } - } - }); - } } /// Lightweight context for the `SubagentEvent::ValidateType` drain arm; /// tolerates evicted parent sessions (returns built-in defaults + warns). @@ -509,7 +365,6 @@ impl MvpAgent { }; let (gcs_upload_method, gcs_bucket_url) = match self.trace_upload_config_snapshot() { Some(method) => { - use crate::session::repo_changes::UploadMethod; let bucket = match &method { UploadMethod::Direct { .. } => self .cfg @@ -552,7 +407,6 @@ impl MvpAgent { }; Some(crate::agent::subagent::SubagentSpawnContext { lsp: parent_lsp, - gateway: self.gateway.clone(), client_hooks: Default::default(), sampling_config: self.sampling_config.borrow().clone(), managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url @@ -565,7 +419,6 @@ impl MvpAgent { .cloned() .unwrap_or_else(|| acp::AuthMethodId::new("default")), model_id: parent_model_id, - storage_mode: self.storage_mode, auth: self.current_or_buffered_auth(), parent_cwd: parent_cwd.clone(), parent_session_id: parent_session_id.to_string(), @@ -632,7 +485,6 @@ impl MvpAgent { agent_config: Some(self.cfg.borrow().clone()), gcs_upload_method, hook_registry: parent_hook_registry, - hook_workspace_root: String::new(), permission_handle: { let sessions = self.sessions.borrow(); sessions @@ -664,7 +516,7 @@ impl MvpAgent { }, managed_mcp_state: self.managed_mcp_cache.clone(), parent_mcp_pool: None, - parent_tool_snapshot: None, + parent_tool_definitions: None, parent_skills: None, parent_skills_config: self.cfg.borrow().skills.clone(), parent_compat: self.cfg.borrow().compat_resolved, @@ -700,15 +552,6 @@ impl MvpAgent { std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }) }, - parent_blocking_wait_depth: { - let sessions = self.sessions.borrow(); - sessions - .get(&parent_sid) - .map(|h| h.tool_context.blocking_wait_depth.clone()) - .unwrap_or_else(|| { - std::sync::Arc::new(crate::tools::tool_context::BlockingWaitState::new()) - }) - }, parent_terminal_backend: parent_terminal_backend.clone(), parent_notification_handle: parent_notification_handle.clone(), parent_scheduler_handle: parent_scheduler_handle.clone(), diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs index 04f36bf..fb2c665 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs @@ -1074,6 +1074,7 @@ async fn file_toolset_override_e2e_to_finalized_toolset() { session_env: std::sync::Arc::new(std::collections::HashMap::new()), notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: tmp.path().join("state.json"), @@ -2131,6 +2132,8 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() { api_backend: crate::sampling::ApiBackend::default(), auth_scheme: Default::default(), extra_headers: IndexMap::new(), + query_params: IndexMap::new(), + env_http_headers: IndexMap::new(), context_window: std::num::NonZeroU64::new(200_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, @@ -2610,6 +2613,31 @@ async fn prepare_video_gen_config_sends_client_identifier_header() { applies the coding ZDR opt-out to Build traffic" ); } +/// Regression: `x.ai/auth/info` must return profile fields even when the +/// access token is expired — profile data does not expire with the token, +/// and hiding it made the desktop render "Signed in" with no identity. +#[tokio::test] +async fn auth_info_returns_profile_when_token_expired() { + let agent = build_agent_with_auth(crate::auth::GrokAuth { + email: Some("user@example.com".into()), + first_name: Some("Test".into()), + refresh_token: Some("rt".into()), + expires_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), + ..crate::auth::GrokAuth::test_default() + }); + let resp = crate::extensions::auth::handle( + &agent, + &acp::ExtRequest::new( + "x.ai/auth/info", + std::sync::Arc::from(serde_json::value::to_raw_value(&serde_json::json!({})).unwrap()), + ), + ) + .await + .expect("auth/info must succeed with an expired token"); + let info: serde_json::Value = serde_json::from_str(resp.0.get()).unwrap(); + assert_eq!(info["email"], "user@example.com"); + assert_eq!(info["firstName"], "Test"); +} #[tokio::test] async fn data_collection_enabled_for_normal_user() { let agent = build_agent_with_auth(crate::auth::GrokAuth::test_default()); @@ -4775,6 +4803,11 @@ mod soft_default_settings_emit { let cfg = AgentConfig { remote_settings: Some(crate::util::config::RemoteSettings { permission_mode: Some("always-approve".into()), + slash_command_tags: Some( + [("workflows".to_string(), "new".to_string())] + .into_iter() + .collect(), + ), ..Default::default() }), ..Default::default() @@ -4795,6 +4828,14 @@ mod soft_default_settings_emit { Some("always-approve"), "post-auth emit must carry remote permission_mode for first session" ); + assert_eq!( + params + .get("slash_command_tags") + .and_then(|v| v.get("workflows")) + .and_then(|v| v.as_str()), + Some("new"), + "post-auth emit must carry remote slash_command_tags" + ); let _ = args.response_tx.send(Ok(())); }) .await; diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs deleted file mode 100644 index 1fb54c1..0000000 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs +++ /dev/null @@ -1,578 +0,0 @@ -#![cfg_attr(rustfmt, rustfmt::skip)] -#![allow(unused_imports)] -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use agent_client_protocol as acp; -use tokio::sync::{Notify, mpsc, oneshot}; -use tokio_util::sync::CancellationToken; -use crate::extensions::notification::{SessionNotification, SessionUpdate}; -use crate::session::{ - self, SessionCommand, SessionHandle, SessionThread, - commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult}, - fs_watch::FsWatchCapabilities, info::Info as SessionInfo, -}; -use crate::terminal::AsyncTerminalRunner; -use crate::tools::ToolContext; -use crate::upload::trace::{ - GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata, - local_sandbox_telemetry, upload_metadata, upload_session_state, - upload_subagent_metadata, upload_turn_result, -}; -use crate::upload::turn::{PromptTraceContext, complete_prompt_trace}; -use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; -use xai_grok_tools::implementations::grok_build::task::types::*; -use xai_grok_workspace::file_system::AsyncFileSystem; -use xai_hunk_tracker::HunkTrackerHandle; -use super::*; -impl SubagentCoordinator { - pub fn new() -> Self { - Self { - pending: HashMap::new(), - active: HashMap::new(), - completed: HashMap::new(), - completion_notify: Arc::new(Notify::new()), - pending_completions: Vec::new(), - is_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), - synthetic_trace_tx: None, - running_gauge: Arc::new(std::sync::atomic::AtomicUsize::new(0)), - block_wait_slots: HashMap::new(), - subagent_usage_not_applied_prompts: std::collections::HashSet::new(), - loop_owned: HashMap::new(), - } - } - pub fn mark_subagent_usage_not_applied(&mut self, prompt_id: &str) { - self.subagent_usage_not_applied_prompts.insert(prompt_id.to_string()); - } - pub fn subagent_usage_not_applied(&self, prompt_id: &str) -> bool { - self.subagent_usage_not_applied_prompts.contains(prompt_id) - } - pub fn clear_subagent_usage_not_applied(&mut self, prompt_id: &str) { - self.subagent_usage_not_applied_prompts.remove(prompt_id); - } - pub fn parent_prompt_id_for(&self, subagent_id: &str) -> Option<String> { - self.active - .get(subagent_id) - .and_then(|t| t.parent_prompt_id.clone()) - .or_else(|| { - self.pending.get(subagent_id).and_then(|p| p.parent_prompt_id.clone()) - }) - } - /// Rebind the running-subagent gauge, copying the current count so a - /// late rebind cannot under-report. - pub fn set_running_gauge(&mut self, gauge: Arc<std::sync::atomic::AtomicUsize>) { - gauge - .store( - self.pending.len() + self.active.len(), - std::sync::atomic::Ordering::Relaxed, - ); - self.running_gauge = gauge; - } - /// Recompute the gauge from `pending` + `active` after every mutation of - /// either map — recomputing (rather than incrementing) prevents drift. - fn sync_running_gauge(&self) { - self.running_gauge - .store( - self.pending.len() + self.active.len(), - std::sync::atomic::Ordering::Relaxed, - ); - } - pub fn completion_notify(&self) -> Arc<Notify> { - Arc::clone(&self.completion_notify) - } - /// Returns a shared handle to the turn-active flag. - pub fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> { - Arc::clone(&self.is_turn_active) - } - /// Whether the model's turn is currently active. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "used from tests only; remove expect when wired in production" - ) - )] - pub fn is_turn_active(&self) -> bool { - self.is_turn_active.load(std::sync::atomic::Ordering::Relaxed) - } - /// Pending + active turn-blocking subagent IDs for `prompt_id`. - /// Background children are excluded: they outlive the turn by design, so - /// the freeze drain must not wait on them (their spend reaches the session - /// ledger when they finish; the prompt report flags them via - /// `background_live`). - pub fn outstanding_for_prompt(&self, prompt_id: &str) -> Vec<String> { - let mut ids: Vec<String> = self - .pending - .values() - .filter(|p| { - p.parent_prompt_id.as_deref() == Some(prompt_id) && !p.run_in_background - }) - .map(|p| p.subagent_id.clone()) - .chain( - self - .active - .values() - .filter(|t| { - t.parent_prompt_id.as_deref() == Some(prompt_id) - && !t.run_in_background - }) - .map(|t| t.subagent_id.clone()), - ) - .collect(); - ids.sort(); - ids - } - /// True while any background child of `prompt_id` is pending or active. - /// Their spend is missing from the prompt report (it lands on the session - /// ledger at completion), so the report is incomplete — without waiting. - pub fn background_live_for_prompt(&self, prompt_id: &str) -> bool { - self - .pending - .values() - .any(|p| { - p.parent_prompt_id.as_deref() == Some(prompt_id) && p.run_in_background - }) - || self - .active - .values() - .any(|t| { - t.parent_prompt_id.as_deref() == Some(prompt_id) - && t.run_in_background - }) - } - /// Record that a foreground child was auto-backgrounded (await budget - /// expired): it no longer blocks the turn, so the freeze drain must stop - /// waiting on it. - pub fn mark_backgrounded(&mut self, subagent_id: &str) { - if let Some(t) = self.active.values_mut().find(|t| t.subagent_id == subagent_id) - { - t.run_in_background = true; - } - if let Some(p) = self.pending.values_mut().find(|p| p.subagent_id == subagent_id) - { - p.run_in_background = true; - } - } - pub fn outstanding_reply_for_prompt( - &self, - prompt_id: &str, - ) -> xai_grok_tools::implementations::grok_build::task::types::SubagentOutstandingReply { - xai_grok_tools::implementations::grok_build::task::types::SubagentOutstandingReply { - live_ids: self.outstanding_for_prompt(prompt_id), - background_live: self.background_live_for_prompt(prompt_id), - subagent_usage_not_applied: self.subagent_usage_not_applied(prompt_id), - } - } - pub fn drain_pending_completions_for( - &mut self, - session_id: &str, - ) -> Vec<SubagentCompletionSummary> { - if session_id.is_empty() { - return std::mem::take(&mut self.pending_completions); - } - let (mine, others) = std::mem::take(&mut self.pending_completions) - .into_iter() - .partition(|c| { - c.owner_session_id.is_empty() || c.owner_session_id == session_id - }); - self.pending_completions = others; - mine - } - pub fn discard_pending_completions_for(&mut self, session_id: &str) { - if session_id.is_empty() { - return; - } - self.pending_completions.retain(|c| c.owner_session_id != session_id); - } - fn enforce_pending_completions_cap(&mut self) { - const MAX_PENDING_COMPLETIONS: usize = 256; - if self.pending_completions.len() > MAX_PENDING_COMPLETIONS { - let excess = self.pending_completions.len() - MAX_PENDING_COMPLETIONS; - self.pending_completions.drain(..excess); - } - } - /// Collect references to subagents spawned for a specific parent prompt. - /// Returns only the children whose `parent_prompt_id` matches, so the - /// parent turn's `turn_result.json` accurately reflects what was spawned - /// during that turn — not the entire coordinator lifetime. - pub fn spawned_refs_for_prompt(&self, prompt_id: &str) -> Vec<SubagentSpawnedRef> { - let mut refs: Vec<_> = self - .active - .values() - .filter(|t| t.parent_prompt_id.as_deref() == Some(prompt_id)) - .map(|t| SubagentSpawnedRef { - subagent_id: t.subagent_id.clone(), - child_session_id: t.child_session_id.0.to_string(), - subagent_type: t.subagent_type.clone(), - description: t.description.clone(), - persona: t.persona.clone(), - resumed_from: t.resumed_from.clone(), - }) - .chain( - self - .completed - .values() - .filter(|c| c.parent_prompt_id.as_deref() == Some(prompt_id)) - .map(|c| SubagentSpawnedRef { - subagent_id: c.subagent_id.clone(), - child_session_id: c.child_session_id.clone(), - subagent_type: c.subagent_type.clone(), - description: c.description.clone(), - persona: c.persona.clone(), - resumed_from: c.resumed_from.clone(), - }), - ) - .collect(); - refs.sort_by(|a, b| a.subagent_id.cmp(&b.subagent_id)); - refs - } - /// Register a subagent as pending (initializing). Call this early, - /// before any blocking work like worktree creation, so that - /// `get_task_output` can report the subagent as initializing instead - /// of "not found". - pub fn insert_pending(&mut self, entry: PendingSubagent) { - self.pending.insert(entry.subagent_id.clone(), entry); - self.sync_running_gauge(); - } - /// Remove a pending subagent without recording a failure. - /// Used by cancel flows where the subagent was intentionally stopped. - #[cfg(test)] - pub fn remove_pending(&mut self, id: &str) { - self.pending.remove(id); - self.sync_running_gauge(); - } - /// Move a pending subagent directly to `completed` so it stays queryable via - /// `get_task_output`. `cancelled` stamps `"cancelled"` vs `"failed"`. - fn move_pending_to_terminal(&mut self, id: &str, error: &str, cancelled: bool) { - let Some(pending) = self.pending.remove(id) else { - return; - }; - self.record_failure_completion(FailureCompletion { - subagent_id: pending.subagent_id, - subagent_type: pending.subagent_type, - description: pending.description, - parent_prompt_id: pending.parent_prompt_id, - parent_session_id: pending.parent_session_id, - owner: pending.owner, - persona: pending.persona, - started_at: pending.started_at, - error, - surface_completion: pending.surface_completion, - cancelled, - }); - } - /// Move a pending subagent to `completed` as a failure so it stays queryable - /// via `get_task_output`. - pub fn move_pending_to_failed(&mut self, id: &str, error: &str) { - self.move_pending_to_terminal(id, error, false); - } - /// Like [`Self::move_pending_to_failed`] but stamps `"cancelled"` — a pending - /// subagent killed while initializing. - pub fn move_pending_to_cancelled(&mut self, id: &str, error: &str) { - self.move_pending_to_terminal(id, error, true); - } - /// Record a synthetic failure for a subagent that never reached `pending`. - pub fn record_pre_spawn_failure( - &mut self, - subagent_id: String, - subagent_type: String, - description: String, - parent_prompt_id: Option<String>, - parent_session_id: String, - owner: SubagentOwner, - error: &str, - surface_completion: bool, - ) { - self.record_failure_completion(FailureCompletion { - subagent_id, - subagent_type, - description, - parent_prompt_id, - parent_session_id, - owner, - persona: None, - started_at: std::time::Instant::now(), - error, - surface_completion, - cancelled: false, - }); - } - /// Insert a synthetic failed entry, push a completion summary, notify waiters. - /// Clears any stale pending entry for the same id. - fn record_failure_completion(&mut self, c: FailureCompletion<'_>) { - self.pending.remove(&c.subagent_id); - self.loop_owned.remove(&c.subagent_id); - self.sync_running_gauge(); - let FailureCompletion { - subagent_id, - subagent_type, - description, - parent_prompt_id, - parent_session_id, - owner, - persona, - started_at, - error, - surface_completion, - cancelled, - } = c; - let result = SubagentResult { - success: false, - cancelled, - error: Some(error.to_string()), - subagent_id: subagent_id.clone(), - ..Default::default() - }; - let summary_output = result.output.clone(); - let owner_session_id = parent_session_id.clone(); - self.completed - .insert( - subagent_id.clone(), - CompletedSubagent { - subagent_id: subagent_id.clone(), - parent_session_id, - parent_prompt_id, - owner, - child_session_id: String::new(), - description: description.clone(), - subagent_type: subagent_type.clone(), - persona, - started_at, - completed_at: std::time::Instant::now(), - result, - resumed_from: None, - child_cwd: String::new(), - worktree_path: None, - snapshot_ref: None, - effective_model_id: String::new(), - block_waited: false, - explicitly_killed: false, - completion_output_cap: None, - persisted_output_dir: None, - }, - ); - self.enforce_completed_cap(); - if surface_completion { - self.pending_completions - .push(SubagentCompletionSummary { - subagent_id, - owner_session_id, - subagent_type, - description, - success: false, - duration_ms: 0, - tool_calls: 0, - turns: 0, - output: summary_output, - }); - self.enforce_pending_completions_cap(); - } - self.completion_notify.notify_waiters(); - } - pub fn insert(&mut self, tracker: SubagentTracker) { - self.pending.remove(&tracker.subagent_id); - self.active.insert(tracker.subagent_id.clone(), tracker); - self.sync_running_gauge(); - } - /// Move a finished subagent from `active` to `completed`. - /// Returns the tracker if it was active. - pub fn move_to_completed( - &mut self, - id: &str, - description: String, - subagent_type: String, - result: SubagentResult, - persisted_output_dir: Option<PathBuf>, - ) -> Option<SubagentTracker> { - let tracker = self.active.remove(id); - self.loop_owned.remove(id); - self.sync_running_gauge(); - let started_at = tracker - .as_ref() - .map(|t| t.started_at) - .unwrap_or_else(std::time::Instant::now); - let parent_session_id = tracker - .as_ref() - .map(|t| t.parent_session_id.clone()) - .unwrap_or_default(); - let child_session_id = tracker - .as_ref() - .map(|t| t.child_session_id.0.to_string()) - .unwrap_or_default(); - let parent_prompt_id = tracker.as_ref().and_then(|t| t.parent_prompt_id.clone()); - let owner = tracker.as_ref().map(|t| t.owner.clone()).unwrap_or_default(); - let persona = tracker.as_ref().and_then(|t| t.persona.clone()); - let child_cwd = tracker - .as_ref() - .map(|t| t.child_cwd.clone()) - .unwrap_or_default(); - let worktree_path = tracker.as_ref().and_then(|t| t.worktree_path.clone()); - let resumed_from = tracker.as_ref().and_then(|t| t.resumed_from.clone()); - let effective_model_id = tracker - .as_ref() - .map(|t| t.effective_model_id.clone()) - .unwrap_or_default(); - let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited); - let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed); - let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion); - let completion_output_cap = tracker - .as_ref() - .and_then(|t| t.completion_output_cap); - let mut completed = CompletedSubagent { - subagent_id: id.to_string(), - parent_session_id, - parent_prompt_id, - owner, - child_session_id, - description, - subagent_type, - persona, - started_at, - completed_at: std::time::Instant::now(), - result, - resumed_from, - child_cwd, - worktree_path, - snapshot_ref: None, - effective_model_id, - block_waited, - explicitly_killed, - completion_output_cap, - persisted_output_dir, - }; - let success = completed.result.success && !completed.result.cancelled; - { - let preview = crate::util::truncate(&completed.result.output, 200); - let level_fn = if success { - xai_grok_telemetry::unified_log::info - } else { - xai_grok_telemetry::unified_log::error - }; - level_fn( - if success { "subagent completed" } else { "subagent failed" }, - None, - Some( - serde_json::json!({ - "subagent_id": &completed.subagent_id, - "subagent_type": &completed.subagent_type, - "effective_model": &completed.effective_model_id, - "success": success, - "cancelled": completed.result.cancelled, - "duration_ms": completed.result.duration_ms, - "turns": completed.result.turns, - "tool_calls": completed.result.tool_calls, - "output_preview": preview, - "error": &completed.result.error, - }), - ), - ); - } - if surface_completion { - self.pending_completions - .push(SubagentCompletionSummary { - subagent_id: id.to_string(), - owner_session_id: completed.parent_session_id.clone(), - subagent_type: completed.subagent_type.clone(), - description: completed.description.clone(), - success, - duration_ms: completed.result.duration_ms, - tool_calls: completed.result.tool_calls, - turns: completed.result.turns, - output: super::cap_completion_output( - &completed.result.output, - completed.completion_output_cap, - ), - }); - self.enforce_pending_completions_cap(); - } - if completed.persisted_output_dir.is_some() { - completed.result.output = Arc::from(""); - } - self.completed.insert(id.to_string(), completed); - self.enforce_completed_cap(); - self.completion_notify.notify_waiters(); - tracker - } - /// Record the durable worktree snapshot ref on a completed subagent so - /// in-memory `resume_from` resolution can rehydrate the disposed worktree. - /// No-op if the entry was already evicted (the on-disk meta.json still has it). - pub fn set_completed_snapshot_ref(&mut self, id: &str, snapshot_ref: String) { - if let Some(completed) = self.completed.get_mut(id) { - completed.snapshot_ref = Some(snapshot_ref); - } - } - /// Cancel all active subagents that were launched by a specific parent turn, - /// including `run_in_background: true` subagents. - pub fn cancel_by_parent_prompt_id(&mut self, parent_prompt_id: &str) { - for tracker in self.active.values() { - if tracker.parent_prompt_id.as_deref() == Some(parent_prompt_id) { - Self::cancel_tracker(tracker); - } - } - for pending in self.pending.values() { - if pending.parent_prompt_id.as_deref() == Some(parent_prompt_id) { - pending.cancel_token.cancel(); - } - } - } - pub fn cancel_workflow_children(&mut self, run_id: &str) -> usize { - for tracker in self.active.values() { - if tracker.owner.workflow_run_id() == Some(run_id) { - Self::cancel_tracker(tracker); - } - } - for pending in self.pending.values() { - if pending.owner.workflow_run_id() == Some(run_id) { - pending.cancel_token.cancel(); - } - } - self.outstanding_for_workflow(run_id) - } - pub fn outstanding_for_workflow(&self, run_id: &str) -> usize { - self - .pending - .values() - .filter(|entry| entry.owner.workflow_run_id() == Some(run_id)) - .count() - + self - .active - .values() - .filter(|entry| entry.owner.workflow_run_id() == Some(run_id)) - .count() - } - /// Attempt to cancel a subagent. Returns a typed outcome covering all cases: - /// - Active → cancel it, return Cancelled - /// - Pending (initializing) → fire its spawn token, return Cancelled - /// - Already finished → return AlreadyFinished with terminal status - /// - Unknown ID → return NotFound - pub fn cancel_with_outcome(&mut self, subagent_id: &str) -> SubagentCancelOutcome { - if let Some(tracker) = self.active.get(subagent_id) { - Self::cancel_tracker(tracker); - return SubagentCancelOutcome::Cancelled; - } - if let Some(pending) = self.pending.get(subagent_id) { - pending.cancel_token.cancel(); - return SubagentCancelOutcome::Cancelled; - } - if let Some(entry) = self.completed.get(subagent_id) { - return SubagentCancelOutcome::AlreadyFinished { - status: entry.result.status().to_string(), - }; - } - SubagentCancelOutcome::NotFound - } - /// Internal: send Cancel + Shutdown to a tracked subagent. - fn cancel_tracker(tracker: &SubagentTracker) { - tracker.cancel_token.cancel(); - let _ = tracker - .child_handle - .cmd_tx - .send(SessionCommand::Cancel { - cancel_subagents: true, - kill_background_tasks: true, - rewind_if_pristine: false, - trigger: None, - }); - let _ = tracker.child_handle.cmd_tx.send(SessionCommand::Shutdown); - } -} diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs deleted file mode 100644 index eb5a1c7..0000000 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs +++ /dev/null @@ -1,427 +0,0 @@ -#![cfg_attr(rustfmt, rustfmt::skip)] -#![allow(unused_imports)] -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use agent_client_protocol as acp; -use tokio::sync::{Notify, mpsc, oneshot}; -use tokio_util::sync::CancellationToken; -use crate::extensions::notification::{SessionNotification, SessionUpdate}; -use crate::session::{ - self, SessionCommand, SessionHandle, SessionThread, - commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult}, - fs_watch::FsWatchCapabilities, info::Info as SessionInfo, -}; -use crate::terminal::AsyncTerminalRunner; -use crate::tools::ToolContext; -use crate::upload::trace::{ - GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata, - local_sandbox_telemetry, upload_metadata, upload_session_state, - upload_subagent_metadata, upload_turn_result, -}; -use crate::upload::turn::{PromptTraceContext, complete_prompt_trace}; -use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; -use xai_grok_tools::implementations::grok_build::task::types::*; -use xai_grok_workspace::file_system::AsyncFileSystem; -use xai_hunk_tracker::HunkTrackerHandle; -use super::*; -impl SubagentCoordinator { - /// Synchronous lookup of a subagent by ID. - /// - /// Returns a three-way result so the caller can drop the `RefCell` borrow - /// before awaiting the signals handle for running subagents. - /// - /// - `Ready` — completed/failed/cancelled snapshot, no async work needed. - /// - `NeedsSignals` — subagent is running; caller must await - /// `resolve_snapshot()` after dropping the coordinator borrow. - /// - `None` — ID not found in active, completed, or pending maps. - pub(crate) fn lookup(&self, id: &str) -> Option<SnapshotLookup> { - if let Some(tracker) = self.active.get(id) { - if tracker.owner.is_workflow() { - return None; - } - return Some( - SnapshotLookup::NeedsSignals(RunningSnapshotSeed { - subagent_id: tracker.subagent_id.clone(), - description: tracker.description.clone(), - subagent_type: tracker.subagent_type.clone(), - started_at_epoch_ms: instant_to_epoch_ms(tracker.started_at), - duration_ms: tracker.started_at.elapsed().as_millis() as u64, - persona: tracker.persona.clone(), - signals_handle: tracker.child_handle.signals_handle.clone(), - }), - ); - } - if let Some(completed) = self.completed.get(id) { - if completed.owner.is_workflow() { - return None; - } - let status = if completed.result.cancelled { - SubagentSnapshotStatus::Cancelled { - reason: completed.result.error.clone(), - } - } else if completed.result.success { - let output = match &completed.persisted_output_dir { - Some(dir) => { - read_subagent_output(dir) - .unwrap_or_else(|| { - OUTPUT_UNAVAILABLE_PLACEHOLDER.to_string() - }) - } - None => completed.result.output.to_string(), - }; - SubagentSnapshotStatus::Completed { - output, - tool_calls: completed.result.tool_calls, - turns: completed.result.turns, - worktree_path: completed.result.worktree_path.clone(), - } - } else { - SubagentSnapshotStatus::Failed { - error: completed - .result - .error - .clone() - .unwrap_or_else(|| "Unknown error".to_string()), - } - }; - return Some( - SnapshotLookup::Ready(SubagentSnapshot { - subagent_id: completed.subagent_id.clone(), - description: completed.description.clone(), - subagent_type: completed.subagent_type.clone(), - status, - started_at_epoch_ms: instant_to_epoch_ms(completed.started_at), - duration_ms: completed.result.duration_ms, - persona: completed.persona.clone(), - }), - ); - } - if let Some(pending) = self.pending.get(id) { - if pending.owner.is_workflow() { - return None; - } - return Some( - SnapshotLookup::Ready(SubagentSnapshot { - subagent_id: pending.subagent_id.clone(), - description: pending.description.clone(), - subagent_type: pending.subagent_type.clone(), - status: SubagentSnapshotStatus::Initializing, - started_at_epoch_ms: instant_to_epoch_ms(pending.started_at), - duration_ms: pending.started_at.elapsed().as_millis() as u64, - persona: pending.persona.clone(), - }), - ); - } - None - } - /// Parent session of the running subagent whose child session is - /// `child_session_id`. Used to re-parent spawn requests that originate - /// inside a child session (e.g. a loop iteration spawning its own - /// subagent) to the root session that owns it. - pub(crate) fn parent_of_child_session( - &self, - child_session_id: &str, - ) -> Option<String> { - self.active - .values() - .find(|t| t.child_session_id.0.as_ref() == child_session_id) - .map(|t| t.parent_session_id.clone()) - } - /// Return `(parent_session_id, child_session_id)` for a given subagent. - /// - /// Checks active first, then completed. Returns `None` if not found. - pub(crate) fn session_ids_for(&self, id: &str) -> Option<(String, String)> { - if let Some(t) = self.active.get(id) { - return Some((t.parent_session_id.clone(), t.child_session_id.0.to_string())); - } - if let Some(c) = self.completed.get(id) { - return Some((c.parent_session_id.clone(), c.child_session_id.clone())); - } - None - } - /// Mark a subagent as block-waited so auto-wake is suppressed on completion. - pub(crate) fn mark_block_waited(&mut self, id: &str) { - if let Some(t) = self.active.get_mut(id) { - t.block_waited = true; - } else if let Some(c) = self.completed.get_mut(id) { - c.block_waited = true; - } - } - /// Clear the block-waited flag after a block timed out without receiving - /// the completion, so auto-wake can still fire when the subagent finishes. - pub(crate) fn clear_block_waited(&mut self, id: &str) { - if let Some(t) = self.active.get_mut(id) { - t.block_waited = false; - } else if let Some(c) = self.completed.get_mut(id) { - c.block_waited = false; - } - } - /// Whether a block-waiter already consumed this subagent's result. - pub(crate) fn is_block_waited(&self, id: &str) -> bool { - self.active.get(id).is_some_and(|t| t.block_waited) - || self.completed.get(id).is_some_and(|c| c.block_waited) - } - /// Register a live blocking-query reply slot and mark `block_waited`. - /// - /// The slot lets `block_wait_delivered_or_live` verify at completion - /// time that the waiter can still receive the result — the flag alone - /// can be stale when the waiting turn was cancelled moments before the - /// subagent finished. - pub(crate) fn register_block_wait(&mut self, id: &str, slot: BlockWaitSlot) { - self.mark_block_waited(id); - self.block_wait_slots.entry(id.to_string()).or_default().push(slot); - } - /// Drop a previously registered reply slot (query poll loop exited). - pub(crate) fn unregister_block_wait(&mut self, id: &str, slot: &BlockWaitSlot) { - if let Some(slots) = self.block_wait_slots.get_mut(id) { - slots.retain(|s| !std::rc::Rc::ptr_eq(s, slot)); - if slots.is_empty() { - self.block_wait_slots.remove(id); - } - } - } - /// Decision-time gate for the completion auto-wake: returns true when - /// the result was already delivered to a blocking waiter, or a live - /// waiter is still parked and will receive it. When every registered - /// waiter is gone (receivers dropped by a cancelled turn), clears - /// `block_waited` and returns false so the auto-wake fires. - /// - /// This closes the race where the query poll loop clears the flag up to - /// one poll interval *after* the caller cancelled — the completion - /// handler could read the stale flag in that window and skip the wake. - /// Consumes the id's slot registrations (completion is terminal). - pub(crate) fn block_wait_delivered_or_live(&mut self, id: &str) -> bool { - let slots = self.block_wait_slots.remove(id).unwrap_or_default(); - if !self.is_block_waited(id) { - return false; - } - let delivered_or_live = slots.is_empty() - || slots - .iter() - .any(|s| s.borrow().as_ref().is_none_or(|tx| !tx.is_closed())); - if !delivered_or_live { - self.clear_block_waited(id); - } - delivered_or_live - } - /// Mark a subagent as explicitly killed so auto-wake is suppressed on completion. - pub(crate) fn mark_explicitly_killed(&mut self, id: &str) { - if let Some(t) = self.active.get_mut(id) { - t.explicitly_killed = true; - } else if let Some(c) = self.completed.get_mut(id) { - c.explicitly_killed = true; - } - } - /// Whether the model explicitly killed this subagent via the kill tool. - pub(crate) fn is_explicitly_killed(&self, id: &str) -> bool { - self.active.get(id).is_some_and(|t| t.explicitly_killed) - || self.completed.get(id).is_some_and(|c| c.explicitly_killed) - } - /// Return fork provenance for a given subagent. - pub(crate) fn provenance_for(&self, id: &str) -> SubagentProvenance { - if let Some(t) = self.active.get(id) { - return SubagentProvenance { - fork_parent_prompt_id: t.parent_prompt_id.clone(), - resumed_from: t.resumed_from.clone(), - }; - } - if let Some(c) = self.completed.get(id) { - return SubagentProvenance { - fork_parent_prompt_id: c.parent_prompt_id.clone(), - resumed_from: c.resumed_from.clone(), - }; - } - SubagentProvenance::default() - } - /// Resolve a completed subagent scoped to the requesting parent session. - /// - /// Returns `None` if the subagent is not found, still active, or belongs - /// to a different parent session (prevents cross-session context bleed). - /// - /// Fast path: checks the in-memory `completed` map first. When that - /// misses (e.g. after cap eviction), falls back to on-disk metadata - /// in `{parent_session_dir}/subagents/{id}/meta.json`. - pub(crate) fn resumable_source_for( - &self, - id: &str, - parent_session_id: &str, - parent_cwd: &Path, - ) -> Option<ResumeSourceData> { - if let Some(completed) = self.completed.get(id) { - if completed.parent_session_id != parent_session_id { - return None; - } - return Some(ResumeSourceData { - subagent_id: completed.subagent_id.clone(), - child_session_id: completed.child_session_id.clone(), - child_cwd: completed.child_cwd.clone(), - worktree_path: completed.worktree_path.clone(), - snapshot_ref: completed.snapshot_ref.clone(), - subagent_type: completed.subagent_type.clone(), - persona: completed.persona.clone(), - model_id: Some(completed.effective_model_id.clone()), - }); - } - let parent_info = SessionInfo { - id: acp::SessionId::new(parent_session_id), - cwd: parent_cwd.to_string_lossy().to_string(), - }; - let meta_path = session::persistence::session_dir(&parent_info) - .join("subagents") - .join(id) - .join("meta.json"); - let data = std::fs::read_to_string(&meta_path).ok()?; - let meta: SubagentMeta = serde_json::from_str(&data).ok()?; - if meta.parent_session_id != parent_session_id { - return None; - } - match meta.status.as_str() { - "completed" | "failed" | "cancelled" => {} - _ => return None, - } - Some(ResumeSourceData { - subagent_id: meta.subagent_id, - child_session_id: meta.child_session_id, - child_cwd: meta.child_cwd.unwrap_or_default(), - worktree_path: meta.worktree_path.map(PathBuf::from), - snapshot_ref: meta.snapshot_ref, - subagent_type: meta.subagent_type, - persona: meta.persona, - model_id: meta.effective_model_id, - }) - } - /// Check whether an ID refers to a currently-active (running) subagent. - pub(crate) fn is_active(&self, id: &str) -> bool { - self.active.contains_key(id) - } - /// Whether the coordinator still has this id in flight (spawning or running). - /// Orphan reconcile skips these — there is nothing stuck to heal. - pub(crate) fn is_active_or_pending(&self, id: &str) -> bool { - self.active.contains_key(id) || self.pending.contains_key(id) - } - pub(crate) fn record_loop_owner(&mut self, subagent_id: &str, task_id: &str) { - self.loop_owned.insert(subagent_id.to_string(), task_id.to_string()); - } - pub(crate) fn remove_loop_owner(&mut self, subagent_id: &str) { - self.loop_owned.remove(subagent_id); - } - pub(crate) fn loop_task_id_of_child_session( - &self, - child_session_id: &str, - ) -> Option<String> { - let subagent_id = self - .active - .values() - .find(|t| t.child_session_id.0.as_ref() == child_session_id)? - .subagent_id - .clone(); - self.loop_owned.get(&subagent_id).cloned() - } - pub(crate) fn loop_unit_active(&self, task_id: &str) -> bool { - self.loop_owned.values().any(|t| t == task_id) - } - /// The terminal `SubagentFinished` for an id the coordinator already holds in - /// `completed`, else `None`. Lets orphan reconcile re-emit a subagent's real - /// outcome when only its terminal meta write was lost (reconnect race: entry - /// in `completed` but the on-disk meta is still `running`) instead of - /// force-cancelling it and discarding the result. - pub(crate) fn completed_finish(&self, id: &str) -> Option<SessionUpdate> { - let c = self.completed.get(id)?; - let duration_ms = c - .completed_at - .saturating_duration_since(c.started_at) - .as_millis() as u64; - Some(SessionUpdate::SubagentFinished { - subagent_id: c.subagent_id.clone(), - child_session_id: c.child_session_id.clone(), - status: c.result.status().to_string(), - error: c.result.error.clone(), - tool_calls: c.result.tool_calls, - turns: c.result.turns, - duration_ms, - tokens_used: 0, - output: None, - will_wake: false, - }) - } - /// Lifecycle-map entry counts as `(pending, active, completed)`. - pub(crate) fn registry_snapshot(&self) -> (usize, usize, usize) { - (self.pending.len(), self.active.len(), self.completed.len()) - } - /// Oldest completions are evicted first; their `output.json` stays on disk. - pub fn enforce_completed_cap(&mut self) { - if self.completed.len() <= MAX_COMPLETED_ENTRIES { - return; - } - let excess = self.completed.len() - MAX_COMPLETED_ENTRIES; - let mut by_age: Vec<(std::time::Instant, String)> = self - .completed - .iter() - .map(|(id, e)| (e.completed_at, id.clone())) - .collect(); - by_age.sort_unstable_by_key(|(completed_at, _)| *completed_at); - for (_, id) in by_age.into_iter().take(excess) { - self.completed.remove(&id); - } - } - /// Snapshot all currently-running subagents for compaction state context. - /// - /// Completed/failed/cancelled subagents are NOT included — they live in - /// the `completed` map and are irrelevant for post-compaction reminders - /// (the model already saw their tool results before compaction). - /// - /// The `elapsed_ms` field is computed from `started_at.elapsed()` at call - /// time, so the values are a snapshot of "right now" — appropriate for - /// compaction since it happens once and the reminder is static. - #[cfg(test)] - pub fn active_summaries(&self) -> Vec<ActiveSubagentSummary> { - self.active - .values() - .filter(|t| !t.owner.is_workflow()) - .map(tracker_to_summary) - .collect() - } - pub fn active_summaries_for( - &self, - parent_session_id: &str, - ) -> Vec<ActiveSubagentSummary> { - self.active - .values() - .filter(|t| { - t.parent_session_id == parent_session_id && !t.owner.is_workflow() - }) - .map(tracker_to_summary) - .collect() - } - /// - /// Each seed carries copied identity metadata plus a cloned - /// `SessionSignalsHandle` so the caller can resolve live progress - /// asynchronously after dropping the coordinator borrow. - /// - /// Returns an empty `Vec` if no active subagents match the given - /// parent session ID. Callers (e.g. the `x.ai/subagent/list_running` - /// ACP handler) should treat an empty result as a normal "no running - /// subagents" response, not an error. - pub(crate) fn list_running_for_parent( - &self, - parent_session_id: &str, - ) -> Vec<RunningSubagentListSeed> { - self.active - .values() - .filter(|t| { - t.parent_session_id == parent_session_id && !t.owner.is_workflow() - }) - .map(|t| RunningSubagentListSeed { - subagent_id: t.subagent_id.clone(), - parent_session_id: t.parent_session_id.clone(), - child_session_id: t.child_session_id.0.to_string(), - subagent_type: t.subagent_type.clone(), - description: t.description.clone(), - started_at_epoch_ms: instant_to_epoch_ms(t.started_at), - duration_ms: t.started_at.elapsed().as_millis() as u64, - signals_handle: t.child_handle.signals_handle.clone(), - }) - .collect() - } -} diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs index 027fdac..4f1a4b9 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs @@ -1,48 +1,6 @@ -#![cfg_attr(rustfmt, rustfmt::skip)] -#![allow(unused_imports)] -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use agent_client_protocol as acp; -use tokio::sync::{Notify, mpsc, oneshot}; -use tokio_util::sync::CancellationToken; -use crate::extensions::notification::{SessionNotification, SessionUpdate}; -use crate::session::{ - self, SessionCommand, SessionHandle, SessionThread, - commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult}, - fs_watch::FsWatchCapabilities, info::Info as SessionInfo, -}; -use crate::terminal::AsyncTerminalRunner; -use crate::tools::ToolContext; -use crate::upload::trace::{ - GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata, - local_sandbox_telemetry, upload_metadata, upload_session_state, - upload_subagent_metadata, upload_turn_result, -}; -use crate::upload::turn::{PromptTraceContext, complete_prompt_trace}; -use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; -use xai_grok_tools::implementations::grok_build::task::types::*; -use xai_grok_workspace::file_system::AsyncFileSystem; -use xai_hunk_tracker::HunkTrackerHandle; use super::*; -/// Remove the task tool (and orphaned background-task actions) from a child -/// toolset at or beyond `MAX_SUBAGENT_DEPTH`. Returns whether the task tool -/// was removed. -pub(super) fn strip_task_tools_at_max_depth( - tool_config: &mut xai_grok_tools::registry::types::ToolServerConfig, - child_depth: u32, -) -> bool { - use xai_grok_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH; - use xai_grok_tools::types::tool::ToolKind; - if child_depth < MAX_SUBAGENT_DEPTH { - return false; - } - let before = tool_config.tools.len(); - tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task)); - let stripped = tool_config.tools.len() < before; - prune_orphaned_background_task_tools(tool_config); - stripped -} +use xai_grok_sampling_types::ReasoningEffort; +use xai_grok_tools::implementations::{grok_build, opencode}; pub(super) fn canonical_total_tokens(totals: &xai_chat_state::UsageTotals) -> u64 { totals.total_tokens() } @@ -54,6 +12,35 @@ pub(super) fn usage_is_incomplete( ) -> bool { ledger_incomplete || cancellation_may_hide_usage } +pub(super) async fn record_subagent_usage( + parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>, + by_model: Option<Vec<(String, xai_chat_state::UsageTotals)>>, + parent_prompt_id: Option<String>, + incomplete: bool, +) -> bool { + match by_model { + None => false, + Some(by_model) if by_model.is_empty() && !incomplete => true, + Some(by_model) => { + let Some(cmd_tx) = parent_cmd_tx else { + return false; + }; + let (respond_to, ack) = oneshot::channel(); + if cmd_tx + .send(SessionCommand::RecordSubagentUsage { + by_model, + parent_prompt_id, + incomplete, + respond_to, + }) + .is_err() + { + return false; + } + ack.await.is_ok() + } + } +} pub(super) fn task_model_override_error( requested: Option<&str>, provenance: ModelOverrideProvenance, @@ -65,17 +52,10 @@ pub(super) fn task_model_override_error( return None; } let requested = requested?; - crate::agent::models::task_model_error_for_catalog( - requested, - available, - is_session_auth, - ) + crate::agent::models::task_model_error_for_catalog(requested, available, is_session_auth) } -/// This is a free async function, NOT a method on MvpAgent. It receives -/// a `SubagentSpawnContext` with everything it needs, and a mutable -/// reference to the coordinator for tracking. -/// -/// Returns when the child session completes (or fails/cancels). +/// Runtime adapter for one shell child. Shared lifecycle state is owned by the +/// `xai-grok-tools` coordinator actor and reached only through `reporter`. #[tracing::instrument( name = "subagent.handle_request", skip_all, @@ -85,27 +65,25 @@ pub(super) fn task_model_override_error( subagent_type = %request.subagent_type, ) )] -pub(crate) async fn handle_subagent_request( +pub(crate) async fn run_shell_child( mut request: SubagentRequest, mut ctx: SubagentSpawnContext, - coordinator: &std::cell::RefCell<SubagentCoordinator>, + cancel_token: CancellationToken, + reporter: ChildReporter<ShellChildRuntime>, gateway: &GatewaySender, -) { +) -> ChildRunOutput<ShellCompletionData> { let start = std::time::Instant::now(); - let mut parent_wait_guard = subagent_blocks_parent_turn(&request) - .then(|| crate::tools::tool_context::BlockingWaitGuard::enter( - ctx.parent_blocking_wait_depth.clone(), - )); - if request.owner.is_workflow() && request.cancel_token.is_cancelled() { - parent_wait_guard.take(); - send_pre_spawn_cancelled(request, "Subagent was cancelled"); - return; + let mut completion_data = ShellCompletionData::from_context(&ctx); + if request.owner.is_workflow() && cancel_token.is_cancelled() { + return child_run_output( + cancelled_result(&request, "Subagent was cancelled"), + completion_data, + None, + ); } - let Some(mut definition) = resolve_agent_definition(&request.subagent_type, &ctx) - else { + let Some(mut definition) = resolve_agent_definition(&request.subagent_type, &ctx) else { let msg = format!("Unknown subagent type: {}", request.subagent_type); - send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway); - return; + return child_run_output(failure_result(&request, &msg), completion_data, None); }; match gate_subagent_type(&request.subagent_type, &ctx) { SubagentValidateTypeOutcome::Disabled => { @@ -113,8 +91,7 @@ pub(crate) async fn handle_subagent_request( "Subagent '{}' is disabled via [subagents.toggle] in config.toml", request.subagent_type ); - send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway); - return; + return child_run_output(failure_result(&request, &msg), completion_data, None); } SubagentValidateTypeOutcome::NotAllowed { allowed } => { let msg = format!( @@ -122,83 +99,37 @@ pub(crate) async fn handle_subagent_request( allowed.join(", "), request.subagent_type ); - send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway); - return; + return child_run_output(failure_result(&request, &msg), completion_data, None); + } + SubagentValidateTypeOutcome::Unknown { .. } + | SubagentValidateTypeOutcome::ValidationUnavailable => { + let msg = format!("Cannot validate subagent '{}'", request.subagent_type); + return child_run_output(failure_result(&request, &msg), completion_data, None); + } + SubagentValidateTypeOutcome::Ok => {} + _ => { + let msg = format!("Cannot validate subagent '{}'", request.subagent_type); + return child_run_output(failure_result(&request, &msg), completion_data, None); } - _ => {} } - let run_in_background = request.run_in_background - || definition.background.unwrap_or(false); - let cancel_token = request.cancel_token.clone(); - coordinator - .borrow_mut() - .insert_pending(PendingSubagent { - subagent_id: request.id.clone(), - subagent_type: request.subagent_type.clone(), - description: request.description.clone(), - persona: request.runtime_overrides.persona.clone(), - parent_prompt_id: request.parent_prompt_id.clone(), - parent_session_id: ctx.parent_session_id.clone(), - owner: request.owner.clone(), - started_at: start, - run_in_background, - surface_completion: request.surface_completion, - color: definition.color, - cancel_token: cancel_token.clone(), - }); - let mut pending_guard = PendingGuard { - coordinator, - id: request.id.clone(), - defused: false, - error: None, - }; resolve_subagent_toolset( &request.subagent_type, request.runtime_overrides.harness_agent_type.as_deref(), &ctx, &mut definition, ); - let (role, role_key) = { - let by_type = ctx.subagent_roles.get(&request.subagent_type); - if by_type.is_some() { - (by_type, Some(request.subagent_type.clone())) - } else { - let by_persona = request - .runtime_overrides - .persona - .as_deref() - .and_then(|p| ctx.subagent_roles.get(p)); - let key = if by_persona.is_some() { - request.runtime_overrides.persona.clone() - } else { - None - }; - (by_persona, key) - } - }; - let cwd = ctx.parent_session_info.as_ref().map(|i| std::path::Path::new(&i.cwd)); - let effective_runtime = resolve_effective_overrides( + let cwd = ctx + .parent_session_info + .as_ref() + .map(|i| std::path::Path::new(&i.cwd)); + let mut effective_runtime = xai_grok_subagent_resolution::resolve_runtime_config( + &request.subagent_type, &request.runtime_overrides, - role, + &ctx.subagent_roles, &ctx.subagent_personas, cwd, - role_key, + &definition, ); - let mut effective_runtime = effective_runtime; - if effective_runtime.reasoning_effort.is_none() { - effective_runtime.reasoning_effort = definition - .effort - .map(|e| <&str>::from(e).to_string()); - } - { - use xai_tool_types::SubagentIsolationMode; - if effective_runtime.isolation == SubagentIsolationMode::None - && definition.isolation - == Some(xai_grok_agent::config::IsolationMode::Worktree) - { - effective_runtime.isolation = SubagentIsolationMode::Worktree; - } - } let prompt = request.prompt.clone(); if let Some(ref err) = effective_runtime.persona_error { tracing::error!( @@ -206,9 +137,7 @@ pub(crate) async fn handle_subagent_request( error = err, "Persona resolution failed, aborting subagent spawn" ); - pending_guard.set_error(err.clone()); - send_failure(request, err); - return; + return child_run_output(failure_result(&request, err), completion_data, None); } if let Some(ref warn) = effective_runtime.role_prompt_warning { tracing::warn!( @@ -222,31 +151,43 @@ pub(crate) async fn handle_subagent_request( .as_deref() .filter(|s| is_valid_resume_id(s)) { - let coord = coordinator.borrow(); - if coord.is_active(resume_id) { - let msg = format!( - "Cannot resume from subagent '{resume_id}': it is still running. \ - Wait for it to complete before resuming." - ); - drop(coord); - send_failure(request, &msg); - return; - } - match coord - .resumable_source_for(resume_id, &ctx.parent_session_id, &ctx.parent_cwd) + match reporter + .resume_source(resume_id, &ctx.parent_session_id) + .await { - Some(info) => { - drop(coord); - Some(info) - } - None => { + SubagentResumeLookup::Active => { let msg = format!( - "Cannot resume from subagent '{resume_id}': not found. \ - The subagent may have been evicted or the ID is invalid." + "Cannot resume from subagent '{resume_id}': it is still running. \ + Wait for it to complete before resuming." ); - drop(coord); - send_failure(request, &msg); - return; + return child_run_output(failure_result(&request, &msg), completion_data, None); + } + SubagentResumeLookup::Completed(info) => Some(ResumeSourceData { + subagent_id: info.subagent_id, + child_session_id: info.child_session_id, + child_cwd: info.child_cwd, + worktree_path: info.worktree_path.map(PathBuf::from), + snapshot_ref: info.snapshot_ref, + subagent_type: info.subagent_type, + persona: info.persona, + model_id: info.model_id, + }), + SubagentResumeLookup::Missing => { + match durable_resume_source_for(resume_id, &ctx.parent_session_id, &ctx.parent_cwd) + { + Some(info) => Some(info), + None => { + let msg = format!( + "Cannot resume from subagent '{resume_id}': not found. \ + The subagent may have been evicted or the ID is invalid." + ); + return child_run_output( + failure_result(&request, &msg), + completion_data, + None, + ); + } + } } } } else { @@ -265,8 +206,11 @@ pub(crate) async fn handle_subagent_request( request.runtime_overrides.persona.as_deref(), source, ) { - send_failure(request, &e.to_string()); - return; + return child_run_output( + failure_result(&request, &e.to_string()), + completion_data, + None, + ); } } if let Some(error) = task_model_override_error( @@ -274,11 +218,11 @@ pub(crate) async fn handle_subagent_request( request.runtime_overrides.model_override_provenance, resume_source.is_some(), &ctx.available_models, - ctx.auth_manager.current_or_expired().is_some_and(|a| a.is_session_auth()), + ctx.auth_manager + .current_or_expired() + .is_some_and(|a| a.is_session_auth()), ) { - pending_guard.set_error(error.clone()); - send_failure(request, &error); - return; + return child_run_output(failure_result(&request, &error), completion_data, None); } let worktree_path = if let Some(ref source) = resume_source { if effective_runtime.isolation != xai_tool_types::SubagentIsolationMode::None @@ -292,24 +236,18 @@ pub(crate) async fn handle_subagent_request( match source.worktree_path.as_deref() { None => None, Some(dest) => { - match resume_worktree_action( - dest.is_dir(), - source.snapshot_ref.as_deref(), - ) { + match resume_worktree_action(dest.is_dir(), source.snapshot_ref.as_deref()) { ResumeWorktreeAction::Reuse => Some(dest.to_path_buf()), ResumeWorktreeAction::Rehydrate => { - let snapshot_ref = source - .snapshot_ref - .clone() - .unwrap_or_default(); + let snapshot_ref = source.snapshot_ref.clone().unwrap_or_default(); let source_repo = resolve_subagent_source_repo(&ctx); match crate::session::worktree::rehydrate_subagent_worktree( - dest, - &source_repo, - &snapshot_ref, - Some(source.subagent_id.as_str()), - ) - .await + dest, + &source_repo, + &snapshot_ref, + Some(source.subagent_id.as_str()), + ) + .await { Ok(path) => { tracing::info!( @@ -341,12 +279,9 @@ pub(crate) async fn handle_subagent_request( } } } - } else if effective_runtime.isolation != xai_tool_types::SubagentIsolationMode::None - { + } else if effective_runtime.isolation != xai_tool_types::SubagentIsolationMode::None { let source_cwd = parent_source_cwd(&ctx); - let dest = match crate::session::worktree::worktree_base_dir_for_source( - &source_cwd, - ) { + let dest = match crate::session::worktree::worktree_base_dir_for_source(&source_cwd) { Ok(base) => base.join(format!("subagent-{}", request.id)), Err(e) => { tracing::warn!( @@ -354,7 +289,9 @@ pub(crate) async fn handle_subagent_request( error = %e, "Could not resolve worktree base dir, using temp dir for subagent worktree" ); - std::env::temp_dir().join("grok-subagent-worktrees").join(&request.id) + std::env::temp_dir() + .join("grok-subagent-worktrees") + .join(&request.id) } }; let source_clone = source_cwd; @@ -362,22 +299,17 @@ pub(crate) async fn handle_subagent_request( let creation_mode: xai_fast_worktree::CreationMode = ctx.worktree_type.into(); let btrfs_delegate = crate::session::worktree::btrfs_delegate_from_env(); match tokio::task::spawn_blocking(move || { - let mut builder = xai_fast_worktree::WorktreeBuilder::new( - &source_clone, - &dest, - ) - .working_tree_mode( - xai_fast_worktree::WorkingTreeMode::PreserveWorkingTree, - ) - .creation_mode(creation_mode) - .worktree_kind(xai_fast_worktree::WorktreeKind::Subagent) - .session_id(subagent_id); - if let Some(delegate) = btrfs_delegate { - builder = builder.btrfs_delegate(delegate); - } - builder.create() - }) - .await + let mut builder = xai_fast_worktree::WorktreeBuilder::new(&source_clone, &dest) + .working_tree_mode(xai_fast_worktree::WorkingTreeMode::PreserveWorkingTree) + .creation_mode(creation_mode) + .worktree_kind(xai_fast_worktree::WorktreeKind::Subagent) + .session_id(subagent_id); + if let Some(delegate) = btrfs_delegate { + builder = builder.btrfs_delegate(delegate); + } + builder.create() + }) + .await { Ok(Ok(report)) => { tracing::info!( @@ -420,8 +352,11 @@ pub(crate) async fn handle_subagent_request( } else { format!("cwd \"{cwd_path}\" does not exist") }; - send_failure(request, &msg); - return; + return child_run_output( + failure_result(&request, &msg), + completion_data, + None, + ); } } request.cwd = Some(cwd_path); @@ -429,9 +364,7 @@ pub(crate) async fn handle_subagent_request( None => request.cwd = None, } } - if effective_runtime.reasoning_effort.is_some() - || effective_runtime.capability_mode.is_some() - { + if effective_runtime.reasoning_effort.is_some() || effective_runtime.capability_mode.is_some() { tracing::info!( subagent_id = %request.id, reasoning_effort = ?effective_runtime.reasoning_effort, @@ -443,8 +376,19 @@ pub(crate) async fn handle_subagent_request( effective_runtime.capability_mode, definition.capability_mode, ); + let child_depth = request + .runtime_overrides + .spawn_depth + .unwrap_or(ctx.parent_depth + 1); + let tools_before_policy = definition.tool_config.tools.len(); + let allow_nested_subagents = + child_depth < xai_grok_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH; + xai_grok_subagent_resolution::apply_child_tool_policy( + &mut definition, + effective_runtime.capability_mode, + allow_nested_subagents, + ); if let Some(mode) = effective_runtime.capability_mode { - mode.filter_tool_config(&mut definition.tool_config); tracing::info!( subagent_id = %request.id, capability_mode = ?mode, @@ -452,11 +396,7 @@ pub(crate) async fn handle_subagent_request( "Applied capability mode filter to agent tool config" ); } - let child_depth = request - .runtime_overrides - .spawn_depth - .unwrap_or(ctx.parent_depth + 1); - if strip_task_tools_at_max_depth(&mut definition.tool_config, child_depth) { + if !allow_nested_subagents && definition.tool_config.tools.len() < tools_before_policy { tracing::info!( subagent_id = %request.id, child_depth, @@ -464,35 +404,33 @@ pub(crate) async fn handle_subagent_request( ); } if request.owner.is_workflow() { - definition - .tool_config - .tools - .retain(|tool| { - !matches!( + definition.tool_config.tools.retain(|tool| { + !matches!( tool.id.rsplit(':').next(), Some("scheduler_create" | "scheduler_list" | "scheduler_delete") ) - }); + }); } if request.fork_context { effective_runtime.model = Some(ctx.model_id.0.to_string()); } let (mut effective_sampling_config, mut effective_model_id) = resolve_effective_model_config( - effective_runtime.model.as_deref(), - &request.subagent_type, - &definition.model, - &ctx, - ) - .await; - let subagent_max_turns = resolve_subagent_max_turns( - definition.max_turns, - ctx.parent_max_turns, - ); + effective_runtime.model.as_deref(), + &request.subagent_type, + &definition.model, + &ctx, + ) + .await; + let subagent_max_turns = resolve_subagent_max_turns(definition.max_turns, ctx.parent_max_turns); { let model_str = &effective_sampling_config.model; - let model_unknown = !model_str.is_empty() && !ctx.available_models.is_empty() + let model_unknown = !model_str.is_empty() + && !ctx.available_models.is_empty() && !ctx.available_models.contains_key(model_str) - && !ctx.available_models.values().any(|e| e.info().model == *model_str); + && !ctx + .available_models + .values() + .any(|e| e.info().model == *model_str); if model_unknown { let (parent_config, parent_mid) = read_parent_sampling_config(&ctx).await; tracing::warn!( @@ -525,8 +463,7 @@ pub(crate) async fn handle_subagent_request( is no longer available in the model catalogue.", source.subagent_id, ); - send_failure(request, &msg); - return; + return child_run_output(failure_result(&request, &msg), completion_data, None); } } if let Some(raw) = effective_runtime.reasoning_effort.as_deref() @@ -534,29 +471,21 @@ pub(crate) async fn handle_subagent_request( .models_manager .model_supports_reasoning_effort(effective_model_id.0.as_ref()) { - use xai_grok_sampling_types::ReasoningEffort; match raw.parse::<ReasoningEffort>() { Ok(eff) => effective_sampling_config.reasoning_effort = Some(eff), Err(err) => { tracing::warn!( - value = raw, - error = %err, - "subagent reasoning_effort: parse failed, ignoring override" - ) + value = raw, + error = %err, + "subagent reasoning_effort: parse failed, ignoring override" + ) } } } let subagent_id = request.id.clone(); let child_session_id = acp::SessionId::new(subagent_id.clone()); - let override_cwd = select_override_cwd( - resume_source.as_ref(), - request.cwd.as_deref(), - ); - let effective_cwd = resolve_child_cwd( - worktree_path.as_deref(), - override_cwd, - &ctx.parent_cwd, - ) + let override_cwd = select_override_cwd(resume_source.as_ref(), request.cwd.as_deref()); + let effective_cwd = resolve_child_cwd(worktree_path.as_deref(), override_cwd, &ctx.parent_cwd) .to_string_lossy() .into_owned(); let child_session_info = SessionInfo { @@ -564,12 +493,10 @@ pub(crate) async fn handle_subagent_request( cwd: effective_cwd, }; let child_session_dir = session::persistence::session_dir(&child_session_info); - let parent_session_dir = session::persistence::session_dir( - &SessionInfo { - id: acp::SessionId::new(ctx.parent_session_id.clone()), - cwd: ctx.parent_cwd.to_string_lossy().to_string(), - }, - ); + let parent_session_dir = session::persistence::session_dir(&SessionInfo { + id: acp::SessionId::new(ctx.parent_session_id.clone()), + cwd: ctx.parent_cwd.to_string_lossy().to_string(), + }); let subagent_meta_dir = parent_session_dir.join("subagents").join(&subagent_id); let InitialContext { source: context_source, @@ -578,15 +505,15 @@ pub(crate) async fn handle_subagent_request( conversation: forked_conversation, verbatim_fork: context_verbatim_fork, } = match bootstrap_initial_context( - &request, - resume_source.as_ref(), - &ctx, - &child_session_info, - &child_session_dir, - effective_model_id.0.as_ref(), - effective_sampling_config.context_window, - ) - .await + &request, + resume_source.as_ref(), + &ctx, + &child_session_info, + &child_session_dir, + effective_model_id.0.as_ref(), + effective_sampling_config.context_window, + ) + .await { BootstrapInitialContext::Ready(ctx) => ctx, BootstrapInitialContext::ResumeAbort(msg) => { @@ -595,18 +522,16 @@ pub(crate) async fn handle_subagent_request( error = %msg, "Resume-copy failed, aborting subagent spawn" ); - send_failure(request, &msg); - return; + return child_run_output(failure_result(&request, &msg), completion_data, None); } }; - let verbatim_mirror_fork = context_source == InitialContextSource::Forked - && context_verbatim_fork; + let verbatim_mirror_fork = + context_source == InitialContextSource::Forked && context_verbatim_fork; let task_prompt_text = prompt.clone(); - let (mut forked_conversation, mut inherited_prefix_len) = ( - forked_conversation, - inherited_prefix_len.unwrap_or(0), - ); - if context_source != InitialContextSource::Resumed && !verbatim_mirror_fork + let (mut forked_conversation, mut inherited_prefix_len) = + (forked_conversation, inherited_prefix_len.unwrap_or(0)); + if context_source != InitialContextSource::Resumed + && !verbatim_mirror_fork && let Some(ref pi) = effective_runtime.persona_instructions { let reminder = xai_grok_sampling_types::conversation::ConversationItem::system_reminder( @@ -636,23 +561,19 @@ pub(crate) async fn handle_subagent_request( turns: None, error: None, effective_context_source: Some(effective_source_str.to_string()), - context_normalized: fork_context_normalized( - &context_source, - context_verbatim_fork, - ), + context_normalized: fork_context_normalized(&context_source, context_verbatim_fork), fork_copy_error: fork_copy_error.clone(), persona: effective_runtime.persona.clone(), resumed_from: request.resume_from.clone(), child_cwd: Some(child_session_info.cwd.clone()), - worktree_path: worktree_path.as_ref().map(|p| p.to_string_lossy().to_string()), + worktree_path: worktree_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), snapshot_ref: None, effective_model_id: Some(effective_model_id.0.to_string()), }; write_subagent_meta(&subagent_meta_dir, &subagent_meta); - if let (Some(bucket_url), Some(upload_method)) = ( - &ctx.gcs_bucket_url, - &ctx.gcs_upload_method, - ) { + if let (Some(bucket_url), Some(upload_method)) = (&ctx.gcs_bucket_url, &ctx.gcs_upload_method) { let gcs_meta = SubagentSessionMetadata::from_meta( &subagent_meta, Some(&*effective_model_id.0), @@ -699,10 +620,7 @@ pub(crate) async fn handle_subagent_request( subagent_type: request.subagent_type.clone(), description: request.description.clone(), effective_context_source: Some(effective_source_str.to_string()), - context_normalized: fork_context_normalized( - &context_source, - context_verbatim_fork, - ), + context_normalized: fork_context_normalized(&context_source, context_verbatim_fork), capability_mode: effective_runtime .capability_mode .and_then(|m| serde_json::to_value(m).ok()) @@ -715,6 +633,7 @@ pub(crate) async fn handle_subagent_request( }, ctx.parent_cmd_tx.as_ref(), ); + completion_data.spawned_notification_emitted = true; let early_gcs_ctx = GcsUploadContext { bucket_url: ctx.gcs_bucket_url.clone(), upload_method: ctx.gcs_upload_method.clone(), @@ -728,61 +647,45 @@ pub(crate) async fn handle_subagent_request( depth: 0, auth_manager: ctx.auth_manager.clone(), }; - let sampling_client = match crate::sampling::Client::new( - effective_sampling_config.clone(), - ) { + let sampling_client = match crate::sampling::Client::new(effective_sampling_config.clone()) { Ok(c) => c, Err(e) => { let msg = format!("Sampling client error: {e}"); - pending_guard.set_error(msg.clone()); - fail_subagent( - request, + let result = fail_subagent( &msg, &subagent_id, &child_session_id, &subagent_meta_dir, - gateway, - &ctx.parent_session_id, - ctx.parent_cmd_tx.as_ref(), 0, &early_gcs_ctx, ); - return; + return child_run_output(result, completion_data, None); } }; let persistence = match session::persistence::new_with_explicit_dir( - &child_session_info, - child_session_dir.clone(), - effective_model_id.clone(), - sampling_client, - effective_sampling_config.model.clone(), - ) - .await + &child_session_info, + child_session_dir.clone(), + effective_model_id.clone(), + sampling_client, + effective_sampling_config.model.clone(), + ) + .await { Ok(p) => p, Err(e) => { let msg = format!("Persistence error: {e}"); - pending_guard.set_error(msg.clone()); - fail_subagent( - request, + let result = fail_subagent( &msg, &subagent_id, &child_session_id, &subagent_meta_dir, - gateway, - &ctx.parent_session_id, - ctx.parent_cmd_tx.as_ref(), 0, &early_gcs_ctx, ); - return; + return child_run_output(result, completion_data, None); } }; - let child_cwd = resolve_child_cwd( - worktree_path.as_deref(), - override_cwd, - &ctx.parent_cwd, - ); + let child_cwd = resolve_child_cwd(worktree_path.as_deref(), override_cwd, &ctx.parent_cwd); let cwd_outside_parent = match ( dunce::canonicalize(&child_cwd), dunce::canonicalize(&ctx.parent_cwd), @@ -794,21 +697,20 @@ pub(crate) async fn handle_subagent_request( hunk_tracking: ctx.hunk_tracking_enabled && cwd_outside_parent, ..FsWatchCapabilities::none() }; - let child_cwd_abs = xai_grok_paths::AbsPathBuf::new(child_cwd) - .unwrap_or_else(|_| { - xai_grok_paths::AbsPathBuf::new(std::env::current_dir().unwrap_or_default()) - .expect("current_dir should be absolute") - }); + let child_cwd_abs = xai_grok_paths::AbsPathBuf::new(child_cwd).unwrap_or_else(|_| { + xai_grok_paths::AbsPathBuf::new(std::env::current_dir().unwrap_or_default()) + .expect("current_dir should be absolute") + }); let mut tool_ctx = ToolContext::with_preloaded_env( - child_cwd_abs, - Some(gateway.clone()), - Some(child_session_id.clone()), - ctx.fs.clone(), - ctx.terminal.clone(), - ctx.hunk_tracker_handle.clone(), - (*ctx.session_env).clone(), - ) - .with_hunk_tracking_enabled(ctx.hunk_tracking_enabled); + child_cwd_abs, + Some(gateway.clone()), + Some(child_session_id.clone()), + ctx.fs.clone(), + ctx.terminal.clone(), + ctx.hunk_tracker_handle.clone(), + (*ctx.session_env).clone(), + ) + .with_hunk_tracking_enabled(ctx.hunk_tracking_enabled); tool_ctx.subagent_event_tx = Some(ctx.subagent_event_tx.clone()); let task_output_budget = request .runtime_overrides @@ -822,15 +724,12 @@ pub(crate) async fn handle_subagent_request( let parent_traceparent = xai_file_utils::trace_context::current_traceparent(); let tracker_child_cwd = child_session_info.cwd.clone(); let tracker_model_id = effective_model_id.0.to_string(); - let initial_child_tokens = xai_chat_state::estimate_conversation_tokens( - &forked_conversation, - ); + let initial_child_tokens = xai_chat_state::estimate_conversation_tokens(&forked_conversation); let model_entry = crate::agent::config::find_model_by_id( &ctx.available_models, effective_model_id.0.as_ref(), ); - let model_has_own_creds = model_entry - .is_some_and(|entry| entry.has_own_credentials()); + let model_has_own_creds = model_entry.is_some_and(|entry| entry.has_own_credentials()); let inherited_auth_type = subagent_auth_type(model_entry, &ctx.auth_method_id); let credentials = xai_chat_state::Credentials { api_key: effective_sampling_config.api_key.clone(), @@ -841,8 +740,7 @@ pub(crate) async fn handle_subagent_request( xai_grok_telemetry::unified_log::info( "subagent spawn credentials", None, - Some( - serde_json::json!({ + Some(serde_json::json!({ "subagent_id": &request.id, "subagent_type": &request.subagent_type, "effective_model": effective_model_id.0.as_ref(), @@ -855,13 +753,10 @@ pub(crate) async fn handle_subagent_request( "parent_model": ctx.model_id.0.as_ref(), "parent_key_prefix": key_prefix(&ctx.sampling_config.api_key), "context_window": effective_sampling_config.context_window, - }), - ), + })), ); - let attribution_callback: Option<xai_grok_sampler::SharedAttributionCallback> = effective_sampling_config - .attribution_callback - .clone(); - let tracker_color = definition.color; + let attribution_callback: Option<xai_grok_sampler::SharedAttributionCallback> = + effective_sampling_config.attribution_callback.clone(); let agent_memory_scope = definition.memory; let agent_name_for_memory = definition.name.clone(); let is_plugin_agent = definition.plugin_name.is_some(); @@ -886,8 +781,6 @@ pub(crate) async fn handle_subagent_request( } } if let Some(scope) = agent_memory_scope { - use xai_grok_tools::implementations::grok_build; - use xai_grok_tools::implementations::opencode; let memory_tools: Vec<xai_grok_tools::registry::types::ToolConfig> = vec![ (&grok_build::ReadFileTool).into(), (&grok_build::SearchReplaceTool).into(), @@ -901,7 +794,9 @@ pub(crate) async fn handle_subagent_request( let resolved_mem = scope.resolve_dir(&agent_name_for_memory, &ctx.parent_cwd); let memory_dir = &resolved_mem.path; let memory_md = memory_dir.join("MEMORY.md"); - if memory_md.is_file() && let Ok(content) = std::fs::read_to_string(&memory_md) { + if memory_md.is_file() + && let Ok(content) = std::fs::read_to_string(&memory_md) + { const MAX_LINES: usize = 200; const MAX_BYTES: usize = 25 * 1024; let truncated: String = content @@ -909,19 +804,15 @@ pub(crate) async fn handle_subagent_request( .take(MAX_LINES) .collect::<Vec<_>>() .join("\n"); - let truncated = xai_grok_tools::util::truncate::truncate_str( - &truncated, - MAX_BYTES, - ) - .to_string(); + let truncated = + xai_grok_tools::util::truncate::truncate_str(&truncated, MAX_BYTES).to_string(); if !truncated.is_empty() { let injection = format!( "\n\n<agent-memory>\nMemory directory: {}\n\n{truncated}\n</agent-memory>", memory_dir.display() ); - definition.prompt_body = Some( - definition.prompt_body.unwrap_or_default() + injection.as_str(), - ); + definition.prompt_body = + Some(definition.prompt_body.unwrap_or_default() + injection.as_str()); } } } @@ -933,10 +824,9 @@ pub(crate) async fn handle_subagent_request( plugin = ?definition.plugin_name, "ignoring hooks on plugin agent (not supported for security)" ); - } else if !crate::agent::folder_trust::agent_inline_hooks_allowed( - definition.scope, - || crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd), - ) { + } else if !crate::agent::folder_trust::agent_inline_hooks_allowed(definition.scope, || { + crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd) + }) { tracing::warn!( agent = %definition.name, "ignoring hooks on untrusted project agent (folder not trusted; re-run with --trust)" @@ -1041,11 +931,8 @@ pub(crate) async fn handle_subagent_request( None } else { ctx.parent_mcp_pool - .take() - .and_then(|pool| filter_pool_by_inheritance( - pool, - &definition.mcp_inheritance, - )) + .take() + .and_then(|pool| filter_pool_by_inheritance(pool, &definition.mcp_inheritance)) }; let mcp_inherited_count = parent_mcp_pool .as_ref() @@ -1059,20 +946,24 @@ pub(crate) async fn handle_subagent_request( ); } let inherit_skills = definition.inherit_skills; + let definition_background = definition.background.unwrap_or(false); if inherit_skills && ctx.parent_skills.is_none() { let parent_cwd_str = ctx.parent_cwd.to_string_lossy().to_string(); ctx.parent_skills = Some( xai_grok_agent::prompt::skills::list_skills_with_plugins( - Some(&parent_cwd_str), - &ctx.parent_skills_config, - ctx.plugin_registry.as_deref(), - ctx.parent_compat, - ) - .await, + Some(&parent_cwd_str), + &ctx.parent_skills_config, + ctx.plugin_registry.as_deref(), + ctx.parent_compat, + ) + .await, ); } let skills_inherited_count = if inherit_skills { - ctx.parent_skills.as_ref().map(|s| s.len() as u32).unwrap_or(0) + ctx.parent_skills + .as_ref() + .map(|s| s.len() as u32) + .unwrap_or(0) } else { 0 }; @@ -1105,226 +996,202 @@ pub(crate) async fn handle_subagent_request( agent_name: Some(definition.name.clone()), reasoning_effort: Some(effective_sampling_config.reasoning_effort), }); - let forked_tool_override = if verbatim_mirror_fork && !request.owner.is_workflow() { - ctx.parent_tool_snapshot.clone() - } else { - None - }; let spawn_result = session::spawn_session_on_thread( - child_session_info, - gateway.clone(), - effective_sampling_config, - credentials, - crate::agent::auth_method::new_shared_auth_method_id( - Some(ctx.auth_method_id.clone()), - ), - Some(ctx.auth_manager.clone()), - attribution_callback, - tool_ctx, - agent_mcp_servers, - vec![], - Default::default(), - parent_mcp_pool, - Vec::new(), - true, - false, - None, - persistence, - forked_conversation, - None, - None, - initial_child_tokens, - crate::session::StartupHints { - inherited_prefix_len: Some(inherited_prefix_len), - is_subagent: true, - parent_session_id: Some(ctx.parent_session_id.clone()), - subagent_type: Some(request.subagent_type.clone()), - preserve_inherited_system: verbatim_mirror_fork, - ..Default::default() - }, - xai_grok_workspace::permission::ClientType::Generic, - ctx.resolve_auto_compact_threshold_percent(&subagent_model_id), - xai_grok_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(), - xai_chat_state::CompactionMode::Summary, - ctx.resolve_compaction_verbatim_input(), - ctx.resolve_compaction_tool_choice(), - false, - None, - None, - std::sync::Arc::new( - parking_lot::Mutex::new( - xai_grok_workspace::file_system::CodebaseIndexManager::new(), - ), - ), - false, - subagent_fs_watch, - None, - None, - None, - None, - false, - false, - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), - definition, - subagent_session_default_agent_profile, - if inherit_skills { - ctx.parent_skills_config.clone() - } else { - xai_grok_agent::prompt::skills::SkillsConfig::default() - }, - if inherit_skills { ctx.parent_skills.take() } else { None }, - ctx.parent_compat, - false, - None, - None, - None, - Vec::new(), - None, - if verbatim_mirror_fork { - None - } else if let Some(scope) = agent_memory_scope { - ctx.memory_config - .as_ref() - .map(|mc| { - let mut c = mc.clone(); - let resolved = scope - .resolve_dir(&agent_name_for_memory, &ctx.parent_cwd); - c.enabled = true; - c.root_dir_override = Some(resolved.path); - c.flat_memory_root = resolved.is_project_scoped; - c - }) - } else { - ctx.memory_config.clone() - }, - false, - Default::default(), - ctx.managed_mcp_state.clone(), - None, - ctx.managed_mcp_proxy_base_url.clone(), - effective_model_id, - ctx.yolo_mode - || matches!( + child_session_info, + gateway.clone(), + effective_sampling_config, + credentials, + crate::agent::auth_method::new_shared_auth_method_id(Some(ctx.auth_method_id.clone())), + Some(ctx.auth_manager.clone()), + attribution_callback, + tool_ctx, + agent_mcp_servers, + vec![], + Default::default(), + parent_mcp_pool, + Vec::new(), + true, + false, + None, + persistence, + forked_conversation, + None, + None, + initial_child_tokens, + crate::session::StartupHints { + inherited_prefix_len: Some(inherited_prefix_len), + is_subagent: true, + parent_session_id: Some(ctx.parent_session_id.clone()), + subagent_type: Some(request.subagent_type.clone()), + preserve_inherited_system: verbatim_mirror_fork, + ..Default::default() + }, + xai_grok_workspace::permission::ClientType::Generic, + ctx.resolve_auto_compact_threshold_percent(&subagent_model_id), + xai_grok_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(), + xai_chat_state::CompactionMode::Summary, + ctx.resolve_compaction_verbatim_input(), + ctx.resolve_compaction_tool_choice(), + false, + None, + None, + std::sync::Arc::new(parking_lot::Mutex::new( + xai_grok_workspace::file_system::CodebaseIndexManager::new(), + )), + false, + subagent_fs_watch, + None, + None, + None, + None, + false, + false, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), + definition, + subagent_session_default_agent_profile, + if inherit_skills { + ctx.parent_skills_config.clone() + } else { + xai_grok_agent::prompt::skills::SkillsConfig::default() + }, + if inherit_skills { + ctx.parent_skills.take() + } else { + None + }, + ctx.parent_compat, + false, + None, + None, + None, + Vec::new(), + None, + if verbatim_mirror_fork { + None + } else if let Some(scope) = agent_memory_scope { + ctx.memory_config.as_ref().map(|mc| { + let mut c = mc.clone(); + let resolved = scope.resolve_dir(&agent_name_for_memory, &ctx.parent_cwd); + c.enabled = true; + c.root_dir_override = Some(resolved.path); + c.flat_memory_root = resolved.is_project_scoped; + c + }) + } else { + ctx.memory_config.clone() + }, + false, + Default::default(), + ctx.managed_mcp_state.clone(), + None, + ctx.managed_mcp_proxy_base_url.clone(), + effective_model_id, + ctx.yolo_mode + || matches!( agent_permission_mode, xai_grok_agent::config::PermissionMode::BypassPermissions ), - false, - None, - ctx.inference_idle_timeout_secs, - None, - ctx.web_search_sampling_config.clone(), - ctx.web_fetch_config.clone(), - ctx.image_gen_config.clone(), - ctx.video_gen_config.clone(), - ctx.app_builder_deployer_config.clone(), - ctx.write_file_enabled, - ctx.goal_enabled, - ctx.background_workflows_enabled, - true, - ctx.ask_user_question_enabled, - ctx.client_hooks.clone(), - None, - std::collections::HashMap::new(), - Vec::new(), - xai_grok_agent::prompt::context::PromptAudience::Subagent, - effective_runtime.role_prompt.clone(), - None, - ctx.disable_web_search, - ctx.backend_tools_enabled, - ctx.respect_gitignore, - ctx.path_not_found_hints, - ctx.resolve_tool_params_json(), - ctx.plugin_registry.clone(), - None, - ctx.models_manager.clone(), - parent_traceparent, - ctx.permission_handle.clone(), - ctx.api_key_provider.clone(), - ctx.image_description_model.clone(), - ctx.hook_registry.clone(), - ctx.workspace_ops.clone(), - vec![], - ctx.todo_gate, - std::mem::take(&mut ctx.remote_settings), - std::mem::take(&mut ctx.laziness_debug_log), - ctx.parent_terminal_backend.clone(), - if request.owner.is_workflow() { - None - } else { - ctx.parent_scheduler_handle.clone() - }, - subagent_max_turns, - forked_tool_override, - ) - .await; + false, + None, + ctx.inference_idle_timeout_secs, + None, + ctx.web_search_sampling_config.clone(), + ctx.web_fetch_config.clone(), + ctx.image_gen_config.clone(), + ctx.video_gen_config.clone(), + ctx.app_builder_deployer_config.clone(), + ctx.write_file_enabled, + ctx.goal_enabled, + ctx.background_workflows_enabled, + true, + ctx.ask_user_question_enabled, + ctx.client_hooks.clone(), + None, + std::collections::HashMap::new(), + Vec::new(), + xai_grok_agent::prompt::context::PromptAudience::Subagent, + effective_runtime.role_prompt.clone(), + None, + ctx.disable_web_search, + ctx.backend_tools_enabled, + ctx.respect_gitignore, + ctx.path_not_found_hints, + ctx.resolve_tool_params_json(), + ctx.plugin_registry.clone(), + None, + ctx.models_manager.clone(), + parent_traceparent, + ctx.permission_handle.clone(), + ctx.api_key_provider.clone(), + ctx.image_description_model.clone(), + ctx.hook_registry.clone(), + ctx.workspace_ops.clone(), + vec![], + ctx.todo_gate, + std::mem::take(&mut ctx.remote_settings), + std::mem::take(&mut ctx.laziness_debug_log), + ctx.parent_terminal_backend.clone(), + if request.owner.is_workflow() { + None + } else { + ctx.parent_scheduler_handle.clone() + }, + subagent_max_turns, + if verbatim_mirror_fork && !request.owner.is_workflow() { + std::mem::take(&mut ctx.parent_tool_definitions) + } else { + None + }, + ) + .await; let (child_handle, mut permission_rx, _system_prompt, child_thread) = match spawn_result { Ok(r) => r, Err(e) => { let msg = format!("Failed to spawn child session: {e}"); - pending_guard.set_error(msg.clone()); - fail_subagent( - request, + let result = fail_subagent( &msg, &subagent_id, &child_session_id, &subagent_meta_dir, - gateway, - &ctx.parent_session_id, - ctx.parent_cmd_tx.as_ref(), start.elapsed().as_millis() as u64, &gcs_upload_ctx, ); - return; + return child_run_output(result, completion_data, None); } }; - if cancel_token.is_cancelled() { - pending_guard.defuse(); - ctx.workspace_ops.end_local_session(child_session_id.0.as_ref()); - cancel_pending_subagent_at_promote( - request, - &child_handle, - &subagent_id, - &child_session_id, - &subagent_meta_dir, - coordinator, - gateway, - &ctx.parent_session_id, - ctx.parent_cmd_tx.as_ref(), - worktree_path.as_deref(), - worktree_freshly_created, - start.elapsed().as_millis() as u64, - &gcs_upload_ctx, - ) - .await; - return; - } - pending_guard.defuse(); - coordinator - .borrow_mut() - .insert(SubagentTracker { - subagent_id: request.id.clone(), - parent_session_id: ctx.parent_session_id.clone(), - parent_prompt_id: request.parent_prompt_id.clone(), - owner: request.owner.clone(), - child_session_id: child_session_id.clone(), - subagent_type: request.subagent_type.clone(), + let promoted = reporter + .started(StartedChild { + child_session_id: child_session_id.0.to_string(), persona: effective_runtime.persona.clone(), - description: request.description.clone(), - started_at: start, - child_handle: child_handle.clone(), - child_thread, - cancel_token: cancel_token.clone(), resumed_from: request.resume_from.clone(), child_cwd: tracker_child_cwd, - worktree_path: worktree_path.clone(), - effective_model_id: tracker_model_id, - run_in_background, - surface_completion: request.surface_completion, - completion_output_cap: request.runtime_overrides.completion_output_cap, - color: tracker_color, - block_waited: false, - explicitly_killed: false, - }); + worktree_path: worktree_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + effective_model_id: tracker_model_id.clone(), + definition_background, + control: ShellChildRuntime { + child_handle: child_handle.clone(), + _child_thread: child_thread, + }, + }) + .await; + if !promoted { + ctx.workspace_ops + .end_local_session(child_session_id.0.as_ref()); + let result = cancel_pending_shell_child( + &child_handle.cmd_tx, + &subagent_id, + &child_session_id, + &subagent_meta_dir, + worktree_path.as_deref(), + worktree_freshly_created, + start.elapsed().as_millis() as u64, + &gcs_upload_ctx, + ) + .await; + return child_run_output(result, completion_data, None); + } spawn_progress_publisher( child_handle.signals_handle.clone(), gateway.clone(), @@ -1336,152 +1203,51 @@ pub(crate) async fn handle_subagent_request( goal_tick_cmd_tx(ctx.goal_enabled, ctx.parent_cmd_tx.as_ref()), ); let (before_copy_tx, before_copy_rx) = tokio::sync::oneshot::channel(); - let _ = child_handle - .cmd_tx - .send(SessionCommand::CopyFile { - respond_to: before_copy_tx, - }); + let _ = child_handle.cmd_tx.send(SessionCommand::CopyFile { + respond_to: before_copy_tx, + }); if let Some(overrides) = ctx.inherited_tool_overrides.clone() { let _ = child_handle .cmd_tx - .send(SessionCommand::SetToolOverrides { - overrides, - }); + .send(SessionCommand::SetToolOverrides { overrides }); } let (prompt_tx, prompt_rx) = oneshot::channel(); let prompt_text = task_prompt_text; let child_prompt_id = uuid::Uuid::now_v7().to_string(); let turn_started_at = chrono::Utc::now().to_rfc3339(); - let _ = child_handle - .cmd_tx - .send(SessionCommand::Prompt { - prompt_id: child_prompt_id.clone(), - prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(prompt_text))], - prompt_mode: crate::session::plan_mode::PromptMode::Agent, - artifact_upload_ctx: ctx - .gcs_bucket_url - .as_ref() - .and_then(|_| { - ctx - .gcs_upload_method - .as_ref() - .map(|method| crate::upload::manifest::ArtifactUploadContext { - gcs_config: crate::session::repo_changes::TraceExportConfig { - bucket_url: ctx.gcs_bucket_url.clone(), - service_account_key: None, - prefix_dir: None, - gcs_prefix: Some(format!("{}/turn_0", child_session_id.0)), - absolute_paths: false, - archive_name_override: None, - upload_method: method.clone(), - }, - artifact_tracker: crate::upload::manifest::new_artifact_tracker(), - }) - }), - client_identifier: None, - screen_mode: None, - verbatim: true, - traceparent: xai_file_utils::trace_context::current_traceparent(), - json_schema: request.runtime_overrides.output_schema.clone(), - send_now: false, - admission: None, - tool_overrides_update: None, - respond_to: prompt_tx, - persist_ack: None, - parsed_prompt_tx: None, - }); - let mut result_tx = { - let (dummy_tx, _) = oneshot::channel(); - Some(std::mem::replace(&mut request.result_tx, dummy_tx)) - }; - let wait_outcome = { - let fut = await_subagent_turn_or_cancellation(prompt_rx, cancel_token.clone()); - tokio::pin!(fut); - if !request.run_in_background { - /// How the bounded foreground wait ended. - enum ForegroundWait { - /// The child finished (or was cancelled) within the budget. - Done(SubagentWaitOutcome), - /// The spawning tool's `result_rx` was dropped — parent turn died mid-await. - ParentGone, - /// `subagent_await_budget()` expired. - Budget, - } - let first = { - let parent_await_dropped = async { - match result_tx.as_mut() { - Some(tx) => tx.closed().await, - None => std::future::pending::<()>().await, - } - }; - let budget = async { - if request.await_to_completion { - std::future::pending::<()>().await - } else { - tokio::time::sleep(subagent_await_budget()).await - } - }; - tokio::select! { - // Bias to completion: a child finishing at the budget returns its real result. - biased; - outcome = &mut fut => ForegroundWait::Done(outcome), - _ = parent_await_dropped => ForegroundWait::ParentGone, - _ = budget => ForegroundWait::Budget, + let _ = child_handle.cmd_tx.send(SessionCommand::Prompt { + prompt_id: child_prompt_id.clone(), + prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(prompt_text))], + prompt_mode: crate::session::plan_mode::PromptMode::Agent, + artifact_upload_ctx: ctx.gcs_bucket_url.as_ref().and_then(|_| { + ctx.gcs_upload_method.as_ref().map(|method| { + crate::upload::manifest::ArtifactUploadContext { + gcs_config: crate::session::repo_changes::TraceExportConfig { + bucket_url: ctx.gcs_bucket_url.clone(), + service_account_key: None, + prefix_dir: None, + gcs_prefix: Some(format!("{}/turn_0", child_session_id.0)), + absolute_paths: false, + archive_name_override: None, + upload_method: method.clone(), + }, + artifact_tracker: crate::upload::manifest::new_artifact_tracker(), } - }; - match first { - ForegroundWait::Done(outcome) => { - if matches!(outcome, SubagentWaitOutcome::Cancelled) { - parent_wait_guard.take(); - } - outcome - } - ForegroundWait::ParentGone => { - parent_wait_guard.take(); - if request.owner.is_workflow() { - tracing::info!( - subagent_id = %request.id, - workflow_run_id = ?request.owner.workflow_run_id(), - "workflow subagent result receiver dropped; cancelling child", - ); - cancel_token.cancel(); - } else { - tracing::info!( - subagent_id = %request.id, - "foreground subagent await abandoned by its parent turn; detaching child to background (child keeps running)", - ); - if !cancel_token.is_cancelled() { - request.run_in_background = true; - coordinator.borrow_mut().mark_backgrounded(&request.id); - } - } - fut.await - } - ForegroundWait::Budget => { - tracing::info!( - subagent_id = %request.id, - budget_ms = subagent_await_budget().as_millis() as u64, - "foreground subagent exceeded await budget; auto-backgrounding (child keeps running)", - ); - if let Some(tx) = result_tx.take() { - let _ = tx - .send(SubagentResult { - backgrounded: true, - subagent_id: request.id.clone(), - child_session_id: child_session_id.0.to_string(), - ..Default::default() - }); - } - parent_wait_guard.take(); - request.run_in_background = true; - coordinator.borrow_mut().mark_backgrounded(&request.id); - fut.await - } - } - } else { - fut.await - } - }; + }) + }), + client_identifier: None, + screen_mode: None, + verbatim: true, + traceparent: xai_file_utils::trace_context::current_traceparent(), + json_schema: request.runtime_overrides.output_schema.clone(), + send_now: false, + admission: None, + tool_overrides_update: None, + respond_to: prompt_tx, + persist_ack: None, + parsed_prompt_tx: None, + }); + let wait_outcome = await_subagent_turn_or_cancellation(prompt_rx, cancel_token.clone()).await; let duration_ms = start.elapsed().as_millis() as u64; let mut turn_token_totals: Option<(u64, u64, u64)> = None; let mut cancellation_may_hide_usage = false; @@ -1507,20 +1273,19 @@ pub(crate) async fn handle_subagent_request( SubagentWaitOutcome::TurnResult(turn_result) => { let was_cancelled = cancel_token.is_cancelled(); let (tool_calls, turns) = match &*turn_result { - Ok( - Ok( - crate::session::commands::PromptTurnOk { - turn_snapshot: Some(snapshot), - .. - }, - ), - ) => { + Ok(Ok(crate::session::commands::PromptTurnOk { + turn_snapshot: Some(snapshot), + .. + })) => { turn_token_totals = Some(( snapshot.turn_input_tokens, snapshot.turn_cached_input_tokens, snapshot.turn_output_tokens, )); - (snapshot.current.tool_call_count, snapshot.current.turn_count) + ( + snapshot.current.tool_call_count, + snapshot.current.turn_count, + ) } _ => signals_snapshot_counts(&child_handle).await, }; @@ -1531,17 +1296,10 @@ pub(crate) async fn handle_subagent_request( .unwrap_or_default(); let result_tokens = child_handle.chat_state_handle.get_total_tokens().await; match *turn_result { - Ok( - Ok( - crate::session::commands::PromptTurnOk { - completion_kind: PromptCompletionKind::Cancelled { - category, - context, - }, - .. - }, - ), - ) => { + Ok(Ok(crate::session::commands::PromptTurnOk { + completion_kind: PromptCompletionKind::Cancelled { category, context }, + .. + })) => { cancellation_may_hide_usage = true; let reason = cancellation_error_message(category, context.as_ref()); SubagentResult { @@ -1549,12 +1307,10 @@ pub(crate) async fn handle_subagent_request( cancelled: true, error: Some(reason), output: if final_text.is_empty() { - std::sync::Arc::from( - format!( + std::sync::Arc::from(format!( "Subagent '{}' ({}) was cancelled. {} tool calls, {} turns.", request.description, request.subagent_type, tool_calls, turns - ), - ) + )) } else { std::sync::Arc::from(final_text) }, @@ -1573,91 +1329,65 @@ pub(crate) async fn handle_subagent_request( backgrounded: false, } } - Ok( - Ok( - crate::session::commands::PromptTurnOk { - completion_kind: PromptCompletionKind::MaxTurnsReached { - limit, - }, - .. - }, - ), - ) => { - SubagentResult { - success: false, - cancelled: true, - error: Some(format!("max turns reached (limit: {limit})")), - output: if final_text.is_empty() { - std::sync::Arc::from( - format!( + Ok(Ok(crate::session::commands::PromptTurnOk { + completion_kind: PromptCompletionKind::MaxTurnsReached { limit }, + .. + })) => SubagentResult { + success: false, + cancelled: true, + error: Some(format!("max turns reached (limit: {limit})")), + output: if final_text.is_empty() { + std::sync::Arc::from(format!( "Subagent '{}' ({}) hit max-turns limit ({limit}). {} tool calls, {} turns.", request.description, request.subagent_type, tool_calls, turns - ), - ) - } else { - std::sync::Arc::from(final_text) - }, - subagent_id: request.id.clone(), - child_session_id: child_session_id.0.to_string(), - tool_calls, - turns, - duration_ms, - tokens_used: result_tokens, - output_tokens_used: 0, - output_usage_incomplete: true, - total_tokens_used: 0, - worktree_path: worktree_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - backgrounded: false, - } - } - Ok( - Ok(crate::session::commands::PromptTurnOk { structured_output, .. }), - ) => { - let wanted_schema = request - .runtime_overrides - .output_schema - .is_some(); - let (success, error, output) = match ( - wanted_schema, - structured_output, - ) { + )) + } else { + std::sync::Arc::from(final_text) + }, + subagent_id: request.id.clone(), + child_session_id: child_session_id.0.to_string(), + tool_calls, + turns, + duration_ms, + tokens_used: result_tokens, + output_tokens_used: 0, + output_usage_incomplete: true, + total_tokens_used: 0, + worktree_path: worktree_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + backgrounded: false, + }, + Ok(Ok(crate::session::commands::PromptTurnOk { + structured_output, .. + })) => { + let wanted_schema = request.runtime_overrides.output_schema.is_some(); + let (success, error, output) = match (wanted_schema, structured_output) { (true, Some(Ok(value))) => { (true, None, std::sync::Arc::from(value.to_string())) } - (true, Some(Err(e))) => { - ( - false, - Some(format!("structured output validation failed: {e}")), - std::sync::Arc::from(final_text), - ) - } - (true, None) => { - ( - false, - Some( - "structured output requested but none produced".to_string(), - ), - std::sync::Arc::from(final_text), - ) - } - (false, _) => { - ( - true, - None, - if final_text.is_empty() { - std::sync::Arc::from( - format!( + (true, Some(Err(e))) => ( + false, + Some(format!("structured output validation failed: {e}")), + std::sync::Arc::from(final_text), + ), + (true, None) => ( + false, + Some("structured output requested but none produced".to_string()), + std::sync::Arc::from(final_text), + ), + (false, _) => ( + true, + None, + if final_text.is_empty() { + std::sync::Arc::from(format!( "Subagent '{}' ({}) completed successfully. {} tool calls, {} turns.", request.description, request.subagent_type, tool_calls, turns - ), - ) - } else { - std::sync::Arc::from(final_text) - }, - ) - } + )) + } else { + std::sync::Arc::from(final_text) + }, + ), }; SubagentResult { success, @@ -1683,13 +1413,11 @@ pub(crate) async fn handle_subagent_request( SubagentResult { success: false, cancelled: was_cancelled, - error: Some( - if was_cancelled { - "Subagent was cancelled".to_string() - } else { - format!("Session error: {e}") - }, - ), + error: Some(if was_cancelled { + "Subagent was cancelled".to_string() + } else { + format!("Session error: {e}") + }), subagent_id: request.id.clone(), child_session_id: child_session_id.0.to_string(), tool_calls, @@ -1706,13 +1434,11 @@ pub(crate) async fn handle_subagent_request( SubagentResult { success: false, cancelled: was_cancelled, - error: Some( - if was_cancelled { - "Subagent was cancelled".to_string() - } else { - "Child session dropped unexpectedly".to_string() - }, - ), + error: Some(if was_cancelled { + "Subagent was cancelled".to_string() + } else { + "Child session dropped unexpectedly".to_string() + }), subagent_id: request.id.clone(), child_session_id: child_session_id.0.to_string(), tool_calls, @@ -1727,10 +1453,8 @@ pub(crate) async fn handle_subagent_request( } } }; - if let Some(trace_gcs_config) = gcs_upload_ctx - .upload_method - .as_ref() - .map(|method| crate::session::repo_changes::TraceExportConfig { + if let Some(trace_gcs_config) = gcs_upload_ctx.upload_method.as_ref().map(|method| { + crate::session::repo_changes::TraceExportConfig { bucket_url: gcs_upload_ctx.bucket_url.clone(), service_account_key: None, prefix_dir: None, @@ -1738,21 +1462,17 @@ pub(crate) async fn handle_subagent_request( absolute_paths: false, archive_name_override: None, upload_method: method.clone(), - }) - { + } + }) { let (copy_tx, session_copy_rx) = tokio::sync::oneshot::channel(); - let _ = child_handle - .cmd_tx - .send(SessionCommand::CopyFile { - respond_to: copy_tx, - }); + let _ = child_handle.cmd_tx.send(SessionCommand::CopyFile { + respond_to: copy_tx, + }); let turn_messages: Option<xai_chat_state::TurnCapture> = { let (tx, rx) = tokio::sync::oneshot::channel(); if child_handle .cmd_tx - .send(SessionCommand::TakeTurnMessages { - respond_to: tx, - }) + .send(SessionCommand::TakeTurnMessages { respond_to: tx }) .is_ok() { rx.await.ok().flatten() @@ -1761,22 +1481,20 @@ pub(crate) async fn handle_subagent_request( } }; let streaming_partial = crate::upload::turn::take_streaming_partial( - &child_handle.cmd_tx, - child_prompt_id.clone(), - result.success, - gcs_upload_ctx.model_id.clone(), - ) - .await - .map(|mut cap| { - cap.reason = Some( - if result.cancelled { - "subagent_cancel".to_string() - } else { - "subagent_non_completed".to_string() - }, - ); - cap + &child_handle.cmd_tx, + child_prompt_id.clone(), + result.success, + gcs_upload_ctx.model_id.clone(), + ) + .await + .map(|mut cap| { + cap.reason = Some(if result.cancelled { + "subagent_cancel".to_string() + } else { + "subagent_non_completed".to_string() }); + cap + }); let mut permission_events = Vec::new(); while let Ok(event) = permission_rx.try_recv() { permission_events.push(event); @@ -1795,32 +1513,32 @@ pub(crate) async fn handle_subagent_request( if let Ok(prompt_bytes) = std::fs::read(session_dir.join("system_prompt.txt")) { let gcs_path = format!("{}/system_prompt.txt", child_session_id.0); crate::upload::trace::upload_trace_artifact( - &trace_ctx, - &prompt_bytes, - &gcs_path, - "text/plain", - "system_prompt", - ) - .await; + &trace_ctx, + &prompt_bytes, + &gcs_path, + "text/plain", + "system_prompt", + ) + .await; } if let Ok(ctx_bytes) = std::fs::read(session_dir.join("prompt_context.json")) { let gcs_path = format!("{}/prompt_context.json", child_session_id.0); crate::upload::trace::upload_trace_artifact( - &trace_ctx, - &ctx_bytes, - &gcs_path, - "application/json", - "prompt_context", - ) - .await; - } - upload_session_state( &trace_ctx, - "before", - before_copy_rx, - crate::upload::turn::UploadWait::Confirm, + &ctx_bytes, + &gcs_path, + "application/json", + "prompt_context", ) .await; + } + upload_session_state( + &trace_ctx, + "before", + before_copy_rx, + crate::upload::turn::UploadWait::Confirm, + ) + .await; let subagent_auth = ctx.auth_manager.current(); let metadata = PromptMetadata { schema_version: GCS_SCHEMA_VERSION.to_string(), @@ -1870,30 +1588,26 @@ pub(crate) async fn handle_subagent_request( finished_at: chrono::Utc::now().to_rfc3339(), signals: None, turn_delta: None, - start_prompt_mode: Some( - crate::session::plan_mode::PromptMode::Agent.to_string(), - ), - end_prompt_mode: Some( - crate::session::plan_mode::PromptMode::Agent.to_string(), - ), + start_prompt_mode: Some(crate::session::plan_mode::PromptMode::Agent.to_string()), + end_prompt_mode: Some(crate::session::plan_mode::PromptMode::Agent.to_string()), resolved_model, subagents_spawned: vec![], }; upload_turn_result( - &trace_ctx, - &turn_result_meta, - crate::upload::turn::UploadWait::Confirm, - ) - .await; + &trace_ctx, + &turn_result_meta, + crate::upload::turn::UploadWait::Confirm, + ) + .await; match complete_prompt_trace( - trace_ctx, - permission_events, - session_copy_rx, - turn_messages, - streaming_partial, - crate::upload::turn::UploadWait::Confirm, - ) - .await + trace_ctx, + permission_events, + session_copy_rx, + turn_messages, + streaming_partial, + crate::upload::turn::UploadWait::Confirm, + ) + .await { Ok(_) => { tracing::debug!( @@ -1911,7 +1625,7 @@ pub(crate) async fn handle_subagent_request( } } } - let persisted_output_dir = persist_subagent_output(&subagent_meta_dir, &result); + completion_data.set_persisted_output_dir(persist_subagent_output(&subagent_meta_dir, &result)); persist_subagent_completion(&subagent_meta_dir, &result, &gcs_upload_ctx); let final_status = result.status().to_string(); let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled(); @@ -1920,87 +1634,85 @@ pub(crate) async fn handle_subagent_request( } else { 0 }; + completion_data.telemetry_tokens = telemetry_tokens; let task_budget_usage = task_output_budget.as_ref().map(|budget| budget.usage()); - let ( - subagent_usage_by_model, - subagent_usage_incomplete, - output_tokens_used, - total_tokens_used, - ) = match child_handle.chat_state_handle.try_get_session_usage().await { - Ok(u) => { - let output_tokens = u.totals.output_tokens; - let total_tokens = canonical_total_tokens(&u.totals); - let has_usage_entries = !u.by_model.is_empty(); - let usage_incomplete = usage_is_incomplete( - u.incomplete, - cancellation_may_hide_usage, - total_tokens, - has_usage_entries, - ); - ( - Some(u.by_model.into_iter().collect::<Vec<_>>()), - usage_incomplete, - (!usage_incomplete).then_some(output_tokens), - Some(total_tokens), - ) - } - Err(()) => (None, true, None, None), - }; + let (subagent_usage_by_model, subagent_usage_incomplete, output_tokens_used, total_tokens_used) = + match child_handle.chat_state_handle.try_get_session_usage().await { + Ok(u) => { + let output_tokens = u.totals.output_tokens; + let total_tokens = canonical_total_tokens(&u.totals); + let has_usage_entries = !u.by_model.is_empty(); + let usage_incomplete = usage_is_incomplete( + u.incomplete, + cancellation_may_hide_usage, + total_tokens, + has_usage_entries, + ); + ( + Some(u.by_model.into_iter().collect::<Vec<_>>()), + usage_incomplete, + (!usage_incomplete).then_some(output_tokens), + Some(total_tokens), + ) + } + Err(()) => (None, true, None, None), + }; result.total_tokens_used = total_tokens_used.unwrap_or(0); if let Some((task_spent, task_incomplete)) = task_budget_usage { result.output_tokens_used = output_tokens_used.unwrap_or(task_spent); - result.output_usage_incomplete = task_incomplete || subagent_usage_incomplete - || output_tokens_used.is_none(); + result.output_usage_incomplete = + task_incomplete || subagent_usage_incomplete || output_tokens_used.is_none(); } else { result.output_tokens_used = output_tokens_used.unwrap_or(0); - result.output_usage_incomplete = subagent_usage_incomplete - || output_tokens_used.is_none(); + result.output_usage_incomplete = subagent_usage_incomplete || output_tokens_used.is_none(); } - let fold_acked = match subagent_usage_by_model { - None => false, - Some(ref by_model) if by_model.is_empty() && !subagent_usage_incomplete => true, - Some(by_model) => { - if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() { - let (respond_to, ack) = tokio::sync::oneshot::channel(); - match cmd_tx - .send(crate::session::commands::SessionCommand::RecordSubagentUsage { - by_model, - parent_prompt_id: request.parent_prompt_id.clone(), - incomplete: subagent_usage_incomplete, - respond_to, - }) - { - Ok(()) => ack.await.is_ok(), - Err(_) => false, - } - } else { - false - } - } - }; + let fold_acked = record_subagent_usage( + ctx.parent_cmd_tx.as_ref(), + subagent_usage_by_model, + request.parent_prompt_id.clone(), + subagent_usage_incomplete, + ) + .await; if !fold_acked { tracing::warn!( subagent_id = %request.id, parent_prompt_id = ?request.parent_prompt_id, "subagent usage not applied; parent bill marked incomplete" ); - let sticky_prompt = request - .parent_prompt_id - .clone() - .or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id)); - if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() { + let sticky_prompt = request.parent_prompt_id.clone(); + let marked_by_parent = if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() { let (respond_to, ack) = tokio::sync::oneshot::channel(); if cmd_tx - .send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied { - parent_prompt_id: sticky_prompt, - respond_to, - }) + .send( + crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied { + parent_prompt_id: sticky_prompt.clone(), + respond_to, + }, + ) + .is_ok() + { + ack.await.is_ok() + } else { + false + } + } else { + false + }; + if !marked_by_parent && let Some(pid) = sticky_prompt { + let (respond_to, ack) = tokio::sync::oneshot::channel(); + if ctx + .subagent_event_tx + .send(SubagentEvent::MarkUsageNotApplied( + SubagentMarkUsageNotAppliedRequest { + parent_session_id: ctx.parent_session_id.clone(), + prompt_id: pid, + respond_to, + }, + )) .is_ok() { let _ = ack.await; } - } else if let Some(ref pid) = sticky_prompt { - coordinator.borrow_mut().mark_subagent_usage_not_applied(pid); } } let outcome = if result.success { @@ -2016,9 +1728,16 @@ pub(crate) async fn handle_subagent_request( outcome, duration_ms: result.duration_ms, tool_calls: result.tool_calls, - tokens_used: if telemetry_tokens > 0 { Some(telemetry_tokens) } else { None }, + tokens_used: if telemetry_tokens > 0 { + Some(telemetry_tokens) + } else { + None + }, }); - match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) { + match ( + &ctx.parent_terminal_backend, + &ctx.parent_notification_handle, + ) { (Some(parent_tb), Some(parent_notif_handle)) => { if !request.surface_completion { let reparented_task_ids: Vec<String> = parent_tb @@ -2026,19 +1745,16 @@ pub(crate) async fn handle_subagent_request( .await .into_iter() .filter(|t| { - !t.completed - && t.owner_session_id.as_deref() - == Some(&*child_session_id.0) + !t.completed && t.owner_session_id.as_deref() == Some(&*child_session_id.0) }) .map(|t| t.task_id) .collect(); if !reparented_task_ids.is_empty() && let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() { - let _ = cmd_tx - .send(SessionCommand::RecordGoalTurnTaskIds { - task_ids: reparented_task_ids, - }); + let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds { + task_ids: reparented_task_ids, + }); } } let parent_backend_weak = std::sync::Arc::downgrade(parent_tb); @@ -2064,7 +1780,8 @@ pub(crate) async fn handle_subagent_request( (None, None) => {} } let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown); - ctx.workspace_ops.end_local_session(child_session_id.0.as_ref()); + ctx.workspace_ops + .end_local_session(child_session_id.0.as_ref()); let mut disposed_snapshot_ref: Option<String> = None; let mut worktree_removed = false; if let Some(ref wt_path) = worktree_path { @@ -2072,11 +1789,11 @@ pub(crate) async fn handle_subagent_request( let ref_name = format!("refs/grok/subagents/{}", request.id); let source_repo = resolve_subagent_source_repo(&ctx); match crate::session::worktree::snapshot_subagent_worktree( - wt_path, - &source_repo, - &ref_name, - ) - .await + wt_path, + &source_repo, + &ref_name, + ) + .await { Ok(snapshot_ref) => { let persisted = update_subagent_meta_snapshot_ref( @@ -2086,9 +1803,7 @@ pub(crate) async fn handle_subagent_request( ); if persisted { disposed_snapshot_ref = Some(snapshot_ref); - match crate::session::worktree::remove_subagent_worktree(wt_path) - .await - { + match crate::session::worktree::remove_subagent_worktree(wt_path).await { Ok(()) => { worktree_removed = true; tracing::info!( @@ -2099,11 +1814,11 @@ pub(crate) async fn handle_subagent_request( } Err(e) => { tracing::warn!( - subagent_id = %request.id, - worktree_path = %wt_path.display(), - error = %e, - "snapshotted subagent worktree but removal failed; ref persisted for resume" - ) + subagent_id = %request.id, + worktree_path = %wt_path.display(), + error = %e, + "snapshotted subagent worktree but removal failed; ref persisted for resume" + ) } } } else { @@ -2134,63 +1849,32 @@ pub(crate) async fn handle_subagent_request( if worktree_removed { result.worktree_path = None; } - let (block_waited, explicitly_killed) = { - let mut coord = coordinator.borrow_mut(); - ( - coord.block_wait_delivered_or_live(&request.id), - coord.is_explicitly_killed(&request.id), - ) + let success = result.success && !result.cancelled; + let preview = crate::util::truncate(&result.output, 200); + let level_fn = if success { + xai_grok_telemetry::unified_log::info + } else { + xai_grok_telemetry::unified_log::error }; - let will_wake = should_auto_wake_subagent( - request.run_in_background, - result.cancelled, - ctx.auto_wake_enabled, - block_waited, - explicitly_killed, - ctx.goal_loop_active.load(std::sync::atomic::Ordering::Relaxed), - ctx.parent_cmd_tx.is_some(), - ); - emit_subagent_notification( - gateway, - &ctx.parent_session_id, - SessionUpdate::SubagentFinished { - subagent_id: request.id.clone(), - child_session_id: result.child_session_id.clone(), - status: result.status().to_string(), - error: result.error.clone(), - tool_calls: result.tool_calls, - turns: result.turns, - duration_ms: result.duration_ms, - tokens_used: telemetry_tokens, - output: if result.success { Some(result.output.to_string()) } else { None }, - will_wake, + level_fn( + if success { + "subagent completed" + } else { + "subagent failed" }, - ctx.parent_cmd_tx.as_ref(), + None, + Some(serde_json::json!({ + "subagent_id": &request.id, + "subagent_type": &request.subagent_type, + "effective_model": tracker_model_id, + "success": success, + "cancelled": result.cancelled, + "duration_ms": result.duration_ms, + "turns": result.turns, + "tool_calls": result.tool_calls, + "output_preview": preview, + "error": &result.error, + })), ); - coordinator - .borrow_mut() - .move_to_completed( - &request.id, - request.description.clone(), - request.subagent_type.clone(), - result.clone(), - persisted_output_dir, - ); - if let Some(snapshot_ref) = disposed_snapshot_ref { - coordinator.borrow_mut().set_completed_snapshot_ref(&request.id, snapshot_ref); - } - if will_wake { - inject_subagent_completed_prompt( - &request.id, - &result, - &request, - &ctx.task_completion_reservations, - ctx.parent_cmd_tx.as_ref(), - &ctx.task_output_tool_name, - &ctx.synthetic_trace_tx, - ); - } - if let Some(tx) = result_tx.take() { - let _ = tx.send(result); - } + child_run_output(result, completion_data, disposed_snapshot_ref) } diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs index 05d0d01..2a71804 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs @@ -1,16 +1,16 @@ -//! Subagent coordinator — spawns and tracks hidden child sessions. +//! Shell child runtime adapter and presentation. //! -//! All subagent-specific types, tracking state, and orchestration logic live here. -//! `MvpAgent` only wires the channel and calls `handle_subagent_request()`. +//! Lifecycle state and command scheduling live in the shared +//! `xai-grok-tools` coordinator actor. This module keeps shell-specific +//! child-session construction, ACP presentation, persistence, and trace work. //! //! ## Design //! -//! - `SubagentCoordinator` owns the active-subagent map (stored as a field on `MvpAgent`). -//! - `handle_subagent_request()` is a free async function that receives a -//! `SubagentSpawnContext` parameter bag — it never borrows `MvpAgent`. +//! - `run_shell_child()` runs one shell child behind `ChildRunner`. +//! - Pending/active/completed, waiters, deadlines, and cancellation are actor-owned. //! - Child sessions share the parent's hunk tracker, filesystem, terminal, and env //! so that edits, bash commands, and file reads go through the same backends. -#![allow(unused_imports)] +use crate::agent::config::{resolve_credentials, sampling_config_for_model}; use crate::extensions::notification::{SessionNotification, SessionUpdate}; use crate::session::{ self, SessionCommand, SessionHandle, SessionThread, @@ -21,27 +21,31 @@ use crate::session::{ use crate::terminal::AsyncTerminalRunner; use crate::tools::ToolContext; use crate::upload::trace::{ - GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata, - local_sandbox_telemetry, upload_metadata, upload_session_state, upload_subagent_metadata, - upload_turn_result, + GCS_SCHEMA_VERSION, PromptMetadata, TurnResultMetadata, local_sandbox_telemetry, + upload_metadata, upload_session_state, upload_subagent_metadata, upload_turn_result, }; use crate::upload::turn::{PromptTraceContext, complete_prompt_trace}; use agent_client_protocol as acp; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -#[cfg(test)] -use std::sync::OnceLock; -use tokio::sync::{Notify, mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; +use xai_file_utils::events::types::CancellationCategory; +use xai_grok_agent::config::{McpInheritance, ModelOverride, PermissionMode}; +use xai_grok_sampling_types::conversation::ConversationItem; +use xai_grok_subagent_resolution::ResumeSourceData; +use xai_grok_tools::implementations::grok_build::task::coordinator::{ + ChildCompletion, ChildControl, ChildReporter, ChildRunOutput, LocalBoxFuture, StartedChild, + SubagentProgress, +}; use xai_grok_tools::implementations::grok_build::task::types::*; +use xai_grok_tools::types::tool::ToolKind; use xai_grok_workspace::file_system::AsyncFileSystem; use xai_hunk_tracker::HunkTrackerHandle; -mod coordinator_lifecycle; -mod coordinator_query; mod handle_request; -pub(crate) use handle_request::handle_subagent_request; +pub(crate) use handle_request::run_shell_child; /// How the child session's initial context was bootstrapped. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum InitialContextSource { @@ -54,48 +58,6 @@ pub(crate) enum InitialContextSource { /// prompt context are freshly rendered from the current agent definition. Resumed, } -/// Tracks a single active subagent for progress polling and cleanup. -pub(crate) struct SubagentTracker { - pub subagent_id: String, - pub parent_session_id: String, - pub parent_prompt_id: Option<String>, - pub owner: SubagentOwner, - pub child_session_id: acp::SessionId, - pub subagent_type: String, - pub persona: Option<String>, - pub description: String, - pub started_at: std::time::Instant, - pub child_handle: SessionHandle, - #[expect( - dead_code, - reason = "unused in production; remove expect when wired or delete the item" - )] - pub child_thread: SessionThread, - pub cancel_token: CancellationToken, - /// ID of the source subagent this session was resumed from. - pub resumed_from: Option<String>, - /// Effective cwd used by the child session. Retained so - /// `move_to_completed` can propagate it to `CompletedSubagent`. - pub child_cwd: String, - /// Worktree path if the child used `isolation=worktree`. - pub worktree_path: Option<PathBuf>, - /// Effective model ID used by the child session. - pub effective_model_id: String, - /// Whether the subagent was launched with `run_in_background: true`. - pub run_in_background: bool, - /// Mirrors `SubagentRequest::surface_completion`. - pub surface_completion: bool, - pub completion_output_cap: Option<usize>, - /// Set when a `block=true` waiter consumed this subagent's result. - pub block_waited: bool, - /// Set when the model explicitly killed this subagent via the kill tool. - pub explicitly_killed: bool, - #[expect( - dead_code, - reason = "unused in production; remove expect when wired or delete the item" - )] - pub color: Option<xai_grok_agent::config::AgentColor>, -} /// Captured parent-side tier inputs for resolving /// `auto_compact_threshold_percent` once the subagent's actual model id is /// known. Stored on [`SubagentSpawnContext`] so the resolver can run at @@ -140,11 +102,6 @@ impl AutoCompactThresholdTiers { pub(crate) struct SubagentSpawnContext { /// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal. pub lsp: Option<std::sync::Arc<dyn xai_grok_tools::implementations::lsp::LspBackend>>, - #[expect( - dead_code, - reason = "unused in production; remove expect when wired or delete the item" - )] - pub gateway: GatewaySender, /// Parent's client-registered hooks, inherited so the subagent's tool calls hit the /// same PreToolUse gate and its events fire the same observe hooks over the parent's /// connection. Empty when the parent has none. Filled by the coordinator after the @@ -158,11 +115,6 @@ pub(crate) struct SubagentSpawnContext { pub alpha_test_key: Option<String>, pub auth_method_id: acp::AuthMethodId, pub model_id: acp::ModelId, - #[expect( - dead_code, - reason = "unused in production; remove expect when wired or delete the item" - )] - pub storage_mode: crate::config::StorageMode, pub auth: Option<crate::auth::GrokAuth>, pub parent_cwd: PathBuf, pub parent_session_id: String, @@ -296,11 +248,6 @@ pub(crate) struct SubagentSpawnContext { /// GCS upload method (direct or proxy). pub gcs_upload_method: Option<crate::session::repo_changes::UploadMethod>, pub hook_registry: Option<std::sync::Arc<xai_grok_hooks::discovery::HookRegistry>>, - #[expect( - dead_code, - reason = "unused in production; remove expect when wired or delete the item" - )] - pub hook_workspace_root: String, pub permission_handle: Option<xai_grok_workspace::permission::PermissionHandle>, pub worktree_type: crate::util::config::WorktreeType, pub api_key_provider: Option<xai_grok_tools::types::SharedApiKeyProvider>, @@ -338,10 +285,8 @@ pub(crate) struct SubagentSpawnContext { pub managed_mcp_state: crate::session::managed_mcp::ManagedMcpStateHandle, /// Snapshot of the parent session's MCP client pool at spawn time. pub parent_mcp_pool: Option<crate::session::mcp_servers::SharedMcpPool>, - /// Snapshot of the parent session's resolved tool schema at spawn time. - /// `Some` only when a fork parent's actor answered; threaded to the child so a - /// verbatim mirror-fork sends the parent's exact tool prefix for cache reuse. - pub parent_tool_snapshot: Option<Vec<xai_grok_sampling_types::ToolSpec>>, + /// Exact parent tool schema for verbatim non-workflow forks. + pub parent_tool_definitions: Option<Vec<xai_grok_sampling_types::ToolSpec>>, /// Pre-discovered skills from the parent session, captured at spawn time. pub parent_skills: Option<Vec<xai_grok_tools::implementations::skills::types::SkillInfo>>, /// Parent's skills config for the child's SkillManager. @@ -364,17 +309,8 @@ pub(crate) struct SubagentSpawnContext { /// auto-wake synthetic prompt is suppressed so an async completion wake /// doesn't derail the parent mid-`/goal`; surfaces 2/3 still drain it. pub goal_loop_active: Arc<std::sync::atomic::AtomicBool>, - /// Parent's `blocking_wait_depth` (same `Arc`). A foreground spawn holds a - /// `BlockingWaitGuard` on it for the blocking await so `queue_input` routes - /// a prompt sent during the wait onto send-now; never for background spawns. - pub parent_blocking_wait_depth: Arc<crate::tools::tool_context::BlockingWaitState>, } impl SubagentSpawnContext { - /// Check if a subagent is enabled via the toggle config. - /// Returns `true` if the agent is not in the toggle map (default enabled). - fn is_subagent_enabled(&self, name: &str) -> bool { - self.subagent_toggle.get(name).copied().unwrap_or(true) - } /// Resolve `auto_compact_threshold_percent` for the subagent's actual /// model id (the one selected by `resolve_subagent_sampling_config`, /// not the parent's). Walks the same precedence as the main session's @@ -476,327 +412,145 @@ impl SubagentSpawnContext { } } } -/// A completed subagent entry retained for `TaskOutputTool` polling -/// and `resume_from` resolution. -pub(crate) struct CompletedSubagent { - pub subagent_id: String, - pub parent_session_id: String, - pub parent_prompt_id: Option<String>, - pub owner: SubagentOwner, - pub child_session_id: String, - pub description: String, - pub subagent_type: String, - pub persona: Option<String>, - pub started_at: std::time::Instant, - /// When the subagent moved to the completed map. Orders cap eviction. - pub completed_at: std::time::Instant, - pub result: SubagentResult, - /// ID of the source subagent this session was resumed from. - pub resumed_from: Option<String>, - /// Effective cwd used by the child session (worktree path or parent cwd). - /// Required to reconstruct `SessionInfo` for `resume_from`. - pub child_cwd: String, - /// Path to the isolated worktree, if the child used `isolation=worktree`. - pub worktree_path: Option<PathBuf>, - /// Durable git ref snapshotting the worktree's working state, if captured. - pub snapshot_ref: Option<String>, - /// Effective model ID used by the child session. - pub effective_model_id: String, - /// Set when a `block=true` waiter consumed this subagent's result. - pub block_waited: bool, - /// Set when the model explicitly killed this subagent via the kill tool. - pub explicitly_killed: bool, - pub completion_output_cap: Option<usize>, - /// Directory whose `output.json` holds the output text; when set, the - /// stored `result.output` is cleared and `lookup` reads from disk. - /// `None` (failures, empty outputs, failed writes) serves from memory. - /// Process-scoped and local-only: resume survives a restart via - /// `meta.json`, and trace upload carries the text to GCS. - pub persisted_output_dir: Option<PathBuf>, +/// Shell runtime handle retained while a child is active. +pub(crate) struct ShellChildRuntime { + pub child_handle: SessionHandle, + pub _child_thread: SessionThread, } -pub(crate) fn cap_completion_output( - output: &std::sync::Arc<str>, - cap: Option<usize>, -) -> std::sync::Arc<str> { - match cap { - Some(cap) if output.len() > cap => { - let mut end = cap; - while end > 0 && !output.is_char_boundary(end) { - end -= 1; +impl ChildControl for ShellChildRuntime { + type ProgressFuture = LocalBoxFuture<SubagentProgress>; + fn progress(&self) -> Self::ProgressFuture { + let signals = self.child_handle.signals_handle.clone(); + Box::pin(async move { + let snapshot = signals.snapshot().await.unwrap_or_default(); + SubagentProgress { + turn_count: snapshot.turn_count, + tool_call_count: snapshot.tool_call_count, + tokens_used: snapshot.context_tokens_used, + context_window_tokens: snapshot.context_window_tokens, + context_usage_pct: snapshot.context_window_usage, + tools_used: snapshot.tools_used, + error_count: snapshot.error_count, } - std::sync::Arc::from(format!( - "{}\n[output truncated: {} of {} bytes shown]", - &output[..end], - end, - output.len() - )) - } - _ => output.clone(), + }) + } + fn cancel(&self) { + let _ = self.child_handle.cmd_tx.send(SessionCommand::Cancel { + cancel_subagents: true, + kill_background_tasks: true, + rewind_if_pristine: false, + trigger: None, + }); + let _ = self.child_handle.cmd_tx.send(SessionCommand::Shutdown); } } -/// Lightweight entry for subagents that have been requested but are still -/// initializing (creating worktree, resolving config, spawning session). -/// Promoted to a full `SubagentTracker` once the child session is ready. -pub(crate) struct PendingSubagent { - pub subagent_id: String, - pub subagent_type: String, - pub description: String, - pub persona: Option<String>, - pub parent_prompt_id: Option<String>, - pub parent_session_id: String, - pub owner: SubagentOwner, - pub started_at: std::time::Instant, - pub run_in_background: bool, - /// Mirrors `SubagentRequest::surface_completion`. - pub surface_completion: bool, - #[expect( - dead_code, - reason = "unused in production; remove expect when wired or delete the item" - )] - pub color: Option<xai_grok_agent::config::AgentColor>, - /// Spawn-future cancel token; firing it aborts the spawn at the promote - /// checkpoint and emits a cancelled `SubagentFinished`. - pub cancel_token: CancellationToken, +#[derive(Default)] +pub(crate) struct ShellCompletionData { + auto_wake_enabled: bool, + task_completion_reservations: + Option<xai_grok_tools::reminders::task_completion::TaskCompletionReservations>, + parent_cmd_tx: Option<mpsc::UnboundedSender<SessionCommand>>, + task_output_tool_name: String, + synthetic_trace_tx: + Option<mpsc::UnboundedSender<crate::upload::turn::SyntheticTurnTraceRequest>>, + goal_loop_active: Arc<std::sync::atomic::AtomicBool>, + telemetry_tokens: u64, + spawned_notification_emitted: bool, + persisted_output_dir: Option<PathBuf>, } -/// Parameter bag for `SubagentCoordinator::record_failure_completion`. -struct FailureCompletion<'a> { - subagent_id: String, - subagent_type: String, - description: String, - parent_prompt_id: Option<String>, - parent_session_id: String, - owner: SubagentOwner, - persona: Option<String>, - started_at: std::time::Instant, - error: &'a str, - surface_completion: bool, - /// Terminal status `"cancelled"` rather than `"failed"` (pending subagent - /// killed mid-initialization). - cancelled: bool, +impl ShellCompletionData { + fn from_context(ctx: &SubagentSpawnContext) -> Self { + Self { + auto_wake_enabled: ctx.auto_wake_enabled, + task_completion_reservations: ctx.task_completion_reservations.clone(), + parent_cmd_tx: ctx.parent_cmd_tx.clone(), + task_output_tool_name: ctx.task_output_tool_name.clone(), + synthetic_trace_tx: ctx.synthetic_trace_tx.clone(), + goal_loop_active: Arc::clone(&ctx.goal_loop_active), + telemetry_tokens: 0, + spawned_notification_emitted: false, + persisted_output_dir: None, + } + } + pub(crate) fn persisted_output_dir(&self) -> Option<&Path> { + self.persisted_output_dir.as_deref() + } + fn set_persisted_output_dir(&mut self, path: Option<PathBuf>) { + self.persisted_output_dir = path; + } } -/// Shared reply slot for one live blocking `SubagentQueryRequest`. -/// -/// The query poll loop (`subagent_coordinator.rs`) parks the oneshot sender -/// here and delivers through it; the completion handler reads it to verify — -/// at auto-wake decision time — whether a blocking waiter can still receive -/// the result. `Rc` is safe: the coordinator and all its users live on the -/// agent's single-threaded `LocalSet` (see `spawn_local` in -/// `start_subagent_coordinator`). -pub(crate) type BlockWaitSlot = - std::rc::Rc<std::cell::RefCell<Option<oneshot::Sender<Option<SubagentSnapshot>>>>>; -/// Owns the active-subagent map and completed-result cache. -/// Stored as a field on `MvpAgent`. -/// -/// Methods on this struct contain all the orchestration logic so -/// `mvp_agent/mod.rs` stays thin. -pub(crate) struct SubagentCoordinator { - /// subagent_id → pending entry (initializing subagents) - pending: HashMap<String, PendingSubagent>, - /// subagent_id → tracker (running subagents) - active: HashMap<String, SubagentTracker>, - /// subagent_id → completed result (finished subagents, kept for polling) - completed: HashMap<String, CompletedSubagent>, - /// Notified each time a subagent moves to the completed state. - /// Multi-wait infrastructure subscribes via `completion_notify()`. - completion_notify: Arc<Notify>, - /// Completions buffered for between-turn delivery. - pending_completions: Vec<SubagentCompletionSummary>, - /// Whether the model's turn is currently active. Shared with the session - /// via `Arc` so it can be toggled at turn boundaries from `handle_prompt`. +pub(crate) struct SubagentPresentation { is_turn_active: Arc<std::sync::atomic::AtomicBool>, - /// Sender for synthetic turn trace requests. Set once by - /// `start_subagent_coordinator`; read on each turn to propagate - /// to session `ToolContext` and notification bridge. pub(crate) synthetic_trace_tx: - Option<tokio::sync::mpsc::UnboundedSender<crate::upload::turn::SyntheticTurnTraceRequest>>, - /// Gauge of initializing + running subagents (`pending.len() + - /// active.len()`), kept in sync by [`Self::sync_running_gauge`]. Read by - /// [`crate::agent::activity::AgentActivity::is_busy`] to defer leader - /// auto-update shutdown while subagents are in flight. - running_gauge: Arc<std::sync::atomic::AtomicUsize>, - /// subagent_id → live blocking-query reply slots. Registered together - /// with `block_waited` so the completion handler can verify at decision - /// time that a waiter is actually able to receive the result (a - /// cancelled turn drops the receiver; the sticky flag alone would - /// wrongly suppress the completion auto-wake). - block_wait_slots: HashMap<String, Vec<BlockWaitSlot>>, - /// Prompts whose subagent usage landed session-only or failed to apply. - /// A report-level incomplete signal only: not a token sink, and it never - /// marks ledgers by itself (a true apply-miss marks them at fold time). - /// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model. - subagent_usage_not_applied_prompts: std::collections::HashSet<String>, - loop_owned: HashMap<String, String>, + Option<mpsc::UnboundedSender<crate::upload::turn::SyntheticTurnTraceRequest>>, } -/// Cap on the completed map (entries are small: identity, counts, and an -/// error string; successful output text lives in `output.json`). -pub(crate) const MAX_COMPLETED_ENTRIES: usize = 1024; -/// Served when an entry's `output.json` cannot be read back. -pub(crate) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]"; -fn tracker_to_summary(t: &SubagentTracker) -> ActiveSubagentSummary { - ActiveSubagentSummary { - subagent_id: t.subagent_id.clone(), - subagent_type: t.subagent_type.clone(), - description: t.description.clone(), - elapsed_ms: t.started_at.elapsed().as_millis() as u64, - } -} -/// Result of `SubagentCoordinator::lookup()`. -/// -/// Separates the synchronous map lookup from the async signals query so -/// callers can drop the `RefCell<SubagentCoordinator>` borrow before -/// awaiting. -pub(crate) enum SnapshotLookup { - /// Subagent is finished — snapshot is fully resolved. - Ready(SubagentSnapshot), - /// Subagent is still running — caller must await `resolve_snapshot()` - /// to populate the live progress fields. - NeedsSignals(RunningSnapshotSeed), -} -/// Metadata extracted synchronously from an active `SubagentTracker`, -/// plus a cloned `SessionSignalsHandle` for the async progress query. -pub(crate) struct RunningSnapshotSeed { - pub(crate) subagent_id: String, - pub(crate) description: String, - pub(crate) subagent_type: String, - pub(crate) started_at_epoch_ms: u64, - pub(crate) duration_ms: u64, - pub(crate) persona: Option<String>, - pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle, -} -/// Resolve an `Option<SnapshotLookup>` into `Option<SubagentSnapshot>`. -/// -/// - `None` → `None` (subagent not found). -/// - `Ready` → returns the completed snapshot unchanged. -/// - `NeedsSignals` → awaits `signals_handle.snapshot()` to populate the -/// `Running { ... }` fields. -/// -/// This is the **single** async helper used by both the immediate query -/// path and the `block=true` polling loop. -pub(crate) async fn resolve_snapshot(lookup: Option<SnapshotLookup>) -> Option<SubagentSnapshot> { - match lookup? { - SnapshotLookup::Ready(snap) => Some(snap), - SnapshotLookup::NeedsSignals(seed) => { - let signals = seed.signals_handle.snapshot().await.unwrap_or_default(); - Some(SubagentSnapshot { - subagent_id: seed.subagent_id, - description: seed.description, - subagent_type: seed.subagent_type, - started_at_epoch_ms: seed.started_at_epoch_ms, - duration_ms: seed.duration_ms, - persona: seed.persona, - status: SubagentSnapshotStatus::Running { - turn_count: signals.turn_count, - tool_call_count: signals.tool_call_count, - tokens_used: signals.context_tokens_used, - context_window_tokens: signals.context_window_tokens, - context_usage_pct: signals.context_window_usage, - tools_used: signals.tools_used, - error_count: signals.error_count, - }, - }) +impl SubagentPresentation { + pub(crate) fn new() -> Self { + Self { + is_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), + synthetic_trace_tx: None, } } + pub(crate) fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> { + Arc::clone(&self.is_turn_active) + } } -/// Check whether a resolved snapshot is still in the `Running` state. -pub(crate) fn is_running(snap: &SubagentSnapshot) -> bool { - matches!( - snap.status, - SubagentSnapshotStatus::Running { .. } | SubagentSnapshotStatus::Initializing - ) +pub(crate) fn present_child_completion( + completion: ChildCompletion<ShellCompletionData>, + gateway: &GatewaySender, +) { + let ChildCompletion { + request, + result, + completion_data, + disposition, + } = completion; + let parent_channel_open = completion_data + .parent_cmd_tx + .as_ref() + .is_some_and(|tx| !tx.is_closed()); + let will_wake = should_auto_wake_subagent( + disposition.backgrounded, + result.cancelled, + completion_data.auto_wake_enabled, + disposition.waiter_delivered, + disposition.explicitly_killed, + completion_data + .goal_loop_active + .load(std::sync::atomic::Ordering::Relaxed), + parent_channel_open, + ) && disposition.should_surface; + if completion_data.spawned_notification_emitted || request.run_in_background { + emit_subagent_notification( + gateway, + &request.parent_session_id, + SessionUpdate::SubagentFinished { + subagent_id: request.id.clone(), + child_session_id: result.child_session_id.clone(), + status: result.status().to_owned(), + error: result.error.clone(), + tool_calls: result.tool_calls, + turns: result.turns, + duration_ms: result.duration_ms, + tokens_used: completion_data.telemetry_tokens, + output: result.success.then(|| result.output.to_string()), + will_wake, + }, + completion_data.parent_cmd_tx.as_ref(), + ); + } + if will_wake { + inject_subagent_completed_prompt( + &request.id, + &result, + &request, + &completion_data.task_completion_reservations, + completion_data.parent_cmd_tx.as_ref(), + &completion_data.task_output_tool_name, + &completion_data.synthetic_trace_tx, + ); + } } -/// Seed for one running subagent returned by -/// `SubagentCoordinator::list_running_for_parent()`. -/// -/// Includes identity fields not present on the single-item -/// `RunningSnapshotSeed` (`parent_session_id`, `child_session_id`). -pub(crate) struct RunningSubagentListSeed { - pub(crate) subagent_id: String, - pub(crate) parent_session_id: String, - pub(crate) child_session_id: String, - pub(crate) subagent_type: String, - pub(crate) description: String, - pub(crate) started_at_epoch_ms: u64, - pub(crate) duration_ms: u64, - pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle, -} -/// Resolved running subagent with live progress from `SessionSignals`. -/// -/// Produced by `resolve_running_list()` and consumed by the ACP extension -/// layer for DTO conversion. -pub(crate) struct ResolvedRunningSubagent { - pub(crate) subagent_id: String, - pub(crate) parent_session_id: String, - pub(crate) child_session_id: String, - pub(crate) subagent_type: String, - pub(crate) description: String, - pub(crate) started_at_epoch_ms: u64, - pub(crate) duration_ms: u64, - pub(crate) turn_count: u32, - pub(crate) tool_call_count: u32, - pub(crate) tokens_used: u64, - pub(crate) context_window_tokens: u64, - pub(crate) context_usage_pct: u8, - pub(crate) tools_used: Vec<String>, - pub(crate) error_count: u32, -} -/// Resolve a list of running subagent seeds concurrently into -/// `ResolvedRunningSubagent` values with live progress data. -/// -/// Uses `join_all` to pull signal snapshots in parallel rather than -/// serially awaiting each handle. -pub(crate) async fn resolve_running_list( - seeds: Vec<RunningSubagentListSeed>, -) -> Vec<ResolvedRunningSubagent> { - let futs = seeds.into_iter().map(|seed| async move { - let signals = seed.signals_handle.snapshot().await.unwrap_or_default(); - ResolvedRunningSubagent { - subagent_id: seed.subagent_id, - parent_session_id: seed.parent_session_id, - child_session_id: seed.child_session_id, - subagent_type: seed.subagent_type, - description: seed.description, - started_at_epoch_ms: seed.started_at_epoch_ms, - duration_ms: seed.duration_ms, - turn_count: signals.turn_count, - tool_call_count: signals.tool_call_count, - tokens_used: signals.context_tokens_used, - context_window_tokens: signals.context_window_tokens, - context_usage_pct: signals.context_window_usage, - tools_used: signals.tools_used, - error_count: signals.error_count, - } - }); - futures::future::join_all(futs).await -} -use xai_grok_subagent_resolution::ResumeSourceData; -/// Resume provenance metadata for a subagent. -#[derive(Debug, Clone, Default)] -pub(crate) struct SubagentProvenance { - pub(crate) fork_parent_prompt_id: Option<String>, - /// ID of the source subagent this session was resumed from. - pub(crate) resumed_from: Option<String>, -} -fn subagent_blocks_parent_turn(request: &SubagentRequest) -> bool { - !request.run_in_background && !request.owner.is_workflow() -} -/// Convert a `std::time::Instant` to approximate epoch milliseconds. -/// -/// `Instant` has no absolute epoch, so we compute the offset from -/// `SystemTime::now()` at the time of the call. This is approximate -/// (a few ms of drift) but sufficient for display purposes. -fn instant_to_epoch_ms(instant: std::time::Instant) -> u64 { - let now_instant = std::time::Instant::now(); - let now_system = std::time::SystemTime::now(); - let elapsed_since_instant = now_instant.saturating_duration_since(instant); - let system_at_instant = now_system - .checked_sub(elapsed_since_instant) - .unwrap_or(now_system); - system_at_instant - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} -use xai_grok_subagent_resolution::resolve_effective_overrides; /// Resolve the sampling config and model ID for a subagent. /// /// Subagents inherit the parent session's model by default. Only an @@ -817,7 +571,7 @@ use xai_grok_subagent_resolution::resolve_effective_overrides; /// and resolution falls through to the next priority. /// /// NOTE: the persona/role/runtime override (`effective_runtime.model`) is -/// applied by the caller (`handle_subagent_request`) BEFORE this function +/// applied by the caller (`run_shell_child`) BEFORE this function /// runs, so it is not handled here. /// /// NOTE: `agent_type` and `use_concise` on the resolved model are @@ -829,7 +583,6 @@ async fn resolve_subagent_sampling_config( agent_model: &xai_grok_agent::config::ModelOverride, ctx: &SubagentSpawnContext, ) -> (xai_grok_sampler::SamplerConfig, acp::ModelId) { - use xai_grok_agent::config::ModelOverride; let (parent_config, parent_mid) = read_parent_sampling_config(ctx).await; let try_pin = |model_id: &str, source: &'static str, unknown_msg: &'static str| { match resolve_model_override_to_config(model_id, ctx) { @@ -887,7 +640,7 @@ async fn resolve_subagent_sampling_config( /// model warns and falls through to the pin path; `None` (inherit) hands /// precedence back to the pin path entirely (pin > agent-def > inherit). /// -/// Extracted from `handle_subagent_request` so the precedence is unit-testable +/// Extracted from `run_shell_child` so the precedence is unit-testable /// without spawning a child session. async fn resolve_effective_model_config( runtime_override_model: Option<&str>, @@ -975,6 +728,8 @@ async fn read_parent_sampling_config( api_backend: cfg.api_backend, auth_scheme, extra_headers, + query_params: cfg.query_params.clone(), + env_http_headers: cfg.env_http_headers.clone(), context_window: cfg.context_window.get(), client_version: creds.client_version, reasoning_effort: cfg.reasoning_effort, @@ -1065,7 +820,6 @@ fn resolve_model_override_to_config( model_id: &str, ctx: &SubagentSpawnContext, ) -> Option<(xai_grok_sampler::SamplerConfig, acp::ModelId)> { - use crate::agent::config::{resolve_credentials, sampling_config_for_model}; let entry = crate::agent::config::find_model_by_id(&ctx.available_models, model_id).cloned()?; let canonical_model_id = if ctx.available_models.contains_key(model_id) { acp::ModelId::new(model_id) @@ -1108,7 +862,6 @@ fn resolve_model_override_to_config( pub(crate) fn resume_inherited_prefix_len( conversation: &[xai_grok_sampling_types::conversation::ConversationItem], ) -> usize { - use xai_grok_sampling_types::conversation::ConversationItem; conversation .iter() .take_while(|i| matches!(i, ConversationItem::System(_))) @@ -1179,7 +932,6 @@ fn forked_initial_context( fn conversation_tail_is_complete( items: &[xai_grok_sampling_types::conversation::ConversationItem], ) -> bool { - use xai_grok_sampling_types::conversation::ConversationItem; matches!( items.last(), Some(ConversationItem::Assistant(a)) if a.tool_calls.is_empty() @@ -1208,7 +960,6 @@ fn verbatim_or_normalize_fork( items: Vec<xai_grok_sampling_types::conversation::ConversationItem>, child_context_window: u64, ) -> InitialContext { - use xai_grok_sampling_types::conversation::ConversationItem; if !items .iter() .any(|i| !matches!(i, ConversationItem::System(_))) @@ -1507,37 +1258,6 @@ async fn bootstrap_initial_context( verbatim_fork: false, }) } -/// Drop guard that moves a pending coordinator entry to completed-as-failed -/// on early return. Call `defuse()` after promoting to active. -struct PendingGuard<'a> { - coordinator: &'a std::cell::RefCell<SubagentCoordinator>, - id: String, - defused: bool, - /// Specific error message set by fail_subagent before returning. - /// Falls back to a generic message if unset. - error: Option<String>, -} -impl PendingGuard<'_> { - fn defuse(mut self) { - self.defused = true; - } - fn set_error(&mut self, error: String) { - self.error = Some(error); - } -} -impl Drop for PendingGuard<'_> { - fn drop(&mut self) { - if !self.defused { - let error = self - .error - .take() - .unwrap_or_else(|| "Subagent failed during initialization".to_string()); - self.coordinator - .borrow_mut() - .move_pending_to_failed(&self.id, &error); - } - } -} /// Resolve the effective working directory for a child session. /// /// Precedence: worktree path > `override_cwd` (non-empty) > parent cwd. The @@ -1587,6 +1307,37 @@ fn select_override_cwd<'a>( request_cwd } } +fn durable_resume_source_for( + id: &str, + parent_session_id: &str, + parent_cwd: &Path, +) -> Option<ResumeSourceData> { + let parent_info = SessionInfo { + id: acp::SessionId::new(parent_session_id), + cwd: parent_cwd.to_string_lossy().into_owned(), + }; + let meta_path = session::persistence::session_dir(&parent_info) + .join("subagents") + .join(id) + .join("meta.json"); + let data = std::fs::read_to_string(meta_path).ok()?; + let meta: SubagentMeta = serde_json::from_str(&data).ok()?; + if meta.parent_session_id != parent_session_id + || !matches!(meta.status.as_str(), "completed" | "failed" | "cancelled") + { + return None; + } + Some(ResumeSourceData { + subagent_id: meta.subagent_id, + child_session_id: meta.child_session_id, + child_cwd: meta.child_cwd.unwrap_or_default(), + worktree_path: meta.worktree_path.map(PathBuf::from), + snapshot_ref: meta.snapshot_ref, + subagent_type: meta.subagent_type, + persona: meta.persona, + model_id: meta.effective_model_id, + }) +} /// Apply `McpInheritance` filtering to a parent MCP pool snapshot. /// /// Returns `None` for `McpInheritance::None` (no pool at all — avoids @@ -1596,7 +1347,6 @@ fn filter_pool_by_inheritance( mut pool: crate::session::mcp_servers::SharedMcpPool, inheritance: &xai_grok_agent::config::McpInheritance, ) -> Option<crate::session::mcp_servers::SharedMcpPool> { - use xai_grok_agent::config::McpInheritance; match inheritance { McpInheritance::All => Some(pool), McpInheritance::None => None, @@ -1631,22 +1381,41 @@ fn resolve_agent_definition( subagent_type: &str, ctx: &SubagentSpawnContext, ) -> Option<xai_grok_agent::config::AgentDefinition> { - let mut def = xai_grok_agent::discovery::by_name_in_cwd_with_plugins( + let cli_agents = ctx + .agent_config + .as_ref() + .map(|config| config.cli_agents.as_slice()) + .unwrap_or_default(); + let resolution_context = xai_grok_subagent_resolution::DefinitionResolutionContext { + cwd: &ctx.parent_cwd, + plugins: ctx.plugin_registry.as_deref(), + cli_agents, + toggles: &ctx.subagent_toggle, + allowed_types: ctx.allowed_subagent_types.as_deref(), + }; + let mut def = xai_grok_subagent_resolution::discover_agent_definition( subagent_type, - &ctx.parent_cwd, - ctx.plugin_registry.as_deref(), - ) - .or_else(|| { - ctx.agent_config.as_ref().and_then(|cfg| { - cfg.cli_agents - .iter() - .find(|d| d.name == subagent_type) - .cloned() - }) - })?; + &resolution_context, + )?; ctx.apply_session_cli_overrides(&mut def); Some(def) } +fn available_agent_names(ctx: &SubagentSpawnContext) -> Vec<String> { + let cli_agents = ctx + .agent_config + .as_ref() + .map(|config| config.cli_agents.as_slice()) + .unwrap_or_default(); + xai_grok_subagent_resolution::available_agent_names( + &xai_grok_subagent_resolution::DefinitionResolutionContext { + cwd: &ctx.parent_cwd, + plugins: ctx.plugin_registry.as_deref(), + cli_agents, + toggles: &ctx.subagent_toggle, + allowed_types: ctx.allowed_subagent_types.as_deref(), + }, + ) +} /// Minimal per-session context for `validate_subagent_type`. /// Avoids the heavy `SubagentSpawnContext` clone on the validation hot path. #[derive(Default)] @@ -1657,59 +1426,35 @@ pub(crate) struct SubagentValidationContext { pub allowed_subagent_types: Option<Vec<String>>, pub cli_agent_names: Vec<String>, } -impl SubagentValidationContext { - /// Toggle lookup; absent keys default to enabled. - pub(crate) fn is_subagent_enabled(&self, name: &str) -> bool { - self.subagent_toggle.get(name).copied().unwrap_or(true) - } -} /// Synchronously validate a subagent type against discovery + toggle + allow-list. /// `Unknown { available }` is sorted by `str::cmp` for stable rendering. pub(crate) fn validate_subagent_type( subagent_type: &str, ctx: &SubagentValidationContext, ) -> SubagentValidateTypeOutcome { - let resolves = ctx.cli_agent_names.iter().any(|n| n == subagent_type) - || xai_grok_agent::discovery::by_name_in_cwd_with_plugins( - subagent_type, - &ctx.parent_cwd, - ctx.plugin_registry.as_deref(), - ) - .is_some(); - if !resolves { - let mut available: Vec<String> = xai_grok_agent::discovery::all_subagents_with_plugins( - &ctx.parent_cwd, - &ctx.subagent_toggle, - ctx.plugin_registry.as_deref(), - ) - .into_iter() - .map(|e| e.name) - .collect(); - let mut seen: std::collections::HashSet<String> = available.iter().cloned().collect(); - for name in &ctx.cli_agent_names { - if !ctx.is_subagent_enabled(name) { - continue; - } - if seen.insert(name.clone()) { - available.push(name.clone()); - } + let context = xai_grok_subagent_resolution::DefinitionValidationContext { + cwd: &ctx.parent_cwd, + plugins: ctx.plugin_registry.as_deref(), + cli_agent_names: &ctx.cli_agent_names, + toggles: &ctx.subagent_toggle, + allowed_types: ctx.allowed_subagent_types.as_deref(), + }; + match xai_grok_subagent_resolution::validate_agent_name(subagent_type, &context) { + Ok(()) => SubagentValidateTypeOutcome::Ok, + Err(xai_grok_subagent_resolution::ResolutionError::Unknown { available, .. }) => { + SubagentValidateTypeOutcome::Unknown { available } } - available.sort(); - return SubagentValidateTypeOutcome::Unknown { available }; + Err(xai_grok_subagent_resolution::ResolutionError::Disabled { .. }) => { + SubagentValidateTypeOutcome::Disabled + } + Err(xai_grok_subagent_resolution::ResolutionError::NotAllowed { allowed, .. }) => { + SubagentValidateTypeOutcome::NotAllowed { allowed } + } + Err( + xai_grok_subagent_resolution::ResolutionError::PersonaResolution(_) + | xai_grok_subagent_resolution::ResolutionError::ResumeValidation(_), + ) => SubagentValidateTypeOutcome::ValidationUnavailable, } - if !ctx.is_subagent_enabled(subagent_type) { - return SubagentValidateTypeOutcome::Disabled; - } - if let Some(ref allowed) = ctx.allowed_subagent_types - && !allowed - .iter() - .any(|t| t.eq_ignore_ascii_case(subagent_type)) - { - return SubagentValidateTypeOutcome::NotAllowed { - allowed: allowed.clone(), - }; - } - SubagentValidateTypeOutcome::Ok } /// Gate an already-resolved subagent type against the `[subagents.toggle]` /// disable map and the parent's allow-list. @@ -1718,31 +1463,41 @@ pub(crate) fn validate_subagent_type( /// `AgentDefinition`; this checks ONLY the toggle + allow-list gates, /// returning `Ok` when the type may run and `Disabled` / `NotAllowed` /// otherwise (never `Unknown` / `ValidationUnavailable`). Shared by -/// [`handle_subagent_request`] and [`describe_subagent_type`] so both apply +/// [`run_shell_child`] and [`describe_subagent_type`] so both apply /// identical gates. fn gate_subagent_type( subagent_type: &str, ctx: &SubagentSpawnContext, ) -> SubagentValidateTypeOutcome { - if !ctx.is_subagent_enabled(subagent_type) { - return SubagentValidateTypeOutcome::Disabled; + let cli_agents = ctx + .agent_config + .as_ref() + .map(|config| config.cli_agents.as_slice()) + .unwrap_or_default(); + let resolution_context = xai_grok_subagent_resolution::DefinitionResolutionContext { + cwd: &ctx.parent_cwd, + plugins: ctx.plugin_registry.as_deref(), + cli_agents, + toggles: &ctx.subagent_toggle, + allowed_types: ctx.allowed_subagent_types.as_deref(), + }; + match xai_grok_subagent_resolution::gate_agent_definition(subagent_type, &resolution_context) { + Ok(()) => SubagentValidateTypeOutcome::Ok, + Err(xai_grok_subagent_resolution::ResolutionError::Disabled { .. }) => { + SubagentValidateTypeOutcome::Disabled + } + Err(xai_grok_subagent_resolution::ResolutionError::NotAllowed { allowed, .. }) => { + SubagentValidateTypeOutcome::NotAllowed { allowed } + } + Err( + xai_grok_subagent_resolution::ResolutionError::Unknown { .. } + | xai_grok_subagent_resolution::ResolutionError::PersonaResolution(_) + | xai_grok_subagent_resolution::ResolutionError::ResumeValidation(_), + ) => SubagentValidateTypeOutcome::ValidationUnavailable, } - if let Some(ref allowed) = ctx.allowed_subagent_types - && !allowed - .iter() - .any(|t| t.eq_ignore_ascii_case(subagent_type)) - { - return SubagentValidateTypeOutcome::NotAllowed { - allowed: allowed.clone(), - }; - } - SubagentValidateTypeOutcome::Ok } -/// `false` twin: the alternate flavors re-select toolset presets and -/// templates, so none is representable when the optional -/// harness is compiled out. Keeps ungated call sites compiling. -pub(crate) fn subagent_harness_flavor_is_representable(_agent_type: &str) -> bool { - false +pub(crate) fn subagent_harness_flavor_is_representable(agent_type: &str) -> bool { + xai_grok_subagent_resolution::subagent_harness_flavor_is_representable(agent_type) } /// Apply the harness-dependent toolset/prompt re-selection to a resolved /// agent definition. @@ -1758,28 +1513,27 @@ pub(crate) fn subagent_harness_flavor_is_representable(_agent_type: &str) -> boo /// implementer, else explorer), so the role keeps a capable toolset on the /// chosen harness. /// -/// Extracted so both [`handle_subagent_request`] (real spawn) and +/// Extracted so both [`run_shell_child`] (real spawn) and /// [`describe_subagent_type`] (read-only probe) build the SAME `tool_config` /// for a given `(subagent_type, harness_agent_type, parent_name)` — no /// duplication. fn resolve_subagent_toolset( - #[allow(unused_variables)] subagent_type: &str, + subagent_type: &str, harness_agent_type: Option<&str>, ctx: &SubagentSpawnContext, definition: &mut xai_grok_agent::config::AgentDefinition, ) { - let flavor_agent = match harness_agent_type { - Some(h) => Some(h), - None => ctx - .parent_agent_name - .as_deref() - .filter(|s| subagent_harness_flavor_is_representable(s)) - .or(ctx.parent_model_agent_type.as_deref()), + let resolution_context = xai_grok_subagent_resolution::HarnessToolsetContext { + harness_override: harness_agent_type, + parent_agent_name: ctx.parent_agent_name.as_deref(), + parent_model_agent_type: ctx.parent_model_agent_type.as_deref(), + file_tool_overrides: ctx.file_tool_overrides.as_deref(), }; - if flavor_agent.is_some_and(subagent_harness_flavor_is_representable) { - } else if let Some(ref file_tools) = ctx.file_tool_overrides { - definition.override_file_tools(file_tools.clone()); - } + xai_grok_subagent_resolution::apply_harness_toolset( + subagent_type, + &resolution_context, + definition, + ); } /// Map a resolved `ToolServerConfig` into a [`SubagentTypeSummary`]. /// @@ -1797,8 +1551,6 @@ fn resolve_subagent_toolset( fn summarize_tool_config( config: &xai_grok_tools::registry::types::ToolServerConfig, ) -> SubagentTypeSummary { - use std::collections::HashMap; - use xai_grok_tools::types::tool::ToolKind; let mut tool_names: HashMap<ToolKind, String> = HashMap::new(); for tc in &config.tools { let Some(kind) = tc.kind else { continue }; @@ -1815,7 +1567,7 @@ fn summarize_tool_config( } /// Describe a subagent type's resolved toolset WITHOUT spawning it. /// -/// Runs the same resolution path as [`handle_subagent_request`] — +/// Runs the same resolution path as [`run_shell_child`] — /// [`resolve_agent_definition`] + [`gate_subagent_type`] + /// [`resolve_subagent_toolset`] — then summarizes the resulting /// `tool_config`. Backs the `SubagentEvent::DescribeType` drain arm; the @@ -1839,35 +1591,28 @@ pub(crate) fn describe_subagent_type( if let Some(harness) = harness_agent_type && resolve_agent_definition(harness, ctx).is_none() { - let mut available: Vec<String> = xai_grok_agent::discovery::all_subagents_with_plugins( - &ctx.parent_cwd, - &ctx.subagent_toggle, - ctx.plugin_registry.as_deref(), - ) - .into_iter() - .map(|e| e.name) - .collect(); - available.sort(); - return SubagentDescribeOutcome::Unknown { available }; + return SubagentDescribeOutcome::Unknown { + available: available_agent_names(ctx), + }; } let Some(mut definition) = resolve_agent_definition(subagent_type, ctx) else { - let mut available: Vec<String> = xai_grok_agent::discovery::all_subagents_with_plugins( - &ctx.parent_cwd, - &ctx.subagent_toggle, - ctx.plugin_registry.as_deref(), - ) - .into_iter() - .map(|e| e.name) - .collect(); - available.sort(); - return SubagentDescribeOutcome::Unknown { available }; + return SubagentDescribeOutcome::Unknown { + available: available_agent_names(ctx), + }; }; match gate_subagent_type(subagent_type, ctx) { SubagentValidateTypeOutcome::Disabled => return SubagentDescribeOutcome::Disabled, SubagentValidateTypeOutcome::NotAllowed { allowed } => { return SubagentDescribeOutcome::NotAllowed { allowed }; } - _ => {} + SubagentValidateTypeOutcome::Unknown { available } => { + return SubagentDescribeOutcome::Unknown { available }; + } + SubagentValidateTypeOutcome::ValidationUnavailable => { + return SubagentDescribeOutcome::Unavailable; + } + SubagentValidateTypeOutcome::Ok => {} + _ => return SubagentDescribeOutcome::Unavailable, } resolve_subagent_toolset(subagent_type, harness_agent_type, ctx, &mut definition); SubagentDescribeOutcome::Ok(summarize_tool_config(&definition.tool_config)) @@ -1920,7 +1665,6 @@ fn resolve_subagent_permission_mode( is_plugin: bool, policy_block: Option<&'static str>, ) -> xai_grok_agent::config::PermissionMode { - use xai_grok_agent::config::PermissionMode; if is_plugin { return PermissionMode::Default; } @@ -1948,17 +1692,6 @@ async fn await_subagent_turn_or_cancellation( turn_result = prompt_rx => SubagentWaitOutcome::TurnResult(Box::new(turn_result)), } } -/// Max time a blocking `spawn_subagent` may hold the turn before it is -/// auto-backgrounded (non-destructively). Env override: `GROK_SUBAGENT_AWAIT_BUDGET_MS`. -const SUBAGENT_AWAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(600); -fn subagent_await_budget() -> std::time::Duration { - std::env::var("GROK_SUBAGENT_AWAIT_BUDGET_MS") - .ok() - .and_then(|v| v.parse::<u64>().ok()) - .filter(|&ms| ms > 0) - .map(std::time::Duration::from_millis) - .unwrap_or(SUBAGENT_AWAIT_BUDGET) -} /// Fallback for cancelled/errored paths where TurnDeltaSnapshot is unavailable. async fn signals_snapshot_counts(child_handle: &SessionHandle) -> (u32, u32) { child_handle @@ -1972,7 +1705,6 @@ fn cancellation_error_message( category: Option<xai_file_utils::events::types::CancellationCategory>, context: Option<&crate::session::commands::CancellationContext>, ) -> String { - use xai_file_utils::events::types::CancellationCategory; let detail = context.and_then(|ctx| { let tool = ctx.tool_name.as_deref(); let reason = ctx.reason.as_deref(); @@ -2018,11 +1750,12 @@ fn cancellation_error_message( /// completion notification can never promise a wake the inject won't do. /// /// `cancelled` results never wake: a child dies cancelled because the user -/// (or parent teardown) killed it — most acutely the Ctrl+C race where -/// `ParentGone` detaches a foreground child to background moments before the -/// in-flight `SubagentEvent::Cancel` lands its token, which would otherwise -/// wake the model right after the user stopped everything. The completion is -/// still recorded, so reminder/drain surfaces can report it later. +/// (or parent teardown) killed it — most acutely the Ctrl+C race where the +/// shared coordinator's caller-gone reap (`background_if_caller_gone`) +/// detaches a foreground child to background moments before the in-flight +/// `SubagentEvent::Cancel` lands its token, which would otherwise wake the +/// model right after the user stopped everything. The completion is still +/// recorded, so reminder/drain surfaces can report it later. fn should_auto_wake_subagent( run_in_background: bool, cancelled: bool, @@ -2064,20 +1797,8 @@ fn inject_subagent_completed_prompt( if let Some(reservations) = task_completion_reservations { reservations.reserve(subagent_id.to_string()); } - let summary = SubagentCompletionSummary { - subagent_id: subagent_id.to_string(), - owner_session_id: request.parent_session_id.clone(), - subagent_type: request.subagent_type.clone(), - description: request.description.clone(), - success: result.success && !result.cancelled, - duration_ms: result.duration_ms, - tool_calls: result.tool_calls, - turns: result.turns, - output: cap_completion_output( - &result.output, - request.runtime_overrides.completion_output_cap, - ), - }; + let summary = + xai_grok_tools::implementations::grok_build::task::completion_summary(request, result); let message = xai_grok_tools::reminders::task_completion::format_subagent_completion( &summary, Some(task_output_tool_name), @@ -2130,94 +1851,45 @@ fn inject_subagent_completed_prompt( }); } } -/// Post-`insert_pending`, pre-`SubagentSpawned` failure: just send via oneshot; -/// `PendingGuard::drop` handles the queue side effects. -pub(crate) fn send_failure(request: SubagentRequest, error: &str) { - let _ = request.result_tx.send(SubagentResult { +fn failure_result(request: &SubagentRequest, error: &str) -> SubagentResult { + SubagentResult { success: false, error: Some(error.to_string()), + subagent_id: request.id.clone(), + child_session_id: request.id.clone(), ..Default::default() - }); + } } -fn send_pre_spawn_cancelled(request: SubagentRequest, error: &str) { - let _ = request.result_tx.send(SubagentResult { +fn cancelled_result(request: &SubagentRequest, error: &str) -> SubagentResult { + SubagentResult { success: false, cancelled: true, error: Some(error.to_string()), - subagent_id: request.id, + subagent_id: request.id.clone(), + child_session_id: request.id.clone(), ..Default::default() - }); -} -/// Fail BEFORE `insert_pending`. Sends via oneshot; for background-mode -/// requests also records a synthetic `CompletedSubagent` + emits a -/// `SubagentFinished` notification (persisted + live). -fn send_pre_spawn_failure( - request: SubagentRequest, - error: &str, - coordinator: &std::cell::RefCell<SubagentCoordinator>, - ctx: &SubagentSpawnContext, - gateway: &GatewaySender, -) { - let SubagentRequest { - id, - subagent_type, - description, - parent_prompt_id, - owner, - result_tx, - run_in_background, - surface_completion, - .. - } = request; - if run_in_background { - let notification_subagent_id = id.clone(); - coordinator.borrow_mut().record_pre_spawn_failure( - id, - subagent_type, - description, - parent_prompt_id, - ctx.parent_session_id.clone(), - owner, - error, - surface_completion, - ); - emit_subagent_notification( - gateway, - &ctx.parent_session_id, - SessionUpdate::SubagentFinished { - subagent_id: notification_subagent_id, - child_session_id: String::new(), - status: "failed".to_string(), - error: Some(error.to_string()), - tool_calls: 0, - turns: 0, - duration_ms: 0, - tokens_used: 0, - output: None, - will_wake: false, - }, - ctx.parent_cmd_tx.as_ref(), - ); } - let _ = result_tx.send(SubagentResult { - success: false, - error: Some(error.to_string()), - ..Default::default() - }); } -/// Post-`SubagentSpawned` failure: oneshot + `SubagentFinished` + `meta.json` update. +fn child_run_output( + result: SubagentResult, + completion_data: ShellCompletionData, + snapshot_ref: Option<String>, +) -> ChildRunOutput<ShellCompletionData> { + ChildRunOutput { + result, + completion_data, + snapshot_ref, + } +} +/// Persist a failure after `SubagentSpawned`; lifecycle delivery stays actor-owned. fn fail_subagent( - request: SubagentRequest, error: &str, subagent_id: &str, child_session_id: &acp::SessionId, subagent_meta_dir: &Path, - gateway: &GatewaySender, - parent_session_id: &str, - parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>, duration_ms: u64, gcs_ctx: &GcsUploadContext, -) { +) -> SubagentResult { let result = SubagentResult { success: false, error: Some(error.to_string()), @@ -2227,46 +1899,20 @@ fn fail_subagent( ..Default::default() }; persist_subagent_completion(subagent_meta_dir, &result, gcs_ctx); - emit_subagent_notification( - gateway, - parent_session_id, - SessionUpdate::SubagentFinished { - subagent_id: subagent_id.to_string(), - child_session_id: child_session_id.0.to_string(), - status: result.status().to_string(), - error: result.error.clone(), - tool_calls: 0, - turns: 0, - duration_ms, - tokens_used: 0, - output: None, - will_wake: false, - }, - parent_cmd_tx, - ); - let _ = request.result_tx.send(result); + result } -/// Tear down a subagent killed while pending: shut the idle child, dispose its -/// worktree (only if `worktree_freshly_created` — a resumed subagent's aliases -/// the source's and must survive), persist + emit a single cancelled -/// `SubagentFinished`, move the entry to completed-as-cancelled (stays -/// queryable), and deliver the result. Defuse the `PendingGuard` before calling. -async fn cancel_pending_subagent_at_promote( - request: SubagentRequest, - child_handle: &SessionHandle, +/// Tear down a child whose pending-to-active promotion lost to cancellation. +async fn cancel_pending_shell_child( + child_cmd_tx: &mpsc::UnboundedSender<SessionCommand>, subagent_id: &str, child_session_id: &acp::SessionId, subagent_meta_dir: &Path, - coordinator: &std::cell::RefCell<SubagentCoordinator>, - gateway: &GatewaySender, - parent_session_id: &str, - parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>, worktree_path: Option<&Path>, worktree_freshly_created: bool, duration_ms: u64, gcs_ctx: &GcsUploadContext, -) { - let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown); +) -> SubagentResult { + let _ = child_cmd_tx.send(SessionCommand::Shutdown); if worktree_freshly_created && let Some(wt_path) = worktree_path && let Err(e) = crate::session::worktree::remove_subagent_worktree(wt_path).await @@ -2288,27 +1934,7 @@ async fn cancel_pending_subagent_at_promote( ..Default::default() }; persist_subagent_completion(subagent_meta_dir, &result, gcs_ctx); - emit_subagent_notification( - gateway, - parent_session_id, - SessionUpdate::SubagentFinished { - subagent_id: subagent_id.to_string(), - child_session_id: child_session_id.0.to_string(), - status: result.status().to_string(), - error: result.error.clone(), - tool_calls: 0, - turns: 0, - duration_ms, - tokens_used: 0, - output: None, - will_wake: false, - }, - parent_cmd_tx, - ); - coordinator - .borrow_mut() - .move_pending_to_cancelled(subagent_id, "Subagent was cancelled"); - let _ = request.result_tx.send(result); + result } fn emit_subagent_notification( gateway: &GatewaySender, @@ -2678,14 +2304,7 @@ fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool { } true } -/// On-disk schema of `output.json`, written beside `meta.json`. -#[derive(serde::Deserialize)] -struct SubagentOutputFile { - schema_version: u32, - output: String, -} -/// Borrowed twin of [`SubagentOutputFile`] so serialization does not copy -/// the output text. +/// Borrowed output schema so persistence does not copy the text. #[derive(serde::Serialize)] struct SubagentOutputFileRef<'a> { schema_version: u32, @@ -2710,24 +2329,15 @@ fn write_subagent_output(dir: &Path, output: &str) -> bool { } true } -/// Read back `output.json`. `None` on any read or parse failure. pub(crate) fn read_subagent_output(dir: &Path) -> Option<String> { - let data = std::fs::read_to_string(dir.join("output.json")).ok()?; - let file: SubagentOutputFile = match serde_json::from_str(&data) { - Ok(file) => file, - Err(e) => { - tracing::warn!(error = %e, "failed to parse subagent output.json"); - return None; - } - }; - if file.schema_version != SUBAGENT_OUTPUT_SCHEMA_VERSION { - tracing::warn!( - found = file.schema_version, - expected = SUBAGENT_OUTPUT_SCHEMA_VERSION, - "unexpected output.json schema version" - ); + #[derive(serde::Deserialize)] + struct OutputFile { + schema_version: u32, + output: String, } - Some(file.output) + let data = std::fs::read_to_string(dir.join("output.json")).ok()?; + let file: OutputFile = serde_json::from_str(&data).ok()?; + (file.schema_version == SUBAGENT_OUTPUT_SCHEMA_VERSION).then_some(file.output) } /// Extra runtime context for GCS artifact upload. `SubagentMeta` doesn't /// persist these fields, so they're carried from the spawn site. @@ -2860,21 +2470,38 @@ fn finalize_orphaned_subagent( } /// Parse `meta_path` and return it only when it is a stale `running` orphan /// owned by `parent_session_id` and not tracked live. Malformed metas → `None`. -fn running_orphan_meta( - meta_path: &Path, - coordinator: &SubagentCoordinator, - parent_session_id: &str, -) -> Option<SubagentMeta> { +fn running_orphan_meta(meta_path: &Path, parent_session_id: &str) -> Option<SubagentMeta> { let data = std::fs::read_to_string(meta_path).ok()?; let meta: SubagentMeta = serde_json::from_str(&data).ok()?; if meta.status != "running" || meta.parent_session_id != parent_session_id { return None; } - if coordinator.is_active_or_pending(&meta.subagent_id) { - return None; - } Some(meta) } +fn completed_finish_from_inspection(inspection: &SubagentInspection) -> Option<SessionUpdate> { + let (status, error, tool_calls, turns) = match &inspection.snapshot.status { + SubagentSnapshotStatus::Completed { + tool_calls, turns, .. + } => ("completed", None, *tool_calls, *turns), + SubagentSnapshotStatus::Failed { error } => ("failed", Some(error.clone()), 0, 0), + SubagentSnapshotStatus::Cancelled { reason } => ("cancelled", reason.clone(), 0, 0), + SubagentSnapshotStatus::Initializing | SubagentSnapshotStatus::Running { .. } => { + return None; + } + }; + Some(SessionUpdate::SubagentFinished { + subagent_id: inspection.snapshot.subagent_id.clone(), + child_session_id: inspection.child_session_id.clone(), + status: status.to_owned(), + error, + tool_calls, + turns, + duration_ms: inspection.snapshot.duration_ms, + tokens_used: 0, + output: None, + will_wake: false, + }) +} /// Heal subagents stuck "Running" after a dead process: emit exactly one /// `SubagentFinished` per id, unioning two id-keyed sources (so a crash orphan /// in both heals once) — `unfinished` replayed spawns whose finish a rewind @@ -2883,9 +2510,9 @@ fn running_orphan_meta( /// the coordinator still holds its terminal result, then re-emit that); a terminal /// meta that survived a rewound finish re-emits its real outcome; a no-meta /// replayed spawn → `cancelled`. Runs after replay so the finish orders after the spawn. -pub(crate) fn reconcile_orphaned_subagents( +pub(crate) async fn reconcile_orphaned_subagents_with_backend( unfinished: &[(String, String)], - coordinator: &SubagentCoordinator, + backend: &xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend, session_dir: &Path, parent_session_id: &str, gateway: &GatewaySender, @@ -2900,12 +2527,7 @@ pub(crate) fn reconcile_orphaned_subagents( if let Ok(entries) = std::fs::read_dir(&subagents_dir) { for entry in entries.flatten() { let name = entry.file_name(); - if running_orphan_meta( - &entry.path().join("meta.json"), - coordinator, - parent_session_id, - ) - .is_some() + if running_orphan_meta(&entry.path().join("meta.json"), parent_session_id).is_some() && let Some(id) = name.to_str() { candidates.entry(id.to_string()).or_insert(None); @@ -2913,7 +2535,11 @@ pub(crate) fn reconcile_orphaned_subagents( } } for (subagent_id, spawn_child) in candidates { - if coordinator.is_active_or_pending(&subagent_id) { + let inspection = backend.inspect(&subagent_id).await; + if inspection + .as_ref() + .is_some_and(|inspection| inspection.snapshot.is_running()) + { continue; } let subagent_dir = subagents_dir.join(&subagent_id); @@ -2923,7 +2549,10 @@ pub(crate) fn reconcile_orphaned_subagents( match meta { Some(m) if m.parent_session_id != parent_session_id => {} Some(m) if m.status == "running" => { - if let Some(finish) = coordinator.completed_finish(&subagent_id) { + if let Some(finish) = inspection + .as_ref() + .and_then(completed_finish_from_inspection) + { tracing::info!( subagent_id = %subagent_id, parent_session_id, diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs index a882f85..ae384a3 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs @@ -1,8 +1,14 @@ #![cfg_attr(rustfmt, rustfmt::skip)] use super::*; -use super::handle_request::{canonical_total_tokens, usage_is_incomplete}; +use super::handle_request::{ + canonical_total_tokens, record_subagent_usage, usage_is_incomplete, +}; use crate::test_support::lsp_runtime::{ - DummyLspDispatch, ctx_with_toggle, make_request, test_gateway, + DummyLspDispatch, ctx_with_toggle, test_gateway_with_receiver, +}; +use xai_grok_subagent_resolution::resolve_effective_overrides; +use xai_grok_tools::implementations::grok_build::task::coordinator::{ + ChildCompletion, CompletionDisposition, }; #[test] fn canonical_total_tokens_does_not_double_count_reasoning() { @@ -21,6 +27,78 @@ fn cancellation_makes_an_otherwise_complete_usage_snapshot_incomplete() { assert!(!usage_is_incomplete(false, false, 0, false)); assert!(usage_is_incomplete(true, false, 0, false)); } +#[tokio::test] +async fn usage_ack_precedes_terminal_presentation() { + let mut ctx = ctx_with_toggle(HashMap::new()); + let (parent_cmd_tx, mut parent_cmd_rx) = mpsc::unbounded_channel(); + ctx.parent_cmd_tx = Some(parent_cmd_tx); + let by_model = vec![( + "test-model".to_string(), + xai_chat_state::UsageTotals { + input_tokens: 10, + output_tokens: 4, + ..Default::default() + }, + )]; + let mut fold = Box::pin( + record_subagent_usage( + ctx.parent_cmd_tx.as_ref(), + Some(by_model), + Some("parent-prompt".to_string()), + false, + ), + ); + let command = tokio::select! { + command = parent_cmd_rx.recv() => command.expect("usage command"), + result = &mut fold => panic!("usage fold returned before parent command: {result}"), + }; + let SessionCommand::RecordSubagentUsage { respond_to, .. } = command else { + panic!("expected RecordSubagentUsage"); + }; + assert!( + tokio::time::timeout(std::time::Duration::ZERO, &mut fold) + .await + .is_err(), + "child return must wait for usage acknowledgement" + ); + assert!(parent_cmd_rx.try_recv().is_err()); + respond_to.send(()).expect("usage ack"); + assert!(fold.await); + let (gateway, _gateway_rx) = test_gateway_with_receiver(); + let mut request = auto_wake_test_request("usage-order"); + request.run_in_background = false; + let mut completion_data = ShellCompletionData::from_context(&ctx); + completion_data.spawned_notification_emitted = true; + present_child_completion( + ChildCompletion { + request, + result: SubagentResult { + success: true, + subagent_id: "usage-order".to_string(), + child_session_id: "usage-order".to_string(), + ..Default::default() + }, + completion_data, + disposition: CompletionDisposition { + foreground_delivered: true, + backgrounded: false, + waiter_delivered: false, + explicitly_killed: false, + should_surface: false, + }, + }, + &gateway, + ); + assert!(matches!( + parent_cmd_rx.try_recv(), + Ok(SessionCommand::XaiSessionNotification { + notification: SessionNotification { + update: SessionUpdate::SubagentFinished { .. }, + .. + } + }) + )); +} /// Invariant: resolving a subagent applies the parent session's /// `--tools`/`--disallowed-tools`/`--permission-mode` — driven through /// `resolve_agent_definition` so the spawn path can't skip them. @@ -183,412 +261,6 @@ fn no_parent_lsp_means_child_gets_none() { assert!(ctx.lsp.is_none()); } #[test] -fn is_subagent_enabled_returns_true_for_absent_names() { - let ctx = ctx_with_toggle(HashMap::from([("plan".to_string(), false)])); - assert!( - ctx.is_subagent_enabled("explore"), - "absent key should default to enabled" - ); - assert!( - ctx.is_subagent_enabled("general-purpose"), - "absent key should default to enabled" - ); - assert!( - ctx.is_subagent_enabled("custom-agent"), - "absent key should default to enabled" - ); -} -#[test] -fn is_subagent_enabled_returns_false_for_disabled_names() { - let ctx = ctx_with_toggle( - HashMap::from([ - ("plan".to_string(), false), - ("code-reviewer".to_string(), false), - ("explore".to_string(), true), - ]), - ); - assert!( - !ctx.is_subagent_enabled("plan"), - "plan = false should be disabled" - ); - assert!( - !ctx.is_subagent_enabled("code-reviewer"), - "code-reviewer = false should be disabled" - ); - assert!( - ctx.is_subagent_enabled("explore"), - "explore = true should be enabled" - ); -} -#[test] -fn lookup_returns_none_for_unknown_id() { - let coordinator = SubagentCoordinator::new(); - assert!(coordinator.lookup("nonexistent").is_none()); -} -#[test] -fn lookup_returns_ready_for_completed_subagent() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-1", - "test task".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("found 3 files"), - subagent_id: "sub-1".to_string(), - child_session_id: "sub-1".to_string(), - tool_calls: 5, - turns: 2, - duration_ms: 1234, - ..Default::default() - }, - None, - ); - let lookup = coordinator.lookup("sub-1"); - assert!(lookup.is_some()); - assert!( - matches!(lookup, Some(SnapshotLookup::Ready(ref snap)) if snap.subagent_id == "sub-1"), - "completed subagent should return Ready variant" - ); -} -#[tokio::test] -async fn resolve_snapshot_returns_none_for_none_input() { - let result = resolve_snapshot(None).await; - assert!(result.is_none()); -} -#[tokio::test] -async fn resolve_snapshot_returns_ready_unchanged() { - let snap = SubagentSnapshot { - subagent_id: "sub-1".to_string(), - description: "test".to_string(), - subagent_type: "explore".to_string(), - status: SubagentSnapshotStatus::Completed { - output: "done".to_string(), - tool_calls: 3, - turns: 1, - worktree_path: None, - }, - started_at_epoch_ms: 0, - duration_ms: 100, - persona: None, - }; - let result = resolve_snapshot(Some(SnapshotLookup::Ready(snap))).await; - let result = result.expect("Ready should resolve to Some"); - assert_eq!(result.subagent_id, "sub-1"); - assert!(matches!( - result.status, - SubagentSnapshotStatus::Completed { .. } - )); -} -#[tokio::test] -async fn resolve_snapshot_populates_running_from_signals() { - use crate::session::signals::SessionSignalsHandle; - let signals_handle = SessionSignalsHandle::new(); - signals_handle.increment_turn(); - signals_handle.record_tool_call("bash"); - signals_handle.record_tool_call("read_file"); - signals_handle.record_tool_call("bash"); - tokio::task::yield_now().await; - let seed = RunningSnapshotSeed { - subagent_id: "sub-running".to_string(), - description: "running task".to_string(), - subagent_type: "general-purpose".to_string(), - started_at_epoch_ms: 1000, - duration_ms: 5000, - persona: None, - signals_handle, - }; - let result = resolve_snapshot(Some(SnapshotLookup::NeedsSignals(seed))).await; - let snap = result.expect("NeedsSignals should resolve to Some"); - assert_eq!(snap.subagent_id, "sub-running"); - assert_eq!(snap.duration_ms, 5000); - match &snap.status { - SubagentSnapshotStatus::Running { - turn_count, - tool_call_count, - tools_used, - .. - } => { - assert_eq!(*turn_count, 1, "should have 1 turn"); - assert_eq!(*tool_call_count, 3, "should have 3 tool calls"); - assert!( - tools_used.contains(&"bash".to_string()), - "tools_used should contain bash" - ); - assert!( - tools_used.contains(&"read_file".to_string()), - "tools_used should contain read_file" - ); - } - other => panic!("expected Running, got {other:?}"), - } -} -#[test] -fn is_running_returns_true_for_running_variant() { - let snap = SubagentSnapshot { - subagent_id: "s".to_string(), - description: "d".to_string(), - subagent_type: "t".to_string(), - status: SubagentSnapshotStatus::Running { - turn_count: 0, - tool_call_count: 0, - tokens_used: 0, - context_window_tokens: 0, - context_usage_pct: 0, - tools_used: vec![], - error_count: 0, - }, - started_at_epoch_ms: 0, - duration_ms: 0, - persona: None, - }; - assert!(is_running(&snap)); -} -#[test] -fn is_running_returns_false_for_completed_variant() { - let snap = SubagentSnapshot { - subagent_id: "s".to_string(), - description: "d".to_string(), - subagent_type: "t".to_string(), - status: SubagentSnapshotStatus::Completed { - output: "done".to_string(), - tool_calls: 0, - turns: 0, - worktree_path: None, - }, - started_at_epoch_ms: 0, - duration_ms: 0, - persona: None, - }; - assert!(!is_running(&snap)); -} -#[test] -fn lookup_returns_initializing_for_pending_subagent() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-pending".to_string(), - subagent_type: "general-purpose".to_string(), - description: "pending task".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - let lookup = coordinator.lookup("sub-pending"); - assert!( - matches!( - lookup, - Some(SnapshotLookup::Ready(ref snap)) - if snap.subagent_id == "sub-pending" - && matches!(snap.status, SubagentSnapshotStatus::Initializing) - ), - "pending subagent should return Ready(Initializing)" - ); -} -/// The running gauge must track `pending.len() + active.len()` through the -/// full lifecycle: it feeds `AgentActivity::is_busy`, which gates the -/// leader auto-update shutdown. -#[tokio::test] -async fn running_gauge_tracks_pending_and_active() { - use std::sync::atomic::Ordering; - let mut coordinator = SubagentCoordinator::new(); - let gauge = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - coordinator.set_running_gauge(gauge.clone()); - assert_eq!(gauge.load(Ordering::Relaxed), 0); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-gauge".to_string(), - subagent_type: "general-purpose".to_string(), - description: "gauge task".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - assert_eq!( - gauge.load(Ordering::Relaxed), - 1, - "pending counts as running" - ); - coordinator - .insert( - dummy_tracker("sub-gauge", "parent-session", "general-purpose", "gauge task"), - ); - assert_eq!(gauge.load(Ordering::Relaxed), 1, "active counts as running"); - coordinator - .move_to_completed( - "sub-gauge", - "gauge task".into(), - "general-purpose".into(), - SubagentResult::default(), - None, - ); - assert_eq!(gauge.load(Ordering::Relaxed), 0, "completed does not count"); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-gauge-2".to_string(), - subagent_type: "general-purpose".to_string(), - description: "gauge task 2".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - assert_eq!(gauge.load(Ordering::Relaxed), 1); - coordinator.move_pending_to_failed("sub-gauge-2", "worktree setup failed"); - assert_eq!(gauge.load(Ordering::Relaxed), 0); - let late_gauge = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-gauge-3".to_string(), - subagent_type: "general-purpose".to_string(), - description: "gauge task 3".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator.set_running_gauge(late_gauge.clone()); - assert_eq!(late_gauge.load(Ordering::Relaxed), 1); -} -#[test] -fn mark_block_waited_sets_flag_on_completed() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-bw", - "test".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: "sub-bw".into(), - child_session_id: "sub-bw".into(), - ..Default::default() - }, - None, - ); - assert!(!coordinator.is_block_waited("sub-bw")); - coordinator.mark_block_waited("sub-bw"); - assert!(coordinator.is_block_waited("sub-bw")); -} -#[test] -fn is_block_waited_returns_false_for_unknown_id() { - let coordinator = SubagentCoordinator::new(); - assert!(!coordinator.is_block_waited("nonexistent")); -} -/// Race condition: caller cancels the blocking wait -/// (receiver dropped) and the subagent completes before the query poll -/// loop's next 200ms tick clears the flag. The completion handler's -/// decision-time check must see the dead slot, clear `block_waited`, -/// and let the auto-wake fire. -#[tokio::test] -async fn block_wait_decision_wakes_when_waiter_cancelled_before_poll_tick() { - let mut coordinator = SubagentCoordinator::new(); - let tracker = dummy_tracker("sub-race", "session-A", "explore", "bg task"); - coordinator.insert(tracker); - let (tx, rx) = oneshot::channel::<Option<SubagentSnapshot>>(); - let slot: BlockWaitSlot = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); - coordinator.register_block_wait("sub-race", slot.clone()); - assert!(coordinator.is_block_waited("sub-race")); - drop(rx); - assert!(coordinator.is_block_waited("sub-race")); - assert!( - !coordinator.block_wait_delivered_or_live("sub-race"), - "cancelled waiter must not suppress the completion auto-wake" - ); - assert!( - !coordinator.is_block_waited("sub-race"), - "decision must clear the stale block_waited flag" - ); -} -/// A live waiter (receiver still open) keeps the wake suppressed — the -/// poll loop will deliver the result within one tick. -#[tokio::test] -async fn block_wait_decision_suppresses_for_live_waiter() { - let mut coordinator = SubagentCoordinator::new(); - let tracker = dummy_tracker("sub-live", "session-A", "explore", "bg task"); - coordinator.insert(tracker); - let (tx, _rx) = oneshot::channel::<Option<SubagentSnapshot>>(); - let slot: BlockWaitSlot = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); - coordinator.register_block_wait("sub-live", slot.clone()); - assert!( - coordinator.block_wait_delivered_or_live("sub-live"), - "live waiter will receive the result — wake would be redundant" - ); - assert!( - coordinator.is_block_waited("sub-live"), - "flag stays set for a live waiter" - ); -} -/// A consumed sender (result already delivered) keeps the wake -/// suppressed even though the registration is gone. -#[tokio::test] -async fn block_wait_decision_suppresses_after_delivery() { - let mut coordinator = SubagentCoordinator::new(); - let tracker = dummy_tracker("sub-dlv", "session-A", "explore", "bg task"); - coordinator.insert(tracker); - let (tx, mut rx) = oneshot::channel::<Option<SubagentSnapshot>>(); - let slot: BlockWaitSlot = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); - coordinator.register_block_wait("sub-dlv", slot.clone()); - let tx = slot.borrow_mut().take().expect("sender parked"); - let _ = tx.send(None); - assert!(rx.try_recv().is_ok(), "receiver got the result"); - coordinator.unregister_block_wait("sub-dlv", &slot); - assert!( - coordinator.block_wait_delivered_or_live("sub-dlv"), - "already-delivered result must keep the wake suppressed" - ); -} -#[tokio::test] -async fn mark_explicitly_killed_active_then_propagates_to_completed() { - let mut coordinator = SubagentCoordinator::new(); - let tracker = dummy_tracker("sub-ek", "session-A", "explore", "bg task"); - coordinator.insert(tracker); - assert!(!coordinator.is_explicitly_killed("sub-ek")); - coordinator.mark_explicitly_killed("sub-ek"); - assert!(coordinator.is_explicitly_killed("sub-ek")); - coordinator - .move_to_completed( - "sub-ek", - "bg task".into(), - "explore".into(), - SubagentResult { - success: false, - cancelled: true, - subagent_id: "sub-ek".into(), - child_session_id: "sub-ek".into(), - ..Default::default() - }, - None, - ); - assert!( - coordinator.is_explicitly_killed("sub-ek"), - "flag must propagate from active tracker to completed entry" - ); -} -#[test] fn should_auto_wake_subagent_requires_background_and_enabled() { assert!(!should_auto_wake_subagent( false, false, true, false, false, false, true @@ -641,7 +313,6 @@ fn should_auto_wake_subagent_requires_open_parent_channel() { )); } fn auto_wake_test_request(id: &str) -> SubagentRequest { - let (result_tx, _result_rx) = oneshot::channel(); SubagentRequest { id: id.into(), prompt: String::new(), @@ -658,7 +329,6 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest { fork_context: false, owner: SubagentOwner::Task, cancel_token: CancellationToken::new(), - result_tx, } } /// Behavior-level: the action half of the subagent auto-wake. @@ -727,120 +397,7 @@ fn inject_subagent_completed_prompt_releases_reservation_when_parent_closed() { assert!(trace_rx.try_recv().is_err()); } #[test] -fn mark_explicitly_killed_sets_flag_on_completed() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-ek-c", - "test".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: "sub-ek-c".into(), - child_session_id: "sub-ek-c".into(), - ..Default::default() - }, - None, - ); - assert!(!coordinator.is_explicitly_killed("sub-ek-c")); - coordinator.mark_explicitly_killed("sub-ek-c"); - assert!(coordinator.is_explicitly_killed("sub-ek-c")); -} -#[test] -fn is_explicitly_killed_returns_false_for_unknown_id() { - let coordinator = SubagentCoordinator::new(); - assert!(!coordinator.is_explicitly_killed("nonexistent")); -} -#[tokio::test] -async fn block_waited_propagates_through_move_to_completed() { - let mut coordinator = SubagentCoordinator::new(); - let mut tracker = dummy_tracker("sub-prop", "session-A", "explore", "bg task"); - tracker.block_waited = true; - coordinator.insert(tracker); - coordinator - .move_to_completed( - "sub-prop", - "bg task".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: "sub-prop".into(), - child_session_id: "sub-prop".into(), - ..Default::default() - }, - None, - ); - assert!(coordinator.is_block_waited("sub-prop")); -} -fn complete_dummy(coordinator: &mut SubagentCoordinator, id: &str, surface: bool) { - let mut tracker = dummy_tracker(id, "session-A", "explore", "task"); - tracker.surface_completion = surface; - coordinator.insert(tracker); - coordinator - .move_to_completed( - id, - "task".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: id.into(), - child_session_id: id.into(), - ..Default::default() - }, - None, - ); -} -#[tokio::test] -async fn move_to_completed_surfaces_when_flag_true() { - let mut coordinator = SubagentCoordinator::new(); - complete_dummy(&mut coordinator, "sub-surface", true); - let drained = coordinator.drain_pending_completions_for(""); - assert_eq!(drained.len(), 1); - assert_eq!(drained[0].subagent_id, "sub-surface"); -} -#[tokio::test] -async fn move_to_completed_skips_buffer_when_flag_false() { - let mut coordinator = SubagentCoordinator::new(); - complete_dummy(&mut coordinator, "sub-hidden", false); - assert!(coordinator.drain_pending_completions_for("").is_empty()); - assert!(coordinator.lookup("sub-hidden").is_some()); -} -fn fail_pending(coordinator: &mut SubagentCoordinator, id: &str, surface: bool) { - coordinator - .insert_pending(PendingSubagent { - subagent_id: id.to_string(), - subagent_type: "explore".to_string(), - description: "task".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: surface, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator.move_pending_to_failed(id, "boom"); -} -#[test] -fn failure_completion_surfaces_when_flag_true() { - let mut coordinator = SubagentCoordinator::new(); - fail_pending(&mut coordinator, "fail-surface", true); - let drained = coordinator.drain_pending_completions_for(""); - assert_eq!(drained.len(), 1); - assert_eq!(drained[0].subagent_id, "fail-surface"); - assert!(!drained[0].success); -} -#[test] -fn failure_completion_skips_buffer_when_flag_false() { - let mut coordinator = SubagentCoordinator::new(); - fail_pending(&mut coordinator, "fail-hidden", false); - assert!(coordinator.drain_pending_completions_for("").is_empty()); - assert!(coordinator.lookup("fail-hidden").is_some()); -} -#[test] -fn is_running_returns_true_for_initializing_variant() { +fn initializing_snapshot_is_running() { let snap = SubagentSnapshot { subagent_id: "s".to_string(), description: "d".to_string(), @@ -850,275 +407,7 @@ fn is_running_returns_true_for_initializing_variant() { duration_ms: 0, persona: None, }; - assert!(is_running(&snap)); -} -#[test] -fn remove_pending_clears_entry() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-1".to_string(), - subagent_type: "explore".to_string(), - description: "test".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - assert!(coordinator.lookup("sub-1").is_some()); - coordinator.remove_pending("sub-1"); - assert!( - coordinator.lookup("sub-1").is_none(), - "pending entry should be gone after remove_pending" - ); -} -#[test] -fn move_pending_to_failed_creates_completed_entry() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-fail".to_string(), - subagent_type: "explore".to_string(), - description: "will fail during init".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator.move_pending_to_failed("sub-fail", "Sampling client error: bad config"); - assert!(!coordinator.pending.contains_key("sub-fail")); - let lookup = coordinator.lookup("sub-fail"); - assert!(lookup.is_some(), "failed subagent should be queryable"); - match lookup.unwrap() { - SnapshotLookup::Ready(snap) => { - assert_eq!(snap.subagent_id, "sub-fail"); - assert!( - matches!(snap.status, SubagentSnapshotStatus::Failed { .. }), - "status should be Failed" - ); - if let SubagentSnapshotStatus::Failed { error } = &snap.status { - assert!( - error.contains("Sampling client error"), - "error should contain specific message, got: {error}" - ); - } - } - _ => panic!("expected Ready snapshot for completed-as-failed subagent"), - } -} -#[test] -fn move_pending_to_failed_fires_completion_notify() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-notify".to_string(), - subagent_type: "explore".to_string(), - description: "notify test".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator.move_pending_to_failed("sub-notify", "test error"); - let summaries = coordinator.drain_pending_completions_for(""); - assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].subagent_id, "sub-notify"); - assert!(!summaries[0].success); -} -#[test] -fn move_pending_to_failed_noop_for_unknown_id() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.move_pending_to_failed("nonexistent", "error"); - assert!(coordinator.completed.is_empty()); -} -#[test] -fn move_pending_to_cancelled_creates_cancelled_entry() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-killed".to_string(), - subagent_type: "explore".to_string(), - description: "killed while initializing".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator.move_pending_to_cancelled("sub-killed", "Subagent was cancelled"); - assert!(!coordinator.pending.contains_key("sub-killed")); - match coordinator.lookup("sub-killed") { - Some(SnapshotLookup::Ready(snap)) => { - assert!( - matches!(snap.status, SubagentSnapshotStatus::Cancelled { .. }), - "killed-while-pending should be Cancelled, got {:?}", - snap.status - ) - } - _ => { - panic!("expected Ready cancelled snapshot for killed-while-pending subagent") - } - } -} -fn completed_with_output( - id: &str, - text: &str, - persisted_output_dir: Option<PathBuf>, -) -> CompletedSubagent { - CompletedSubagent { - subagent_id: id.into(), - parent_session_id: String::new(), - owner: SubagentOwner::Task, - parent_prompt_id: None, - child_session_id: String::new(), - description: "task".into(), - subagent_type: "explore".into(), - persona: None, - started_at: std::time::Instant::now(), - completed_at: std::time::Instant::now(), - result: SubagentResult { - success: true, - output: std::sync::Arc::from(text), - ..Default::default() - }, - resumed_from: None, - child_cwd: String::new(), - worktree_path: None, - snapshot_ref: None, - effective_model_id: String::new(), - block_waited: false, - explicitly_killed: false, - completion_output_cap: None, - persisted_output_dir, - } -} -fn lookup_output(coordinator: &SubagentCoordinator, id: &str) -> String { - match coordinator.lookup(id) { - Some(SnapshotLookup::Ready(snap)) => { - match snap.status { - SubagentSnapshotStatus::Completed { output, .. } => output, - other => panic!("expected Completed status, got {other:?}"), - } - } - other => { - panic!( - "expected Ready lookup, got {:?}", - other.map(|_| "NeedsSignals/other") - ) - } - } -} -#[test] -fn lookup_degrades_to_placeholder_when_output_file_is_missing() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut coordinator = SubagentCoordinator::new(); - coordinator - .completed - .insert( - "sub-gone".to_string(), - completed_with_output("sub-gone", "", Some(dir.path().to_path_buf())), - ); - assert_eq!( - lookup_output(&coordinator, "sub-gone"), - OUTPUT_UNAVAILABLE_PLACEHOLDER, - "an entry whose output.json is gone must degrade, not fail the query" - ); -} -#[test] -fn lookup_serves_unpersisted_output_from_memory() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .completed - .insert("sub-mem".to_string(), completed_with_output("sub-mem", "output", None)); - assert_eq!( - lookup_output(&coordinator, "sub-mem"), - "output", - "an entry with nothing on disk must serve its in-memory output" - ); -} -#[test] -fn completed_entries_are_capped_oldest_first() { - let mut coordinator = SubagentCoordinator::new(); - let base = std::time::Instant::now(); - for i in 0..(MAX_COMPLETED_ENTRIES + 2) { - let mut entry = completed_with_output( - &format!("sub-{i}"), - "", - Some(std::path::PathBuf::from("/nonexistent")), - ); - entry.completed_at = base + std::time::Duration::from_millis(i as u64); - coordinator.completed.insert(format!("sub-{i}"), entry); - } - coordinator.enforce_completed_cap(); - assert_eq!( - coordinator.completed.len(), - MAX_COMPLETED_ENTRIES, - "the completed map must be capped at MAX_COMPLETED_ENTRIES" - ); - assert!( - !coordinator.completed.contains_key("sub-0") - && !coordinator.completed.contains_key("sub-1"), - "the oldest completions must be evicted first" - ); - assert!( - coordinator.completed.contains_key("sub-2"), - "entries within the cap must survive" - ); -} -#[test] -fn move_to_completed_clears_persisted_output_after_the_summary_clone() { - let dir = tempfile::tempdir().expect("tempdir"); - let full_output = "final report".repeat(100); - assert!(write_subagent_output(dir.path(), &full_output)); - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-e2e", - "task".into(), - "explore".into(), - SubagentResult { - success: true, - output: std::sync::Arc::from(full_output.as_str()), - subagent_id: "sub-e2e".into(), - child_session_id: "sub-e2e".into(), - ..Default::default() - }, - Some(dir.path().to_path_buf()), - ); - let entry = coordinator.completed.get("sub-e2e").expect("entry inserted"); - assert!( - entry.result.output.is_empty(), - "a persisted entry must not keep the output in memory" - ); - assert_eq!( - lookup_output(&coordinator, "sub-e2e"), - full_output, - "lookup must serve the persisted output from disk" - ); - let summaries = coordinator.drain_pending_completions_for(""); - assert_eq!( - &*summaries[0].output, full_output, - "the completion summary must carry the full output" - ); + assert!(snap.is_running()); } #[test] fn persist_gate_only_persists_successful_nonempty_outputs() { @@ -1155,382 +444,6 @@ fn subagent_output_roundtrips_through_output_json() { assert_eq!(read_subagent_output(dir.path()), None); } #[test] -fn cancel_with_outcome_fires_pending_token() { - let mut coordinator = SubagentCoordinator::new(); - let token = CancellationToken::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-cancel".to_string(), - subagent_type: "explore".to_string(), - description: "will be cancelled".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: token.clone(), - }); - let outcome = coordinator.cancel_with_outcome("sub-cancel"); - assert!( - matches!(outcome, SubagentCancelOutcome::Cancelled), - "cancelling pending should return Cancelled" - ); - assert!( - token.is_cancelled(), - "pending cancel must fire the spawn token" - ); - assert!( - coordinator.lookup("sub-cancel").is_some(), - "pending entry stays queryable until the spawn future tears it down" - ); -} -#[tokio::test] -async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("sub-active", "session-A", "explore", "task")); - assert!(matches!( - coordinator.cancel_with_outcome("sub-active"), - SubagentCancelOutcome::Cancelled - )); - coordinator - .move_to_completed( - "sub-done", - "done".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - subagent_id: "sub-done".to_string(), - ..Default::default() - }, - None, - ); - assert!(matches!( - coordinator.cancel_with_outcome("sub-done"), - SubagentCancelOutcome::AlreadyFinished { status } if status == "completed" - )); - assert!(matches!( - coordinator.cancel_with_outcome("nonexistent"), - SubagentCancelOutcome::NotFound - )); -} -#[test] -fn cancel_by_parent_prompt_id_fires_matching_pending_token() { - let mut coordinator = SubagentCoordinator::new(); - let token_a = CancellationToken::new(); - let token_b = CancellationToken::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-p1".to_string(), - subagent_type: "explore".to_string(), - description: "child of prompt-A".to_string(), - persona: None, - parent_prompt_id: Some("prompt-A".to_string()), - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: token_a.clone(), - }); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-p2".to_string(), - subagent_type: "explore".to_string(), - description: "child of prompt-B".to_string(), - persona: None, - parent_prompt_id: Some("prompt-B".to_string()), - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: token_b.clone(), - }); - coordinator.cancel_by_parent_prompt_id("prompt-A"); - assert!(token_a.is_cancelled(), "prompt-A token must fire"); - assert!( - coordinator.lookup("sub-p1").is_some(), - "prompt-A entry stays queryable until spawn teardown" - ); - assert!(!token_b.is_cancelled(), "prompt-B token must not fire"); - assert!(coordinator.lookup("sub-p2").is_some()); -} -#[test] -fn completed_takes_precedence_over_pending_in_lookup() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-dup".to_string(), - subagent_type: "explore".to_string(), - description: "duplicate".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator - .move_to_completed( - "sub-dup", - "duplicate".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("done"), - subagent_id: "sub-dup".to_string(), - child_session_id: "child-dup".to_string(), - ..Default::default() - }, - None, - ); - let lookup = coordinator.lookup("sub-dup"); - assert!( - matches!( - lookup, - Some(SnapshotLookup::Ready(ref snap)) - if matches!(snap.status, SubagentSnapshotStatus::Completed { .. }) - ), - "completed should take precedence over pending" - ); -} -#[test] -fn list_running_for_parent_returns_empty_when_no_active() { - let coordinator = SubagentCoordinator::new(); - let seeds = coordinator.list_running_for_parent("parent-1"); - assert!(seeds.is_empty()); -} -fn dummy_tracker( - subagent_id: &str, - parent_session_id: &str, - subagent_type: &str, - description: &str, -) -> SubagentTracker { - use crate::session::feedback_manager::{FeedbackManager, FeedbackManagerConfig}; - use crate::session::handle::SessionHandle; - use crate::session::info::Info; - use crate::session::plan_mode::PlanModeTracker; - use crate::session::signals::SessionSignalsHandle; - use std::sync::atomic::AtomicBool; - let gateway = test_gateway(); - let cwd = xai_grok_paths::AbsPathBuf::new(PathBuf::from("/tmp")).unwrap(); - let fs: Arc<dyn xai_grok_workspace::file_system::AsyncFileSystem> = Arc::new( - xai_grok_workspace::file_system::LocalFs::new(PathBuf::from("/tmp")), - ); - let terminal: Arc<dyn crate::terminal::AsyncTerminalRunner> = Arc::new( - crate::terminal::TerminalRunner::new( - Arc::new(test_gateway()), - acp::SessionId::new("test"), - ), - ); - let tool_context = crate::tools::ToolContext::new( - cwd, - Some(gateway), - Some(acp::SessionId::new("test")), - fs, - terminal, - xai_hunk_tracker::HunkTrackerHandle::noop(), - ); - let signals_handle = SessionSignalsHandle::new(); - let feedback_manager = FeedbackManager::new( - "test", - None, - FeedbackManagerConfig::default(), - ); - let handle = SessionHandle { - cmd_tx: tokio::sync::mpsc::unbounded_channel().0, - persistence_tx: tokio::sync::mpsc::unbounded_channel().0, - current_prompt_id: Arc::new(std::sync::Mutex::new(None)), - pending_interactions: Arc::new( - std::sync::Mutex::new(std::collections::HashMap::new()), - ), - info: Info { - id: acp::SessionId::new(subagent_id), - cwd: "/tmp".into(), - }, - max_turns: None, - resolved_tool_overrides: std::sync::Arc::new(arc_swap::ArcSwapOption::empty()), - hunk_tracker_handle: xai_hunk_tracker::HunkTrackerHandle::noop(), - chat_state_handle: xai_chat_state::ChatStateHandle::noop(), - signals_handle, - gateway_enabled: Arc::new(AtomicBool::new(false)), - mcp_servers: vec![], - initial_client_mcp_servers: vec![], - display_cwd: None, - feedback_manager: Arc::new(feedback_manager), - upload_queue: Arc::new(OnceLock::new()), - upload_failures_since_success: Arc::new(std::sync::atomic::AtomicU64::new(0)), - tool_context, - model_id: acp::ModelId::new("test"), - reasoning_effort: None, - yolo_mode: false, - origin_client: None, - code_nav_enabled: false, - ask_user_question_enabled: true, - plan_mode: Arc::new( - parking_lot::Mutex::new(PlanModeTracker::new(PathBuf::from("/tmp"))), - ), - force_compact: Arc::new(AtomicBool::new(false)), - permission_handle: xai_grok_workspace::permission::PermissionHandle::allow_all(), - attribution_callback: None, - agent_name: "grok-build".to_string(), - managed_mcp_proxy_base_url: String::new(), - session_default_agent_profile: None, - allowed_subagent_types: None, - hook_registry: None, - workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), - terminal_backend: None, - tools_notification_handle: None, - scheduler_handle: None, - }; - SubagentTracker { - subagent_id: subagent_id.into(), - parent_session_id: parent_session_id.into(), - owner: SubagentOwner::Task, - parent_prompt_id: None, - child_session_id: acp::SessionId::new(subagent_id), - subagent_type: subagent_type.into(), - persona: None, - description: description.into(), - started_at: std::time::Instant::now(), - child_handle: handle, - child_thread: crate::session::SessionThread::from_handle( - std::thread::spawn(|| {}), - ), - cancel_token: tokio_util::sync::CancellationToken::new(), - resumed_from: None, - child_cwd: String::new(), - worktree_path: None, - effective_model_id: String::new(), - run_in_background: false, - surface_completion: true, - completion_output_cap: None, - color: None, - block_waited: false, - explicitly_killed: false, - } -} -#[tokio::test] -async fn active_summaries_for_filters_by_parent_session_id() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("sub-1", "session-A", "explore", "task 1")); - coordinator.insert(dummy_tracker("sub-2", "session-B", "plan", "task 2")); - coordinator.insert(dummy_tracker("sub-3", "session-A", "general-purpose", "task 3")); - let summaries_a = coordinator.active_summaries_for("session-A"); - assert_eq!(summaries_a.len(), 2); - let ids_a: Vec<&str> = summaries_a.iter().map(|s| s.subagent_id.as_str()).collect(); - assert!(ids_a.contains(&"sub-1")); - assert!(ids_a.contains(&"sub-3")); - let summaries_b = coordinator.active_summaries_for("session-B"); - assert_eq!(summaries_b.len(), 1); - assert_eq!(summaries_b[0].subagent_id, "sub-2"); - assert_eq!(summaries_b[0].subagent_type, "plan"); - assert_eq!(summaries_b[0].description, "task 2"); - let summaries_none = coordinator.active_summaries_for("session-C"); - assert!(summaries_none.is_empty()); -} -#[tokio::test] -async fn drain_pending_completions_filters_by_owner_session() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("sub-a", "session-A", "explore", "task a")); - coordinator.insert(dummy_tracker("sub-b", "session-B", "plan", "task b")); - for id in ["sub-a", "sub-b"] { - coordinator - .move_to_completed( - id, - format!("task {id}"), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("done"), - subagent_id: id.to_string(), - child_session_id: id.to_string(), - ..Default::default() - }, - None, - ); - } - let b = coordinator.drain_pending_completions_for("session-B"); - assert_eq!(b.len(), 1); - assert_eq!(b[0].subagent_id, "sub-b"); - assert_eq!(b[0].owner_session_id, "session-B"); - let a = coordinator.drain_pending_completions_for("session-A"); - assert_eq!(a.len(), 1); - assert_eq!(a[0].subagent_id, "sub-a"); - assert!(coordinator.drain_pending_completions_for("").is_empty()); -} -#[tokio::test] -async fn active_summaries_returns_all_regardless_of_parent() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("sub-1", "session-A", "explore", "task 1")); - coordinator.insert(dummy_tracker("sub-2", "session-B", "plan", "task 2")); - let all = coordinator.active_summaries(); - assert_eq!(all.len(), 2); -} -/// Spawns issued from inside a child session (loop iterations) re-parent -/// to the root session via the running tracker's child→parent mapping. -#[tokio::test] -async fn parent_of_child_session_maps_to_root() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert( - dummy_tracker( - "iter-child-sess", - "root-session", - "general-purpose", - "loop iteration", - ), - ); - assert_eq!( - coordinator - .parent_of_child_session("iter-child-sess") - .as_deref(), - Some("root-session") - ); - assert_eq!(coordinator.parent_of_child_session("unknown-sess"), None); -} -#[tokio::test] -async fn resolve_running_list_returns_empty_for_empty_seeds() { - let resolved = resolve_running_list(vec![]).await; - assert!(resolved.is_empty()); -} -#[tokio::test] -async fn resolve_running_list_populates_fields_from_signals() { - use crate::session::signals::SessionSignalsHandle; - let signals = SessionSignalsHandle::new(); - signals.increment_turn(); - signals.record_tool_call("grep"); - tokio::task::yield_now().await; - let seed = RunningSubagentListSeed { - subagent_id: "sub-1".to_string(), - parent_session_id: "parent-1".to_string(), - child_session_id: "child-1".to_string(), - subagent_type: "explore".to_string(), - description: "find endpoints".to_string(), - started_at_epoch_ms: 1000, - duration_ms: 2000, - signals_handle: signals, - }; - let resolved = resolve_running_list(vec![seed]).await; - assert_eq!(resolved.len(), 1); - let r = &resolved[0]; - assert_eq!(r.subagent_id, "sub-1"); - assert_eq!(r.parent_session_id, "parent-1"); - assert_eq!(r.child_session_id, "child-1"); - assert_eq!(r.subagent_type, "explore"); - assert_eq!(r.turn_count, 1); - assert_eq!(r.tool_call_count, 1); - assert!(r.tools_used.contains(&"grep".to_string())); -} -#[test] fn explicit_override_takes_precedence_over_role() { let overrides = SubagentRuntimeOverrides { model: Some("explicit-model".into()), @@ -2267,7 +1180,6 @@ fn fork_context_normalized_only_for_summarized() { )); } fn bootstrap_test_request(fork_context: bool) -> SubagentRequest { - let (result_tx, _) = oneshot::channel(); SubagentRequest { id: "bootstrap-test".into(), prompt: "plan".into(), @@ -2284,7 +1196,6 @@ fn bootstrap_test_request(fork_context: bool) -> SubagentRequest { fork_context, owner: SubagentOwner::Task, cancel_token: CancellationToken::new(), - result_tx, } } #[tokio::test] @@ -2482,160 +1393,6 @@ async fn copy_session_data_preserves_parent_chat_history() { "child should have inherited parent chat history" ); } -#[tokio::test] -async fn handle_subagent_request_rejects_disabled_agent() { - let toggle = HashMap::from([("explore".to_string(), false)]); - let ctx = ctx_with_toggle(toggle); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_request("explore"); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - assert!(!result.success, "disabled subagent should fail"); - assert!( - result - .error - .as_deref() - .unwrap_or("") - .contains("[subagents.toggle]"), - "error should mention [subagents.toggle], got: {:?}", - result.error - ); -} -#[tokio::test] -async fn handle_subagent_request_allows_when_absent_from_toggle() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_request("explore"); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - if !result.success { - assert!( - !result - .error - .as_deref() - .unwrap_or("") - .contains("[subagents.toggle]"), - "should not be rejected by toggle gate when absent from toggle, \ - but got: {:?}", - result.error - ); - } -} -#[tokio::test] -async fn handle_subagent_request_rejects_nonexistent_cwd() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (mut request, result_rx) = make_request("explore"); - request.cwd = Some("/nonexistent/path/that/does/not/exist".into()); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - assert!(!result.success, "nonexistent cwd should fail"); - assert!( - result - .error - .as_deref() - .unwrap_or("") - .contains("does not exist"), - "error should mention path does not exist, got: {:?}", - result.error - ); -} -#[tokio::test] -async fn handle_subagent_request_rejects_file_as_cwd() { - let tmp_dir = tempfile::TempDir::new().unwrap(); - let tmp_file = tmp_dir.path().join("grok-test-cwd-file"); - std::fs::write(&tmp_file, b"not a directory").unwrap(); - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (mut request, result_rx) = make_request("explore"); - request.cwd = Some(tmp_file.to_string_lossy().to_string()); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - assert!(!result.success, "file-as-cwd should fail"); - assert!( - result - .error - .as_deref() - .unwrap_or("") - .contains("not a directory"), - "error should mention not a directory, got: {:?}", - result.error - ); -} -#[tokio::test] -async fn handle_subagent_request_valid_cwd_passes_validation() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (mut request, result_rx) = make_request("explore"); - request.cwd = Some("/tmp".into()); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - if !result.success { - let err = result.error.as_deref().unwrap_or(""); - assert!( - !err.contains("does not exist") && !err.contains("not a directory"), - "valid cwd should pass validation, but got cwd error: {err}" - ); - } -} -#[tokio::test] -async fn handle_subagent_request_quoted_cwd_passes_validation() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (mut request, result_rx) = make_request("explore"); - request.cwd = Some("\"/tmp".into()); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - if !result.success { - let err = result.error.as_deref().unwrap_or(""); - assert!( - !err.contains("does not exist") && !err.contains("not a directory"), - "quoted cwd should be sanitized before validation, but got cwd error: {err}" - ); - } -} fn make_validation_ctx(toggle: HashMap<String, bool>) -> SubagentValidationContext { SubagentValidationContext { parent_cwd: PathBuf::from("/tmp"), @@ -2786,23 +1543,6 @@ fn validate_subagent_type_recognizes_cli_agent_by_name() { )); } #[test] -#[serial_test::serial] -fn subagent_await_budget_default_and_override() { - unsafe { std::env::remove_var("GROK_SUBAGENT_AWAIT_BUDGET_MS") }; - assert_eq!(SUBAGENT_AWAIT_BUDGET, std::time::Duration::from_secs(600)); - assert_eq!(subagent_await_budget(), SUBAGENT_AWAIT_BUDGET); - unsafe { std::env::set_var("GROK_SUBAGENT_AWAIT_BUDGET_MS", "1500") }; - assert_eq!( - subagent_await_budget(), - std::time::Duration::from_millis(1500) - ); - unsafe { std::env::set_var("GROK_SUBAGENT_AWAIT_BUDGET_MS", "0") }; - assert_eq!(subagent_await_budget(), SUBAGENT_AWAIT_BUDGET); - unsafe { std::env::set_var("GROK_SUBAGENT_AWAIT_BUDGET_MS", "not-a-number") }; - assert_eq!(subagent_await_budget(), SUBAGENT_AWAIT_BUDGET); - unsafe { std::env::remove_var("GROK_SUBAGENT_AWAIT_BUDGET_MS") }; -} -#[test] fn summarize_tool_config_uses_name_override_and_strips_namespace() { use xai_grok_tools::registry::types::{ToolConfig, ToolServerConfig}; use xai_grok_tools::types::tool::ToolKind; @@ -2926,281 +1666,8 @@ fn subagent_keeps_default_flavor_when_parent_model_is_non_strict() { "a non-strict parent model must leave subagents on the default harness", ); } -fn make_background_request( - subagent_type: &str, -) -> (SubagentRequest, oneshot::Receiver<SubagentResult>) { - let (mut req, rx) = make_request(subagent_type); - req.run_in_background = true; - (req, rx) -} -#[tokio::test] -async fn background_unknown_type_records_failure_completion() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_background_request("totally-invented-type"); - assert_background_pre_spawn_failure( - ctx, - &coordinator, - &gateway, - request, - result_rx, - "Unknown subagent type", - ) - .await; -} -async fn assert_background_pre_spawn_failure( - ctx: SubagentSpawnContext, - coordinator: &std::cell::RefCell<SubagentCoordinator>, - gateway: &GatewaySender, - request: SubagentRequest, - result_rx: oneshot::Receiver<SubagentResult>, - expected_error_substring: &str, -) { - let subagent_id = request.id.clone(); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, coordinator, gateway)).await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - assert!(!result.success); - let err = result.error.as_deref().unwrap_or(""); - assert!( - err.contains(expected_error_substring), - "expected error substring {expected_error_substring:?} in {err:?}", - ); - let lookup = coordinator.borrow().lookup(&subagent_id); - match lookup { - Some(SnapshotLookup::Ready(snap)) => { - assert_eq!(snap.subagent_id, subagent_id); - assert!(matches!(snap.status, SubagentSnapshotStatus::Failed { .. })); - } - _ => panic!("expected Ready(Failed) snapshot"), - } - let summaries = coordinator.borrow_mut().drain_pending_completions_for(""); - assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].subagent_id, subagent_id); - assert!(!summaries[0].success); -} -#[tokio::test] -async fn background_disabled_type_records_failure_completion() { - let toggle = HashMap::from([("explore".to_string(), false)]); - let ctx = ctx_with_toggle(toggle); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_background_request("explore"); - assert_background_pre_spawn_failure( - ctx, - &coordinator, - &gateway, - request, - result_rx, - "[subagents.toggle]", - ) - .await; -} -#[tokio::test] -async fn background_not_allowed_type_records_failure_completion() { - let mut ctx = ctx_with_toggle(HashMap::new()); - ctx.allowed_subagent_types = Some(vec!["plan".to_string()]); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_background_request("explore"); - assert_background_pre_spawn_failure( - ctx, - &coordinator, - &gateway, - request, - result_rx, - "not allowed", - ) - .await; -} -async fn assert_blocking_pre_spawn_does_not_push_summary( - ctx: SubagentSpawnContext, - coordinator: &std::cell::RefCell<SubagentCoordinator>, - gateway: &GatewaySender, - request: SubagentRequest, - result_rx: oneshot::Receiver<SubagentResult>, -) { - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, coordinator, gateway)).await; - }) - .await; - let result = result_rx.await.expect("should receive result"); - assert!(!result.success); - let summaries = coordinator.borrow_mut().drain_pending_completions_for(""); - assert!( - summaries.is_empty(), - "blocking-mode pre-spawn failure must not push completion summaries: {summaries:?}", - ); -} -#[tokio::test] -async fn blocking_unknown_type_does_not_push_completion_summary() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_request("totally-invented-type"); - assert_blocking_pre_spawn_does_not_push_summary( - ctx, - &coordinator, - &gateway, - request, - result_rx, - ) - .await; -} -#[tokio::test] -async fn blocking_disabled_type_does_not_push_completion_summary() { - let toggle = HashMap::from([("explore".to_string(), false)]); - let ctx = ctx_with_toggle(toggle); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_request("explore"); - assert_blocking_pre_spawn_does_not_push_summary( - ctx, - &coordinator, - &gateway, - request, - result_rx, - ) - .await; -} -#[tokio::test] -async fn blocking_not_allowed_type_does_not_push_completion_summary() { - let mut ctx = ctx_with_toggle(HashMap::new()); - ctx.allowed_subagent_types = Some(vec!["plan".to_string()]); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (request, result_rx) = make_request("explore"); - assert_blocking_pre_spawn_does_not_push_summary( - ctx, - &coordinator, - &gateway, - request, - result_rx, - ) - .await; -} -#[tokio::test] -async fn background_failure_summary_includes_description() { - let ctx = ctx_with_toggle(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let gateway = test_gateway(); - let (mut request, _result_rx) = make_background_request("invented"); - request.description = "find auth middleware".into(); - let id = request.id.clone(); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let summaries = coordinator.borrow_mut().drain_pending_completions_for(""); - assert_eq!(summaries.len(), 1); - let s = &summaries[0]; - assert_eq!(s.subagent_id, id); - assert_eq!(s.subagent_type, "invented"); - assert_eq!(s.description, "find auth middleware"); -} -#[tokio::test] -async fn background_unknown_type_emits_subagent_finished_notification() { - use crate::test_support::lsp_runtime::{ - ctx_with_toggle_and_cmd_tx, test_gateway_with_receiver, - }; - let (ctx, mut cmd_rx) = ctx_with_toggle_and_cmd_tx(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let (gateway, mut gateway_rx) = test_gateway_with_receiver(); - let (request, _result_rx) = make_background_request("invented-type"); - let subagent_id = request.id.clone(); - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway)) - .await; - }) - .await; - let mut found_persisted = false; - while let Ok(cmd) = cmd_rx.try_recv() { - if let SessionCommand::XaiSessionNotification { notification } = cmd - && let SessionUpdate::SubagentFinished { - subagent_id: id, - status, - error, - .. - } = ¬ification.update - { - assert_eq!(*id, subagent_id); - assert_eq!(status, "failed"); - assert!( - error - .as_deref() - .is_some_and(|e| e.contains("Unknown subagent type")), - ); - found_persisted = true; - } - } - assert!( - found_persisted, - "must persist SubagentFinished via parent_cmd_tx" - ); - let mut found_live = false; - while let Ok(msg) = gateway_rx.try_recv() { - if let xai_acp_lib::AcpClientMessage::ExtNotification(args) = msg { - let req: &acp::ExtNotification = &args.request; - assert_eq!(req.method.as_ref(), "x.ai/session_notification"); - let body = req.params.get(); - assert!(body.contains("subagent_finished")); - assert!(body.contains(&subagent_id)); - assert!(body.contains("\"status\":\"failed\"")); - assert!(body.contains("Unknown subagent type")); - assert!(body.contains("\"will_wake\":false")); - found_live = true; - break; - } - } - assert!(found_live, "must broadcast SubagentFinished via gateway"); -} -/// The promote-guard teardown emits EXACTLY ONE cancelled `SubagentFinished` -/// (on both the persist + gateway channels), delivers a cancelled result, and -/// leaves the entry queryable as `Cancelled`. -#[tokio::test] -async fn cancel_pending_subagent_at_promote_emits_exactly_one_cancelled_finish() { - use crate::test_support::lsp_runtime::{ - ctx_with_toggle_and_cmd_tx, test_gateway_with_receiver, - }; - let (ctx, mut cmd_rx) = ctx_with_toggle_and_cmd_tx(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let (gateway, mut gateway_rx) = test_gateway_with_receiver(); - let (request, result_rx) = make_request("explore"); - let subagent_id = request.id.clone(); - let child_session_id = acp::SessionId::new(subagent_id.clone()); - coordinator - .borrow_mut() - .insert_pending(PendingSubagent { - subagent_id: subagent_id.clone(), - subagent_type: "explore".to_string(), - description: "killed while pending".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: ctx.parent_session_id.clone(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - let child_handle = dummy_tracker(&subagent_id, "test-parent", "explore", "task") - .child_handle; - let meta_dir = std::env::temp_dir() - .join(format!("subagent-promote-test-{subagent_id}")); - let gcs_ctx = GcsUploadContext { +fn test_gcs_context(ctx: &SubagentSpawnContext) -> GcsUploadContext { + GcsUploadContext { bucket_url: None, upload_method: None, model_id: None, @@ -3212,154 +1679,104 @@ async fn cancel_pending_subagent_at_promote_emits_exactly_one_cancelled_finish() parent_prompt_id: None, depth: 0, auth_manager: ctx.auth_manager.clone(), - }; - cancel_pending_subagent_at_promote( - request, - &child_handle, - &subagent_id, - &child_session_id, - &meta_dir, - &coordinator, - &gateway, - &ctx.parent_session_id, - ctx.parent_cmd_tx.as_ref(), + } +} +#[tokio::test] +async fn cancel_pending_shell_child_presents_one_cancelled_finish() { + let mut ctx = ctx_with_toggle(HashMap::new()); + let (parent_cmd_tx, mut parent_cmd_rx) = mpsc::unbounded_channel(); + ctx.parent_cmd_tx = Some(parent_cmd_tx); + let (child_cmd_tx, mut child_cmd_rx) = mpsc::unbounded_channel(); + let (gateway, mut gateway_rx) = test_gateway_with_receiver(); + let request = auto_wake_test_request("promote-cancel"); + let meta_dir = tempfile::tempdir().expect("meta dir"); + let result = cancel_pending_shell_child( + &child_cmd_tx, + &request.id, + &acp::SessionId::new(request.id.clone()), + meta_dir.path(), None, false, 42, - &gcs_ctx, + &test_gcs_context(&ctx), ) .await; + assert!(matches!( + child_cmd_rx.try_recv(), + Ok(SessionCommand::Shutdown) + )); + assert!(result.cancelled); + assert!(!result.success); + let mut completion_data = ShellCompletionData::from_context(&ctx); + completion_data.spawned_notification_emitted = true; + present_child_completion( + ChildCompletion { + request, + result, + completion_data, + disposition: CompletionDisposition { + foreground_delivered: false, + backgrounded: false, + waiter_delivered: false, + explicitly_killed: false, + should_surface: false, + }, + }, + &gateway, + ); let mut persisted = 0; - while let Ok(cmd) = cmd_rx.try_recv() { - if let SessionCommand::XaiSessionNotification { notification } = cmd - && let SessionUpdate::SubagentFinished { subagent_id: id, status, .. } = ¬ification - .update - { - assert_eq!(*id, subagent_id); - assert_eq!(status, "cancelled"); + while let Ok(command) = parent_cmd_rx.try_recv() { + if matches!( + command, + SessionCommand::XaiSessionNotification { + notification: SessionNotification { + update: SessionUpdate::SubagentFinished { status, .. }, + .. + } + } if status == "cancelled" + ) { persisted += 1; } } - assert_eq!(persisted, 1, "exactly one persisted SubagentFinished"); + assert_eq!(persisted, 1); let mut live = 0; - while let Ok(msg) = gateway_rx.try_recv() { - if let xai_acp_lib::AcpClientMessage::ExtNotification(args) = msg { - let body = args.request.params.get(); - if body.contains("subagent_finished") { - assert!(body.contains(&subagent_id)); - assert!(body.contains("\"status\":\"cancelled\"")); - live += 1; - } + while let Ok(message) = gateway_rx.try_recv() { + if matches!( + message, + xai_acp_lib::AcpClientMessage::ExtNotification(args) + if args.request.params.get().contains("\"status\":\"cancelled\"") + ) { + live += 1; } } - assert_eq!(live, 1, "exactly one live SubagentFinished"); - let result = result_rx.await.expect("result delivered to oneshot"); - assert!(result.cancelled, "result must be cancelled"); - assert!(!result.success); - match coordinator.borrow().lookup(&subagent_id) { - Some(SnapshotLookup::Ready(snap)) => { - assert!( - matches!(snap.status, SubagentSnapshotStatus::Cancelled { .. }), - "expected Cancelled, got {:?}", - snap.status - ) - } - _ => panic!("expected Ready(Cancelled) snapshot after promote-abort"), - } + assert_eq!(live, 1); } -/// Drive `cancel_pending_subagent_at_promote` against a real `worktree` and -/// assert it still emits EXACTLY ONE cancelled finish + leaves the entry -/// queryable as Cancelled. The caller asserts the worktree dir's fate. async fn run_promote_cancel_with_worktree( worktree: &Path, worktree_freshly_created: bool, ) { - use crate::test_support::lsp_runtime::{ - ctx_with_toggle_and_cmd_tx, test_gateway_with_receiver, - }; - let (ctx, mut cmd_rx) = ctx_with_toggle_and_cmd_tx(HashMap::new()); - let coordinator = std::cell::RefCell::new(SubagentCoordinator::new()); - let (gateway, mut gateway_rx) = test_gateway_with_receiver(); - let (request, result_rx) = make_request("explore"); - let subagent_id = request.id.clone(); - let child_session_id = acp::SessionId::new(subagent_id.clone()); - coordinator - .borrow_mut() - .insert_pending(PendingSubagent { - subagent_id: subagent_id.clone(), - subagent_type: "explore".to_string(), - description: "killed while pending".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: ctx.parent_session_id.clone(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - let child_handle = dummy_tracker(&subagent_id, "test-parent", "explore", "task") - .child_handle; - let meta_dir = std::env::temp_dir().join(format!("subagent-wt-test-{subagent_id}")); - let gcs_ctx = GcsUploadContext { - bucket_url: None, - upload_method: None, - model_id: None, - cwd: None, - isolation_mode: None, - capability_mode: None, - reasoning_effort: None, - role_name: None, - parent_prompt_id: None, - depth: 0, - auth_manager: ctx.auth_manager.clone(), - }; - cancel_pending_subagent_at_promote( - request, - &child_handle, - &subagent_id, - &child_session_id, - &meta_dir, - &coordinator, - &gateway, - &ctx.parent_session_id, - ctx.parent_cmd_tx.as_ref(), + let ctx = ctx_with_toggle(HashMap::new()); + let (child_cmd_tx, mut child_cmd_rx) = mpsc::unbounded_channel(); + let meta_dir = tempfile::tempdir().expect("meta dir"); + let result = cancel_pending_shell_child( + &child_cmd_tx, + "worktree-cancel", + &acp::SessionId::new("worktree-cancel"), + meta_dir.path(), Some(worktree), worktree_freshly_created, 42, - &gcs_ctx, + &test_gcs_context(&ctx), ) .await; - let mut persisted = 0; - while let Ok(cmd) = cmd_rx.try_recv() { - if let SessionCommand::XaiSessionNotification { notification } = cmd - && matches!(notification.update, SessionUpdate::SubagentFinished { .. }) - { - persisted += 1; - } - } - assert_eq!(persisted, 1, "exactly one persisted SubagentFinished"); - let mut live = 0; - while let Ok(msg) = gateway_rx.try_recv() { - if let xai_acp_lib::AcpClientMessage::ExtNotification(args) = msg - && args.request.params.get().contains("subagent_finished") - { - live += 1; - } - } - assert_eq!(live, 1, "exactly one live SubagentFinished"); - let result = result_rx.await.expect("result delivered to oneshot"); - assert!(result.cancelled, "result must be cancelled"); assert!(matches!( - coordinator.borrow().lookup(&subagent_id), - Some(SnapshotLookup::Ready(snap)) if matches!(snap.status, SubagentSnapshotStatus::Cancelled { .. }) + child_cmd_rx.try_recv(), + Ok(SessionCommand::Shutdown) )); + assert!(result.cancelled); } -/// The promote-abort teardown removes a FRESHLY-created worktree (this -/// subagent's own, pristine) but PRESERVES a resumed subagent's reused -/// worktree (it aliases the source's dir — deleting it would lose the -/// source's working state). Exactly one cancelled finish emits either way. +/// A pending cancel removes a freshly-created worktree but preserves a +/// resumed child worktree owned by its source. #[tokio::test] async fn cancel_pending_at_promote_removes_fresh_worktree_preserves_resumed() { xai_test_utils::require_git!(); @@ -3399,143 +1816,6 @@ async fn cancel_pending_at_promote_removes_fresh_worktree_preserves_resumed() { "the source's working state must be left untouched" ); } -#[test] -fn record_pre_spawn_failure_populates_completed_and_summary() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .record_pre_spawn_failure( - "sub-x".to_string(), - "invented".to_string(), - "bg job".to_string(), - Some("prompt-1".to_string()), - "parent-1".to_string(), - SubagentOwner::Task, - "Unknown subagent type: invented", - true, - ); - let lookup = coordinator.lookup("sub-x"); - match lookup { - Some(SnapshotLookup::Ready(snap)) => { - assert_eq!(snap.subagent_id, "sub-x"); - match &snap.status { - SubagentSnapshotStatus::Failed { error } => { - assert!(error.contains("Unknown subagent type")); - } - other => panic!("expected Failed, got {other:?}"), - } - } - _ => panic!("expected Ready snapshot for recorded pre-spawn failure"), - } - let summaries = coordinator.drain_pending_completions_for(""); - assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].subagent_id, "sub-x"); - assert_eq!(summaries[0].subagent_type, "invented"); - assert_eq!(summaries[0].description, "bg job"); - assert!(!summaries[0].success); -} -#[test] -fn record_pre_spawn_failure_skips_buffer_when_flag_false() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .record_pre_spawn_failure( - "sub-hidden-pre".to_string(), - "invented".to_string(), - "bg job".to_string(), - None, - "parent-1".to_string(), - SubagentOwner::Task, - "Unknown subagent type: invented", - false, - ); - assert!(coordinator.drain_pending_completions_for("").is_empty()); - assert!(coordinator.lookup("sub-hidden-pre").is_some()); -} -#[tokio::test] -async fn record_pre_spawn_failure_notifies_waiters() { - let mut coordinator = SubagentCoordinator::new(); - let notify = coordinator.completion_notify(); - let waiter = notify.notified(); - coordinator - .record_pre_spawn_failure( - "sub-y".to_string(), - "invented".to_string(), - "bg job".to_string(), - None, - "parent-1".to_string(), - SubagentOwner::Task, - "error", - true, - ); - tokio::time::timeout(std::time::Duration::from_millis(50), waiter) - .await - .expect("notify_waiters must wake pre-armed waiter"); -} -#[tokio::test] -async fn record_pre_spawn_failure_notifies_all_waiters() { - let mut coordinator = SubagentCoordinator::new(); - let notify = coordinator.completion_notify(); - let waiter_a = notify.notified(); - let waiter_b = notify.notified(); - coordinator - .record_pre_spawn_failure( - "sub-multi".to_string(), - "invented".to_string(), - "bg job".to_string(), - None, - "parent-1".to_string(), - SubagentOwner::Task, - "error", - true, - ); - let timeout = std::time::Duration::from_millis(50); - tokio::time::timeout(timeout, waiter_a).await.expect("waiter_a must wake"); - tokio::time::timeout(timeout, waiter_b).await.expect("waiter_b must wake"); -} -#[test] -fn record_pre_spawn_failure_clears_stale_pending_entry() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-z".to_string(), - subagent_type: "invented".to_string(), - description: "stale".to_string(), - persona: None, - parent_prompt_id: Some("prompt-X".to_string()), - parent_session_id: "parent-1".to_string(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: true, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - assert!(coordinator.pending.contains_key("sub-z")); - coordinator - .record_pre_spawn_failure( - "sub-z".to_string(), - "invented".to_string(), - "bg job".to_string(), - Some("prompt-X".to_string()), - "parent-1".to_string(), - SubagentOwner::Task, - "Unknown subagent type: invented", - true, - ); - assert!(!coordinator.pending.contains_key("sub-z")); - match coordinator.lookup("sub-z") { - Some(SnapshotLookup::Ready(snap)) => { - assert!(matches!(snap.status, SubagentSnapshotStatus::Failed { .. })); - } - _ => panic!("expected Ready(Failed) post-collision"), - } - assert!( - !coordinator - .outstanding_for_prompt("prompt-X") - .iter() - .any(|id| id == "sub-z"), - "outstanding_for_prompt must not still list a recorded-failed id", - ); -} fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry { crate::agent::config::ModelEntry { info: crate::agent::config::ModelInfo { @@ -3551,6 +1831,8 @@ fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry { api_backend: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(256_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, @@ -3847,6 +2129,8 @@ fn test_sampling_config(model_slug: &str) -> xai_grok_sampling_types::SamplingCo top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: NonZeroU64::new(256_000).expect("non-zero context window"), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs index 99793fb..da5c446 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs @@ -1,8 +1,8 @@ #![cfg_attr(rustfmt, rustfmt::skip)] use super::*; -use crate::test_support::lsp_runtime::{ - DummyLspDispatch, ctx_with_toggle, make_request, test_gateway, -}; +use crate::test_support::lsp_runtime::{ctx_with_toggle, test_gateway}; +use crate::upload::trace::SubagentSpawnedRef; +use xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend; #[test] fn normalize_forked_context_strips_project_layout() { use xai_grok_sampling_types::conversation::ConversationItem; @@ -323,98 +323,6 @@ fn compaction_no_prefix_passes_through() { assert!(matches!(result[0], ConversationItem::System(_))); } #[test] -fn resumable_source_returns_none_for_unknown_id() { - let coordinator = SubagentCoordinator::new(); - assert!( - coordinator - .resumable_source_for("unknown", "parent", Path::new("/tmp")) - .is_none() - ); -} -#[test] -fn resumable_source_returns_none_for_active_subagent() { - let coordinator = SubagentCoordinator::new(); - assert!(!coordinator.is_active("active-id")); - assert!( - coordinator - .resumable_source_for("active-id", "parent", Path::new("/tmp")) - .is_none() - ); -} -#[test] -fn resumable_source_returns_info_for_completed_subagent() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .completed - .insert( - "sub-resume".to_string(), - CompletedSubagent { - subagent_id: "sub-resume".into(), - parent_session_id: "parent-1".into(), - parent_prompt_id: Some("prompt-1".into()), - owner: SubagentOwner::Task, - child_session_id: "child-resume".into(), - description: "resumable task".into(), - subagent_type: "general-purpose".into(), - persona: Some("implementer".into()), - started_at: std::time::Instant::now(), - completed_at: std::time::Instant::now(), - result: SubagentResult { - success: true, - output: "done".into(), - subagent_id: "sub-resume".into(), - child_session_id: "child-resume".into(), - ..Default::default() - }, - resumed_from: None, - child_cwd: "/workspace".into(), - worktree_path: Some(PathBuf::from("/tmp/worktree-1")), - snapshot_ref: None, - effective_model_id: "grok-3".into(), - block_waited: false, - explicitly_killed: false, - completion_output_cap: None, - persisted_output_dir: None, - }, - ); - let info = coordinator - .resumable_source_for("sub-resume", "parent-1", Path::new("/tmp")) - .expect("should find completed subagent"); - assert_eq!(info.subagent_id, "sub-resume"); - assert_eq!(info.child_session_id, "child-resume"); - assert_eq!(info.child_cwd, "/workspace"); - assert_eq!( - info.worktree_path.as_deref(), - Some(Path::new("/tmp/worktree-1")) - ); - assert_eq!(info.subagent_type, "general-purpose"); - assert_eq!(info.persona.as_deref(), Some("implementer")); -} -#[test] -fn resumable_source_survives_move_to_completed_with_metadata() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-moved", - "moved task".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: "found files".into(), - subagent_id: "sub-moved".into(), - child_session_id: "sub-moved".into(), - ..Default::default() - }, - None, - ); - let info = coordinator - .resumable_source_for("sub-moved", "", Path::new("/tmp")) - .expect("should find moved subagent"); - assert_eq!(info.subagent_id, "sub-moved"); - assert_eq!(info.child_cwd, ""); - assert!(info.worktree_path.is_none()); -} -#[test] fn resumed_from_field_in_meta_roundtrips() { let meta = SubagentMeta { subagent_id: "sa-resumed".into(), @@ -586,7 +494,7 @@ fn snapshot_test_meta(id: &str) -> SubagentMeta { } } /// The follow-up writer persists `snapshot_ref` into an already-finalized -/// meta.json so `resumable_source_for` rehydrates the disposed worktree. +/// meta.json so `durable_resume_source_for` rehydrates the disposed worktree. #[test] fn update_subagent_meta_snapshot_ref_persists_to_disk() { let dir = tempfile::TempDir::new().unwrap(); @@ -647,50 +555,6 @@ fn snapshot_ref_write_promotes_nonterminal_status_to_terminal() { ); assert_eq!("completed", reread.status); } -/// The coordinator setter stamps the snapshot ref onto the in-memory -/// completed entry so `resume_from` can rehydrate before cap eviction. -#[tokio::test] -async fn set_completed_snapshot_ref_updates_in_memory_entry() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("sa-mem", "session-A", "explore", "task")); - coordinator - .move_to_completed( - "sa-mem", - "task".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: "sa-mem".into(), - child_session_id: "sa-mem".into(), - ..Default::default() - }, - None, - ); - let before = coordinator - .resumable_source_for("sa-mem", "session-A", Path::new("/tmp")) - .unwrap(); - assert!(before.snapshot_ref.is_none()); - coordinator - .set_completed_snapshot_ref("sa-mem", "refs/grok/subagents/sa-mem".into()); - let after = coordinator - .resumable_source_for("sa-mem", "session-A", Path::new("/tmp")) - .unwrap(); - assert_eq!( - after.snapshot_ref.as_deref(), - Some("refs/grok/subagents/sa-mem") - ); -} -/// Unknown id is a no-op (entry already cap-evicted; meta.json still holds it). -#[test] -fn set_completed_snapshot_ref_unknown_id_is_noop() { - let mut coordinator = SubagentCoordinator::new(); - coordinator.set_completed_snapshot_ref("ghost", "refs/grok/subagents/ghost".into()); - assert!( - coordinator - .resumable_source_for("ghost", "session-A", Path::new("/tmp")) - .is_none() - ); -} /// Gate defaults OFF: no config, no remote → snapshotting disabled, so the /// completion path keeps the worktree preserved (no production change). #[test] @@ -749,58 +613,9 @@ fn subagent_tool_params_carry_ask_user_question_timeouts() { assert!(ask.get("timeout_enabled").is_some_and(|v| v.is_boolean())); assert!(ask.get("timeout_secs").is_some_and(|v| v.is_u64())); } -/// Seed a coordinator with one completed subagent owned by `session-A`. -fn coordinator_with_completed(id: &str) -> SubagentCoordinator { - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker(id, "session-A", "explore", "task")); - coordinator - .move_to_completed( - id, - "task".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: id.into(), - child_session_id: id.into(), - ..Default::default() - }, - None, - ); - coordinator -} -#[tokio::test] -async fn loop_unit_active_tracks_and_prunes_owned_subagents() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert( - dummy_tracker("iter-1", "root-sess", "general-purpose", "loop: watch ci"), - ); - coordinator.record_loop_owner("iter-1", "task-42"); - assert!(coordinator.loop_unit_active("task-42")); - assert!(!coordinator.loop_unit_active("other-task")); - assert_eq!( - coordinator.loop_task_id_of_child_session("iter-1"), - Some("task-42".to_string()) - ); - assert_eq!(coordinator.loop_task_id_of_child_session("unknown"), None); - coordinator - .move_to_completed( - "iter-1", - "loop: watch ci".into(), - "general-purpose".into(), - SubagentResult { - success: true, - subagent_id: "iter-1".into(), - child_session_id: "iter-1".into(), - ..Default::default() - }, - None, - ); - assert!(!coordinator.loop_unit_active("task-42")); -} /// End-to-end glue: gate ON + a worktree present runs the completion -/// sequence (snapshot → persist ref to meta.json AND in-memory → remove) -/// and asserts all three post-conditions hold together. +/// sequence (snapshot → persist ref to meta.json → remove) and verifies the +/// durable shell resume fallback sees the ref after removal. #[tokio::test] async fn completion_snapshot_sequence_persists_ref_then_removes_worktree() { xai_test_utils::require_git!(); @@ -824,7 +639,6 @@ async fn completion_snapshot_sequence_persists_ref_then_removes_worktree() { assert!(ctx.resolve_subagent_worktree_snapshot_enabled()); let meta_dir = temp.path().join("meta"); write_subagent_meta(&meta_dir, &snapshot_test_meta("glue-1")); - let mut coordinator = coordinator_with_completed("glue-1"); let ref_name = "refs/grok/subagents/glue-1"; let snapshot_ref = crate::session::worktree::snapshot_subagent_worktree( &wt, @@ -838,164 +652,15 @@ async fn completion_snapshot_sequence_persists_ref_then_removes_worktree() { &snapshot_ref, "completed" )); - coordinator.set_completed_snapshot_ref("glue-1", snapshot_ref); crate::session::worktree::remove_subagent_worktree(&wt).await.unwrap(); let data = std::fs::read_to_string(meta_dir.join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(reread.snapshot_ref.as_deref(), Some(ref_name)); - let src = coordinator - .resumable_source_for("glue-1", "session-A", Path::new("/tmp")) - .unwrap(); - assert_eq!(src.snapshot_ref.as_deref(), Some(ref_name)); assert!( !wt.exists(), "worktree dir should be removed after the sequence" ); } -/// With snapshot-dispose on, completion clears the model-facing -/// `result.worktree_path` (the dir is removed) while resume still recovers -/// the tracker-retained direct `worktree_path` plus the snapshot_ref. -#[tokio::test] -async fn gate_on_completion_clears_model_facing_worktree_path_but_resume_retains_it() { - let wt = PathBuf::from("/tmp/grok-wt/subagent-disp-1"); - let mut coordinator = SubagentCoordinator::new(); - let mut tracker = dummy_tracker("disp-1", "session-A", "explore", "task"); - tracker.worktree_path = Some(wt.clone()); - coordinator.insert(tracker); - let mut result = SubagentResult { - success: true, - subagent_id: "disp-1".into(), - child_session_id: "disp-1".into(), - worktree_path: Some(wt.to_string_lossy().into_owned()), - ..Default::default() - }; - let worktree_removed = true; - if worktree_removed { - result.worktree_path = None; - } - coordinator - .move_to_completed("disp-1", "task".into(), "explore".into(), result, None); - coordinator - .set_completed_snapshot_ref("disp-1", "refs/grok/subagents/disp-1".into()); - let listed = coordinator.completed.get("disp-1").expect("completed entry"); - assert_eq!(None, listed.result.worktree_path); - let src = coordinator - .resumable_source_for("disp-1", "session-A", Path::new("/tmp")) - .unwrap(); - assert_eq!(Some(wt), src.worktree_path); - assert_eq!( - Some("refs/grok/subagents/disp-1"), - src.snapshot_ref.as_deref() - ); -} -/// Gate on but the worktree was NOT removed (snapshot/persist/remove failed): -/// the model-facing `result.worktree_path` is RETAINED so the parent can still -/// locate the preserved dir. -#[tokio::test] -async fn gate_on_completion_retains_worktree_path_when_not_removed() { - let wt = PathBuf::from("/tmp/grok-wt/subagent-keep-1"); - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("keep-1", "session-A", "explore", "task")); - let mut result = SubagentResult { - success: true, - subagent_id: "keep-1".into(), - child_session_id: "keep-1".into(), - worktree_path: Some(wt.to_string_lossy().into_owned()), - ..Default::default() - }; - let worktree_removed = false; - if worktree_removed { - result.worktree_path = None; - } - coordinator - .move_to_completed("keep-1", "task".into(), "explore".into(), result, None); - let entry = coordinator.completed.get("keep-1").expect("completed entry"); - assert_eq!( - Some(wt.to_string_lossy().into_owned()), - entry.result.worktree_path - ); -} -/// Teardown ordering invariant: disposal (snapshot -> persist -> remove) runs -/// BEFORE the subagent is made observable, so the first completed-map entry -/// already reflects a removed worktree plus a recorded snapshot_ref. -#[tokio::test] -async fn disposal_completes_before_subagent_is_observable() { - xai_test_utils::require_git!(); - use xai_test_utils::git::{git_commit_all, init_git_repo}; - let temp = tempfile::TempDir::new().unwrap(); - let repo = temp.path().join("repo"); - std::fs::create_dir(&repo).unwrap(); - init_git_repo(&repo); - std::fs::write(repo.join("tracked.txt"), "original").unwrap(); - git_commit_all(&repo, "initial"); - let wt = temp.path().join("subagent-order-1"); - xai_fast_worktree::WorktreeBuilder::new(&repo, &wt) - .standalone(true) - .create() - .unwrap(); - std::fs::write(wt.join("tracked.txt"), "edited").unwrap(); - let meta_dir = temp.path().join("meta"); - write_subagent_meta(&meta_dir, &snapshot_test_meta("order-1")); - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker("order-1", "session-A", "explore", "task")); - let ref_name = "refs/grok/subagents/order-1"; - let snapshot_ref = crate::session::worktree::snapshot_subagent_worktree( - &wt, - &repo, - ref_name, - ) - .await - .unwrap(); - assert!(update_subagent_meta_snapshot_ref( - &meta_dir, - &snapshot_ref, - "completed" - )); - let disposed_snapshot_ref = Some(snapshot_ref); - crate::session::worktree::remove_subagent_worktree(&wt).await.unwrap(); - assert!(!coordinator.completed.contains_key("order-1")); - assert!( - !wt.exists(), - "worktree must be removed before observability" - ); - coordinator - .move_to_completed( - "order-1", - "task".into(), - "explore".into(), - SubagentResult { - success: true, - subagent_id: "order-1".into(), - child_session_id: "order-1".into(), - ..Default::default() - }, - None, - ); - if let Some(r) = disposed_snapshot_ref { - coordinator.set_completed_snapshot_ref("order-1", r); - } - let entry = coordinator.completed.get("order-1").expect("completed entry"); - assert_eq!(Some(ref_name), entry.snapshot_ref.as_deref()); - assert!(!wt.exists()); -} -/// Gate OFF: the completion path snapshots/removes nothing and records no -/// ref, so the worktree is preserved for review (no production change). -#[tokio::test] -async fn completion_gate_off_preserves_and_records_no_ref() { - let ctx = ctx_with_toggle(std::collections::HashMap::new()); - assert!( - !ctx.resolve_subagent_worktree_snapshot_enabled(), - "default gate must be off" - ); - let coordinator = coordinator_with_completed("glue-off"); - let src = coordinator - .resumable_source_for("glue-off", "session-A", Path::new("/tmp")) - .unwrap(); - assert!( - src.snapshot_ref.is_none(), - "gate off must not record a snapshot ref" - ); -} #[test] fn subagent_session_metadata_roundtrip() { let meta = SubagentMeta { @@ -1463,51 +1128,6 @@ fn select_override_cwd_fresh_spawn_uses_request_cwd() { assert_eq!(select_override_cwd(None, Some("/x")), Some("/x")); } #[test] -fn resumable_source_rejects_cross_session_lookup() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .completed - .insert( - "sub-other".to_string(), - CompletedSubagent { - subagent_id: "sub-other".into(), - parent_session_id: "session-A".into(), - parent_prompt_id: None, - owner: SubagentOwner::Task, - child_session_id: "child-other".into(), - description: "other task".into(), - subagent_type: "explore".into(), - persona: None, - started_at: std::time::Instant::now(), - completed_at: std::time::Instant::now(), - result: SubagentResult { - success: true, - ..Default::default() - }, - resumed_from: None, - child_cwd: "/workspace".into(), - worktree_path: None, - snapshot_ref: None, - effective_model_id: String::new(), - block_waited: false, - explicitly_killed: false, - completion_output_cap: None, - persisted_output_dir: None, - }, - ); - assert!( - coordinator - .resumable_source_for("sub-other", "session-A", Path::new("/tmp")) - .is_some() - ); - assert!( - coordinator - .resumable_source_for("sub-other", "session-B", Path::new("/tmp")) - .is_none(), - "should reject resume from a different parent session" - ); -} -#[test] fn resumed_session_uses_current_runtime_contract() { use xai_grok_sampling_types::conversation::ConversationItem; let mut conversation = [ @@ -1737,386 +1357,175 @@ fn running_test_meta(id: &str, parent_session_id: &str) -> SubagentMeta { effective_model_id: None, } } -#[test] -fn reconcile_orphan_flips_running_meta_to_cancelled() { +fn inspection(id: &str, status: SubagentSnapshotStatus) -> SubagentInspection { + SubagentInspection { + snapshot: SubagentSnapshot { + subagent_id: id.to_string(), + description: "task".to_string(), + subagent_type: "explore".to_string(), + status, + started_at_epoch_ms: 0, + duration_ms: 50, + persona: None, + }, + parent_session_id: "parent-x".to_string(), + child_session_id: format!("child-{id}"), + fork_parent_prompt_id: None, + resumed_from: None, + } +} +async fn reconcile_with_inspections( + unfinished: &[(String, String)], + inspections: HashMap<String, Option<SubagentInspection>>, + session_dir: &Path, + gateway: &GatewaySender, + parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>, +) { + let expected = inspections.len(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let backend = ChannelBackend::new(event_tx); + let respond = async move { + for _ in 0..expected { + let event = event_rx.recv().await.expect("inspection event"); + let SubagentEvent::Inspect(request) = event else { + panic!("expected Inspect event"); + }; + let value = inspections.get(&request.subagent_id).cloned().flatten(); + let _ = request.respond_to.send(value); + } + }; + tokio::join!( + reconcile_orphaned_subagents_with_backend( + unfinished, + &backend, + session_dir, + "parent-x", + gateway, + parent_cmd_tx, + ), + respond, + ); +} +#[tokio::test] +async fn reconcile_orphan_flips_running_meta_to_cancelled() { + use crate::test_support::lsp_runtime::test_gateway_with_receiver; let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-orphan"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); - let coordinator = SubagentCoordinator::new(); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - None, - ); - let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); - let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); - assert_eq!(reread.status, "cancelled"); - assert!(reread.completed_at.is_some(), "must stamp completed_at"); - assert!(reread.duration_ms.is_some(), "must stamp duration_ms"); - assert_eq!(reread.tool_calls, Some(0)); - assert_eq!(reread.turns, Some(0)); - assert_eq!( - reread.error.as_deref(), - Some("interrupted by process restart"), - ); -} -#[tokio::test] -async fn reconcile_orphan_skips_ids_in_live_registry() { - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-live"; - let sub_dir = session_dir.path().join("subagents").join(id); - write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker(id, "parent-x", "explore", "task")); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - None, - ); - let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); - let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); - assert_eq!( - reread.status, "running", - "a live subagent must not be reconciled" - ); -} -#[test] -fn reconcile_orphan_skips_pending_ids_in_live_registry() { - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-pending"; - let sub_dir = session_dir.path().join("subagents").join(id); - write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: id.to_string(), - subagent_type: "explore".to_string(), - description: "task".to_string(), - persona: None, - parent_prompt_id: None, - parent_session_id: "parent-x".to_string(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - None, - ); - let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); - let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); - assert_eq!( - reread.status, "running", - "a pending (initializing) subagent must not be reconciled" - ); -} -#[test] -fn reconcile_orphan_idempotent_on_terminal_meta() { - use crate::test_support::lsp_runtime::test_gateway_with_receiver; - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-done"; - let sub_dir = session_dir.path().join("subagents").join(id); - let mut meta = running_test_meta(id, "parent-x"); - meta.status = "cancelled".into(); - meta.completed_at = Some(chrono::Utc::now()); - meta.error = Some("interrupted by process restart".into()); - write_subagent_meta(&sub_dir, &meta); - let coordinator = SubagentCoordinator::new(); let (gateway, mut gateway_rx) = test_gateway_with_receiver(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &gateway, - Some(&cmd_tx), - ); - assert!( - cmd_rx.try_recv().is_err(), - "terminal meta must not persist a fresh SubagentFinished" - ); - assert!( - gateway_rx.try_recv().is_err(), - "terminal meta must not broadcast" - ); -} -#[test] -fn reconcile_orphan_ignores_other_parent_session() { - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-other"; - let sub_dir = session_dir.path().join("subagents").join(id); - write_subagent_meta(&sub_dir, &running_test_meta(id, "other-parent")); - let coordinator = SubagentCoordinator::new(); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - None, - ); - let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); - let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); - assert_eq!( - reread.status, "running", - "cross-parent meta must be left alone" - ); -} -#[test] -fn reconcile_orphan_skips_malformed_meta() { - let session_dir = tempfile::TempDir::new().unwrap(); - let sub_dir = session_dir.path().join("subagents").join("sa-bad"); - std::fs::create_dir_all(&sub_dir).unwrap(); - std::fs::write(sub_dir.join("meta.json"), "{not valid json").unwrap(); - let coordinator = SubagentCoordinator::new(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - assert!( - cmd_rx.try_recv().is_err(), - "malformed meta must not emit a finish" - ); - assert_eq!( - std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(), - "{not valid json" - ); -} -#[test] -fn reconcile_orphan_noop_on_missing_subagents_dir() { - let session_dir = tempfile::TempDir::new().unwrap(); - let coordinator = SubagentCoordinator::new(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - assert!(cmd_rx.try_recv().is_err(), "no subagents dir → no emit"); -} -#[test] -fn reconcile_replayed_orphan_emits_finish_for_inherited_orphan() { - let session_dir = tempfile::TempDir::new().unwrap(); - let coordinator = SubagentCoordinator::new(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - let unfinished = vec![("sa-inherited".to_string(), "child-inherited".to_string())]; - reconcile_orphaned_subagents( - &unfinished, - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - assert_eq!(drain_cancelled_finish_cmds(&mut cmd_rx, "sa-inherited"), 1); -} -#[test] -fn reconcile_replayed_orphan_uses_real_terminal_status_from_meta() { - let session_dir = tempfile::TempDir::new().unwrap(); - let sub_dir = session_dir.path().join("subagents").join("sa-done"); - let mut meta = running_test_meta("sa-done", "parent-x"); - meta.status = "completed".into(); - meta.tool_calls = Some(7); - write_subagent_meta(&sub_dir, &meta); - let coordinator = SubagentCoordinator::new(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - let unfinished = vec![("sa-done".to_string(), "child-sa-done".to_string())]; - reconcile_orphaned_subagents( - &unfinished, - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - let mut found = None; - while let Ok(cmd) = cmd_rx.try_recv() { - if let SessionCommand::XaiSessionNotification { notification } = cmd - && let SessionUpdate::SubagentFinished { - subagent_id, - status, - tool_calls, - .. - } = ¬ification.update && subagent_id == "sa-done" - { - found = Some((status.clone(), *tool_calls)); - } - } - assert_eq!(found, Some(("completed".to_string(), 7))); -} -#[tokio::test] -async fn reconcile_reemits_rewound_finish_even_when_id_still_in_completed_registry() { - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-done"; - let sub_dir = session_dir.path().join("subagents").join(id); - let mut meta = running_test_meta(id, "parent-x"); - meta.status = "completed".into(); - meta.tool_calls = Some(7); - write_subagent_meta(&sub_dir, &meta); - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker(id, "parent-x", "explore", "task")); - coordinator - .move_to_completed( - id, - "task".into(), - "explore".into(), - SubagentResult { - success: true, - ..Default::default() - }, - None, - ); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - let unfinished = vec![(id.to_string(), format!("child-{id}"))]; - reconcile_orphaned_subagents( - &unfinished, - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - let mut found = None; - while let Ok(cmd) = cmd_rx.try_recv() { - if let SessionCommand::XaiSessionNotification { notification } = cmd - && let SessionUpdate::SubagentFinished { subagent_id, status, .. } = ¬ification - .update && subagent_id == id - { - found = Some(status.clone()); - } - } - assert_eq!( - found, - Some("completed".to_string()), - "a completed-then-rewound subagent must re-emit its real finish, not be skipped" - ); -} -#[tokio::test] -async fn reconcile_reemits_real_outcome_for_completed_with_running_meta() { - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-raced"; - let sub_dir = session_dir.path().join("subagents").join(id); - write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); - let mut coordinator = SubagentCoordinator::new(); - coordinator.insert(dummy_tracker(id, "parent-x", "explore", "task")); - coordinator - .move_to_completed( - id, - "task".into(), - "explore".into(), - SubagentResult { - success: true, - ..Default::default() - }, - None, - ); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - let unfinished = vec![(id.to_string(), format!("child-{id}"))]; - reconcile_orphaned_subagents( - &unfinished, - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - let mut found = None; - while let Ok(cmd) = cmd_rx.try_recv() { - if let SessionCommand::XaiSessionNotification { notification } = cmd - && let SessionUpdate::SubagentFinished { subagent_id, status, .. } = ¬ification - .update && subagent_id == id - { - found = Some(status.clone()); - } - } - assert_eq!( - found, - Some("completed".to_string()), - "must re-emit the real terminal outcome, not cancel" - ); + reconcile_with_inspections( + &[], + HashMap::from([(id.to_string(), None)]), + session_dir.path(), + &gateway, + Some(&cmd_tx), + ) + .await; let reread: SubagentMeta = serde_json::from_str( &std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(), ) .unwrap(); - assert_eq!( - reread.status, "running", - "must not finalize a completed subagent as cancelled" - ); -} -#[test] -fn reconcile_dedups_orphan_present_in_both_sources() { - let session_dir = tempfile::TempDir::new().unwrap(); - let sub_dir = session_dir.path().join("subagents").join("sa-crash"); - write_subagent_meta(&sub_dir, &running_test_meta("sa-crash", "parent-x")); - let coordinator = SubagentCoordinator::new(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - let unfinished = vec![("sa-crash".to_string(), "child-sa-crash".to_string())]; - reconcile_orphaned_subagents( - &unfinished, - &coordinator, - session_dir.path(), - "parent-x", - &test_gateway(), - Some(&cmd_tx), - ); - assert_eq!( - drain_cancelled_finish_cmds(&mut cmd_rx, "sa-crash"), - 1, - "an orphan in both sources is healed exactly once" - ); -} -#[test] -fn reconcile_orphan_persists_subagent_finished_via_cmd_tx() { - use crate::test_support::lsp_runtime::test_gateway_with_receiver; - let session_dir = tempfile::TempDir::new().unwrap(); - let id = "sa-emit"; - let sub_dir = session_dir.path().join("subagents").join(id); - write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); - let coordinator = SubagentCoordinator::new(); - let (gateway, mut gateway_rx) = test_gateway_with_receiver(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); - reconcile_orphaned_subagents( - &[], - &coordinator, - session_dir.path(), - "parent-x", - &gateway, - Some(&cmd_tx), - ); - assert_eq!( - drain_cancelled_finish_cmds(&mut cmd_rx, id), - 1, - "must persist exactly one SubagentFinished via parent_cmd_tx" - ); + assert_eq!(reread.status, "cancelled"); + assert_eq!(reread.tool_calls, Some(0)); + assert_eq!(reread.turns, Some(0)); + assert_eq!(drain_cancelled_finish_cmds(&mut cmd_rx, id), 1); assert_eq!( drain_cancelled_finish_broadcasts(&mut gateway_rx, id), - 1, - "must broadcast exactly one SubagentFinished via gateway" + 1 ); } +#[tokio::test] +async fn reconcile_orphan_skips_shared_actor_live_child() { + let session_dir = tempfile::TempDir::new().unwrap(); + let id = "sa-live"; + let sub_dir = session_dir.path().join("subagents").join(id); + write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); + reconcile_with_inspections( + &[], + HashMap::from([ + ( + id.to_string(), + Some(inspection(id, SubagentSnapshotStatus::Initializing)), + ), + ]), + session_dir.path(), + &test_gateway(), + None, + ) + .await; + let reread: SubagentMeta = serde_json::from_str( + &std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(), + ) + .unwrap(); + assert_eq!(reread.status, "running"); +} +#[tokio::test] +async fn reconcile_reemits_shared_actor_terminal_outcome() { + let session_dir = tempfile::TempDir::new().unwrap(); + let id = "sa-raced"; + let sub_dir = session_dir.path().join("subagents").join(id); + write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); + reconcile_with_inspections( + &[(id.to_string(), format!("child-{id}"))], + HashMap::from([ + ( + id.to_string(), + Some( + inspection( + id, + SubagentSnapshotStatus::Completed { + output: "done".to_string(), + tool_calls: 7, + turns: 2, + worktree_path: None, + }, + ), + ), + ), + ]), + session_dir.path(), + &test_gateway(), + Some(&cmd_tx), + ) + .await; + let finish = std::iter::from_fn(|| cmd_rx.try_recv().ok()) + .find_map(|command| { + let SessionCommand::XaiSessionNotification { notification } = command else { + return None; + }; + let SessionUpdate::SubagentFinished { status, tool_calls, .. } = notification + .update else { + return None; + }; + Some((status, tool_calls)) + }); + assert_eq!(finish, Some(("completed".to_string(), 7))); + let reread: SubagentMeta = serde_json::from_str( + &std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(), + ) + .unwrap(); + assert_eq!(reread.status, "running"); +} +#[tokio::test] +async fn reconcile_dedups_replay_and_running_meta_sources() { + let session_dir = tempfile::TempDir::new().unwrap(); + let id = "sa-crash"; + let sub_dir = session_dir.path().join("subagents").join(id); + write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); + reconcile_with_inspections( + &[(id.to_string(), format!("child-{id}"))], + HashMap::from([(id.to_string(), None)]), + session_dir.path(), + &test_gateway(), + Some(&cmd_tx), + ) + .await; + assert_eq!(drain_cancelled_finish_cmds(&mut cmd_rx, id), 1); +} #[test] fn resume_rejects_conflicting_subagent_type() { let source = ResumeSourceData { @@ -2344,332 +1753,31 @@ fn upload_ref_includes_resumed_from() { assert!(parsed.description.is_empty()); } #[test] -fn completed_subagent_propagates_resumed_from() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .completed - .insert( - "sub-prov".to_string(), - CompletedSubagent { - subagent_id: "sub-prov".into(), - parent_session_id: "parent".into(), - parent_prompt_id: Some("prompt-1".into()), - owner: SubagentOwner::Task, - child_session_id: "child-prov".into(), - description: "provenance test".into(), - subagent_type: "general-purpose".into(), - persona: None, - started_at: std::time::Instant::now(), - completed_at: std::time::Instant::now(), - result: SubagentResult { - success: true, - ..Default::default() - }, - resumed_from: Some("source-agent".into()), - child_cwd: "/workspace".into(), - worktree_path: None, - snapshot_ref: None, - effective_model_id: "grok-3".into(), - block_waited: false, - explicitly_killed: false, - completion_output_cap: None, - persisted_output_dir: None, - }, - ); - let refs = coordinator.spawned_refs_for_prompt("prompt-1"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].resumed_from.as_deref(), Some("source-agent")); - assert_eq!(refs[0].description, "provenance test"); -} -#[tokio::test] -async fn completion_notify_fires_on_move_to_completed() { - let mut coordinator = SubagentCoordinator::new(); - let notify = coordinator.completion_notify(); - let notified = notify.notified(); - coordinator - .move_to_completed( - "sub-n1", - "notify test".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("ok"), - subagent_id: "sub-n1".to_string(), - child_session_id: "sub-n1".to_string(), - tool_calls: 1, - turns: 1, - duration_ms: 100, - ..Default::default() - }, - None, - ); - tokio::time::timeout(std::time::Duration::from_millis(50), notified) - .await - .expect("completion_notify should have fired after move_to_completed"); -} -#[test] -fn drain_pending_completions_returns_and_clears() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-d1", - "task 1".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("done"), - subagent_id: "sub-d1".to_string(), - child_session_id: "sub-d1".to_string(), - tool_calls: 3, - turns: 2, - duration_ms: 500, - ..Default::default() - }, - None, - ); - coordinator - .move_to_completed( - "sub-d2", - "task 2".to_string(), - "plan".to_string(), - SubagentResult { - success: false, - output: std::sync::Arc::from(""), - error: Some("crashed".to_string()), - subagent_id: "sub-d2".to_string(), - child_session_id: "sub-d2".to_string(), - duration_ms: 200, - ..Default::default() - }, - None, - ); - let summaries = coordinator.drain_pending_completions_for(""); - assert_eq!(summaries.len(), 2); - assert_eq!(summaries[0].subagent_id, "sub-d1"); - assert!(summaries[0].success); - assert_eq!(summaries[0].description, "task 1"); - assert_eq!(summaries[0].subagent_type, "explore"); - assert_eq!(summaries[0].tool_calls, 3); - assert_eq!(summaries[0].turns, 2); - assert_eq!(summaries[0].duration_ms, 500); - assert_eq!(summaries[1].subagent_id, "sub-d2"); - assert!(!summaries[1].success); - let again = coordinator.drain_pending_completions_for(""); - assert!(again.is_empty(), "buffer should be empty after drain"); -} -#[test] -fn drain_pending_completions_cancelled_is_not_success() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .move_to_completed( - "sub-c1", - "cancelled task".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - cancelled: true, - output: std::sync::Arc::from(""), - subagent_id: "sub-c1".to_string(), - child_session_id: "sub-c1".to_string(), - ..Default::default() - }, - None, - ); - let summaries = coordinator.drain_pending_completions_for(""); - assert_eq!(summaries.len(), 1); - assert!( - !summaries[0].success, - "cancelled subagent should not be marked as success" - ); -} -#[tokio::test] -async fn outstanding_for_prompt_includes_pending_and_active() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "sub-p1".to_string(), - subagent_type: "explore".to_string(), - description: "pending for X".to_string(), - persona: None, - parent_prompt_id: Some("prompt-X".to_string()), - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - let mut tracker = dummy_tracker("sub-a1", "session-1", "plan", "active for X"); - tracker.parent_prompt_id = Some("prompt-X".to_string()); - coordinator.insert(tracker); - let mut tracker2 = dummy_tracker("sub-a2", "session-1", "explore", "active for Y"); - tracker2.parent_prompt_id = Some("prompt-Y".to_string()); - coordinator.insert(tracker2); - let outstanding = coordinator.outstanding_for_prompt("prompt-X"); - assert_eq!(outstanding.len(), 2); - assert!(outstanding.contains(&"sub-p1".to_string())); - assert!(outstanding.contains(&"sub-a1".to_string())); -} -#[tokio::test] -async fn outstanding_for_prompt_excludes_completed() { - let mut coordinator = SubagentCoordinator::new(); - let mut tracker = dummy_tracker("sub-done", "session-1", "explore", "done for X"); - tracker.parent_prompt_id = Some("prompt-X".to_string()); - coordinator.insert(tracker); - coordinator - .move_to_completed( - "sub-done", - "done for X".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("done"), - subagent_id: "sub-done".to_string(), - child_session_id: "sub-done".to_string(), - ..Default::default() - }, - None, - ); - let outstanding = coordinator.outstanding_for_prompt("prompt-X"); - assert!( - outstanding.is_empty(), - "completed subagents should not appear in outstanding" - ); -} -#[test] -fn outstanding_for_prompt_returns_empty_for_unknown_prompt() { - let coordinator = SubagentCoordinator::new(); - let outstanding = coordinator.outstanding_for_prompt("nonexistent"); - assert!(outstanding.is_empty()); -} -/// Background children never gate the turn-end drain: they are excluded -/// from `outstanding_for_prompt` and reported via `background_live` -/// instead, including a foreground child auto-backgrounded mid-turn. -#[tokio::test] -async fn background_children_do_not_gate_the_drain() { - let mut coordinator = SubagentCoordinator::new(); - let mut bg = dummy_tracker("sub-bg", "session-1", "explore", "background"); - bg.parent_prompt_id = Some("prompt-X".to_string()); - bg.run_in_background = true; - coordinator.insert(bg); - let mut fg = dummy_tracker("sub-fg", "session-1", "plan", "foreground"); - fg.parent_prompt_id = Some("prompt-X".to_string()); - coordinator.insert(fg); - assert_eq!( - coordinator.outstanding_for_prompt("prompt-X"), - vec!["sub-fg".to_string()], - "only the foreground child gates the drain" - ); - assert!(coordinator.background_live_for_prompt("prompt-X")); - assert!(!coordinator.background_live_for_prompt("prompt-Y")); - coordinator.mark_backgrounded("sub-fg"); - assert!(coordinator.outstanding_for_prompt("prompt-X").is_empty()); - assert!(coordinator.background_live_for_prompt("prompt-X")); -} -#[tokio::test] -async fn subagent_usage_not_applied_sticky_after_completion_and_is_prompt_scoped() { - let mut coordinator = SubagentCoordinator::new(); - let mut tracker = dummy_tracker("sub-1", "session-1", "explore", "task"); - tracker.parent_prompt_id = Some("p-1".to_string()); - coordinator.insert(tracker); - coordinator.mark_subagent_usage_not_applied("p-1"); - coordinator - .move_to_completed( - "sub-1", - "task".into(), - "explore".into(), - SubagentResult { - success: true, - output: std::sync::Arc::from("ok"), - subagent_id: "sub-1".to_string(), - child_session_id: "sub-1".to_string(), - ..Default::default() - }, - None, - ); - assert!(coordinator.outstanding_for_prompt("p-1").is_empty()); - assert!(coordinator.subagent_usage_not_applied("p-1")); - assert!(!coordinator.subagent_usage_not_applied("p-2")); - let reply = coordinator.outstanding_reply_for_prompt("p-1"); - assert!(reply.live_ids.is_empty()); - assert!(reply.subagent_usage_not_applied); - coordinator.clear_subagent_usage_not_applied("p-1"); - assert!(!coordinator.subagent_usage_not_applied("p-1")); -} -#[test] -fn outstanding_for_prompt_returns_sorted_ids() { - let mut coordinator = SubagentCoordinator::new(); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "zzz".to_string(), - subagent_type: "explore".to_string(), - description: "z".to_string(), - persona: None, - parent_prompt_id: Some("p".to_string()), - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - coordinator - .insert_pending(PendingSubagent { - subagent_id: "aaa".to_string(), - subagent_type: "explore".to_string(), - description: "a".to_string(), - persona: None, - parent_prompt_id: Some("p".to_string()), - parent_session_id: String::new(), - owner: SubagentOwner::Task, - started_at: std::time::Instant::now(), - run_in_background: false, - surface_completion: true, - color: None, - cancel_token: CancellationToken::new(), - }); - let ids = coordinator.outstanding_for_prompt("p"); - assert_eq!(ids, vec!["aaa", "zzz"]); -} -#[test] fn turn_active_flag_defaults_to_false() { - let coordinator = SubagentCoordinator::new(); - assert!(!coordinator.is_turn_active()); + let presentation = SubagentPresentation::new(); + assert!( + !presentation + .turn_active_flag() + .load(std::sync::atomic::Ordering::Relaxed) + ); } #[test] fn turn_active_flag_shared_via_arc() { - let coordinator = SubagentCoordinator::new(); - let flag = coordinator.turn_active_flag(); + let presentation = SubagentPresentation::new(); + let flag = presentation.turn_active_flag(); assert!(!flag.load(std::sync::atomic::Ordering::Relaxed)); flag.store(true, std::sync::atomic::Ordering::Relaxed); - assert!(coordinator.is_turn_active()); - flag.store(false, std::sync::atomic::Ordering::Relaxed); - assert!(!coordinator.is_turn_active()); -} -#[test] -fn completions_buffered_while_turn_inactive_drained_later() { - let mut coordinator = SubagentCoordinator::new(); - assert!(!coordinator.is_turn_active()); - coordinator - .move_to_completed( - "sub-idle", - "idle task".to_string(), - "explore".to_string(), - SubagentResult { - success: true, - output: std::sync::Arc::from("result"), - subagent_id: "sub-idle".to_string(), - child_session_id: "sub-idle".to_string(), - ..Default::default() - }, - None, + assert!( + presentation + .turn_active_flag() + .load(std::sync::atomic::Ordering::Relaxed) + ); + flag.store(false, std::sync::atomic::Ordering::Relaxed); + assert!( + !presentation + .turn_active_flag() + .load(std::sync::atomic::Ordering::Relaxed) ); - let drained = coordinator.drain_pending_completions_for(""); - assert_eq!(drained.len(), 1); - assert_eq!(drained[0].subagent_id, "sub-idle"); - assert!(coordinator.drain_pending_completions_for("").is_empty()); } fn ctx_with_parent_chat_state( session_model_id: &str, @@ -2830,7 +1938,7 @@ async fn read_parent_sampling_config_fallback_resolves_compactions_remaining_fro ); } /// Drive the REAL precedence path -/// (`resolve_effective_model_config`, which `handle_subagent_request` +/// (`resolve_effective_model_config`, which `run_shell_child` /// calls) with BOTH an explicit `runtime_override_model` AND a /// `[subagents.models]` pin for the same agent present, asserting the /// runtime override wins; with `None` (inherit) the pin wins (precedence @@ -2891,7 +1999,7 @@ async fn runtime_override_wins_over_subagents_models_pin_in_precedence_path() { /// A `fork_context = true` spawn must infer on the parent session model /// (`ctx.model_id`) for per-model radix reuse, even when a /// `[subagents.models]` pin and an `AgentDefinition.model` override are -/// both present. `handle_subagent_request` forces +/// both present. `run_shell_child` forces /// `effective_runtime.model = Some(ctx.model_id)` on the fork path after /// other override sources; the runtime override wins in /// `resolve_effective_model_config`. @@ -3573,23 +2681,3 @@ async fn progress_publisher_delivers_ticks_to_parent_cmd_channel() { }) .await; } -/// A harness-pinned `spawn_depth` of 0 (scheduler loop iterations) keeps -/// the task tool in the child toolset; a natural depth-1 child loses it. -#[test] -fn strip_task_tools_honors_spawn_depth() { - use xai_grok_agent::config::AgentDefinition; - use xai_grok_tools::registry::types::ToolServerConfig; - use xai_grok_tools::types::tool::ToolKind; - use super::super::handle_request::strip_task_tools_at_max_depth; - let has_task = |cfg: &ToolServerConfig| { - cfg.tools.iter().any(|tc| tc.kind == Some(ToolKind::Task)) - }; - let base = AgentDefinition::general_purpose().tool_config; - assert!(has_task(&base)); - let mut natural_child = base.clone(); - assert!(strip_task_tools_at_max_depth(&mut natural_child, 1)); - assert!(!has_task(&natural_child)); - let mut loop_iteration = base.clone(); - assert!(!strip_task_tools_at_max_depth(&mut loop_iteration, 0)); - assert!(has_task(&loop_iteration)); -} diff --git a/crates/codegen/xai-grok-shell/src/auth/auth_provider.rs b/crates/codegen/xai-grok-shell/src/auth/auth_provider.rs index 4d17158..c1b2302 100644 --- a/crates/codegen/xai-grok-shell/src/auth/auth_provider.rs +++ b/crates/codegen/xai-grok-shell/src/auth/auth_provider.rs @@ -21,19 +21,16 @@ use super::token_output::{expiry_after_seconds, parse_token_output}; #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] #[serde(default)] pub struct AuthProviderConfig { - /// Command that prints a bearer token on stdout, bare or as JSON - /// `{access_token, expires_in}`. Without `args` it runs via `sh -c`. + /// Command to run; without `args` it uses the platform shell, with `args` it execs directly. pub command: String, - /// Arguments for `command`. When present (even empty), the command runs - /// directly with no shell; `command` is a program name on `PATH`, or a path. + /// Command arguments; when set (even empty) the command execs directly. pub args: Option<Vec<String>>, - /// Fallback token lifetime in seconds, used when the command's output - /// carries no `expires_in`. Takes precedence over a JWT `exp` claim. + /// Fallback token lifetime used when the output carries no `expires_in`. pub token_ttl_secs: Option<u64>, - /// Maximum seconds to wait for the command (default 30, clamped to 1..=600). - /// A turn waits up to this long on a mint, so keep helpers fast and - /// non-interactive. + /// Max seconds to wait for the command (default 30, clamped to 1..=600). pub timeout_secs: Option<u64>, + /// Working directory for the command; a leading `~` expands to home. + pub cwd: Option<String>, } impl AuthProviderConfig { @@ -210,14 +207,17 @@ const PROVIDER_STDERR_CAP_BYTES: u64 = 64 << 10; // 64 KiB /// new `AuthProviderConfig` field is a compile error until it is classified as /// token-shaping (add it here) or an execution knob like `timeout_secs` /// (editing it never invalidates). -fn token_identity(config: &AuthProviderConfig) -> (&str, Option<&[String]>, Option<u64>) { +fn token_identity( + config: &AuthProviderConfig, +) -> (&str, Option<&[String]>, Option<u64>, Option<&str>) { let AuthProviderConfig { command, args, token_ttl_secs, timeout_secs: _, + cwd, } = config; - (command, args.as_deref(), *token_ttl_secs) + (command, args.as_deref(), *token_ttl_secs, cwd.as_deref()) } fn minted_token_is_stale(minted: &MintedProviderToken, config: &AuthProviderConfig) -> bool { @@ -333,6 +333,19 @@ async fn run_capped( }) } +fn resolve_program(command: &str, cwd: Option<&std::path::Path>) -> std::path::PathBuf { + let path = std::path::Path::new(command); + if path.is_absolute() { + return path.to_path_buf(); + } + if path.components().count() > 1 + && let Some(dir) = cwd + { + return dir.join(path); + } + std::path::PathBuf::from(command) +} + async fn mint_provider_token( provider: &AuthProviderRef, mark_expired: bool, @@ -356,20 +369,33 @@ async fn mint_provider_token( "auth provider: running helper command" ); + let cwd = config + .cwd + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(crate::util::expand_home); + let mut cmd = match config.args { Some(ref args) => { - // Direct exec: the program name is a PATH lookup, so trim stray - // whitespace that would otherwise fail to resolve. - let mut cmd = tokio::process::Command::new(config.command.trim()); + let program = resolve_program(config.command.trim(), cwd.as_deref()); + let mut cmd = tokio::process::Command::new(program); cmd.args(args); cmd } None => { - let mut cmd = tokio::process::Command::new("sh"); - cmd.args(["-c", &config.command]); + #[cfg(windows)] + let (shell, flag) = ("cmd", "/C"); + #[cfg(not(windows))] + let (shell, flag) = ("sh", "-c"); + let mut cmd = tokio::process::Command::new(shell); + cmd.args([flag, config.command.as_str()]); cmd } }; + if let Some(ref dir) = cwd { + cmd.current_dir(dir); + } cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) // Capture stderr for the failure log; inheriting corrupts the TUI. @@ -613,6 +639,7 @@ pub(crate) fn test_counting_provider(name: &str, dir: &std::path::Path) -> AuthP args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ) } diff --git a/crates/codegen/xai-grok-shell/src/auth/auth_provider_tests.rs b/crates/codegen/xai-grok-shell/src/auth/auth_provider_tests.rs index cd260a1..d48e464 100644 --- a/crates/codegen/xai-grok-shell/src/auth/auth_provider_tests.rs +++ b/crates/codegen/xai-grok-shell/src/auth/auth_provider_tests.rs @@ -149,6 +149,7 @@ async fn provider_config_edit_invalidates_cached_token() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); assert_eq!( @@ -181,6 +182,7 @@ async fn provider_401_recovery_reminted_under_edited_config() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); assert_eq!( @@ -205,6 +207,7 @@ async fn provider_timeout_edit_does_not_invalidate_token() { args: None, token_ttl_secs: Some(3600), timeout_secs: Some(5), + cwd: None, }, ); assert_eq!( @@ -214,6 +217,31 @@ async fn provider_timeout_edit_does_not_invalidate_token() { ); } +/// `cwd` is part of `token_identity`, so editing it invalidates the cache: the +/// same helper in a different directory can mint a different token. +#[tokio::test] +async fn provider_cwd_edit_invalidates_cached_token() { + let dir = tempfile::tempdir().unwrap(); + let provider = counting_provider("test-cwd-edit", dir.path()); + provider.ensure_fresh_token(None).await.rotated().unwrap(); + + let moved = AuthProviderRef::new( + "test-cwd-edit".to_owned(), + AuthProviderConfig { + command: provider.config.command.clone(), + args: None, + token_ttl_secs: Some(3600), + timeout_secs: None, + cwd: Some("/some/other/dir".to_owned()), + }, + ); + assert_eq!( + moved.cached_token(), + None, + "a cwd edit must invalidate the cached token" + ); +} + #[tokio::test] async fn attach_trusted_config_lets_a_revived_ref_mint() { let dir = tempfile::tempdir().unwrap(); @@ -296,6 +324,7 @@ async fn provider_refresh_sets_expired_env() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); assert_eq!( @@ -325,6 +354,7 @@ async fn provider_concurrent_mints_single_flight() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); let (a, b) = tokio::join!( @@ -373,6 +403,7 @@ async fn provider_expiry_source_precedence() { args: None, token_ttl_secs, timeout_secs: None, + cwd: None, }, ); let first = provider @@ -430,6 +461,7 @@ async fn provider_unusable_expiry_still_mints() { args: None, token_ttl_secs: Some(u64::MAX), timeout_secs: None, + cwd: None, }, ); assert_eq!( @@ -454,6 +486,7 @@ async fn provider_args_run_without_a_shell() { args: Some(vec!["tok-$HOME;42".to_owned()]), token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); assert_eq!( @@ -471,6 +504,7 @@ async fn provider_command_times_out() { args: None, token_ttl_secs: None, timeout_secs: Some(1), + cwd: None, }, ); let start = std::time::Instant::now(); @@ -496,6 +530,7 @@ async fn provider_zero_timeout_clamps_to_one_second() { args: None, token_ttl_secs: Some(3600), timeout_secs: Some(0), + cwd: None, }, ); assert_eq!( @@ -512,6 +547,7 @@ async fn provider_zero_timeout_clamps_to_one_second() { args: None, token_ttl_secs: Some(3600), timeout_secs: Some(0), + cwd: None, }, ); assert!( @@ -534,6 +570,7 @@ async fn mint_error_messages_distinguish_failure_modes() { args: None, token_ttl_secs: None, timeout_secs: Some(1), + cwd: None, }, ); let err = mint_provider_token(&timed_out, false, None) @@ -549,6 +586,7 @@ async fn mint_error_messages_distinguish_failure_modes() { args: Some(vec![]), token_ttl_secs: None, timeout_secs: Some(5), + cwd: None, }, ); let err = mint_provider_token(&missing, false, None) @@ -564,6 +602,7 @@ async fn mint_error_messages_distinguish_failure_modes() { args: None, token_ttl_secs: None, timeout_secs: Some(5), + cwd: None, }, ); let err = mint_provider_token(&empty_output, false, None) @@ -585,6 +624,7 @@ async fn re_mint_hands_the_prior_token_back_to_the_command() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); @@ -620,6 +660,7 @@ async fn failed_401_remint_invalidates_the_cached_token() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); @@ -658,6 +699,7 @@ async fn failed_pre_turn_mint_does_not_serve_the_stale_token() { args: None, token_ttl_secs: Some(3600), timeout_secs: None, + cwd: None, }, ); @@ -689,6 +731,7 @@ async fn provider_output_over_cap_fails_closed() { args: None, token_ttl_secs: None, timeout_secs: Some(5), + cwd: None, }, ); let err = mint_provider_token(&provider, false, None) @@ -761,3 +804,85 @@ async fn provider_helper_env_scrubs_first_party_credentials() { "no first-party credential may survive into the helper env" ); } + +/// `resolve_program` branches: bare name via `PATH`, absolute as-is, relative +/// against `cwd`. +#[test] +fn resolve_program_resolves_against_cwd() { + let cwd = std::path::Path::new("/work"); + assert_eq!( + super::resolve_program("token-helper", Some(cwd)), + std::path::PathBuf::from("token-helper") + ); + let abs = if cfg!(windows) { + r"C:\bin\helper.exe" + } else { + "/usr/local/bin/helper" + }; + assert_eq!( + super::resolve_program(abs, Some(cwd)), + std::path::PathBuf::from(abs) + ); + assert_eq!( + super::resolve_program("bin/helper", Some(cwd)), + cwd.join("bin/helper") + ); + assert_eq!( + super::resolve_program("bin/helper", None), + std::path::PathBuf::from("bin/helper"), + "with no cwd a relative path is left to the process cwd" + ); +} + +/// The `args` form (the portable, no-shell shape a desktop/Windows helper +/// should use) resolves a relative program against the provider's `cwd`. +#[cfg(unix)] +#[tokio::test] +async fn provider_resolves_relative_program_against_cwd() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("token.sh"); + std::fs::write(&script, "#!/bin/sh\nprintf 'cwd-tok'\n").unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + + let provider = AuthProviderRef::new( + "test-cwd-relative".to_owned(), + AuthProviderConfig { + command: "./token.sh".to_owned(), + args: Some(vec![]), + token_ttl_secs: Some(3600), + timeout_secs: None, + cwd: Some(dir.path().to_string_lossy().into_owned()), + }, + ); + assert_eq!( + provider.ensure_fresh_token(None).await.rotated().as_deref(), + Some("cwd-tok") + ); +} + +/// `cwd` is the command's runtime directory: reading a file by relative name +/// only succeeds if `current_dir` took effect (here via the shell form). +#[cfg(unix)] +#[tokio::test] +async fn provider_command_runs_in_cwd() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("token.txt"), "file-tok").unwrap(); + + let provider = AuthProviderRef::new( + "test-cwd-shell".to_owned(), + AuthProviderConfig { + command: "cat token.txt".to_owned(), + args: None, + token_ttl_secs: Some(3600), + timeout_secs: None, + cwd: Some(dir.path().to_string_lossy().into_owned()), + }, + ); + assert_eq!( + provider.ensure_fresh_token(None).await.rotated().as_deref(), + Some("file-tok") + ); +} diff --git a/crates/codegen/xai-grok-shell/src/claude_import.rs b/crates/codegen/xai-grok-shell/src/claude_import.rs index 4302e43..d979821 100644 --- a/crates/codegen/xai-grok-shell/src/claude_import.rs +++ b/crates/codegen/xai-grok-shell/src/claude_import.rs @@ -561,28 +561,6 @@ pub(crate) fn reset_marker_cache_for_test() { *MARKER_CACHE.write().expect("MARKER_CACHE poisoned") = None; } -/// Expand a leading bare `~` or `~/` to the home directory. Returns the path -/// unchanged if home cannot be resolved or the input has no leading tilde. -/// -/// `~user/` (other-user home) is **not** supported — this is a config field, -/// not a shell input, so the surface is intentionally narrow. -/// -/// Shared by `extensions/skills.rs` (skills paths from `[paths] extra_skill_dirs`) -/// and `inspect.rs` (rules paths from `[paths] extra_rule_dirs`) so both call -/// sites apply identical normalisation. -pub fn expand_home(s: &str) -> std::path::PathBuf { - if let Some(stripped) = s.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } else if s == "~" - && let Some(home) = dirs::home_dir() - { - return home; - } - std::path::PathBuf::from(s) -} - /// Like [`is_claude_import_marked`], but logs a one-time `info!` line on the /// first true result per process so users can see the runtime cutoff is active. /// @@ -2070,43 +2048,6 @@ extra_rule_dirs = ["/c/rules"] ); } - #[test] - fn expand_home_passthrough_for_absolute_path() { - assert_eq!( - expand_home("/abs/path"), - std::path::PathBuf::from("/abs/path") - ); - } - - #[test] - fn expand_home_passthrough_for_relative_path() { - assert_eq!( - expand_home("rel/path"), - std::path::PathBuf::from("rel/path") - ); - } - - #[test] - fn expand_home_bare_tilde() { - let home = dirs::home_dir().expect("home_dir required for this test"); - assert_eq!(expand_home("~"), home); - } - - #[test] - fn expand_home_tilde_slash() { - let home = dirs::home_dir().expect("home_dir required for this test"); - assert_eq!(expand_home("~/foo/bar"), home.join("foo/bar")); - } - - #[test] - fn expand_home_does_not_handle_user_tilde() { - // Documented limitation: `~bob/path` is treated as a literal relative path. - assert_eq!( - expand_home("~bob/path"), - std::path::PathBuf::from("~bob/path") - ); - } - #[test] fn scan_claude_path_dirs_dedupes_global_and_project_when_same() { // Simulate a workspace where project_root canonicalises to the home dir diff --git a/crates/codegen/xai-grok-shell/src/config/mod.rs b/crates/codegen/xai-grok-shell/src/config/mod.rs index c2dee92..49da5f9 100644 --- a/crates/codegen/xai-grok-shell/src/config/mod.rs +++ b/crates/codegen/xai-grok-shell/src/config/mod.rs @@ -1146,6 +1146,17 @@ fn apply_requirements_inner( enforce_str!("models", "web_search", config.models.web_search); enforce_str!("cli", "channel", config.cli.channel); enforce_str!("cli", "minimum_version", config.cli.minimum_version); + enforce_str!("cli", "maximum_version", config.cli.maximum_version); + enforce_str!( + "cli", + "required_minimum_version", + config.cli.required_minimum_version + ); + enforce_str!( + "cli", + "required_maximum_version", + config.cli.required_maximum_version + ); if let Some(val) = req_str(req, "endpoints", "xai_api_base_url") && config.endpoints.xai_api_base_url != val { @@ -1311,12 +1322,17 @@ pub fn apply_sandbox( #[cfg(target_os = "linux")] let requires_read_deny = xai_grok_sandbox::requires_read_deny(&sandbox_profile, &workspace); #[cfg(target_os = "linux")] + let requires_hook_write_deny = + xai_grok_sandbox::requires_hook_write_deny(&sandbox_profile, &workspace); + #[cfg(target_os = "linux")] + let requires_bwrap = requires_read_deny || requires_hook_write_deny; + #[cfg(target_os = "linux")] { let refuse_unprotected = |detail: &str| { eprintln!( - "error: this sandbox could not enforce its read-deny set on Linux \ - (bubblewrap missing/unusable, or a deny glob exceeded its expansion \ - limit — see any message above). Install bubblewrap with \ + "error: this sandbox could not enforce its mount-namespace deny set \ + on Linux (bubblewrap missing/unusable, or a deny glob exceeded its \ + expansion limit — see any message above). Install bubblewrap with \ `apt install -y bubblewrap` if needed. Refusing to start with denied \ paths unprotected.{detail}" ); @@ -1325,7 +1341,7 @@ pub fn apply_sandbox( Some(mut cmd) => { use std::os::unix::process::CommandExt; let err = cmd.exec(); - if requires_read_deny { + if requires_bwrap { refuse_unprotected(&format!(" (bwrap exec failed: {err})")); std::process::exit(1); } @@ -1335,7 +1351,19 @@ pub fn apply_sandbox( Install bubblewrap: apt install -y bubblewrap" ); } - None if requires_read_deny && !xai_grok_sandbox::is_inside_bwrap() => { + None if requires_bwrap && xai_grok_sandbox::is_inside_bwrap() => { + if requires_hook_write_deny + && let Err(e) = xai_grok_sandbox::verify_hook_write_deny_enforced() + { + eprintln!( + "error: sandbox reports bwrap but required hook write-deny \ + mounts are missing or writable ({e}); refusing to start \ + (possible __GROK_INSIDE_BWRAP spoof)" + ); + std::process::exit(1); + } + } + None if requires_bwrap => { refuse_unprotected(""); std::process::exit(1); } @@ -1344,7 +1372,12 @@ pub fn apply_sandbox( } if sandbox_profile != xai_grok_sandbox::ProfileName::Off { #[cfg(any(target_os = "linux", target_os = "macos"))] - let is_custom = matches!(sandbox_profile, xai_grok_sandbox::ProfileName::Custom(_)); + let requires_protection = { + let is_custom = matches!(sandbox_profile, xai_grok_sandbox::ProfileName::Custom(_)); + let needs_hooks = + xai_grok_sandbox::requires_hook_write_deny(&sandbox_profile, &workspace); + is_custom || needs_hooks + }; let mut sandbox = xai_grok_sandbox::SandboxManager::new(sandbox_profile, &workspace); if let Err(e) = sandbox.apply(&workspace) { eprintln!("warning: sandbox could not be applied: {e}"); @@ -1352,17 +1385,30 @@ pub fn apply_sandbox( #[cfg(any(target_os = "linux", target_os = "macos"))] { #[cfg(target_os = "macos")] - let unappliable_custom = is_custom && !sandbox.is_applied(); + let unappliable = requires_protection && !sandbox.is_applied(); #[cfg(target_os = "linux")] - let unappliable_custom = - is_custom && !sandbox.is_applied() && !xai_grok_sandbox::is_inside_bwrap(); - if unappliable_custom { + let unappliable = requires_protection + && !sandbox.is_applied() + && !xai_grok_sandbox::is_inside_bwrap(); + if unappliable { eprintln!( - "error: could not apply the '{}' sandbox profile; refusing to start rather than run unsandboxed.", + "error: could not apply the '{}' sandbox profile (including \ + direct global-hook write protection); refusing to start.", sandbox.profile() ); std::process::exit(1); } + #[cfg(target_os = "linux")] + if requires_hook_write_deny + && xai_grok_sandbox::is_inside_bwrap() + && let Err(e) = xai_grok_sandbox::verify_hook_write_deny_enforced() + { + eprintln!( + "error: required hook write-deny mounts not verified after apply ({e}); \ + refusing to start" + ); + std::process::exit(1); + } } sandbox.install(); } diff --git a/crates/codegen/xai-grok-shell/src/config/tests.rs b/crates/codegen/xai-grok-shell/src/config/tests.rs index 7705c58..de38f0c 100644 --- a/crates/codegen/xai-grok-shell/src/config/tests.rs +++ b/crates/codegen/xai-grok-shell/src/config/tests.rs @@ -3147,7 +3147,7 @@ fn apply_requirements_value_overrides_user_settings() { } /// Strict precedence: requirement always wins (covers from-None and /// from-higher-user cases). The enforced floor lives in -/// `resolve_minimum_version`, not this field. +/// `VersionPolicy`, not this field. #[test] fn apply_requirements_pins_minimum_version() { let source = RequirementSource::Requirements { diff --git a/crates/codegen/xai-grok-shell/src/extensions/auth.rs b/crates/codegen/xai-grok-shell/src/extensions/auth.rs index 6b72083..2cec239 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/auth.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/auth.rs @@ -209,7 +209,7 @@ fn handle_info(agent: &MvpAgent) -> ExtResult { .load() .as_ref() .map(|m| m.0.to_string()); - let auth = agent.auth_manager.current(); + let auth = agent.auth_manager.current_or_expired(); let raw_asset_id = auth.as_ref().and_then(|a| a.profile_image_asset_id.clone()); // Return a grok-asset:// URL that the Electron renderer resolves at diff --git a/crates/codegen/xai-grok-shell/src/extensions/debug.rs b/crates/codegen/xai-grok-shell/src/extensions/debug.rs index 4196532..2b9bfcf 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/debug.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/debug.rs @@ -23,13 +23,14 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { handle_trigger_feedback(agent, args).await } "x.ai/debug/arm_auto_compact" => handle_arm_auto_compact(agent, args), - "x.ai/debug/agent" => handle_agent(agent), + "x.ai/debug/agent" => handle_agent(agent).await, _ => Err(acp::Error::method_not_found()), } } -fn handle_agent(agent: &MvpAgent) -> ExtResult { - ExtMethodResult::success(serde_json::json!({ "registries": agent.registry_snapshot() })) +async fn handle_agent(agent: &MvpAgent) -> ExtResult { + let registries = agent.registry_snapshot().await; + ExtMethodResult::success(serde_json::json!({ "registries": registries })) .to_ext_response() .map_err(|e| acp::Error::internal_error().data(e.to_string())) } diff --git a/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs b/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs index 77fcf4f..64876b9 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs @@ -866,6 +866,39 @@ async fn handle_add_source(url: &str) -> xai_hooks_plugins_types::ActionOutcome }; } + // Reject URLs that aren't reachable git repos (e.g. MCP endpoints pasted + // into the wrong tab) before persisting. The probe blocks on a git + // subprocess, so run it off the LocalSet. + if let MarketplaceAddInput::GitUrl(git_url) = &input { + let probe_url = git_url.clone(); + let probe = tokio::task::spawn_blocking(move || { + xai_grok_plugin_marketplace::git::probe_git_remote(&probe_url) + }) + .await; + match probe { + Ok(Ok(())) => {} + Ok(Err(e)) => { + return ActionOutcome { + status: OutcomeStatus::ValidationError, + message: format!( + "{e}. Not a reachable git repository — to add it anyway (e.g. a \ + VPN-gated host), run: grok plugin marketplace add {url} --force" + ), + requires_reload: false, + requires_restart: false, + }; + } + Err(e) => { + return ActionOutcome { + status: OutcomeStatus::InternalError, + message: format!("Probe task failed: {e}"), + requires_reload: false, + requires_restart: false, + }; + } + } + } + let is_official = matches!(&input, MarketplaceAddInput::GitUrl(u) if xai_grok_plugin_marketplace::is_official_source_url(u)); let name = if is_official { diff --git a/crates/codegen/xai-grok-shell/src/extensions/skills.rs b/crates/codegen/xai-grok-shell/src/extensions/skills.rs index 8dde141..65cf884 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/skills.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/skills.rs @@ -248,7 +248,7 @@ fn discover_auto_sources(cwd: &str, skills: &[SkillInfo]) -> Vec<(String, usize) // scan locations. Used both standalone and as the migration target after // /import-claude when the runtime .claude/skills/ scan is disabled. for dir in extra_skill_dirs_from_config() { - let path = crate::claude_import::expand_home(&dir); + let path = crate::util::expand_home(&dir); if path.is_dir() && !sources .iter() diff --git a/crates/codegen/xai-grok-shell/src/extensions/task.rs b/crates/codegen/xai-grok-shell/src/extensions/task.rs index 1aa59a5..0395481 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/task.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/task.rs @@ -3,11 +3,11 @@ use serde::{Deserialize, Serialize}; use xai_grok_tools::types::{KillOutcome, TaskSnapshot}; use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentCancelOutcome, SubagentSnapshot, SubagentSnapshotStatus, + SubagentCancelOutcome, SubagentInspection, SubagentProvenance, SubagentSnapshot, + SubagentSnapshotStatus, }; use crate::agent::MvpAgent; -use crate::agent::subagent::{ResolvedRunningSubagent, is_running, resolve_running_list}; use crate::session::ExtMethodResult; type ExtResult = Result<acp::ExtResponse, acp::Error>; @@ -140,23 +140,41 @@ struct SubagentLiveSnapshotDto { error_count: u32, } -impl From<ResolvedRunningSubagent> for SubagentLiveSnapshotDto { - fn from(r: ResolvedRunningSubagent) -> Self { +impl From<SubagentInspection> for SubagentLiveSnapshotDto { + fn from(inspection: SubagentInspection) -> Self { + let SubagentInspection { + snapshot, + parent_session_id, + child_session_id, + .. + } = inspection; + let SubagentSnapshotStatus::Running { + turn_count, + tool_call_count, + tokens_used, + context_window_tokens, + context_usage_pct, + tools_used, + error_count, + } = snapshot.status + else { + unreachable!("list_running returns only active children"); + }; Self { - subagent_id: r.subagent_id, - parent_session_id: r.parent_session_id, - child_session_id: r.child_session_id, - subagent_type: r.subagent_type, - description: r.description, - started_at_epoch_ms: r.started_at_epoch_ms, - duration_ms: r.duration_ms, - turn_count: r.turn_count, - tool_call_count: r.tool_call_count, - tokens_used: r.tokens_used, - context_window_tokens: r.context_window_tokens, - context_usage_pct: r.context_usage_pct, - tools_used: r.tools_used, - error_count: r.error_count, + subagent_id: snapshot.subagent_id, + parent_session_id, + child_session_id, + subagent_type: snapshot.subagent_type, + description: snapshot.description, + started_at_epoch_ms: snapshot.started_at_epoch_ms, + duration_ms: snapshot.duration_ms, + turn_count, + tool_call_count, + tokens_used, + context_window_tokens, + context_usage_pct, + tools_used, + error_count, } } } @@ -238,7 +256,7 @@ impl SubagentSnapshotDto { snap: SubagentSnapshot, parent_session_id: String, child_session_id: String, - provenance: crate::agent::subagent::SubagentProvenance, + provenance: SubagentProvenance, ) -> Self { let mut dto = SubagentSnapshotDto { subagent_id: snap.subagent_id, @@ -392,7 +410,8 @@ pub async fn handle_subagent(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRes "x.ai/subagent/cancel" => { let req: CancelSubagentRequest = parse(args)?; tracing::info!(subagent_id = %req.subagent_id, "Cancelling subagent via ext method"); - let outcome = SubagentCancelOutcomeDto::from(agent.cancel_subagent(&req.subagent_id)); + let outcome = + SubagentCancelOutcomeDto::from(agent.cancel_subagent(&req.subagent_id).await); respond(Ok::<_, String>(CancelSubagentResponse { subagent_id: req.subagent_id, cancelled: outcome.cancelled_bool(), @@ -404,51 +423,38 @@ pub async fn handle_subagent(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRes let block = req.block.unwrap_or(false); let timeout_ms = req.timeout_ms.unwrap_or(30_000); - let ids = agent.session_ids_for_subagent(&req.subagent_id); - let (parent_sid, child_sid) = ids.unwrap_or_default(); - let provenance = agent.provenance_for_subagent(&req.subagent_id); - - let to_dto = |snap: SubagentSnapshot| { - SubagentSnapshotDto::from_snapshot( - snap, - parent_sid.clone(), - child_sid.clone(), - provenance.clone(), - ) - }; - - // Sync lookup, drop borrow, then resolve async. - let lookup = agent.lookup_subagent(&req.subagent_id); - let snapshot = crate::agent::subagent::resolve_snapshot(lookup).await; - - if block && snapshot.as_ref().is_some_and(is_running) { - // Poll every 200ms until done or timeout. - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - let lookup = agent.lookup_subagent(&req.subagent_id); - let snap = crate::agent::subagent::resolve_snapshot(lookup).await; - let still_running = snap.as_ref().is_some_and(is_running); - if !still_running || tokio::time::Instant::now() >= deadline { - return respond(Ok::<_, String>(GetSubagentResponse { - snapshot: snap.map(&to_dto), - })); - } - } - } else { - respond(Ok::<_, String>(GetSubagentResponse { - snapshot: snapshot.map(to_dto), - })) - } + let snapshot = agent + .query_subagent(&req.subagent_id, block, Some(timeout_ms)) + .await; + let inspection = agent.inspect_subagent(&req.subagent_id).await; + let (parent_session_id, child_session_id, provenance) = inspection + .map(|inspection| { + ( + inspection.parent_session_id, + inspection.child_session_id, + SubagentProvenance { + fork_parent_prompt_id: inspection.fork_parent_prompt_id, + resumed_from: inspection.resumed_from, + }, + ) + }) + .unwrap_or_default(); + respond(Ok::<_, String>(GetSubagentResponse { + snapshot: snapshot.map(|snapshot| { + SubagentSnapshotDto::from_snapshot( + snapshot, + parent_session_id, + child_session_id, + provenance, + ) + }), + })) } "x.ai/subagent/list_running" => { let req: ListRunningSubagentsRequest = parse(args)?; - // Sync: collect seeds from coordinator, drop borrow. - let seeds = agent.list_running_subagents(&req.session_id); - // Async: resolve live signals concurrently. - let resolved = resolve_running_list(seeds).await; - let subagents = resolved + let subagents = agent + .list_running_subagents(&req.session_id) + .await .into_iter() .map(SubagentLiveSnapshotDto::from) .collect(); @@ -517,21 +523,28 @@ mod tests { #[test] fn from_resolved_running_subagent_maps_all_fields() { - let resolved = ResolvedRunningSubagent { - subagent_id: "s".into(), + let resolved = SubagentInspection { + snapshot: SubagentSnapshot { + subagent_id: "s".into(), + subagent_type: "plan".into(), + description: "d".into(), + started_at_epoch_ms: 100, + duration_ms: 200, + persona: None, + status: SubagentSnapshotStatus::Running { + turn_count: 1, + tool_call_count: 3, + tokens_used: 500, + context_window_tokens: 1000, + context_usage_pct: 50, + tools_used: vec!["read_file".into()], + error_count: 0, + }, + }, parent_session_id: "p".into(), child_session_id: "c".into(), - subagent_type: "plan".into(), - description: "d".into(), - started_at_epoch_ms: 100, - duration_ms: 200, - turn_count: 1, - tool_call_count: 3, - tokens_used: 500, - context_window_tokens: 1000, - context_usage_pct: 50, - tools_used: vec!["read_file".into()], - error_count: 0, + fork_parent_prompt_id: None, + resumed_from: None, }; let dto = SubagentLiveSnapshotDto::from(resolved); assert_eq!(dto.subagent_id, "s"); @@ -782,146 +795,6 @@ mod tests { assert!(req.timeout_ms.is_none()); } - // ── Polling control-flow tests ────────────────────────────────────── - - #[test] - fn block_true_with_completed_snapshot_returns_immediately() { - // When block=true but the snapshot is already completed, - // the handler should NOT enter the polling loop. - let snap = SubagentSnapshot { - subagent_id: "sub-done".into(), - subagent_type: "explore".into(), - description: "d".into(), - started_at_epoch_ms: 0, - duration_ms: 100, - persona: None, - status: SubagentSnapshotStatus::Completed { - output: "done".into(), - tool_calls: 1, - turns: 1, - worktree_path: None, - }, - }; - // The handler's decision: `block && is_running(&snap)` → false - let block = true; - let should_poll = block && is_running(&snap); - assert!( - !should_poll, - "completed snapshot should not trigger polling loop" - ); - } - - #[test] - fn block_false_with_running_snapshot_skips_polling() { - let snap = SubagentSnapshot { - subagent_id: "sub-run".into(), - subagent_type: "explore".into(), - description: "d".into(), - started_at_epoch_ms: 0, - duration_ms: 100, - persona: None, - status: SubagentSnapshotStatus::Running { - turn_count: 1, - tool_call_count: 2, - tokens_used: 1000, - context_window_tokens: 256_000, - context_usage_pct: 1, - tools_used: vec![], - error_count: 0, - }, - }; - // The handler's decision: `block && is_running(&snap)` → false - let block = false; - let should_poll = block && is_running(&snap); - assert!( - !should_poll, - "block=false should not trigger polling loop even if running" - ); - } - - #[test] - fn block_true_with_running_snapshot_enters_polling() { - let snap = SubagentSnapshot { - subagent_id: "sub-run".into(), - subagent_type: "explore".into(), - description: "d".into(), - started_at_epoch_ms: 0, - duration_ms: 100, - persona: None, - status: SubagentSnapshotStatus::Running { - turn_count: 1, - tool_call_count: 2, - tokens_used: 1000, - context_window_tokens: 256_000, - context_usage_pct: 1, - tools_used: vec![], - error_count: 0, - }, - }; - // The handler's decision: `block && is_running(&snap)` → true - let block = true; - let should_poll = block && is_running(&snap); - assert!( - should_poll, - "block=true + running should trigger polling loop" - ); - } - - #[test] - fn polling_loop_exits_when_snapshot_transitions_to_completed() { - // Simulates the polling loop's exit condition when a snapshot - // transitions from running to completed between iterations. - let completed_snap = SubagentSnapshot { - subagent_id: "sub-1".into(), - subagent_type: "explore".into(), - description: "d".into(), - started_at_epoch_ms: 0, - duration_ms: 500, - persona: None, - status: SubagentSnapshotStatus::Completed { - output: "found it".into(), - tool_calls: 3, - turns: 1, - worktree_path: None, - }, - }; - // The polling loop checks: `!is_running(&snap) || deadline_passed` - // When the snapshot becomes completed, `!is_running` is true → exits. - assert!( - !is_running(&completed_snap), - "completed snapshot should cause polling loop exit" - ); - } - - #[test] - fn polling_loop_exits_on_deadline_even_if_still_running() { - let running_snap = SubagentSnapshot { - subagent_id: "sub-1".into(), - subagent_type: "explore".into(), - description: "d".into(), - started_at_epoch_ms: 0, - duration_ms: 100, - persona: None, - status: SubagentSnapshotStatus::Running { - turn_count: 1, - tool_call_count: 1, - tokens_used: 1000, - context_window_tokens: 256_000, - context_usage_pct: 1, - tools_used: vec![], - error_count: 0, - }, - }; - // Simulate: deadline has passed, but snapshot is still running. - // The polling loop checks: `!is_running(&snap) || deadline_passed` - let deadline_passed = true; - let should_exit = !is_running(&running_snap) || deadline_passed; - assert!( - should_exit, - "deadline expiry should cause polling loop exit even if still running" - ); - } - #[test] fn snapshot_dto_resumed_provenance_serializes() { let snap = SubagentSnapshot { @@ -941,7 +814,7 @@ mod tests { error_count: 0, }, }; - let provenance = crate::agent::subagent::SubagentProvenance { + let provenance = SubagentProvenance { fork_parent_prompt_id: Some("prompt-5".into()), resumed_from: Some("source-agent-id".into()), }; diff --git a/crates/codegen/xai-grok-shell/src/inspect/mod.rs b/crates/codegen/xai-grok-shell/src/inspect/mod.rs index 1add6f8..e1595f1 100644 --- a/crates/codegen/xai-grok-shell/src/inspect/mod.rs +++ b/crates/codegen/xai-grok-shell/src/inspect/mod.rs @@ -520,7 +520,7 @@ async fn list_instructions(cwd: &Path) -> Vec<InstructionFile> { // have this limitation; rules need the same treatment in a follow-up. let extra_rule_prefixes: Vec<std::path::PathBuf> = extra_rule_dirs .iter() - .map(|d| crate::claude_import::expand_home(d)) + .map(|d| crate::util::expand_home(d)) .collect(); configs diff --git a/crates/codegen/xai-grok-shell/src/managed_config.rs b/crates/codegen/xai-grok-shell/src/managed_config.rs index 7120e43..c876da7 100644 --- a/crates/codegen/xai-grok-shell/src/managed_config.rs +++ b/crates/codegen/xai-grok-shell/src/managed_config.rs @@ -137,6 +137,12 @@ fn team_principal_signed_in() -> std::io::Result<bool> { /// configured and no team signed in (logout). A configured deployment key keeps /// its files (original "never auto-deletes" behavior). Runs at startup and on /// logout; best-effort. +/// +/// **fail_closed:** when the marker or on-disk requirements opt in to fail-closed +/// (or requirements exist but are unreadable), do **not** wipe. A personal/User +/// principal (or signed-out auth) must not escape enforced policy by swapping +/// `auth.json` and letting orphan clear delete the artifacts. Non-fail-closed +/// team policy still clears on logout as before. pub fn clear_orphan() { if resolve_deployment_key().is_some() { return; @@ -153,6 +159,12 @@ pub fn clear_orphan() { let Some(_lock) = try_lock_managed_config(&home) else { return; // another process is syncing; retry next call }; + if xai_grok_config::fail_closed_policy_armed_at(&home) { + tracing::info!( + "keeping fail_closed managed policy on disk; no team principal present to own a clear" + ); + return; + } remove_managed_config_files(&home); } diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs index 30e8546..de4a2f7 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs @@ -642,6 +642,9 @@ impl SessionActor { let spawner: std::sync::Arc<dyn crate::session::goal_classifier::GoalClassifierSpawner> = std::sync::Arc::new(ChannelSpawner { event_tx, + foreground_wait: Some(crate::tools::tool_context::subagent_foreground_wait( + self.tool_context.blocking_wait_depth.clone(), + )), parent_session_id: self.session_id_string(), parent_prompt_id, cwd: Some(self.tool_context.cwd.as_str().to_owned()), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal_support.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal_support.rs index 1db0efc..3ff97af 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal_support.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal_support.rs @@ -1112,6 +1112,9 @@ impl SessionActor { let spawner: std::sync::Arc<dyn crate::session::goal_planner::GoalPlannerSpawner> = std::sync::Arc::new(crate::session::goal_planner::ChannelSpawner { event_tx, + foreground_wait: Some(crate::tools::tool_context::subagent_foreground_wait( + self.tool_context.blocking_wait_depth.clone(), + )), parent_session_id: self.session_id_string(), parent_prompt_id, cwd: Some(self.tool_context.cwd.as_str().to_owned()), @@ -1281,6 +1284,9 @@ impl SessionActor { let spawner: std::sync::Arc<dyn crate::session::goal_strategist::GoalStrategistSpawner> = std::sync::Arc::new(crate::session::goal_strategist::ChannelSpawner { event_tx, + foreground_wait: Some(crate::tools::tool_context::subagent_foreground_wait( + self.tool_context.blocking_wait_depth.clone(), + )), parent_session_id: self.session_id_string(), parent_prompt_id, cwd: Some(self.tool_context.cwd.as_str().to_owned()), @@ -1383,6 +1389,9 @@ impl SessionActor { let spawner: std::sync::Arc<dyn crate::session::goal_summarizer::GoalSummarizerSpawner> = std::sync::Arc::new(crate::session::goal_summarizer::ChannelSpawner { event_tx, + foreground_wait: Some(crate::tools::tool_context::subagent_foreground_wait( + self.tool_context.blocking_wait_depth.clone(), + )), parent_session_id: self.session_id_string(), parent_prompt_id, cwd: Some(self.tool_context.cwd.as_str().to_owned()), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs index 510ecc1..c2c2203 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hook_dispatch.rs @@ -376,6 +376,7 @@ mod notification_hook_filter_tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }, will_wake: false, }; diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs index 5a0e39b..e6e5d2a 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs @@ -54,6 +54,8 @@ impl SessionActor { top_p: sampling_config.top_p, api_backend: sampling_config.api_backend.clone(), extra_headers: sampling_config.extra_headers.clone(), + query_params: sampling_config.query_params.clone(), + env_http_headers: sampling_config.env_http_headers.clone(), context_window: new_context_window, reasoning_effort: sampling_config.reasoning_effort, stream_tool_calls: Some(sampling_config.stream_tool_calls), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs index d5206c8..f121cb9 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs @@ -618,10 +618,11 @@ impl SessionActor { .as_ref() .map(|reservations| reservations.snapshot()) .unwrap_or_default(); + let parent_session_id = Some(self.session_id_string()); let (respond_to, rx) = tokio::sync::oneshot::channel(); if tx .send(SubagentEvent::Completions(SubagentCompletionsRequest { - session_id: self.session_info.id.0.to_string(), + parent_session_id, suppress_ids, respond_to, })) diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs index 67b8caf..dd23e82 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs @@ -432,6 +432,8 @@ impl SessionActor { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(256_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -443,6 +445,9 @@ impl SessionActor { SessionTokenAuthGate::new(auth_method.as_deref(), model_facts.byok, &cfg.base_url); let use_bearer_resolver = gate.active(); self.log_auth_gate_unknown("reconstruct_full_config", gate, &cfg.base_url); + if use_bearer_resolver && let Some(am) = self.auth_manager.as_ref() { + let _ = am.auth().await; + } let auth_scheme = model_facts.auth_scheme; let mut extra_headers = cfg.extra_headers; crate::agent::config::inject_url_derived_headers( @@ -484,6 +489,8 @@ impl SessionActor { api_backend: cfg.api_backend, auth_scheme, extra_headers, + query_params: cfg.query_params.clone(), + env_http_headers: cfg.env_http_headers.clone(), context_window: cfg.context_window.get(), client_version: creds.client_version, reasoning_effort: cfg.reasoning_effort, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs index cc27199..ca4845c 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs @@ -419,6 +419,8 @@ pub(crate) async fn spawn_session_actor( top_p: sampling_config.top_p, api_backend: sampling_config.api_backend.clone(), extra_headers: sampling_config.extra_headers.clone(), + query_params: sampling_config.query_params.clone(), + env_http_headers: sampling_config.env_http_headers.clone(), context_window: context_window_override.unwrap_or(baseline_context_window), reasoning_effort: sampling_config.reasoning_effort, stream_tool_calls: Some(sampling_config.stream_tool_calls), @@ -548,19 +550,6 @@ pub(crate) async fn spawn_session_actor( }, ); let tool_context_for_handle = tool_context.clone(); - let resolve_search_shadows = || { - let user_cfg = crate::config::load_effective_config().ok(); - let requirements = crate::config::load_merged_requirements(); - let (find_bfs, grep_ugrep) = crate::util::config::resolve_search_tools_enabled( - requirements.as_ref(), - user_cfg.as_ref(), - None, - ); - xai_grok_tools::computer::local::SearchShadowConfig { - find_bfs, - grep_ugrep, - } - }; let cursor_harness = false; let terminal_backend_kind = select_terminal_backend_kind( startup_hints.is_subagent, @@ -569,6 +558,25 @@ pub(crate) async fn spawn_session_actor( tool_context.gateway.is_some(), cursor_harness, ); + let effective_cfg = matches!( + terminal_backend_kind, + TerminalBackendKind::LocalPersistent | TerminalBackendKind::LocalNonPersistent + ) + .then(crate::config::load_effective_config) + .and_then(Result::ok); + let resolve_search_shadows = || { + let requirements = crate::config::load_merged_requirements(); + let (find_bfs, grep_ugrep) = crate::util::config::resolve_search_tools_enabled( + requirements.as_ref(), + effective_cfg.as_ref(), + None, + ); + xai_grok_tools::computer::local::SearchShadowConfig { + find_bfs, + grep_ugrep, + } + }; + let resolve_policy = || crate::util::config::resolve_shell_env_policy(effective_cfg.as_ref()); let terminal_backend: std::sync::Arc<dyn xai_grok_tools::computer::types::TerminalBackend> = match terminal_backend_kind { TerminalBackendKind::ReuseParent => parent_terminal_backend @@ -580,9 +588,12 @@ pub(crate) async fn spawn_session_actor( )) as std::sync::Arc<dyn xai_grok_tools::computer::types::TerminalBackend> } - TerminalBackendKind::LocalPersistent => std::sync::Arc::new( - LocalTerminalBackend::new_local_with_persistent_shell(resolve_search_shadows()), - ), + TerminalBackendKind::LocalPersistent => { + std::sync::Arc::new(LocalTerminalBackend::new_local_with_persistent_shell( + resolve_search_shadows(), + resolve_policy(), + )) + } TerminalBackendKind::LocalNonPersistent => { let login_shell_capture = crate::util::config::resolve_login_shell_capture( remote_settings.as_ref().and_then(|r| r.login_shell_capture), @@ -590,6 +601,7 @@ pub(crate) async fn spawn_session_actor( std::sync::Arc::new(LocalTerminalBackend::new_local_with_login_shell_capture( resolve_search_shadows(), login_shell_capture, + resolve_policy(), )) } }; @@ -887,6 +899,7 @@ pub(crate) async fn spawn_session_actor( user_question_tx: user_question_tx.clone(), subagent_depth: tool_context.subagent_depth, session_id_str: session_info.id.0.to_string(), + blocking_wait_depth: tool_context.blocking_wait_depth.clone(), respect_gitignore, path_not_found_hints, scheduler_background_loops: crate::util::config::resolve_scheduler_background_loops( diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs index c15b23c..435a5e9 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/stop_gate.rs @@ -387,6 +387,7 @@ mod stop_gate_snapshot_tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs index f801ce9..22e1536 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs @@ -195,6 +195,7 @@ impl SessionActor { SubagentCancelRequest, SubagentCancelTarget, SubagentEvent, }; let _ = event_tx.send(SubagentEvent::Cancel(SubagentCancelRequest { + parent_session_id: Some(self.session_id_string()), target: SubagentCancelTarget::ParentPromptId(parent_prompt_id.to_string()), respond_to: tokio::sync::oneshot::channel().0, })); diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs index eecdee9..baeb56d 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs @@ -1345,6 +1345,7 @@ impl SessionActor { if tx .send(SubagentEvent::MarkUsageNotApplied( SubagentMarkUsageNotAppliedRequest { + parent_session_id: self.session_id_string(), prompt_id: pid, respond_to, }, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs index d941aec..8a09ab5 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs @@ -134,6 +134,7 @@ impl SessionActor { let (respond_to, rx) = tokio::sync::oneshot::channel(); if tx .send(SubagentEvent::Outstanding(SubagentOutstandingRequest { + parent_session_id: self.session_id_string(), prompt_id: prompt_id.to_string(), respond_to, })) @@ -163,6 +164,7 @@ impl SessionActor { }; let _ = tx.send(SubagentEvent::ClearUsageNotApplied( SubagentClearUsageNotAppliedRequest { + parent_session_id: self.session_id_string(), prompt_id: prompt_id.to_string(), }, )); diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs index 0875209..4d03f64 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs @@ -1033,6 +1033,8 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() { api_backend: crate::sampling::ApiBackend::ChatCompletions, auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 256_000, client_version: None, force_http1: false, @@ -1124,6 +1126,8 @@ async fn switch_to_first_party_model_drops_minted_provider_token() { api_backend: crate::sampling::ApiBackend::ChatCompletions, auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 256_000, client_version: None, force_http1: false, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs index fc89dbf..6a0a07b 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs @@ -513,6 +513,7 @@ async fn genuine_user_start_consumes_deferred_completions_without_notification_t block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }, Some("get_command_or_subagent_output"), ); @@ -1528,7 +1529,6 @@ async fn between_turn_drain_suppresses_reserved_subagents() { *captured_task.lock().unwrap() = req.suppress_ids.clone(); let mk = |id: &str| SubagentCompletionSummary { subagent_id: id.into(), - owner_session_id: String::new(), subagent_type: "general-purpose".into(), description: format!("desc {id}"), success: true, @@ -1666,6 +1666,7 @@ fn completed_bash_task(id: &str) -> xai_grok_tools::computer::types::TaskSnapsho block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } /// Real-actor coverage for the `SessionCommand::IsBusy` predicate diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/between_turn_completion_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/between_turn_completion_tests.rs index d024c06..bb68a4d 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/between_turn_completion_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/between_turn_completion_tests.rs @@ -11,7 +11,6 @@ fn summary( ) -> SubagentCompletionSummary { SubagentCompletionSummary { subagent_id: id.into(), - owner_session_id: String::new(), subagent_type: typ.into(), description: desc.into(), success, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index 5052cd7..3b7bd4e 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -48,6 +48,8 @@ async fn persist_ack_waits_for_disk_flush_before_success() { api_backend: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 100_000, client_version: None, force_http1: false, @@ -94,6 +96,8 @@ async fn persist_ack_waits_for_disk_flush_before_success() { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(100_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -350,6 +354,8 @@ async fn first_turn_memory_injection_persists_to_chat_history() { model: "test-model".to_string(), max_completion_tokens: None, extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), temperature: None, top_p: None, api_backend: Default::default(), @@ -403,6 +409,8 @@ async fn first_turn_memory_injection_persists_to_chat_history() { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(100_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -480,6 +488,8 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() model: "test-model".to_string(), max_completion_tokens: None, extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), temperature: None, top_p: None, api_backend: Default::default(), @@ -535,6 +545,8 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(100_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, @@ -2020,6 +2032,8 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { api_backend: xai_grok_sampler::ApiBackend::Responses, auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 100_000, client_version: None, force_http1: false, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/fs_injection_regression_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/fs_injection_regression_tests.rs index 0cf552f..83a9963 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/fs_injection_regression_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/fs_injection_regression_tests.rs @@ -51,6 +51,7 @@ async fn tool_bridge_routes_writes_through_injected_fs() { session_env: std::sync::Arc::new(std::collections::HashMap::new()), notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: std::env::temp_dir().join("grok-test-fs/tool_state.json"), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs index 31ba934..904dad3 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs @@ -110,6 +110,8 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index f8f3708..14543dd 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -54,6 +54,8 @@ async fn create_test_actor( top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, @@ -491,6 +493,8 @@ async fn create_test_actor_with_memory( top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, @@ -1254,6 +1258,8 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs index 81a6ef5..48707b4 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -104,6 +104,8 @@ async fn create_test_actor_with_memory( top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs index b5b9c71..a8bfbe6 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs @@ -121,6 +121,7 @@ async fn test_agent_from_config( session_env: std::sync::Arc::new(std::collections::HashMap::new()), notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: std::path::PathBuf::from("/tmp/tool_state.json"), @@ -219,6 +220,8 @@ pub(crate) async fn create_test_actor_ex( top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/web_search_e2e_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/web_search_e2e_tests.rs index 44e5ced..aa34f8b 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/web_search_e2e_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/web_search_e2e_tests.rs @@ -94,6 +94,7 @@ async fn web_search_uses_model_override_from_config_end_to_end() { session_env: std::sync::Arc::new(std::collections::HashMap::new()), notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: std::env::temp_dir().join("grok-web-search-e2e/state.json"), @@ -173,6 +174,7 @@ async fn web_search_errors_when_configured_model_cannot_be_resolved() { session_env: std::sync::Arc::new(std::collections::HashMap::new()), notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: std::env::temp_dir().join("grok-web-search-disabled/state.json"), diff --git a/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs b/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs index 884960a..706463e 100644 --- a/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs +++ b/crates/codegen/xai-grok-shell/src/session/agent_rebuild.rs @@ -123,6 +123,7 @@ pub(crate) struct AgentRebuildSpec { pub user_question_tx: UnboundedSender<UserQuestionRequest>, pub subagent_depth: u32, pub session_id_str: String, + pub blocking_wait_depth: Arc<crate::tools::tool_context::BlockingWaitState>, pub respect_gitignore: bool, pub path_not_found_hints: bool, pub scheduler_background_loops: bool, @@ -219,6 +220,7 @@ impl AgentRebuildSpec { user_question_tx, subagent_depth, session_id_str, + blocking_wait_depth, respect_gitignore, path_not_found_hints, scheduler_background_loops, @@ -327,7 +329,10 @@ impl AgentRebuildSpec { use xai_grok_tools::implementations::grok_build::task::types::{ SessionIdResource, SubagentDepthCounter, SubagentEventSender, }; - let backend = SubagentBackendResource(Arc::new(ChannelBackend::new(event_tx.clone()))); + let backend = SubagentBackendResource(Arc::new(ChannelBackend::for_session( + event_tx.clone(), + session_id_str.clone(), + ))); agent.tool_bridge().update_resource(backend).await; agent .tool_bridge() @@ -341,6 +346,12 @@ impl AgentRebuildSpec { .tool_bridge() .update_resource(SubagentEventSender(event_tx)) .await; + agent + .tool_bridge() + .update_resource(crate::tools::tool_context::subagent_foreground_wait( + Arc::clone(blocking_wait_depth), + )) + .await; if let Some(buffer) = monitor_event_buffer.clone() { agent.tool_bridge().update_resource(buffer).await; } @@ -430,6 +441,7 @@ pub(crate) fn test_rebuild_spec_default() -> Arc<AgentRebuildSpec> { user_question_tx: uq_tx, subagent_depth: 0, session_id_str: "test-session".to_string(), + blocking_wait_depth: Arc::new(crate::tools::tool_context::BlockingWaitState::new()), respect_gitignore: false, scheduler_background_loops: true, path_not_found_hints: false, diff --git a/crates/codegen/xai-grok-shell/src/session/compaction.rs b/crates/codegen/xai-grok-shell/src/session/compaction.rs index fb1b7a8..3fac8e5 100644 --- a/crates/codegen/xai-grok-shell/src/session/compaction.rs +++ b/crates/codegen/xai-grok-shell/src/session/compaction.rs @@ -2266,6 +2266,8 @@ mod inline_auto_compact_flow_tests { top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, diff --git a/crates/codegen/xai-grok-shell/src/session/goal_classifier.rs b/crates/codegen/xai-grok-shell/src/session/goal_classifier.rs index e7a2403..e81ff3a 100644 --- a/crates/codegen/xai-grok-shell/src/session/goal_classifier.rs +++ b/crates/codegen/xai-grok-shell/src/session/goal_classifier.rs @@ -16,7 +16,8 @@ pub(crate) mod evidence; use crate::session::events::{Event, GoalClassifierFailOpenReason}; use crate::session::goal_planner::{ - GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, RoleSpawnOverride, spawn_with_fail_open_retry, + GOAL_ROLE_AWAIT_BUDGET_EXCEEDED, GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, + RoleSpawnOverride, spawn_with_fail_open_retry, }; use crate::session::goal_role_tools::RoleToolNames; use crate::session::goal_tracker::GoalClassifierVerdict; @@ -25,7 +26,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use xai_file_utils::events::EventWriter; -use xai_grok_tools::implementations::grok_build::task::types::SubagentOwner; +use xai_grok_tools::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend}; +use xai_grok_tools::implementations::grok_build::task::types::{ + SubagentOwner, SubagentRequest, SubagentRuntimeOverrides, +}; // Constants @@ -512,6 +516,8 @@ pub(crate) struct ChannelSpawner { pub(crate) event_tx: tokio::sync::mpsc::UnboundedSender< xai_grok_tools::implementations::grok_build::task::types::SubagentEvent, >, + pub(crate) foreground_wait: + Option<xai_grok_tools::implementations::grok_build::task::types::SubagentForegroundWait>, pub(crate) parent_session_id: String, pub(crate) parent_prompt_id: Option<String>, pub(crate) cwd: Option<String>, @@ -595,10 +601,6 @@ impl ChannelSpawner { harness_agent_type: Option<String>, resume_from: Option<&str>, ) -> Result<String, SpawnError> { - use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentEvent, SubagentRequest, SubagentRuntimeOverrides, - }; - let (result_tx, result_rx) = tokio::sync::oneshot::channel(); let request = SubagentRequest { id: id.to_string(), prompt, @@ -620,20 +622,19 @@ impl ChannelSpawner { fork_context: false, owner: SubagentOwner::Task, cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx, }; - if self - .event_tx - .send(SubagentEvent::Spawn(Box::new(request))) - .is_err() - { - return Err(SpawnError::Transport( - "subagent coordinator channel closed".to_string(), - )); - } - let result = result_rx + let backend = ChannelBackend::new(self.event_tx.clone()); + let result = backend + .spawn_with_foreground_wait(request, self.foreground_wait.as_ref()) .await - .map_err(|_| SpawnError::Transport("subagent result channel dropped".to_string()))?; + .map_err(|error| SpawnError::Transport(error.to_string()))?; + if result.backgrounded { + let _ = backend.cancel(&result.subagent_id).await; + return Err(SpawnError::Runtime { + message: GOAL_ROLE_AWAIT_BUDGET_EXCEEDED.to_owned(), + cancelled: true, + }); + } if !result.success { let message = result.error.unwrap_or_else(|| "unknown error".to_string()); return Err(SpawnError::Runtime { @@ -2457,6 +2458,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -2507,6 +2509,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -2577,6 +2580,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -5872,6 +5876,7 @@ mod tests { let spawner: Arc<dyn GoalClassifierSpawner> = Arc::new(ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -6245,6 +6250,7 @@ mod tests { let spawner = ChannelSpawner { event_tx, + foreground_wait: None, parent_session_id: "parent-session".into(), parent_prompt_id: None, cwd: None, diff --git a/crates/codegen/xai-grok-shell/src/session/goal_planner.rs b/crates/codegen/xai-grok-shell/src/session/goal_planner.rs index 822aada..43f368b 100644 --- a/crates/codegen/xai-grok-shell/src/session/goal_planner.rs +++ b/crates/codegen/xai-grok-shell/src/session/goal_planner.rs @@ -11,7 +11,10 @@ use crate::session::goal_role_tools::RoleToolNames; use std::path::{Path, PathBuf}; use std::sync::Arc; use xai_file_utils::events::EventWriter; -use xai_grok_tools::implementations::grok_build::task::types::SubagentOwner; +use xai_grok_tools::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend}; +use xai_grok_tools::implementations::grok_build::task::types::{ + SubagentOwner, SubagentRequest, SubagentRuntimeOverrides, +}; // Shared per-role model override + spawn-and-retry-once fail-open wrapper @@ -26,6 +29,8 @@ use xai_grok_tools::implementations::grok_build::task::types::SubagentOwner; /// /// [`SubagentRuntimeOverrides::harness_agent_type`]: xai_grok_tools::implementations::grok_build::task::types::SubagentRuntimeOverrides::harness_agent_type pub(crate) const GOAL_ROLE_SUBAGENT_TYPE: &str = "general-purpose"; +pub(crate) const GOAL_ROLE_AWAIT_BUDGET_EXCEEDED: &str = + "goal role subagent exceeded foreground wait budget"; /// Resolved per-role spawn override. /// @@ -259,6 +264,8 @@ pub(crate) struct ChannelSpawner { pub(crate) event_tx: tokio::sync::mpsc::UnboundedSender< xai_grok_tools::implementations::grok_build::task::types::SubagentEvent, >, + pub(crate) foreground_wait: + Option<xai_grok_tools::implementations::grok_build::task::types::SubagentForegroundWait>, pub(crate) parent_session_id: String, pub(crate) parent_prompt_id: Option<String>, pub(crate) cwd: Option<String>, @@ -333,10 +340,6 @@ impl ChannelSpawner { model: Option<String>, harness_agent_type: Option<String>, ) -> Result<String, SpawnError> { - use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentEvent, SubagentRequest, SubagentRuntimeOverrides, - }; - let (result_tx, result_rx) = tokio::sync::oneshot::channel(); let request = SubagentRequest { id: id.to_string(), prompt, @@ -358,20 +361,19 @@ impl ChannelSpawner { fork_context: true, owner: SubagentOwner::Task, cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx, }; - if self - .event_tx - .send(SubagentEvent::Spawn(Box::new(request))) - .is_err() - { - return Err(SpawnError::Transport( - "subagent coordinator channel closed".to_string(), - )); - } - let result = result_rx + let backend = ChannelBackend::new(self.event_tx.clone()); + let result = backend + .spawn_with_foreground_wait(request, self.foreground_wait.as_ref()) .await - .map_err(|_| SpawnError::Transport("subagent result channel dropped".to_string()))?; + .map_err(|error| SpawnError::Transport(error.to_string()))?; + if result.backgrounded { + let _ = backend.cancel(&result.subagent_id).await; + return Err(SpawnError::Runtime { + message: GOAL_ROLE_AWAIT_BUDGET_EXCEEDED.to_owned(), + cancelled: true, + }); + } if !result.success { let message = result.error.unwrap_or_else(|| "unknown error".to_string()); return Err(SpawnError::Runtime { @@ -639,8 +641,12 @@ mod tests { }; let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let wait_depth = Arc::new(crate::tools::tool_context::BlockingWaitState::new()); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: Some(crate::tools::tool_context::subagent_foreground_wait( + Arc::clone(&wait_depth), + )), parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -657,12 +663,14 @@ mod tests { let SubagentEvent::Spawn(request) = rx.recv().await.expect("spawn event") else { panic!("expected Spawn"); }; + assert_eq!(wait_depth.depth(), 1); assert!( !request.surface_completion, "planner subagent must not surface to the idle reminder" ); let _ = request.result_tx.send(SubagentResult::default()); handle.await.unwrap(); + assert_eq!(wait_depth.depth(), 0); } #[test] @@ -1243,6 +1251,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -1568,6 +1577,7 @@ mod tests { }); let spawner = Arc::new(ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "p".into(), parent_prompt_id: None, cwd: None, @@ -1632,6 +1642,7 @@ mod tests { }); let spawner = Arc::new(ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "p".into(), parent_prompt_id: None, cwd: None, diff --git a/crates/codegen/xai-grok-shell/src/session/goal_strategist.rs b/crates/codegen/xai-grok-shell/src/session/goal_strategist.rs index 7af5f0f..6804e78 100644 --- a/crates/codegen/xai-grok-shell/src/session/goal_strategist.rs +++ b/crates/codegen/xai-grok-shell/src/session/goal_strategist.rs @@ -22,14 +22,17 @@ use crate::session::events::{Event, GoalStrategistFailReason, GoalStrategistRestoreFailReason}; use crate::session::goal_planner::{ - GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, RoleSpawnOverride, SpawnError, - parse_terminal_response, spawn_with_fail_open_retry, + GOAL_ROLE_AWAIT_BUDGET_EXCEEDED, GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, + RoleSpawnOverride, SpawnError, parse_terminal_response, spawn_with_fail_open_retry, }; use crate::session::goal_role_tools::RoleToolNames; use std::path::{Path, PathBuf}; use std::sync::Arc; use xai_file_utils::events::EventWriter; -use xai_grok_tools::implementations::grok_build::task::types::SubagentOwner; +use xai_grok_tools::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend}; +use xai_grok_tools::implementations::grok_build::task::types::{ + SubagentOwner, SubagentRequest, SubagentRuntimeOverrides, +}; // Constants @@ -110,6 +113,8 @@ pub(crate) struct ChannelSpawner { pub(crate) event_tx: tokio::sync::mpsc::UnboundedSender< xai_grok_tools::implementations::grok_build::task::types::SubagentEvent, >, + pub(crate) foreground_wait: + Option<xai_grok_tools::implementations::grok_build::task::types::SubagentForegroundWait>, pub(crate) parent_session_id: String, pub(crate) parent_prompt_id: Option<String>, pub(crate) cwd: Option<String>, @@ -182,10 +187,6 @@ impl ChannelSpawner { model: Option<String>, harness_agent_type: Option<String>, ) -> Result<String, SpawnError> { - use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentEvent, SubagentRequest, SubagentRuntimeOverrides, - }; - let (result_tx, result_rx) = tokio::sync::oneshot::channel(); let request = SubagentRequest { id: id.to_string(), prompt, @@ -207,20 +208,19 @@ impl ChannelSpawner { fork_context: false, owner: SubagentOwner::Task, cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx, }; - if self - .event_tx - .send(SubagentEvent::Spawn(Box::new(request))) - .is_err() - { - return Err(SpawnError::Transport( - "subagent coordinator channel closed".to_string(), - )); - } - let result = result_rx + let backend = ChannelBackend::new(self.event_tx.clone()); + let result = backend + .spawn_with_foreground_wait(request, self.foreground_wait.as_ref()) .await - .map_err(|_| SpawnError::Transport("subagent result channel dropped".to_string()))?; + .map_err(|error| SpawnError::Transport(error.to_string()))?; + if result.backgrounded { + let _ = backend.cancel(&result.subagent_id).await; + return Err(SpawnError::Runtime { + message: GOAL_ROLE_AWAIT_BUDGET_EXCEEDED.to_owned(), + cancelled: true, + }); + } if !result.success { let message = result.error.unwrap_or_else(|| "unknown error".to_string()); return Err(SpawnError::Runtime { @@ -581,6 +581,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, @@ -622,6 +623,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, diff --git a/crates/codegen/xai-grok-shell/src/session/goal_summarizer.rs b/crates/codegen/xai-grok-shell/src/session/goal_summarizer.rs index da9cd7d..d47085a 100644 --- a/crates/codegen/xai-grok-shell/src/session/goal_summarizer.rs +++ b/crates/codegen/xai-grok-shell/src/session/goal_summarizer.rs @@ -14,14 +14,18 @@ use crate::session::events::{Event, GoalSummarizerFailReason}; use crate::session::goal_planner::{ - GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, RoleSpawnOverride, SpawnError, - spawn_with_fail_open_retry, + GOAL_ROLE_AWAIT_BUDGET_EXCEEDED, GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, + RoleSpawnOverride, SpawnError, spawn_with_fail_open_retry, }; use crate::session::goal_role_tools::RoleToolNames; use std::path::Path; use std::sync::Arc; use xai_file_utils::events::EventWriter; -use xai_grok_tools::implementations::grok_build::task::types::SubagentOwner; +use xai_grok_tools::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend}; +use xai_grok_tools::implementations::grok_build::task::types::{ + SubagentOwner, SubagentRequest, SubagentRuntimeOverrides, +}; +use xai_tool_types::SubagentCapabilityMode; // Constants @@ -85,6 +89,8 @@ pub(crate) struct ChannelSpawner { pub(crate) event_tx: tokio::sync::mpsc::UnboundedSender< xai_grok_tools::implementations::grok_build::task::types::SubagentEvent, >, + pub(crate) foreground_wait: + Option<xai_grok_tools::implementations::grok_build::task::types::SubagentForegroundWait>, pub(crate) parent_session_id: String, pub(crate) parent_prompt_id: Option<String>, pub(crate) cwd: Option<String>, @@ -156,11 +162,6 @@ impl ChannelSpawner { model: Option<String>, harness_agent_type: Option<String>, ) -> Result<String, SpawnError> { - use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentEvent, SubagentRequest, SubagentRuntimeOverrides, - }; - use xai_tool_types::SubagentCapabilityMode; - let (result_tx, result_rx) = tokio::sync::oneshot::channel(); let request = SubagentRequest { id: id.to_string(), prompt, @@ -183,20 +184,19 @@ impl ChannelSpawner { fork_context: false, owner: SubagentOwner::Task, cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx, }; - if self - .event_tx - .send(SubagentEvent::Spawn(Box::new(request))) - .is_err() - { - return Err(SpawnError::Transport( - "subagent coordinator channel closed".to_string(), - )); - } - let result = result_rx + let backend = ChannelBackend::new(self.event_tx.clone()); + let result = backend + .spawn_with_foreground_wait(request, self.foreground_wait.as_ref()) .await - .map_err(|_| SpawnError::Transport("subagent result channel dropped".to_string()))?; + .map_err(|error| SpawnError::Transport(error.to_string()))?; + if result.backgrounded { + let _ = backend.cancel(&result.subagent_id).await; + return Err(SpawnError::Runtime { + message: GOAL_ROLE_AWAIT_BUDGET_EXCEEDED.to_owned(), + cancelled: true, + }); + } if !result.success { let message = result.error.unwrap_or_else(|| "unknown error".to_string()); return Err(SpawnError::Runtime { @@ -642,6 +642,7 @@ mod tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let spawner = ChannelSpawner { event_tx: tx, + foreground_wait: None, parent_session_id: "parent".into(), parent_prompt_id: None, cwd: None, diff --git a/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs b/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs index 8c2d376..9db539c 100644 --- a/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs +++ b/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs @@ -1636,6 +1636,8 @@ mod reasoning_compaction_regression_tests { api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 256_000, client_version: None, force_http1: false, diff --git a/crates/codegen/xai-grok-shell/src/session/persistence.rs b/crates/codegen/xai-grok-shell/src/session/persistence.rs index 368cc95..0049f5a 100644 --- a/crates/codegen/xai-grok-shell/src/session/persistence.rs +++ b/crates/codegen/xai-grok-shell/src/session/persistence.rs @@ -722,6 +722,30 @@ fn most_recent_local_summary_for_cwd_in_view( Ok(best) } +/// Sync, local-only session summaries for `cwd` (hidden sessions filtered). +/// For startup paths that must resolve a resume target before the +/// irreversible OS sandbox is applied; async callers use [`list_summaries`]. +/// +/// Listing failures propagate so pre-sandbox callers can fail closed; +/// individual unreadable summaries are skipped, matching the async path's +/// tolerance for a single corrupt file. +pub fn local_summaries_for_cwd_sync(cwd: &str) -> io::Result<Vec<Summary>> { + local_summaries_for_cwd_sync_in_root(cwd, &grok_home().join("sessions")) +} + +fn local_summaries_for_cwd_sync_in_root( + cwd: &str, + sessions_root: &Path, +) -> io::Result<Vec<Summary>> { + let view = storage_view(sessions_root).map_err(io::Error::other)?; + let dirs = view.session_dirs(Some(cwd)).map_err(io::Error::other)?; + Ok(dirs + .iter() + .filter_map(|dir| read_summary_from_dir(dir).ok()) + .filter(|s| !s.is_hidden()) + .collect()) +} + /// Best-effort lookup of the sandbox profile persisted with a session that is /// about to be resumed, used at startup to restore the session's profile before /// the (irreversible) OS sandbox is applied. diff --git a/crates/codegen/xai-grok-shell/src/session/slash_commands.rs b/crates/codegen/xai-grok-shell/src/session/slash_commands.rs index ab0efdb..1ac7516 100644 --- a/crates/codegen/xai-grok-shell/src/session/slash_commands.rs +++ b/crates/codegen/xai-grok-shell/src/session/slash_commands.rs @@ -507,6 +507,7 @@ impl<'a> EffectiveCommandCatalog<'a> { "model", "multiline", "new", + "onboarding", "personas", "plan", "plan-view", @@ -538,7 +539,9 @@ impl<'a> EffectiveCommandCatalog<'a> { "timestamps", "title", "toggle-mouse-reporting", + "tour", "transcript", + "tutorial", "t", "usage", "view-plan", diff --git a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs index 90dd086..1743c2c 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/tests.rs @@ -3496,6 +3496,8 @@ async fn retry_after_lost_ack_converges_memory_and_disk_to_authoritative_item() top_p: None, api_backend: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: std::num::NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, diff --git a/crates/codegen/xai-grok-shell/src/session/wire_tags.rs b/crates/codegen/xai-grok-shell/src/session/wire_tags.rs index ca3541c..ad605ee 100644 --- a/crates/codegen/xai-grok-shell/src/session/wire_tags.rs +++ b/crates/codegen/xai-grok-shell/src/session/wire_tags.rs @@ -81,6 +81,7 @@ pub(crate) static TASK_COMPLETED: LazyLock<String> = LazyLock::new(|| { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }, will_wake: false, }) diff --git a/crates/codegen/xai-grok-shell/src/session/workflow/host_service.rs b/crates/codegen/xai-grok-shell/src/session/workflow/host_service.rs index 4a70820..f678118 100644 --- a/crates/codegen/xai-grok-shell/src/session/workflow/host_service.rs +++ b/crates/codegen/xai-grok-shell/src/session/workflow/host_service.rs @@ -5,6 +5,11 @@ use std::time::Duration; use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; +use xai_grok_tools::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend}; +use xai_grok_tools::implementations::grok_build::task::types::{ + ModelOverrideProvenance, SubagentCancelRequest, SubagentCancelTarget, SubagentEvent, + SubagentOwner, SubagentRequest, SubagentRuntimeOverrides, +}; use xai_workflow::{AgentOpts, AgentResult, BudgetState, HostError, WorkflowHostRequest}; use super::notify::WorkflowNotifySender; @@ -310,11 +315,6 @@ impl HostService { } async fn spawn_agent(&self, mut opts: AgentOpts) -> Result<AgentResult, HostError> { - use xai_grok_tools::implementations::grok_build::task::types::{ - ModelOverrideProvenance, SubagentEvent, SubagentOwner, SubagentRequest, - SubagentRuntimeOverrides, - }; - if self.params.cancel.is_cancelled() { return Err(HostError::Cancelled); } @@ -404,8 +404,7 @@ impl HostService { let spawn_once = |child_id: String, prompt: String, resume_from: Option<String>, fork_context: bool| { - let (result_tx, result_rx) = oneshot::channel(); - let request = SubagentRequest { + SubagentRequest { id: child_id, prompt, description: description.clone(), @@ -429,9 +428,7 @@ impl HostService { fork_context, owner: SubagentOwner::workflow(&self.params.run_id), cancel_token: cancel_token.clone(), - result_tx, - }; - (request, result_rx) + } }; let mut attempts: u32 = 0; @@ -456,30 +453,21 @@ impl HostService { } else { uuid::Uuid::now_v7().to_string() }; - let (request, result_rx) = spawn_once( + let request = spawn_once( child_id.clone(), next_prompt.clone(), resume_child, fork_context, ); - if self - .params - .subagent_event_tx - .send(SubagentEvent::Spawn(Box::new(request))) - .is_err() - { - row.finish("failed", total_tokens, total_duration); - return Err(HostError::Failed( - "subagent coordinator channel closed".into(), - )); - } self.active_agents.fetch_add(1, Ordering::Relaxed); self.tick(); - let mut result_rx = result_rx; + let backend = ChannelBackend::new(self.params.subagent_event_tx.clone()); + let result_fut = backend.spawn(request); + tokio::pin!(result_fut); let result = tokio::select! { - result = &mut result_rx => result, + result = &mut result_fut => result, _ = self.params.cancel.cancelled() => { cancel_token.cancel(); self.active_agents.fetch_sub(1, Ordering::Relaxed); @@ -492,7 +480,7 @@ impl HostService { let Ok(result) = result else { row.finish("failed", total_tokens, total_duration); return Err(HostError::Failed( - "subagent result channel closed before completion".into(), + "subagent coordinator channel closed before completion".into(), )); }; total_tokens = total_tokens.saturating_add(result.total_tokens_used); @@ -588,15 +576,12 @@ impl HostService { } async fn cancel_and_drain_children(&self) -> HostDrainOutcome { - use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentCancelRequest, SubagentCancelTarget, SubagentEvent, - }; - let (respond_to, response) = oneshot::channel(); if self .params .subagent_event_tx .send(SubagentEvent::Cancel(SubagentCancelRequest { + parent_session_id: Some(self.params.parent_session_id.clone()), target: SubagentCancelTarget::WorkflowRunId(self.params.run_id.clone()), respond_to, })) diff --git a/crates/codegen/xai-grok-shell/src/session/workflow/manager.rs b/crates/codegen/xai-grok-shell/src/session/workflow/manager.rs index 8b17187..3d2a2cf 100644 --- a/crates/codegen/xai-grok-shell/src/session/workflow/manager.rs +++ b/crates/codegen/xai-grok-shell/src/session/workflow/manager.rs @@ -457,6 +457,7 @@ impl WorkflowManager { .send( xai_grok_tools::implementations::grok_build::task::types::SubagentEvent::Cancel( xai_grok_tools::implementations::grok_build::task::types::SubagentCancelRequest { + parent_session_id: Some(self.session_id.clone()), target: xai_grok_tools::implementations::grok_build::task::types::SubagentCancelTarget::WorkflowRunId( run_id.to_owned(), ), @@ -877,10 +878,11 @@ mod tests { let spawn_req = subagent_rx.recv().await.expect("respawned agent"); use xai_grok_tools::implementations::grok_build::task::types::SubagentResult; if let SubagentEvent::Spawn(req) = spawn_req { + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("resumed output"), - subagent_id: req.id.clone(), + subagent_id: id, ..Default::default() }); } else { @@ -940,10 +942,11 @@ mod tests { let SubagentEvent::Spawn(req) = subagent_rx.recv().await.expect("respawned agent") else { panic!("expected respawn event"); }; + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("resumed output"), - subagent_id: req.id.clone(), + subagent_id: id, ..Default::default() }); assert!(matches!( @@ -974,10 +977,11 @@ mod tests { let SubagentEvent::Spawn(req) = subagent_rx.recv().await.expect("first spawn") else { panic!("expected spawn event"); }; + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("one"), - subagent_id: req.id.clone(), + subagent_id: id, ..Default::default() }); assert!(matches!( @@ -1085,10 +1089,11 @@ mod tests { xai_grok_tools::implementations::grok_build::task::types::ModelOverrideProvenance::Tool, "script model overrides are untrusted tool provenance" ); + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("slow but done"), - subagent_id: req.id.clone(), + subagent_id: id, ..Default::default() }); let outcome = outcome_rx.await.unwrap(); @@ -1240,11 +1245,12 @@ mod tests { assert_eq!(retry.resume_from.as_deref(), Some(first_id.as_str())); assert!(retry.prompt.contains("did not satisfy the output contract")); assert_eq!(retry.runtime_overrides.output_token_budget, None); + let retry_id = retry.id.clone(); let _ = retry.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("```json\n{\"ok\": true}\n```"), - subagent_id: retry.id.clone(), - child_session_id: retry.id.clone(), + subagent_id: retry_id.clone(), + child_session_id: retry_id, tokens_used: 50, output_tokens_used: 50, total_tokens_used: 50, @@ -1291,11 +1297,12 @@ mod tests { panic!("expected spawn"); }; assert_eq!(req.runtime_overrides.output_token_budget, None); + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("done"), - subagent_id: req.id.clone(), - child_session_id: req.id.clone(), + subagent_id: id.clone(), + child_session_id: id, output_tokens_used: 120, total_tokens_used: 120, ..Default::default() @@ -1328,11 +1335,12 @@ mod tests { panic!("expected spawn"); }; assert_eq!(req.runtime_overrides.output_token_budget, None); + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from("done"), - subagent_id: req.id.clone(), - child_session_id: req.id.clone(), + subagent_id: id.clone(), + child_session_id: id, output_tokens_used: 1, ..Default::default() }); @@ -1402,9 +1410,10 @@ mod tests { let SubagentEvent::Spawn(req) = spawn_req else { panic!("expected spawn event"); }; + let id = req.id.clone(); let _ = req.result_tx.send(SubagentResult { backgrounded: true, - subagent_id: req.id.clone(), + subagent_id: id, ..Default::default() }); let outcome = outcome_rx.await.unwrap(); diff --git a/crates/codegen/xai-grok-shell/src/terminal/adapter.rs b/crates/codegen/xai-grok-shell/src/terminal/adapter.rs index d9517c3..eb8dbbb 100644 --- a/crates/codegen/xai-grok-shell/src/terminal/adapter.rs +++ b/crates/codegen/xai-grok-shell/src/terminal/adapter.rs @@ -1,34 +1,37 @@ -//! AcpTerminalAdapter: implements `xai-grok-tools::TerminalBackend` using ACP gateway calls. -//! -//! This adapter enables bash tool execution over ACP (remote execution). -//! It translates xai-grok-tools' `TerminalBackend` trait into ACP protocol calls: -//! `run()` → create_terminal → wait_for_exit → terminal_output → release_terminal -//! `run_background()` → create_terminal + spawn exit watcher -//! `get_task()` → terminal_output (merged with tracked metadata) -//! `kill_task()` → kill_terminal_command (watcher detects exit) -//! `wait_for_completion()` → wait_for_terminal_exit with timeout +//! `AcpTerminalAdapter`: implements `xai-grok-tools::TerminalBackend` over ACP +//! gateway calls, for bash execution when the terminal is served by the client. use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; +use super::exit_watcher::{poll_for_terminal_exit, release_terminal, watch_for_exit}; +use super::output_recorder::OutputRecorder; use agent_client_protocol as acp; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; use xai_grok_tools::computer::types::{ - BackgroundHandle, ComputerError, KillOutcome, TaskSnapshot, TerminalBackend, + BackgroundHandle, ComputerError, KillOutcome, TaskKind, TaskSnapshot, TerminalBackend, TerminalRunRequest, TerminalRunResult, }; -use xai_grok_tools::notification::types::ToolNotificationHandle; -// ── Tracked task state ─────────────────────────────────────────────── +/// A snapshot's per-completion fields, grouped to avoid transposed positional args. +#[derive(Clone)] +pub(super) struct SnapshotOutput { + pub(super) output: String, + pub(super) truncated: bool, + pub(super) exit_code: Option<i32>, + pub(super) signal: Option<String>, +} -struct TrackedTask { +pub(super) struct TrackedTask { command: String, display_command: Option<String>, cwd: String, output_file: PathBuf, start_time: std::time::SystemTime, + /// Stamped once when the task completes, so repeated snapshots agree. + end_time: Option<std::time::SystemTime>, completed: bool, exit_code: Option<i32>, signal: Option<String>, @@ -36,190 +39,74 @@ struct TrackedTask { last_truncated: bool, block_waited: bool, explicitly_killed: bool, + kind: TaskKind, + owner_session_id: Option<String>, + description: Option<String>, +} + +/// Hand-written (`SystemTime` has no `Default`); call sites spread from it. +impl Default for TrackedTask { + fn default() -> Self { + Self { + command: String::new(), + display_command: None, + cwd: String::new(), + output_file: PathBuf::new(), + start_time: std::time::SystemTime::now(), + end_time: None, + completed: false, + exit_code: None, + signal: None, + last_output: String::new(), + last_truncated: false, + block_waited: false, + explicitly_killed: false, + kind: TaskKind::Bash, + owner_session_id: None, + description: None, + } + } } impl TrackedTask { - fn mark_completed( - &mut self, - exit_code: Option<i32>, - signal: Option<String>, - output: String, - truncated: bool, - ) { + pub(super) fn mark_completed(&mut self, out: SnapshotOutput) { self.completed = true; - self.exit_code = exit_code; - self.signal = signal; - self.last_output = output; - self.last_truncated = truncated; + self.end_time = Some(std::time::SystemTime::now()); + self.exit_code = out.exit_code; + self.signal = out.signal; + self.last_output = out.output; + self.last_truncated = out.truncated; } - fn to_snapshot( - &self, - task_id: &str, - output: String, - truncated: bool, - exit_code: Option<i32>, - signal: Option<String>, - ) -> TaskSnapshot { - let completed = self.completed || exit_code.is_some(); + pub(super) fn to_snapshot(&self, task_id: &str, out: SnapshotOutput) -> TaskSnapshot { + let completed = self.completed || out.exit_code.is_some(); TaskSnapshot { task_id: task_id.to_string(), command: self.command.clone(), display_command: self.display_command.clone(), cwd: self.cwd.clone(), start_time: self.start_time, - end_time: completed.then(std::time::SystemTime::now), - output, + end_time: self + .end_time + .or_else(|| completed.then(std::time::SystemTime::now)), + output: out.output, output_file: self.output_file.clone(), - truncated, - exit_code, - signal, + truncated: out.truncated, + exit_code: out.exit_code, + signal: out.signal, completed, block_waited: self.block_waited, explicitly_killed: self.explicitly_killed, - kind: xai_grok_tools::computer::types::TaskKind::Bash, - owner_session_id: None, + kind: self.kind, + owner_session_id: self.owner_session_id.clone(), + description: self.description.clone(), } } } -type TaskMap = Arc<Mutex<HashMap<String, TrackedTask>>>; - -// ── Exit watcher ───────────────────────────────────────────────────── - -/// Spawned per background task. Blocks on `WaitForTerminalExitRequest`, -/// then fetches final output, emits `TaskCompleted`, and releases the -/// terminal. -async fn watch_for_exit( - gateway: GatewaySender, - session_id: acp::SessionId, - task_id: String, - tasks: TaskMap, - notification_handle: ToolNotificationHandle, -) { - let terminal_id = acp::TerminalId::new(task_id.clone()); - - match gateway - .send(acp::WaitForTerminalExitRequest::new( - session_id.clone(), - terminal_id.clone(), - )) - .await - { - Ok(_) => {} - Err(e) => { - tracing::warn!( - task_id, - error = %e, - "watch_for_exit: gateway error waiting for terminal exit, polling until exit" - ); - if !poll_for_terminal_exit(&gateway, &session_id, &terminal_id, None).await { - // Gateway lost — mark the task as completed so it doesn't - // remain as a ghost "running" entry forever. - let snapshot = { - let mut tasks = tasks.lock().unwrap(); - let Some(task) = tasks.get_mut(&task_id) else { - return; - }; - task.mark_completed(None, Some("gateway-lost".into()), String::new(), false); - task.to_snapshot( - &task_id, - String::new(), - false, - None, - Some("gateway-lost".into()), - ) - }; - notification_handle.send_task_complete(snapshot); - let _ = gateway - .send(acp::ReleaseTerminalRequest::new(session_id, terminal_id)) - .await; - return; - } - } - } - - let (exit_code, signal, output_text, truncated) = match gateway - .send(acp::TerminalOutputRequest::new( - session_id.clone(), - terminal_id.clone(), - )) - .await - { - Ok(o) => { - let (code, sig) = parse_exit(&o.exit_status); - (code, sig, o.output, o.truncated) - } - Err(_) => (None, None, String::new(), false), - }; - - let snapshot = { - let mut tasks = tasks.lock().unwrap(); - let Some(task) = tasks.get_mut(&task_id) else { - return; - }; - task.mark_completed(exit_code, signal.clone(), output_text.clone(), truncated); - task.to_snapshot(&task_id, output_text, truncated, exit_code, signal) - }; - - notification_handle.send_task_complete(snapshot); - - let _ = gateway - .send(acp::ReleaseTerminalRequest::new(session_id, terminal_id)) - .await; -} - -// ── Helpers ────────────────────────────────────────────────────────── - -/// Poll `TerminalOutputRequest` at 500ms intervals until `exit_status` is -/// present, a deadline is hit, or 60 consecutive gateway errors occur. -/// Returns `true` when an exit was detected. -async fn poll_for_terminal_exit( - gateway: &GatewaySender, - session_id: &acp::SessionId, - terminal_id: &acp::TerminalId, - deadline: Option<tokio::time::Instant>, -) -> bool { - let mut consecutive_errors = 0u32; - loop { - if let Some(dl) = deadline - && tokio::time::Instant::now() >= dl - { - return false; - } - tokio::time::sleep(Duration::from_millis(500)).await; - match gateway - .send(acp::TerminalOutputRequest::new( - session_id.clone(), - terminal_id.clone(), - )) - .await - { - Ok(output) => { - consecutive_errors = 0; - if output.exit_status.is_some() { - return true; - } - } - Err(e) => { - consecutive_errors += 1; - if consecutive_errors >= 60 { - tracing::error!( - terminal_id = %terminal_id.0, - error = %e, - "gateway unreachable after 60 consecutive poll failures" - ); - return false; - } - } - } - } -} +pub(super) type TaskMap = Arc<Mutex<HashMap<String, TrackedTask>>>; fn wrap_command(command: &str) -> Result<String, ComputerError> { - // On Windows the ACP client (grok-desktop) spawns with `shell: true` - // which delegates to cmd.exe. Wrapping in /bin/bash would fail because - // that path doesn't exist on Windows. Send the raw command instead. #[cfg(not(unix))] { let _ = command; @@ -241,15 +128,15 @@ fn to_env(env: HashMap<String, String>) -> Vec<acp::EnvVariable> { .collect() } -fn parse_exit(status: &Option<acp::TerminalExitStatus>) -> (Option<i32>, Option<String>) { +pub(super) fn parse_exit( + status: &Option<acp::TerminalExitStatus>, +) -> (Option<i32>, Option<String>) { match status { Some(e) => (e.exit_code.map(|v| v as i32), e.signal.clone()), None => (None, None), } } -// ── Adapter ────────────────────────────────────────────────────────── - /// Wraps xai-grok-shell's ACP gateway to satisfy xai-grok-tools' TerminalBackend. pub struct AcpTerminalAdapter { gateway: GatewaySender, @@ -304,7 +191,10 @@ impl TerminalBackend for AcpTerminalAdapter { .await { Ok(Ok(_)) => false, - Ok(Err(e)) => return Err(ComputerError::io(e.to_string())), + Ok(Err(e)) => { + release_terminal(&self.gateway, &self.session_id, &create_res.terminal_id).await; + return Err(ComputerError::io(e.to_string())); + } Err(_) => { let _ = self .gateway @@ -317,25 +207,33 @@ impl TerminalBackend for AcpTerminalAdapter { } }; - let output = self + let output = match self .gateway .send(acp::TerminalOutputRequest::new( self.session_id.clone(), create_res.terminal_id.clone(), )) .await - .map_err(|e| ComputerError::io(e.to_string()))?; + { + Ok(output) => output, + Err(e) => { + release_terminal(&self.gateway, &self.session_id, &create_res.terminal_id).await; + return Err(ComputerError::io(e.to_string())); + } + }; - let _ = self - .gateway - .send(acp::ReleaseTerminalRequest::new( - self.session_id.clone(), - create_res.terminal_id, - )) - .await; + release_terminal(&self.gateway, &self.session_id, &create_res.terminal_id).await; let (exit_code, signal) = parse_exit(&output.exit_status); let total_bytes = output.output.len(); + + let mut recorder = + OutputRecorder::new(request.output_file.clone(), request.output_byte_limit); + recorder.initialize().await; + if let Err(e) = recorder.append(&output.output).await { + tracing::warn!(error = %e, "output recorder failed to write foreground output"); + } + Ok(TerminalRunResult { combined_output: output.output, exit_code, @@ -344,8 +242,6 @@ impl TerminalBackend for AcpTerminalAdapter { timed_out, output_file: request.output_file, total_bytes, - // ACP gateway does not surface a local PID -- the process - // runs on the remote side. pid: None, }) } @@ -362,6 +258,7 @@ impl TerminalBackend for AcpTerminalAdapter { let create_res = self.create_terminal(command.clone(), &request).await?; let task_id = create_res.terminal_id.0.to_string(); + let description = request.description; { let mut tasks = self.tasks.lock().unwrap(); @@ -372,31 +269,28 @@ impl TerminalBackend for AcpTerminalAdapter { display_command, cwd, output_file: output_file.clone(), - start_time: std::time::SystemTime::now(), - completed: false, - exit_code: None, - signal: None, - last_output: String::new(), - last_truncated: false, - block_waited: false, - explicitly_killed: false, + kind: request.kind, + owner_session_id: request.owner_session_id.clone(), + description, + ..Default::default() }, ); } + let recorder = OutputRecorder::new(output_file.clone(), request.output_byte_limit); + recorder.initialize().await; tokio::spawn(watch_for_exit( self.gateway.clone(), self.session_id.clone(), task_id.clone(), Arc::clone(&self.tasks), notification_handle, + recorder, )); Ok(BackgroundHandle { task_id, output_file, - // ACP gateway does not surface a local PID -- the process - // runs on the remote side. pid: None, }) } @@ -411,56 +305,73 @@ impl TerminalBackend for AcpTerminalAdapter { .await .ok(); - let tasks = self.tasks.lock().unwrap(); - let tracked = tasks.get(task_id); + // The std Mutex guard cannot be held across the await below, so resolve + // under the lock and read the log file after releasing it. + enum Resolved { + Ready(TaskSnapshot), + FromLog(TaskSnapshot, PathBuf), + Missing, + } + let resolved = { + let tasks = self.tasks.lock().unwrap(); + match (live, tasks.get(task_id)) { + (Some(output), Some(tracked)) => { + let (exit_code, signal) = parse_exit(&output.exit_status); + Resolved::Ready(tracked.to_snapshot( + task_id, + SnapshotOutput { + output: output.output, + truncated: output.truncated, + exit_code, + signal, + }, + )) + } + (Some(output), None) => { + let (exit_code, signal) = parse_exit(&output.exit_status); + Resolved::Ready(TrackedTask::default().to_snapshot( + task_id, + SnapshotOutput { + output: output.output, + truncated: output.truncated, + exit_code, + signal, + }, + )) + } + (None, Some(tracked)) => Resolved::FromLog( + tracked.to_snapshot( + task_id, + SnapshotOutput { + output: tracked.last_output.clone(), + truncated: tracked.last_truncated, + exit_code: tracked.exit_code, + signal: tracked.signal.clone(), + }, + ), + tracked.output_file.clone(), + ), + (None, None) => Resolved::Missing, + } + }; - match (live, tracked) { - (Some(output), Some(tracked)) => { - let (exit_code, signal) = parse_exit(&output.exit_status); - Some(tracked.to_snapshot( - task_id, - output.output, - output.truncated, - exit_code, - signal, - )) + match resolved { + Resolved::Ready(snapshot) => Some(snapshot), + Resolved::Missing => None, + // Live poll failed: fill output from the mirrored log so a running + // task does not report empty while the file already holds data. + Resolved::FromLog(mut snapshot, output_file) => { + if let Ok(logged) = tokio::fs::read_to_string(&output_file).await + && !logged.is_empty() + { + snapshot.output = logged; + } + Some(snapshot) } - (Some(output), None) => { - let (exit_code, signal) = parse_exit(&output.exit_status); - let completed = exit_code.is_some(); - Some(TaskSnapshot { - task_id: task_id.to_string(), - command: String::new(), - display_command: None, - cwd: String::new(), - start_time: std::time::SystemTime::now(), - end_time: completed.then(std::time::SystemTime::now), - output: output.output, - output_file: PathBuf::new(), - truncated: output.truncated, - exit_code, - signal, - completed, - kind: xai_grok_tools::computer::types::TaskKind::Bash, - block_waited: false, - explicitly_killed: false, - owner_session_id: None, - }) - } - (None, Some(tracked)) if tracked.completed => Some(tracked.to_snapshot( - task_id, - tracked.last_output.clone(), - tracked.last_truncated, - tracked.exit_code, - tracked.signal.clone(), - )), - _ => None, } } async fn kill_task(&self, task_id: &str) -> KillOutcome { - // Mark as explicitly killed BEFORE sending the kill request so the - // exit watcher's snapshot carries the flag. { let mut tasks = self.tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(task_id) { @@ -488,7 +399,6 @@ impl TerminalBackend for AcpTerminalAdapter { ) -> Option<TaskSnapshot> { let timeout = timeout.unwrap_or(Duration::from_secs(30)); - // Mark BEFORE waiting so watch_for_exit sees the flag in its snapshot. { let mut tasks = self.tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(task_id) { @@ -520,9 +430,6 @@ impl TerminalBackend for AcpTerminalAdapter { } Err(_) => { tracing::debug!(task_id, "timeout waiting for terminal exit"); - // The block timed out: the agent did not receive the - // completion result, so auto-wake should still fire - // when the task eventually completes. let mut tasks = self.tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(task_id) { task.block_waited = false; @@ -570,32 +477,45 @@ impl TerminalBackend for AcpTerminalAdapter { #[cfg(test)] mod tests { use super::*; + use xai_grok_tools::notification::types::ToolNotificationHandle; fn make_tracked_task(command: &str) -> TrackedTask { TrackedTask { command: command.to_string(), - display_command: None, cwd: "/tmp".to_string(), output_file: PathBuf::from("/tmp/out.log"), - start_time: std::time::SystemTime::now(), - completed: false, - exit_code: None, - signal: None, - last_output: String::new(), - last_truncated: false, - block_waited: false, - explicitly_killed: false, + ..Default::default() } } + fn out(output: &str, exit_code: Option<i32>, signal: Option<String>) -> SnapshotOutput { + SnapshotOutput { + output: output.into(), + truncated: false, + exit_code, + signal, + } + } + + #[test] + fn to_snapshot_preserves_description() { + let mut task = make_tracked_task("sleep 1"); + task.description = Some("build frontend".to_string()); + let snap = task.to_snapshot("t-1", out("ok", Some(0), None)); + assert_eq!(snap.description.as_deref(), Some("build frontend")); + assert_eq!(snap.task_id, "t-1"); + assert_eq!(snap.exit_code, Some(0)); + + let bare = make_tracked_task("sleep 1"); + let snap = bare.to_snapshot("t-2", out("", None, None)); + assert!(snap.description.is_none()); + } + #[test] fn wrap_command_quotes_shell_metacharacters() { let cmd = wrap_command("echo 'hello world' && ls").unwrap(); #[cfg(unix)] { - // The resolved bash path may live in any prefix (`/bin`, - // `/opt/homebrew/bin`, `/run/current-system/sw/bin`, …), so just - // assert the prefix shape: `<resolved-bash> -lc <quoted-cmd>`. let shell = crate::terminal::default_shell_path(); assert!( cmd.starts_with(&format!("{shell} -lc")), @@ -608,43 +528,18 @@ mod tests { } #[test] - fn parse_exit_with_code() { - let status = Some(acp::TerminalExitStatus::new().exit_code(Some(42))); - let (code, sig) = parse_exit(&status); - assert_eq!(code, Some(42)); - assert_eq!(sig, None); - } - - #[test] - fn parse_exit_with_signal() { - let status = Some(acp::TerminalExitStatus::new().signal(Some("SIGKILL".into()))); - let (code, sig) = parse_exit(&status); - assert_eq!(code, None); - assert_eq!(sig, Some("SIGKILL".into())); - } - - #[test] - fn parse_exit_none() { + fn parse_exit_maps_code_signal_and_none() { + let code = Some(acp::TerminalExitStatus::new().exit_code(Some(42))); + assert_eq!(parse_exit(&code), (Some(42), None)); + let signal = Some(acp::TerminalExitStatus::new().signal(Some("SIGKILL".into()))); + assert_eq!(parse_exit(&signal), (None, Some("SIGKILL".into()))); assert_eq!(parse_exit(&None), (None, None)); } - #[test] - fn tracked_task_mark_completed() { - let mut task = make_tracked_task("sleep 10"); - assert!(!task.completed); - assert_eq!(task.exit_code, None); - - task.mark_completed(Some(137), Some("SIGTERM".into()), "output".into(), false); - assert!(task.completed); - assert_eq!(task.exit_code, Some(137)); - assert_eq!(task.signal, Some("SIGTERM".into())); - assert_eq!(task.last_output, "output"); - } - #[test] fn tracked_task_to_snapshot_running() { let task = make_tracked_task("ls -la"); - let snap = task.to_snapshot("t-1", "file1\nfile2".into(), false, None, None); + let snap = task.to_snapshot("t-1", out("file1\nfile2", None, None)); assert_eq!(snap.task_id, "t-1"); assert_eq!(snap.command, "ls -la"); @@ -658,8 +553,8 @@ mod tests { #[test] fn tracked_task_to_snapshot_completed() { let mut task = make_tracked_task("echo done"); - task.mark_completed(Some(0), None, "done\n".into(), false); - let snap = task.to_snapshot("t-2", "done\n".into(), false, Some(0), None); + task.mark_completed(out("done\n", Some(0), None)); + let snap = task.to_snapshot("t-2", out("done\n", Some(0), None)); assert!(snap.completed); assert!(snap.end_time.is_some()); @@ -670,77 +565,128 @@ mod tests { #[test] fn tracked_task_to_snapshot_completed_by_exit_code_alone() { let task = make_tracked_task("fast cmd"); - let snap = task.to_snapshot("t-3", String::new(), false, Some(1), None); + let snap = task.to_snapshot("t-3", out("", Some(1), None)); assert!(snap.completed); assert!(snap.end_time.is_some()); } - #[test] - fn tracked_task_to_snapshot_preserves_display_command() { - let mut task = make_tracked_task("/bin/bash -lc 'echo hi'"); - task.display_command = Some("echo hi".into()); - let snap = task.to_snapshot("t-4", String::new(), false, None, None); - assert_eq!(snap.display_command, Some("echo hi".into())); + /// Scripted client side of the terminal protocol: each `terminal/output` + /// serves the next snapshot; `wait_for_exit` resolves after the last one. + fn scripted_gateway(outputs: Vec<(String, bool)>) -> GatewaySender { + use xai_acp_lib::AcpClientMessage; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut next = 0usize; + let mut wait_reply: Option< + tokio::sync::oneshot::Sender< + xai_acp_lib::AcpResult<acp::WaitForTerminalExitResponse>, + >, + > = None; + let mut exited = false; + while let Some(msg) = rx.recv().await { + match msg { + AcpClientMessage::CreateTerminal(args) => { + let _ = args + .response_tx + .send(Ok(acp::CreateTerminalResponse::new("term-1"))); + } + AcpClientMessage::WaitForTerminalExit(args) => { + wait_reply = Some(args.response_tx); + } + AcpClientMessage::TerminalOutput(args) => { + let idx = next.min(outputs.len() - 1); + let (text, truncated) = outputs[idx].clone(); + let mut response = acp::TerminalOutputResponse::new(text, truncated); + if exited { + response = response.exit_status(Some( + acp::TerminalExitStatus::new().exit_code(Some(0)), + )); + } + next += 1; + let _ = args.response_tx.send(Ok(response)); + if next >= outputs.len() + && let Some(reply) = wait_reply.take() + { + exited = true; + let _ = reply.send(Ok(acp::WaitForTerminalExitResponse::new( + acp::TerminalExitStatus::new().exit_code(Some(0)), + ))); + } + } + AcpClientMessage::ReleaseTerminal(args) => { + let _ = args + .response_tx + .send(Ok(acp::ReleaseTerminalResponse::new())); + break; + } + AcpClientMessage::KillTerminalCommand(args) => { + let _ = args.response_tx.send(Ok(acp::KillTerminalResponse::new())); + } + _ => {} + } + } + }); + GatewaySender::new(tx) } - #[test] - fn task_map_insert_and_mark_completed() { - let tasks: TaskMap = Arc::new(Mutex::new(HashMap::new())); - { - let mut map = tasks.lock().unwrap(); - map.insert("t-1".into(), make_tracked_task("sleep 60")); - } - { - let mut map = tasks.lock().unwrap(); - let task = map.get_mut("t-1").unwrap(); - task.mark_completed(Some(143), Some("SIGTERM".into()), String::new(), false); - assert!(task.completed); - } - { - let map = tasks.lock().unwrap(); - let task = map.get("t-1").unwrap(); - assert!(task.completed); - assert_eq!(task.exit_code, Some(143)); + fn background_request(output_file: PathBuf) -> TerminalRunRequest { + TerminalRunRequest { + command: "watch-something".into(), + working_directory: PathBuf::from("/tmp"), + env: HashMap::new(), + timeout: Duration::from_secs(60), + output_byte_limit: 1024 * 1024, + output_file, + notification_handle: ToolNotificationHandle::noop(), + tool_call_id: "call-1".into(), + display_command: Some("[monitor] watch".into()), + auto_background_on_timeout: false, + foreground_block_budget: None, + kind: TaskKind::Monitor, + owner_session_id: Some("owner-1".into()), + description: None, } } - #[test] - fn task_map_filter_running() { - let tasks: TaskMap = Arc::new(Mutex::new(HashMap::new())); - { - let mut map = tasks.lock().unwrap(); - map.insert("running-1".into(), make_tracked_task("sleep 60")); - let mut done = make_tracked_task("echo done"); - done.mark_completed(Some(0), None, String::new(), false); - map.insert("done-1".into(), done); - map.insert("running-2".into(), make_tracked_task("sleep 120")); - } - let running: Vec<String> = { - let map = tasks.lock().unwrap(); - map.iter() - .filter(|(_, t)| !t.completed) - .map(|(id, _)| id.clone()) - .collect() + #[tokio::test(start_paused = true)] + async fn run_background_records_snapshots_and_threads_task_kind() { + use xai_grok_tools::notification::types::ToolNotification; + + let dir = tempfile::tempdir().unwrap(); + let output_file = dir.path().join("terminal").join("monitor-call-1.log"); + + let gateway = scripted_gateway(vec![ + ("line1\n".into(), false), + ("line1\nline2\n".into(), false), + ("line1\nline2\nline3\n".into(), false), + ]); + let adapter = AcpTerminalAdapter::new(gateway, acp::SessionId::new("sess-1")); + + let (handle, mut notifications) = ToolNotificationHandle::channel(); + let mut request = background_request(output_file.clone()); + request.notification_handle = handle; + + let bg = adapter.run_background(request).await.unwrap(); + assert_eq!(bg.task_id, "term-1"); + assert!(output_file.exists()); + + let snapshot = adapter.get_task(&bg.task_id).await.unwrap(); + assert_eq!(snapshot.kind, TaskKind::Monitor); + assert_eq!(snapshot.owner_session_id.as_deref(), Some("owner-1")); + + let completed = loop { + match notifications.recv().await.expect("completion notification") { + ToolNotification::TaskCompleted(snapshot) => break snapshot, + _ => continue, + } }; - assert_eq!(running.len(), 2); - assert!(running.contains(&"running-1".into())); - assert!(running.contains(&"running-2".into())); - } + assert_eq!(completed.kind, TaskKind::Monitor); + assert_eq!(completed.owner_session_id.as_deref(), Some("owner-1")); + assert_eq!(completed.exit_code, Some(0)); - #[test] - fn completed_task_snapshot_uses_cached_output() { - let mut task = make_tracked_task("echo hello"); - task.mark_completed(Some(0), None, "hello\n".into(), false); - - let snap = task.to_snapshot( - "t-5", - task.last_output.clone(), - task.last_truncated, - task.exit_code, - task.signal.clone(), + assert_eq!( + std::fs::read_to_string(&output_file).unwrap(), + "line1\nline2\nline3\n" ); - assert!(snap.completed); - assert_eq!(snap.output, "hello\n"); - assert_eq!(snap.exit_code, Some(0)); } } diff --git a/crates/codegen/xai-grok-shell/src/terminal/exit_watcher.rs b/crates/codegen/xai-grok-shell/src/terminal/exit_watcher.rs new file mode 100644 index 0000000..3b92204 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/terminal/exit_watcher.rs @@ -0,0 +1,255 @@ +//! Exit detection and completion for ACP background terminals: awaits +//! `wait_for_exit` while polling `terminal/output` into the [`OutputRecorder`], +//! then completes and releases the terminal. + +use std::time::Duration; + +use agent_client_protocol as acp; +use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; +use xai_grok_tools::notification::types::ToolNotificationHandle; + +use super::adapter::{SnapshotOutput, TaskMap, parse_exit}; +use super::output_recorder::OutputRecorder; + +const RECORDER_POLL: Duration = Duration::from_millis(250); + +const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(500); + +const GATEWAY_LOST_AFTER: Duration = Duration::from_secs(30); + +fn max_poll_errors(cadence: Duration) -> u32 { + (GATEWAY_LOST_AFTER.as_millis() / cadence.as_millis().max(1)).max(1) as u32 +} + +enum PollStep { + Output(Box<acp::TerminalOutputResponse>), + Retry, + GaveUp, +} + +async fn poll_terminal_output( + gateway: &GatewaySender, + session_id: &acp::SessionId, + terminal_id: &acp::TerminalId, + consecutive_errors: &mut u32, + max_errors: u32, +) -> PollStep { + match gateway + .send(acp::TerminalOutputRequest::new( + session_id.clone(), + terminal_id.clone(), + )) + .await + { + Ok(output) => { + *consecutive_errors = 0; + PollStep::Output(Box::new(output)) + } + Err(e) => { + *consecutive_errors += 1; + if *consecutive_errors >= max_errors { + tracing::error!( + terminal_id = %terminal_id.0, + error = %e, + "gateway unreachable after consecutive poll failures" + ); + PollStep::GaveUp + } else { + PollStep::Retry + } + } + } +} + +enum Exit { + WithOutput(Box<acp::TerminalOutputResponse>), + NeedFetch, + Lost, +} + +pub(super) async fn watch_for_exit( + gateway: GatewaySender, + session_id: acp::SessionId, + task_id: String, + tasks: TaskMap, + notification_handle: ToolNotificationHandle, + mut recorder: OutputRecorder, +) { + let terminal_id = acp::TerminalId::new(task_id.clone()); + + let wait = gateway.send(acp::WaitForTerminalExitRequest::new( + session_id.clone(), + terminal_id.clone(), + )); + tokio::pin!(wait); + let mut wait_pending = true; + let mut consecutive_errors = 0u32; + let poll_error_budget = max_poll_errors(RECORDER_POLL); + let exit = loop { + tokio::select! { + res = &mut wait, if wait_pending => match res { + Ok(_) => break Exit::NeedFetch, + Err(e) => { + tracing::warn!( + task_id, + error = %e, + "watch_for_exit: gateway error waiting for terminal exit, polling until exit" + ); + wait_pending = false; + } + }, + _ = tokio::time::sleep(RECORDER_POLL) => { + match poll_terminal_output( + &gateway, + &session_id, + &terminal_id, + &mut consecutive_errors, + poll_error_budget, + ) + .await + { + PollStep::Output(output) => { + if let Err(e) = recorder.append(&output.output).await { + tracing::debug!(task_id, error = %e, "output recorder append failed; retrying next poll"); + } + if output.exit_status.is_some() { + break Exit::WithOutput(output); + } + } + PollStep::Retry => {} + PollStep::GaveUp => break Exit::Lost, + } + } + } + }; + + let output = match exit { + Exit::Lost => { + complete_and_release( + &gateway, + &session_id, + &terminal_id, + &tasks, + ¬ification_handle, + &task_id, + SnapshotOutput { + output: recorder.mirrored().to_string(), + truncated: false, + exit_code: None, + signal: Some("gateway-lost".into()), + }, + ) + .await; + return; + } + Exit::WithOutput(output) => *output, + Exit::NeedFetch => match gateway + .send(acp::TerminalOutputRequest::new( + session_id.clone(), + terminal_id.clone(), + )) + .await + { + Ok(output) => output, + // The fetch failed; fall back to what we already mirrored to disk so + // the completion snapshot is not empty while the log file has data. + Err(_) => acp::TerminalOutputResponse::new(recorder.mirrored().to_string(), false), + }, + }; + + let (exit_code, signal) = parse_exit(&output.exit_status); + if let Err(e) = recorder.append(&output.output).await { + tracing::warn!(task_id, error = %e, "output recorder failed to write final output"); + } + complete_and_release( + &gateway, + &session_id, + &terminal_id, + &tasks, + ¬ification_handle, + &task_id, + SnapshotOutput { + output: output.output, + truncated: output.truncated, + exit_code, + signal, + }, + ) + .await; +} + +/// Releases even when the task is gone, so the client terminal is not leaked. +async fn complete_and_release( + gateway: &GatewaySender, + session_id: &acp::SessionId, + terminal_id: &acp::TerminalId, + tasks: &TaskMap, + notification_handle: &ToolNotificationHandle, + task_id: &str, + out: SnapshotOutput, +) { + let snapshot = { + let mut guard = tasks.lock().unwrap(); + guard.get_mut(task_id).map(|task| { + task.mark_completed(out.clone()); + task.to_snapshot(task_id, out) + }) + }; + if let Some(snapshot) = snapshot { + notification_handle.send_task_complete(snapshot); + } + release_terminal(gateway, session_id, terminal_id).await; +} + +pub(super) async fn release_terminal( + gateway: &GatewaySender, + session_id: &acp::SessionId, + terminal_id: &acp::TerminalId, +) { + if let Err(e) = gateway + .send(acp::ReleaseTerminalRequest::new( + session_id.clone(), + terminal_id.clone(), + )) + .await + { + tracing::debug!(terminal_id = %terminal_id.0, error = %e, "release_terminal failed"); + } +} + +/// Fallback exit detector for the blocking `wait_for_completion` path. Unlike +/// [`watch_for_exit`] it only detects exit and does not mirror output. Returns +/// `true` on exit, `false` on deadline or [`GATEWAY_LOST_AFTER`] of failures. +pub(super) async fn poll_for_terminal_exit( + gateway: &GatewaySender, + session_id: &acp::SessionId, + terminal_id: &acp::TerminalId, + deadline: Option<tokio::time::Instant>, +) -> bool { + let mut consecutive_errors = 0u32; + loop { + if let Some(dl) = deadline + && tokio::time::Instant::now() >= dl + { + return false; + } + tokio::time::sleep(EXIT_POLL_INTERVAL).await; + match poll_terminal_output( + gateway, + session_id, + terminal_id, + &mut consecutive_errors, + max_poll_errors(EXIT_POLL_INTERVAL), + ) + .await + { + PollStep::Output(output) => { + if output.exit_status.is_some() { + return true; + } + } + PollStep::Retry => {} + PollStep::GaveUp => return false, + } + } +} diff --git a/crates/codegen/xai-grok-shell/src/terminal/mod.rs b/crates/codegen/xai-grok-shell/src/terminal/mod.rs index 68015f7..55926ec 100644 --- a/crates/codegen/xai-grok-shell/src/terminal/mod.rs +++ b/crates/codegen/xai-grok-shell/src/terminal/mod.rs @@ -16,6 +16,9 @@ pub use acp_terminal::AcpTerminalRunner; pub mod adapter; pub use adapter::AcpTerminalAdapter; +mod exit_watcher; +mod output_recorder; + pub mod pty_session; pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); diff --git a/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs b/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs new file mode 100644 index 0000000..b847e32 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs @@ -0,0 +1,236 @@ +//! Reconstructs a client-side terminal's log file from its `terminal/output` +//! snapshots, for the truncation `read_file` path and the monitor file tail. +//! +//! TODO: fallback until clients push exact output via an +//! `x.ai/terminal/output_delta` notification (tracked separately). + +use std::path::PathBuf; + +pub(crate) struct OutputRecorder { + path: PathBuf, + last: String, + /// Must span the whole client buffer, or a rolled buffer's overlap is missed + /// and the snapshot is re-appended each poll. + overlap_window: usize, + realign_warned: bool, + file: Option<tokio::fs::File>, + overlap_s: Vec<u8>, + overlap_pi: Vec<u32>, +} + +impl OutputRecorder { + pub(crate) fn new(path: PathBuf, output_byte_limit: usize) -> Self { + Self { + path, + last: String::new(), + overlap_window: output_byte_limit, + realign_warned: false, + file: None, + overlap_s: Vec::new(), + overlap_pi: Vec::new(), + } + } + + pub(crate) fn mirrored(&self) -> &str { + &self.last + } + + pub(crate) async fn initialize(&self) { + if let Some(parent) = self.path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + if let Err(e) = tokio::fs::File::create(&self.path).await { + tracing::debug!(path = %self.path.display(), error = %e, "output recorder: failed to create log file"); + } + } + + /// Append what `current` adds beyond the previous snapshot, realigning on the + /// largest overlap once the buffer rolls. On write error `last` is left + /// unadvanced so the next poll retries, and the error is returned. + pub(crate) async fn append(&mut self, current: &str) -> std::io::Result<()> { + // Empty snapshot must not clear the baseline, or the next cumulative one + // gets re-appended in full. + if current.is_empty() || current == self.last { + return Ok(()); + } + let new_suffix = match current.strip_prefix(self.last.as_str()) { + Some(suffix) => suffix, + None => { + let overlap = largest_overlap( + &self.last, + current, + self.overlap_window, + &mut self.overlap_s, + &mut self.overlap_pi, + ); + if overlap == 0 && !self.last.is_empty() && !self.realign_warned { + self.realign_warned = true; + tracing::warn!( + path = %self.path.display(), + "output recorder: no overlap between consecutive output snapshots; appending whole snapshot (possible duplication)" + ); + } + ¤t[overlap..] + } + }; + if !new_suffix.is_empty() { + use tokio::io::AsyncWriteExt; + if self.file.is_none() { + self.file = Some( + tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .await?, + ); + } + let write = { + let file = self.file.as_mut().expect("handle opened above"); + match file.write_all(new_suffix.as_bytes()).await { + Ok(()) => file.flush().await, + Err(e) => Err(e), + } + }; + if let Err(e) = write { + self.file = None; + return Err(e); + } + } + self.last.clear(); + self.last.push_str(current); + Ok(()) + } +} + +/// Largest suffix of `last` (within its last `window` bytes) that is a prefix of +/// `current`, via a linear KMP over `current ++ tail`. Best-effort: repetitive +/// output can over-match and drop a segment. +fn largest_overlap( + last: &str, + current: &str, + window: usize, + s: &mut Vec<u8>, + pi: &mut Vec<u32>, +) -> usize { + let cur = current.as_bytes(); + let last_bytes = last.as_bytes(); + if cur.is_empty() || last_bytes.is_empty() { + return 0; + } + let tail = &last_bytes[last_bytes.len().saturating_sub(window)..]; + + s.clear(); + s.extend_from_slice(cur); + s.extend_from_slice(tail); + pi.clear(); + pi.resize(s.len(), 0); + let mut k: u32 = 0; + for i in 1..s.len() { + while k > 0 && s[i] != s[k as usize] { + k = pi[(k - 1) as usize]; + } + if s[i] == s[k as usize] { + k += 1; + } + pi[i] = k; + } + + let cap = cur.len().min(tail.len()); + let mut overlap = pi[s.len() - 1] as usize; + while overlap > cap { + overlap = pi[overlap - 1] as usize; + } + while overlap > 0 && !current.is_char_boundary(overlap) { + overlap -= 1; + } + overlap +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ov(last: &str, current: &str, window: usize) -> usize { + let mut s = Vec::new(); + let mut pi = Vec::new(); + largest_overlap(last, current, window, &mut s, &mut pi) + } + + #[test] + fn largest_overlap_finds_rolling_tail_alignment() { + assert_eq!(ov("line1\nline2\n", "ne2\nline3\n", 8192), "ne2\n".len()); + assert_eq!(ov("aaaa", "bbbb", 8192), 0); + assert_eq!(ov("abc", "abc", 8192), 3); + assert_eq!(ov("xxabcdef", "abcdefyy", 3), 0); + assert_eq!(ov("", "abc", 8192), 0); + assert_eq!(ov("abc", "", 8192), 0); + assert_eq!(ov("xé", "é!", 8192), "é".len()); + assert_eq!(ov("abababab", "ababXY", 8192), 4); + assert_eq!(ov("xxabcxx", "abc", 8192), 0); + } + + #[tokio::test] + async fn recorder_appends_cumulative_suffixes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("task.log"); + let mut recorder = OutputRecorder::new(path.clone(), 1024 * 1024); + recorder.initialize().await; + assert_eq!(std::fs::read_to_string(&path).unwrap(), ""); + + recorder.append("line1\n").await.unwrap(); + recorder.append("line1\nline2\n").await.unwrap(); + recorder.append("line1\nline2\n").await.unwrap(); + recorder.append("line1\nline2\nline3\n").await.unwrap(); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "line1\nline2\nline3\n" + ); + } + + #[tokio::test] + async fn recorder_retries_the_suffix_after_a_failed_write() { + let dir = tempfile::tempdir().unwrap(); + + let mut recorder = OutputRecorder::new(dir.path().to_path_buf(), 1024 * 1024); + assert!(recorder.append("line1\n").await.is_err()); + assert_eq!(recorder.last, ""); + + let path = dir.path().join("task.log"); + recorder.path = path.clone(); + recorder.append("line1\nline2\n").await.unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "line1\nline2\n"); + } + + #[tokio::test] + async fn recorder_reconstructs_stream_across_repeated_rolls() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("task.log"); + let limit = 8usize; + let mut recorder = OutputRecorder::new(path.clone(), limit); + recorder.initialize().await; + + let full = "abcdefghijklmnopqrstuvwxyz"; + for end in 1..=full.len() { + let start = end.saturating_sub(limit); + recorder.append(&full[start..end]).await.unwrap(); + } + + assert_eq!(std::fs::read_to_string(&path).unwrap(), full); + } + + #[tokio::test] + async fn recorder_ignores_empty_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("task.log"); + let mut recorder = OutputRecorder::new(path.clone(), 1024 * 1024); + recorder.initialize().await; + + recorder.append("line1\nline2\n").await.unwrap(); + recorder.append("").await.unwrap(); + recorder.append("line1\nline2\nline3\n").await.unwrap(); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "line1\nline2\nline3\n" + ); + } +} diff --git a/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs b/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs index 2ed84e0..b23ecba 100644 --- a/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs +++ b/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs @@ -1,14 +1,10 @@ use crate::agent::subagent::SubagentSpawnContext; -use crate::session::SessionCommand; use agent_client_protocol as acp; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; -use xai_grok_tools::implementations::grok_build::task::types::{ - SubagentOwner, SubagentRequest, SubagentResult, -}; pub(crate) type GatewayOut = <acp::AgentSide as xai_acp_lib::AcpSide>::OutMessage; pub(crate) fn test_gateway() -> GatewaySender { let (tx, _rx) = mpsc::unbounded_channel(); @@ -19,24 +15,11 @@ pub(crate) fn test_gateway_with_receiver() -> (GatewaySender, mpsc::UnboundedRec let (tx, rx) = mpsc::unbounded_channel(); (GatewaySender::new(tx), rx) } -/// `ctx_with_toggle` with a wired `parent_cmd_tx`. -pub(crate) fn ctx_with_toggle_and_cmd_tx( - toggle: HashMap<String, bool>, -) -> ( - SubagentSpawnContext, - mpsc::UnboundedReceiver<SessionCommand>, -) { - let mut ctx = ctx_with_toggle(toggle); - let (tx, rx) = mpsc::unbounded_channel(); - ctx.parent_cmd_tx = Some(tx); - (ctx, rx) -} pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnContext { let (tx, _rx) = mpsc::unbounded_channel(); SubagentSpawnContext { lsp: None, parent_max_turns: None, - gateway: test_gateway(), client_hooks: Default::default(), sampling_config: xai_grok_sampler::SamplerConfig { api_key: None, @@ -48,6 +31,8 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon api_backend: Default::default(), auth_scheme: Default::default(), extra_headers: Default::default(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 256_000, client_version: None, force_http1: false, @@ -70,7 +55,6 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon alpha_test_key: None, auth_method_id: acp::AuthMethodId::new("test"), model_id: acp::ModelId::new("test"), - storage_mode: crate::config::StorageMode::Local, auth: None, parent_cwd: PathBuf::from("/tmp"), parent_session_id: "test-parent".into(), @@ -119,7 +103,6 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon gcs_bucket_url: None, gcs_upload_method: None, hook_registry: None, - hook_workspace_root: String::new(), parent_depth: 0, inference_idle_timeout_secs: 600, auto_compact_threshold_tiers: crate::agent::subagent::AutoCompactThresholdTiers::default(), @@ -140,7 +123,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon managed_mcp_state: crate::session::managed_mcp::ManagedMcpStateHandle::default(), managed_mcp_proxy_base_url: String::new(), parent_mcp_pool: None, - parent_tool_snapshot: None, + parent_tool_definitions: None, parent_skills: None, parent_skills_config: xai_grok_agent::prompt::skills::SkillsConfig::default(), parent_compat: xai_grok_tools::types::compat::CompatConfig::default(), @@ -150,38 +133,11 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon .to_string(), auto_wake_enabled: true, goal_loop_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - parent_blocking_wait_depth: std::sync::Arc::new( - crate::tools::tool_context::BlockingWaitState::new(), - ), parent_terminal_backend: None, parent_notification_handle: None, parent_scheduler_handle: None, } } -pub(crate) fn make_request( - subagent_type: &str, -) -> (SubagentRequest, oneshot::Receiver<SubagentResult>) { - let (tx, rx) = oneshot::channel(); - let req = SubagentRequest { - id: uuid::Uuid::now_v7().to_string(), - prompt: "do something".into(), - description: "test task".into(), - subagent_type: subagent_type.into(), - parent_session_id: "test-parent".into(), - parent_prompt_id: Some("parent-prompt".into()), - resume_from: None, - cwd: None, - runtime_overrides: Default::default(), - run_in_background: false, - surface_completion: true, - await_to_completion: false, - fork_context: false, - owner: SubagentOwner::Task, - cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx: tx, - }; - (req, rx) -} #[derive(Default)] pub(crate) struct DummyLspDispatch; #[async_trait::async_trait] diff --git a/crates/codegen/xai-grok-shell/src/test_support/mod.rs b/crates/codegen/xai-grok-shell/src/test_support/mod.rs index 9170380..d5b8863 100644 --- a/crates/codegen/xai-grok-shell/src/test_support/mod.rs +++ b/crates/codegen/xai-grok-shell/src/test_support/mod.rs @@ -23,6 +23,14 @@ pub(crate) fn ensure_hermetic_git_on_path() { let cur = std::env::var("PATH").unwrap_or_default(); unsafe { std::env::set_var("PATH", format!("{}:{}", dir.display(), cur)); + // git-minimal spawns subcommands (`git stash` → `git + // update-index`) through its exec path, which is baked to + // a build-machine prefix. Helpers live next to the binary, + // so point the exec path there. Skip the host-fallback + // wrapper: host git must keep its own exec path. + if p.file_name().is_some_and(|name| name == "git") { + std::env::set_var("GIT_EXEC_PATH", dir); + } } } } diff --git a/crates/codegen/xai-grok-shell/src/tools/config.rs b/crates/codegen/xai-grok-shell/src/tools/config.rs index e492506..d3704aa 100644 --- a/crates/codegen/xai-grok-shell/src/tools/config.rs +++ b/crates/codegen/xai-grok-shell/src/tools/config.rs @@ -220,6 +220,8 @@ impl ShellToolsetConfig { api_backend: Default::default(), auth_scheme: Default::default(), extra_headers: indexmap::IndexMap::new(), + query_params: indexmap::IndexMap::new(), + env_http_headers: indexmap::IndexMap::new(), context_window: 256_000, client_version: None, reasoning_effort: None, diff --git a/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs b/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs index cdb8b0a..147666c 100644 --- a/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs +++ b/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs @@ -375,6 +375,7 @@ async fn handle_notification( "FileWritten notification forwarded to hunk tracker" ); } + ToolNotification::SubagentCompleted(_) => {} ToolNotification::TaskCompleted(task_snapshot) => { let is_monitor = task_snapshot.kind == xai_grok_tools::computer::types::TaskKind::Monitor; @@ -935,6 +936,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } #[tokio::test] @@ -2221,6 +2223,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } /// Extract the auto-wake prompt text emitted on the session command channel. diff --git a/crates/codegen/xai-grok-shell/src/tools/tool_context.rs b/crates/codegen/xai-grok-shell/src/tools/tool_context.rs index 9699e78..241024a 100644 --- a/crates/codegen/xai-grok-shell/src/tools/tool_context.rs +++ b/crates/codegen/xai-grok-shell/src/tools/tool_context.rs @@ -129,6 +129,13 @@ impl Drop for BlockingWaitGuard { } } } +pub(crate) fn subagent_foreground_wait( + state: Arc<BlockingWaitState>, +) -> xai_grok_tools::implementations::grok_build::task::types::SubagentForegroundWait { + xai_grok_tools::implementations::grok_build::task::types::SubagentForegroundWait::new( + move || Box::new(BlockingWaitGuard::enter(Arc::clone(&state))), + ) +} /// Session-level context. NOT used for tool execution (bridge handles that). /// Holds ACP gateway, cwd, hunk tracker, etc. for session infrastructure. #[derive(Clone)] diff --git a/crates/codegen/xai-grok-shell/src/util/config/persist.rs b/crates/codegen/xai-grok-shell/src/util/config/persist.rs index 944947f..ae337cd 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/persist.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/persist.rs @@ -735,6 +735,9 @@ auto_update = true "worktree_type", "session_registry", "minimum_version", + "maximum_version", + "required_minimum_version", + "required_maximum_version", ]; /// Assert that every `CliConfig` `Option<T>` field NOT in `present` is /// absent from `table`. diff --git a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs index 80d9fa8..7f1800d 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs @@ -40,6 +40,26 @@ pub fn resolve_search_tools_enabled( ) } +/// Parse `[shell_environment_policy]` from the merged effective config, or `None` +/// when unset or unparseable (the child then inherits the full environment). This +/// is the authoritative parse; the `Config` field of the same name only feeds the +/// unrecognized-key scan. +pub fn resolve_shell_env_policy( + effective_cfg: Option<&TomlValue>, +) -> Option<xai_grok_tools::util::ShellEnvironmentPolicy> { + let value = effective_cfg?.get("shell_environment_policy")?.clone(); + match value.try_into::<xai_grok_tools::util::ShellEnvironmentPolicy>() { + Ok(policy) => Some(policy), + Err(error) => { + tracing::warn!( + %error, + "failed to parse [shell_environment_policy]; inheriting the full environment" + ); + None + } + } +} + /// Pure precedence for [`resolve_search_tools_enabled`] (tiers injected so it is /// unit-testable without env/disk): requirement (org policy) wins outright — even /// over the user `DISABLE_*` master kill-switch — then the master forces off, @@ -704,3 +724,42 @@ mod tests { )); } } + +#[cfg(test)] +mod shell_env_policy_tests { + use super::*; + use xai_grok_tools::util::{EnvironmentVariablePattern, ShellEnvironmentPolicyInherit}; + + #[test] + fn resolve_shell_env_policy_absent_parsed_typo_and_typed_error() { + // Absent table → None (child inherits the full environment). + let empty: TomlValue = toml::from_str("").unwrap(); + assert!(resolve_shell_env_policy(Some(&empty)).is_none()); + assert!(resolve_shell_env_policy(None).is_none()); + + // A well-formed table parses through. + let cfg: TomlValue = + toml::from_str("[shell_environment_policy]\ninherit = \"core\"\nexclude = [\"FOO\"]\n") + .unwrap(); + let policy = resolve_shell_env_policy(Some(&cfg)).expect("policy parses"); + assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::Core); + assert_eq!( + policy.exclude, + vec![EnvironmentVariablePattern::new_case_insensitive("FOO")] + ); + + // An unknown sub-key is ignored; the known keys still apply (the + // load-time scan warns on the typo). + let typo: TomlValue = + toml::from_str("[shell_environment_policy]\ninherit = \"none\"\ninhert = \"core\"\n") + .unwrap(); + let policy = resolve_shell_env_policy(Some(&typo)).expect("known keys still parse"); + assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::None); + + // A wrong-typed known key fails to parse → None (full environment, + // logged), not a spawn abort. + let bad: TomlValue = + toml::from_str("[shell_environment_policy]\nexclude = \"not-an-array\"\n").unwrap(); + assert!(resolve_shell_env_policy(Some(&bad)).is_none()); + } +} diff --git a/crates/codegen/xai-grok-shell/src/util/config/resolve/version.rs b/crates/codegen/xai-grok-shell/src/util/config/resolve/version.rs index 7977d1b..1b87614 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/resolve/version.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/resolve/version.rs @@ -1,3 +1,4 @@ +use semver::Version; use toml::Value as TomlValue; /// Machine-readable channel name derived from the GCS stable pointer cache. @@ -28,110 +29,484 @@ pub fn channel_name_from_cache() -> Option<&'static str> { }) } -/// Read the minimum-version floor from one TOML layer. -pub fn minimum_version_from_toml(root: &TomlValue) -> Option<String> { - root.get("cli")? - .get("minimum_version")? - .as_str() - .map(str::to_owned) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum VersionKnob { + Minimum, + Maximum, + RequiredMinimum, + RequiredMaximum, } -/// Semver-max across candidates. Fails closed on any unparseable input so a -/// typo in one layer can't silently disable enforcement. -pub fn pick_max_minimum_version( - candidates: &[&str], -) -> Result<Option<String>, (String, semver::Error)> { - let mut best: Option<semver::Version> = None; - for raw in candidates { - let parsed = semver::Version::parse(raw).map_err(|e| ((*raw).to_string(), e))?; - match best.as_ref() { - Some(cur) if cur >= &parsed => {} - _ => best = Some(parsed), +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Bound { + Floor, + Ceiling, +} + +impl VersionKnob { + pub(crate) fn toml_key(self) -> &'static str { + match self { + VersionKnob::Minimum => "minimum_version", + VersionKnob::Maximum => "maximum_version", + VersionKnob::RequiredMinimum => "required_minimum_version", + VersionKnob::RequiredMaximum => "required_maximum_version", } } - Ok(best.map(|v| v.to_string())) -} -/// Effective `cli.minimum_version`: semver-max across all layers so managed -/// floors can't be lowered by user/project pins. -pub fn resolve_minimum_version() -> Result<Option<String>, (String, semver::Error)> { - let layers = match crate::config::ConfigLayers::load() { - Ok(l) => l, - Err(e) => { - tracing::warn!(error = %e, "minimum_version: failed to load config layers"); - return Ok(None); + pub(crate) fn env_var(self) -> &'static str { + match self { + VersionKnob::Minimum => "GROK_MINIMUM_VERSION", + VersionKnob::Maximum => "GROK_MAXIMUM_VERSION", + VersionKnob::RequiredMinimum => "GROK_REQUIRED_MINIMUM_VERSION", + VersionKnob::RequiredMaximum => "GROK_REQUIRED_MAXIMUM_VERSION", } - }; - resolve_minimum_version_from_layers(&layers) + } + + fn bound(self) -> Bound { + match self { + VersionKnob::Minimum | VersionKnob::RequiredMinimum => Bound::Floor, + VersionKnob::Maximum | VersionKnob::RequiredMaximum => Bound::Ceiling, + } + } } -/// Semver-max of `cli.minimum_version` across every layer (incl. the macOS MDM -/// floor) so a managed floor can't be lowered by a user/project pin. Split from -/// the disk load so the layer set can be injected in tests. -fn resolve_minimum_version_from_layers( +fn cli_version_from_toml(root: &TomlValue, key: &str) -> Option<String> { + root.get("cli")?.get(key)?.as_str().map(str::to_owned) +} + +fn env_version(var: &str) -> Option<String> { + std::env::var(var).ok() +} + +/// `cli.<key>` across the config layers. `managed_only` excludes the user's own +/// `config.toml` so a user-set bound can't count as organization policy. +fn version_candidates( layers: &crate::config::ConfigLayers, -) -> Result<Option<String>, (String, semver::Error)> { - let candidates: Vec<String> = [ - minimum_version_from_toml(&layers.system_managed), - minimum_version_from_toml(&layers.managed), - minimum_version_from_toml(&layers.user), + key: &str, + managed_only: bool, +) -> Vec<String> { + [ + cli_version_from_toml(&layers.system_managed, key), + cli_version_from_toml(&layers.managed, key), + (!managed_only) + .then(|| cli_version_from_toml(&layers.user, key)) + .flatten(), layers .user_requirements .as_ref() - .and_then(minimum_version_from_toml), + .and_then(|l| cli_version_from_toml(l, key)), layers .system_requirements .as_ref() - .and_then(minimum_version_from_toml), + .and_then(|l| cli_version_from_toml(l, key)), layers .mdm_requirements .as_ref() - .and_then(minimum_version_from_toml), + .and_then(|l| cli_version_from_toml(l, key)), ] .into_iter() .flatten() - .collect(); + .collect() +} - let refs: Vec<&str> = candidates.iter().map(String::as_str).collect(); - pick_max_minimum_version(&refs) +fn fold_bound(raws: Vec<String>, knob: VersionKnob) -> Option<Version> { + let mut best: Option<Version> = None; + for raw in raws { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + match Version::parse(trimmed) { + Ok(v) => { + best = Some(match (best, knob.bound()) { + (None, _) => v, + (Some(cur), Bound::Floor) => cur.max(v), + (Some(cur), Bound::Ceiling) => cur.min(v), + }); + } + Err(source) => tracing::warn!( + knob = knob.toml_key(), + value = %trimmed, + error = %source, + "ignoring invalid version bound" + ), + } + } + best +} + +/// Env joins the same extreme as the layers, so it can only tighten a managed bound. +fn resolve_version_bound<E: Fn(&str) -> Option<String>>( + layers: &crate::config::ConfigLayers, + env: &E, + knob: VersionKnob, +) -> Option<Version> { + let mut raws = version_candidates(layers, knob.toml_key(), false); + raws.extend(env(knob.env_var())); + fold_bound(raws, knob) +} + +/// Org-deployed layers only (no `user` layer, no env). +fn resolve_version_bound_managed( + layers: &crate::config::ConfigLayers, + knob: VersionKnob, +) -> Option<Version> { + fold_bound(version_candidates(layers, knob.toml_key(), true), knob) +} + +/// The four resolved version bounds: soft `minimum`/`maximum` steer the updater; +/// hard `required_*` gate startup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VersionPolicy { + pub minimum: Option<Version>, + pub maximum: Option<Version>, + pub required_minimum: Option<Version>, + pub required_maximum: Option<Version>, +} + +impl VersionPolicy { + /// Resolve from config layers and env; every knob fails open. + pub fn resolve() -> Self { + let layers = crate::config::ConfigLayers::load().unwrap_or_else(|e| { + tracing::warn!(error = %e, "version policy: config layers failed to load; using env overrides only"); + crate::config::ConfigLayers::default() + }); + Self::from_layers(&layers, &env_version) + } + + fn from_layers<E: Fn(&str) -> Option<String>>( + layers: &crate::config::ConfigLayers, + env: &E, + ) -> Self { + let get = |knob| resolve_version_bound(layers, env, knob); + let minimum = get(VersionKnob::Minimum); + let maximum = get(VersionKnob::Maximum); + let mut required_minimum = get(VersionKnob::RequiredMinimum); + let mut required_maximum = get(VersionKnob::RequiredMaximum); + + // A contradictory required range means a user/env bound crossed it. Managed + // policy is authoritative, so fall back to the managed-only bounds wholesale; + // a purely managed contradiction still fails open below. + if let (Some(lo), Some(hi)) = (&required_minimum, &required_maximum) + && lo > hi + { + required_minimum = resolve_version_bound_managed(layers, VersionKnob::RequiredMinimum); + required_maximum = resolve_version_bound_managed(layers, VersionKnob::RequiredMaximum); + } + + if let (Some(lo), Some(hi)) = (&minimum, &maximum) + && lo > hi + { + tracing::warn!(%lo, %hi, "minimum_version exceeds maximum_version; updates will be skipped"); + } + + Self { + minimum, + maximum, + required_minimum, + required_maximum, + } + } + + /// An unsatisfiable required range is ignored (fail-open). + pub fn has_contradictory_required_range(&self) -> bool { + matches!( + (&self.required_minimum, &self.required_maximum), + (Some(lo), Some(hi)) if lo > hi + ) + } + + /// `None` on a contradictory range, so the fail-open guard lives in one place. + fn effective_required_minimum(&self) -> Option<&Version> { + (!self.has_contradictory_required_range()) + .then_some(self.required_minimum.as_ref()) + .flatten() + } + + fn effective_required_maximum(&self) -> Option<&Version> { + (!self.has_contradictory_required_range()) + .then_some(self.required_maximum.as_ref()) + .flatten() + } + + /// Shared clamp core: cap at the ceilings, then the hard `required_minimum` + /// last so it wins over a lower ceiling. + fn clamp_version(&self, mut v: Version) -> Version { + if let Some(c) = &self.maximum + && v > *c + { + v = c.clone(); + } + if let Some(hi) = self.effective_required_maximum() + && v > *hi + { + v = hi.clone(); + } + if let Some(lo) = self.effective_required_minimum() + && v < *lo + { + v = lo.clone(); + } + v + } + + /// Clamp then skip; the single place that ordering lives. `None` means an + /// anti-downgrade skip. + pub fn resolve_target(&self, latest: &str) -> Option<String> { + let target = self.clamp(latest); + (!self.skips_update_target(&target)).then_some(target) + } + + /// Clamp `target` into range. An unparseable target resolves to the lowest + /// in-range version when a hard floor applies, else passes through unchanged. + fn clamp(&self, target: &str) -> String { + match Version::parse(target) { + Ok(v) => self.clamp_version(v).to_string(), + Err(_) if self.effective_required_minimum().is_some() => { + self.clamp_version(Version::new(0, 0, 0)).to_string() + } + Err(_) => target.to_string(), + } + } + + /// Anti-downgrade: skip a target below the soft `minimum`. Never clamps up. + fn skips_update_target(&self, target: &str) -> bool { + matches!( + (&self.minimum, Version::parse(target)), + (Some(min), Ok(t)) if t < *min + ) + } + + /// Lowest version an explicit `--version` pin may install, always agreeing + /// with [`clamp`](Self::clamp). Only the hard `required_minimum` blocks a pin. + pub fn installable_floor(&self) -> Option<Version> { + self.effective_required_minimum()?; + Some(self.clamp_version(Version::new(0, 0, 0))) + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn pick_max_minimum_version_picks_max_and_fails_closed_on_typos() { - assert_eq!( - pick_max_minimum_version(&["0.1.200", "0.1.100"]) - .unwrap() - .as_deref(), - Some("0.1.200") - ); - let (bad, _) = pick_max_minimum_version(&["not-a-version", "0.1.150"]).unwrap_err(); - assert_eq!(bad, "not-a-version"); + fn no_env(_: &str) -> Option<String> { + None + } + + fn layers(managed: &str, user: &str, mdm: &str) -> crate::config::ConfigLayers { + let parse = |s: &str| { + if s.is_empty() { + TomlValue::Table(Default::default()) + } else { + toml::from_str(s).unwrap() + } + }; + crate::config::ConfigLayers { + system_managed: TomlValue::Table(Default::default()), + managed: parse(managed), + user: parse(user), + user_requirements: None, + system_requirements: None, + mdm_requirements: if mdm.is_empty() { + None + } else { + Some(parse(mdm)) + }, + ..Default::default() + } + } + + fn v(s: &str) -> Version { + Version::parse(s).unwrap() } #[test] - fn minimum_version_includes_the_mdm_layer() { - // The MDM floor must win the semver-max so a managed minimum can't be - // lowered by a user pin. - let layers = crate::config::ConfigLayers { - system_managed: TomlValue::Table(Default::default()), - managed: TomlValue::Table(Default::default()), - user: toml::from_str("[cli]\nminimum_version = \"0.1.100\"\n").unwrap(), - user_requirements: None, - system_requirements: None, - mdm_requirements: Some( - toml::from_str("[cli]\nminimum_version = \"0.1.200\"\n").unwrap(), - ), - ..Default::default() + fn floor_is_semver_max_ceiling_is_semver_min_across_layers() { + let l = layers( + "[cli]\nminimum_version = \"0.1.100\"\nmaximum_version = \"0.2.150\"\n", + "[cli]\nminimum_version = \"0.1.50\"\nmaximum_version = \"0.2.130\"\n", + "[cli]\nminimum_version = \"0.1.200\"\nmaximum_version = \"0.2.140\"\n", + ); + let p = VersionPolicy::from_layers(&l, &no_env); + assert_eq!(p.minimum, Some(v("0.1.200"))); + assert_eq!(p.maximum, Some(v("0.2.130"))); + } + + #[test] + fn env_tightens_but_cannot_loosen() { + let l = layers( + "[cli]\nminimum_version = \"0.2.100\"\nmaximum_version = \"0.2.200\"\n", + "", + "", + ); + let tighten = |var: &str| match var { + "GROK_MINIMUM_VERSION" => Some("0.2.150".to_string()), + "GROK_MAXIMUM_VERSION" => Some("0.2.180".to_string()), + _ => None, }; + let p = VersionPolicy::from_layers(&l, &tighten); + assert_eq!(p.minimum, Some(v("0.2.150"))); + assert_eq!(p.maximum, Some(v("0.2.180"))); + + let loosen = |var: &str| match var { + "GROK_MINIMUM_VERSION" => Some("0.2.1".to_string()), + "GROK_MAXIMUM_VERSION" => Some("0.2.999".to_string()), + _ => None, + }; + let p = VersionPolicy::from_layers(&l, &loosen); + assert_eq!(p.minimum, Some(v("0.2.100"))); + assert_eq!(p.maximum, Some(v("0.2.200"))); + } + + #[test] + fn every_knob_fails_open_on_an_invalid_value() { + let l = layers( + "[cli]\nminimum_version = \"nope\"\nmaximum_version = \"bad\"\n\ + required_minimum_version = \"junk\"\nrequired_maximum_version = \"0.2.150\"\n", + "", + "", + ); + let p = VersionPolicy::from_layers(&l, &no_env); + assert_eq!(p.minimum, None); + assert_eq!(p.maximum, None); + assert_eq!(p.required_minimum, None); + assert_eq!(p.required_maximum, Some(v("0.2.150"))); + } + + #[test] + fn a_user_bound_cannot_cancel_a_managed_hard_bound() { + // Managed floor; an env ceiling below it would make the range + // contradictory and naively drop both. The managed floor must survive. + let l = layers("[cli]\nrequired_minimum_version = \"0.2.100\"\n", "", ""); + let low_ceiling = + |var: &str| (var == "GROK_REQUIRED_MAXIMUM_VERSION").then(|| "0.2.50".to_string()); + let p = VersionPolicy::from_layers(&l, &low_ceiling); + assert_eq!(p.required_minimum, Some(v("0.2.100"))); + assert_eq!(p.required_maximum, None); + + // Symmetric: a user floor can't cancel a managed ceiling. + let l = layers("[cli]\nrequired_maximum_version = \"0.2.100\"\n", "", ""); + let high_floor = + |var: &str| (var == "GROK_REQUIRED_MINIMUM_VERSION").then(|| "0.2.200".to_string()); + let p = VersionPolicy::from_layers(&l, &high_floor); + assert_eq!(p.required_maximum, Some(v("0.2.100"))); + assert_eq!(p.required_minimum, None); + + // Tightening BOTH sides into a contradiction must not drop the managed floor. + let l = layers("[cli]\nrequired_minimum_version = \"0.2.100\"\n", "", ""); + let both = |var: &str| match var { + "GROK_REQUIRED_MINIMUM_VERSION" => Some("99.0.0".to_string()), + "GROK_REQUIRED_MAXIMUM_VERSION" => Some("0.0.1".to_string()), + _ => None, + }; + let p = VersionPolicy::from_layers(&l, &both); + assert_eq!(p.required_minimum, Some(v("0.2.100"))); + assert_eq!(p.required_maximum, None); + assert!(!p.has_contradictory_required_range()); + + // A purely managed contradiction still fails open (ignored, not reverted). + let l = layers( + "[cli]\nrequired_minimum_version = \"0.3.0\"\nrequired_maximum_version = \"0.2.0\"\n", + "", + "", + ); + let p = VersionPolicy::from_layers(&l, &no_env); + assert!(p.has_contradictory_required_range()); + } + + fn pol( + min: Option<&str>, + max: Option<&str>, + rmin: Option<&str>, + rmax: Option<&str>, + ) -> VersionPolicy { + VersionPolicy { + minimum: min.map(v), + maximum: max.map(v), + required_minimum: rmin.map(v), + required_maximum: rmax.map(v), + } + } + + #[test] + fn soft_minimum_skips_a_downgrade_but_never_clamps_up() { + let p = pol(Some("0.2.100"), None, None, None); + assert!(p.skips_update_target("0.2.50")); + assert_eq!(p.clamp("0.2.50"), "0.2.50"); + assert!(!p.skips_update_target("0.2.100")); + assert!(!p.skips_update_target("dev")); + assert!(!pol(None, None, None, None).skips_update_target("0.0.1")); + assert_eq!(p.installable_floor(), None); + } + + #[test] + fn clamp_caps_at_ceilings_and_the_hard_floor_wins() { + assert_eq!(pol(None, None, None, None).clamp("0.2.200"), "0.2.200"); assert_eq!( - resolve_minimum_version_from_layers(&layers) - .unwrap() - .as_deref(), - Some("0.1.200"), + pol(None, Some("0.2.150"), None, None).clamp("0.2.200"), + "0.2.150" + ); + assert_eq!( + pol(None, None, None, Some("0.2.150")).clamp("0.2.200"), + "0.2.150" + ); + // Hard floor wins over a lower soft ceiling. + assert_eq!( + pol(None, Some("0.2.100"), Some("0.2.180"), None).clamp("0.2.50"), + "0.2.180" + ); + // Contradictory hard range is ignored (fail open). + assert_eq!( + pol(None, None, Some("0.3.0"), Some("0.2.0")).clamp("0.2.120"), + "0.2.120" + ); + // Unparseable target: floored to the hard minimum, else passed through. + assert_eq!( + pol(None, None, Some("0.2.100"), None).clamp("dev"), + "0.2.100" + ); + assert_eq!(pol(None, None, None, None).clamp("dev"), "dev"); + } + + #[test] + fn resolve_target_clamps_then_skips() { + assert_eq!( + pol(None, None, None, None).resolve_target("0.2.200"), + Some("0.2.200".into()) + ); + assert_eq!( + pol(Some("0.2.100"), None, None, None).resolve_target("0.2.50"), + None + ); + assert_eq!( + pol(None, Some("0.2.150"), None, None).resolve_target("0.2.200"), + Some("0.2.150".into()) + ); + // max < min clamps below the floor, then the skip catches the clamped + // value. This is the ordering every updater path depends on. + assert_eq!( + pol(Some("0.2.100"), Some("0.2.50"), None, None).resolve_target("0.2.200"), + None ); } + + #[test] + fn installable_floor_tracks_only_the_hard_minimum() { + assert_eq!( + pol(None, None, Some("0.2.120"), None).installable_floor(), + Some(v("0.2.120")) + ); + // Contradictory hard range is ignored, so there is no floor. + assert_eq!( + pol(None, None, Some("0.3.0"), Some("0.2.0")).installable_floor(), + None + ); + } + + #[test] + fn whitespace_and_empty_values_are_ignored() { + let l = layers("[cli]\nminimum_version = \" \"\n", "", ""); + let p = VersionPolicy::from_layers(&l, &no_env); + assert_eq!(p.minimum, None); + } } diff --git a/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs b/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs index bbc363a..4a7da7b 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs @@ -265,6 +265,12 @@ pub async fn set_voice_stt_language(value: String) -> Result<()> { update_config(|cfg| cfg.ui.voice_stt_language = Some(value)).await } +/// Persist `[ui].voice_keybind_enabled` via `update_config`. When `false` the +/// Ctrl+Space / F8 voice chord is ignored (`/voice` still works). +pub async fn set_voice_keybind_enabled(value: bool) -> Result<()> { + update_config(|cfg| cfg.ui.voice_keybind_enabled = Some(value)).await +} + /// Persist `[ui].default_selected_permission` via `update_config`. Value is /// one of the canonical strings from `DEFAULT_SELECTED_PERMISSION_CHOICES` /// (`default` | `allow_once` | `allow_always` | `reject`); `default` is the diff --git a/crates/codegen/xai-grok-shell/src/util/config/tips.rs b/crates/codegen/xai-grok-shell/src/util/config/tips.rs index 6941772..ce74d95 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/tips.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/tips.rs @@ -112,6 +112,85 @@ pub fn resolve_tips_from_disk( crate::util::tips::pick_and_advance(&all, grok_home) } +/// Parse `[slash_command_tags]` from a TOML value into a name → tag map. +/// Only string values are kept; non-string entries are ignored. +fn slash_command_tags_from_toml(root: &TomlValue) -> std::collections::HashMap<String, String> { + let mut out = std::collections::HashMap::new(); + if let Some(TomlValue::Table(table)) = root.get("slash_command_tags") { + for (name, value) in table { + if let Some(tag) = value.as_str() { + out.insert(name.clone(), tag.to_string()); + } + } + } + out +} + +/// Parse a `GROK_SLASH_COMMAND_TAGS` payload (a JSON object of string→string) +/// into a name → tag map. `None`/empty → empty; malformed → warn + empty. Split +/// from env-reading so the parse is unit-testable without mutating process env. +fn parse_slash_command_tags_json(raw: Option<&str>) -> std::collections::HashMap<String, String> { + // Unset or empty/whitespace-only is the normal "no override" state, not an + // error — only real, non-empty input is parsed (and warned on failure). + let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { + return std::collections::HashMap::new(); + }; + match serde_json::from_str::<std::collections::BTreeMap<String, String>>(raw) { + Ok(map) => map.into_iter().collect(), + Err(e) => { + tracing::warn!( + error = %e, + "ignoring malformed GROK_SLASH_COMMAND_TAGS; expected a JSON object of string values" + ); + std::collections::HashMap::new() + } + } +} + +/// Read per-command tags from the `GROK_SLASH_COMMAND_TAGS` env var. Unset → +/// empty; malformed → warn + empty. +fn slash_command_tags_from_env() -> std::collections::HashMap<String, String> { + parse_slash_command_tags_json(std::env::var("GROK_SLASH_COMMAND_TAGS").ok().as_deref()) +} + +/// Pure per-key merge of the three tag sources. Precedence lowest → highest: +/// remote (base) → local `[slash_command_tags]` → env. Every key from every +/// layer survives; higher layers override per key. Pure so precedence is +/// unit-testable without touching process env. +fn merge_command_tags( + remote: Option<&std::collections::BTreeMap<String, String>>, + local: std::collections::HashMap<String, String>, + env: std::collections::HashMap<String, String>, +) -> std::collections::HashMap<String, String> { + let mut out: std::collections::HashMap<String, String> = remote + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + out.extend(local); // local overrides remote + out.extend(env); // env overrides local + out +} + +/// Env-injectable core of [`resolve_slash_command_tags`]: remote → local +/// `[slash_command_tags]` → `env` (highest). Takes the env map explicitly so the +/// TOML-extraction + merge composition is hermetically testable (no process env). +fn resolve_slash_command_tags_with_env( + effective_config: &TomlValue, + remote: Option<&std::collections::BTreeMap<String, String>>, + env: std::collections::HashMap<String, String>, +) -> std::collections::HashMap<String, String> { + merge_command_tags(remote, slash_command_tags_from_toml(effective_config), env) +} + +/// Resolve per-command slash-dropdown tags. Precedence lowest → highest: remote +/// settings (base) → local `[slash_command_tags]` → `GROK_SLASH_COMMAND_TAGS` +/// env var (wins). Empty/missing everywhere → empty map. +pub fn resolve_slash_command_tags( + effective_config: &TomlValue, + remote: Option<&std::collections::BTreeMap<String, String>>, +) -> std::collections::HashMap<String, String> { + resolve_slash_command_tags_with_env(effective_config, remote, slash_command_tags_from_env()) +} + /// Read `[cli] channel` from config.toml. /// Returns `None` when absent (falls through to remote settings). pub fn channel_from_toml_opt(root: &TomlValue) -> Option<String> { @@ -177,4 +256,143 @@ mod tests { let s: RemoteSettings = serde_json::from_str(json).unwrap(); assert_eq!(s.tips, Some(vec!["a".to_string(), "b".to_string()])); } + + // Hermetic: drive the resolver through `_with_env` with an EXPLICIT env map + // so ambient `GROK_SLASH_COMMAND_TAGS` can't affect these assertions. + #[test] + fn resolve_slash_command_tags_local_overrides_remote_per_key() { + let mut remote = std::collections::BTreeMap::new(); + remote.insert("workflows".to_string(), "beta".to_string()); + remote.insert("model".to_string(), "remote-only".to_string()); + let local: TomlValue = + toml::from_str("[slash_command_tags]\nworkflows = \"new\"\nplan = \"local-only\"\n") + .unwrap(); + + let resolved = resolve_slash_command_tags_with_env( + &local, + Some(&remote), + std::collections::HashMap::new(), + ); + // Local wins per key. + assert_eq!(resolved.get("workflows").map(String::as_str), Some("new")); + // Remote-only key passes through. + assert_eq!( + resolved.get("model").map(String::as_str), + Some("remote-only") + ); + // Local-only key is added. + assert_eq!(resolved.get("plan").map(String::as_str), Some("local-only")); + assert_eq!(resolved.len(), 3); + } + + #[test] + fn resolve_slash_command_tags_missing_is_empty_and_remote_passes_through() { + let empty = TomlValue::Table(toml::map::Map::new()); + assert!( + resolve_slash_command_tags_with_env(&empty, None, std::collections::HashMap::new()) + .is_empty() + ); + + let mut remote = std::collections::BTreeMap::new(); + remote.insert("commit".to_string(), "new".to_string()); + let resolved = resolve_slash_command_tags_with_env( + &empty, + Some(&remote), + std::collections::HashMap::new(), + ); + assert_eq!(resolved.get("commit").map(String::as_str), Some("new")); + assert_eq!(resolved.len(), 1); + } + + // Env wins through the public composition — proven hermetically via `_with_env` + // (no process-env mutation). + #[test] + fn resolve_slash_command_tags_env_overrides_local_and_remote() { + let mut remote = std::collections::BTreeMap::new(); + remote.insert("workflows".to_string(), "remote".to_string()); + let local: TomlValue = + toml::from_str("[slash_command_tags]\nworkflows = \"local\"\n").unwrap(); + let mut env = std::collections::HashMap::new(); + env.insert("workflows".to_string(), "env".to_string()); + + let resolved = resolve_slash_command_tags_with_env(&local, Some(&remote), env); + assert_eq!(resolved.get("workflows").map(String::as_str), Some("env")); + assert_eq!(resolved.len(), 1); + } + + #[test] + fn remote_settings_slash_command_tags_absent_and_malformed() { + // Absent → None. + let s: RemoteSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(s.slash_command_tags, None); + // Malformed (array instead of map) → tolerated as None, whole parse ok. + let s: RemoteSettings = + serde_json::from_str(r#"{"slash_command_tags": ["oops"]}"#).unwrap(); + assert_eq!(s.slash_command_tags, None); + // Well-formed map parses. + let s: RemoteSettings = + serde_json::from_str(r#"{"slash_command_tags": {"commit": "new"}}"#).unwrap(); + assert_eq!( + s.slash_command_tags + .as_ref() + .and_then(|m| m.get("commit")) + .map(String::as_str), + Some("new") + ); + } + + #[test] + fn merge_command_tags_env_beats_local_beats_remote_per_key() { + let mut remote = std::collections::BTreeMap::new(); + remote.insert("a".to_string(), "remote-a".to_string()); + remote.insert("b".to_string(), "remote-b".to_string()); + remote.insert("r".to_string(), "remote-only".to_string()); + + let mut local = std::collections::HashMap::new(); + local.insert("a".to_string(), "local-a".to_string()); + local.insert("b".to_string(), "local-b".to_string()); + local.insert("l".to_string(), "local-only".to_string()); + + let mut env = std::collections::HashMap::new(); + env.insert("a".to_string(), "env-a".to_string()); + env.insert("e".to_string(), "env-only".to_string()); + + let merged = merge_command_tags(Some(&remote), local, env); + assert_eq!(merged.get("a").map(String::as_str), Some("env-a")); // env > local > remote + assert_eq!(merged.get("b").map(String::as_str), Some("local-b")); // local > remote (no env) + assert_eq!(merged.get("r").map(String::as_str), Some("remote-only")); // remote-only survives + assert_eq!(merged.get("l").map(String::as_str), Some("local-only")); // local-only survives + assert_eq!(merged.get("e").map(String::as_str), Some("env-only")); // env-only survives + assert_eq!(merged.len(), 5); + + // All sources empty → empty map. + assert!( + merge_command_tags( + None, + std::collections::HashMap::new(), + std::collections::HashMap::new() + ) + .is_empty() + ); + } + + #[test] + fn parse_slash_command_tags_json_handles_none_valid_and_malformed() { + // Unset → empty (no warn). + assert!(parse_slash_command_tags_json(None).is_empty()); + // Empty / whitespace-only is the normal "no override" state → empty (no warn). + assert!(parse_slash_command_tags_json(Some("")).is_empty()); + assert!(parse_slash_command_tags_json(Some(" ")).is_empty()); + // Valid JSON object of string→string → parsed. + let parsed = parse_slash_command_tags_json(Some(r#"{"commit":"new","plan":"beta"}"#)); + assert_eq!(parsed.get("commit").map(String::as_str), Some("new")); + assert_eq!(parsed.get("plan").map(String::as_str), Some("beta")); + assert_eq!(parsed.len(), 2); + // Array instead of object → empty (tolerated). + assert!(parse_slash_command_tags_json(Some(r#"["oops"]"#)).is_empty()); + // Non-string value → whole parse fails → empty (only string values kept). + assert!(parse_slash_command_tags_json(Some(r#"{"commit": 3}"#)).is_empty()); + // Not JSON → empty. + assert!(parse_slash_command_tags_json(Some("garbage")).is_empty()); + } } diff --git a/crates/codegen/xai-grok-shell/src/util/hooks.rs b/crates/codegen/xai-grok-shell/src/util/hooks.rs index 12313e0..4dc689e 100644 --- a/crates/codegen/xai-grok-shell/src/util/hooks.rs +++ b/crates/codegen/xai-grok-shell/src/util/hooks.rs @@ -2,7 +2,9 @@ use std::path::{Path, PathBuf}; +use xai_grok_config::resolve_global_hook_sources; use xai_grok_hooks::discovery::HookSource; +use xai_grok_hooks::error::HookError; /// Owned paths for hook sources. Callers borrow via `as_sources()`. pub struct HookSourcePaths { @@ -31,65 +33,68 @@ fn path_to_source(p: &Path) -> HookSource<'_> { } } -/// Build hook source paths for global (`~/`) and project (`<git_root>/`) scopes. -/// Callers gate project sources on trust via `as_sources(trusted)`. +fn include_claude_hooks(compat: &xai_grok_tools::types::compat::CompatConfig) -> bool { + compat.claude.hooks + && !crate::claude_import::is_claude_import_marked_with_log("discover_hook_source_paths") +} + +fn include_cursor_hooks(compat: &xai_grok_tools::types::compat::CompatConfig) -> bool { + compat.cursor.hooks +} + +/// Global + project hook source paths. Registry file is never a discovery +/// source; Claude/Cursor globals are appended when gates are on. pub fn discover_hook_source_paths( git_root: Option<&Path>, compat: &xai_grok_tools::types::compat::CompatConfig, ) -> HookSourcePaths { - // Compat gate: skip .claude hook sources when disabled. - let skip_claude_compat = !compat.claude.hooks; - // Phase 2 cutoff: if the user has imported, skip .claude/settings.json - // sources. Native .grok/hooks/ directories are still scanned (they hold - // any hooks that were imported by /import-claude). - let skip_claude = skip_claude_compat - || crate::claude_import::is_claude_import_marked_with_log("discover_hook_source_paths"); - - // Compat gate: skip Cursor hook sources when disabled. - let skip_cursor = !compat.cursor.hooks; - - let home = dirs::home_dir(); - // user_grok_home() is None when no home resolves, so inspect lists the same - // sources a live session loads, instead of a cwd-relative .grok. let grok = xai_grok_config::user_grok_home(); - let mut global = Vec::new(); + let home = dirs::home_dir(); + let include_claude = include_claude_hooks(compat); + let include_cursor = include_cursor_hooks(compat); - if !skip_claude && let Some(ref h) = home { - global.push(h.join(".claude").join("settings.json")); - global.push(h.join(".claude").join("settings.local.json")); - } - if let Some(ref grok) = grok { - global.push(grok.join("hooks")); - } + // Soft hooks-paths I/O keeps fixed slots; hard resolve omits Grok globals. + let mut global: Vec<PathBuf> = + match resolve_global_hook_sources(grok.as_deref(), /* reject_symlinks */ false) { + Ok(resolved) => { + if let Some(e) = &resolved.configured_error { + tracing::warn!( + error = %e, + "hooks-paths unreadable; retaining fixed Grok hook discovery sources only" + ); + } + resolved + .discovery_sources() + .map(|s| s.path.clone()) + .collect() + } + Err(e) => { + tracing::warn!( + error = %e, + "global hook source resolve hard-failed; omitting Grok global sources" + ); + Vec::new() + } + }; - let custom_paths: Vec<PathBuf> = grok - .as_ref() - .and_then(|g| std::fs::read_to_string(g.join("hooks-paths")).ok()) - .map(|content| { - content - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| PathBuf::from(l.trim())) - .collect() - }) - .unwrap_or_default(); - global.extend(custom_paths); - - if let Some(ref h) = home - && !skip_cursor - { - global.push(h.join(".cursor").join("hooks.json")); + if let Some(h) = home.as_deref() { + if include_claude { + global.push(h.join(".claude").join("settings.json")); + global.push(h.join(".claude").join("settings.local.json")); + } + if include_cursor { + global.push(h.join(".cursor").join("hooks.json")); + } } let mut project = Vec::new(); - if let Some(root) = git_root { - if !skip_claude { + if include_claude { project.push(root.join(".claude").join("settings.json")); project.push(root.join(".claude").join("settings.local.json")); } project.push(root.join(".grok").join("hooks")); - if !skip_cursor { + if include_cursor { project.push(root.join(".cursor").join("hooks.json")); } } @@ -99,18 +104,12 @@ pub fn discover_hook_source_paths( /// Single load entry point: build compat-aware sources, gate project sources on /// trust, then load. Every session-startup and mid-session reload site routes -/// through here so the source policy stays in one place. `discover_hook_source_paths` -/// and `HookSourcePaths::as_sources` stay public for the `inspect` path (which -/// enumerates sources with all vendors on) and the unit tests that assert on the -/// raw source lists. +/// through here so the source policy stays in one place. pub fn discover_hooks( git_root: Option<&Path>, compat: &xai_grok_tools::types::compat::CompatConfig, trusted: bool, -) -> ( - xai_grok_hooks::discovery::HookRegistry, - Vec<xai_grok_hooks::error::HookError>, -) { +) -> (xai_grok_hooks::discovery::HookRegistry, Vec<HookError>) { let source_paths = discover_hook_source_paths(git_root, compat); let (global_sources, project_sources) = source_paths.as_sources(trusted); xai_grok_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources) diff --git a/crates/codegen/xai-grok-shell/src/util/mod.rs b/crates/codegen/xai-grok-shell/src/util/mod.rs index 7df6175..c48a0d2 100644 --- a/crates/codegen/xai-grok-shell/src/util/mod.rs +++ b/crates/codegen/xai-grok-shell/src/util/mod.rs @@ -52,6 +52,62 @@ impl Drop for AbortOnDrop { } } +/// Expand a leading `~` to the home directory; other paths pass through. +pub(crate) fn expand_home(s: &str) -> std::path::PathBuf { + if let Some(stripped) = s.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } else if s == "~" + && let Some(home) = dirs::home_dir() + { + return home; + } + std::path::PathBuf::from(s) +} + +#[cfg(test)] +mod expand_home_tests { + use super::expand_home; + + #[test] + fn passthrough_for_absolute_path() { + assert_eq!( + expand_home("/abs/path"), + std::path::PathBuf::from("/abs/path") + ); + } + + #[test] + fn passthrough_for_relative_path() { + assert_eq!( + expand_home("rel/path"), + std::path::PathBuf::from("rel/path") + ); + } + + #[test] + fn bare_tilde() { + let home = dirs::home_dir().expect("home_dir required for this test"); + assert_eq!(expand_home("~"), home); + } + + #[test] + fn tilde_slash() { + let home = dirs::home_dir().expect("home_dir required for this test"); + assert_eq!(expand_home("~/foo/bar"), home.join("foo/bar")); + } + + #[test] + fn does_not_handle_user_tilde() { + // `~bob/path` is treated as a literal relative path. + assert_eq!( + expand_home("~bob/path"), + std::path::PathBuf::from("~bob/path") + ); + } +} + #[cfg(test)] mod is_user_instruction_path_tests { use super::is_user_instruction_path; diff --git a/crates/codegen/xai-grok-shell/tests/common/mod.rs b/crates/codegen/xai-grok-shell/tests/common/mod.rs index 766a5c6..c3922ce 100644 --- a/crates/codegen/xai-grok-shell/tests/common/mod.rs +++ b/crates/codegen/xai-grok-shell/tests/common/mod.rs @@ -335,6 +335,8 @@ pub fn test_sampler_config( .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), + query_params: Default::default(), + env_http_headers: Default::default(), context_window: 256_000, client_version: None, force_http1: false, diff --git a/crates/codegen/xai-grok-shell/tests/team_managed_config.rs b/crates/codegen/xai-grok-shell/tests/team_managed_config.rs index 807ad7e..6362b9d 100644 --- a/crates/codegen/xai-grok-shell/tests/team_managed_config.rs +++ b/crates/codegen/xai-grok-shell/tests/team_managed_config.rs @@ -1355,6 +1355,155 @@ async fn logout_clears_team_config() { ); } +/// Seed on-disk fail_closed policy for clear_orphan keep tests. +/// `with_managed_files` writes managed_config + sig sidecars. +/// `with_marker` stamps a fail_closed sync marker for team-ms-fail-closed. +fn seed_fail_closed_orphan_artifacts( + home: &std::path::Path, + with_managed_files: bool, + with_marker: bool, +) { + if with_managed_files { + std::fs::write(home.join("managed_config.toml"), TEAM_MANAGED).unwrap(); + std::fs::write(home.join("managed_config.sig.json"), r#"{"key_id":"v1"}"#).unwrap(); + std::fs::write(home.join("managed_identity.sig.json"), r#"{"key_id":"v1"}"#).unwrap(); + } + std::fs::write( + home.join("requirements.toml"), + format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), + ) + .unwrap(); + if with_marker { + xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker { + principal: Some("team-ms-fail-closed"), + had_managed_config: with_managed_files, + had_requirements: true, + key_fingerprint: None, + fail_closed: true, + }); + } +} + +/// fail_closed escape fix: personal (User) auth with leftover MS fail_closed +/// artifacts must NOT be wiped by `clear_orphan` — that was the offline +/// switch-to-personal escape (managed files deleted, session ALLOW unrestricted). +#[test] +#[serial] +fn clear_orphan_keeps_fail_closed_when_switched_to_personal() { + let home = test_home().clone(); + reset(&home); + seed_fail_closed_orphan_artifacts(&home, true, true); + + // Personal User principal (no team_id) — the escape repro. + let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope(); + let auth = serde_json::json!({ + scope: { + "key": "personal-token", + "auth_mode": "oidc", + "create_time": "2026-01-01T00:00:00Z", + "expires_at": FAR_FUTURE, + "user_id": "user-1", + } + }); + std::fs::write(home.join("auth.json"), auth.to_string()).unwrap(); + + xai_grok_shell::managed_config::clear_orphan(); + + assert!( + home.join("requirements.toml").exists(), + "fail_closed requirements must survive personal identity switch" + ); + assert!( + home.join("managed_config.toml").exists(), + "fail_closed managed_config must survive personal identity switch" + ); + assert!( + home.join("managed_config.sig.json").exists(), + "sig sidecar must survive personal identity switch under fail_closed" + ); + assert!( + home.join("managed_config_cache.json").exists(), + "fail_closed marker must survive personal identity switch" + ); +} + +/// Signed-out logout with fail_closed still keeps policy (same as personal switch). +#[test] +#[serial] +fn clear_orphan_keeps_fail_closed_when_signed_out() { + let home = test_home().clone(); + reset(&home); + seed_fail_closed_orphan_artifacts(&home, false, true); + // No auth.json = signed out. + xai_grok_shell::managed_config::clear_orphan(); + + assert!( + home.join("requirements.toml").exists(), + "signed-out must not wipe fail_closed requirements" + ); + assert!( + home.join("managed_config_cache.json").exists(), + "signed-out must not wipe fail_closed marker" + ); +} + +/// Marker stripped but requirements still say fail_closed = true: still keep. +#[test] +#[serial] +fn clear_orphan_keeps_fail_closed_requirements_without_marker() { + let home = test_home().clone(); + reset(&home); + seed_fail_closed_orphan_artifacts(&home, false, false); + // No marker, no team auth. + xai_grok_shell::managed_config::clear_orphan(); + + assert!( + home.join("requirements.toml").exists(), + "on-disk fail_closed requirements must be kept even without a marker" + ); +} + +/// Unreadable requirements (PermissionDenied) with no fail_closed marker must +/// still keep artifacts — cannot confirm disarmed, so clear_orphan must not wipe. +#[test] +#[serial] +#[cfg(unix)] +fn clear_orphan_keeps_unreadable_requirements_without_marker() { + use std::os::unix::fs::PermissionsExt; + + let home = test_home().clone(); + reset(&home); + seed_fail_closed_orphan_artifacts(&home, true, false); + // No fail_closed marker; requirements exist with fail_closed = true but will + // be made unreadable so the flag cannot be parsed. + let req = home.join("requirements.toml"); + std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap(); + struct RestorePerms<'a>(&'a std::path::Path); + impl Drop for RestorePerms<'_> { + fn drop(&mut self) { + let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600)); + } + } + let _restore = RestorePerms(&req); + + assert!( + xai_grok_config::fail_closed_policy_armed_at(&home), + "unreadable requirements must arm fail_closed" + ); + xai_grok_shell::managed_config::clear_orphan(); + + // Restore so exists() / cleanup can inspect the tree. + drop(_restore); + assert!( + home.join("requirements.toml").exists(), + "unreadable requirements must not be wiped by clear_orphan" + ); + assert!( + home.join("managed_config.toml").exists(), + "managed_config must survive when requirements are unreadable" + ); +} + /// An expired token for a still-signed-in team is not a logout: cold-start /// tokens are routinely expired before refresh, so the clear is expiry-agnostic. #[test] diff --git a/crates/codegen/xai-grok-subagent-resolution/Cargo.toml b/crates/codegen/xai-grok-subagent-resolution/Cargo.toml index cc2da24..0a328de 100644 --- a/crates/codegen/xai-grok-subagent-resolution/Cargo.toml +++ b/crates/codegen/xai-grok-subagent-resolution/Cargo.toml @@ -3,22 +3,25 @@ license = "Apache-2.0" name = "xai-grok-subagent-resolution" version = "0.1.0" edition.workspace = true -description = "Subagent configuration resolution: merges persona, role, and spawn-time overrides into a resolved spec" +description = "Shared subagent definition, runtime, prompt, and resume resolution" [dependencies] +chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +xai-grok-agent = { path = "../xai-grok-agent" } xai-grok-sampling-types = { path = "../xai-grok-sampling-types" } xai-grok-tools = { path = "../xai-grok-tools" } xai-tool-types.workspace = true -# TODO(phase2): add these when resolve_subagent_spec() composition is implemented: -# xai-grok-agent = { path = "../xai-grok-agent" } # AgentDefinition lookup -# xai-fast-worktree = { path = "../xai-fast-worktree" } # worktree creation + +[features] +default = [] [dev-dependencies] tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } toml = { workspace = true } [lints] diff --git a/crates/codegen/xai-grok-subagent-resolution/src/definition.rs b/crates/codegen/xai-grok-subagent-resolution/src/definition.rs new file mode 100644 index 0000000..ad98001 --- /dev/null +++ b/crates/codegen/xai-grok-subagent-resolution/src/definition.rs @@ -0,0 +1,408 @@ +//! Production subagent definition discovery and tool-policy resolution. +use crate::config::{SubagentPersona, SubagentRole}; +use crate::types::{EffectiveRuntimeConfig, ResolutionError}; +use std::collections::HashMap; +use std::path::Path; +use xai_grok_agent::config::{AgentDefinition, IsolationMode}; +use xai_grok_agent::plugins::PluginRegistry; +use xai_grok_agent::prompt::context::{PromptAudience, PromptContext}; +use xai_grok_tools::implementations::grok_build::task::types::{ + SubagentCapabilityModeExt, SubagentRuntimeOverrides, prune_orphaned_background_task_tools, +}; +use xai_grok_tools::registry::types::ToolConfig; +use xai_grok_tools::types::compat::CompatConfig; +use xai_grok_tools::types::template_renderer::TemplateRenderer; +use xai_grok_tools::types::tool::ToolKind; +use xai_tool_types::{SubagentCapabilityMode, SubagentIsolationMode}; +/// Inputs that affect definition discovery and spawn permission. +pub struct DefinitionResolutionContext<'a> { + pub cwd: &'a Path, + pub plugins: Option<&'a PluginRegistry>, + pub cli_agents: &'a [AgentDefinition], + pub toggles: &'a HashMap<String, bool>, + pub allowed_types: Option<&'a [String]>, +} +/// Inputs for validating a type when only session CLI names are available. +pub struct DefinitionValidationContext<'a> { + pub cwd: &'a Path, + pub plugins: Option<&'a PluginRegistry>, + pub cli_agent_names: &'a [String], + pub toggles: &'a HashMap<String, bool>, + pub allowed_types: Option<&'a [String]>, +} +/// Parent/runtime inputs that choose the production child harness flavor. +pub struct HarnessToolsetContext<'a> { + pub harness_override: Option<&'a str>, + pub parent_agent_name: Option<&'a str>, + pub parent_model_agent_type: Option<&'a str>, + pub file_tool_overrides: Option<&'a [ToolConfig]>, +} +/// `false` twin: the alternate flavors re-select toolset presets and +/// templates, so none is representable when the optional harness is compiled +/// out. Keeps ungated call sites compiling. +pub fn subagent_harness_flavor_is_representable(_agent_type: &str) -> bool { + false +} +/// Apply the production parent/harness-dependent child toolset selection. +pub fn apply_harness_toolset( + #[allow(unused_variables)] subagent_type: &str, + context: &HarnessToolsetContext<'_>, + definition: &mut AgentDefinition, +) { + let flavor_agent = context.harness_override.or_else(|| { + context + .parent_agent_name + .filter(|name| subagent_harness_flavor_is_representable(name)) + .or(context.parent_model_agent_type) + }); + if flavor_agent.is_some_and(subagent_harness_flavor_is_representable) { + } else if let Some(file_tools) = context.file_tool_overrides { + definition.override_file_tools(file_tools.to_vec()); + } +} +/// Discover the same project/builtin/user/plugin definition used by production, +/// with session CLI definitions as the final fallback. +pub fn discover_agent_definition( + subagent_type: &str, + context: &DefinitionResolutionContext<'_>, +) -> Option<AgentDefinition> { + xai_grok_agent::discovery::by_name_in_cwd_with_plugins( + subagent_type, + context.cwd, + context.plugins, + ) + .or_else(|| { + context + .cli_agents + .iter() + .find(|definition| definition.name == subagent_type) + .cloned() + }) +} +/// Sorted model-facing names available under the current discovery context. +pub fn available_agent_names(context: &DefinitionResolutionContext<'_>) -> Vec<String> { + let mut available: Vec<String> = xai_grok_agent::discovery::all_subagents_with_plugins( + context.cwd, + context.toggles, + context.plugins, + ) + .into_iter() + .map(|entry| entry.name) + .collect(); + for definition in context.cli_agents { + if context + .toggles + .get(&definition.name) + .copied() + .unwrap_or(true) + && !available.contains(&definition.name) + { + available.push(definition.name.clone()); + } + } + available.sort(); + available +} +/// Apply the production toggle and parent allow-list gates. +pub fn gate_agent_definition( + subagent_type: &str, + context: &DefinitionResolutionContext<'_>, +) -> Result<(), ResolutionError> { + if !context.toggles.get(subagent_type).copied().unwrap_or(true) { + return Err(ResolutionError::Disabled { + subagent_type: subagent_type.to_string(), + }); + } + if let Some(allowed) = context.allowed_types + && !allowed + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(subagent_type)) + { + return Err(ResolutionError::NotAllowed { + subagent_type: subagent_type.to_string(), + allowed: allowed.to_vec(), + }); + } + Ok(()) +} +/// Validate discovery, toggle, and allow-list gates without cloning definitions. +pub fn validate_agent_name( + subagent_type: &str, + context: &DefinitionValidationContext<'_>, +) -> Result<(), ResolutionError> { + let resolves = context + .cli_agent_names + .iter() + .any(|name| name == subagent_type) + || xai_grok_agent::discovery::by_name_in_cwd_with_plugins( + subagent_type, + context.cwd, + context.plugins, + ) + .is_some(); + if !resolves { + let mut available: Vec<String> = xai_grok_agent::discovery::all_subagents_with_plugins( + context.cwd, + context.toggles, + context.plugins, + ) + .into_iter() + .map(|entry| entry.name) + .collect(); + for name in context.cli_agent_names { + if context.toggles.get(name).copied().unwrap_or(true) && !available.contains(name) { + available.push(name.clone()); + } + } + available.sort(); + return Err(ResolutionError::Unknown { + subagent_type: subagent_type.to_owned(), + available, + }); + } + let gate_context = DefinitionResolutionContext { + cwd: context.cwd, + plugins: context.plugins, + cli_agents: &[], + toggles: context.toggles, + allowed_types: context.allowed_types, + }; + gate_agent_definition(subagent_type, &gate_context) +} +/// Discover and gate one production agent definition. +pub fn resolve_agent_definition( + subagent_type: &str, + context: &DefinitionResolutionContext<'_>, +) -> Result<AgentDefinition, ResolutionError> { + let definition = discover_agent_definition(subagent_type, context).ok_or_else(|| { + ResolutionError::Unknown { + subagent_type: subagent_type.to_string(), + available: available_agent_names(context), + } + })?; + gate_agent_definition(subagent_type, context)?; + Ok(definition) +} +/// Resolve the role selected by production: type-specific first, then persona. +pub fn select_role<'a>( + subagent_type: &str, + overrides: &SubagentRuntimeOverrides, + roles: &'a HashMap<String, SubagentRole>, +) -> (Option<&'a SubagentRole>, Option<String>) { + if let Some(role) = roles.get(subagent_type) { + return (Some(role), Some(subagent_type.to_string())); + } + let Some(persona) = overrides.persona.as_deref() else { + return (None, None); + }; + match roles.get(persona) { + Some(role) => (Some(role), Some(persona.to_string())), + None => (None, None), + } +} +/// Fill runtime values whose defaults live on the resolved agent definition. +pub fn apply_definition_runtime_defaults( + runtime: &mut EffectiveRuntimeConfig, + definition: &AgentDefinition, +) { + if runtime.capability_mode.is_none() { + runtime.capability_mode = definition.capability_mode; + } + if runtime.reasoning_effort.is_none() { + runtime.reasoning_effort = definition + .effort + .map(|effort| <&str>::from(effort).to_string()); + } + if runtime.isolation == SubagentIsolationMode::None + && definition.isolation == Some(IsolationMode::Worktree) + { + runtime.isolation = SubagentIsolationMode::Worktree; + } +} +/// Apply capability filtering and recursion depth to the exact production +/// definition toolset. +pub fn apply_child_tool_policy( + definition: &mut AgentDefinition, + capability_mode: Option<SubagentCapabilityMode>, + allow_nested_subagents: bool, +) { + if let Some(mode) = capability_mode { + mode.filter_tool_config(&mut definition.tool_config); + } + if !allow_nested_subagents { + definition + .tool_config + .tools + .retain(|tool| tool.kind != Some(ToolKind::Task)); + prune_orphaned_background_task_tools(&mut definition.tool_config); + } +} +/// Resolve runtime overrides and definition defaults in the production order. +pub fn resolve_runtime_config( + subagent_type: &str, + overrides: &SubagentRuntimeOverrides, + roles: &HashMap<String, SubagentRole>, + personas: &HashMap<String, SubagentPersona>, + cwd: Option<&Path>, + definition: &AgentDefinition, +) -> EffectiveRuntimeConfig { + let (role, role_name) = select_role(subagent_type, overrides, roles); + let mut runtime = crate::resolve_effective_overrides(overrides, role, personas, cwd, role_name); + apply_definition_runtime_defaults(&mut runtime, definition); + runtime +} +/// Render the same full subagent base template + definition body used by the +/// production `AgentBuilder`, for runtimes that expose only finalized tool +/// names rather than a complete `ToolBridge`. +pub fn render_subagent_system_prompt( + definition: &AgentDefinition, + runtime: &EffectiveRuntimeConfig, + renderer: &TemplateRenderer, + working_directory: &Path, +) -> Option<String> { + let context = PromptContext { + prompt_mode: definition.prompt_mode.clone(), + audience: PromptAudience::Subagent, + prompt_body: definition.prompt_body.clone(), + system_prompt: definition.system_prompt.clone(), + role_instructions: runtime.role_prompt.clone(), + persona_instructions: runtime.persona_instructions.clone(), + os_name: Some(format!( + "{} {}", + std::env::consts::OS, + std::env::consts::ARCH + )), + shell_path: std::env::var("SHELL").ok(), + working_directory: Some(working_directory.to_string_lossy().into_owned()), + current_date: Some(chrono::Local::now().format("%Y-%m-%d").to_string()), + is_non_interactive: true, + ..Default::default() + }; + context.render_with_renderer(renderer) +} +/// Render project instructions as the child's prepended user message. +pub async fn render_subagent_initial_user_message( + definition: &AgentDefinition, + working_directory: &Path, + compat: CompatConfig, +) -> Option<String> { + if !definition.agents_md { + return None; + } + let agents_md_files = xai_grok_agent::prompt::agents_md::read_agents_config_with_paths( + &working_directory.to_string_lossy(), + compat, + ) + .await; + PromptContext { + audience: PromptAudience::Subagent, + system_prompt: definition.system_prompt.clone(), + agents_md_files, + ..Default::default() + } + .agents_md_user_reminder() +} +#[cfg(test)] +mod tests { + use super::*; + fn context<'a>( + cwd: &'a Path, + toggles: &'a HashMap<String, bool>, + ) -> DefinitionResolutionContext<'a> { + DefinitionResolutionContext { + cwd, + plugins: None, + cli_agents: &[], + toggles, + allowed_types: None, + } + } + #[test] + fn builtin_explore_uses_production_read_only_toolset() { + let cwd = tempfile::tempdir().unwrap(); + let toggles = HashMap::new(); + let mut definition = + resolve_agent_definition("explore", &context(cwd.path(), &toggles)).unwrap(); + apply_child_tool_policy(&mut definition, None, false); + let kinds: Vec<Option<ToolKind>> = definition + .tool_config + .tools + .iter() + .map(|tool| tool.kind) + .collect(); + assert!(kinds.contains(&Some(ToolKind::Read))); + assert!(kinds.contains(&Some(ToolKind::Search))); + assert!(!kinds.contains(&Some(ToolKind::Execute))); + assert!(!kinds.contains(&Some(ToolKind::Task))); + } + #[test] + fn gates_disabled_and_not_allowed_definitions() { + let cwd = tempfile::tempdir().unwrap(); + let toggles = HashMap::from([("explore".to_string(), false)]); + let disabled = context(cwd.path(), &toggles); + assert!(matches!( + resolve_agent_definition("explore", &disabled), + Err(ResolutionError::Disabled { .. }) + )); + let allowed = ["plan".to_string()]; + let toggles = HashMap::new(); + let restricted = DefinitionResolutionContext { + allowed_types: Some(&allowed), + ..context(cwd.path(), &toggles) + }; + assert!(matches!( + resolve_agent_definition("explore", &restricted), + Err(ResolutionError::NotAllowed { .. }) + )); + } + #[test] + fn definition_defaults_fill_runtime_without_overwriting_explicit_values() { + let cwd = tempfile::tempdir().unwrap(); + let toggles = HashMap::new(); + let mut definition = + resolve_agent_definition("explore", &context(cwd.path(), &toggles)).unwrap(); + definition.isolation = Some(IsolationMode::Worktree); + let mut runtime = EffectiveRuntimeConfig::default(); + apply_definition_runtime_defaults(&mut runtime, &definition); + assert_eq!(runtime.isolation, SubagentIsolationMode::Worktree); + } + #[test] + fn full_prompt_uses_production_subagent_template_and_body() { + let cwd = tempfile::tempdir().unwrap(); + let toggles = HashMap::new(); + let definition = + resolve_agent_definition("explore", &context(cwd.path(), &toggles)).unwrap(); + let renderer = TemplateRenderer::new( + HashMap::from([ + (ToolKind::Read, "read_x".to_string()), + (ToolKind::List, "list_x".to_string()), + (ToolKind::Search, "search_x".to_string()), + ]), + HashMap::new(), + ); + let prompt = render_subagent_system_prompt( + &definition, + &EffectiveRuntimeConfig::default(), + &renderer, + cwd.path(), + ) + .unwrap(); + assert!(prompt.contains("<project_instructions_spec>")); + assert!(prompt.contains("read-only codebase exploration agent")); + assert!(prompt.contains(&format!("Workspace Path: {}", cwd.path().display()))); + assert!(!prompt.contains("${{")); + } + #[tokio::test] + async fn initial_user_message_contains_project_instructions() { + let cwd = tempfile::tempdir().unwrap(); + std::fs::write(cwd.path().join("AGENTS.md"), "Use the project contract.").unwrap(); + let toggles = HashMap::new(); + let definition = + resolve_agent_definition("explore", &context(cwd.path(), &toggles)).unwrap(); + let message = + render_subagent_initial_user_message(&definition, cwd.path(), CompatConfig::default()) + .await + .unwrap(); + assert!(message.contains("Use the project contract.")); + } +} diff --git a/crates/codegen/xai-grok-subagent-resolution/src/lib.rs b/crates/codegen/xai-grok-subagent-resolution/src/lib.rs index 3a861f6..8d0c536 100644 --- a/crates/codegen/xai-grok-subagent-resolution/src/lib.rs +++ b/crates/codegen/xai-grok-subagent-resolution/src/lib.rs @@ -14,24 +14,27 @@ //! Designed to be consumed by local hosts (e.g. `xai-grok-shell`) and any //! future remote spawn path that only needs pure resolution logic. //! -//! ## Planned composition API -//! -//! Future work may add a higher-level composition helper once shell call sites -//! are refactored onto this crate: -//! -//! - `resolve_subagent_spec()` composition function -//! - `SubagentSpec`, `ResolveSubagentRequest`, `ResolutionContext` boundary types -//! - Optional deps for `AgentDefinition` lookup and worktree creation -//! - Model override resolution chain (global > per-type > role > parent) -//! - Capability mode filtering (delegates to `SubagentCapabilityMode::filter_tool_config()`) +//! Definition discovery, gating, prompt context, runtime defaults, and +//! capability/depth tool policy are shared here. Model catalog selection and +//! workspace materialization remain host adapters. pub mod config; pub mod context; +pub mod definition; pub mod overrides; pub mod resume; pub mod types; pub use config::{PersonaIOField, SubagentPersona, SubagentRole}; +pub use definition::{ + DefinitionResolutionContext, DefinitionValidationContext, HarnessToolsetContext, + apply_child_tool_policy, apply_definition_runtime_defaults, apply_harness_toolset, + available_agent_names, discover_agent_definition, gate_agent_definition, + render_subagent_initial_user_message, render_subagent_system_prompt, resolve_agent_definition, + resolve_runtime_config, select_role, subagent_harness_flavor_is_representable, + validate_agent_name, +}; pub use overrides::{intersect_capability_modes, resolve_effective_overrides}; pub use resume::{ResumeValidationError, validate_resume_identity}; pub use types::{ContextSource, EffectiveRuntimeConfig, ResolutionError, ResumeSourceData}; +pub use xai_grok_agent::config::AgentDefinition; diff --git a/crates/codegen/xai-grok-subagent-resolution/src/types.rs b/crates/codegen/xai-grok-subagent-resolution/src/types.rs index 310088e..aa35313 100644 --- a/crates/codegen/xai-grok-subagent-resolution/src/types.rs +++ b/crates/codegen/xai-grok-subagent-resolution/src/types.rs @@ -82,6 +82,24 @@ pub struct ResumeSourceData { /// Errors that can occur during subagent resolution. #[derive(Debug, thiserror::Error)] pub enum ResolutionError { + /// No production or session CLI definition has this name. + #[error("unknown subagent type \"{subagent_type}\"; available: {available:?}")] + Unknown { + subagent_type: String, + available: Vec<String>, + }, + + /// The definition exists but is disabled by the session toggle. + #[error("subagent \"{subagent_type}\" is disabled")] + Disabled { subagent_type: String }, + + /// The parent session restricts which child types may run. + #[error("subagent \"{subagent_type}\" is not allowed; allowed: {allowed:?}")] + NotAllowed { + subagent_type: String, + allowed: Vec<String>, + }, + /// Persona was explicitly requested but could not be resolved. #[error("persona resolution failed: {0}")] PersonaResolution(String), diff --git a/crates/codegen/xai-grok-test-support/README.md b/crates/codegen/xai-grok-test-support/README.md index eaecbe3..36143c9 100644 --- a/crates/codegen/xai-grok-test-support/README.md +++ b/crates/codegen/xai-grok-test-support/README.md @@ -20,7 +20,7 @@ test-support surface. | Module | What it provides | |--------|------------------| | `inference_override` | Typed request matching and response precedence shared by all inference routes: endpoint + foreground/auxiliary classification, named expectation state, overlapping-duplicate fingerprint replay, per-expectation barriers, compatibility FIFO dispatch, auth rejection, and compatibility completion-gate policy. The module is crate-private; only `InferenceEndpoint`, `InferenceRequestMatcher`, and `InferenceExpectation` are re-exported. | -| `mock_server` | `MockInferenceServer` — `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models`, `/v1/settings`, `/v1/user` on `127.0.0.1:0`. `/v1/models` entries are `MockModelEntry` (re-exported as `MockModel` for PTY tests): `new(id)` / `with_agent_type(id, ty)` plus chainable `with_api_backend`, `with_supports_backend_search(bool)` → `supportsBackendSearch`, `with_supports_reasoning_effort(bool)` → `supportsReasoningEffort`, `with_reasoning_effort(&str)` → `reasoningEffort`, `with_reasoning_efforts(Vec<Value>)` → `reasoningEfforts` (raw option tables/bare strings), all emitted top-level as `parse_remote_model_value` reads them. Inference precedence is **matched expectation > compatibility FIFO > required-auth > echo/fixed mode**. Register a uniquely named response with `expect_response(name, InferenceRequestMatcher::{foreground,auxiliary}(InferenceEndpoint::{ChatCompletions,Responses,Messages}), ScriptedResponse)` or `expect_response_blocked`; duplicate names fail at registration and requests atomically claim one matching expectation. Overlapping duplicate requests replay by a deterministic fingerprint of endpoint, request kind, non-empty `x-grok-req-id`, and serialized request body; tool-result follow-ups reuse the turn id but change the body, so they claim the next expectation. Production exposes no explicit HTTP attempt/model-call identity, so completed sequential retries are intentionally not inferred from timing: after the active shared call settles, an identical request claims the next expectation. A foreground request normally carries a non-empty `x-grok-turn-idx`; a non-turn non-empty `x-grok-req-id` is auxiliary even if it uses tools, and empty headers fall through to the 2+-tool compatibility heuristic. The returned `InferenceExpectation` has watch-backed `wait_received`, `wait_blocked`, `release`, `wait_satisfied`, `is_satisfied`, and `assert_satisfied` lifecycle operations. `release` only opens the barrier; response-body/stream-owned RAII publishes `Satisfied` only when the primary crosses terminal and every active overlapping copy settles. Primary cancellation cleans up without satisfaction or replay retention, and dropping a handle safely releases blocked work. Echo (default) streams `Echo: <last user message>` and fixed mode via `set_response(text)` reconstructs bytes exactly. Constructors (`start`, `start_with_models`, `start_with_required_auth`) return `anyhow::Result`. Settings are 404-until-set (`set_settings(impl Serialize)`, `preset_allow_access()` for the `{"allow_access": true}` gate); scripted `/v1/settings` one-shots (`enqueue_response`) take precedence over the steady-state value (stale-snapshot tests). `/v1/user` serves a minimal `UserInfo` whose `subscriptionTier` is controlled by `set_user_subscription_tier(Option<&str>)` (`None` = free); its log entries keep the query string (e.g. `/v1/user?include=subscription`) so subscription-check cadence is countable. Request log: `requests()` (`LogEntry` with body, `authorization`, full POST headers + `header(name)` accessor), `request_bodies()`, `request_count()`, `has_chat_completion_request()` / `has_responses_request()` (exact, per endpoint), `messages_request_count()`, `last_system_prompt()`, `request_log_summary()`. **Storage:** `POST /v1/storage` with flippable 401 (`set_storage_unauthorized`); accepted uploads via `storage_uploads()` → `StorageUpload { path, size, body, authorization }` (`body` retained up to 256 KiB, empty above; `authorization` is the raw header). Runtime knobs: `set_models`, `set_messages_stop_reason`. Shuts down on drop. | +| `mock_server` | `MockInferenceServer` — `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models`, `/v1/settings`, `/v1/user` on `127.0.0.1:0`. `/v1/models` entries are `MockModelEntry` (re-exported as `MockModel` for PTY tests): `new(id)` / `with_agent_type(id, ty)` plus chainable `with_api_backend`, `with_supports_backend_search(bool)` → `supportsBackendSearch`, `with_supports_reasoning_effort(bool)` → `supportsReasoningEffort`, `with_reasoning_effort(&str)` → `reasoningEffort`, `with_reasoning_efforts(Vec<Value>)` → `reasoningEfforts` (raw option tables/bare strings), all emitted top-level as `parse_remote_model_value` reads them. Inference precedence is **matched expectation > compatibility FIFO > required-auth > echo/fixed mode**. Register a uniquely named response with `expect_response(name, InferenceRequestMatcher::{foreground,auxiliary}(InferenceEndpoint::{ChatCompletions,Responses,Messages}), ScriptedResponse)` or `expect_response_blocked`; duplicate names fail at registration and requests atomically claim one matching expectation. Overlapping duplicate requests replay by a deterministic fingerprint of endpoint, request kind, non-empty `x-grok-req-id`, and serialized request body; tool-result follow-ups reuse the turn id but change the body, so they claim the next expectation. Production exposes no explicit HTTP attempt/model-call identity, so completed sequential retries are intentionally not inferred from timing: after the active shared call settles, an identical request claims the next expectation. A foreground request normally carries a non-empty `x-grok-turn-idx`; a non-turn non-empty `x-grok-req-id` is auxiliary even if it uses tools, and empty headers fall through to the 2+-tool compatibility heuristic. The returned `InferenceExpectation` has watch-backed `wait_received`, `wait_blocked`, `release`, `wait_satisfied`, `is_satisfied`, and `assert_satisfied` lifecycle operations. `release` only opens the barrier; response-body/stream-owned RAII publishes `Satisfied` only when the primary crosses terminal and every active overlapping copy settles. Primary cancellation cleans up without satisfaction or replay retention, and dropping a handle safely releases blocked work. Echo (default) streams `Echo: <last user message>` and fixed mode via `set_response(text)` reconstructs bytes exactly. Constructors (`start`, `start_with_models`, `start_with_required_auth`) return `anyhow::Result`. Settings are 404-until-set (`set_settings(impl Serialize)`, `preset_allow_access()` for the `{"allow_access": true}` gate); scripted `/v1/settings` one-shots (`enqueue_response`) take precedence over the steady-state value (stale-snapshot tests). `/v1/user` serves a minimal `UserInfo` whose `subscriptionTier` is controlled by `set_user_subscription_tier(Option<&str>)` (`None` = free); its log entries keep the query string (e.g. `/v1/user?include=subscription`) so subscription-check cadence is countable. Request log: `requests()` (`LogEntry` with body, `authorization`, full POST headers + `header(name)` accessor), `request_bodies()`, `request_count()`, `has_chat_completion_request()` / `has_responses_request()` (exact, per endpoint), `messages_request_count()`, `last_system_prompt()`, `request_log_summary()`. **Storage:** `POST /v1/storage` with flippable 401 (`set_storage_unauthorized`); accepted uploads via `storage_uploads()` → `StorageUpload { path, size, body, authorization }` (`body` retained up to 256 KiB, empty above; `authorization` is the raw header). **Privacy:** `PUT /v1/privacy/coding-data-retention` mimics cli-chat-proxy's success path — 200 echoing the request's `codingDataRetentionOptOut` boolean, logged like every route (privacy-banner e2e). Runtime knobs: `set_models`, `set_messages_stop_reason`. Shuts down on drop. | | `scripted` | Data-only response bodies (no axum types in the public surface): `SseEvent { event, data }` (`::data`, `::with_event`), `ScriptedBody::{Json, Sse, Raw}` (`Raw` = byte-controllable malformed SSE), `ScriptedResponse { status, headers, body }` (`::sse`, `::json`, `::text`). Prefer request-matched expectations for inference calls; `enqueue_response(path, response)` remains a compatibility FIFO per path and is still used for non-inference one-shots such as `/v1/settings`. Scripted SSE honors `set_chunk_delay`; matched JSON, raw, SSE, and even empty SSE bodies all honor per-expectation completion barriers. The compatibility `hold_agent_completions` gate also covers foreground scripted SSE on all three inference endpoints. Validation is eager — bad status/header panics at registration. | | `sse` | The three wire formats as event-list builders: `chat_completion_events` / `responses_api_events` / `messages_api_events(text, model, stop_reason)` (echo-style, whitespace-collapsing) plus byte-exact axum variants `chat_completion_events_exact` / `responses_api_events_exact` and matching public scripted variants `chat_completion_script_exact` / `responses_api_script_exact` (messages is single-delta, byte-exact by construction). The exact/echo split is load-bearing — see the in-module byte-exactness tests. Also the scripted-scenario builders returning `SseEvent`s (for `ScriptedResponse::sse`): `responses_api_reasoning_only_events(reasoning, model)` — reasoning summary deltas completing with a `reasoning` item but no message/output-text, so the shell collector classifies the turn `EmptyReason::ReasoningOnly` (the model-doomloop trigger); `responses_api_reasoning_and_text_events(reasoning, text, model)` — reasoning deltas then a normal text answer (the ordinary reasoning-model turn); `responses_api_reasoning_then_tool_call_events(reasoning, call_id, name, arguments, model)` + its Chat Completions twin `chat_completions_reasoning_then_tool_call_events(...)` — reasoning deltas then one tool call (the think-then-call turn whose tool call finishes the thought and keeps the turn non-empty); the doom-loop check trio: `responses_api_doom_loop_check_events(triggers, reasoning, model)` — a doomed reasoning-only turn with NAMED `response.doom_loop_check` frames re-sent per cumulative prefix of `triggers` plus the terminal `doom_loop_check.triggers` copy on `response.completed`, `responses_api_doom_loop_terminal_only_events(triggers, reasoning, text, model)` — a normal answer whose terminal response alone carries the field, and `responses_api_with_doom_loop_frame(check_frame_data, reasoning, text, model)` — splices one named check frame with a caller-supplied payload (byte-exact `xai_grok_sampling_types::doom_loop::SAMPLE_CHECK_EVENT_DATA{,_CUMULATIVE}` fixtures or malformed variants) into an ordinary turn. | | `sandbox` | `TestSandbox` — one owner for a temp root, isolated `HOME`/`USERPROFILE`, explicit `GROK_HOME`, workspace, and `TMPDIR`/`TMP`/`TEMP`. Child commands use `env_clear()` plus a minimal platform allowlist, loopback `NO_PROXY`, interactive-git suppression, telemetry/feedback/trace/instrumentation/updater kill switches, and no ambient leader socket or proxy variables. Unix preserves the host `SHELL` when set and falls back to `/bin/sh`; explicit overrides still win. `TestSandbox::builder().mock_url(url)` wires grok API/models/auxiliary endpoints plus a fake CI key; `.git()` initializes and commits the owned workspace. Bazel test targets that execute Git directly provide `@git_hermetic` runfiles and `GIT_BIN_PATH`; at construction, `TestSandbox` resolves that path against the parent cwd while it is still the Bazel execroot, stores absolute `GIT_BIN_PATH`/`GIT_EXEC_PATH`, and prepends the binary parent to its baseline `PATH`. `TestSandbox::git_command()` applies that cleared environment plus detached, non-interactive Git settings. Without `GIT_BIN_PATH`, ordinary baseline `PATH` is preserved and no special binary/exec vars are added. `set_env`/`extend_env` and `remove_env` are the narrow post-baseline override seam. `diagnostic_summary()` redacts credential-key segments/suffixes and all malformed/non-loopback endpoints; loopback URLs are parsed and stripped of userinfo/query/fragment. | diff --git a/crates/codegen/xai-grok-test-support/src/mock_server.rs b/crates/codegen/xai-grok-test-support/src/mock_server.rs index 6132139..fd1e5d3 100644 --- a/crates/codegen/xai-grok-test-support/src/mock_server.rs +++ b/crates/codegen/xai-grok-test-support/src/mock_server.rs @@ -20,7 +20,7 @@ use anyhow::Context as _; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::{get, post, put}; use axum::{Json, Router}; use futures_util::stream; use serde_json::{Value, json}; @@ -246,7 +246,8 @@ struct StorageState { } /// Mock `/v1/chat/completions` + `/v1/responses` + `/v1/messages` + -/// `/v1/models` + `/v1/settings` + `/v1/storage` server. +/// `/v1/models` + `/v1/settings` + `/v1/storage` + +/// `/v1/privacy/coding-data-retention` server. /// Logs all requests. Shuts down on drop. pub struct MockInferenceServer { addr: SocketAddr, @@ -956,6 +957,32 @@ impl MockInferenceServer { } }), ) + .route( + "/v1/privacy/coding-data-retention", + put({ + let log = log.clone(); + move |headers: HeaderMap, Json(body): Json<Value>| { + let log = log.clone(); + async move { + let auth = Self::extract_auth(&headers); + log.record( + "PUT", + "/v1/privacy/coding-data-retention", + Some(&body), + auth.as_deref(), + Self::headers_vec(&headers), + ); + // Echo the received flag back like the real + // cli-chat-proxy does on success. + let opt_out = body + .get("codingDataRetentionOptOut") + .cloned() + .unwrap_or(Value::Bool(false)); + Json(json!({ "codingDataRetentionOptOut": opt_out })) + } + } + }), + ) .route( "/v1/user", get( @@ -1674,6 +1701,39 @@ mod tests { assert_eq!(body, json!({ "allow_access": true })); } + #[tokio::test] + async fn privacy_coding_data_retention_echoes_flag_and_logs() { + let server = MockInferenceServer::start().await.unwrap(); + let url = format!("{}/privacy/coding-data-retention", server.url()); + + for flag in [false, true] { + let resp = reqwest::Client::new() + .put(&url) + .json(&json!({ "codingDataRetentionOptOut": flag })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body, json!({ "codingDataRetentionOptOut": flag })); + } + + let entries = server.requests(); + let puts: Vec<_> = entries + .iter() + .filter(|e| e.method == "PUT" && e.path == "/v1/privacy/coding-data-retention") + .collect(); + assert_eq!(puts.len(), 2); + assert_eq!( + puts[0].body, + Some(json!({ "codingDataRetentionOptOut": false })) + ); + assert_eq!( + puts[1].body, + Some(json!({ "codingDataRetentionOptOut": true })) + ); + } + #[tokio::test] async fn request_bodies_returns_bodies_in_arrival_order() { let server = MockInferenceServer::start().await.unwrap(); diff --git a/crates/codegen/xai-grok-tools-api/build.rs b/crates/codegen/xai-grok-tools-api/build.rs index 55ab20a..8cbf62d 100644 --- a/crates/codegen/xai-grok-tools-api/build.rs +++ b/crates/codegen/xai-grok-tools-api/build.rs @@ -29,6 +29,22 @@ fn main() { ".xai.grok.tools.v1.ToolConfigEntry.description_override", "#[serde(default)]", ) + .field_attribute( + ".xai.grok.tools.v1.FinalizeToolServerConfigRequest.client_callback_addr", + "#[serde(default)]", + ) + .field_attribute( + ".xai.grok.tools.v1.FinalizeToolServerConfigRequest.session_id", + "#[serde(default)]", + ) + .field_attribute( + ".xai.grok.tools.v1.FinalizeToolServerConfigRequest.client_callback_secret", + "#[serde(default)]", + ) + .field_attribute( + ".xai.grok.tools.v1.FinalizeToolServerConfigResponse.callback_status", + "#[serde(default)]", + ) .compile_protos(&["proto/grok-tools.proto"], &["proto/"]) .unwrap(); } diff --git a/crates/codegen/xai-grok-tools-api/proto/grok-tools.proto b/crates/codegen/xai-grok-tools-api/proto/grok-tools.proto index 60ada1a..0e6791a 100644 --- a/crates/codegen/xai-grok-tools-api/proto/grok-tools.proto +++ b/crates/codegen/xai-grok-tools-api/proto/grok-tools.proto @@ -171,6 +171,29 @@ service GrokToolsService { } } +// ============================================================================ +// CLIENT CALLBACK SERVICE +// ============================================================================ + +/// GrokToolsCallbackService is implemented by the client/host process and +/// dialed by grok-tools-server during finalize when callback fields are set. +/// +/// The contract is deliberately minimal — the server owns all subagent +/// knowledge (type registry, prompts, toolset resolution, and lifecycle state +/// in the shared coordinator actor); the host only does genuinely host-side work: +/// +/// - `SpawnSubagent`: execute one resolved child request to completion. +/// The RPC spans the whole request; cancellation is the call's cancellation. +/// - `SendNotification`: fire-and-forget push of serde-tagged notification +/// JSON (e.g. `SubagentCompleted`) into the host's conversation stream. +service GrokToolsCallbackService { + /// Rust sends one tool notification to the host process. + rpc SendNotification(ToolNotificationMsg) returns (NotificationAck); + + /// Execute one resolved child request and return its final result. + rpc SpawnSubagent(SpawnSubagentRequest) returns (SubagentResultMsg); +} + // ============================================================================ // TOOL SERVER CONFIG MESSAGES (finalize-time configuration) // ============================================================================ @@ -207,6 +230,23 @@ message FinalizeToolServerConfigRequest { /// Behavior preset name (e.g. "current", "legacy-0.4.10"). /// Applied to all version-managed tools. Defaults to "current" when empty. optional string behavior_preset = 5; + + /// Optional host callback address. When set, grok-tools-server dials this + /// client-hosted gRPC endpoint during finalize and injects callback-backed + /// resource views for notifications and subagents. The server accepts bare + /// "host:port" addresses and assumes "http://". + optional string client_callback_addr = 6; + + /// Logical session identifier used for callback correlation and resource + /// scoping (SessionIdResource / OwnerSessionId). If omitted while a callback + /// address is present, the server uses its generated process session id. + optional string session_id = 7; + + /// Per-session bearer secret required by the client-hosted callback service. + /// Only used when client_callback_addr is set. + optional string client_callback_secret = 8; + + reserved 9; } /// Per-tool configuration entry. @@ -281,6 +321,13 @@ message VersionWarning { string message = 4; } +/// Status of the optional callback connection established during finalize. +message CallbackStatus { + bool connected = 1; + repeated string active_surfaces = 2; + optional string message = 3; +} + /// Response from finalizing the tool server configuration. message FinalizeToolServerConfigResponse { bool success = 1; @@ -293,6 +340,78 @@ message FinalizeToolServerConfigResponse { /// Deprecation/lifecycle warnings for resolved versions. /// Empty when all versions are Active. repeated VersionWarning version_warnings = 4; + + /// Optional status for the finalize-time callback dial. + optional CallbackStatus callback_status = 5; +} + +// ============================================================================ +// CALLBACK MESSAGES +// ============================================================================ + +message ToolNotificationMsg { + string session_id = 1; + /// serde(tag = "type") JSON for xai_grok_tools::notification::ToolNotification. + string notification_json = 2; + /// Monotonically increasing sequence number for observability. + uint64 sequence = 3; +} + +message NotificationAck {} + +/// Execute one resolved child request. +/// +/// The server resolves everything from its own registry and finalized +/// toolset before dialing: `system_prompt` is the complete production +/// subagent base template + definition body rendered with the child's actual +/// (possibly randomized) tool names, and `tool_names` is selected from the +/// canonical production AgentDefinition. The host executes the request with +/// the supplied tool names and working directory. +/// +/// Lifecycle (backgrounding, foreground budget, query/cancel, completion +/// surfacing) is owned by the server-side coordinator actor; the host keeps +/// no lifecycle state. Cancellation = the gRPC call's cancellation. +message SpawnSubagentRequest { + string id = 1; + string prompt = 2; + string description = 3; + string subagent_type = 4; + string parent_session_id = 5; + optional string parent_prompt_id = 6; + /// Resume a previously completed child: the server validates source identity + /// and workspace; the host replays non-system turns, installs the freshly + /// rendered `system_prompt`, and appends `prompt`. + optional string resume_from = 7; + optional string cwd = 8; + reserved 9, 10, 11, 12, 13; + /// Complete rendered production system prompt for the child. + optional string system_prompt = 14; + /// Client-facing names of the tools the child may use. + repeated string tool_names = 15; + /// Optional user message prepended before the task prompt. + optional string initial_user_message = 16; +} + +message SubagentResultMsg { + bool success = 1; + string output = 2; + optional string error = 3; + bool cancelled = 4; + /// Deprecated: identity is stamped by the server. + string subagent_id = 5; + /// Deprecated: identity is stamped by the server. + string child_session_id = 6; + uint32 tool_calls = 7; + uint32 turns = 8; + uint64 duration_ms = 9; + /// Legacy total/context usage fallback. + uint64 tokens_used = 10; + /// Deprecated: workspace is stamped by the server. + optional string worktree_path = 11; + /// Deprecated: delivery state is owned by the server. + bool backgrounded = 12; + optional uint64 output_tokens_used = 13; + optional uint64 total_tokens_used = 14; } // ============================================================================ @@ -641,6 +760,13 @@ message ToolInfo { /// Populated by ListTools pre-finalization from the fully-qualified /// registry key (e.g. "GrokBuild:grep" → namespace="GrokBuild"). string namespace = 19; + + // 20-21 were `allowed_capability_modes` / `tool_kind`, exposed so remote + // clients could filter child subagent toolsets themselves. The server now + // resolves child toolsets from its own registry and sends the result in + // SpawnSubagentRequest, so nothing consumes them. No released pin ever + // read them. + reserved 20, 21; } /// Describes an output format supported by a tool diff --git a/crates/codegen/xai-grok-tools-api/src/lib.rs b/crates/codegen/xai-grok-tools-api/src/lib.rs index d8b2ab8..3b7caf3 100644 --- a/crates/codegen/xai-grok-tools-api/src/lib.rs +++ b/crates/codegen/xai-grok-tools-api/src/lib.rs @@ -21,6 +21,7 @@ pub use pb::{ AgentToolExecConfig, AgentToolRetryConfig, // Request/response types + CallbackStatus, ClearToolOverrideRequest, ClearToolOverrideResponse, DisableToolRequest, @@ -75,10 +76,12 @@ pub use pb::{ SetToolOverrideResponse, SetTruncationConfigRequest, SetTruncationConfigResponse, + SpawnSubagentRequest, // Streaming types StreamDataChunk, StreamDataKind, StreamFinalResult, + SubagentResultMsg, // Capability/metadata types ToolCapabilities, ToolCategory, @@ -86,6 +89,7 @@ pub use pb::{ ToolConfigEntry, ToolError, ToolInfo, + ToolNotificationMsg, ToolSource, ToolStreamChunk, ToolSuccess, diff --git a/crates/codegen/xai-grok-tools-api/tests/wire_shape.rs b/crates/codegen/xai-grok-tools-api/tests/wire_shape.rs index d809e2c..b8145a5 100644 --- a/crates/codegen/xai-grok-tools-api/tests/wire_shape.rs +++ b/crates/codegen/xai-grok-tools-api/tests/wire_shape.rs @@ -21,6 +21,19 @@ fn full_entry() -> ToolConfigEntry { } } +fn finalize_request_minimal() -> xai_grok_tools_api::FinalizeToolServerConfigRequest { + xai_grok_tools_api::FinalizeToolServerConfigRequest { + tools: vec![], + truncation: None, + system_reminders_enabled: false, + initial_tool_state_json: None, + behavior_preset: None, + client_callback_addr: None, + session_id: None, + client_callback_secret: None, + } +} + #[test] fn tool_config_entry_serializes_to_pinned_json_shape() { let value = serde_json::to_value(full_entry()).expect("serialize"); @@ -100,3 +113,38 @@ fn explicit_null_map_is_rejected() { "null params_name_overrides must be rejected (omit the key or send {{}})" ); } + +#[test] +fn finalize_request_callback_fields_are_optional_and_snake_case() { + let mut req = finalize_request_minimal(); + req.client_callback_addr = Some("http://127.0.0.1:50051".to_owned()); + req.session_id = Some("session-123".to_owned()); + req.client_callback_secret = Some("secret-123".to_owned()); + + let value = serde_json::to_value(&req).expect("serialize"); + assert_eq!( + value.get("client_callback_addr"), + Some(&serde_json::json!("http://127.0.0.1:50051")) + ); + assert_eq!( + value.get("session_id"), + Some(&serde_json::json!("session-123")) + ); + assert_eq!( + value.get("client_callback_secret"), + Some(&serde_json::json!("secret-123")) + ); +} + +#[test] +fn finalize_request_callback_fields_default_when_absent() { + let back: xai_grok_tools_api::FinalizeToolServerConfigRequest = + serde_json::from_value(serde_json::json!({ + "tools": [], + "system_reminders_enabled": false, + })) + .expect("deserialize sparse finalize request"); + assert_eq!(back.client_callback_addr, None); + assert_eq!(back.session_id, None); + assert_eq!(back.client_callback_secret, None); +} diff --git a/crates/codegen/xai-grok-tools/Cargo.toml b/crates/codegen/xai-grok-tools/Cargo.toml index 3b5b8f4..27ee2bb 100644 --- a/crates/codegen/xai-grok-tools/Cargo.toml +++ b/crates/codegen/xai-grok-tools/Cargo.toml @@ -74,6 +74,7 @@ tonic = { workspace = true } tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true, features = ["v7"] } +wildmatch = { workspace = true } xai-grok-env = { workspace = true } serde_yaml = { workspace = true } shellexpand = "3.1" diff --git a/crates/codegen/xai-grok-tools/src/bridge.rs b/crates/codegen/xai-grok-tools/src/bridge.rs index 862e7e7..a882a29 100644 --- a/crates/codegen/xai-grok-tools/src/bridge.rs +++ b/crates/codegen/xai-grok-tools/src/bridge.rs @@ -141,17 +141,22 @@ impl ToolBridge { template: &str, placeholders: &serde_json::Value, ) -> Option<String> { - let registry = &*self.registry; - let result; - { - result = registry - .resources - .lock() - .await - .get::<TemplateRenderer>() - .and_then(|r| r.render_with_extra(template, placeholders).ok()); - } - result + self.registry + .resources + .lock() + .await + .get::<TemplateRenderer>() + .and_then(|renderer| renderer.render_with_extra(template, placeholders).ok()) + } + + /// Return the finalized template renderer for multi-part prompt assembly. + pub async fn template_renderer_snapshot(&self) -> Option<TemplateRenderer> { + self.registry + .resources + .lock() + .await + .get::<TemplateRenderer>() + .cloned() } pub async fn register_mcp_tools<T>( @@ -829,6 +834,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: owner.map(|s| s.to_string()), + description: None, } } diff --git a/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs b/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs index ecad81f..6f9f05a 100644 --- a/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs +++ b/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs @@ -272,6 +272,7 @@ impl ShellState { pub async fn init( shell: ShellKind, cwd: &Path, + shell_env_policy: Option<&crate::util::ShellEnvironmentPolicy>, ) -> Result<Self, crate::computer::types::ComputerError> { let dump_script = shell.dump_script(); let dump_fn = shell.dump_function_name(); @@ -297,6 +298,21 @@ impl ShellState { .stderr(Stdio::null()) .kill_on_drop(true); crate::util::detach_command(&mut cmd); + // Apply the policy before the `export -p` snapshot so the replayed state + // is already filtered; otherwise the restore would undo it. No-op unless set. + // + // SECURITY: this filters the base env only. Variables an rc file exports + // during login are captured in the replay snapshot and are not + // re-filtered by `exclude`/`include_only` on the persistent backend, so + // warn when a policy is active. The non-persistent backend has no such + // gap (it filters login capture directly). + if shell_env_policy.is_some_and(|p| !p.is_noop()) { + tracing::warn!( + "shell_environment_policy filters the persistent shell's base env only; \ + variables exported by rc files enter the replay snapshot unfiltered" + ); + } + crate::util::apply_shell_environment_policy(&mut cmd, shell_env_policy); cmd.envs(crate::util::pager_env()); let mut child = cmd.spawn().map_err(|e| { crate::computer::types::ComputerError::io(format!( @@ -839,7 +855,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); assert!(state.cwd.is_absolute()); // The snapshot should contain at least some env var exports assert!( @@ -858,7 +874,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); // Run "export GROK_TEST_VAR=hello" and capture the new state let prep = state @@ -964,7 +980,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); // cd to /tmp (macOS resolves to /private/tmp via symlink) let (code, _) = run_command(&mut state, "cd /tmp").await; @@ -987,7 +1003,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); // Export a variable let (code, _) = run_command(&mut state, "export MY_TEST_VAR=persistent_value").await; @@ -1006,7 +1022,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); let (code, _) = run_command(&mut state, "export GPG_TTY=/grok-sentinel-tty").await; assert_eq!(code, 0); @@ -1026,7 +1042,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Zsh, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Zsh, &cwd, None).await.unwrap(); let (code, _) = run_command(&mut state, "export GPG_TTY=/grok-sentinel-tty").await; assert_eq!(code, 0); @@ -1046,7 +1062,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Zsh, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Zsh, &cwd, None).await.unwrap(); let prep = state .prepare_command( @@ -1091,7 +1107,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); // Define a function let (code, _) = run_command(&mut state, "greet() { echo \"hello $1\"; }").await; @@ -1109,7 +1125,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); // Define an alias let (code, _) = run_command(&mut state, "alias ll='ls -la'").await; @@ -1136,7 +1152,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); let prep = state.prepare_command("true", None, shadows, None).unwrap(); // Shadows enabled → the self-resolving find/grep functions are always @@ -1175,7 +1191,7 @@ mod tests { return; } let cwd = std::env::current_dir().unwrap(); - let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); + let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap(); // Set up some state let (_, _) = run_command(&mut state, "export SURVIVE_TEST=yes").await; diff --git a/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs b/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs index e31a22c..e6b950f 100644 --- a/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs +++ b/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs @@ -303,6 +303,7 @@ struct ProcessState { /// Session that owns this process. Used to scope kill operations so /// subagent teardown only kills the subagent's own tasks. owner_session_id: Option<String>, + description: Option<String>, } impl ProcessState { @@ -442,6 +443,7 @@ impl ProcessState { explicitly_killed: self.explicitly_killed, kind: self.kind, owner_session_id: self.owner_session_id.clone(), + description: self.description.clone(), } } } @@ -516,6 +518,10 @@ struct LocalTerminalActor { /// a subagent reusing this backend can't clobber the parent's shadows. search_shadows: SearchShadowConfig, + /// Shell-environment policy baked in at construction (like `search_shadows`); + /// `None` inherits the full environment. + shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>, + /// Persistent shell state (env vars, cwd, functions, aliases). /// Lazily initialized on first command when `persistent_shell` is true. #[cfg(unix)] @@ -542,11 +548,13 @@ impl LocalTerminalActor { foreground_block_budget: Duration, output_file_cap: u64, scope: crate::util::ProcessScope, + shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>, ) -> Self { Self { cmd_rx, cancel_token, scope, + shell_env_policy, processes: HashMap::new(), completion_waiters: HashMap::new(), completed_task_snapshots: HashMap::new(), @@ -598,8 +606,14 @@ impl LocalTerminalActor { #[cfg(not(unix))] let login_env: Option<&HashMap<String, String>> = None; - let (child, process_group) = - spawn_shell_command(command, cwd, env, login_env, self.search_shadows)?; + let (child, process_group) = spawn_shell_command( + command, + cwd, + env, + login_env, + self.search_shadows, + self.shell_env_policy.as_ref(), + )?; Ok(SpawnResult { child, process_group, @@ -658,22 +672,12 @@ impl LocalTerminalActor { .stderr(Stdio::piped()) .kill_on_drop(true); - if let Some(login) = self.login_env.as_ref() { - for (key, value) in login { - if key != "PATH" && std::env::var_os(key).is_none() { - cmd.env(key, value); - } - } - } - cmd.envs(shell_state::shell_env_overrides()); - for (key, value) in env { - cmd.env(key, value); - } - cmd.envs(crate::util::pager_env()); - if let Some(path) = self.login_env.as_ref().and_then(|l| l.get("PATH")) { - cmd.env("PATH", path); - } - crate::util::apply_grok_agent_marker(&mut cmd); + apply_child_env( + &mut cmd, + self.shell_env_policy.as_ref(), + self.login_env.as_ref(), + env, + ); cmd.fd_mappings(prep.fd_mappings) .map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?; @@ -722,7 +726,7 @@ impl LocalTerminalActor { return; } let shell = shell_state::ShellKind::detect(); - match shell_state::ShellState::init(shell, cwd).await { + match shell_state::ShellState::init(shell, cwd, self.shell_env_policy.as_ref()).await { Ok(state) => self.shell_state = Some(state), Err(e) => { tracing::warn!("persistent shell init failed, using empty state: {e}"); @@ -791,17 +795,9 @@ impl LocalTerminalActor { .stderr(Stdio::piped()) .kill_on_drop(true); - // Apply SHELL_ENV_OVERRIDES (TERM=dumb, NO_COLOR, GROK_AGENT=1, etc.) - // + request env + pager env. Agent marker is re-applied last so request - // env cannot clear it. - cmd.envs(shell_state::shell_env_overrides()); - - for (key, value) in env { - cmd.env(key, value); - } - - cmd.envs(crate::util::pager_env()); - crate::util::apply_grok_agent_marker(&mut cmd); + // The persistent backend restores login state from its snapshot, so no + // login-env layering here. + apply_child_env(&mut cmd, self.shell_env_policy.as_ref(), None, env); cmd.fd_mappings(prep.fd_mappings) .map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?; @@ -1095,6 +1091,7 @@ impl LocalTerminalActor { explicitly_killed: false, state_dump_handle, owner_session_id: request.owner_session_id.clone(), + description: request.description.filter(|d| !d.trim().is_empty()), }; // Send an initial empty notification so the TUI shows the execution @@ -1238,6 +1235,7 @@ impl LocalTerminalActor { None }, owner_session_id: request.owner_session_id.clone(), + description: request.description.filter(|d| !d.trim().is_empty()), }; // Store under task_id — this is the key that get_task/kill_task will use @@ -1618,6 +1616,7 @@ impl LocalTerminalActor { block_waited: p.block_waited, explicitly_killed: p.explicitly_killed, owner_session_id: p.owner_session_id.clone(), + description: p.description.clone(), }; self.completed_task_snapshots.insert(id.clone(), snapshot); } @@ -2044,15 +2043,25 @@ impl LocalTerminalActor { // "Monitor" row (matching the original-spawn path) rather than a // bash-highlighted "[monitor] …". let is_monitor = process.kind == crate::computer::types::TaskKind::Monitor; - let monitor_description = if is_monitor { + // Recover monitor label once; reuse for backgrounded notify + pipeline. + // Filter empty/whitespace the same way as spawn so `[monitor] ` + // / blank recovery does not stick as Some("") and block the + // command fallback for the re-spawned pipeline label. + let recovered_monitor_description = if is_monitor { process .display_command .as_deref() .and_then(|d| d.strip_prefix("[monitor] ")) .map(str::to_string) + .filter(|d| !d.trim().is_empty()) } else { None }; + let effective_description = process + .description + .clone() + .filter(|d| !d.trim().is_empty()) + .or_else(|| recovered_monitor_description.clone()); let reparent_command = if is_monitor { process.command.clone() } else { @@ -2072,20 +2081,16 @@ impl LocalTerminalActor { }, output_file: process.output_file.clone(), task_id: task_id.clone(), - monitor_description, - // Reparent path has no model tool description; monitors use - // `monitor_description` above. - description: None, + monitor_description: recovered_monitor_description, + description: effective_description.clone(), }); // Re-spawn the monitor pipeline so events continue streaming. // The old pipeline died with the child's runtime. if process.kind == crate::computer::types::TaskKind::Monitor { let pipeline_task_id = task_id.clone(); - let pipeline_description = process - .display_command - .clone() - .unwrap_or_else(|| process.command.clone()); + let pipeline_description = + effective_description.unwrap_or_else(|| process.command.clone()); // Weak so the reparented monitor doesn't pin the backend. let pipeline_terminal = backend_weak.clone(); let pipeline_notif = new_handle.clone(); @@ -2127,6 +2132,31 @@ pub struct LocalTerminalBackend { cancel_token: CancellationToken, } +/// Grouped inputs for [`LocalTerminalBackend::new_inner`], so call sites read as +/// named fields instead of a telescoping list of positional `bool`s. Constructors +/// override only the fields they vary via `..Default::default()`. +struct LocalTerminalConfig { + memory_config: Option<CgroupMemoryConfig>, + use_spawn_local: bool, + persistent_shell: bool, + login_shell_capture: bool, + search_shadows: SearchShadowConfig, + shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>, +} + +impl Default for LocalTerminalConfig { + fn default() -> Self { + Self { + memory_config: None, + use_spawn_local: false, + persistent_shell: false, + login_shell_capture: true, + search_shadows: SearchShadowConfig::default(), + shell_env_policy: None, + } + } +} + impl LocalTerminalBackend { /// Create a new LocalTerminalBackend and spawn the actor task. /// @@ -2134,7 +2164,7 @@ impl LocalTerminalBackend { /// If `memory_config` is provided, a cgroupv2 memory limit is enforced on /// all spawned commands (Linux only; silently degrades to no-op elsewhere). pub fn new() -> Self { - Self::new_inner(None, false, false, true, SearchShadowConfig::default()) + Self::new_inner(LocalTerminalConfig::default()) } /// Create a new LocalTerminalBackend with persistent shell state. @@ -2143,31 +2173,29 @@ impl LocalTerminalBackend { /// and shell options persist across command invocations. The user's login shell /// (bash or zsh) is detected and its rc files are loaded once on first command. pub fn with_persistent_shell() -> Self { - Self::new_inner(None, false, true, true, SearchShadowConfig::default()) + Self::new_inner(LocalTerminalConfig { + persistent_shell: true, + ..Default::default() + }) } /// Create a new LocalTerminalBackend with cgroup memory limits. /// /// See [`CgroupMemoryConfig`] for details on the soft/hard limit model. pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self { - Self::new_inner( - Some(config), - false, - false, - true, - SearchShadowConfig::default(), - ) + Self::new_inner(LocalTerminalConfig { + memory_config: Some(config), + ..Default::default() + }) } /// Create a new LocalTerminalBackend with both memory limits and persistent shell. pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self { - Self::new_inner( - Some(config), - false, - true, - true, - SearchShadowConfig::default(), - ) + Self::new_inner(LocalTerminalConfig { + memory_config: Some(config), + persistent_shell: true, + ..Default::default() + }) } /// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes). @@ -2175,33 +2203,51 @@ impl LocalTerminalBackend { /// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable /// state, baked into this backend (see [`SearchShadowConfig`]). pub fn new_local(search_shadows: SearchShadowConfig) -> Self { - Self::new_inner(None, true, false, true, search_shadows) + Self::new_inner(LocalTerminalConfig { + use_spawn_local: true, + search_shadows, + ..Default::default() + }) } pub fn new_local_with_login_shell_capture( search_shadows: SearchShadowConfig, login_shell_capture: bool, + shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>, ) -> Self { - Self::new_inner(None, true, false, login_shell_capture, search_shadows) + Self::new_inner(LocalTerminalConfig { + use_spawn_local: true, + login_shell_capture, + search_shadows, + shell_env_policy, + ..Default::default() + }) } /// Create a new LocalTerminalBackend using spawn_local with persistent shell. /// /// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable /// state, baked into this backend (see [`SearchShadowConfig`]). - pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self { - Self::new_inner(None, true, true, true, search_shadows) + pub fn new_local_with_persistent_shell( + search_shadows: SearchShadowConfig, + shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>, + ) -> Self { + Self::new_inner(LocalTerminalConfig { + use_spawn_local: true, + persistent_shell: true, + search_shadows, + shell_env_policy, + ..Default::default() + }) } /// Create a new LocalTerminalBackend using spawn_local with memory limits. pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self { - Self::new_inner( - Some(config), - true, - false, - true, - SearchShadowConfig::default(), - ) + Self::new_inner(LocalTerminalConfig { + memory_config: Some(config), + use_spawn_local: true, + ..Default::default() + }) } /// Test-only: a spawn_local backend that enrolls spawned children into @@ -2222,6 +2268,7 @@ impl LocalTerminalBackend { FOREGROUND_BLOCK_BUDGET, MAX_OUTPUT_FILE_BYTES, scope, + None, ) } @@ -2238,6 +2285,7 @@ impl LocalTerminalBackend { FOREGROUND_BLOCK_BUDGET, MAX_OUTPUT_FILE_BYTES, crate::util::global_process_scope().clone(), + None, ) } @@ -2254,6 +2302,7 @@ impl LocalTerminalBackend { budget, MAX_OUTPUT_FILE_BYTES, crate::util::global_process_scope().clone(), + None, ) } @@ -2270,16 +2319,19 @@ impl LocalTerminalBackend { FOREGROUND_BLOCK_BUDGET, output_file_cap, crate::util::global_process_scope().clone(), + None, ) } - fn new_inner( - memory_config: Option<CgroupMemoryConfig>, - use_spawn_local: bool, - persistent_shell: bool, - login_shell_capture: bool, - search_shadows: SearchShadowConfig, - ) -> Self { + fn new_inner(config: LocalTerminalConfig) -> Self { + let LocalTerminalConfig { + memory_config, + use_spawn_local, + persistent_shell, + login_shell_capture, + search_shadows, + shell_env_policy, + } = config; Self::new_with_ttl( memory_config, use_spawn_local, @@ -2290,6 +2342,7 @@ impl LocalTerminalBackend { foreground_block_budget_from_env(), output_file_cap_from_env(), crate::util::global_process_scope().clone(), + shell_env_policy, ) } @@ -2303,6 +2356,7 @@ impl LocalTerminalBackend { foreground_block_budget: Duration, output_file_cap: u64, scope: crate::util::ProcessScope, + shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>, ) -> Self { let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE); let cancel_token = CancellationToken::new(); @@ -2329,6 +2383,7 @@ impl LocalTerminalBackend { foreground_block_budget, output_file_cap, scope, + shell_env_policy, ); actor.run().await; }; @@ -2920,19 +2975,107 @@ async fn capture_login_env() -> HashMap<String, String> { } } -/// Spawn the shell command and attach the child to a [`ProcessGroup`]. +/// Layer login-shell captured vars (except `PATH`) onto `cmd`, dropping those the +/// active policy filters out and those already set in grok's own environment. +#[cfg(unix)] +fn layer_login_env_vars( + cmd: &mut tokio::process::Command, + login_env: Option<&HashMap<String, String>>, + active_policy: Option<&crate::util::ShellEnvironmentPolicy>, +) { + if let Some(login) = login_env { + for (key, value) in login { + // `var_os` reads grok's own process env (not the possibly cleared + // child env): a login var already present in grok's environment is + // left alone. Capture is filtered through the policy so an rc export + // cannot bypass it. + if key != "PATH" + && std::env::var_os(key).is_none() + && active_policy.is_none_or(|p| p.allows_with_inherit(key)) + { + cmd.env(key, value); + } + } + } +} + +/// Layer per-request env (`.envrc`, ACP, session settings) onto `cmd`, dropping +/// names the active policy excludes so a request-supplied secret cannot bypass +/// it. Honors `exclude`/`include_only`/default excludes, not `inherit`, since +/// request env is provided explicitly rather than inherited. +fn layer_request_env( + cmd: &mut tokio::process::Command, + env: &HashMap<String, String>, + active_policy: Option<&crate::util::ShellEnvironmentPolicy>, +) { + for (key, value) in env { + if active_policy.is_none_or(|p| p.allows(key)) { + cmd.env(key, value); + } + } +} + +/// Re-inject the login-shell `PATH` last (so rc-file additions win), unless the +/// active policy filters `PATH` out. +#[cfg(unix)] +fn layer_login_path( + cmd: &mut tokio::process::Command, + login_env: Option<&HashMap<String, String>>, + active_policy: Option<&crate::util::ShellEnvironmentPolicy>, +) { + if let Some(path) = login_env.and_then(|l| l.get("PATH")) + && active_policy.is_none_or(|p| p.allows_with_inherit("PATH")) + { + cmd.env("PATH", path); + } +} + +/// Compose the child environment on `cmd` in one place, in a fixed order: +/// policy base, login-shell capture, grok control vars, request env, pager +/// vars, login `PATH` last, then the agent marker. Untrusted layers (login +/// capture and request env) pass through the policy name filter so an excluded +/// name cannot re-enter; grok's own control vars, login `PATH`, and the marker +/// are applied unfiltered and last. `login_env` is `None` for the persistent +/// backend, which restores login state from its own snapshot. /// -/// The returned `ProcessGroup` is what the teardown helpers -/// ([`send_sigterm_to_group`], [`send_sigkill_to_group`]) dispatch to: -/// `killpg` on Unix; `TerminateJobObject` on Windows. This gives -/// grandchild teardown for fan-out workloads (npm install, git clone, -/// cargo build) on both platforms. +/// Layers are applied incrementally rather than composed into one map and +/// installed via `env_clear`: the default policy is a no-op, and the common +/// path must inherit grok's environment untouched (including non-UTF-8 vars). +/// A base env is cleared and rebuilt only when a policy is active. Request env +/// is filtered by name only, so `inherit = none` still admits explicitly +/// provided `.envrc`/ACP vars. +/// +/// Unix only: the Windows spawn path applies the policy inline (it has no +/// login-shell capture and uses the shell-invocation env instead of overrides). +#[cfg(unix)] +fn apply_child_env( + cmd: &mut tokio::process::Command, + policy: Option<&crate::util::ShellEnvironmentPolicy>, + login_env: Option<&HashMap<String, String>>, + request_env: &HashMap<String, String>, +) { + let active_policy = policy.filter(|p| !p.is_noop()); + // 1. Base env: cleared and rebuilt from the policy only when one is active. + crate::util::shell_env_policy::install_policy_base_env(cmd, active_policy); + // 2. Login-shell capture (filtered). 3. Grok control vars. 4. Request env + // (filtered). 5. Pager vars. 6. Login PATH last. 7. Agent marker wins. + layer_login_env_vars(cmd, login_env, active_policy); + cmd.envs(shell_state::shell_env_overrides()); + layer_request_env(cmd, request_env, active_policy); + cmd.envs(crate::util::pager_env()); + layer_login_path(cmd, login_env, active_policy); + crate::util::apply_grok_agent_marker(cmd); +} + +/// Spawn the shell command and attach the child to a [`ProcessGroup`] for +/// grandchild teardown (`killpg` on Unix, `TerminateJobObject` on Windows). fn spawn_shell_command( command: &str, cwd: &std::path::Path, env: &HashMap<String, String>, login_env: Option<&HashMap<String, String>>, search_shadows: SearchShadowConfig, + shell_env_policy: Option<&crate::util::ShellEnvironmentPolicy>, ) -> std::io::Result<(tokio::process::Child, crate::util::ProcessGroup)> { // `login_env` and `search_shadows` are only consumed by the `#[cfg(unix)]` // shell wrapper below; keep them live on Windows to avoid unused-arg warnings. @@ -2966,30 +3109,7 @@ fn spawn_shell_command( // detach_from_tty() handles both session and process group creation. .kill_on_drop(true); - if let Some(login) = login_env { - for (key, value) in login { - if key != "PATH" && std::env::var_os(key).is_none() { - cmd.env(key, value); - } - } - } - // Apply env vars from the request (e.g., .envrc, color vars, ACP-provided vars). - cmd.envs(shell_state::shell_env_overrides()); - for (key, value) in env { - cmd.env(key, value); - } - cmd.envs(crate::util::pager_env()); - - // Inject the user's login-shell PATH LAST so tools installed via rc - // files (.bashrc, .zshrc, virtualenvs) are always discoverable. The - // request env often carries a copy of the parent process's PATH which - // doesn't include rc-file additions — applying login PATH after the - // request env ensures those additions aren't clobbered. - if let Some(path) = login_env.and_then(|l| l.get("PATH")) { - cmd.env("PATH", path); - } - // Agent marker must win over request/login env. - crate::util::apply_grok_agent_marker(&mut cmd); + apply_child_env(&mut cmd, shell_env_policy, login_env, env); // Detach from the controlling terminal so subprocesses cannot open // /dev/tty and compete with the TUI for terminal input. @@ -3018,18 +3138,21 @@ fn spawn_shell_command( let inv = xai_grok_config::shell::shell_command_argv(command); let mut cmd = tokio::process::Command::new(&inv.program); cmd.args(&inv.args) - .envs(inv.env) .current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - for (key, value) in env { - cmd.env(key, value); - } + // Policy base first (cleared + rebuilt only when a policy is active), then + // the shell-invocation env, the filtered request env, pager vars, and the + // agent marker last. Mirrors the unix ordering in `apply_child_env`; + // `inv.env` is grok's trusted shell setup, so it is not filtered. + let active_policy = shell_env_policy.filter(|p| !p.is_noop()); + crate::util::shell_env_policy::install_policy_base_env(&mut cmd, active_policy); + cmd.envs(inv.env); + layer_request_env(&mut cmd, env, active_policy); cmd.envs(crate::util::pager_env()); - // Agent marker must win over request env. crate::util::apply_grok_agent_marker(&mut cmd); // Set creation flags inline rather than via crate::util::detach_command @@ -3142,9 +3265,42 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, } } + #[tokio::test] + async fn run_background_preserves_description_on_snapshot() { + let backend = LocalTerminalBackend::new(); + let mut with_desc = make_request("sleep 30"); + with_desc.description = Some("build frontend".to_string()); + let handle = backend.run_background(with_desc).await.unwrap(); + let snap = backend + .get_task(&handle.task_id) + .await + .expect("running task snapshot"); + assert_eq!(snap.description.as_deref(), Some("build frontend")); + let listed = backend.list_tasks().await; + let listed_snap = listed + .iter() + .find(|t| t.task_id == handle.task_id) + .expect("task listed"); + assert_eq!(listed_snap.description.as_deref(), Some("build frontend")); + let _ = backend.kill_task(&handle.task_id).await; + + let without = make_request("sleep 30"); + let handle = backend.run_background(without).await.unwrap(); + let snap = backend + .get_task(&handle.task_id) + .await + .expect("running task snapshot"); + assert!( + snap.description.is_none(), + "absent description must stay None" + ); + let _ = backend.kill_task(&handle.task_id).await; + } + /// Poll `get_task` every 25ms until the task reports `completed`, returning /// `false` if `timeout` elapses first. Lets callers keep a bespoke assert /// message while sharing the poll-until-reaped boilerplate. @@ -3170,6 +3326,79 @@ mod tests { } } + #[test] + fn layer_request_env_drops_names_the_policy_excludes() { + use crate::util::{EnvironmentVariablePattern, ShellEnvironmentPolicy}; + + let glob = EnvironmentVariablePattern::new_case_insensitive; + let policy = ShellEnvironmentPolicy { + exclude: vec![glob("AWS_*")], + include_only: vec![glob("PATH"), glob("SAFE_*")], + ..Default::default() + }; + let env = HashMap::from([ + ("PATH".to_string(), "/bin".to_string()), + ("SAFE_FLAG".to_string(), "1".to_string()), + ("AWS_SECRET".to_string(), "leak".to_string()), + ("OTHER".to_string(), "x".to_string()), + ]); + + let mut cmd = tokio::process::Command::new("true"); + layer_request_env(&mut cmd, &env, Some(&policy)); + let applied: HashMap<String, String> = cmd + .as_std() + .get_envs() + .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) + .collect(); + + assert_eq!(applied.get("PATH").map(String::as_str), Some("/bin")); + assert_eq!(applied.get("SAFE_FLAG").map(String::as_str), Some("1")); + assert!(!applied.contains_key("AWS_SECRET")); + assert!(!applied.contains_key("OTHER")); + } + + #[cfg(unix)] + #[test] + fn apply_child_env_layers_in_fixed_order() { + use crate::util::{EnvironmentVariablePattern, ShellEnvironmentPolicy}; + + let policy = ShellEnvironmentPolicy { + exclude: vec![EnvironmentVariablePattern::new_case_insensitive("*SECRET*")], + set: HashMap::from([("GROK_TEST_BASE".to_string(), "1".to_string())]), + ..Default::default() + }; + let login = HashMap::from([ + ("GROK_TEST_LOGIN".to_string(), "l".to_string()), + ("PATH".to_string(), "/login/bin".to_string()), + ]); + let request = HashMap::from([ + ("GROK_TEST_REQ".to_string(), "r".to_string()), + ("PATH".to_string(), "/req/bin".to_string()), + ("GROK_TEST_SECRET".to_string(), "s".to_string()), + ]); + + let mut cmd = tokio::process::Command::new("true"); + apply_child_env(&mut cmd, Some(&policy), Some(&login), &request); + let env: HashMap<String, String> = cmd + .as_std() + .get_envs() + .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) + .collect(); + + assert_eq!(env.get("GROK_TEST_BASE").map(String::as_str), Some("1")); + assert_eq!(env.get("GROK_TEST_LOGIN").map(String::as_str), Some("l")); + assert_eq!(env.get("GROK_TEST_REQ").map(String::as_str), Some("r")); + // Request env is filtered by the policy. + assert!(!env.contains_key("GROK_TEST_SECRET")); + // Login PATH is applied last and wins over the request PATH. + assert_eq!(env.get("PATH").map(String::as_str), Some("/login/bin")); + // The agent marker wins over every layer. + assert_eq!( + env.get(crate::util::GROK_AGENT_ENV).map(String::as_str), + Some(crate::util::GROK_AGENT_ENV_VALUE) + ); + } + #[tokio::test] #[ignore = "flaky: combined_output is sometimes empty in CI"] async fn test_simple_command() { @@ -3220,6 +3449,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3251,6 +3481,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3314,6 +3545,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let start = Instant::now(); @@ -3385,6 +3617,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3431,6 +3664,7 @@ mod tests { foreground_block_budget: Some(Duration::from_millis(300)), kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let start = Instant::now(); @@ -3482,6 +3716,7 @@ mod tests { foreground_block_budget: Some(Duration::MAX), kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let start = Instant::now(); @@ -3533,6 +3768,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3591,6 +3827,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3627,6 +3864,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; // Start background task @@ -3667,6 +3905,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let handle = backend.run_background(request).await.unwrap(); @@ -3707,6 +3946,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3782,6 +4022,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3848,6 +4089,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3883,6 +4125,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3917,6 +4160,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -3947,6 +4191,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; backend.run(request).await.unwrap(); @@ -3986,6 +4231,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; backend.run(request).await.unwrap(); @@ -4034,6 +4280,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -4067,6 +4314,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let handle = backend.run_background(request).await.unwrap(); @@ -4111,6 +4359,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let result = backend.run(request).await.unwrap(); @@ -4143,6 +4392,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Bash, owner_session_id: None, + description: None, }; let start = Instant::now(); diff --git a/crates/codegen/xai-grok-tools/src/computer/types.rs b/crates/codegen/xai-grok-tools/src/computer/types.rs index 75fd3ce..75cb955 100644 --- a/crates/codegen/xai-grok-tools/src/computer/types.rs +++ b/crates/codegen/xai-grok-tools/src/computer/types.rs @@ -110,6 +110,8 @@ pub struct TerminalRunRequest { /// `kill_all_background_tasks_by_owner` only targets the requesting /// session's processes — not the parent's or sibling's. pub owner_session_id: Option<String>, + /// Model-supplied label for task UI / snapshots. + pub description: Option<String>, } /// Distinguishes different types of background tasks. @@ -214,6 +216,9 @@ pub struct TaskSnapshot { /// the parent's or sibling's. #[serde(default, skip_serializing_if = "Option::is_none")] pub owner_session_id: Option<String>, + /// Model-supplied label for task UI / snapshots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, } impl TaskSnapshot { diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs index b401084..22f1090 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs @@ -2037,6 +2037,7 @@ impl xai_tool_runtime::Tool for BashTool { foreground_block_budget: None, kind: crate::computer::types::TaskKind::Bash, owner_session_id: owner_session_id.clone(), + description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()), }; let handle = match backend.run_background(request).await { @@ -2072,7 +2073,7 @@ impl xai_tool_runtime::Tool for BashTool { output_file: bg_output_file.clone(), task_id: task_id.clone(), monitor_description: None, - description: Some(input.description.clone()), + description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()), }); let retrieval_hint = Self::background_retrieval_hint(&resources, &task_id).await?; @@ -2133,6 +2134,7 @@ impl xai_tool_runtime::Tool for BashTool { foreground_block_budget: Self::effective_foreground_block_budget(¶ms), kind: crate::computer::types::TaskKind::Bash, owner_session_id: owner_session_id.clone(), + description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()), }; let result = match backend.run(request).await { @@ -2166,7 +2168,7 @@ impl xai_tool_runtime::Tool for BashTool { output_file: output_file.clone(), task_id: tool_call_id.as_str().to_owned(), monitor_description: None, - description: Some(input.description.clone()), + description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()), }); let retrieval_hint = @@ -2233,7 +2235,7 @@ impl xai_tool_runtime::Tool for BashTool { truncated: result.truncated, signal: result.signal, timed_out: result.timed_out, - description: Some(input.description), + description: Some(input.description).filter(|d| !d.trim().is_empty()), current_dir: cwd.to_string_lossy().to_string(), output_file: output_file.to_string_lossy().to_string(), total_bytes: result.total_bytes, diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_edit/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_edit/mod.rs index f462d2c..e9443a3 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_edit/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_edit/mod.rs @@ -26,7 +26,7 @@ use crate::types::resources::SessionFolder; use crate::types::tool::{ToolKind, ToolNamespace}; use crate::util::image_compress::{FilterType, ReEncodeParams, re_encode_under_limit}; -const XAI_IMAGINE_MODEL: &str = "grok-imagine-image-quality"; +pub(crate) const XAI_IMAGINE_EDIT_MODEL: &str = "grok-imagine-image-quality"; /// Size/dimension limits for reference images sent to the Imagine API. /// Tighter than the vision path; the backend returns 400 when exceeded. @@ -353,7 +353,7 @@ impl xai_tool_runtime::Tool for ImageEditTool { let url = format!("{base}/images/edits"); let mut payload = serde_json::json!({ - "model": XAI_IMAGINE_MODEL, + "model": client.edit_model(), "prompt": input.prompt, "n": 1, "resolution": "1k", diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_gen/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_gen/mod.rs index 6808719..c280217 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_gen/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/image_gen/mod.rs @@ -58,6 +58,7 @@ pub struct ImageGenClient { /// [`XAI_IMAGINE_MODEL`]). `image_edit` uses its own model and is /// unaffected. model: String, + edit_model: String, writer: super::storage::SessionFileWriter, api_key_provider: Option<SharedApiKeyProvider>, /// Optional 401-attribution hook. Hosts wire this so a 401 from the @@ -81,6 +82,7 @@ impl ImageGenClient { base_url, extra_headers, model_override, + edit_model_override, tier_restricted, .. } = config @@ -93,6 +95,10 @@ impl ImageGenClient { .clone() .filter(|m| !m.trim().is_empty()) .unwrap_or_else(|| XAI_IMAGINE_MODEL.to_owned()); + let edit_model = edit_model_override + .clone() + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| super::image_edit::XAI_IMAGINE_EDIT_MODEL.to_owned()); let mut headers = reqwest::header::HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -138,6 +144,7 @@ impl ImageGenClient { http, base_url: base_url.clone(), model, + edit_model, writer: super::storage::SessionFileWriter::new(DEFAULT_IMAGE_DIR, "jpg"), api_key_provider, attribution_callback: None, @@ -183,6 +190,10 @@ impl ImageGenClient { &self.writer } + pub(crate) fn edit_model(&self) -> &str { + &self.edit_model + } + pub async fn generate( &self, prompt: &str, @@ -277,6 +288,7 @@ pub enum ImageGenConfig { /// ([`XAI_IMAGINE_MODEL`]). Driven by the remote /// `image_gen_model_override` config flag. `image_edit` is unaffected. model_override: Option<String>, + edit_model_override: Option<String>, /// `true` when the user is on a tier the Imagine server zero-limits /// (free / X Basic). The tools stay advertised to the model, but /// `image_gen` / `image_edit` short-circuit at call time with the @@ -483,6 +495,7 @@ mod tests { image_gen_enabled: false, image_edit_enabled: true, model_override: Some("grok-imagine-image".into()), + edit_model_override: None, tier_restricted: false, }; assert!(cfg.has_credentials()); @@ -502,6 +515,7 @@ mod tests { image_gen_enabled: true, image_edit_enabled: true, model_override: model_override.map(String::from), + edit_model_override: None, tier_restricted: false, }; // No override → default quality model. @@ -523,6 +537,33 @@ mod tests { ); } + #[test] + fn client_selects_edit_model_from_override() { + let mk = |edit_model_override: Option<&str>| ImageGenConfig::Enabled { + api_key: "k".into(), + base_url: "https://api.x.ai/v1".into(), + extra_headers: indexmap::IndexMap::new(), + image_gen_enabled: true, + image_edit_enabled: true, + model_override: None, + edit_model_override: edit_model_override.map(String::from), + tier_restricted: false, + }; + assert_eq!( + ImageGenClient::new(&mk(None), None).unwrap().edit_model(), + super::super::image_edit::XAI_IMAGINE_EDIT_MODEL + ); + assert_eq!( + ImageGenClient::new(&mk(Some(" ")), None) + .unwrap() + .edit_model(), + super::super::image_edit::XAI_IMAGINE_EDIT_MODEL + ); + let client = ImageGenClient::new(&mk(Some("grok-imagine-image-v2")), None).unwrap(); + assert_eq!(client.edit_model(), "grok-imagine-image-v2"); + assert_eq!(client.model, XAI_IMAGINE_MODEL); + } + #[tokio::test] async fn errors_when_client_missing() { let tool = ImageGenTool; @@ -558,6 +599,7 @@ mod tests { image_gen_enabled: true, image_edit_enabled: true, model_override: None, + edit_model_override: None, tier_restricted: true, }; let mut resources = crate::types::resources::Resources::new(); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/monitor/tool.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/monitor/tool.rs index d311b83..fa07e4d 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/monitor/tool.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/monitor/tool.rs @@ -83,7 +83,7 @@ impl xai_tool_runtime::Tool for MonitorTool { .map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?; let resolved_timeout = input.resolved_timeout_ms(); - let description = input.description.clone(); + let description = input.description; let (terminal, notification_handle, cwd, session_folder, owner_session_id) = { let res = resources.lock().await; @@ -127,16 +127,18 @@ impl xai_tool_runtime::Tool for MonitorTool { output_file, notification_handle: notification_handle.clone(), tool_call_id: ctx.call_id.as_str().to_owned(), - display_command: Some(format!("[monitor] {}", input.description)), + display_command: Some(format!("[monitor] {description}")), auto_background_on_timeout: false, foreground_block_budget: None, kind: crate::computer::types::TaskKind::Monitor, owner_session_id, + description: Some(description.clone()).filter(|d| !d.trim().is_empty()), }) .await .map_err(|e| xai_tool_runtime::ToolError::custom("process_manager", e.to_string()))?; let task_id = bg_handle.task_id.clone(); + let tray_description = Some(description.clone()).filter(|d| !d.trim().is_empty()); // Notify the pager so the monitor appears in the tasks pane // (same notification that bash background tasks send). @@ -155,15 +157,15 @@ impl xai_tool_runtime::Tool for MonitorTool { }, output_file: bg_handle.output_file.clone(), task_id: task_id.clone(), - monitor_description: Some(input.description.clone()), - description: None, + monitor_description: tray_description.clone(), + description: tray_description, }); // Spawn the stdout processing pipeline. // Reads the output file, processes lines through the rate limiter, // and emits MonitorEvent notifications. let pipeline_task_id = task_id.clone(); - let pipeline_description = description.clone(); + let pipeline_description = description; // Weak handle: the pipeline must not keep the session's terminal backend // (and the monitored process) alive past session end. See // `run_monitor_pipeline`. @@ -449,6 +451,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Monitor, owner_session_id: Some("session-A".to_string()), + description: None, }) .await .expect("spawn monitor"); @@ -525,6 +528,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Monitor, owner_session_id: Some("session-A".to_string()), + description: None, }) .await .expect("spawn monitor"); @@ -594,6 +598,7 @@ mod tests { foreground_block_budget: None, kind: TaskKind::Monitor, owner_session_id: Some("child-session".to_string()), + description: None, }) .await .expect("spawn monitor"); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs index 59b9706..1b16e58 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/scheduler/actor.rs @@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken; use crate::implementations::grok_build::task::types::{ SessionIdResource, SubagentEvent, SubagentEventSender, SubagentLoopUnitActiveRequest, SubagentOwner, SubagentQueryRequest, SubagentRequest, SubagentRuntimeOverrides, - SubagentSnapshotStatus, + SubagentSnapshotStatus, SubagentSpawnRequest, }; use crate::notification::types::ToolNotificationHandle; use crate::notification::{ @@ -526,6 +526,7 @@ impl SchedulerActor { .0 .send(SubagentEvent::Query(SubagentQueryRequest { subagent_id: prev_id.clone(), + parent_session_id: Some(parent_session_id.clone()), block: false, timeout_ms: None, respond_to, @@ -675,12 +676,14 @@ impl SchedulerActor { fork_context: false, owner: SubagentOwner::Task, cancel_token: CancellationToken::new(), - result_tx, }; if events .0 - .send(SubagentEvent::Spawn(Box::new(request))) + .send(SubagentEvent::Spawn(SubagentSpawnRequest { + request: Box::new(request), + result_tx, + })) .is_err() { let mut res = self.resources.lock().await; @@ -1818,7 +1821,7 @@ mod tests { let SubagentEvent::Spawn(spawn) = next_event(rx).await else { panic!("expected subagent spawn"); }; - spawn + spawn.request } async fn answer_loop_unit_active( diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend.rs index 592a00d..adbc3dc 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend.rs @@ -4,21 +4,21 @@ //! `TaskOutputTool`, `KillTaskTool`) from the transport mechanism used to //! communicate with the subagent coordinator. //! -//! Two implementations are planned: -//! -//! - [`ChannelBackend`] — wraps in-process `tokio::mpsc` channels used by -//! the local host shell. This is the only implementation today. -//! - `RemoteBackend` (future) — dispatches over a remote transport to an -//! out-of-process spawner. +//! All hosts use [`ChannelBackend`]. +//! The receiver is owned by the shared single-writer coordinator actor; only +//! the child runner plugged into that actor differs by host. use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; use super::types::{ - SubagentCancelOutcome, SubagentCancelRequest, SubagentCancelTarget, SubagentDescribeOutcome, - SubagentDescribeRequest, SubagentEvent, SubagentQueryRequest, SubagentRequest, SubagentResult, - SubagentSnapshot, SubagentValidateTypeOutcome, SubagentValidateTypeRequest, + SpawnedSubagentRef, SubagentCancelOutcome, SubagentCancelRequest, SubagentCancelTarget, + SubagentDescribeOutcome, SubagentDescribeRequest, SubagentEvent, SubagentInspectRequest, + SubagentInspection, SubagentListRunningRequest, SubagentQueryRequest, SubagentRegistryCounts, + SubagentRegistryCountsRequest, SubagentRequest, SubagentResult, SubagentSnapshot, + SubagentSpawnRequest, SubagentSpawnedRefsRequest, SubagentValidateTypeOutcome, + SubagentValidateTypeRequest, }; use crate::register_resource; use xai_tool_runtime::ToolError; @@ -110,13 +110,132 @@ register_resource!( /// Wraps a single `mpsc::UnboundedSender<SubagentEvent>` that carries /// spawn, query, and cancel messages to the coordinator. The oneshot for /// `spawn` is created inside the backend so callers never manage it. +#[derive(Clone)] pub struct ChannelBackend { tx: mpsc::UnboundedSender<SubagentEvent>, + parent_session_id: Option<Arc<str>>, } impl ChannelBackend { pub fn new(tx: mpsc::UnboundedSender<SubagentEvent>) -> Self { - Self { tx } + Self { + tx, + parent_session_id: None, + } + } + + /// Bind model-facing operations to one parent session. + pub fn for_session( + tx: mpsc::UnboundedSender<SubagentEvent>, + parent_session_id: impl Into<Arc<str>>, + ) -> Self { + Self { + tx, + parent_session_id: Some(parent_session_id.into()), + } + } + + fn parent_session_id(&self) -> Option<String> { + self.parent_session_id.as_deref().map(str::to_owned) + } + + pub fn sender(&self) -> mpsc::UnboundedSender<SubagentEvent> { + self.tx.clone() + } + + pub fn into_resource(self) -> SubagentBackendResource { + SubagentBackendResource(Arc::new(self)) + } + + pub async fn cancel_parent_prompt(&self, parent_prompt_id: &str) -> SubagentCancelOutcome { + let (respond_to, response_rx) = oneshot::channel(); + if self + .tx + .send(SubagentEvent::Cancel(SubagentCancelRequest { + parent_session_id: self.parent_session_id(), + target: SubagentCancelTarget::ParentPromptId(parent_prompt_id.to_owned()), + respond_to, + })) + .is_err() + { + return SubagentCancelOutcome::NotFound; + } + response_rx.await.unwrap_or(SubagentCancelOutcome::NotFound) + } + + pub async fn inspect(&self, id: &str) -> Option<SubagentInspection> { + let (respond_to, response_rx) = oneshot::channel(); + self.tx + .send(SubagentEvent::Inspect(SubagentInspectRequest { + subagent_id: id.to_owned(), + parent_session_id: self.parent_session_id(), + respond_to, + })) + .ok()?; + response_rx.await.ok().flatten() + } + + pub async fn list_running(&self, parent_session_id: &str) -> Vec<SubagentInspection> { + let (respond_to, response_rx) = oneshot::channel(); + if self + .tx + .send(SubagentEvent::ListRunning(SubagentListRunningRequest { + parent_session_id: parent_session_id.to_owned(), + respond_to, + })) + .is_err() + { + return Vec::new(); + } + response_rx.await.unwrap_or_default() + } + + pub async fn spawned_refs_for_prompt( + &self, + parent_session_id: &str, + prompt_id: &str, + ) -> Vec<SpawnedSubagentRef> { + let (respond_to, response_rx) = oneshot::channel(); + if self + .tx + .send(SubagentEvent::SpawnedRefs(SubagentSpawnedRefsRequest { + parent_session_id: self + .parent_session_id + .as_deref() + .unwrap_or(parent_session_id) + .to_owned(), + prompt_id: prompt_id.to_owned(), + respond_to, + })) + .is_err() + { + return Vec::new(); + } + response_rx.await.unwrap_or_default() + } + + pub async fn registry_counts(&self) -> SubagentRegistryCounts { + let (respond_to, response_rx) = oneshot::channel(); + if self + .tx + .send(SubagentEvent::RegistryCounts( + SubagentRegistryCountsRequest { respond_to }, + )) + .is_err() + { + return SubagentRegistryCounts::default(); + } + response_rx.await.unwrap_or_default() + } + + /// Spawn while holding the host's interruptible foreground-wait token. + pub async fn spawn_with_foreground_wait( + &self, + request: SubagentRequest, + wait: Option<&super::types::SubagentForegroundWait>, + ) -> Result<SubagentResult, ToolError> { + let _wait = wait.map(super::types::SubagentForegroundWait::enter); + self.spawn(request).await } } @@ -135,20 +254,18 @@ impl Drop for CancelResultReceiverOnDrop { #[async_trait::async_trait] impl SubagentBackend for ChannelBackend { - async fn spawn(&self, request: SubagentRequest) -> Result<SubagentResult, ToolError> { - let (result_tx, result_rx) = oneshot::channel(); + async fn spawn(&self, mut request: SubagentRequest) -> Result<SubagentResult, ToolError> { + if let Some(parent_session_id) = self.parent_session_id.as_deref() { + request.parent_session_id = parent_session_id.to_owned(); + } + let (respond_to, response_rx) = oneshot::channel(); let cancel_on_receiver_drop = request.owner.is_workflow(); let cancel_token = request.cancel_token.clone(); - - // Replace the dummy oneshot with our fresh one. Using struct update - // syntax (`..request`) ensures new fields added to `SubagentRequest` - // are forwarded automatically — a field-by-field copy would silently - // drop them. self.tx - .send(SubagentEvent::Spawn(Box::new(SubagentRequest { - result_tx, - ..request - }))) + .send(SubagentEvent::Spawn(SubagentSpawnRequest { + request: Box::new(request), + result_tx: respond_to, + })) .map_err(|_| { ToolError::custom( "channel_closed", @@ -160,7 +277,7 @@ impl SubagentBackend for ChannelBackend { cancel_token: cancel_token.clone(), armed: true, }); - let result = result_rx.await; + let result = response_rx.await; if result.is_ok() { if let Some(guard) = receiver_guard.as_mut() { guard.armed = false; @@ -185,6 +302,7 @@ impl SubagentBackend for ChannelBackend { let (respond_to, response_rx) = oneshot::channel(); let sent = self.tx.send(SubagentEvent::Query(SubagentQueryRequest { subagent_id: id.to_string(), + parent_session_id: self.parent_session_id(), block, timeout_ms, respond_to, @@ -198,6 +316,7 @@ impl SubagentBackend for ChannelBackend { async fn cancel(&self, id: &str) -> SubagentCancelOutcome { let (respond_to, response_rx) = oneshot::channel(); let sent = self.tx.send(SubagentEvent::Cancel(SubagentCancelRequest { + parent_session_id: self.parent_session_id(), target: SubagentCancelTarget::SubagentId(id.to_string()), respond_to, })); @@ -212,6 +331,10 @@ impl SubagentBackend for ChannelBackend { subagent_type: &str, parent_session_id: &str, ) -> SubagentValidateTypeOutcome { + let parent_session_id = self + .parent_session_id + .as_deref() + .unwrap_or(parent_session_id); let (respond_to, response_rx) = oneshot::channel(); if self .tx @@ -255,6 +378,10 @@ impl SubagentBackend for ChannelBackend { harness_agent_type: Option<&str>, parent_session_id: &str, ) -> SubagentDescribeOutcome { + let parent_session_id = self + .parent_session_id + .as_deref() + .unwrap_or(parent_session_id); let (respond_to, response_rx) = oneshot::channel(); if self .tx @@ -301,7 +428,7 @@ pub const VALIDATE_TYPE_TIMEOUT: std::time::Duration = std::time::Duration::from pub const VALIDATE_TYPE_TIMEOUT_ENV_VAR: &str = "XAI_VALIDATE_TYPE_TIMEOUT_MS"; /// Validation timeout, honoring the env-var override. -pub(crate) fn validate_type_timeout() -> std::time::Duration { +pub fn validate_type_timeout() -> std::time::Duration { let raw = std::env::var(VALIDATE_TYPE_TIMEOUT_ENV_VAR).ok(); parse_timeout_ms(raw.as_deref()) .map(std::time::Duration::from_millis) @@ -313,604 +440,14 @@ pub(crate) fn parse_timeout_ms(value: Option<&str>) -> Option<u64> { value?.parse::<u64>().ok().filter(|&ms| ms > 0) } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - use tokio::sync::mpsc; - - /// Helper: receive the next event, match the expected variant, or panic. - macro_rules! recv_event { - ($rx:expr, Spawn) => {{ - let event = $rx.recv().await.unwrap(); - match event { - SubagentEvent::Spawn(inner) => *inner, - _ => panic!("Expected SubagentEvent::Spawn, got different variant"), - } - }}; - ($rx:expr, $variant:ident) => {{ - let event = $rx.recv().await.unwrap(); - match event { - SubagentEvent::$variant(inner) => inner, - _ => panic!( - "Expected SubagentEvent::{}, got different variant", - stringify!($variant) - ), - } - }}; - } - - #[tokio::test] - async fn channel_backend_spawn_success() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let req = recv_event!(rx, Spawn); - assert_eq!(req.id, "test-id"); - assert_eq!(req.prompt, "do something"); - req.result_tx - .send(SubagentResult { - success: true, - output: Arc::from("done"), - subagent_id: "test-id".to_string(), - child_session_id: "test-id".to_string(), - tool_calls: 3, - turns: 1, - duration_ms: 500, - ..Default::default() - }) - .unwrap(); - }); - - let (dummy_tx, _dummy_rx) = oneshot::channel(); - let request = SubagentRequest { - id: "test-id".to_string(), - prompt: "do something".to_string(), - description: "test".to_string(), - subagent_type: "general-purpose".to_string(), - parent_session_id: "parent".to_string(), - parent_prompt_id: None, - resume_from: None, - cwd: None, - runtime_overrides: Default::default(), - run_in_background: false, - surface_completion: true, - await_to_completion: false, - fork_context: false, - owner: super::super::types::SubagentOwner::Task, - cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx: dummy_tx, - }; - - let result = backend.spawn(request).await.unwrap(); - assert!(result.success); - assert_eq!(result.subagent_id, "test-id"); - assert_eq!(result.tool_calls, 3); - - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_spawn_closed_channel() { - let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); - drop(rx); - - let backend = ChannelBackend::new(tx); - - let (dummy_tx, _dummy_rx) = oneshot::channel(); - let request = SubagentRequest { - id: "test-id".to_string(), - prompt: "do something".to_string(), - description: "test".to_string(), - subagent_type: "general-purpose".to_string(), - parent_session_id: "parent".to_string(), - parent_prompt_id: None, - resume_from: None, - cwd: None, - runtime_overrides: Default::default(), - run_in_background: false, - surface_completion: true, - await_to_completion: false, - fork_context: false, - owner: super::super::types::SubagentOwner::Task, - cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx: dummy_tx, - }; - - let err = backend.spawn(request).await.unwrap_err(); - assert!(err.to_string().contains("channel closed")); - } - - #[tokio::test] - async fn channel_backend_query_found() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let req = recv_event!(rx, Query); - assert_eq!(req.subagent_id, "sub-1"); - assert!(req.block); - assert_eq!(req.timeout_ms, Some(5000)); - req.respond_to - .send(Some(SubagentSnapshot { - subagent_id: "sub-1".to_string(), - description: "find bugs".to_string(), - subagent_type: "explore".to_string(), - status: super::super::types::SubagentSnapshotStatus::Completed { - output: "result".to_string(), - tool_calls: 2, - turns: 1, - worktree_path: None, - }, - started_at_epoch_ms: 1000, - duration_ms: 200, - persona: Some("reviewer".to_string()), - })) - .unwrap(); - }); - - let snap = backend.query("sub-1", true, Some(5000)).await; - let snap = snap.expect("snapshot should be present"); - assert_eq!(snap.subagent_id, "sub-1"); - assert_eq!(snap.description, "find bugs"); - assert_eq!(snap.subagent_type, "explore"); - assert_eq!(snap.started_at_epoch_ms, 1000); - assert_eq!(snap.duration_ms, 200); - assert_eq!(snap.persona.as_deref(), Some("reviewer")); - match &snap.status { - super::super::types::SubagentSnapshotStatus::Completed { - output, - tool_calls, - turns, - worktree_path, - } => { - assert_eq!(output, "result"); - assert_eq!(*tool_calls, 2); - assert_eq!(*turns, 1); - assert!(worktree_path.is_none()); - } - other => panic!("Expected Completed, got {:?}", other), - } - - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_query_non_blocking_passes_through() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let req = recv_event!(rx, Query); - assert_eq!(req.subagent_id, "sub-nb"); - assert!(!req.block, "block should be false"); - assert_eq!(req.timeout_ms, None, "timeout_ms should be None"); - req.respond_to.send(None).unwrap(); - }); - - let snap = backend.query("sub-nb", false, None).await; - assert!(snap.is_none()); - - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_query_not_found() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let req = recv_event!(rx, Query); - req.respond_to.send(None).unwrap(); - }); - - let snap = backend.query("nonexistent", false, None).await; - assert!(snap.is_none()); - - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_cancel_success() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let req = recv_event!(rx, Cancel); - match &req.target { - SubagentCancelTarget::SubagentId(id) => assert_eq!(id, "sub-cancel"), - other => panic!("Expected SubagentId, got {:?}", other), - } - req.respond_to - .send(SubagentCancelOutcome::Cancelled) - .unwrap(); - }); - - let outcome = backend.cancel("sub-cancel").await; - assert!(matches!(outcome, SubagentCancelOutcome::Cancelled)); - - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_cancel_closed_channel() { - let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); - drop(rx); - - let backend = ChannelBackend::new(tx); - - let outcome = backend.cancel("sub-cancel").await; - assert!(matches!(outcome, SubagentCancelOutcome::NotFound)); - } - - #[tokio::test] - async fn workflow_spawn_future_drop_cancels_but_task_drop_does_not() { - fn request_for(owner: super::super::types::SubagentOwner) -> SubagentRequest { - let (dummy_tx, _dummy_rx) = oneshot::channel(); - SubagentRequest { - id: "drop-owner-test".to_string(), - prompt: "test".to_string(), - description: "test".to_string(), - subagent_type: "general-purpose".to_string(), - parent_session_id: "parent".to_string(), - parent_prompt_id: None, - resume_from: None, - cwd: None, - runtime_overrides: Default::default(), - run_in_background: false, - surface_completion: false, - await_to_completion: true, - fork_context: false, - owner, - cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx: dummy_tx, - } - } - - for (owner, should_cancel) in [ - (super::super::types::SubagentOwner::Task, false), - (super::super::types::SubagentOwner::workflow("wf-1"), true), - ] { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = Arc::new(ChannelBackend::new(tx)); - let request = request_for(owner); - let cancel_token = request.cancel_token.clone(); - let task = tokio::spawn({ - let backend = backend.clone(); - async move { backend.spawn(request).await } - }); - let spawned = recv_event!(rx, Spawn); - task.abort(); - let _ = task.await; - assert_eq!( - cancel_token.is_cancelled(), - should_cancel, - "only workflow receiver drop owns cancellation" - ); - drop(spawned.result_tx); - } - } - - #[tokio::test] - async fn channel_backend_spawn_result_dropped() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let req = recv_event!(rx, Spawn); - drop(req.result_tx); - }); - - let (dummy_tx, _dummy_rx) = oneshot::channel(); - let request = SubagentRequest { - id: "drop-test".to_string(), - prompt: "test".to_string(), - description: "test".to_string(), - subagent_type: "general-purpose".to_string(), - parent_session_id: "parent".to_string(), - parent_prompt_id: None, - resume_from: None, - cwd: None, - runtime_overrides: Default::default(), - run_in_background: false, - surface_completion: true, - await_to_completion: false, - fork_context: false, - owner: super::super::types::SubagentOwner::Task, - cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx: dummy_tx, - }; - - let err = backend.spawn(request).await.unwrap_err(); - assert!( - err.to_string().contains("result channel dropped"), - "error: {err}" - ); - - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_query_closed_channel() { - let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); - drop(rx); - - let backend = ChannelBackend::new(tx); - - let snap = backend.query("sub-1", false, None).await; - assert!(snap.is_none()); - } - - // ── validate_type ──────────────────────────────────────────────── - - #[tokio::test] - async fn channel_backend_validate_type_round_trips_outcome() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - let event = rx.recv().await.unwrap(); - match event { - SubagentEvent::ValidateType(req) => { - assert_eq!(req.subagent_type, "explore"); - assert_eq!(req.parent_session_id, "parent-1"); - req.respond_to - .send(SubagentValidateTypeOutcome::Ok) - .unwrap(); - } - _ => panic!("Expected ValidateType event"), - } - }); - - let outcome = backend.validate_type("explore", "parent-1").await; - assert!(matches!(outcome, SubagentValidateTypeOutcome::Ok)); - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_validate_type_propagates_unknown_outcome() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await { - req.respond_to - .send(SubagentValidateTypeOutcome::Unknown { - available: vec!["explore".into(), "plan".into()], - }) - .unwrap(); - } - }); - - let outcome = backend.validate_type("invented", "p").await; - match outcome { - SubagentValidateTypeOutcome::Unknown { available } => { - assert_eq!(available, vec!["explore".to_string(), "plan".to_string()]); - } - other => panic!("expected Unknown, got {other:?}"), - } - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_validate_type_returns_validation_unavailable_when_channel_closed() { - let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); - drop(rx); - let backend = ChannelBackend::new(tx); - let outcome = backend.validate_type("explore", "p").await; - assert!(matches!( - outcome, - SubagentValidateTypeOutcome::ValidationUnavailable - )); - } - - #[tokio::test] - async fn channel_backend_validate_type_returns_validation_unavailable_when_responder_dropped() { - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - let handle = tokio::spawn(async move { - if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await { - drop(req.respond_to); - } - }); - let outcome = backend.validate_type("explore", "p").await; - assert!(matches!( - outcome, - SubagentValidateTypeOutcome::ValidationUnavailable, - )); - handle.await.unwrap(); - } - - use super::super::types::test_capture; - - #[tokio::test(start_paused = true)] - async fn channel_backend_validate_type_logs_warn_on_timeout() { - let captured = test_capture::capture(); - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - // Coordinator receives but never replies; keeps the responder - // alive so the timeout arm fires (not responder-dropped). - let holder = tokio::spawn(async move { - if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await { - std::mem::forget(req.respond_to); - std::future::pending::<()>().await; - } - }); - - let validate = tokio::spawn(async move { backend.validate_type("explore", "p").await }); - tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await; - let outcome = validate.await.unwrap(); - assert!(matches!( - outcome, - SubagentValidateTypeOutcome::ValidationUnavailable - )); - - let mut events_rx = captured.events_rx; - let mut saw_timeout_warn = false; - while let Ok(event) = events_rx.try_recv() { - if event.level == tracing::Level::WARN - && event.fields.contains("coordinator validation timed out") - && event.fields.contains("subagent_type=explore") - && event.fields.contains("timeout_ms=") - { - saw_timeout_warn = true; - break; - } - } - assert!(saw_timeout_warn, "must emit WARN with timeout_ms field"); - - holder.abort(); - } - - // ── describe_subagent_type ─────────────────────────────────────── - - #[tokio::test] - async fn channel_backend_describe_round_trips_summary() { - use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary}; - use crate::types::tool::ToolKind; - - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - match rx.recv().await.unwrap() { - SubagentEvent::DescribeType(req) => { - assert_eq!(req.subagent_type, "explore"); - assert_eq!(req.harness_agent_type.as_deref(), Some("cursor")); - assert_eq!(req.parent_session_id, "parent-1"); - let mut summary = SubagentTypeSummary { - can_read: true, - can_search: true, - ..Default::default() - }; - summary - .tool_names - .insert(ToolKind::Read, "read_file".to_string()); - req.respond_to - .send(SubagentDescribeOutcome::Ok(summary)) - .unwrap(); - } - _ => panic!("Expected DescribeType event"), - } - }); - - let outcome = backend - .describe_subagent_type("explore", Some("cursor"), "parent-1") - .await; - match outcome { - SubagentDescribeOutcome::Ok(summary) => { - assert!(summary.can_read && summary.can_search && !summary.can_execute); - assert_eq!( - summary.tool_names.get(&ToolKind::Read).unwrap(), - "read_file" - ); - } - other => panic!("expected Ok, got {other:?}"), - } - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_describe_propagates_not_allowed_outcome() { - use super::super::types::SubagentDescribeOutcome; - - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let handle = tokio::spawn(async move { - if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await { - req.respond_to - .send(SubagentDescribeOutcome::NotAllowed { - allowed: vec!["explore".into()], - }) - .unwrap(); - } - }); - - match backend.describe_subagent_type("plan", None, "p").await { - SubagentDescribeOutcome::NotAllowed { allowed } => { - assert_eq!(allowed, vec!["explore".to_string()]); - } - other => panic!("expected NotAllowed, got {other:?}"), - } - handle.await.unwrap(); - } - - #[tokio::test] - async fn channel_backend_describe_returns_unavailable_when_channel_closed() { - use super::super::types::SubagentDescribeOutcome; - let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); - drop(rx); - let backend = ChannelBackend::new(tx); - assert!(matches!( - backend.describe_subagent_type("explore", None, "p").await, - SubagentDescribeOutcome::Unavailable - )); - } - - #[tokio::test] - async fn channel_backend_describe_returns_unavailable_when_responder_dropped() { - use super::super::types::SubagentDescribeOutcome; - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - let handle = tokio::spawn(async move { - if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await { - drop(req.respond_to); - } - }); - assert!(matches!( - backend.describe_subagent_type("explore", None, "p").await, - SubagentDescribeOutcome::Unavailable - )); - handle.await.unwrap(); - } - - #[tokio::test(start_paused = true)] - async fn channel_backend_describe_returns_unavailable_on_timeout() { - use super::super::types::SubagentDescribeOutcome; - let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); - let backend = ChannelBackend::new(tx); - - let holder = tokio::spawn(async move { - if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await { - std::mem::forget(req.respond_to); - std::future::pending::<()>().await; - } - }); - - let describe = - tokio::spawn(async move { backend.describe_subagent_type("explore", None, "p").await }); - tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await; - assert!(matches!( - describe.await.unwrap(), - SubagentDescribeOutcome::Unavailable - )); - holder.abort(); - } - - #[test] - fn parse_timeout_ms_returns_none_for_unset() { - assert_eq!(parse_timeout_ms(None), None); - } - - #[test] - fn parse_timeout_ms_returns_none_for_unparseable() { - assert_eq!(parse_timeout_ms(Some("not-a-number")), None); - assert_eq!(parse_timeout_ms(Some("")), None); - assert_eq!(parse_timeout_ms(Some("3.14")), None); - assert_eq!(parse_timeout_ms(Some("-100")), None); - } - - #[test] - fn parse_timeout_ms_returns_none_for_zero() { - assert_eq!(parse_timeout_ms(Some("0")), None); - } - - #[test] - fn parse_timeout_ms_returns_value_for_positive_integer() { - assert_eq!(parse_timeout_ms(Some("5000")), Some(5000)); - assert_eq!(parse_timeout_ms(Some("1")), Some(1)); - } +/// Resolve a `Duration` from a positive-millisecond env override, falling back +/// to `default` when the var is unset / non-numeric / zero. +pub fn env_duration_or(env_var: &str, default: std::time::Duration) -> std::time::Duration { + parse_timeout_ms(std::env::var(env_var).ok().as_deref()) + .map(std::time::Duration::from_millis) + .unwrap_or(default) } + +#[cfg(test)] +#[path = "backend_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend_tests.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend_tests.rs new file mode 100644 index 0000000..f9aedd6 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/backend_tests.rs @@ -0,0 +1,590 @@ +use super::*; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// Helper: receive the next event, match the expected variant, or panic. +macro_rules! recv_event { + ($rx:expr, Spawn) => {{ + let event = $rx.recv().await.unwrap(); + match event { + SubagentEvent::Spawn(inner) => inner, + _ => panic!("Expected SubagentEvent::Spawn, got different variant"), + } + }}; + ($rx:expr, $variant:ident) => {{ + let event = $rx.recv().await.unwrap(); + match event { + SubagentEvent::$variant(inner) => inner, + _ => panic!( + "Expected SubagentEvent::{}, got different variant", + stringify!($variant) + ), + } + }}; +} + +#[tokio::test] +async fn channel_backend_spawn_success() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let req = recv_event!(rx, Spawn); + assert_eq!(req.request.id, "test-id"); + assert_eq!(req.request.prompt, "do something"); + req.result_tx + .send(SubagentResult { + success: true, + output: Arc::from("done"), + subagent_id: "test-id".to_string(), + child_session_id: "test-id".to_string(), + tool_calls: 3, + turns: 1, + duration_ms: 500, + ..Default::default() + }) + .unwrap(); + }); + + let request = SubagentRequest { + id: "test-id".to_string(), + prompt: "do something".to_string(), + description: "test".to_string(), + subagent_type: "general-purpose".to_string(), + parent_session_id: "parent".to_string(), + parent_prompt_id: None, + resume_from: None, + cwd: None, + runtime_overrides: Default::default(), + run_in_background: false, + surface_completion: true, + await_to_completion: false, + fork_context: false, + owner: super::super::types::SubagentOwner::Task, + cancel_token: tokio_util::sync::CancellationToken::new(), + }; + + let result = backend.spawn(request).await.unwrap(); + assert!(result.success); + assert_eq!(result.subagent_id, "test-id"); + assert_eq!(result.tool_calls, 3); + + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_spawn_closed_channel() { + let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); + drop(rx); + + let backend = ChannelBackend::new(tx); + + let request = SubagentRequest { + id: "test-id".to_string(), + prompt: "do something".to_string(), + description: "test".to_string(), + subagent_type: "general-purpose".to_string(), + parent_session_id: "parent".to_string(), + parent_prompt_id: None, + resume_from: None, + cwd: None, + runtime_overrides: Default::default(), + run_in_background: false, + surface_completion: true, + await_to_completion: false, + fork_context: false, + owner: super::super::types::SubagentOwner::Task, + cancel_token: tokio_util::sync::CancellationToken::new(), + }; + + let err = backend.spawn(request).await.unwrap_err(); + assert!(err.to_string().contains("channel closed")); +} + +#[tokio::test] +async fn channel_backend_query_found() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let req = recv_event!(rx, Query); + assert_eq!(req.subagent_id, "sub-1"); + assert!(req.block); + assert_eq!(req.timeout_ms, Some(5000)); + req.respond_to + .send(Some(SubagentSnapshot { + subagent_id: "sub-1".to_string(), + description: "find bugs".to_string(), + subagent_type: "explore".to_string(), + status: super::super::types::SubagentSnapshotStatus::Completed { + output: "result".to_string(), + tool_calls: 2, + turns: 1, + worktree_path: None, + }, + started_at_epoch_ms: 1000, + duration_ms: 200, + persona: Some("reviewer".to_string()), + })) + .unwrap(); + }); + + let snap = backend.query("sub-1", true, Some(5000)).await; + let snap = snap.expect("snapshot should be present"); + assert_eq!(snap.subagent_id, "sub-1"); + assert_eq!(snap.description, "find bugs"); + assert_eq!(snap.subagent_type, "explore"); + assert_eq!(snap.started_at_epoch_ms, 1000); + assert_eq!(snap.duration_ms, 200); + assert_eq!(snap.persona.as_deref(), Some("reviewer")); + match &snap.status { + super::super::types::SubagentSnapshotStatus::Completed { + output, + tool_calls, + turns, + worktree_path, + } => { + assert_eq!(output, "result"); + assert_eq!(*tool_calls, 2); + assert_eq!(*turns, 1); + assert!(worktree_path.is_none()); + } + other => panic!("Expected Completed, got {:?}", other), + } + + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_query_non_blocking_passes_through() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let req = recv_event!(rx, Query); + assert_eq!(req.subagent_id, "sub-nb"); + assert!(!req.block, "block should be false"); + assert_eq!(req.timeout_ms, None, "timeout_ms should be None"); + req.respond_to.send(None).unwrap(); + }); + + let snap = backend.query("sub-nb", false, None).await; + assert!(snap.is_none()); + + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_query_not_found() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let req = recv_event!(rx, Query); + req.respond_to.send(None).unwrap(); + }); + + let snap = backend.query("nonexistent", false, None).await; + assert!(snap.is_none()); + + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_cancel_success() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let req = recv_event!(rx, Cancel); + match &req.target { + SubagentCancelTarget::SubagentId(id) => assert_eq!(id, "sub-cancel"), + other => panic!("Expected SubagentId, got {:?}", other), + } + req.respond_to + .send(SubagentCancelOutcome::Cancelled) + .unwrap(); + }); + + let outcome = backend.cancel("sub-cancel").await; + assert!(matches!(outcome, SubagentCancelOutcome::Cancelled)); + + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_cancel_closed_channel() { + let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); + drop(rx); + + let backend = ChannelBackend::new(tx); + + let outcome = backend.cancel("sub-cancel").await; + assert!(matches!(outcome, SubagentCancelOutcome::NotFound)); +} + +#[tokio::test] +async fn workflow_spawn_future_drop_cancels_but_task_drop_does_not() { + fn request_for(owner: super::super::types::SubagentOwner) -> SubagentRequest { + SubagentRequest { + id: "drop-owner-test".to_string(), + prompt: "test".to_string(), + description: "test".to_string(), + subagent_type: "general-purpose".to_string(), + parent_session_id: "parent".to_string(), + parent_prompt_id: None, + resume_from: None, + cwd: None, + runtime_overrides: Default::default(), + run_in_background: false, + surface_completion: false, + await_to_completion: true, + fork_context: false, + owner, + cancel_token: tokio_util::sync::CancellationToken::new(), + } + } + + for (owner, should_cancel) in [ + (super::super::types::SubagentOwner::Task, false), + (super::super::types::SubagentOwner::workflow("wf-1"), true), + ] { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = Arc::new(ChannelBackend::new(tx)); + let request = request_for(owner); + let cancel_token = request.cancel_token.clone(); + let task = tokio::spawn({ + let backend = backend.clone(); + async move { backend.spawn(request).await } + }); + let spawned = recv_event!(rx, Spawn); + task.abort(); + let _ = task.await; + assert_eq!( + cancel_token.is_cancelled(), + should_cancel, + "only workflow receiver drop owns cancellation" + ); + drop(spawned.result_tx); + } +} + +#[tokio::test] +async fn channel_backend_spawn_result_dropped() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let req = recv_event!(rx, Spawn); + drop(req.result_tx); + }); + + let request = SubagentRequest { + id: "drop-test".to_string(), + prompt: "test".to_string(), + description: "test".to_string(), + subagent_type: "general-purpose".to_string(), + parent_session_id: "parent".to_string(), + parent_prompt_id: None, + resume_from: None, + cwd: None, + runtime_overrides: Default::default(), + run_in_background: false, + surface_completion: true, + await_to_completion: false, + fork_context: false, + owner: super::super::types::SubagentOwner::Task, + cancel_token: tokio_util::sync::CancellationToken::new(), + }; + + let err = backend.spawn(request).await.unwrap_err(); + assert!( + err.to_string().contains("result channel dropped"), + "error: {err}" + ); + + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_query_closed_channel() { + let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); + drop(rx); + + let backend = ChannelBackend::new(tx); + + let snap = backend.query("sub-1", false, None).await; + assert!(snap.is_none()); +} + +// ── validate_type ──────────────────────────────────────────────── + +#[tokio::test] +async fn channel_backend_validate_type_round_trips_outcome() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + let event = rx.recv().await.unwrap(); + match event { + SubagentEvent::ValidateType(req) => { + assert_eq!(req.subagent_type, "explore"); + assert_eq!(req.parent_session_id, "parent-1"); + req.respond_to + .send(SubagentValidateTypeOutcome::Ok) + .unwrap(); + } + _ => panic!("Expected ValidateType event"), + } + }); + + let outcome = backend.validate_type("explore", "parent-1").await; + assert!(matches!(outcome, SubagentValidateTypeOutcome::Ok)); + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_validate_type_propagates_unknown_outcome() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await { + req.respond_to + .send(SubagentValidateTypeOutcome::Unknown { + available: vec!["explore".into(), "plan".into()], + }) + .unwrap(); + } + }); + + let outcome = backend.validate_type("invented", "p").await; + match outcome { + SubagentValidateTypeOutcome::Unknown { available } => { + assert_eq!(available, vec!["explore".to_string(), "plan".to_string()]); + } + other => panic!("expected Unknown, got {other:?}"), + } + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_validate_type_returns_validation_unavailable_when_channel_closed() { + let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); + drop(rx); + let backend = ChannelBackend::new(tx); + let outcome = backend.validate_type("explore", "p").await; + assert!(matches!( + outcome, + SubagentValidateTypeOutcome::ValidationUnavailable + )); +} + +#[tokio::test] +async fn channel_backend_validate_type_returns_validation_unavailable_when_responder_dropped() { + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + let handle = tokio::spawn(async move { + if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await { + drop(req.respond_to); + } + }); + let outcome = backend.validate_type("explore", "p").await; + assert!(matches!( + outcome, + SubagentValidateTypeOutcome::ValidationUnavailable, + )); + handle.await.unwrap(); +} + +use super::super::types::test_capture; + +#[tokio::test(start_paused = true)] +async fn channel_backend_validate_type_logs_warn_on_timeout() { + let captured = test_capture::capture(); + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + // Coordinator receives but never replies; keeps the responder + // alive so the timeout arm fires (not responder-dropped). + let holder = tokio::spawn(async move { + if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await { + std::mem::forget(req.respond_to); + std::future::pending::<()>().await; + } + }); + + let validate = tokio::spawn(async move { backend.validate_type("explore", "p").await }); + tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await; + let outcome = validate.await.unwrap(); + assert!(matches!( + outcome, + SubagentValidateTypeOutcome::ValidationUnavailable + )); + + let mut events_rx = captured.events_rx; + let mut saw_timeout_warn = false; + while let Ok(event) = events_rx.try_recv() { + if event.level == tracing::Level::WARN + && event.fields.contains("coordinator validation timed out") + && event.fields.contains("subagent_type=explore") + && event.fields.contains("timeout_ms=") + { + saw_timeout_warn = true; + break; + } + } + assert!(saw_timeout_warn, "must emit WARN with timeout_ms field"); + + holder.abort(); +} + +// ── describe_subagent_type ─────────────────────────────────────── + +#[tokio::test] +async fn channel_backend_describe_round_trips_summary() { + use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary}; + use crate::types::tool::ToolKind; + + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + match rx.recv().await.unwrap() { + SubagentEvent::DescribeType(req) => { + assert_eq!(req.subagent_type, "explore"); + assert_eq!(req.harness_agent_type.as_deref(), Some("cursor")); + assert_eq!(req.parent_session_id, "parent-1"); + let mut summary = SubagentTypeSummary { + can_read: true, + can_search: true, + ..Default::default() + }; + summary + .tool_names + .insert(ToolKind::Read, "read_file".to_string()); + req.respond_to + .send(SubagentDescribeOutcome::Ok(summary)) + .unwrap(); + } + _ => panic!("Expected DescribeType event"), + } + }); + + let outcome = backend + .describe_subagent_type("explore", Some("cursor"), "parent-1") + .await; + match outcome { + SubagentDescribeOutcome::Ok(summary) => { + assert!(summary.can_read && summary.can_search && !summary.can_execute); + assert_eq!( + summary.tool_names.get(&ToolKind::Read).unwrap(), + "read_file" + ); + } + other => panic!("expected Ok, got {other:?}"), + } + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_describe_propagates_not_allowed_outcome() { + use super::super::types::SubagentDescribeOutcome; + + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let handle = tokio::spawn(async move { + if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await { + req.respond_to + .send(SubagentDescribeOutcome::NotAllowed { + allowed: vec!["explore".into()], + }) + .unwrap(); + } + }); + + match backend.describe_subagent_type("plan", None, "p").await { + SubagentDescribeOutcome::NotAllowed { allowed } => { + assert_eq!(allowed, vec!["explore".to_string()]); + } + other => panic!("expected NotAllowed, got {other:?}"), + } + handle.await.unwrap(); +} + +#[tokio::test] +async fn channel_backend_describe_returns_unavailable_when_channel_closed() { + use super::super::types::SubagentDescribeOutcome; + let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>(); + drop(rx); + let backend = ChannelBackend::new(tx); + assert!(matches!( + backend.describe_subagent_type("explore", None, "p").await, + SubagentDescribeOutcome::Unavailable + )); +} + +#[tokio::test] +async fn channel_backend_describe_returns_unavailable_when_responder_dropped() { + use super::super::types::SubagentDescribeOutcome; + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + let handle = tokio::spawn(async move { + if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await { + drop(req.respond_to); + } + }); + assert!(matches!( + backend.describe_subagent_type("explore", None, "p").await, + SubagentDescribeOutcome::Unavailable + )); + handle.await.unwrap(); +} + +#[tokio::test(start_paused = true)] +async fn channel_backend_describe_returns_unavailable_on_timeout() { + use super::super::types::SubagentDescribeOutcome; + let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>(); + let backend = ChannelBackend::new(tx); + + let holder = tokio::spawn(async move { + if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await { + std::mem::forget(req.respond_to); + std::future::pending::<()>().await; + } + }); + + let describe = + tokio::spawn(async move { backend.describe_subagent_type("explore", None, "p").await }); + tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await; + assert!(matches!( + describe.await.unwrap(), + SubagentDescribeOutcome::Unavailable + )); + holder.abort(); +} + +#[test] +fn parse_timeout_ms_returns_none_for_unset() { + assert_eq!(parse_timeout_ms(None), None); +} + +#[test] +fn parse_timeout_ms_returns_none_for_unparseable() { + assert_eq!(parse_timeout_ms(Some("not-a-number")), None); + assert_eq!(parse_timeout_ms(Some("")), None); + assert_eq!(parse_timeout_ms(Some("3.14")), None); + assert_eq!(parse_timeout_ms(Some("-100")), None); +} + +#[test] +fn parse_timeout_ms_returns_none_for_zero() { + assert_eq!(parse_timeout_ms(Some("0")), None); +} + +#[test] +fn parse_timeout_ms_returns_value_for_positive_integer() { + assert_eq!(parse_timeout_ms(Some("5000")), Some(5000)); + assert_eq!(parse_timeout_ms(Some("1")), Some(1)); +} diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator.rs new file mode 100644 index 0000000..c818b27 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator.rs @@ -0,0 +1,840 @@ +//! Single-writer subagent coordinator actor. +//! +//! The actor owns the command receiver, pending/active/completed state, +//! concrete blocking waiters, foreground deadlines, cancellation, and the +//! terminal delivery disposition. All hosts drive it through `ChannelBackend`; +//! only their `ChildRunner` implementations differ. +//! +//! There is intentionally no shared mutable state in this module. A runner's +//! associated futures may be `Send` or non-`Send`; the resulting actor future +//! inherits that property naturally on stable Rust. + +mod query; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +use futures::FutureExt; +use futures::stream::{FuturesUnordered, StreamExt}; +use tokio::sync::{mpsc, oneshot}; + +use super::coordinator_state::{ + ActiveChild, BlockingWaiter, BufferedCompletion, ChildRecord, CompletedChild, InternalEvent, + ListRequest, MAX_COMPLETED_ENTRIES, PendingChild, ProgressFuture, ProgressTarget, ReplyFuture, + TaggedFuture, active_summary, background_at_deadline, background_if_caller_gone, + completed_snapshot, completion_summary, sleep_until, workflow_outstanding, +}; +use super::types::{ + SpawnedSubagentRef, SubagentCancelOutcome, SubagentCancelTarget, SubagentDescribeOutcome, + SubagentEvent, SubagentOutstandingReply, SubagentRegistryCounts, SubagentRequest, + SubagentResult, SubagentResumeLookup, SubagentResumeSource, SubagentValidateTypeOutcome, +}; + +pub use super::coordinator_state::{ + ChildCompletion, ChildControl, ChildReporter, ChildRunOutput, ChildRunRequest, ChildRunner, + CompletionDisposition, CoordinatorConfig, LocalBoxFuture, SendBoxFuture, StartedChild, + SubagentProgress, +}; + +/// Channel-owned subagent lifecycle actor. +pub struct SubagentCoordinator<R: ChildRunner> { + commands: mpsc::UnboundedReceiver<SubagentEvent>, + internal_tx: mpsc::UnboundedSender<InternalEvent<R::Control>>, + internal_rx: mpsc::UnboundedReceiver<InternalEvent<R::Control>>, + runner: R, + config: CoordinatorConfig, + pending: HashMap<String, PendingChild>, + active: HashMap<String, ActiveChild<R::Control>>, + completed: HashMap<String, CompletedChild>, + completed_order: VecDeque<String>, + waiters: HashMap<String, Vec<BlockingWaiter>>, + workflow_cancel_waiters: HashMap<String, Vec<oneshot::Sender<SubagentCancelOutcome>>>, + usage_not_applied_prompts: HashSet<PromptScope>, + pending_completions: Vec<BufferedCompletion>, + runs: FuturesUnordered< + TaggedFuture<futures::future::CatchUnwind<std::panic::AssertUnwindSafe<R::RunFuture>>>, + >, + validations: FuturesUnordered<ReplyFuture<R::ValidateFuture, SubagentValidateTypeOutcome>>, + descriptions: FuturesUnordered<ReplyFuture<R::DescribeFuture, SubagentDescribeOutcome>>, + progress: FuturesUnordered<ProgressFuture<<R::Control as ChildControl>::ProgressFuture>>, + list_requests: HashMap<u64, ListRequest>, + next_list_request_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PromptScope { + parent_session_id: String, + prompt_id: String, +} + +impl PromptScope { + fn new(parent_session_id: String, prompt_id: String) -> Self { + Self { + parent_session_id, + prompt_id, + } + } +} + +impl<R: ChildRunner> SubagentCoordinator<R> { + pub fn new( + commands: mpsc::UnboundedReceiver<SubagentEvent>, + runner: R, + config: CoordinatorConfig, + ) -> Self { + let (internal_tx, internal_rx) = mpsc::unbounded_channel(); + Self { + commands, + internal_tx, + internal_rx, + runner, + config, + pending: HashMap::new(), + active: HashMap::new(), + completed: HashMap::new(), + completed_order: VecDeque::new(), + waiters: HashMap::new(), + workflow_cancel_waiters: HashMap::new(), + usage_not_applied_prompts: HashSet::new(), + pending_completions: Vec::new(), + runs: FuturesUnordered::new(), + validations: FuturesUnordered::new(), + descriptions: FuturesUnordered::new(), + progress: FuturesUnordered::new(), + list_requests: HashMap::new(), + next_list_request_id: 0, + } + } + + pub async fn run(mut self) { + let mut commands_open = true; + loop { + if !commands_open + && self.runs.is_empty() + && self.validations.is_empty() + && self.descriptions.is_empty() + && self.progress.is_empty() + { + break; + } + + let deadline = self.next_deadline(); + tokio::select! { + biased; + Some(event) = self.internal_rx.recv() => self.handle_internal(event), + Some((id, output)) = self.runs.next(), if !self.runs.is_empty() => { + match output { + Ok(output) => self.finish_child(&id, output), + Err(_) => self.finish_panicked_child(&id), + } + } + Some((respond_to, outcome)) = self.validations.next(), if !self.validations.is_empty() => { + let _ = respond_to.send(outcome); + } + Some((respond_to, outcome)) = self.descriptions.next(), if !self.descriptions.is_empty() => { + let _ = respond_to.send(outcome); + } + Some((seed, target, progress)) = self.progress.next(), if !self.progress.is_empty() => { + self.finish_progress(seed, target, progress); + } + command = self.commands.recv(), if commands_open => { + match command { + Some(command) => { + self.reap_abandoned_callers(); + self.handle_command(command); + } + None => commands_open = false, + } + } + _ = sleep_until(deadline), if deadline.is_some() => self.process_deadlines(), + } + while self.completed.len() > MAX_COMPLETED_ENTRIES { + let Some(id) = self.completed_order.pop_front() else { + break; + }; + self.completed.remove(&id); + } + } + + self.cancel_all_children(); + } + + fn handle_command(&mut self, command: SubagentEvent) { + match command { + SubagentEvent::Spawn(command) => { + let mut request = *command.request; + if let Some((root_parent, loop_task_id)) = self + .active + .values() + .find(|child| child.child_session_id == request.parent_session_id) + .map(|child| { + ( + child.request.parent_session_id.clone(), + child.request.runtime_overrides.loop_task_id.clone(), + ) + }) + { + request.parent_session_id = root_parent; + request.surface_completion = false; + if request.runtime_overrides.loop_task_id.is_none() { + request.runtime_overrides.loop_task_id = loop_task_id; + } + } + let id = request.id.clone(); + if self.pending.contains_key(&id) + || self.active.contains_key(&id) + || self.completed.contains_key(&id) + { + let _ = command.result_tx.send(SubagentResult { + success: false, + error: Some(format!("Subagent id '{id}' already exists")), + subagent_id: id.clone(), + child_session_id: id, + ..Default::default() + }); + return; + } + let cancellation = request.cancel_token.clone(); + let handle_only = request.run_in_background; + let foreground_deadline = (!request.run_in_background + && !request.await_to_completion) + .then(|| tokio::time::Instant::now() + self.config.foreground_budget); + self.pending.insert( + id.clone(), + PendingChild { + request: request.clone(), + started_at: std::time::Instant::now(), + cancellation: cancellation.clone(), + spawn_reply: Some(command.result_tx), + foreground_deadline, + handle_only, + explicitly_killed: false, + }, + ); + self.running_count_changed(); + let reporter = ChildReporter { + subagent_id: id.clone(), + tx: self.internal_tx.clone(), + }; + self.runs.push(TaggedFuture { + subagent_id: id, + future: Box::pin( + std::panic::AssertUnwindSafe(self.runner.run(ChildRunRequest { + request, + cancellation, + reporter, + })) + .catch_unwind(), + ), + }); + } + SubagentEvent::Query(query) => { + self.handle_query( + query.subagent_id, + query.parent_session_id, + query.block, + query.timeout_ms, + query.respond_to, + ); + } + SubagentEvent::Cancel(request) => match request.target { + SubagentCancelTarget::SubagentId(id) => { + let outcome = self.cancel_one(&id, request.parent_session_id.as_deref(), true); + let _ = request.respond_to.send(outcome); + } + SubagentCancelTarget::ParentPromptId(prompt_id) => { + self.cancel_parent_prompt(&prompt_id, request.parent_session_id.as_deref()); + let _ = request.respond_to.send(SubagentCancelOutcome::Cancelled); + } + SubagentCancelTarget::WorkflowRunId(run_id) => { + self.cancel_workflow_children(&run_id, request.parent_session_id.as_deref()); + if workflow_outstanding(&self.pending, &self.active, &run_id) == 0 { + let _ = request.respond_to.send(SubagentCancelOutcome::Cancelled); + } else { + self.workflow_cancel_waiters + .entry(run_id) + .or_default() + .push(request.respond_to); + } + } + }, + SubagentEvent::ListActive(request) => { + let summaries = self + .active + .values() + .filter(|child| { + child.request.parent_session_id == request.parent_session_id + && !child.request.owner.is_workflow() + }) + .map(active_summary) + .collect(); + let _ = request.respond_to.send(summaries); + } + SubagentEvent::ListRunning(request) => { + self.handle_list_running(request.parent_session_id, request.respond_to); + } + SubagentEvent::Completions(request) => { + let (owned, foreign): (Vec<_>, Vec<_>) = + std::mem::take(&mut self.pending_completions) + .into_iter() + .partition(|completion| { + request + .parent_session_id + .as_ref() + .is_none_or(|id| completion.parent_session_id == *id) + }); + self.pending_completions = foreign; + let completions = owned + .into_iter() + .map(|completion| completion.summary) + .filter(|summary| !request.suppress_ids.contains(&summary.subagent_id)) + .collect(); + let _ = request.respond_to.send(completions); + } + SubagentEvent::DiscardSessionCompletions { parent_session_id } => { + self.pending_completions + .retain(|completion| completion.parent_session_id != parent_session_id); + } + SubagentEvent::Outstanding(request) => { + // Reap again here so turn-freeze / Outstanding polls see + // ParentGone even if no other command woke the actor first. + self.reap_abandoned_callers(); + let mut live_ids: Vec<_> = self + .pending + .values() + .filter(|child| { + child.request.parent_session_id == request.parent_session_id + && child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id) + && !child.request.owner.is_workflow() + && !child.handle_only + }) + .map(|child| child.request.id.clone()) + .chain( + self.active + .values() + .filter(|child| { + child.request.parent_session_id == request.parent_session_id + && child.request.parent_prompt_id.as_deref() + == Some(&request.prompt_id) + && !child.request.owner.is_workflow() + // Definition-declared background children are + // background for accounting even while the + // spawning tool block-awaits them. + && !child.handle_only + && !child.definition_background + }) + .map(|child| child.request.id.clone()), + ) + .collect(); + live_ids.sort(); + let background_live = self.pending.values().any(|child| { + child.request.parent_session_id == request.parent_session_id + && child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id) + && !child.request.owner.is_workflow() + && child.handle_only + }) || self.active.values().any(|child| { + child.request.parent_session_id == request.parent_session_id + && child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id) + && !child.request.owner.is_workflow() + && (child.handle_only || child.definition_background) + }); + let scope = + PromptScope::new(request.parent_session_id.clone(), request.prompt_id.clone()); + let _ = request.respond_to.send(SubagentOutstandingReply { + live_ids, + background_live, + subagent_usage_not_applied: self.usage_not_applied_prompts.contains(&scope), + }); + } + SubagentEvent::ClearUsageNotApplied(request) => { + self.usage_not_applied_prompts.remove(&PromptScope::new( + request.parent_session_id, + request.prompt_id, + )); + } + SubagentEvent::MarkUsageNotApplied(request) => { + self.usage_not_applied_prompts.insert(PromptScope::new( + request.parent_session_id, + request.prompt_id, + )); + let _ = request.respond_to.send(()); + } + SubagentEvent::RegistryCounts(request) => { + let _ = request.respond_to.send(SubagentRegistryCounts { + pending: self.pending.len(), + active: self.active.len(), + completed: self.completed.len(), + }); + } + SubagentEvent::Inspect(request) => { + self.handle_inspect( + request.subagent_id, + request.parent_session_id, + request.respond_to, + ); + } + SubagentEvent::SpawnedRefs(request) => { + let mut refs: Vec<_> = self + .active + .values() + .filter(|child| { + child.request.parent_session_id == request.parent_session_id + && child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id) + }) + .map(|child| SpawnedSubagentRef { + subagent_id: child.request.id.clone(), + child_session_id: child.child_session_id.clone(), + subagent_type: child.request.subagent_type.clone(), + description: child.request.description.clone(), + persona: child.persona.clone(), + resumed_from: child.resumed_from.clone(), + }) + .chain( + self.completed + .values() + .filter(|child| { + child.request.parent_session_id == request.parent_session_id + && child.request.parent_prompt_id.as_deref() + == Some(&request.prompt_id) + }) + .map(|child| SpawnedSubagentRef { + subagent_id: child.request.id.clone(), + child_session_id: child.child_session_id.clone(), + subagent_type: child.request.subagent_type.clone(), + description: child.request.description.clone(), + persona: child.persona.clone(), + resumed_from: child.resumed_from.clone(), + }), + ) + .collect(); + refs.sort_by(|a, b| a.subagent_id.cmp(&b.subagent_id)); + let _ = request.respond_to.send(refs); + } + SubagentEvent::ValidateType(request) => { + self.validations.push(ReplyFuture { + future: Box::pin( + self.runner + .validate_type(request.subagent_type, request.parent_session_id), + ), + respond_to: Some(request.respond_to), + }); + } + SubagentEvent::DescribeType(request) => { + self.descriptions.push(ReplyFuture { + future: Box::pin(self.runner.describe_type( + request.subagent_type, + request.harness_agent_type, + request.parent_session_id, + )), + respond_to: Some(request.respond_to), + }); + } + SubagentEvent::LoopUnitActive(request) => { + let is_active = self.pending.values().any(|child| { + child.request.runtime_overrides.loop_task_id.as_deref() + == Some(&request.task_id) + }) || self.active.values().any(|child| { + child.request.runtime_overrides.loop_task_id.as_deref() + == Some(&request.task_id) + }); + let _ = request.respond_to.send(is_active); + } + } + } + + fn handle_internal(&mut self, event: InternalEvent<R::Control>) { + match event { + InternalEvent::Started { + subagent_id, + child, + respond_to, + } => { + let Some(pending) = self.pending.remove(&subagent_id) else { + let _ = respond_to.send(false); + return; + }; + if pending.cancellation.is_cancelled() { + self.pending.insert(subagent_id, pending); + let _ = respond_to.send(false); + return; + } + self.active.insert( + subagent_id, + ActiveChild { + request: pending.request, + started_at: pending.started_at, + cancellation: pending.cancellation, + spawn_reply: pending.spawn_reply, + foreground_deadline: pending.foreground_deadline, + handle_only: pending.handle_only, + definition_background: child.definition_background, + explicitly_killed: pending.explicitly_killed, + child_session_id: child.child_session_id, + persona: child.persona, + resumed_from: child.resumed_from, + child_cwd: child.child_cwd, + worktree_path: child.worktree_path, + effective_model_id: child.effective_model_id, + control: child.control, + }, + ); + let _ = respond_to.send(true); + } + InternalEvent::ResumeSource { + source_id, + parent_session_id, + respond_to, + } => { + let source_is_active = + self.pending + .get(&source_id) + .is_some_and(|child| child.request.parent_session_id == parent_session_id) + || self.active.get(&source_id).is_some_and(|child| { + child.request.parent_session_id == parent_session_id + }); + let lookup = if source_is_active { + SubagentResumeLookup::Active + } else if let Some(child) = self.completed.get(&source_id) + && child.request.parent_session_id == parent_session_id + { + SubagentResumeLookup::Completed(SubagentResumeSource { + subagent_id: child.request.id.clone(), + child_session_id: child.child_session_id.clone(), + child_cwd: child.child_cwd.clone(), + worktree_path: child.worktree_path.clone(), + snapshot_ref: child.snapshot_ref.clone(), + subagent_type: child.request.subagent_type.clone(), + persona: child.persona.clone(), + model_id: Some(child.effective_model_id.clone()), + }) + } else { + SubagentResumeLookup::Missing + }; + let _ = respond_to.send(lookup); + } + } + } + + fn finish_child(&mut self, id: &str, output: ChildRunOutput<R::CompletionData>) { + let record = if let Some(child) = self.active.remove(id) { + ChildRecord::Active(child) + } else if let Some(child) = self.pending.remove(id) { + ChildRecord::Pending(child) + } else { + return; + }; + + let request = record.request().clone(); + let explicitly_killed = record.explicitly_killed(); + let ( + started_at, + child_session_id, + persona, + resumed_from, + child_cwd, + worktree_path, + effective_model_id, + mut spawn_reply, + mut handle_only, + ) = match record { + ChildRecord::Pending(child) => ( + child.started_at, + output.result.child_session_id.clone(), + child.request.runtime_overrides.persona.clone(), + child.request.resume_from.clone(), + child.request.cwd.clone().unwrap_or_default(), + output.result.worktree_path.clone(), + String::new(), + child.spawn_reply, + child.handle_only, + ), + ChildRecord::Active(child) => ( + child.started_at, + child.child_session_id, + child.persona, + child.resumed_from, + child.child_cwd, + child.worktree_path, + child.effective_model_id, + child.spawn_reply, + child.handle_only, + ), + }; + + let persisted_output_ref = self.runner.persisted_output_ref(&output.completion_data); + let mut completed = CompletedChild { + request: request.clone(), + started_at, + child_session_id, + persona, + resumed_from, + child_cwd, + worktree_path, + snapshot_ref: output.snapshot_ref, + persisted_output_ref, + effective_model_id, + result: output.result.clone(), + }; + let snapshot = completed_snapshot(&completed, None); + + let mut waiter_delivered = false; + for waiter in self.waiters.remove(id).unwrap_or_default() { + waiter_delivered |= waiter.respond_to.send(Some(snapshot.clone())).is_ok(); + } + + let mut foreground_delivered = false; + if let Some(respond_to) = spawn_reply.take() { + let sent = respond_to.send(output.result.clone()).is_ok(); + if !handle_only { + foreground_delivered = sent; + handle_only = !sent; + } + } else if !handle_only { + handle_only = true; + } + + if self.config.buffer_completions + && request.surface_completion + && !request.owner.is_workflow() + { + let mut summary = completion_summary(&request, &output.result); + if let Some(cap) = self.config.buffered_completion_output_cap { + summary.output = super::cap_completion_output(&summary.output, cap); + } + self.pending_completions.push(BufferedCompletion { + parent_session_id: request.parent_session_id.clone(), + summary, + }); + // Bound the buffer (drop oldest): sessions unloaded without a + // DiscardSessionCompletions cannot grow it unboundedly. + const MAX_PENDING_COMPLETIONS: usize = 256; + if self.pending_completions.len() > MAX_PENDING_COMPLETIONS { + let excess = self.pending_completions.len() - MAX_PENDING_COMPLETIONS; + self.pending_completions.drain(..excess); + } + } + if completed.persisted_output_ref.is_some() { + completed.result.output = Arc::from(""); + } + + let should_surface = request.surface_completion + && handle_only + && !output.result.cancelled + && !waiter_delivered + && !explicitly_killed; + let disposition = CompletionDisposition { + foreground_delivered, + backgrounded: handle_only, + waiter_delivered, + explicitly_killed, + should_surface, + }; + self.completed.insert(id.to_owned(), completed); + self.completed_order.push_back(id.to_owned()); + self.running_count_changed(); + let workflow_run_id = request.owner.workflow_run_id().map(str::to_owned); + self.runner.on_completed(ChildCompletion { + request, + result: output.result, + completion_data: output.completion_data, + disposition, + }); + if let Some(run_id) = workflow_run_id { + self.resolve_workflow_cancel_waiters(&run_id); + } + } + + fn finish_panicked_child(&mut self, id: &str) { + let request = self + .active + .get(id) + .map(|child| child.request.clone()) + .or_else(|| self.pending.get(id).map(|child| child.request.clone())); + let Some(request) = request else { + return; + }; + tracing::error!(subagent_id = id, "subagent child runner panicked"); + self.finish_child( + id, + ChildRunOutput { + result: SubagentResult { + success: false, + error: Some("Subagent runtime panicked".to_owned()), + subagent_id: request.id.clone(), + child_session_id: request.id, + ..Default::default() + }, + completion_data: R::CompletionData::default(), + snapshot_ref: None, + }, + ); + } + + fn cancel_one( + &mut self, + id: &str, + parent_session_id: Option<&str>, + explicit: bool, + ) -> SubagentCancelOutcome { + if let Some(child) = self.active.get_mut(id) + && belongs_to_session(&child.request, parent_session_id) + { + child.explicitly_killed |= explicit; + child.cancellation.cancel(); + child.control.cancel(); + return SubagentCancelOutcome::Cancelled; + } + if let Some(child) = self.pending.get_mut(id) + && belongs_to_session(&child.request, parent_session_id) + { + child.explicitly_killed |= explicit; + child.cancellation.cancel(); + return SubagentCancelOutcome::Cancelled; + } + if let Some(child) = self.completed.get(id) + && belongs_to_session(&child.request, parent_session_id) + { + return SubagentCancelOutcome::AlreadyFinished { + status: child.result.status().to_owned(), + }; + } + SubagentCancelOutcome::NotFound + } + + fn cancel_parent_prompt(&mut self, parent_prompt_id: &str, parent_session_id: Option<&str>) { + for child in self.active.values() { + if child.request.parent_prompt_id.as_deref() == Some(parent_prompt_id) + && belongs_to_session(&child.request, parent_session_id) + { + child.cancellation.cancel(); + child.control.cancel(); + } + } + for child in self.pending.values() { + if child.request.parent_prompt_id.as_deref() == Some(parent_prompt_id) + && belongs_to_session(&child.request, parent_session_id) + { + child.cancellation.cancel(); + } + } + } + + fn cancel_workflow_children(&mut self, run_id: &str, parent_session_id: Option<&str>) { + for child in self.active.values() { + if child.request.owner.workflow_run_id() == Some(run_id) + && belongs_to_session(&child.request, parent_session_id) + { + child.cancellation.cancel(); + child.control.cancel(); + } + } + for child in self.pending.values() { + if child.request.owner.workflow_run_id() == Some(run_id) + && belongs_to_session(&child.request, parent_session_id) + { + child.cancellation.cancel(); + } + } + } + + fn resolve_workflow_cancel_waiters(&mut self, run_id: &str) { + if workflow_outstanding(&self.pending, &self.active, run_id) != 0 { + return; + } + for respond_to in self + .workflow_cancel_waiters + .remove(run_id) + .unwrap_or_default() + { + let _ = respond_to.send(SubagentCancelOutcome::Cancelled); + } + } + + fn next_deadline(&self) -> Option<tokio::time::Instant> { + self.pending + .values() + .filter_map(|child| child.foreground_deadline) + .chain( + self.active + .values() + .filter_map(|child| child.foreground_deadline), + ) + .chain( + self.waiters + .values() + .flatten() + .map(|waiter| waiter.deadline), + ) + .min() + } + + fn reap_abandoned_callers(&mut self) { + for child in self.pending.values_mut() { + background_if_caller_gone(child); + } + for child in self.active.values_mut() { + background_if_caller_gone(child); + } + } + + fn process_deadlines(&mut self) { + self.reap_abandoned_callers(); + let now = tokio::time::Instant::now(); + for child in self.pending.values_mut() { + background_at_deadline(child, now, self.config.foreground_budget); + } + for child in self.active.values_mut() { + background_at_deadline(child, now, self.config.foreground_budget); + } + + let ids: Vec<_> = self.waiters.keys().cloned().collect(); + for id in ids { + let waiters = self.waiters.remove(&id).unwrap_or_default(); + let (due, live): (Vec<_>, Vec<_>) = waiters + .into_iter() + .partition(|waiter| waiter.deadline <= now); + if !live.is_empty() { + self.waiters.insert(id.clone(), live); + } + for waiter in due { + if waiter.respond_to.is_closed() { + continue; + } + if self.active.contains_key(&id) { + self.queue_active_progress(&id, ProgressTarget::Query(waiter.respond_to)); + } else { + let _ = waiter.respond_to.send(self.ready_snapshot(&id)); + } + } + } + } + + fn running_count_changed(&self) { + self.runner + .running_count_changed(self.pending.len() + self.active.len()); + } + + fn cancel_all_children(&self) { + for child in self.active.values() { + child.cancellation.cancel(); + child.control.cancel(); + } + for child in self.pending.values() { + child.cancellation.cancel(); + } + } +} + +fn belongs_to_session(request: &SubagentRequest, parent_session_id: Option<&str>) -> bool { + parent_session_id.is_none_or(|id| request.parent_session_id == id) +} + +impl<R: ChildRunner> Drop for SubagentCoordinator<R> { + fn drop(&mut self) { + self.cancel_all_children(); + } +} + +#[cfg(test)] +#[path = "coordinator_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator/query.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator/query.rs new file mode 100644 index 0000000..f47d1e5 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator/query.rs @@ -0,0 +1,256 @@ +//! Session-scoped query, inspection, and progress delivery. + +use std::sync::Arc; + +use tokio::sync::oneshot; + +use super::super::coordinator_state::{ + BlockingWaiter, CompletedChild, ListRequest, OUTPUT_UNAVAILABLE_PLACEHOLDER, ProgressFuture, + ProgressTarget, RunningSeed, completed_inspection, completed_snapshot, pending_inspection, + pending_snapshot, running_inspection, running_seed, +}; +use super::super::types::{SubagentInspection, SubagentSnapshot}; +use super::{ChildControl, ChildRunner, SubagentCoordinator, SubagentProgress, belongs_to_session}; + +impl<R: ChildRunner> SubagentCoordinator<R> { + pub(super) fn handle_query( + &mut self, + id: String, + parent_session_id: Option<String>, + block: bool, + timeout_ms: Option<u64>, + respond_to: oneshot::Sender<Option<SubagentSnapshot>>, + ) { + if let Some(child) = self + .completed + .get(&id) + .filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref())) + { + let snapshot = (!child.request.owner.is_workflow()) + .then(|| self.completed_snapshot_for_query(child)); + let _ = respond_to.send(snapshot); + return; + } + if let Some(child) = self + .active + .get(&id) + .filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref())) + { + if child.request.owner.is_workflow() { + let _ = respond_to.send(None); + return; + } + if block { + self.waiters.entry(id).or_default().push(BlockingWaiter { + deadline: tokio::time::Instant::now() + + std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)), + respond_to, + }); + } else { + self.queue_active_progress(&id, ProgressTarget::Query(respond_to)); + } + return; + } + if let Some(child) = self + .pending + .get(&id) + .filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref())) + { + if child.request.owner.is_workflow() { + let _ = respond_to.send(None); + return; + } + if block { + self.waiters.entry(id).or_default().push(BlockingWaiter { + deadline: tokio::time::Instant::now() + + std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)), + respond_to, + }); + } else { + let _ = respond_to.send(Some(pending_snapshot(child))); + } + return; + } + let _ = respond_to.send(None); + } + + pub(super) fn handle_inspect( + &mut self, + id: String, + parent_session_id: Option<String>, + respond_to: oneshot::Sender<Option<SubagentInspection>>, + ) { + if let Some(child) = self + .completed + .get(&id) + .filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref())) + { + let _ = respond_to.send(Some(self.completed_inspection_for_query(child))); + } else if let Some(child) = self + .pending + .get(&id) + .filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref())) + { + let _ = respond_to.send(Some(pending_inspection(child))); + } else if self + .active + .get(&id) + .is_some_and(|child| belongs_to_session(&child.request, parent_session_id.as_deref())) + { + self.queue_active_progress(&id, ProgressTarget::Inspect(respond_to)); + } else { + let _ = respond_to.send(None); + } + } + + fn persisted_output(&self, child: &CompletedChild) -> Option<Arc<str>> { + child.persisted_output_ref.as_deref().map(|reference| { + self.runner + .load_persisted_output(reference) + .unwrap_or_else(|| Arc::from(OUTPUT_UNAVAILABLE_PLACEHOLDER)) + }) + } + + fn completed_snapshot_for_query(&self, child: &CompletedChild) -> SubagentSnapshot { + let output = self.persisted_output(child); + completed_snapshot(child, output.as_deref()) + } + + fn completed_inspection_for_query(&self, child: &CompletedChild) -> SubagentInspection { + let output = self.persisted_output(child); + completed_inspection(child, output.as_deref()) + } + + pub(super) fn ready_snapshot(&self, id: &str) -> Option<SubagentSnapshot> { + self.completed + .get(id) + .filter(|child| !child.request.owner.is_workflow()) + .map(|child| self.completed_snapshot_for_query(child)) + .or_else(|| { + self.pending + .get(id) + .filter(|child| !child.request.owner.is_workflow()) + .map(pending_snapshot) + }) + } + + pub(super) fn handle_list_running( + &mut self, + parent_session_id: String, + respond_to: oneshot::Sender<Vec<SubagentInspection>>, + ) { + let ids: Vec<_> = self + .active + .values() + .filter(|child| { + child.request.parent_session_id == parent_session_id + && !child.request.owner.is_workflow() + }) + .map(|child| child.request.id.clone()) + .collect(); + if ids.is_empty() { + let _ = respond_to.send(Vec::new()); + return; + } + + let request_id = self.next_list_request_id; + self.next_list_request_id = self.next_list_request_id.wrapping_add(1); + self.list_requests.insert( + request_id, + ListRequest { + slots: vec![None; ids.len()], + remaining: ids.len(), + respond_to, + }, + ); + for (index, id) in ids.into_iter().enumerate() { + self.queue_active_progress(&id, ProgressTarget::List { request_id, index }); + } + } + + pub(super) fn queue_active_progress(&mut self, id: &str, target: ProgressTarget) { + let Some(child) = self.active.get(id) else { + match target { + ProgressTarget::Query(tx) => { + let _ = tx.send(self.ready_snapshot(id)); + } + ProgressTarget::Inspect(tx) => { + let value = self + .completed + .get(id) + .map(|child| self.completed_inspection_for_query(child)); + let _ = tx.send(value); + } + ProgressTarget::List { request_id, index } => { + self.finish_list_slot(request_id, index, None); + } + } + return; + }; + self.progress.push(ProgressFuture { + future: Box::pin(child.control.progress()), + seed: Some(running_seed(child)), + target: Some(target), + }); + } + + pub(super) fn finish_progress( + &mut self, + seed: RunningSeed, + target: ProgressTarget, + progress: SubagentProgress, + ) { + let still_active = self.active.contains_key(&seed.subagent_id); + if !still_active { + match target { + ProgressTarget::Query(respond_to) => { + let _ = respond_to.send(self.ready_snapshot(&seed.subagent_id)); + } + ProgressTarget::Inspect(respond_to) => { + let value = self + .completed + .get(&seed.subagent_id) + .map(|child| self.completed_inspection_for_query(child)); + let _ = respond_to.send(value); + } + ProgressTarget::List { request_id, index } => { + self.finish_list_slot(request_id, index, None); + } + } + return; + } + let inspection = running_inspection(seed, progress); + match target { + ProgressTarget::Query(respond_to) => { + let _ = respond_to.send(Some(inspection.snapshot)); + } + ProgressTarget::Inspect(respond_to) => { + let _ = respond_to.send(Some(inspection)); + } + ProgressTarget::List { request_id, index } => { + self.finish_list_slot(request_id, index, Some(inspection)); + } + } + } + + fn finish_list_slot( + &mut self, + request_id: u64, + index: usize, + inspection: Option<SubagentInspection>, + ) { + let Some(request) = self.list_requests.get_mut(&request_id) else { + return; + }; + request.slots[index] = inspection; + request.remaining = request.remaining.saturating_sub(1); + if request.remaining != 0 { + return; + } + let Some(request) = self.list_requests.remove(&request_id) else { + return; + }; + let values = request.slots.into_iter().flatten().collect(); + let _ = request.respond_to.send(values); + } +} diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator_state.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator_state.rs new file mode 100644 index 0000000..7d02fe7 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator_state.rs @@ -0,0 +1,731 @@ +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +use super::types::{ + ActiveSubagentSummary, SubagentCompletionSummary, SubagentDescribeOutcome, SubagentInspection, + SubagentRequest, SubagentResult, SubagentResumeLookup, SubagentSnapshot, + SubagentSnapshotStatus, SubagentValidateTypeOutcome, +}; + +pub(super) const MAX_COMPLETED_ENTRIES: usize = 1024; +pub(super) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]"; + +pub type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>; +pub type SendBoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>; + +/// Runtime-specific live progress for one active child. +#[derive(Debug, Clone, Default)] +pub struct SubagentProgress { + pub turn_count: u32, + pub tool_call_count: u32, + pub tokens_used: u64, + pub context_window_tokens: u64, + pub context_usage_pct: u8, + pub tools_used: Vec<String>, + pub error_count: u32, +} + +/// Runtime handle retained while a child is active. +pub trait ChildControl: 'static { + type ProgressFuture: Future<Output = SubagentProgress> + 'static; + + fn progress(&self) -> Self::ProgressFuture; + fn cancel(&self); +} + +/// Data reported when runtime initialization has produced a live child. +pub struct StartedChild<C> { + pub child_session_id: String, + pub persona: Option<String>, + pub resumed_from: Option<String>, + pub child_cwd: String, + pub worktree_path: Option<String>, + pub effective_model_id: String, + /// The resolved agent definition declares `background: true`. Folded into + /// `Outstanding` accounting (background, never turn-blocking) while the + /// foreground await budget stays gated on the tool's own + /// `run_in_background` flag. + pub definition_background: bool, + pub control: C, +} + +/// Input to one runtime-specific child run. +pub struct ChildRunRequest<C> { + pub request: SubagentRequest, + pub cancellation: CancellationToken, + pub reporter: ChildReporter<C>, +} + +/// Terminal output from one runtime-specific child run. +pub struct ChildRunOutput<D> { + pub result: SubagentResult, + pub completion_data: D, + pub snapshot_ref: Option<String>, +} + +/// Coordinator-owned delivery decision passed to host presentation. +#[derive(Debug, Clone)] +pub struct CompletionDisposition { + pub foreground_delivered: bool, + pub backgrounded: bool, + pub waiter_delivered: bool, + pub explicitly_killed: bool, + pub should_surface: bool, +} + +/// Terminal event delivered to the runtime adapter after state is committed. +pub struct ChildCompletion<D> { + pub request: SubagentRequest, + pub result: SubagentResult, + pub completion_data: D, + pub disposition: CompletionDisposition, +} + +/// The only host-specific seam. +/// +/// Associated future types intentionally carry no unconditional `Send` bound. +/// A local runner may return non-`Send` futures, while a multithreaded runner +/// may return `Send` futures. +pub trait ChildRunner: 'static { + type Control: ChildControl; + type CompletionData: Default + 'static; + type RunFuture: Future<Output = ChildRunOutput<Self::CompletionData>> + 'static; + type ValidateFuture: Future<Output = SubagentValidateTypeOutcome> + 'static; + type DescribeFuture: Future<Output = SubagentDescribeOutcome> + 'static; + + fn run(&self, request: ChildRunRequest<Self::Control>) -> Self::RunFuture; + + fn validate_type( + &self, + subagent_type: String, + parent_session_id: String, + ) -> Self::ValidateFuture; + + fn describe_type( + &self, + subagent_type: String, + harness_agent_type: Option<String>, + parent_session_id: String, + ) -> Self::DescribeFuture; + + fn on_completed(&self, completion: ChildCompletion<Self::CompletionData>); + + fn running_count_changed(&self, _running: usize) {} + + fn persisted_output_ref(&self, _completion_data: &Self::CompletionData) -> Option<String> { + None + } + + fn load_persisted_output(&self, _reference: &str) -> Option<Arc<str>> { + None + } +} + +/// Host-configurable lifecycle policy. The transition logic remains shared. +#[derive(Debug, Clone)] +pub struct CoordinatorConfig { + pub foreground_budget: std::time::Duration, + /// Whether the host drains completion summaries between turns. + pub buffer_completions: bool, + /// Extra cap applied to BUFFERED summary outputs only (the request's own + /// `completion_output_cap` still applies first). Buffered entries pin the + /// child's output `Arc` until drained; hosts whose reminder rendering + /// never inlines the output (a polling tool exists, e.g. the callback + /// tools-server) should bound it. `None` keeps outputs verbatim — the + /// shell needs this for toolsets with no polling tool, where the inline + /// reminder is the model's only chance to see the output. + pub buffered_completion_output_cap: Option<usize>, +} + +impl Default for CoordinatorConfig { + fn default() -> Self { + Self { + foreground_budget: std::time::Duration::from_secs(45), + buffer_completions: false, + buffered_completion_output_cap: None, + } + } +} + +/// Runner-side channel back into the actor. +pub struct ChildReporter<C> { + pub(super) subagent_id: String, + pub(super) tx: mpsc::UnboundedSender<InternalEvent<C>>, +} + +impl<C> Clone for ChildReporter<C> { + fn clone(&self) -> Self { + Self { + subagent_id: self.subagent_id.clone(), + tx: self.tx.clone(), + } + } +} + +impl<C: 'static> ChildReporter<C> { + /// Promote the pending child to active. The acknowledgement closes the + /// cancel-at-promote race: `false` means cancellation won and the adapter + /// must tear down the half-initialized runtime. + pub async fn started(&self, child: StartedChild<C>) -> bool { + let (respond_to, response_rx) = oneshot::channel(); + if self + .tx + .send(InternalEvent::Started { + subagent_id: self.subagent_id.clone(), + child, + respond_to, + }) + .is_err() + { + return false; + } + response_rx.await.unwrap_or(false) + } + + /// Resolve an in-memory resume source without sharing coordinator state. + pub async fn resume_source( + &self, + source_id: &str, + parent_session_id: &str, + ) -> SubagentResumeLookup { + let (respond_to, response_rx) = oneshot::channel(); + if self + .tx + .send(InternalEvent::ResumeSource { + source_id: source_id.to_owned(), + parent_session_id: parent_session_id.to_owned(), + respond_to, + }) + .is_err() + { + return SubagentResumeLookup::Missing; + } + response_rx.await.unwrap_or(SubagentResumeLookup::Missing) + } +} + +pub(super) enum InternalEvent<C> { + Started { + subagent_id: String, + child: StartedChild<C>, + respond_to: oneshot::Sender<bool>, + }, + ResumeSource { + source_id: String, + parent_session_id: String, + respond_to: oneshot::Sender<SubagentResumeLookup>, + }, +} + +pub(super) struct PendingChild { + pub(super) request: SubagentRequest, + pub(super) started_at: std::time::Instant, + pub(super) cancellation: CancellationToken, + pub(super) spawn_reply: Option<oneshot::Sender<SubagentResult>>, + pub(super) foreground_deadline: Option<tokio::time::Instant>, + pub(super) handle_only: bool, + pub(super) explicitly_killed: bool, +} + +pub(super) struct ActiveChild<C> { + pub(super) request: SubagentRequest, + pub(super) started_at: std::time::Instant, + pub(super) cancellation: CancellationToken, + pub(super) spawn_reply: Option<oneshot::Sender<SubagentResult>>, + pub(super) foreground_deadline: Option<tokio::time::Instant>, + pub(super) handle_only: bool, + /// Definition-declared background (see [`StartedChild`]): background for + /// `Outstanding` accounting even while the spawn caller block-awaits. + pub(super) definition_background: bool, + pub(super) explicitly_killed: bool, + pub(super) child_session_id: String, + pub(super) persona: Option<String>, + pub(super) resumed_from: Option<String>, + pub(super) child_cwd: String, + pub(super) worktree_path: Option<String>, + pub(super) effective_model_id: String, + pub(super) control: C, +} + +pub(super) struct CompletedChild { + pub(super) request: SubagentRequest, + pub(super) started_at: std::time::Instant, + pub(super) child_session_id: String, + pub(super) persona: Option<String>, + pub(super) resumed_from: Option<String>, + pub(super) child_cwd: String, + pub(super) worktree_path: Option<String>, + pub(super) snapshot_ref: Option<String>, + pub(super) persisted_output_ref: Option<String>, + pub(super) effective_model_id: String, + pub(super) result: SubagentResult, +} + +pub(super) struct BlockingWaiter { + pub(super) deadline: tokio::time::Instant, + pub(super) respond_to: oneshot::Sender<Option<SubagentSnapshot>>, +} + +pub(super) struct BufferedCompletion { + pub(super) parent_session_id: String, + pub(super) summary: SubagentCompletionSummary, +} + +pub(super) struct TaggedFuture<F> { + pub(super) subagent_id: String, + pub(super) future: Pin<Box<F>>, +} + +impl<F: Future> Future for TaggedFuture<F> { + type Output = (String, F::Output); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + let this = self.get_mut(); + this.future + .as_mut() + .poll(cx) + .map(|output| (this.subagent_id.clone(), output)) + } +} + +pub(super) struct ReplyFuture<F, T> { + pub(super) future: Pin<Box<F>>, + pub(super) respond_to: Option<oneshot::Sender<T>>, +} + +impl<F, T> Future for ReplyFuture<F, T> +where + F: Future<Output = T>, +{ + type Output = (oneshot::Sender<T>, T); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + let this = self.get_mut(); + this.future.as_mut().poll(cx).map(|output| { + let respond_to = match this.respond_to.take() { + Some(respond_to) => respond_to, + None => unreachable!("reply future polled after completion"), + }; + (respond_to, output) + }) + } +} + +#[derive(Clone)] +pub(super) struct RunningSeed { + pub(super) subagent_id: String, + pub(super) description: String, + pub(super) subagent_type: String, + pub(super) started_at_epoch_ms: u64, + pub(super) duration_ms: u64, + pub(super) persona: Option<String>, + pub(super) parent_session_id: String, + pub(super) child_session_id: String, + pub(super) fork_parent_prompt_id: Option<String>, + pub(super) resumed_from: Option<String>, +} + +pub(super) enum ProgressTarget { + Query(oneshot::Sender<Option<SubagentSnapshot>>), + Inspect(oneshot::Sender<Option<SubagentInspection>>), + List { request_id: u64, index: usize }, +} + +pub(super) struct ProgressFuture<F> { + pub(super) future: Pin<Box<F>>, + pub(super) seed: Option<RunningSeed>, + pub(super) target: Option<ProgressTarget>, +} + +impl<F> Future for ProgressFuture<F> +where + F: Future<Output = SubagentProgress>, +{ + type Output = (RunningSeed, ProgressTarget, SubagentProgress); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + let this = self.get_mut(); + this.future.as_mut().poll(cx).map(|progress| { + let seed = match this.seed.take() { + Some(seed) => seed, + None => unreachable!("progress future polled without a seed"), + }; + let target = match this.target.take() { + Some(target) => target, + None => unreachable!("progress future polled without a target"), + }; + (seed, target, progress) + }) + } +} + +pub(super) struct ListRequest { + pub(super) slots: Vec<Option<SubagentInspection>>, + pub(super) remaining: usize, + pub(super) respond_to: oneshot::Sender<Vec<SubagentInspection>>, +} + +pub(super) enum ChildRecord<C> { + Pending(PendingChild), + Active(ActiveChild<C>), +} + +impl<C> ChildRecord<C> { + pub(super) fn request(&self) -> &SubagentRequest { + match self { + Self::Pending(child) => &child.request, + Self::Active(child) => &child.request, + } + } + + pub(super) fn explicitly_killed(&self) -> bool { + match self { + Self::Pending(child) => child.explicitly_killed, + Self::Active(child) => child.explicitly_killed, + } + } +} + +pub(super) trait ForegroundChild { + fn id(&self) -> &str; + fn child_session_id(&self) -> &str; + fn deadline(&self) -> Option<tokio::time::Instant>; + /// True when the spawn caller dropped its result receiver while this + /// child was still treated as turn-blocking (old shell `ParentGone`). + fn caller_gone(&self) -> bool; + fn is_workflow(&self) -> bool; + fn take_reply(&mut self) -> Option<oneshot::Sender<SubagentResult>>; + fn mark_backgrounded(&mut self); + /// Cancel the child's execution (token + active control where present). + fn cancel(&mut self); +} + +impl ForegroundChild for PendingChild { + fn id(&self) -> &str { + &self.request.id + } + + fn child_session_id(&self) -> &str { + &self.request.id + } + + fn deadline(&self) -> Option<tokio::time::Instant> { + self.foreground_deadline + } + + fn caller_gone(&self) -> bool { + !self.handle_only && self.spawn_reply.as_ref().is_some_and(|tx| tx.is_closed()) + } + + fn is_workflow(&self) -> bool { + self.request.owner.is_workflow() + } + + fn take_reply(&mut self) -> Option<oneshot::Sender<SubagentResult>> { + self.spawn_reply.take() + } + + fn mark_backgrounded(&mut self) { + self.handle_only = true; + self.foreground_deadline = None; + } + + fn cancel(&mut self) { + self.cancellation.cancel(); + } +} + +impl<C: ChildControl> ForegroundChild for ActiveChild<C> { + fn id(&self) -> &str { + &self.request.id + } + + fn child_session_id(&self) -> &str { + &self.child_session_id + } + + fn deadline(&self) -> Option<tokio::time::Instant> { + self.foreground_deadline + } + + fn caller_gone(&self) -> bool { + !self.handle_only && self.spawn_reply.as_ref().is_some_and(|tx| tx.is_closed()) + } + + fn is_workflow(&self) -> bool { + self.request.owner.is_workflow() + } + + fn take_reply(&mut self) -> Option<oneshot::Sender<SubagentResult>> { + self.spawn_reply.take() + } + + fn mark_backgrounded(&mut self) { + self.handle_only = true; + self.foreground_deadline = None; + } + + fn cancel(&mut self) { + self.cancellation.cancel(); + self.control.cancel(); + } +} + +pub(super) fn background_at_deadline( + child: &mut impl ForegroundChild, + now: tokio::time::Instant, + budget: std::time::Duration, +) { + if child.deadline().is_none_or(|deadline| deadline > now) { + return; + } + tracing::warn!( + subagent_id = child.id(), + budget_ms = budget.as_millis() as u64, + "foreground subagent exceeded await budget; auto-backgrounding (child keeps running)", + ); + if let Some(respond_to) = child.take_reply() { + // Interim handoff, not a completion: keep `success: false` (default) + // so `SubagentResult::status()` consumers cannot record a completed + // status for a still-running child. Callers branch on `backgrounded`. + let _ = respond_to.send(SubagentResult { + backgrounded: true, + subagent_id: child.id().to_owned(), + child_session_id: child.child_session_id().to_owned(), + ..Default::default() + }); + } + child.mark_backgrounded(); +} + +/// Handle a foreground child whose spawn caller dropped the result channel +/// (parent turn stop / cancelled await). Task-owned children keep running and +/// just leave the turn-blocking `Outstanding` set — shell `ParentGone` parity. +/// Workflow-owned children are CANCELLED instead (old shell `ParentGone` +/// cancelled workflow children); `ChannelBackend`'s drop-cancel arming remains +/// defense in depth for hosts that go through it. +pub(super) fn background_if_caller_gone(child: &mut impl ForegroundChild) { + if !child.caller_gone() { + return; + } + let _ = child.take_reply(); + if child.is_workflow() { + tracing::debug!( + subagent_id = child.id(), + "workflow subagent caller gone; cancelling child", + ); + child.cancel(); + return; + } + tracing::debug!( + subagent_id = child.id(), + "foreground subagent caller gone; auto-backgrounding (child keeps running)", + ); + child.mark_backgrounded(); +} + +pub(super) async fn sleep_until(deadline: Option<tokio::time::Instant>) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } +} + +fn instant_to_epoch_ms(instant: std::time::Instant) -> u64 { + let now_instant = std::time::Instant::now(); + let now_system = std::time::SystemTime::now(); + let elapsed = now_instant.saturating_duration_since(instant); + now_system + .checked_sub(elapsed) + .unwrap_or(now_system) + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub(super) fn active_summary<C>(child: &ActiveChild<C>) -> ActiveSubagentSummary { + ActiveSubagentSummary { + subagent_id: child.request.id.clone(), + subagent_type: child.request.subagent_type.clone(), + description: child.request.description.clone(), + elapsed_ms: child.started_at.elapsed().as_millis() as u64, + } +} + +pub(super) fn running_seed<C>(child: &ActiveChild<C>) -> RunningSeed { + RunningSeed { + subagent_id: child.request.id.clone(), + description: child.request.description.clone(), + subagent_type: child.request.subagent_type.clone(), + started_at_epoch_ms: instant_to_epoch_ms(child.started_at), + duration_ms: child.started_at.elapsed().as_millis() as u64, + persona: child.persona.clone(), + parent_session_id: child.request.parent_session_id.clone(), + child_session_id: child.child_session_id.clone(), + fork_parent_prompt_id: child.request.parent_prompt_id.clone(), + resumed_from: child.resumed_from.clone(), + } +} + +pub(super) fn running_inspection( + seed: RunningSeed, + progress: SubagentProgress, +) -> SubagentInspection { + SubagentInspection { + snapshot: SubagentSnapshot { + subagent_id: seed.subagent_id, + description: seed.description, + subagent_type: seed.subagent_type, + status: SubagentSnapshotStatus::Running { + turn_count: progress.turn_count, + tool_call_count: progress.tool_call_count, + tokens_used: progress.tokens_used, + context_window_tokens: progress.context_window_tokens, + context_usage_pct: progress.context_usage_pct, + tools_used: progress.tools_used, + error_count: progress.error_count, + }, + started_at_epoch_ms: seed.started_at_epoch_ms, + duration_ms: seed.duration_ms, + persona: seed.persona, + }, + parent_session_id: seed.parent_session_id, + child_session_id: seed.child_session_id, + fork_parent_prompt_id: seed.fork_parent_prompt_id, + resumed_from: seed.resumed_from, + } +} + +pub(super) fn pending_snapshot(child: &PendingChild) -> SubagentSnapshot { + SubagentSnapshot { + subagent_id: child.request.id.clone(), + description: child.request.description.clone(), + subagent_type: child.request.subagent_type.clone(), + status: SubagentSnapshotStatus::Initializing, + started_at_epoch_ms: instant_to_epoch_ms(child.started_at), + duration_ms: child.started_at.elapsed().as_millis() as u64, + persona: child.request.runtime_overrides.persona.clone(), + } +} + +pub(super) fn pending_inspection(child: &PendingChild) -> SubagentInspection { + SubagentInspection { + snapshot: pending_snapshot(child), + parent_session_id: child.request.parent_session_id.clone(), + child_session_id: String::new(), + fork_parent_prompt_id: child.request.parent_prompt_id.clone(), + resumed_from: child.request.resume_from.clone(), + } +} + +pub(super) fn completed_snapshot( + child: &CompletedChild, + persisted_output: Option<&str>, +) -> SubagentSnapshot { + let status = if child.result.cancelled { + SubagentSnapshotStatus::Cancelled { + reason: child.result.error.clone(), + } + } else if child.result.success { + SubagentSnapshotStatus::Completed { + output: persisted_output + .map(str::to_owned) + .unwrap_or_else(|| child.result.output.to_string()), + tool_calls: child.result.tool_calls, + turns: child.result.turns, + worktree_path: child.result.worktree_path.clone(), + } + } else { + SubagentSnapshotStatus::Failed { + error: child + .result + .error + .clone() + .unwrap_or_else(|| "Unknown error".to_owned()), + } + }; + SubagentSnapshot { + subagent_id: child.request.id.clone(), + description: child.request.description.clone(), + subagent_type: child.request.subagent_type.clone(), + status, + started_at_epoch_ms: instant_to_epoch_ms(child.started_at), + duration_ms: child.result.duration_ms, + persona: child.persona.clone(), + } +} + +pub(super) fn completed_inspection( + child: &CompletedChild, + persisted_output: Option<&str>, +) -> SubagentInspection { + SubagentInspection { + snapshot: completed_snapshot(child, persisted_output), + parent_session_id: child.request.parent_session_id.clone(), + child_session_id: child.child_session_id.clone(), + fork_parent_prompt_id: child.request.parent_prompt_id.clone(), + resumed_from: child.resumed_from.clone(), + } +} + +/// Truncate `output` to `cap` bytes (UTF-8 safe) with a truncation footer. +/// Returns a refcount clone when already within the cap. +pub fn cap_completion_output(output: &Arc<str>, cap: usize) -> Arc<str> { + if output.len() <= cap { + return output.clone(); + } + let mut end = cap; + while end > 0 && !output.is_char_boundary(end) { + end -= 1; + } + Arc::from(format!( + "{}\n[output truncated: {} of {} bytes shown]", + &output[..end], + end, + output.len() + )) +} + +/// Model-facing summary for a finished child, honoring the request's +/// `completion_output_cap`. Shared by the coordinator's buffered reminder +/// path and the shell's auto-wake synthetic prompt. +pub fn completion_summary( + request: &SubagentRequest, + result: &SubagentResult, +) -> SubagentCompletionSummary { + let output = match request.runtime_overrides.completion_output_cap { + Some(cap) => cap_completion_output(&result.output, cap), + None => result.output.clone(), + }; + SubagentCompletionSummary { + subagent_id: request.id.clone(), + subagent_type: request.subagent_type.clone(), + description: request.description.clone(), + success: result.success && !result.cancelled, + duration_ms: result.duration_ms, + tool_calls: result.tool_calls, + turns: result.turns, + output, + } +} + +pub(super) fn workflow_outstanding<C>( + pending: &HashMap<String, PendingChild>, + active: &HashMap<String, ActiveChild<C>>, + run_id: &str, +) -> usize { + pending + .values() + .filter(|child| child.request.owner.workflow_run_id() == Some(run_id)) + .count() + + active + .values() + .filter(|child| child.request.owner.workflow_run_id() == Some(run_id)) + .count() +} diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator_tests.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator_tests.rs new file mode 100644 index 0000000..f35e69c --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/coordinator_tests.rs @@ -0,0 +1,1191 @@ +use super::*; +use crate::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend}; +use crate::implementations::grok_build::task::types::{ + SubagentCancelRequest, SubagentClearUsageNotAppliedRequest, SubagentCompletionsRequest, + SubagentListActiveRequest, SubagentLoopUnitActiveRequest, SubagentMarkUsageNotAppliedRequest, + SubagentOutstandingReply, SubagentOutstandingRequest, SubagentOwner, SubagentRegistryCounts, + SubagentRequest, SubagentSnapshotStatus, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct TestControl { + cancellation: CancellationToken, +} + +impl ChildControl for TestControl { + type ProgressFuture = std::future::Ready<SubagentProgress>; + + fn progress(&self) -> Self::ProgressFuture { + std::future::ready(SubagentProgress { + turn_count: 2, + tool_call_count: 3, + tokens_used: 100, + context_window_tokens: 1_000, + context_usage_pct: 10, + tools_used: vec!["read_file".to_owned()], + error_count: 0, + }) + } + + fn cancel(&self) { + self.cancellation.cancel(); + } +} + +struct TestRunner { + wait_before_start: bool, + wait_after_cancel: bool, + start: tokio::sync::broadcast::Sender<()>, + finish: tokio::sync::broadcast::Sender<()>, + completions: mpsc::UnboundedSender<CompletionDisposition>, + requests: mpsc::UnboundedSender<SubagentRequest>, + started: mpsc::UnboundedSender<String>, +} + +impl ChildRunner for TestRunner { + type Control = TestControl; + type CompletionData = (); + type RunFuture = SendBoxFuture<ChildRunOutput<()>>; + type ValidateFuture = SendBoxFuture<SubagentValidateTypeOutcome>; + type DescribeFuture = SendBoxFuture<SubagentDescribeOutcome>; + + fn run(&self, run: ChildRunRequest<Self::Control>) -> Self::RunFuture { + let wait_before_start = self.wait_before_start; + let wait_after_cancel = self.wait_after_cancel; + let mut start = self.start.subscribe(); + let mut finish = self.finish.subscribe(); + let requests = self.requests.clone(); + let started = self.started.clone(); + Box::pin(async move { + let ChildRunRequest { + request, + cancellation, + reporter, + } = run; + let _ = requests.send(request.clone()); + if wait_before_start { + tokio::select! { + _ = cancellation.cancelled() => { + if wait_after_cancel { + let _ = finish.recv().await; + } + return ChildRunOutput { + result: cancelled_result(&request), + completion_data: (), + snapshot_ref: None, + }; + } + _ = start.recv() => {} + } + } + if !reporter + .started(StartedChild { + child_session_id: request.id.clone(), + persona: None, + resumed_from: request.resume_from.clone(), + child_cwd: request.cwd.clone().unwrap_or_default(), + worktree_path: None, + effective_model_id: "test-model".to_owned(), + // Mock definition resolution: this type declares background. + definition_background: request.subagent_type == "background-default", + control: TestControl { + cancellation: cancellation.clone(), + }, + }) + .await + { + return ChildRunOutput { + result: cancelled_result(&request), + completion_data: (), + snapshot_ref: None, + }; + } + let _ = started.send(request.id.clone()); + let result = tokio::select! { + _ = cancellation.cancelled() => { + if wait_after_cancel { + let _ = finish.recv().await; + } + cancelled_result(&request) + }, + _ = finish.recv() => SubagentResult { + success: true, + output: request.prompt.clone().into(), + subagent_id: request.id.clone(), + child_session_id: request.id.clone(), + tool_calls: 3, + turns: 2, + ..Default::default() + }, + }; + ChildRunOutput { + result, + completion_data: (), + snapshot_ref: None, + } + }) + } + + fn validate_type( + &self, + _subagent_type: String, + _parent_session_id: String, + ) -> Self::ValidateFuture { + Box::pin(std::future::ready(SubagentValidateTypeOutcome::Ok)) + } + + fn describe_type( + &self, + _subagent_type: String, + _harness_agent_type: Option<String>, + _parent_session_id: String, + ) -> Self::DescribeFuture { + Box::pin(std::future::ready(SubagentDescribeOutcome::Unavailable)) + } + + fn on_completed(&self, completion: ChildCompletion<Self::CompletionData>) { + let _ = self.completions.send(completion.disposition); + } +} + +fn cancelled_result(request: &SubagentRequest) -> SubagentResult { + SubagentResult { + success: false, + cancelled: true, + error: Some("cancelled".to_owned()), + subagent_id: request.id.clone(), + child_session_id: request.id.clone(), + ..Default::default() + } +} + +fn request(id: &str, background: bool) -> SubagentRequest { + SubagentRequest { + id: id.to_owned(), + prompt: "work".to_owned(), + description: "test child".to_owned(), + subagent_type: "explore".to_owned(), + parent_session_id: "parent".to_owned(), + parent_prompt_id: Some("prompt".to_owned()), + resume_from: None, + cwd: None, + runtime_overrides: Default::default(), + run_in_background: background, + surface_completion: true, + await_to_completion: false, + fork_context: false, + owner: SubagentOwner::Task, + cancel_token: CancellationToken::new(), + } +} + +struct Harness { + backend: ChannelBackend, + start: tokio::sync::broadcast::Sender<()>, + finish: tokio::sync::broadcast::Sender<()>, + completions: mpsc::UnboundedReceiver<CompletionDisposition>, + requests: mpsc::UnboundedReceiver<SubagentRequest>, + started: mpsc::UnboundedReceiver<String>, + actor: tokio::task::JoinHandle<()>, +} + +fn harness(wait_before_start: bool, foreground_budget: std::time::Duration) -> Harness { + harness_with_config( + wait_before_start, + CoordinatorConfig { + foreground_budget, + ..CoordinatorConfig::default() + }, + ) +} + +fn harness_with_config(wait_before_start: bool, config: CoordinatorConfig) -> Harness { + harness_with_options(wait_before_start, false, config) +} + +fn harness_with_options( + wait_before_start: bool, + wait_after_cancel: bool, + config: CoordinatorConfig, +) -> Harness { + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let (start, _) = tokio::sync::broadcast::channel(4); + let (finish, _) = tokio::sync::broadcast::channel(4); + let (completion_tx, completions) = mpsc::unbounded_channel(); + let (request_tx, requests) = mpsc::unbounded_channel(); + let (started_tx, started) = mpsc::unbounded_channel(); + let actor = tokio::spawn( + SubagentCoordinator::new( + command_rx, + TestRunner { + wait_before_start, + wait_after_cancel, + start: start.clone(), + finish: finish.clone(), + completions: completion_tx, + requests: request_tx, + started: started_tx, + }, + config, + ) + .run(), + ); + Harness { + backend: ChannelBackend::new(command_tx), + start, + finish, + completions, + requests, + started, + actor, + } +} + +async fn loop_unit_active(backend: &ChannelBackend, task_id: &str) -> bool { + let (respond_to, response_rx) = oneshot::channel(); + backend + .sender() + .send(SubagentEvent::LoopUnitActive( + SubagentLoopUnitActiveRequest { + task_id: task_id.to_owned(), + respond_to, + }, + )) + .expect("actor command channel open"); + response_rx.await.expect("loop activity response") +} + +async fn outstanding(backend: &ChannelBackend, prompt_id: &str) -> SubagentOutstandingReply { + let (respond_to, response_rx) = oneshot::channel(); + backend + .sender() + .send(SubagentEvent::Outstanding(SubagentOutstandingRequest { + parent_session_id: "parent".to_owned(), + prompt_id: prompt_id.to_owned(), + respond_to, + })) + .expect("actor command channel open"); + response_rx.await.expect("outstanding response") +} + +#[tokio::test] +async fn foreground_completion_is_delivered_inline() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("inline", false)).await } + }); + tokio::task::yield_now().await; + let _ = harness.finish.send(()); + + let result = spawn.await.unwrap().unwrap(); + assert!(result.success); + let disposition = harness.completions.recv().await.unwrap(); + assert!(disposition.foreground_delivered); + assert!(!disposition.should_surface); + harness.actor.abort(); +} + +#[tokio::test(start_paused = true)] +async fn foreground_deadline_hands_off_without_stopping_child() { + let mut harness = harness(false, std::time::Duration::from_secs(1)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("slow", false)).await } + }); + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(1)).await; + let interim = spawn.await.unwrap().unwrap(); + assert!(interim.backgrounded); + // Interim handoff must not read as a completion (status() contract). + assert!(!interim.success); + assert_eq!( + outstanding(&harness.backend, "prompt").await, + SubagentOutstandingReply { + live_ids: Vec::new(), + background_live: true, + subagent_usage_not_applied: false, + } + ); + assert_eq!( + harness.backend.registry_counts().await, + SubagentRegistryCounts { + pending: 0, + active: 1, + completed: 0, + } + ); + + let running = harness.backend.query("slow", false, None).await.unwrap(); + assert!(running.is_running()); + let _ = harness.finish.send(()); + let disposition = harness.completions.recv().await.unwrap(); + assert!(disposition.backgrounded); + assert!(disposition.should_surface); + harness.actor.abort(); +} + +#[tokio::test] +async fn live_blocking_waiter_suppresses_async_surface() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("waited", true)).await } + }); + tokio::task::yield_now().await; + let wait = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.query("waited", true, Some(60_000)).await } + }); + tokio::task::yield_now().await; + let _ = harness.finish.send(()); + + assert!(wait.await.unwrap().unwrap().status.is_terminal()); + let disposition = harness.completions.recv().await.unwrap(); + assert!(disposition.waiter_delivered); + assert!(!disposition.should_surface); + assert!(spawn.await.unwrap().unwrap().success); + harness.actor.abort(); +} + +#[tokio::test(start_paused = true)] +async fn timed_out_waiter_does_not_suppress_later_completion() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("timeout", true)).await } + }); + tokio::task::yield_now().await; + let snapshot = harness + .backend + .query("timeout", true, Some(1_000)) + .await + .unwrap(); + assert!(snapshot.is_running()); + + let _ = harness.finish.send(()); + let disposition = harness.completions.recv().await.unwrap(); + assert!(!disposition.waiter_delivered); + assert!(disposition.should_surface); + assert!(spawn.await.unwrap().unwrap().success); + harness.actor.abort(); +} + +#[tokio::test(start_paused = true)] +async fn surviving_waiter_suppresses_after_peer_times_out() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("two-waiters", true)).await } + }); + tokio::task::yield_now().await; + let short = tokio::spawn({ + let backend = harness.backend.clone(); + async move { + backend + .query("two-waiters", true, Some(1_000)) + .await + .unwrap() + } + }); + let long = tokio::spawn({ + let backend = harness.backend.clone(); + async move { + backend + .query("two-waiters", true, Some(60_000)) + .await + .unwrap() + } + }); + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(1)).await; + assert!(short.await.unwrap().is_running()); + + let _ = harness.finish.send(()); + assert!(long.await.unwrap().status.is_terminal()); + let disposition = harness.completions.recv().await.unwrap(); + assert!(disposition.waiter_delivered); + assert!(!disposition.should_surface); + assert!(spawn.await.unwrap().unwrap().success); + harness.actor.abort(); +} + +#[tokio::test] +async fn dropped_waiter_does_not_suppress_completion() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("dropped-wait", true)).await } + }); + tokio::task::yield_now().await; + let wait = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.query("dropped-wait", true, Some(60_000)).await } + }); + tokio::task::yield_now().await; + wait.abort(); + let _ = wait.await; + + let _ = harness.finish.send(()); + let disposition = harness.completions.recv().await.unwrap(); + assert!(!disposition.waiter_delivered); + assert!(disposition.should_surface); + assert!(spawn.await.unwrap().unwrap().success); + harness.actor.abort(); +} + +#[tokio::test] +async fn pending_cancel_delivers_waiter_once() { + let mut harness = harness(true, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("pending-cancel", true)).await } + }); + tokio::task::yield_now().await; + let wait = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.query("pending-cancel", true, Some(60_000)).await } + }); + tokio::task::yield_now().await; + assert!(matches!( + harness.backend.cancel("pending-cancel").await, + SubagentCancelOutcome::Cancelled + )); + let snapshot = wait.await.unwrap().unwrap(); + assert!(matches!( + snapshot.status, + SubagentSnapshotStatus::Cancelled { .. } + )); + let disposition = harness.completions.recv().await.unwrap(); + assert!(disposition.waiter_delivered); + assert!(disposition.explicitly_killed); + assert!(!disposition.should_surface); + assert!(spawn.await.unwrap().unwrap().cancelled); + harness.actor.abort(); +} + +#[tokio::test] +async fn caller_drop_during_initialization_does_not_drop_owned_run() { + let mut harness = harness(true, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("owned", false)).await } + }); + tokio::task::yield_now().await; + spawn.abort(); + let _ = spawn.await; + + let initializing = harness.backend.query("owned", false, None).await.unwrap(); + assert!(matches!( + initializing.status, + SubagentSnapshotStatus::Initializing + )); + let _ = harness.start.send(()); + tokio::task::yield_now().await; + let _ = harness.finish.send(()); + let disposition = harness.completions.recv().await.unwrap(); + assert!( + disposition.should_surface, + "dropped foreground receiver becomes handle-only" + ); + let terminal = harness.backend.query("owned", false, None).await.unwrap(); + assert!(terminal.status.is_terminal()); + harness.actor.abort(); +} + +#[tokio::test] +async fn abandoned_foreground_caller_clears_outstanding() { + // ParentGone parity: dropping the spawn await must leave Outstanding + // (turn-freeze) without waiting for the foreground budget. + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("abandoned", false)).await } + }); + tokio::task::yield_now().await; + assert_eq!( + outstanding(&harness.backend, "prompt").await.live_ids, + vec!["abandoned".to_owned()], + "live foreground child blocks the turn" + ); + + spawn.abort(); + let _ = spawn.await; + assert_eq!( + outstanding(&harness.backend, "prompt").await, + SubagentOutstandingReply { + live_ids: Vec::new(), + background_live: true, + subagent_usage_not_applied: false, + }, + "caller-gone foreground is handle-only for Outstanding" + ); + let running = harness + .backend + .query("abandoned", false, None) + .await + .unwrap(); + assert!(running.is_running(), "child keeps running after ParentGone"); + + let _ = harness.finish.send(()); + let disposition = harness.completions.recv().await.unwrap(); + assert!(disposition.backgrounded); + assert!(disposition.should_surface); + harness.actor.abort(); +} + +#[tokio::test] +async fn duplicate_subagent_id_is_rejected_without_replacing_live_child() { + let harness = harness(false, std::time::Duration::from_secs(60)); + let first = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("duplicate", true)).await } + }); + tokio::task::yield_now().await; + + let duplicate = harness + .backend + .spawn(request("duplicate", false)) + .await + .expect("duplicate rejection is a lifecycle result"); + assert!(!duplicate.success); + assert!( + duplicate + .error + .as_deref() + .is_some_and(|error| error.contains("already exists")) + ); + + let running = harness + .backend + .query("duplicate", false, None) + .await + .expect("original child remains queryable"); + assert!(running.is_running()); + let _ = harness.finish.send(()); + assert!(first.await.unwrap().unwrap().success); + harness.actor.abort(); +} + +#[tokio::test] +async fn external_cancel_token_cancels_live_child() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let request = request("external-cancel", false); + let cancel_token = request.cancel_token.clone(); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!( + harness.started.recv().await.as_deref(), + Some("external-cancel") + ); + + cancel_token.cancel(); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), spawn) + .await + .expect("external cancellation should finish") + .unwrap() + .unwrap(); + assert!(result.cancelled); + let disposition = harness.completions.recv().await.unwrap(); + assert!(!disposition.explicitly_killed); + harness.actor.abort(); +} + +#[tokio::test] +async fn dropping_coordinator_cancels_live_child() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let cancellation = CancellationToken::new(); + let mut request = request("owner-drop", true); + request.cancel_token = cancellation.clone(); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some("owner-drop")); + + harness.actor.abort(); + tokio::time::timeout(std::time::Duration::from_secs(1), cancellation.cancelled()) + .await + .expect("coordinator drop should cancel child"); + assert!(spawn.await.unwrap().is_err()); +} + +#[tokio::test(start_paused = true)] +async fn await_to_completion_has_no_foreground_deadline() { + let mut harness = harness(false, std::time::Duration::from_secs(1)); + let mut request = request("await-completion", false); + request.await_to_completion = true; + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!( + harness.started.recv().await.as_deref(), + Some("await-completion") + ); + + tokio::time::advance(std::time::Duration::from_secs(10)).await; + assert!(!spawn.is_finished()); + let _ = harness.finish.send(()); + let result = spawn.await.unwrap().unwrap(); + assert!(result.success); + assert!(!result.backgrounded); + harness.actor.abort(); +} + +#[tokio::test] +async fn workflow_cancel_waits_for_drain_and_hides_owned_children() { + let mut harness = harness_with_options( + true, + true, + CoordinatorConfig { + buffer_completions: true, + ..CoordinatorConfig::default() + }, + ); + + let mut active_request = request("workflow-active", false); + active_request.await_to_completion = true; + active_request.owner = SubagentOwner::workflow("workflow-run"); + let active_spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(active_request).await } + }); + assert_eq!( + harness + .requests + .recv() + .await + .as_ref() + .map(|request| request.id.as_str()), + Some("workflow-active") + ); + let _ = harness.start.send(()); + assert_eq!( + harness.started.recv().await.as_deref(), + Some("workflow-active") + ); + + let mut pending_request = request("workflow-pending", false); + pending_request.await_to_completion = true; + pending_request.owner = SubagentOwner::workflow("workflow-run"); + let pending_spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(pending_request).await } + }); + assert_eq!( + harness + .requests + .recv() + .await + .as_ref() + .map(|request| request.id.as_str()), + Some("workflow-pending") + ); + + assert!( + harness + .backend + .query("workflow-active", false, None) + .await + .is_none() + ); + assert!( + harness + .backend + .query("workflow-pending", false, None) + .await + .is_none() + ); + assert!(harness.backend.inspect("workflow-active").await.is_some()); + assert!(harness.backend.inspect("workflow-pending").await.is_some()); + assert!(harness.backend.list_running("parent").await.is_empty()); + let (list_respond_to, list_response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::ListActive(SubagentListActiveRequest { + parent_session_id: "parent".to_owned(), + respond_to: list_respond_to, + })) + .expect("actor command channel open"); + assert!(list_response_rx.await.unwrap().is_empty()); + + let (cancel_respond_to, mut cancel_response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::Cancel(SubagentCancelRequest { + parent_session_id: Some("parent".to_owned()), + target: SubagentCancelTarget::WorkflowRunId("workflow-run".to_owned()), + respond_to: cancel_respond_to, + })) + .expect("actor command channel open"); + assert!(harness.backend.inspect("workflow-active").await.is_some()); + assert!(matches!( + cancel_response_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + + let _ = harness.finish.send(()); + assert!(matches!( + cancel_response_rx.await.unwrap(), + SubagentCancelOutcome::Cancelled + )); + assert!(active_spawn.await.unwrap().unwrap().cancelled); + assert!(pending_spawn.await.unwrap().unwrap().cancelled); + assert!( + harness + .backend + .query("workflow-active", false, None) + .await + .is_none() + ); + assert!(harness.backend.inspect("workflow-active").await.is_some()); + + let (completions_respond_to, completions_response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::Completions(SubagentCompletionsRequest { + parent_session_id: Some("parent".to_owned()), + suppress_ids: Vec::new(), + respond_to: completions_respond_to, + })) + .expect("actor command channel open"); + assert!(completions_response_rx.await.unwrap().is_empty()); + harness.actor.abort(); +} + +#[tokio::test] +async fn usage_events_feed_sorted_outstanding_reply() { + let mut harness = harness(true, std::time::Duration::from_secs(60)); + let mut spawns = Vec::new(); + for (id, is_background) in [ + ("z-foreground", false), + ("a-foreground", false), + ("background", true), + ] { + spawns.push(tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request(id, is_background)).await } + })); + assert_eq!( + harness + .requests + .recv() + .await + .as_ref() + .map(|request| request.id.as_str()), + Some(id) + ); + } + + let (foreign_respond_to, foreign_response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::MarkUsageNotApplied( + SubagentMarkUsageNotAppliedRequest { + parent_session_id: "foreign".to_owned(), + prompt_id: "prompt".to_owned(), + respond_to: foreign_respond_to, + }, + )) + .expect("actor command channel open"); + foreign_response_rx.await.expect("mark acknowledgement"); + assert!( + !outstanding(&harness.backend, "prompt") + .await + .subagent_usage_not_applied + ); + + let (mark_respond_to, mark_response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::MarkUsageNotApplied( + SubagentMarkUsageNotAppliedRequest { + parent_session_id: "parent".to_owned(), + prompt_id: "prompt".to_owned(), + respond_to: mark_respond_to, + }, + )) + .expect("actor command channel open"); + mark_response_rx.await.expect("mark acknowledgement"); + assert_eq!( + outstanding(&harness.backend, "prompt").await, + SubagentOutstandingReply { + live_ids: vec!["a-foreground".to_owned(), "z-foreground".to_owned()], + background_live: true, + subagent_usage_not_applied: true, + } + ); + + harness + .backend + .sender() + .send(SubagentEvent::ClearUsageNotApplied( + SubagentClearUsageNotAppliedRequest { + parent_session_id: "parent".to_owned(), + prompt_id: "prompt".to_owned(), + }, + )) + .expect("actor command channel open"); + assert_eq!( + outstanding(&harness.backend, "prompt").await, + SubagentOutstandingReply { + live_ids: vec!["a-foreground".to_owned(), "z-foreground".to_owned()], + background_live: true, + subagent_usage_not_applied: false, + } + ); + + assert!(matches!( + harness.backend.cancel_parent_prompt("prompt").await, + SubagentCancelOutcome::Cancelled + )); + for spawn in spawns { + assert!(spawn.await.unwrap().unwrap().cancelled); + } + harness.actor.abort(); +} + +#[tokio::test] +async fn loop_tracking_covers_pending_active_and_nested_reparenting() { + let mut harness = harness(true, std::time::Duration::from_secs(60)); + let mut outer_request = request("outer", true); + outer_request.runtime_overrides.loop_task_id = Some("loop-task".to_owned()); + let outer_spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(outer_request).await } + }); + let observed_outer = harness.requests.recv().await.unwrap(); + assert_eq!(observed_outer.parent_session_id, "parent"); + assert!(loop_unit_active(&harness.backend, "loop-task").await); + + let _ = harness.start.send(()); + assert_eq!(harness.started.recv().await.as_deref(), Some("outer")); + let refs = harness + .backend + .spawned_refs_for_prompt("parent", "prompt") + .await; + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].description, "test child"); + + let mut nested_request = request("nested", true); + nested_request.parent_session_id = "outer".to_owned(); + let nested_spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(nested_request).await } + }); + let observed_nested = harness.requests.recv().await.unwrap(); + assert_eq!(observed_nested.parent_session_id, "parent"); + assert!(!observed_nested.surface_completion); + assert_eq!( + observed_nested.runtime_overrides.loop_task_id.as_deref(), + Some("loop-task") + ); + assert!(loop_unit_active(&harness.backend, "loop-task").await); + + let _ = harness.start.send(()); + assert_eq!(harness.started.recv().await.as_deref(), Some("nested")); + let _ = harness.finish.send(()); + assert!(outer_spawn.await.unwrap().unwrap().success); + assert!(nested_spawn.await.unwrap().unwrap().success); + assert!(!loop_unit_active(&harness.backend, "loop-task").await); + harness.actor.abort(); +} + +#[tokio::test] +async fn completion_buffer_caps_summary_without_mutating_result() { + let mut harness = harness_with_config( + false, + CoordinatorConfig { + buffer_completions: true, + ..CoordinatorConfig::default() + }, + ); + let mut request = request("buffered", true); + request.prompt = "aéb".to_owned(); + request.runtime_overrides.completion_output_cap = Some(2); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some("buffered")); + let _ = harness.finish.send(()); + let result = spawn.await.unwrap().unwrap(); + assert_eq!(result.output.as_ref(), "aéb"); + let _ = harness.completions.recv().await; + let snapshot = harness + .backend + .query("buffered", false, None) + .await + .unwrap(); + let SubagentSnapshotStatus::Completed { output, .. } = snapshot.status else { + panic!("expected completed snapshot"); + }; + assert_eq!(output, "aéb"); + + let (respond_to, response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::Completions(SubagentCompletionsRequest { + parent_session_id: Some("parent".to_owned()), + suppress_ids: Vec::new(), + respond_to, + })) + .expect("actor command channel open"); + let buffered = response_rx.await.expect("completion response"); + assert_eq!(buffered.len(), 1); + assert_eq!(buffered[0].subagent_id, "buffered"); + assert_eq!( + buffered[0].output.as_ref(), + "a\n[output truncated: 1 of 4 bytes shown]" + ); + harness.actor.abort(); +} + +/// Regression (review): an agent definition with `background: true` spawned +/// with a BLOCKING tool call (`run_in_background: false`) is background for +/// Outstanding/freeze accounting — not turn-blocking — while the spawn caller +/// still receives the result inline. +#[tokio::test] +async fn definition_background_counts_as_background_for_outstanding() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let mut blocking_request = request("bg-def", false); + blocking_request.subagent_type = "background-default".to_owned(); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(blocking_request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some("bg-def")); + + // Started with definition background: live for the child itself but not + // turn-blocking; the prompt sees it as background work. + assert_eq!( + outstanding(&harness.backend, "prompt").await, + SubagentOutstandingReply { + live_ids: Vec::new(), + background_live: true, + subagent_usage_not_applied: false, + } + ); + + // The blocking caller still gets the completed result inline. + let _ = harness.finish.send(()); + let result = spawn.await.unwrap().unwrap(); + assert!(result.success); + assert!(!result.backgrounded); + harness.actor.abort(); +} + +#[tokio::test] +async fn buffered_completion_output_cap_bounds_buffered_summary() { + let mut harness = harness_with_config( + false, + CoordinatorConfig { + buffer_completions: true, + buffered_completion_output_cap: Some(8), + ..CoordinatorConfig::default() + }, + ); + let mut request = request("capped", true); + request.prompt = "x".repeat(64); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some("capped")); + let _ = harness.finish.send(()); + // Spawn result and queryable snapshot keep the full output… + let result = spawn.await.unwrap().unwrap(); + assert_eq!(result.output.len(), 64); + let _ = harness.completions.recv().await; + + // …only the buffered reminder copy is truncated. + let (respond_to, response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::Completions(SubagentCompletionsRequest { + parent_session_id: Some("parent".to_owned()), + suppress_ids: Vec::new(), + respond_to, + })) + .expect("actor command channel open"); + let buffered = response_rx.await.expect("completion response"); + assert_eq!(buffered.len(), 1); + assert!( + buffered[0] + .output + .contains("[output truncated: 8 of 64 bytes shown]"), + "buffered output must be capped, got: {}", + buffered[0].output + ); + harness.actor.abort(); +} + +#[tokio::test] +async fn discard_session_completions_drops_only_that_sessions_buffer() { + let mut harness = harness_with_config( + false, + CoordinatorConfig { + buffer_completions: true, + ..CoordinatorConfig::default() + }, + ); + for (id, parent) in [("child-a", "parent-a"), ("child-b", "parent-b")] { + let mut request = request(id, true); + request.parent_session_id = parent.to_owned(); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some(id)); + let _ = harness.finish.send(()); + assert!(spawn.await.unwrap().unwrap().success); + let _ = harness.completions.recv().await; + } + + // Removing parent-a (session unload) discards its buffered completion... + harness + .backend + .sender() + .send(SubagentEvent::DiscardSessionCompletions { + parent_session_id: "parent-a".to_owned(), + }) + .expect("actor command channel open"); + + let drain = |parent: &str| { + let sender = harness.backend.sender(); + let parent = parent.to_owned(); + async move { + let (respond_to, response_rx) = oneshot::channel(); + sender + .send(SubagentEvent::Completions(SubagentCompletionsRequest { + parent_session_id: Some(parent), + suppress_ids: Vec::new(), + respond_to, + })) + .expect("actor command channel open"); + response_rx.await.expect("completion response") + } + }; + assert!(drain("parent-a").await.is_empty()); + // ...while parent-b's completion stays buffered for its own drain. + let b = drain("parent-b").await; + assert_eq!(b.len(), 1); + assert_eq!(b[0].subagent_id, "child-b"); + harness.actor.abort(); +} + +#[tokio::test] +async fn completion_drain_is_scoped_to_parent_session() { + let mut harness = harness_with_config( + false, + CoordinatorConfig { + buffer_completions: true, + ..CoordinatorConfig::default() + }, + ); + for (id, parent) in [("child-a", "parent-a"), ("child-b", "parent-b")] { + let mut request = request(id, true); + request.parent_session_id = parent.to_owned(); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some(id)); + let _ = harness.finish.send(()); + assert!(spawn.await.unwrap().unwrap().success); + let _ = harness.completions.recv().await; + } + + for (parent, expected_id) in [("parent-a", "child-a"), ("parent-b", "child-b")] { + let (respond_to, response_rx) = oneshot::channel(); + harness + .backend + .sender() + .send(SubagentEvent::Completions(SubagentCompletionsRequest { + parent_session_id: Some(parent.to_owned()), + suppress_ids: Vec::new(), + respond_to, + })) + .expect("actor command channel open"); + let completions = response_rx.await.expect("completion response"); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0].subagent_id, expected_id); + } + harness.actor.abort(); +} + +#[tokio::test] +async fn session_backend_cannot_query_or_cancel_foreign_child() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + async move { backend.spawn(request("scoped", true)).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some("scoped")); + + let foreign = ChannelBackend::for_session(harness.backend.sender(), "foreign-parent"); + assert!(foreign.query("scoped", false, None).await.is_none()); + assert!(foreign.inspect("scoped").await.is_none()); + assert!(matches!( + foreign.cancel("scoped").await, + SubagentCancelOutcome::NotFound + )); + + assert!(matches!( + harness.backend.cancel("scoped").await, + SubagentCancelOutcome::Cancelled + )); + assert!(spawn.await.unwrap().unwrap().cancelled); + let _ = harness.completions.recv().await; + harness.actor.abort(); +} + +#[tokio::test] +async fn completed_cache_evicts_oldest_entry_at_cap() { + let mut harness = harness(false, std::time::Duration::from_secs(60)); + for index in 0..=MAX_COMPLETED_ENTRIES { + let id = format!("cache-{index:04}"); + let spawn = tokio::spawn({ + let backend = harness.backend.clone(); + let request = request(&id, true); + async move { backend.spawn(request).await } + }); + assert_eq!(harness.started.recv().await.as_deref(), Some(id.as_str())); + let _ = harness.finish.send(()); + assert!(spawn.await.unwrap().unwrap().success); + } + + assert!( + harness + .backend + .query("cache-0000", false, None) + .await + .is_none() + ); + assert!( + harness + .backend + .query("cache-0001", false, None) + .await + .is_some() + ); + assert!( + harness + .backend + .query(&format!("cache-{MAX_COMPLETED_ENTRIES:04}"), false, None,) + .await + .is_some() + ); + harness.actor.abort(); +} diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs index 730e7cb..f152d54 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/mod.rs @@ -2,17 +2,21 @@ //! //! The TaskTool delegates subagent operations to a [`SubagentBackend`] //! (injected as [`SubagentBackendResource`]). The backend abstracts over the -//! transport mechanism (in-process channels for the local host, remote -//! backends, etc.). +//! coordinator mailbox. All hosts use the same backend and coordinator actor; +//! only their child runners differ. //! //! ## Resources //! //! - `SubagentBackendResource` — backend for spawn/query/cancel (required) //! - `SubagentDepthCounter` — current nesting depth (optional, defaults to 0) //! - `SessionIdResource` — current session ID for parent scoping (optional) +//! - `SubagentForegroundWait` — host wait-window guard factory (optional) //! - `TaskModelValidator` — validates explicit model slugs before spawn pub mod backend; +pub mod coordinator; +mod coordinator_state; +pub use coordinator_state::{cap_completion_output, completion_summary}; pub mod types; use self::backend::SubagentBackendResource; @@ -116,9 +120,12 @@ impl xai_tool_runtime::Tool for TaskTool { ) -> Result<ToolOutput, xai_tool_runtime::ToolError> { use crate::types::tool_metadata::shared_resources; let resources = shared_resources(&ctx)?; + let tool_cancellation = ctx + .get::<xai_tool_runtime::Cancellation>() + .map(|cancellation| cancellation.0.clone()); // 1. Depth check - let (depth, backend, model_validator, parent_session_id, parent_prompt_id) = { + let (depth, backend, model_validator, parent_session_id, parent_prompt_id, foreground_wait) = { let res = resources.lock().await; let depth = res.get::<SubagentDepthCounter>().map(|d| d.0).unwrap_or(0); @@ -144,6 +151,7 @@ impl xai_tool_runtime::Tool for TaskTool { .get::<CurrentPromptIdResource>() .map(|p| p.0.clone()) .filter(|prompt_id| !prompt_id.is_empty()); + let foreground_wait = res.get::<SubagentForegroundWait>().cloned(); ( depth, @@ -151,6 +159,7 @@ impl xai_tool_runtime::Tool for TaskTool { model_validator, parent_session_id, parent_prompt_id, + foreground_wait, ) }; @@ -289,9 +298,18 @@ impl xai_tool_runtime::Tool for TaskTool { .task_id .clone() .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()); - - // Placeholder; `ChannelBackend::spawn` replaces it with a fresh one. - let (result_tx, _) = tokio::sync::oneshot::channel(); + let child_cancellation = tokio_util::sync::CancellationToken::new(); + let cancellation_forwarder = (!input.run_in_background) + .then(|| { + tool_cancellation.map(|tool_cancellation| { + let child_cancellation = child_cancellation.clone(); + tokio::spawn(async move { + tool_cancellation.cancelled().await; + child_cancellation.cancel(); + }) + }) + }) + .flatten(); let request = SubagentRequest { id: id.clone(), @@ -325,8 +343,7 @@ impl xai_tool_runtime::Tool for TaskTool { await_to_completion: false, fork_context: false, owner: SubagentOwner::Task, - cancel_token: tokio_util::sync::CancellationToken::new(), - result_tx, + cancel_token: child_cancellation, }; // 4. Background mode: fire-and-forget via backend.spawn(). @@ -377,7 +394,12 @@ impl xai_tool_runtime::Tool for TaskTool { } // 5. Blocking mode (default): spawn via backend and await result - let result = backend.backend().spawn(request).await?; + let _foreground_wait = foreground_wait.map(|wait| wait.enter()); + let result = backend.backend().spawn(request).await; + if let Some(forwarder) = cancellation_forwarder { + forwarder.abort(); + } + let result = result?; // 5b. The await budget expired and the coordinator auto-backgrounded the // still-running child — return a task_id to poll, like the background @@ -495,10 +517,10 @@ mod tests { (backend, proxy_rx) } - /// Extract a `SubagentRequest` from a `SubagentEvent`, panicking on wrong variant. - fn unwrap_spawn(event: SubagentEvent) -> SubagentRequest { + /// Extract a spawn envelope from a `SubagentEvent`. + fn unwrap_spawn(event: SubagentEvent) -> SubagentSpawnRequest { match event { - SubagentEvent::Spawn(r) => *r, + SubagentEvent::Spawn(r) => r, _ => panic!("Expected SubagentEvent::Spawn"), } } @@ -621,8 +643,7 @@ mod tests { assert_eq!(request.parent_session_id, "parent-session"); assert_eq!(request.parent_prompt_id.as_deref(), Some("prompt-123")); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: std::sync::Arc::from("Found 3 auth middleware files"), subagent_id: request.id.clone(), @@ -683,8 +704,7 @@ mod tests { let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); request - .result_tx - .send(SubagentResult { + .respond_with(|_| SubagentResult { success: false, error: Some("Child session crashed".to_string()), ..Default::default() @@ -765,11 +785,22 @@ mod tests { #[tokio::test] async fn auto_backgrounded_result_returns_task_id_text() { let (backend, mut rx) = make_backend(); - let resources = resources_for_task(backend); + let mut resources = resources_for_task(backend); + let wait_closed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + struct WaitProbe(Arc<std::sync::atomic::AtomicBool>); + impl Drop for WaitProbe { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + let wait_closed_for_factory = Arc::clone(&wait_closed); + resources.insert(SubagentForegroundWait::new(move || { + Box::new(WaitProbe(Arc::clone(&wait_closed_for_factory))) + })); let drain = tokio::spawn(async move { if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await { - let _ = boxed.result_tx.send(SubagentResult { + let _ = boxed.respond_with(|boxed| SubagentResult { backgrounded: true, subagent_id: boxed.id.clone(), child_session_id: boxed.id.clone(), @@ -785,6 +816,10 @@ mod tests { ) .await .expect("auto-backgrounded blocking spawn returns Ok"); + assert!( + wait_closed.load(std::sync::atomic::Ordering::Relaxed), + "auto-backgrounding must close the foreground wait window" + ); match result { ToolOutput::Text(text) => { @@ -982,7 +1017,7 @@ mod tests { let drain = tokio::spawn(async move { if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await { - let _ = boxed.result_tx.send(SubagentResult { + let _ = boxed.respond_with(|boxed| SubagentResult { success: true, output: std::sync::Arc::from(""), subagent_id: boxed.id.clone(), @@ -1023,7 +1058,7 @@ mod tests { let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); let drain = tokio::spawn(async move { if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await { - let _ = boxed.result_tx.send(SubagentResult { + let _ = boxed.respond_with(|boxed| SubagentResult { success: false, error: Some("worktree creation failed".to_string()), subagent_id: boxed.id.clone(), @@ -1502,8 +1537,7 @@ mod tests { "model-spawned task must not set fork_context" ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -1585,8 +1619,7 @@ mod tests { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "resumed".into(), subagent_id: request.id.clone(), @@ -1652,8 +1685,7 @@ mod tests { request.resume_from ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "fresh".into(), subagent_id: request.id.clone(), @@ -1781,8 +1813,7 @@ mod tests { request.cwd ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -1832,8 +1863,7 @@ mod tests { request.cwd ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -1883,8 +1913,7 @@ mod tests { request.cwd ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -1937,8 +1966,7 @@ mod tests { request.cwd ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -2027,8 +2055,7 @@ mod tests { request.cwd ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -2081,8 +2108,7 @@ mod tests { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.cwd.as_deref(), Some("/tmp")); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "done".into(), subagent_id: request.id.clone(), @@ -2139,8 +2165,7 @@ mod tests { "stray leading quote should be stripped before reaching the backend", ); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -2192,8 +2217,7 @@ mod tests { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.cwd.as_deref(), Some("/tmp")); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), @@ -2241,8 +2265,7 @@ mod tests { assert_eq!(request.cwd.as_deref(), Some("/tmp/some-dir")); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); request - .result_tx - .send(SubagentResult { + .respond_with(|request| SubagentResult { success: true, output: "resumed".into(), subagent_id: request.id.clone(), @@ -2301,13 +2324,14 @@ mod tests { ); assert!(request.runtime_overrides.reasoning_effort.is_none()); assert!(request.runtime_overrides.persona.is_none()); + let id = request.id.clone(); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), - subagent_id: request.id.clone(), - child_session_id: request.id.clone(), + subagent_id: id.clone(), + child_session_id: id, ..Default::default() }) .unwrap(); @@ -2338,13 +2362,14 @@ mod tests { "omitted model must stay None, got {:?}", request.runtime_overrides.model ); + let id = request.id.clone(); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), - subagent_id: request.id.clone(), - child_session_id: request.id.clone(), + subagent_id: id.clone(), + child_session_id: id, ..Default::default() }) .unwrap(); @@ -2387,13 +2412,14 @@ mod tests { "sentinel {sentinel:?} must normalize to None, got {:?}", request.runtime_overrides.model ); + let id = request.id.clone(); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), - subagent_id: request.id.clone(), - child_session_id: request.id.clone(), + subagent_id: id.clone(), + child_session_id: id, ..Default::default() }) .unwrap(); @@ -2427,13 +2453,14 @@ mod tests { Some("test-model"), "leading/trailing whitespace should be trimmed" ); + let id = request.id.clone(); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), - subagent_id: request.id.clone(), - child_session_id: request.id.clone(), + subagent_id: id.clone(), + child_session_id: id, ..Default::default() }) .unwrap(); @@ -2467,13 +2494,14 @@ mod tests { ); assert!(request.runtime_overrides.reasoning_effort.is_none()); assert!(request.runtime_overrides.persona.is_none()); + let id = request.id.clone(); request .result_tx .send(SubagentResult { success: true, output: "resumed".into(), - subagent_id: request.id.clone(), - child_session_id: request.id.clone(), + subagent_id: id.clone(), + child_session_id: id, ..Default::default() }) .unwrap(); @@ -2502,13 +2530,14 @@ mod tests { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); assert!(request.runtime_overrides.model.is_none()); + let id = request.id.clone(); request .result_tx .send(SubagentResult { success: true, output: "resumed".into(), - subagent_id: request.id.clone(), - child_session_id: request.id.clone(), + subagent_id: id.clone(), + child_session_id: id, ..Default::default() }) .unwrap(); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs index f1bd541..eccb2b4 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs @@ -1,7 +1,8 @@ -//! Channel types for subagent communication (TaskTool ↔ MvpAgent coordinator). +//! Data and channel types for subagent coordination. //! -//! These types define the request/response protocol between the `TaskTool` -//! (in `xai-grok-tools`) and the subagent coordinator (in `xai-grok-shell`). +//! Request data is deliberately separate from command reply envelopes. The +//! shared coordinator actor owns every reply sender and every lifecycle +//! transition; child runners receive only plain request data. //! //! ## Resource types //! @@ -23,6 +24,8 @@ use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; use xai_tool_types::{SubagentCapabilityMode, SubagentIsolationMode, WaitMode}; +use crate::register_resource; + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum SubagentOwner { #[default] @@ -51,13 +54,10 @@ impl SubagentOwner { } } -use crate::register_resource; - // Request / Response -/// Request emitted by TaskTool, received by MvpAgent coordinator. -#[derive(Educe)] -#[educe(Debug)] +/// Plain spawn request emitted by `TaskTool`. +#[derive(Debug, Clone)] pub struct SubagentRequest { /// Subagent ID (UUID v7). Same as `TaskToolInput.task_id`; becomes the child session ID. pub id: String, @@ -75,15 +75,17 @@ pub struct SubagentRequest { /// freshly rendered. pub resume_from: Option<String>, /// Explicit working directory for the child session. - /// Validated at spawn time in `handle_subagent_request()`. + /// Validated at spawn time by the injected child runner. pub cwd: Option<String>, /// Runtime overrides for the child agent. pub runtime_overrides: SubagentRuntimeOverrides, /// Whether this subagent was launched with `run_in_background: true`. /// - /// Background subagents survive parent-turn cancellation — they are - /// excluded from `cancel_by_parent_prompt_id` so the user can poll - /// results later via `get_task_output`. + /// Controls immediate handle delivery and completion surfacing. A + /// background child still auto-surfaces its completion to the model + /// (buffered reminder / auto-wake) when `surface_completion` is set — + /// background does not mean fire-and-forget. Prompt cancellation still + /// cancels every child owned by that prompt. pub run_in_background: bool, /// When false, the subagent's completion is NOT buffered for the /// between-turn "idle completion" reminder — used by harness-internal @@ -95,11 +97,39 @@ pub struct SubagentRequest { pub fork_context: bool, pub owner: SubagentOwner, pub cancel_token: CancellationToken, - /// Oneshot channel for the coordinator to send back the result. +} + +/// Spawn command envelope owned by the coordinator mailbox. +#[derive(Educe)] +#[educe(Debug)] +pub struct SubagentSpawnRequest { + pub request: Box<SubagentRequest>, #[educe(Debug(ignore))] pub result_tx: oneshot::Sender<SubagentResult>, } +impl std::ops::Deref for SubagentSpawnRequest { + type Target = SubagentRequest; + + fn deref(&self) -> &Self::Target { + &self.request + } +} + +impl SubagentSpawnRequest { + /// Build and send a reply while the plain request remains borrowable. + /// + /// Primarily useful for channel adapters and deterministic test harnesses; + /// production lifecycle replies are owned by `SubagentCoordinator`. + pub fn respond_with( + self, + build: impl FnOnce(&SubagentRequest) -> SubagentResult, + ) -> Result<(), SubagentResult> { + let result = build(&self.request); + self.result_tx.send(result) + } +} + /// Per-spawn dynamic runtime overrides for a subagent. /// /// Optional values inherit from the parent or role default. Explicit values take @@ -410,12 +440,14 @@ impl SubagentResult { // Query protocol -/// Query sent by TaskOutputTool, received by MvpAgent coordinator. +/// Query sent by `TaskOutputTool` to the shared coordinator actor. #[derive(Educe)] #[educe(Debug)] pub struct SubagentQueryRequest { /// The subagent ID to look up. pub subagent_id: String, + /// Restrict the lookup to children owned by this parent session. + pub parent_session_id: Option<String>, /// If true, coordinator waits for completion (up to timeout) before responding. pub block: bool, /// Max wait time in ms when blocking. Default 30s. @@ -449,6 +481,27 @@ pub struct SubagentSnapshot { pub persona: Option<String>, } +/// Lifecycle metadata returned to shell presentation and extension callers. +#[derive(Debug, Clone)] +pub struct SubagentInspection { + pub snapshot: SubagentSnapshot, + pub parent_session_id: String, + pub child_session_id: String, + pub fork_parent_prompt_id: Option<String>, + pub resumed_from: Option<String>, +} + +impl SubagentSnapshot { + /// Whether the child is still in flight (initializing or running) — the + /// shared liveness rule every driver's blocking query loops on. + pub fn is_running(&self) -> bool { + matches!( + self.status, + SubagentSnapshotStatus::Running { .. } | SubagentSnapshotStatus::Initializing + ) + } +} + /// Status of a subagent snapshot. #[derive(Debug, Clone)] pub enum SubagentSnapshotStatus { @@ -506,11 +559,11 @@ pub enum SubagentCancelTarget { WorkflowRunId(String), } -/// Cancel request sent by KillTaskTool or session cancellation paths, -/// received by MvpAgent coordinator. +/// Cancel request sent by `KillTaskTool` or session cancellation paths. #[derive(Educe)] #[educe(Debug)] pub struct SubagentCancelRequest { + pub parent_session_id: Option<String>, pub target: SubagentCancelTarget, #[educe(Debug(ignore))] pub respond_to: oneshot::Sender<SubagentCancelOutcome>, @@ -524,10 +577,11 @@ pub enum SubagentCancelOutcome { } /// Summary of a completed subagent, used for between-turn delivery. +/// Session ownership lives on the coordinator's `BufferedCompletion` wrapper; +/// drains are scoped there, so delivered summaries carry no owner field. #[derive(Debug, Clone)] pub struct SubagentCompletionSummary { pub subagent_id: String, - pub owner_session_id: String, pub subagent_type: String, pub description: String, pub success: bool, @@ -560,7 +614,7 @@ pub struct SubagentMultiWaitRequest { #[derive(Educe)] #[educe(Debug)] pub struct SubagentCompletionsRequest { - pub session_id: String, + pub parent_session_id: Option<String>, pub suppress_ids: Vec<String>, #[educe(Debug(ignore))] pub respond_to: oneshot::Sender<Vec<SubagentCompletionSummary>>, @@ -580,6 +634,7 @@ pub struct SubagentOutstandingReply { #[derive(Educe)] #[educe(Debug)] pub struct SubagentOutstandingRequest { + pub parent_session_id: String, pub prompt_id: String, #[educe(Debug(ignore))] pub respond_to: oneshot::Sender<SubagentOutstandingReply>, @@ -588,6 +643,7 @@ pub struct SubagentOutstandingRequest { /// Clear sticky incomplete after freeze/cancel has snapshotted the bill. #[derive(Debug)] pub struct SubagentClearUsageNotAppliedRequest { + pub parent_session_id: String, pub prompt_id: String, } @@ -595,11 +651,94 @@ pub struct SubagentClearUsageNotAppliedRequest { #[derive(Educe)] #[educe(Debug)] pub struct SubagentMarkUsageNotAppliedRequest { + pub parent_session_id: String, pub prompt_id: String, #[educe(Debug(ignore))] pub respond_to: oneshot::Sender<()>, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SubagentRegistryCounts { + pub pending: usize, + pub active: usize, + pub completed: usize, +} + +#[derive(Educe)] +#[educe(Debug)] +pub struct SubagentRegistryCountsRequest { + #[educe(Debug(ignore))] + pub respond_to: oneshot::Sender<SubagentRegistryCounts>, +} + +/// Request for full metadata plus a resolved progress snapshot. +#[derive(Educe)] +#[educe(Debug)] +pub struct SubagentInspectRequest { + pub subagent_id: String, + pub parent_session_id: Option<String>, + #[educe(Debug(ignore))] + pub respond_to: oneshot::Sender<Option<SubagentInspection>>, +} + +/// Request for all running children owned by one parent session. +#[derive(Educe)] +#[educe(Debug)] +pub struct SubagentListRunningRequest { + pub parent_session_id: String, + #[educe(Debug(ignore))] + pub respond_to: oneshot::Sender<Vec<SubagentInspection>>, +} + +/// Fork/resume provenance retained by the shared coordinator. +#[derive(Debug, Clone, Default)] +pub struct SubagentProvenance { + pub fork_parent_prompt_id: Option<String>, + pub resumed_from: Option<String>, +} + +/// Reference to a child spawned during one parent prompt. +#[derive(Debug, Clone)] +pub struct SpawnedSubagentRef { + pub subagent_id: String, + pub child_session_id: String, + pub subagent_type: String, + pub description: String, + pub persona: Option<String>, + pub resumed_from: Option<String>, +} + +/// Request for prompt-scoped spawned-child references. +#[derive(Educe)] +#[educe(Debug)] +pub struct SubagentSpawnedRefsRequest { + pub parent_session_id: String, + pub prompt_id: String, + #[educe(Debug(ignore))] + pub respond_to: oneshot::Sender<Vec<SpawnedSubagentRef>>, +} + +/// In-memory source data used by a runtime adapter to resume a child. +#[derive(Debug, Clone)] +pub struct SubagentResumeSource { + pub subagent_id: String, + pub child_session_id: String, + pub child_cwd: String, + pub worktree_path: Option<String>, + pub snapshot_ref: Option<String>, + pub subagent_type: String, + pub persona: Option<String>, + pub model_id: Option<String>, +} + +/// Result of a resume-source lookup. +#[derive(Debug, Clone)] +pub enum SubagentResumeLookup { + Active, + Completed(SubagentResumeSource), + Missing, +} + // Validate-type protocol #[derive(Debug, Clone)] @@ -700,18 +839,25 @@ pub struct SubagentDescribeRequest { pub respond_to: oneshot::Sender<SubagentDescribeOutcome>, } -/// Coordinator message enum. Intentionally NOT `#[non_exhaustive]` — -/// the cross-crate drain loop in `xai-grok-shell` relies on -/// compile-time exhaustiveness. +/// Coordinator message enum. Kept exhaustive so every actor command is handled. pub enum SubagentEvent { - Spawn(Box<SubagentRequest>), + Spawn(SubagentSpawnRequest), Query(SubagentQueryRequest), Cancel(SubagentCancelRequest), ListActive(SubagentListActiveRequest), + ListRunning(SubagentListRunningRequest), Completions(SubagentCompletionsRequest), + /// Fire-and-forget: drop buffered completions owned by a removed session + /// so unloaded sessions cannot leak entries into the shared buffer. + DiscardSessionCompletions { + parent_session_id: String, + }, Outstanding(SubagentOutstandingRequest), ClearUsageNotApplied(SubagentClearUsageNotAppliedRequest), MarkUsageNotApplied(SubagentMarkUsageNotAppliedRequest), + RegistryCounts(SubagentRegistryCountsRequest), + Inspect(SubagentInspectRequest), + SpawnedRefs(SubagentSpawnedRefsRequest), ValidateType(SubagentValidateTypeRequest), DescribeType(SubagentDescribeRequest), LoopUnitActive(SubagentLoopUnitActiveRequest), @@ -780,10 +926,8 @@ pub fn drain_owned( /// Lightweight summary of a running subagent. /// -/// This is the single shared definition of this type. The coordinator in -/// xai-grok-shell produces it, the channel protocol carries it, and the -/// compaction pipeline in xai-chat-state (via `RunningSubagentSummary`) -/// consumes it. Do not duplicate this type in other crates. +/// The shared coordinator produces this through the channel protocol, and the +/// compaction pipeline consumes it through `RunningSubagentSummary`. #[derive(Debug, Clone)] pub struct ActiveSubagentSummary { /// The subagent's unique ID (same ID used by `get_task_output` / `kill_task`). @@ -799,8 +943,7 @@ pub struct ActiveSubagentSummary { /// Request to list currently-running subagents for a specific parent session. /// /// Sent by the compaction pipeline in `SessionActor::run_compact_inner()`. -/// Handled by `MvpAgent::start_subagent_coordinator()` which borrows the -/// coordinator and calls `active_summaries_for()`. +/// Handled by the shared coordinator actor. #[derive(Educe)] #[educe(Debug)] pub struct SubagentListActiveRequest { @@ -853,6 +996,39 @@ pub struct SessionIdResource(pub String); register_resource!("grok_build", "SessionIdResource", SessionIdResource); +/// Host-owned RAII token for an interruptible foreground wait. +pub trait ForegroundWaitGuard: Send {} + +impl<T: Send> ForegroundWaitGuard for T {} + +type ForegroundWaitFactory = dyn Fn() -> Box<dyn ForegroundWaitGuard> + Send + Sync; + +/// Factory injected by hosts that expose a send-now wait window. +#[derive(Clone)] +pub struct SubagentForegroundWait(Arc<ForegroundWaitFactory>); + +impl SubagentForegroundWait { + pub fn new(factory: impl Fn() -> Box<dyn ForegroundWaitGuard> + Send + Sync + 'static) -> Self { + Self(Arc::new(factory)) + } + + pub fn enter(&self) -> Box<dyn ForegroundWaitGuard> { + (self.0)() + } +} + +impl std::fmt::Debug for SubagentForegroundWait { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubagentForegroundWait").finish() + } +} + +register_resource!( + "grok_build", + "SubagentForegroundWait", + SubagentForegroundWait +); + /// Carries the current parent prompt/turn ID for TaskTool subagent scoping. /// /// Set by xai-grok-shell immediately before a prompt turn begins executing so @@ -1282,19 +1458,18 @@ mod tests { let (respond_to, mut response_rx) = oneshot::channel(); tx.send(super::SubagentCompletionsRequest { - session_id: "session-1".into(), + parent_session_id: Some("parent".into()), suppress_ids: vec!["id-1".into(), "id-2".into()], respond_to, }) .unwrap(); let req = rx.try_recv().unwrap(); - assert_eq!(req.session_id, "session-1"); + assert_eq!(req.parent_session_id.as_deref(), Some("parent")); assert_eq!(req.suppress_ids, vec!["id-1", "id-2"]); let summaries = vec![super::SubagentCompletionSummary { subagent_id: "sub-1".into(), - owner_session_id: "session-1".into(), subagent_type: "general-purpose".into(), description: "test task".into(), success: true, @@ -1373,7 +1548,7 @@ mod tests { .0 .send(super::SubagentEvent::Completions( super::SubagentCompletionsRequest { - session_id: String::new(), + parent_session_id: None, suppress_ids: vec![], respond_to, }, @@ -1405,7 +1580,7 @@ mod tests { .0 .send(super::SubagentEvent::Completions( super::SubagentCompletionsRequest { - session_id: String::new(), + parent_session_id: None, suppress_ids: vec![], respond_to, }, diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs index a237039..6f9b25a 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs @@ -916,6 +916,7 @@ pub(crate) mod test_helpers { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs index c0897d9..b5bc171 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs @@ -147,7 +147,7 @@ Content output format: Usage: - ${{ params.search.pattern }} is a regex: `log.*Error`, `function\s+\w+`, `TODO` -- Output modes: "content" (default, with anchors), "files_with_matches", "count" +- Default output is anchored content matches (no output-mode selector) - Use -A, -B, -C for context lines around matches - Only use '${{ params.search.type }}' or '${{ params.search.glob }}' when certain of the file type - Results are capped; truncated results show "at least" counts"#; diff --git a/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs index 0d077c1..995148c 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs @@ -394,7 +394,9 @@ impl xai_tool_runtime::Tool for BashTool { auto_background_on_timeout: false, // OpenCode doesn't support auto-backgrounding foreground_block_budget: None, kind: crate::computer::types::TaskKind::Bash, - owner_session_id: None, // OpenCode doesn't use shared terminal backends + // OpenCode doesn't use shared terminal backends. + owner_session_id: None, + description: None, }; let result = match backend.run(request).await { diff --git a/crates/codegen/xai-grok-tools/src/implementations/task_output/tool.rs b/crates/codegen/xai-grok-tools/src/implementations/task_output/tool.rs index 2c6ac2a..58e6668 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/task_output/tool.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/task_output/tool.rs @@ -107,6 +107,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } diff --git a/crates/codegen/xai-grok-tools/src/notification/handle.rs b/crates/codegen/xai-grok-tools/src/notification/handle.rs index 0739772..3cd7ea2 100644 --- a/crates/codegen/xai-grok-tools/src/notification/handle.rs +++ b/crates/codegen/xai-grok-tools/src/notification/handle.rs @@ -1,11 +1,13 @@ +use std::collections::VecDeque; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use super::types::{ BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout, BashOutputChunk, FileWritten, LspServerCrashed, LspServerFailed, LspServerReady, LspServerRetrying, LspServerStarting, MonitorEvent, PlanModeEntered, PlanModeExited, - ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved, ToolNotification, - UserQuestionAsked, + ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved, SubagentCompleted, + ToolNotification, UserQuestionAsked, }; use crate::types::TaskSnapshot; @@ -90,9 +92,87 @@ impl NotificationAcknowledgementBatch { #[derive(Clone)] enum ToolNotificationTarget { Plain(tokio::sync::mpsc::UnboundedSender<ToolNotification>), + Bounded(tokio::sync::mpsc::Sender<ToolNotification>), + Capped(Arc<CappedNotificationQueue>), Acknowledged(tokio::sync::mpsc::UnboundedSender<AcknowledgedToolNotification>), } +struct CappedNotificationQueue { + queue: parking_lot::Mutex<VecDeque<ToolNotification>>, + capacity: usize, + closed: AtomicBool, + ready: tokio::sync::Notify, +} + +impl CappedNotificationQueue { + fn push(&self, notification: ToolNotification) { + if self.closed.load(Ordering::Relaxed) { + return; + } + let mut queue = self.queue.lock(); + if queue.len() >= self.capacity { + if !is_critical_notification(¬ification) { + tracing::warn!("tool notification queue full; dropping newest lossy event"); + return; + } + let evict = queue + .iter() + .position(|queued| !is_critical_notification(queued)) + .unwrap_or(0); + queue.remove(evict); + tracing::warn!("tool notification queue full; evicting older event for terminal event"); + } + queue.push_back(notification); + drop(queue); + self.ready.notify_one(); + } +} + +fn is_critical_notification(notification: &ToolNotification) -> bool { + matches!( + notification, + ToolNotification::BashExecutionComplete(_) + | ToolNotification::BashExecutionTimeout(_) + | ToolNotification::BashExecutionFailed(_) + | ToolNotification::TaskCompleted(_) + | ToolNotification::SubagentCompleted(_) + | ToolNotification::PlanModeEntered(_) + | ToolNotification::PlanModeExited(_) + | ToolNotification::UserQuestionAsked(_) + | ToolNotification::LspServerCrashed(_) + | ToolNotification::LspServerFailed(_) + | ToolNotification::ScheduledTaskFired(_) + | ToolNotification::ScheduledTaskRemoved(_) + ) +} + +/// Receiver for a capped queue that preserves terminal notifications. +pub struct CappedToolNotificationReceiver { + queue: Arc<CappedNotificationQueue>, +} + +impl CappedToolNotificationReceiver { + pub async fn recv(&mut self) -> Option<ToolNotification> { + loop { + let ready = self.queue.ready.notified(); + if let Some(notification) = self.queue.queue.lock().pop_front() { + return Some(notification); + } + if self.queue.closed.load(Ordering::Relaxed) { + return None; + } + ready.await; + } + } +} + +impl Drop for CappedToolNotificationReceiver { + fn drop(&mut self) { + self.queue.closed.store(true, Ordering::Relaxed); + self.queue.ready.notify_waiters(); + } +} + /// Cloneable notification fan-out with per-target FIFO ordering. #[derive(Clone)] pub struct ToolNotificationHandle { @@ -127,6 +207,35 @@ impl ToolNotificationHandle { (Self::new(sender), receiver) } + /// Create a capped target that drops the newest event when full. + pub fn bounded_channel( + capacity: usize, + ) -> (Self, tokio::sync::mpsc::Receiver<ToolNotification>) { + let (sender, receiver) = tokio::sync::mpsc::channel(capacity); + ( + Self { + targets: Arc::from([ToolNotificationTarget::Bounded(sender)]), + }, + receiver, + ) + } + + /// Create a capped queue that evicts lossy events before terminal events. + pub fn capped_channel(capacity: usize) -> (Self, CappedToolNotificationReceiver) { + let queue = Arc::new(CappedNotificationQueue { + queue: parking_lot::Mutex::new(VecDeque::new()), + capacity: capacity.max(1), + closed: AtomicBool::new(false), + ready: tokio::sync::Notify::new(), + }); + ( + Self { + targets: Arc::from([ToolNotificationTarget::Capped(Arc::clone(&queue))]), + }, + CappedToolNotificationReceiver { queue }, + ) + } + pub fn acknowledged_channel() -> ( Self, tokio::sync::mpsc::UnboundedReceiver<AcknowledgedToolNotification>, @@ -185,6 +294,12 @@ impl ToolNotificationHandle { ToolNotificationTarget::Plain(target) => { let _ = target.send(notification); } + ToolNotificationTarget::Bounded(target) => { + if target.try_send(notification).is_err() { + tracing::warn!("tool notification queue full; dropping newest event"); + } + } + ToolNotificationTarget::Capped(target) => target.push(notification), ToolNotificationTarget::Acknowledged(target) => { let _ = target.send(AcknowledgedToolNotification { notification, @@ -210,6 +325,12 @@ impl ToolNotificationHandle { ToolNotificationTarget::Plain(target) => { let _ = target.send(notification.clone()); } + ToolNotificationTarget::Bounded(target) => { + if target.try_send(notification.clone()).is_err() { + tracing::warn!("tool notification queue full; dropping newest event"); + } + } + ToolNotificationTarget::Capped(target) => target.push(notification.clone()), ToolNotificationTarget::Acknowledged(target) => { let (acknowledgement, receipt) = tokio::sync::oneshot::channel(); if target @@ -237,6 +358,7 @@ impl ToolNotificationHandle { send_failed, BashExecutionFailed, BashExecutionFailed; send_file_written, FileWritten, FileWritten; send_task_complete, TaskSnapshot, TaskCompleted; + send_subagent_completed, SubagentCompleted, SubagentCompleted; send_plan_mode_entered, PlanModeEntered, PlanModeEntered; send_plan_mode_exited, PlanModeExited, PlanModeExited; send_user_question_asked, UserQuestionAsked, UserQuestionAsked; diff --git a/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs b/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs index fe9f50e..27db566 100644 --- a/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs +++ b/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs @@ -106,3 +106,22 @@ async fn batch_distinguishes_dropped_and_rejected_acknowledgements() { }) ); } + +#[tokio::test] +async fn bounded_channel_drops_newest_when_full() { + let (handle, mut receiver) = ToolNotificationHandle::bounded_channel(1); + handle.send_scheduled_task_created(created("kept")); + handle.send_scheduled_task_created(created("dropped")); + + assert_eq!(task_id(&receiver.recv().await.unwrap()), "kept"); + assert!(receiver.try_recv().is_err()); +} + +#[tokio::test] +async fn capped_channel_evicts_lossy_event_for_terminal_event() { + let (handle, mut receiver) = ToolNotificationHandle::capped_channel(1); + handle.send_scheduled_task_created(created("lossy")); + handle.send(ToolNotification::ScheduledTaskRemoved(removed("terminal"))); + + assert_eq!(task_id(&receiver.recv().await.unwrap()), "terminal"); +} diff --git a/crates/codegen/xai-grok-tools/src/notification/mod.rs b/crates/codegen/xai-grok-tools/src/notification/mod.rs index 4c6ad0c..0450b62 100644 --- a/crates/codegen/xai-grok-tools/src/notification/mod.rs +++ b/crates/codegen/xai-grok-tools/src/notification/mod.rs @@ -2,6 +2,7 @@ pub mod handle; pub mod types; pub use handle::AcknowledgedToolNotification; +pub use handle::CappedToolNotificationReceiver; pub use handle::DurableNotificationTargets; pub use handle::NotificationAcknowledgementBatch; pub use handle::NotificationAcknowledgementError; diff --git a/crates/codegen/xai-grok-tools/src/notification/types.rs b/crates/codegen/xai-grok-tools/src/notification/types.rs index aad9daf..78615c9 100644 --- a/crates/codegen/xai-grok-tools/src/notification/types.rs +++ b/crates/codegen/xai-grok-tools/src/notification/types.rs @@ -368,6 +368,21 @@ pub struct MonitorEvent { pub owner_session_id: Option<String>, } +/// A background subagent reached a terminal state while the parent held a handle. +#[derive(Debug, Clone, PartialEq, Eq, schemars::JsonSchema)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SubagentCompleted { + pub subagent_id: String, + pub subagent_type: String, + pub description: String, + pub status: String, + #[cfg_attr(feature = "serde", serde(default))] + pub error: Option<String>, + pub duration_ms: u64, + #[cfg_attr(feature = "serde", serde(default))] + pub owner_session_id: Option<String>, +} + /// A notification emitted by a tool during or after execution. /// These are sent to external consumers (TUI, logging, etc.) to provide /// real-time visibility into tool execution. @@ -398,6 +413,9 @@ pub enum ToolNotification { /// about the task being finished status TaskCompleted(TaskSnapshot), + /// A background subagent reached a terminal state. + SubagentCompleted(SubagentCompleted), + /// The agent requested to enter plan mode. /// Consumers (gateway, TUI) use this to transition the client into /// plan-mode UI state (e.g., enforce read-only, inject plan-mode @@ -480,6 +498,7 @@ notification_variants! { BashExecutionFailed => BashExecutionFailed, FileWritten => FileWritten, TaskCompleted => TaskSnapshot, + SubagentCompleted => SubagentCompleted, PlanModeEntered => PlanModeEntered, PlanModeExited => PlanModeExited, UserQuestionAsked => UserQuestionAsked, diff --git a/crates/codegen/xai-grok-tools/src/registry/types.rs b/crates/codegen/xai-grok-tools/src/registry/types.rs index bb4b636..c58e15c 100644 --- a/crates/codegen/xai-grok-tools/src/registry/types.rs +++ b/crates/codegen/xai-grok-tools/src/registry/types.rs @@ -210,6 +210,15 @@ pub struct ToolServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub behavior_preset: Option<String>, } +#[derive(Clone)] +pub struct SubagentSessionResources { + pub backend: crate::implementations::grok_build::task::backend::SubagentBackendResource, + /// Same channel as [`Self::backend`]; required so `TaskCompletionReminder` + /// can drain completions onto the next tool result (shell parity). + pub event_sender: crate::implementations::grok_build::task::types::SubagentEventSender, + pub depth: crate::implementations::grok_build::task::types::SubagentDepthCounter, + pub session_id: crate::implementations::grok_build::task::types::SessionIdResource, +} /// Everything a session provides at finalization time. /// /// This is the **public API boundary** — callers pass concrete, strongly-typed @@ -231,6 +240,8 @@ pub struct SessionContext { /// Session ID that owns processes spawned by this session's tools. /// Used to scope kill operations on a shared terminal backend. pub owner_session_id: Option<String>, + /// Complete subagent capability for this session. + pub subagent: Option<SubagentSessionResources>, /// Parent's scheduler handle. When `Some`, the session reuses the parent's /// scheduler actor instead of spawning its own, so scheduled tasks survive /// subagent exit. @@ -975,6 +986,12 @@ impl ToolRegistryBuilder { if let Some(owner_session_id) = ctx.owner_session_id { resources.insert(crate::types::resources::OwnerSessionId(owner_session_id)); } + if let Some(subagent) = ctx.subagent { + resources.insert(subagent.backend); + resources.insert(subagent.event_sender); + resources.insert(subagent.depth); + resources.insert(subagent.session_id); + } let scheduler_notification_handle = ctx.notification_handle.clone(); resources.insert(crate::types::resources::NotificationHandle( ctx.notification_handle, @@ -1303,6 +1320,23 @@ impl FinalizedToolset { .map(|t| (t.client_name.clone(), t.metadata.kind().as_key().to_owned())) .collect() } + /// Map of client-facing tool name → typed [`ToolKind`]. + /// + /// Unlike the finalize-request `ToolConfig`s (whose `kind` is `None` when + /// built from raw IDs over gRPC), the finalized tools always know their + /// real kind from the registry metadata — use this for kind-derived + /// metadata in server responses (e.g. capability-mode classification). + pub fn tool_kind_map(&self) -> HashMap<String, ToolKind> { + self.tools + .read() + .iter() + .map(|t| (t.client_name.clone(), t.metadata.kind())) + .collect() + } + /// Finalized canonical-to-client parameter names by tool kind. + pub fn template_param_names(&self) -> HashMap<ToolKind, HashMap<String, String>> { + self.renderer.param_names() + } pub async fn update_resource<T: Send + Sync + 'static>(&self, resource: T) { self.resources.lock().await.insert(resource); } @@ -1420,6 +1454,9 @@ impl FinalizedToolset { let mut ctx = xai_tool_runtime::ToolCallContext::new(parent_ctx.call_id.clone()); ctx.extensions.insert(self.resources.clone()); ctx.extensions.insert_arc(Arc::clone(&self.renderer)); + if let Some(cancellation) = parent_ctx.get::<xai_tool_runtime::Cancellation>() { + ctx.extensions.insert((*cancellation).clone()); + } ctx.extensions.insert( crate::types::resources::InvokingToolParamNames::from_reverse_params(&reverse_params), ); @@ -1451,9 +1488,27 @@ impl FinalizedToolset { tool_args: serde_json::Value, tool_call_id: &str, cwd_override: Option<std::path::PathBuf>, + ) -> Result<ToolRunResult, xai_tool_runtime::ToolError> { + self.call_with_cancellation(tool_name, tool_args, tool_call_id, cwd_override, None) + .await + } + /// Dispatch with cooperative cancellation exposed to the tool. + pub async fn call_with_cancellation( + self: &Arc<Self>, + tool_name: &str, + tool_args: serde_json::Value, + tool_call_id: &str, + cwd_override: Option<std::path::PathBuf>, + cancellation: Option<tokio_util::sync::CancellationToken>, ) -> Result<ToolRunResult, xai_tool_runtime::ToolError> { use futures::StreamExt; - let mut stream = self.call_streaming(tool_name, tool_args, tool_call_id, cwd_override); + let mut stream = self.call_streaming_with_cancellation( + tool_name, + tool_args, + tool_call_id, + cwd_override, + cancellation, + ); while let Some(item) = stream.next().await { match item { xai_tool_runtime::ToolStreamItem::Progress(_) => continue, @@ -1482,6 +1537,23 @@ impl FinalizedToolset { tool_args: serde_json::Value, tool_call_id: &str, cwd_override: Option<std::path::PathBuf>, + ) -> xai_tool_runtime::ToolStream<ToolRunResult> { + self.call_streaming_with_cancellation( + tool_name, + tool_args, + tool_call_id, + cwd_override, + None, + ) + } + /// Streaming dispatch with cooperative cancellation exposed to the tool. + pub fn call_streaming_with_cancellation( + self: &Arc<Self>, + tool_name: &str, + tool_args: serde_json::Value, + tool_call_id: &str, + cwd_override: Option<std::path::PathBuf>, + cancellation: Option<tokio_util::sync::CancellationToken>, ) -> xai_tool_runtime::ToolStream<ToolRunResult> { use futures::StreamExt; let this = Arc::clone(self); @@ -1493,6 +1565,7 @@ impl FinalizedToolset { tool_args, &tool_call_id, cwd_override, + cancellation, ) { Ok(parts) => parts, Err(e) => { @@ -1542,6 +1615,7 @@ impl FinalizedToolset { tool_args: serde_json::Value, tool_call_id: &str, cwd_override: Option<std::path::PathBuf>, + cancellation: Option<tokio_util::sync::CancellationToken>, ) -> Result<DispatchParts, xai_tool_runtime::ToolError> { let (registry_id, output_converter, reverse_params) = { let tools = self.tools.read(); @@ -1581,6 +1655,10 @@ impl FinalizedToolset { if let Some(cwd) = cwd_override { ctx.extensions.insert(xai_tool_runtime::Cwd(cwd)); } + if let Some(cancellation) = cancellation { + ctx.extensions + .insert(xai_tool_runtime::Cancellation(cancellation)); + } if let Some(ref version) = contract_version { ctx.extensions .insert(xai_tool_runtime::BehaviorVersion(version.clone())); @@ -1807,6 +1885,10 @@ pub fn generate_schema<T: schemars::JsonSchema>() -> serde_json::Value { let generator = settings.into_generator(); let schema = generator.into_root_schema_for::<T>(); let mut value = serde_json::to_value(&schema).unwrap_or_default(); + if let Some(obj) = value.as_object_mut() { + obj.remove("title"); + obj.remove("description"); + } if let Some(obj) = value.as_object_mut() && obj.get("type").and_then(|v| v.as_str()) == Some("object") { @@ -2036,6 +2118,7 @@ mod tests { session_env: Arc::new(HashMap::new()), notification_handle: crate::notification::ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: tmp.path().join("state.json"), @@ -4415,6 +4498,29 @@ mod tests { assert_eq!(skills.0.len(), 2, "should have exactly 2 skills"); } } + /// generate_schema strips the boilerplate root `title` (struct name) and + /// root `description` (struct doc "Input for the <canonical> tool") so the + /// canonical name can't leak via parameters.description after randomization + /// renames the tool. $schema and per-property descriptions are retained. + #[test] + fn generate_schema_strips_root_title_and_description() { + let schema = generate_schema::<crate::implementations::grok_build::bash::BashToolInput>(); + assert!( + schema.get("title").is_none(), + "root title (struct name) must be stripped: {schema}" + ); + assert!( + schema.get("description").is_none(), + "root description (leaks canonical tool name) must be stripped: {schema}" + ); + assert!(schema.get("$schema").is_some(), "$schema must be retained"); + assert!( + schema["properties"] + .as_object() + .is_some_and(|p| !p.is_empty()), + "per-property schema must be retained: {schema}" + ); + } fn toolset_with_viewer_ctx( viewer_ctx: Option<xai_tool_runtime::WorkspaceViewerContext>, ) -> (Arc<FinalizedToolset>, TempDir) { @@ -4455,6 +4561,7 @@ mod tests { serde_json::json!({"target_file": "noop"}), "test-call", None, + None, ) .expect("prepare_dispatch succeeds"); let wvc = parts @@ -4473,6 +4580,7 @@ mod tests { serde_json::json!({"target_file": "noop"}), "test-call", None, + None, ) .expect("prepare_dispatch succeeds"); assert!( diff --git a/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs b/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs index 19155f8..a8b777b 100644 --- a/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs +++ b/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs @@ -643,14 +643,13 @@ impl Reminder for TaskCompletionReminder { .chain(&reserved_ids) .cloned() .collect::<Vec<_>>(); - let (terminal, event_sender, session_id) = { + let (terminal, event_sender, parent_session_id) = { let res = resources.lock().await; ( res.get::<Terminal>().map(|t| t.0.clone()), res.get::<SubagentEventSender>().cloned(), - res.get::<crate::implementations::grok_build::task::types::SessionIdResource>() - .map(|s| s.0.clone()) - .unwrap_or_default(), + res.get::<crate::types::resources::OwnerSessionId>() + .map(|owner| owner.0.clone()), ) }; let mut reminders = Vec::new(); @@ -733,7 +732,7 @@ impl Reminder for TaskCompletionReminder { if sender .0 .send(SubagentEvent::Completions(SubagentCompletionsRequest { - session_id, + parent_session_id, suppress_ids, respond_to: tx, })) @@ -802,6 +801,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None); assert!(msg.contains("abc-123")); @@ -828,6 +828,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_monitor_completion(&task, Some("get_command_or_subagent_output")); assert!( @@ -860,6 +861,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_monitor_completion(&task, None); assert!( @@ -887,6 +889,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None); assert!(msg.contains("cargo test")); @@ -911,6 +914,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None); assert!(msg.contains("exit code: unknown")); @@ -938,6 +942,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None); assert!( @@ -976,6 +981,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None); assert!( @@ -1013,6 +1019,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }; let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None); assert!(msg.contains("exit code: 0")); @@ -1173,6 +1180,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } fn make_running(id: &str) -> TaskSnapshot { @@ -1193,6 +1201,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, } } fn make_bg_started(id: &str) -> crate::types::output::BackgroundTaskStarted { @@ -1438,7 +1447,6 @@ mod tests { fn make_subagent_completion(id: &str, success: bool) -> SubagentCompletionSummary { SubagentCompletionSummary { subagent_id: id.into(), - owner_session_id: String::new(), subagent_type: "general-purpose".into(), description: "test task".into(), success, diff --git a/crates/codegen/xai-grok-tools/src/types/template_renderer.rs b/crates/codegen/xai-grok-tools/src/types/template_renderer.rs index 6af779e..85a6814 100644 --- a/crates/codegen/xai-grok-tools/src/types/template_renderer.rs +++ b/crates/codegen/xai-grok-tools/src/types/template_renderer.rs @@ -175,6 +175,19 @@ impl TemplateRenderer { render_with_env(template, &self.ctx) } + /// Return the finalized canonical-to-client parameter names by tool kind. + pub fn param_names(&self) -> HashMap<ToolKind, HashMap<String, String>> { + self.ctx + .params + .iter() + .filter_map(|(kind, names)| { + serde_json::from_value(serde_json::Value::String(kind.clone())) + .ok() + .map(|kind| (kind, names.clone())) + }) + .collect() + } + /// Render `${{ ... }}` placeholders in every `description` string within a /// JSON Schema, in place — recursing into nested objects, array `items`, and /// `$defs`. Property keys are remapped separately; this resolves diff --git a/crates/codegen/xai-grok-tools/src/util/mod.rs b/crates/codegen/xai-grok-tools/src/util/mod.rs index 523b15b..db77980 100644 --- a/crates/codegen/xai-grok-tools/src/util/mod.rs +++ b/crates/codegen/xai-grok-tools/src/util/mod.rs @@ -13,6 +13,7 @@ pub mod path_suggestions; pub(crate) mod query_tools; pub mod remap; pub mod serde_base64; +pub mod shell_env_policy; pub mod spawn; pub mod truncate; pub mod unicode_confusables; @@ -26,6 +27,10 @@ pub use fs::{UnicodePathMatch, canonicalize_with_timeout, try_resolve_unicode_fi pub use grok_home::{grok_application, grok_home}; pub use path_suggestions::format_not_found_error; pub use remap::{remap_json_keys, remap_schema_properties, reverse_map}; +pub use shell_env_policy::{ + EnvironmentVariablePattern, ShellEnvironmentPolicy, ShellEnvironmentPolicyInherit, + apply_shell_environment_policy, +}; pub use spawn::{ ProcessGroup, ProcessScope, detach_command, global_process_scope, new_process_group, }; diff --git a/crates/codegen/xai-grok-tools/src/util/shell_env_policy.rs b/crates/codegen/xai-grok-tools/src/util/shell_env_policy.rs new file mode 100644 index 0000000..700745c --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/util/shell_env_policy.rs @@ -0,0 +1,237 @@ +//! Controls which environment variables agent subprocesses (bash tool, +//! terminals) inherit. Default is a no-op (inherit everything); enforced at the +//! shell spawn sites on macOS, Linux, and Windows. + +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::LazyLock; +use wildmatch::WildMatchPattern; + +/// Case-insensitive environment-variable-name glob (`*`, `?`). +pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>; + +fn deserialize_patterns<'de, D>( + deserializer: D, +) -> Result<Vec<EnvironmentVariablePattern>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let globs = Vec::<String>::deserialize(deserializer)?; + Ok(globs + .iter() + .map(|s| EnvironmentVariablePattern::new_case_insensitive(s)) + .collect()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ShellEnvironmentPolicyInherit { + /// Core platform variables only (PATH, HOME, SHELL, ...). + Core, + #[default] + All, + None, +} + +/// How to build the environment for agent subprocesses. Applied in order: start +/// from `inherit`; if `ignore_default_excludes` is false, drop the secret +/// patterns `*KEY*`/`*SECRET*`/`*TOKEN*`; drop `exclude`; insert `set`; if +/// `include_only` is non-empty, keep only those. Patterns are case-insensitive +/// globs (`*`, `?`). +#[derive(Debug, Clone, PartialEq, serde::Deserialize)] +#[serde(default)] +pub struct ShellEnvironmentPolicy { + pub inherit: ShellEnvironmentPolicyInherit, + /// Skip the built-in secret excludes (default `true`). + pub ignore_default_excludes: bool, + #[serde(deserialize_with = "deserialize_patterns")] + pub exclude: Vec<EnvironmentVariablePattern>, + /// Values inserted into the base environment before `include_only` filtering + /// (an unmatched name is then dropped). These seed the base; request env + /// layered at spawn can still override them. + pub set: HashMap<String, String>, + #[serde(deserialize_with = "deserialize_patterns")] + pub include_only: Vec<EnvironmentVariablePattern>, +} + +impl Default for ShellEnvironmentPolicy { + fn default() -> Self { + Self { + inherit: ShellEnvironmentPolicyInherit::All, + ignore_default_excludes: true, + exclude: Vec::new(), + set: HashMap::new(), + include_only: Vec::new(), + } + } +} + +impl ShellEnvironmentPolicy { + /// True when the policy leaves the inherited environment untouched. + pub fn is_noop(&self) -> bool { + self.inherit == ShellEnvironmentPolicyInherit::All + && self.ignore_default_excludes + && self.exclude.is_empty() + && self.set.is_empty() + && self.include_only.is_empty() + } + + /// True if `name` matches a built-in secret exclude and those are enabled. + fn matches_default_exclude(&self, name: &str) -> bool { + !self.ignore_default_excludes && DEFAULT_SECRET_EXCLUDES.iter().any(|p| p.matches(name)) + } + + fn matches_exclude(&self, name: &str) -> bool { + self.exclude.iter().any(|p| p.matches(name)) + } + + /// True if `include_only` is empty (all admitted) or `name` matches it. + fn matches_include_only(&self, name: &str) -> bool { + self.include_only.is_empty() || self.include_only.iter().any(|p| p.matches(name)) + } + + /// Whether `name` survives the name filters (default excludes, `exclude`, + /// `include_only`), ignoring `inherit`/`set`. Used to filter variables layered + /// in after the policy base, e.g. login-shell capture. Shares its matchers + /// with [`create_env_from_vars`] so the two cannot drift. + pub fn allows(&self, name: &str) -> bool { + !self.matches_default_exclude(name) + && !self.matches_exclude(name) + && self.matches_include_only(name) + } + + /// Like [`allows`](Self::allows) but also honors `inherit`: `none` admits + /// nothing, `core` admits only core names, `all` defers to `allows`. + pub fn allows_with_inherit(&self, name: &str) -> bool { + match self.inherit { + ShellEnvironmentPolicyInherit::None => return false, + ShellEnvironmentPolicyInherit::Core => { + if !CORE_ENV_VARS + .iter() + .any(|core| core.eq_ignore_ascii_case(name)) + { + return false; + } + } + ShellEnvironmentPolicyInherit::All => {} + } + self.allows(name) + } +} + +/// Built-in secret excludes applied when `ignore_default_excludes` is false. +/// Shared by the base-env build and the login-capture filter so they can't drift. +static DEFAULT_SECRET_EXCLUDES: LazyLock<[EnvironmentVariablePattern; 3]> = LazyLock::new(|| { + [ + EnvironmentVariablePattern::new_case_insensitive("*KEY*"), + EnvironmentVariablePattern::new_case_insensitive("*SECRET*"), + EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"), + ] +}); + +/// "Core" variables retained under [`ShellEnvironmentPolicyInherit::Core`]. +#[cfg(not(target_os = "windows"))] +const CORE_ENV_VARS: &[&str] = &[ + "PATH", "SHELL", "TMPDIR", "TEMP", "TMP", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", + "USER", +]; +#[cfg(target_os = "windows")] +const CORE_ENV_VARS: &[&str] = &[ + "PATH", + "PATHEXT", + "SHELL", + "COMSPEC", + "SYSTEMROOT", + "SYSTEMDRIVE", + "USERNAME", + "USERDOMAIN", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "PROGRAMDATA", + "LOCALAPPDATA", + "APPDATA", + "TEMP", + "TMP", + "TMPDIR", + "POWERSHELL", + "PWSH", +]; + +/// Build the child environment from `policy` and the process env. Uses `vars_os` +/// and skips non-UTF-8 entries so a hostile variable cannot panic at spawn time. +pub(crate) fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> { + let vars = std::env::vars_os() + .filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))); + create_env_from_vars(vars, policy) +} + +pub(crate) fn create_env_from_vars<I>( + vars: I, + policy: &ShellEnvironmentPolicy, +) -> HashMap<String, String> +where + I: IntoIterator<Item = (String, String)>, +{ + let mut env: HashMap<String, String> = match policy.inherit { + ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(), + ShellEnvironmentPolicyInherit::None => HashMap::new(), + ShellEnvironmentPolicyInherit::Core => vars + .into_iter() + .filter(|(k, _)| { + CORE_ENV_VARS + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(k)) + }) + .collect(), + }; + + // Order matters: default excludes, then `exclude`, then `set`, then + // `include_only`. `set` lands before `include_only` so an unmatched set name + // is still dropped. The matchers are shared with `allows`. + env.retain(|k, _| !policy.matches_default_exclude(k)); + env.retain(|k, _| !policy.matches_exclude(k)); + for (k, v) in &policy.set { + env.insert(k.clone(), v.clone()); + } + env.retain(|k, _| policy.matches_include_only(k)); + + // Windows resolves executables via PATHEXT; keep it present even under a + // restrictive policy so commands stay runnable. + if cfg!(target_os = "windows") && !env.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) { + env.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string()); + } + + env +} + +/// Clear the command's inherited env and install the policy-derived base env. +/// `active` must already be noop-filtered; `None` leaves the command untouched. +/// The one base-env code path, shared by the public entry point and the spawn +/// sites. +pub(crate) fn install_policy_base_env( + cmd: &mut tokio::process::Command, + active: Option<&ShellEnvironmentPolicy>, +) { + if let Some(policy) = active { + cmd.env_clear(); + cmd.envs(create_env(policy)); + } +} + +/// Install the policy-derived base env on `cmd` (clearing inherited env first); +/// a `None` or no-op policy leaves it untouched. Call before any other +/// `.env`/`.envs`. +pub fn apply_shell_environment_policy( + cmd: &mut tokio::process::Command, + policy: Option<&ShellEnvironmentPolicy>, +) { + install_policy_base_env(cmd, policy.filter(|p| !p.is_noop())); +} + +#[cfg(test)] +#[path = "shell_env_policy_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-tools/src/util/shell_env_policy_tests.rs b/crates/codegen/xai-grok-tools/src/util/shell_env_policy_tests.rs new file mode 100644 index 0000000..384d9c7 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/util/shell_env_policy_tests.rs @@ -0,0 +1,166 @@ +use super::{ + EnvironmentVariablePattern, ShellEnvironmentPolicy, ShellEnvironmentPolicyInherit, + apply_shell_environment_policy, create_env_from_vars, +}; +use std::collections::HashMap; + +fn vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn patterns(globs: &[&str]) -> Vec<EnvironmentVariablePattern> { + globs + .iter() + .map(|g| EnvironmentVariablePattern::new_case_insensitive(g)) + .collect() +} + +#[test] +fn apply_policy_reshapes_command_env() { + let mut set = HashMap::new(); + set.insert("MY_FLAG".to_string(), "1".to_string()); + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + set, + ..Default::default() + }; + let mut cmd = tokio::process::Command::new("true"); + apply_shell_environment_policy(&mut cmd, Some(&policy)); + let envs: HashMap<String, String> = cmd + .as_std() + .get_envs() + .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) + .collect(); + assert_eq!(envs.get("MY_FLAG").map(String::as_str), Some("1")); + // inherit=None cleared the env, so no inherited PATH leaks through. + assert!(!envs.contains_key("PATH")); +} + +#[test] +fn apply_noop_or_absent_policy_leaves_command_untouched() { + let mut cmd = tokio::process::Command::new("true"); + apply_shell_environment_policy(&mut cmd, None); + apply_shell_environment_policy(&mut cmd, Some(&ShellEnvironmentPolicy::default())); + // No env_clear and no sets: the command carries no explicit env entries. + assert_eq!(cmd.as_std().get_envs().count(), 0); +} + +#[test] +fn default_excludes_drop_secrets_when_enabled() { + let policy = ShellEnvironmentPolicy { + ignore_default_excludes: false, + ..Default::default() + }; + assert!(!policy.is_noop()); + let env = create_env_from_vars( + vars(&[ + ("PATH", "/bin"), + ("MY_API_KEY", "x"), + ("MY_SECRET", "y"), + ("GH_TOKEN", "z"), + ]), + &policy, + ); + assert_eq!(env.get("PATH").map(String::as_str), Some("/bin")); + assert!(!env.contains_key("MY_API_KEY")); + assert!(!env.contains_key("MY_SECRET")); + assert!(!env.contains_key("GH_TOKEN")); +} + +#[test] +fn inherit_none_starts_empty_then_set_applies() { + let mut set = HashMap::new(); + set.insert("PATH".to_string(), "/usr/bin".to_string()); + set.insert("MY_FLAG".to_string(), "1".to_string()); + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + set, + ..Default::default() + }; + let env = create_env_from_vars(vars(&[("PATH", "/bin"), ("HOME", "/root")]), &policy); + assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); + assert_eq!(env.get("MY_FLAG").map(String::as_str), Some("1")); + assert!(!env.contains_key("HOME")); +} + +#[test] +fn inherit_core_keeps_only_core_vars() { + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::Core, + ..Default::default() + }; + let env = create_env_from_vars(vars(&[("PATH", "/bin"), ("RANDOM_VAR", "v")]), &policy); + assert_eq!(env.get("PATH").map(String::as_str), Some("/bin")); + assert!(!env.contains_key("RANDOM_VAR")); +} + +#[test] +fn exclude_and_include_only_filter() { + let policy = ShellEnvironmentPolicy { + exclude: patterns(&["AWS_*"]), + include_only: patterns(&["PATH", "HOME"]), + ..Default::default() + }; + let env = create_env_from_vars( + vars(&[ + ("PATH", "/bin"), + ("HOME", "/root"), + ("AWS_SECRET", "s"), + ("OTHER", "o"), + ]), + &policy, + ); + assert_eq!(env.get("PATH").map(String::as_str), Some("/bin")); + assert_eq!(env.get("HOME").map(String::as_str), Some("/root")); + assert!(!env.contains_key("AWS_SECRET")); + assert!(!env.contains_key("OTHER")); +} + +#[test] +fn allows_filters_by_name_case_insensitively() { + let policy = ShellEnvironmentPolicy { + exclude: patterns(&["aws_*"]), // lowercase pattern, uppercase var + include_only: patterns(&["PATH", "HOME"]), + ..Default::default() + }; + assert!(policy.allows("PATH")); + assert!(!policy.allows("AWS_SECRET")); // excluded (case-insensitive) + assert!(!policy.allows("OTHER")); // not in include_only + + let scrub = ShellEnvironmentPolicy { + ignore_default_excludes: false, + ..Default::default() + }; + assert!(!scrub.allows("my_api_key")); // `*KEY*` matches case-insensitively + assert!(ShellEnvironmentPolicy::default().allows("MY_API_KEY")); // default allows all +} + +#[test] +fn allows_with_inherit_honors_inherit() { + // inherit = none admits nothing. + let none = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + ..Default::default() + }; + assert!(!none.allows_with_inherit("PATH")); + assert!(!none.allows_with_inherit("FOO")); + + // inherit = core admits only core names. + let core = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::Core, + ..Default::default() + }; + assert!(core.allows_with_inherit("PATH")); + assert!(!core.allows_with_inherit("RANDOM_VAR")); + + // inherit = all defers to `allows` (exclude still applies). + let all = ShellEnvironmentPolicy { + exclude: patterns(&["AWS_*"]), + ..Default::default() + }; + assert!(all.allows_with_inherit("RANDOM_VAR")); + assert!(!all.allows_with_inherit("AWS_SECRET")); +} diff --git a/crates/codegen/xai-grok-tools/tests/cgroup_memory_test.rs b/crates/codegen/xai-grok-tools/tests/cgroup_memory_test.rs index 0cbd88d..70826b6 100644 --- a/crates/codegen/xai-grok-tools/tests/cgroup_memory_test.rs +++ b/crates/codegen/xai-grok-tools/tests/cgroup_memory_test.rs @@ -66,6 +66,7 @@ fn make_request(command: &str, timeout_secs: u64) -> TerminalRunRequest { foreground_block_budget: None, kind: Default::default(), owner_session_id: None, + description: None, } } diff --git a/crates/codegen/xai-grok-update/src/auto_update.rs b/crates/codegen/xai-grok-update/src/auto_update.rs index 415fae3..388fa7c 100644 --- a/crates/codegen/xai-grok-update/src/auto_update.rs +++ b/crates/codegen/xai-grok-update/src/auto_update.rs @@ -119,43 +119,56 @@ pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus { }; match get_latest_version(inst, update_config).await { - Ok(latest_version) => { - let mut error = None; - // --check reports upgrades only; a rolled-back pointer isn't a "new version" to advertise here (auto-update converges separately). - let allow_downgrade = false; - let update_available = - match needs_update(¤t_version, &latest_version, &channel, allow_downgrade) { + // --check shares the updater's decision, so it never advertises a version + // the policy would skip, clamp away, or can't satisfy. + Ok(latest) => match plan_for(&config::VersionPolicy::resolve(), latest) { + UpdatePlan::Install { target, .. } => { + let mut error = None; + let update_available = match needs_update( + ¤t_version, + &target, + &channel, + false, + ) { Some(value) => value, None => { - // Distinguish parse failure from unsupported channel for clearer diagnostics. + // Distinguish parse failure from unsupported channel. let parse_ok = semver::Version::parse(¤t_version).is_ok() - && semver::Version::parse(&latest_version).is_ok(); + && semver::Version::parse(&target).is_ok(); error = Some(if parse_ok { format!( - "Unsupported release channel '{}' (current={}, latest={}). \ - Supported channels: stable, alpha, enterprise.", - channel, current_version, latest_version + "Unsupported release channel '{channel}' (current={current_version}, latest={target}). \ + Supported channels: stable, alpha, enterprise." ) } else { format!( - "Failed to parse versions (current={}, latest={})", - current_version, latest_version + "Failed to parse versions (current={current_version}, latest={target})" ) }); false } }; - - UpdateStatus { + UpdateStatus { + current_version, + latest_version: Some(target), + update_available, + installer, + channel, + auto_update, + error, + } + } + // Policy skips (anti-downgrade) or can't satisfy the floor: no upgrade. + UpdatePlan::Skip { latest } | UpdatePlan::Unavailable { latest, .. } => UpdateStatus { current_version, - latest_version: Some(latest_version), - update_available, + latest_version: Some(latest), + update_available: false, installer, channel, auto_update, - error, - } - } + error: None, + }, + }, Err(err) => UpdateStatus { current_version, latest_version: None, @@ -168,6 +181,49 @@ pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus { } } +enum UpdatePlan { + /// Anti-downgrade skip; `latest` is reported to the user. + Skip { + latest: String, + }, + /// A hard `required_minimum` exceeds the latest release, so nothing satisfies it. + Unavailable { + latest: String, + target: String, + }, + Install { + latest: String, + target: String, + }, +} + +/// Classify a fetched `latest` release under `policy`. Pure; `fetch_update_plan` +/// is the IO wrapper. `--check` shares this so it can't diverge from the updater. +fn plan_for(policy: &config::VersionPolicy, latest: String) -> UpdatePlan { + let Some(target) = policy.resolve_target(&latest) else { + return UpdatePlan::Skip { latest }; + }; + // A hard `required_minimum` can clamp above the latest release; that version + // doesn't exist. + if matches!( + (semver::Version::parse(&target), semver::Version::parse(&latest)), + (Ok(t), Ok(l)) if t > l + ) { + UpdatePlan::Unavailable { latest, target } + } else { + UpdatePlan::Install { latest, target } + } +} + +async fn fetch_update_plan( + installer: &str, + update_config: &UpdateConfig, + policy: &config::VersionPolicy, +) -> Result<UpdatePlan> { + let latest = fetch_latest_version(installer, update_config).await?; + Ok(plan_for(policy, latest)) +} + /// Installer + version the leader/background path should converge to: an /// upgrade OR an authoritative-installer rollback. `None` means stay put. Gates /// on the installer (via `installer_allows_downgrade`) so npm is never @@ -175,15 +231,21 @@ pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus { pub async fn auto_update_target(update_config: &UpdateConfig) -> Option<(&'static str, String)> { let installer = get_installer().await?; let current = get_installed_grok_version(); - let latest = fetch_latest_version(installer, update_config).await.ok()?; + let policy = config::VersionPolicy::resolve(); + let UpdatePlan::Install { target, .. } = fetch_update_plan(installer, update_config, &policy) + .await + .ok()? + else { + return None; + }; needs_update( ¤t, - &latest, + &target, &update_config.channel, installer_allows_downgrade(installer), ) .unwrap_or(false) - .then_some((installer, latest)) + .then_some((installer, target)) } /// Outcome of [`ensure_latest_on_disk`]. @@ -224,20 +286,25 @@ pub async fn ensure_latest_on_disk(update_config: &UpdateConfig) -> Result<Ensur }; heal_managed_install(installer).await; let allow_downgrade = installer_allows_downgrade(installer); - let latest = fetch_latest_version(installer, update_config).await?; + let policy = config::VersionPolicy::resolve(); + let UpdatePlan::Install { target, .. } = + fetch_update_plan(installer, update_config, &policy).await? + else { + return Ok(outcome); + }; let effective_current = disk_version_for_installer(installer).unwrap_or_else(get_installed_grok_version); if needs_update( &effective_current, - &latest, + &target, &update_config.channel, allow_downgrade, ) .unwrap_or(false) { - run_install_script(installer, Some(&latest), update_config).await?; - outcome.installed = Some(latest.clone()); + run_install_script(installer, Some(&target), update_config).await?; + outcome.installed = Some(target.clone()); } // Relaunch when the running binary differs from what's on disk in the @@ -403,22 +470,25 @@ pub async fn check_update_background(update_config: &UpdateConfig) -> Background } let current_version = get_installed_grok_version(); - let latest_version = match fetch_latest_version(installer, update_config).await { - Ok(v) => v, - Err(_) => return BackgroundUpdateCheck::none(), + let policy = config::VersionPolicy::resolve(); + let target_version = match fetch_update_plan(installer, update_config, &policy).await { + Ok(UpdatePlan::Install { target, .. }) => target, + Ok(UpdatePlan::Skip { .. } | UpdatePlan::Unavailable { .. }) | Err(_) => { + return BackgroundUpdateCheck::none(); + } }; let allow_downgrade = installer_allows_downgrade(installer); if !needs_update( ¤t_version, - &latest_version, + &target_version, &update_config.channel, allow_downgrade, ) .unwrap_or(false) { let stable_ptr = try_fetch_stable_pointer().await; - write_version_cache(&latest_version, stable_ptr.as_deref()).await; + write_version_cache(&target_version, stable_ptr.as_deref()).await; return BackgroundUpdateCheck::none(); } @@ -431,7 +501,7 @@ pub async fn check_update_background(update_config: &UpdateConfig) -> Background let disk_needs_download = match disk_version_for_installer(installer) { Some(disk) => needs_update( &disk, - &latest_version, + &target_version, &update_config.channel, allow_downgrade, ) @@ -451,14 +521,16 @@ pub async fn check_update_background(update_config: &UpdateConfig) -> Background } } else { tracing::info!( - latest_version = %latest_version, + target_version = %target_version, "Background update: target already on disk, skipping download" ); None }; BackgroundUpdateCheck { - update: Some(UpdateAvailable { latest_version }), + update: Some(UpdateAvailable { + latest_version: target_version, + }), download, } } @@ -502,12 +574,13 @@ pub async fn run_update_if_available( } let current_version = get_installed_grok_version(); - // Fetch without writing version.json — we only cache after confirming the - // update is not needed or after a successful blocking install. This prevents - // a failed background download from suppressing retries for the TTL window. - let latest_version = match fetch_latest_version(inst, update_config).await { - Ok(v) => v, - Err(_) => return Ok(false), + let policy = config::VersionPolicy::resolve(); + // Don't write version.json here; only cache after confirming no update is + // needed or after a successful install, so a failed background download + // doesn't suppress retries for the TTL window. + let latest_version = match fetch_update_plan(inst, update_config, &policy).await { + Ok(UpdatePlan::Install { target, .. }) => target, + Ok(UpdatePlan::Skip { .. } | UpdatePlan::Unavailable { .. }) | Err(_) => return Ok(false), }; if !needs_update( ¤t_version, @@ -2283,10 +2356,11 @@ pub async fn run_update( heal_managed_install(installer).await; let current_version = get_installed_grok_version(); + let policy = config::VersionPolicy::resolve(); // When --version is given, skip the latest-version check and install directly if let Some(version) = pinned_version { - if let Err(e) = crate::minimum_version::check_install_target(version) { + if let Err(e) = crate::version_policy::check_install_target(&policy, version) { anyhow::bail!("{e}"); } eprintln!( @@ -2315,18 +2389,33 @@ pub async fn run_update( .unwrap(), ); pb.enable_steady_tick(Duration::from_millis(100)); - let latest_version = fetch_latest_version(installer, update_config).await?; + let plan = fetch_update_plan(installer, update_config, &policy).await?; pb.finish_and_clear(); - let install_target = match crate::minimum_version::apply_floor(&latest_version) { - Ok(t) => t, - Err(e) => anyhow::bail!("{e}"), + let (latest_version, install_target) = match plan { + UpdatePlan::Skip { latest } => { + // Cache so an explicit `grok update` doesn't re-prompt every run. + let stable_ptr = try_fetch_stable_pointer().await; + write_version_cache(&latest, stable_ptr.as_deref()).await; + eprintln!( + "The latest release ({latest}) is not an allowed update; \ + keeping the current version ({current_version})." + ); + refresh_deployment_config().await; + return Ok(None); + } + UpdatePlan::Unavailable { latest, target } => { + anyhow::bail!( + "The required minimum version ({target}) is newer than the latest \ + available release ({latest}). Contact your administrator." + ); + } + UpdatePlan::Install { latest, target } => (latest, target), }; if install_target != latest_version { eprintln!( - "Latest available is {} but the configured minimum is higher; \ - installing {} instead.", - latest_version, install_target + "Latest available is {latest_version}, but your configured version range \ + allows {install_target}; installing that instead." ); } diff --git a/crates/codegen/xai-grok-update/src/lib.rs b/crates/codegen/xai-grok-update/src/lib.rs index 4d77be8..c8a9758 100644 --- a/crates/codegen/xai-grok-update/src/lib.rs +++ b/crates/codegen/xai-grok-update/src/lib.rs @@ -1,7 +1,7 @@ pub mod auto_update; -mod minimum_version; pub mod version; +mod version_policy; pub use auto_update::UpdateStatus; -pub use minimum_version::enforce_minimum_version_or_exit; pub use version::{UpdateConfig, channel_label, channel_name, write_version_cache}; +pub use version_policy::enforce_version_policy_or_exit; diff --git a/crates/codegen/xai-grok-update/src/minimum_version.rs b/crates/codegen/xai-grok-update/src/minimum_version.rs deleted file mode 100644 index fa9403f..0000000 --- a/crates/codegen/xai-grok-update/src/minimum_version.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Minimum-version enforcement. -//! -//! When `cli.minimum_version` is set in any config layer, Grok refuses to -//! start below that floor. With auto-update on, we install -//! `max(latest, minimum)`; otherwise the user is asked to run `grok update`. -//! -//! Set `GROK_TEST_VERSION` to manually exercise either path without producing -//! a real out-of-date build. - -use crate::auto_update::{get_installer, run_install_script}; -use crate::version::{ - UpdateConfig, fetch_latest_version, get_installed_grok_version, write_version_cache, -}; -use tracing::{info, warn}; -use xai_grok_shell::util::config; - -/// Result of comparing the running binary against a configured floor. -#[derive(Debug, Clone, PartialEq, Eq)] -enum MinimumVersionDecision { - Allow, - BelowMinimum { current: String, minimum: String }, -} - -/// Outcome of a successful enforcement pass. -#[derive(Debug, Clone, PartialEq, Eq)] -enum EnforcementOutcome { - Allowed, - /// New binary on disk; caller MUST restart — running process is still old. - Upgraded, -} - -/// User-facing enforcement failures; `Display` is printed to stderr. -/// `AutoUpdateDisabled` and `NoInstaller` share copy but stay separate so -/// telemetry can distinguish them. -#[derive(Debug, thiserror::Error)] -pub(crate) enum MinimumVersionError { - /// `source` chains via `Error::source()`; omitted from `Display`. - #[error( - "The minimum version \"{value}\" in your Grok configuration \ - isn't a valid version number. Update `cli.minimum_version` and try again." - )] - InvalidMinimum { - value: String, - #[source] - source: semver::Error, - }, - #[error( - "This version of Grok ({current}) is no longer supported. \ - Run `grok update` to install version {minimum} or later." - )] - AutoUpdateDisabled { current: String, minimum: String }, - /// `npm` / `gh` / `internal` GCS — none detected. - #[error( - "This version of Grok ({current}) is no longer supported. \ - Run `grok update` to install version {minimum} or later." - )] - NoInstaller { current: String, minimum: String }, - /// `detail` is telemetry-only; omitted from `Display` to avoid stacking - /// the installer's own action language. - #[error( - "This version of Grok ({current}) is no longer supported, \ - and the update to version {minimum} didn't complete.\n\n\ - Run `grok update` to try again." - )] - UpgradeFailed { - current: String, - minimum: String, - detail: String, - }, - /// Latest release is known but still below the floor (vs `NoReleaseFound`, - /// which couldn't probe at all). - #[error( - "This version of Grok ({current}) is no longer supported. \ - Version {minimum} or later is required, but the most recent release is {latest}. \ - Contact your administrator." - )] - NoSatisfyingVersion { - current: String, - minimum: String, - latest: String, - }, - /// Couldn't probe the registry — likely transient. - #[error( - "This version of Grok ({current}) is no longer supported. \ - Version {minimum} or later is required, but no release was found. \ - Check your network connection, or contact your administrator." - )] - NoReleaseFound { current: String, minimum: String }, - /// `grok update --version X` requested a version below the floor. - #[error( - "Cannot install Grok {target}: the configured minimum is {minimum}. \ - Run `grok update` to install the latest allowed version." - )] - TargetBelowFloor { target: String, minimum: String }, -} - -/// Pure check against the configured floor. Empty / whitespace-only -/// minimums are treated as unset. -fn evaluate_minimum_version( - current_version: &str, - minimum_version: Option<&str>, -) -> Result<MinimumVersionDecision, MinimumVersionError> { - let Some(minimum) = minimum_version.map(str::trim).filter(|s| !s.is_empty()) else { - return Ok(MinimumVersionDecision::Allow); - }; - - let parsed_min = - semver::Version::parse(minimum).map_err(|source| MinimumVersionError::InvalidMinimum { - value: minimum.to_string(), - source, - })?; - - // Unparseable current (e.g. funky dev build): block rather than let an - // unverifiable binary through. - let parsed_cur = match semver::Version::parse(current_version) { - Ok(v) => v, - Err(_) => { - return Ok(MinimumVersionDecision::BelowMinimum { - current: current_version.to_string(), - minimum: parsed_min.to_string(), - }); - } - }; - - if parsed_cur >= parsed_min { - Ok(MinimumVersionDecision::Allow) - } else { - Ok(MinimumVersionDecision::BelowMinimum { - current: parsed_cur.to_string(), - minimum: parsed_min.to_string(), - }) - } -} - -/// Refuse an explicit install target below the configured floor. -/// Used by `grok update --version X`. -pub(crate) fn check_install_target(target: &str) -> Result<(), MinimumVersionError> { - let floor = resolve_floor_or_error()?; - check_install_target_inner(target, floor.as_deref()) -} - -fn check_install_target_inner( - target: &str, - floor: Option<&str>, -) -> Result<(), MinimumVersionError> { - let Some(min) = floor else { return Ok(()) }; - match evaluate_minimum_version(target, Some(min))? { - MinimumVersionDecision::Allow => Ok(()), - MinimumVersionDecision::BelowMinimum { - current: target, - minimum, - } => Err(MinimumVersionError::TargetBelowFloor { target, minimum }), - } -} - -/// `max(target, configured_floor)`; passthrough when no floor is set. -/// Used by `grok update` to keep the install target at or above the pin. -pub(crate) fn apply_floor(target: &str) -> Result<String, MinimumVersionError> { - let floor = resolve_floor_or_error()?; - apply_floor_inner(target, floor.as_deref()) -} - -/// Adapts `config::resolve_minimum_version`'s error shape into ours. -fn resolve_floor_or_error() -> Result<Option<String>, MinimumVersionError> { - config::resolve_minimum_version() - .map_err(|(value, source)| MinimumVersionError::InvalidMinimum { value, source }) -} - -fn apply_floor_inner(target: &str, floor: Option<&str>) -> Result<String, MinimumVersionError> { - let Some(min) = floor else { - return Ok(target.to_string()); - }; - match evaluate_minimum_version(target, Some(min))? { - MinimumVersionDecision::Allow => Ok(target.to_string()), - MinimumVersionDecision::BelowMinimum { minimum, .. } => Ok(minimum), - } -} - -/// `max(latest, minimum)`; falls back to `minimum` if `latest` is missing or unparseable. -fn pick_target_version(latest: Option<&str>, minimum: &str) -> String { - match latest.and_then(|v| semver::Version::parse(v).ok()) { - Some(latest_v) => match semver::Version::parse(minimum) { - Ok(min_v) if latest_v >= min_v => latest_v.to_string(), - _ => minimum.to_string(), - }, - None => minimum.to_string(), - } -} - -/// Call once at startup, before any user-facing UI. On `Ok(Upgraded)` the -/// caller MUST restart. On `Err`, print and exit non-zero. -async fn enforce_minimum_version( - minimum_version: Option<&str>, - update_config: &UpdateConfig, -) -> Result<EnforcementOutcome, MinimumVersionError> { - let current_version = get_installed_grok_version(); - let decision = evaluate_minimum_version(¤t_version, minimum_version)?; - let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else { - info!(current = %current_version, "minimum_version: floor satisfied"); - return Ok(EnforcementOutcome::Allowed); - }; - - info!(%current, %minimum, "minimum_version: below floor; attempting auto-update"); - - // `None` is "default on"; only explicit `false` opts out. - let cfg = config::load_config().await; - if cfg.cli.auto_update == Some(false) { - warn!(%current, %minimum, "minimum_version: auto-update disabled by config"); - return Err(MinimumVersionError::AutoUpdateDisabled { current, minimum }); - } - - let Some(installer) = get_installer().await else { - warn!(%current, %minimum, "minimum_version: no installer detected"); - return Err(MinimumVersionError::NoInstaller { current, minimum }); - }; - - let latest = fetch_latest_version(installer, update_config).await.ok(); - let target = pick_target_version(latest.as_deref(), &minimum); - - info!(%current, %target, installer, "minimum_version: installing upgrade"); - eprintln!( - "This version of Grok ({current}) is no longer supported. \ - Updating to {target}…" - ); - - if let Err(e) = run_install_script(installer, Some(&target), update_config).await { - let detail = format!("{e:#}"); - warn!(%current, %target, %detail, "minimum_version: upgrade failed"); - return Err(MinimumVersionError::UpgradeFailed { - current, - minimum, - detail, - }); - } - - // Post-install: pass None for stable_version (same rationale as run_update). - write_version_cache(&target, None).await; - - // Stale channel pointer or partial install can leave us below the floor; - // surface that rather than starting an out-of-policy binary. - if let MinimumVersionDecision::BelowMinimum { .. } = - evaluate_minimum_version(&target, Some(&minimum))? - { - warn!(%target, %minimum, ?latest, "minimum_version: post-install still below floor"); - return Err(match latest { - Some(latest) => MinimumVersionError::NoSatisfyingVersion { - current: target, - minimum, - latest, - }, - None => MinimumVersionError::NoReleaseFound { - current: target, - minimum, - }, - }); - } - - info!(%target, "minimum_version: upgrade installed successfully"); - Ok(EnforcementOutcome::Upgraded) -} - -/// Single chokepoint for the pager + tui startup paths. Re-execs after a -/// floor-driven install. Prints + exits non-zero on `Err`. -/// -/// `GROK_TEST_VERSION` lets devs override the running version to skip -/// enforcement on a `cargo run` build. -pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) { - let min = match resolve_floor_or_error() { - Ok(None) => return, - Ok(Some(m)) => m, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - match enforce_minimum_version(Some(&min), update_config).await { - Ok(EnforcementOutcome::Allowed) => {} - Ok(EnforcementOutcome::Upgraded) => { - // TODO: restart_grok uses exec() which carries the same - // SIGABRT risk as the old piped-stderr update path if the - // child process ever writes to a broken pipe. For now this - // path is rare (only fires when the server pushes a minimum - // version bump), so print a relaunch message instead. - eprintln!("Update installed. Run `grok` to start."); - std::process::exit(0); - } - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn evaluate_minimum_version_decisions() { - use MinimumVersionDecision::{Allow, BelowMinimum}; - - // Allow: floor unset (None / empty / whitespace) or satisfied (equal / above). - assert_eq!(evaluate_minimum_version("0.1.100", None).unwrap(), Allow); - assert_eq!( - evaluate_minimum_version("0.1.100", Some("")).unwrap(), - Allow - ); - assert_eq!( - evaluate_minimum_version("0.1.100", Some(" ")).unwrap(), - Allow - ); - assert_eq!( - evaluate_minimum_version("0.1.100", Some("0.1.100")).unwrap(), - Allow - ); - assert_eq!( - evaluate_minimum_version("0.2.0", Some("0.1.100")).unwrap(), - Allow - ); - - // BelowMinimum: current < floor. - assert!(matches!( - evaluate_minimum_version("0.1.99", Some("0.1.100")).unwrap(), - BelowMinimum { .. } - )); - - // InvalidMinimum: unparseable floor (admin typo). - assert!(matches!( - evaluate_minimum_version("0.1.100", Some("not-a-version")), - Err(MinimumVersionError::InvalidMinimum { .. }) - )); - } - - #[test] - fn pick_target_returns_max_of_latest_and_minimum() { - // The `None` branch is only reachable here — apply_floor always - // passes `Some(target)`. Production hits it on fetch failure. - assert_eq!(pick_target_version(Some("0.1.200"), "0.1.150"), "0.1.200"); - assert_eq!(pick_target_version(Some("0.1.140"), "0.1.150"), "0.1.150"); - assert_eq!(pick_target_version(None, "0.1.150"), "0.1.150"); - } - - #[test] - fn install_target_helpers_consult_floor() { - // check_install_target rejects below-floor targets. - assert!(check_install_target_inner("0.1.50", None).is_ok()); - assert!(check_install_target_inner("0.1.150", Some("0.1.100")).is_ok()); - assert!(matches!( - check_install_target_inner("0.1.50", Some("0.1.100")).unwrap_err(), - MinimumVersionError::TargetBelowFloor { .. } - )); - - // apply_floor bumps below-floor targets up. - assert_eq!(apply_floor_inner("0.1.50", None).unwrap(), "0.1.50"); - assert_eq!( - apply_floor_inner("0.1.200", Some("0.1.100")).unwrap(), - "0.1.200" - ); - assert_eq!( - apply_floor_inner("0.1.50", Some("0.1.100")).unwrap(), - "0.1.100" - ); - } - - #[test] - #[serial_test::serial] - fn version_env_var_flows_through_to_decision() { - let saved = std::env::var("GROK_TEST_VERSION").ok(); - - // SAFETY: #[serial] excludes other env-touching tests. - unsafe { std::env::set_var("GROK_TEST_VERSION", "0.1.50") }; - let decision = - evaluate_minimum_version(&get_installed_grok_version(), Some("0.1.100")).unwrap(); - assert!(matches!( - decision, - MinimumVersionDecision::BelowMinimum { .. } - )); - - match saved { - Some(v) => unsafe { std::env::set_var("GROK_TEST_VERSION", v) }, - None => unsafe { std::env::remove_var("GROK_TEST_VERSION") }, - } - } -} diff --git a/crates/codegen/xai-grok-update/src/version_policy.rs b/crates/codegen/xai-grok-update/src/version_policy.rs new file mode 100644 index 0000000..d9139c2 --- /dev/null +++ b/crates/codegen/xai-grok-update/src/version_policy.rs @@ -0,0 +1,208 @@ +//! Startup enforcement of the version policy. The hard `required_*` bounds gate +//! startup; `minimum`/`maximum` are updater-only. Every knob fails open. + +use crate::version::get_installed_grok_version; +use semver::Version; +use tracing::warn; +use xai_grok_shell::util::config::VersionPolicy; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequiredRangeDecision { + InRange, + Below { current: String, minimum: String }, + Above { current: String, maximum: String }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum VersionPolicyError { + #[error( + "Cannot install Grok {target}: the minimum allowed version is {minimum}. \ + Run `grok update` to install the latest allowed version." + )] + TargetBelowFloor { target: String, minimum: String }, +} + +/// Fails open: a contradictory range or an unparseable running version yields +/// `InRange`. +fn evaluate_required_range(current_version: &str, policy: &VersionPolicy) -> RequiredRangeDecision { + if policy.has_contradictory_required_range() { + warn!( + required_min = ?policy.required_minimum, + required_max = ?policy.required_maximum, + "required version range is contradictory (min > max); ignoring" + ); + return RequiredRangeDecision::InRange; + } + + let Ok(cur) = Version::parse(current_version) else { + return RequiredRangeDecision::InRange; + }; + + if let Some(mn) = &policy.required_minimum + && cur < *mn + { + return RequiredRangeDecision::Below { + current: cur.to_string(), + minimum: mn.to_string(), + }; + } + if let Some(mx) = &policy.required_maximum + && cur > *mx + { + return RequiredRangeDecision::Above { + current: cur.to_string(), + maximum: mx.to_string(), + }; + } + RequiredRangeDecision::InRange +} + +/// Reject an explicit `--version` pin below the hard floor. A pin above the +/// ceiling is allowed so a too-new install can recover. +pub(crate) fn check_install_target( + policy: &VersionPolicy, + target: &str, +) -> Result<(), VersionPolicyError> { + let Some(min) = policy.installable_floor() else { + return Ok(()); + }; + if !matches!(Version::parse(target), Ok(t) if t >= min) { + return Err(VersionPolicyError::TargetBelowFloor { + target: target.to_string(), + minimum: min.to_string(), + }); + } + Ok(()) +} + +fn required_range_message(decision: &RequiredRangeDecision) -> Option<String> { + match decision { + RequiredRangeDecision::InRange => None, + RequiredRangeDecision::Below { current, minimum } => Some(format!( + "This version of Grok ({current}) is older than the minimum required \ + by your organization ({minimum}).\n\n\ + Update to an approved version through your organization's approved \ + method (for example, run `grok update`)." + )), + RequiredRangeDecision::Above { current, maximum } => Some(format!( + "This version of Grok ({current}) is newer than the maximum allowed \ + by your organization ({maximum}).\n\n\ + Install an approved version through your organization's approved \ + method (for example, run `grok update --version {maximum}`)." + )), + } +} + +/// Refuse to start when the running version is outside the required range. +/// Recovery subcommands return before this, so they stay usable. +pub fn enforce_version_policy_or_exit() { + let policy = VersionPolicy::resolve(); + let current = get_installed_grok_version(); + let decision = evaluate_required_range(¤t, &policy); + if let Some(message) = required_range_message(&decision) { + warn!(?decision, "required version range: refusing to start"); + eprintln!("{message}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Version { + Version::parse(s).unwrap() + } + + fn pol( + min: Option<&str>, + max: Option<&str>, + rmin: Option<&str>, + rmax: Option<&str>, + ) -> VersionPolicy { + VersionPolicy { + minimum: min.map(v), + maximum: max.map(v), + required_minimum: rmin.map(v), + required_maximum: rmax.map(v), + } + } + + #[test] + fn check_install_target_enforces_only_the_hard_floor() { + assert!(check_install_target(&pol(Some("0.2.100"), None, None, None), "0.2.50").is_ok()); + let hard = pol(None, None, Some("0.1.100"), None); + assert!(check_install_target(&hard, "0.1.150").is_ok()); + assert!(matches!( + check_install_target(&hard, "0.1.50").unwrap_err(), + VersionPolicyError::TargetBelowFloor { .. } + )); + assert!(matches!( + check_install_target(&hard, "garbage").unwrap_err(), + VersionPolicyError::TargetBelowFloor { .. } + )); + assert!(check_install_target(&pol(None, None, None, None), "garbage").is_ok()); + assert!( + check_install_target(&pol(None, None, Some("0.3.0"), Some("0.2.0")), "0.1.0").is_ok() + ); + assert!( + check_install_target( + &pol(None, None, Some("0.2.100"), Some("0.2.150")), + "0.2.200" + ) + .is_ok() + ); + } + + #[test] + fn evaluate_required_range_gates_and_fails_open() { + use RequiredRangeDecision::{Above, Below, InRange}; + + assert_eq!( + evaluate_required_range( + "0.2.100", + &pol(None, None, Some("0.2.100"), Some("0.2.150")) + ), + InRange + ); + assert!(matches!( + evaluate_required_range("0.2.99", &pol(None, None, Some("0.2.100"), None)), + Below { .. } + )); + assert!(matches!( + evaluate_required_range("0.2.200", &pol(None, None, None, Some("0.2.150"))), + Above { .. } + )); + assert_eq!( + evaluate_required_range("0.2.50", &pol(None, None, Some("0.3.0"), Some("0.2.0"))), + InRange + ); + assert_eq!( + evaluate_required_range("dev-build", &pol(None, None, Some("0.2.100"), None)), + InRange + ); + assert_eq!( + evaluate_required_range("0.2.50", &pol(Some("0.2.100"), None, None, None)), + InRange + ); + } + + #[test] + fn required_range_message_is_none_only_when_in_range() { + assert!(required_range_message(&RequiredRangeDecision::InRange).is_none()); + assert!( + required_range_message(&RequiredRangeDecision::Below { + current: "0.2.99".into(), + minimum: "0.2.100".into(), + }) + .is_some() + ); + assert!( + required_range_message(&RequiredRangeDecision::Above { + current: "0.2.200".into(), + maximum: "0.2.150".into(), + }) + .is_some() + ); + } +} diff --git a/crates/codegen/xai-grok-version/Cargo.toml b/crates/codegen/xai-grok-version/Cargo.toml index 15aa6cf..73a7432 100644 --- a/crates/codegen/xai-grok-version/Cargo.toml +++ b/crates/codegen/xai-grok-version/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-version" -version = "0.2.110" +version = "0.2.111" edition.workspace = true description = "Lockstepped grok CLI version." diff --git a/crates/codegen/xai-grok-voice/src/audio/capture_linux.rs b/crates/codegen/xai-grok-voice/src/audio/capture_linux.rs index 4bc4d0e..bc23190 100644 --- a/crates/codegen/xai-grok-voice/src/audio/capture_linux.rs +++ b/crates/codegen/xai-grok-voice/src/audio/capture_linux.rs @@ -61,6 +61,15 @@ impl Recorder { let rate = rate.to_string(); match self { Recorder::PwRecord => vec![ + // `--raw` is load-bearing: without it `pw-record` treats + // `--format`/`--rate`/`--channels` as a libsndfile container + // subformat and wraps stdout in a container — WAV on + // PipeWire < 1.6 (unwritable to a pipe: "this file format + // does not support pipe writing", exit 1 — e.g. Ubuntu 24.04 + // / Debian 12 ship 1.0/1.2), AU with a header on ≥ 1.6. Raw + // mode fwrites pure PCM16 frames, which is what the reader + // expects from every backend. + "--raw".into(), "--rate".into(), rate, "--channels".into(), @@ -307,6 +316,10 @@ mod tests { assert!(parec.contains(&"--channels=1".to_string())); let pw = Recorder::PwRecord.args(48_000); + // Raw mode is required: without it pw-record wraps stdout in a + // libsndfile container (WAV on PipeWire < 1.6, which cannot be + // written to a pipe at all; AU with a header on >= 1.6). + assert!(pw.contains(&"--raw".to_string())); let r = pw.iter().position(|a| a == "--rate").unwrap(); assert_eq!(pw[r + 1], "48000"); let f = pw.iter().position(|a| a == "--format").unwrap(); diff --git a/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs b/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs index c3943ad..662e1a3 100644 --- a/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs +++ b/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs @@ -17,6 +17,7 @@ pub enum DeployError { /// window passes. Distinct from the generic `ResourceExhausted` so clients /// can render the retry hint. RateLimited, + ArchiveTooLarge, Internal, Unauthenticated, InvalidArgument, @@ -27,7 +28,7 @@ pub enum DeployError { } impl DeployError { /// Every kind, for exhaustive iteration in tests. - pub const ALL: [DeployError; 17] = [ + pub const ALL: [DeployError; 18] = [ Self::UrlConflict, Self::UrlModeration, Self::IdempotencyConflict, @@ -38,6 +39,7 @@ impl DeployError { Self::ProviderUnavailable, Self::ProjectLimitExceeded, Self::RateLimited, + Self::ArchiveTooLarge, Self::Internal, Self::Unauthenticated, Self::InvalidArgument, @@ -59,6 +61,7 @@ impl DeployError { Self::ProviderUnavailable => "deploy_provider_unavailable", Self::ProjectLimitExceeded => "deploy_project_limit_exceeded", Self::RateLimited => "deploy_rate_limited", + Self::ArchiveTooLarge => "deploy_archive_too_large", Self::Internal => "deploy_internal", Self::Unauthenticated => "deploy_unauthenticated", Self::InvalidArgument => "deploy_invalid_argument", @@ -82,6 +85,7 @@ impl DeployError { "deploy_provider_unavailable" => Self::ProviderUnavailable, "deploy_project_limit_exceeded" => Self::ProjectLimitExceeded, "deploy_rate_limited" => Self::RateLimited, + "deploy_archive_too_large" => Self::ArchiveTooLarge, "deploy_internal" => Self::Internal, "deploy_unauthenticated" => Self::Unauthenticated, "deploy_invalid_argument" => Self::InvalidArgument, diff --git a/crates/codegen/xai-grok-workspace-types/src/rpc/workspace.rs b/crates/codegen/xai-grok-workspace-types/src/rpc/workspace.rs index 978748c..968d3ce 100644 --- a/crates/codegen/xai-grok-workspace-types/src/rpc/workspace.rs +++ b/crates/codegen/xai-grok-workspace-types/src/rpc/workspace.rs @@ -191,6 +191,9 @@ pub struct BackgroundTaskSnapshotWire { pub kind: String, /// RFC3339 start timestamp. pub started_at: String, + /// Model-supplied label when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, } /// One live scheduled task (`/loop`), a slim DTO over the scheduler's diff --git a/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs b/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs index 9415035..79d32f0 100644 --- a/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs +++ b/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs @@ -5,21 +5,59 @@ //! automatically. use clap::Parser; use std::path::PathBuf; +use std::time::Duration; use url::Url; use xai_grok_workspace::config::WorkspaceServerMetadata; use xai_grok_workspace::daemonize; -use xai_grok_workspace::diag_server; +use xai_grok_workspace::diag_server::{self, DiagHandle, ErrorClass}; +use xai_grok_workspace::error::WorkspaceError; use xai_grok_workspace::preview_supervisor::{self, PreviewArgs, PreviewVisibility}; /// OTLP `service.name` for this binary's exported traces/logs/metrics and /// direct-OTLP fastrace export. Single source so the call sites can't drift. const SERVICE_NAME: &str = "prod_grok_workspace"; const EXIT_SERVER_ID_INVALID: i32 = 3; const INVALID_SERVER_ID_MARKER: &str = "workspace-server: invalid --server-id"; +const WORKSPACE_HUB_AUTH_FAILED_MARKER: &str = "workspace hub auth failed"; +/// Post-failure dwell so the host can poll `/ready` before exit ([500ms, 2s]). +const HUB_CONNECT_FAILED_DWELL: Duration = Duration::from_millis(750); fn server_id_startup_error(id: &str) -> Option<String> { id.parse::<xai_tool_protocol::ServerId>() .err() .map(|e| format!("{INVALID_SERVER_ID_MARKER} {id:?}: {e}")) } +/// Classify hub-connect Display strings for `/ready` error_class. +/// Auth needles → `hub_auth`; other hub-connect path failures → `hub_connect`; +/// pre-hub workspace setup messages → `unknown` (still retryable alongside hub_connect). +fn classify_hub_connect_failure(err_msg: &str) -> ErrorClass { + if err_msg.contains("handshake auth failed") || err_msg.contains("auth error:") { + ErrorClass::HubAuth + } else if err_msg.contains("failed to create workspace") { + ErrorClass::Unknown + } else { + ErrorClass::HubConnect + } +} +/// Drop outer `hub error: ` so `/ready` detail is the inner failure text. +fn hub_connect_error_detail(err_msg: &str) -> &str { + err_msg.strip_prefix("hub error: ").unwrap_or(err_msg) +} +fn hub_connect_failure_log_message(class: ErrorClass) -> &'static str { + match class { + ErrorClass::HubAuth => WORKSPACE_HUB_AUTH_FAILED_MARKER, + ErrorClass::HubConnect | ErrorClass::Unknown => "failed to connect workspace to hub", + } +} +/// Mark `/ready` failed and dwell so the host can observe state before exit. +async fn report_hub_connect_failure(diag: &DiagHandle, err: &WorkspaceError) { + let err_msg = err.to_string(); + let class = classify_hub_connect_failure(&err_msg); + diag.set_failed(class, hub_connect_error_detail(&err_msg)); + tracing::error!(error = %err_msg, "{}", hub_connect_failure_log_message(class)); + dwell_after_hub_connect_failed().await; +} +async fn dwell_after_hub_connect_failed() { + tokio::time::sleep(HUB_CONNECT_FAILED_DWELL).await; +} #[derive(Parser)] #[command(name = "xai-workspace-server")] #[command(about = "Standalone workspace ToolServer for the server connection")] @@ -345,7 +383,7 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> { }; let preview_scrape_interval = status_config.preview_activity_scrape_interval; xai_grok_workspace::init_metrics(); - let ws_handle = xai_grok_workspace::handle::connect_local_workspace( + let ws_handle = match xai_grok_workspace::handle::connect_local_workspace( cwd, url, auth_provider, @@ -361,7 +399,13 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> { args.confine_fs_to_workspace_root, ) .await - .map_err(|e| anyhow::anyhow!("failed to connect workspace to hub: {e}"))?; + { + Ok(handle) => handle, + Err(e) => { + report_hub_connect_failure(&diag_handle, &e).await; + return Err(anyhow::anyhow!("failed to connect workspace to hub: {e}")); + } + }; if let Some((tx, control_port)) = &preview_shutdown { tokio::spawn(preview_supervisor::supervise_preview_activity( *control_port, @@ -447,6 +491,216 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> { mod tests { use super::*; #[test] + fn hub_connect_failed_dwell_is_within_design_bounds() { + assert!(HUB_CONNECT_FAILED_DWELL >= Duration::from_millis(500)); + assert!(HUB_CONNECT_FAILED_DWELL <= Duration::from_secs(2)); + } + #[tokio::test(start_paused = true)] + async fn hub_connect_failed_dwell_elapses_exact_budget() { + let start = tokio::time::Instant::now(); + dwell_after_hub_connect_failed().await; + assert_eq!(start.elapsed(), HUB_CONNECT_FAILED_DWELL); + } + #[test] + fn classify_hub_connect_auth_needles() { + assert_eq!( + classify_hub_connect_failure("hub error: handshake auth failed: HTTP 401"), + ErrorClass::HubAuth + ); + assert_eq!( + classify_hub_connect_failure("handshake auth failed: HTTP 401"), + ErrorClass::HubAuth + ); + assert_eq!( + classify_hub_connect_failure("hub error: auth error: token rejected"), + ErrorClass::HubAuth + ); + assert_eq!( + classify_hub_connect_failure("HTTP 401 unauthorized"), + ErrorClass::HubConnect + ); + assert_eq!( + classify_hub_connect_failure("token refresh failed"), + ErrorClass::HubConnect + ); + } + #[test] + fn classify_from_client_error_display_round_trip() { + let handshake = WorkspaceError::HubError( + xai_computer_hub_sdk::ClientError::HandshakeAuthFailed { status: 401 }.to_string(), + ); + let handshake_msg = handshake.to_string(); + assert_eq!( + classify_hub_connect_failure(&handshake_msg), + ErrorClass::HubAuth + ); + assert_eq!( + hub_connect_failure_log_message(ErrorClass::HubAuth), + WORKSPACE_HUB_AUTH_FAILED_MARKER + ); + let auth = WorkspaceError::HubError( + xai_computer_hub_sdk::ClientError::AuthError("token rejected".into()).to_string(), + ); + assert_eq!( + classify_hub_connect_failure(&auth.to_string()), + ErrorClass::HubAuth + ); + let network = WorkspaceError::HubError( + xai_computer_hub_sdk::ClientError::NetworkError("connection refused".into()) + .to_string(), + ); + assert_eq!( + classify_hub_connect_failure(&network.to_string()), + ErrorClass::HubConnect + ); + assert_ne!( + hub_connect_failure_log_message(ErrorClass::HubConnect), + WORKSPACE_HUB_AUTH_FAILED_MARKER + ); + } + #[test] + fn classify_hub_connect_non_auth_is_hub_connect() { + assert_eq!( + classify_hub_connect_failure("hub error: network error: connection refused"), + ErrorClass::HubConnect + ); + assert_eq!( + classify_hub_connect_failure("hub error: protocol error: bad hello"), + ErrorClass::HubConnect + ); + assert_eq!( + classify_hub_connect_failure("failed to create workspace: disk full"), + ErrorClass::Unknown + ); + } + #[test] + fn hub_auth_marker_is_stable_literal() { + assert_eq!( + WORKSPACE_HUB_AUTH_FAILED_MARKER, + "workspace hub auth failed" + ); + } + #[test] + fn hub_connect_error_detail_strips_hub_error_prefix() { + let err = WorkspaceError::HubError("handshake auth failed: HTTP 401".into()); + assert_eq!( + hub_connect_error_detail(&err.to_string()), + "handshake auth failed: HTTP 401" + ); + let other = WorkspaceError::HubError("network error: timeout".into()); + assert_eq!( + hub_connect_error_detail(&other.to_string()), + "network error: timeout" + ); + } + /// Install a capturing tracing subscriber for the duration of an async + /// report; returns emitted event messages. + async fn report_with_captured_messages( + handle: &DiagHandle, + err: &WorkspaceError, + ) -> (Duration, Vec<String>) { + use std::sync::{Arc, Mutex}; + use tracing::field::{Field, Visit}; + use tracing_subscriber::layer::{Context, SubscriberExt as _}; + use tracing_subscriber::{Layer, Registry}; + #[derive(Default)] + struct MsgVisitor { + message: Option<String>, + } + impl Visit for MsgVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = Some(format!("{value:?}").trim_matches('"').to_owned()); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = Some(value.to_owned()); + } + } + } + struct CaptureLayer { + msgs: Arc<Mutex<Vec<String>>>, + } + impl<S: tracing::Subscriber> Layer<S> for CaptureLayer { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + let mut v = MsgVisitor::default(); + event.record(&mut v); + if let Some(msg) = v.message { + self.msgs + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(msg); + } + } + } + let msgs = Arc::new(Mutex::new(Vec::new())); + let subscriber = Registry::default().with(CaptureLayer { msgs: msgs.clone() }); + let _guard = tracing::subscriber::set_default(subscriber); + let start = tokio::time::Instant::now(); + report_hub_connect_failure(handle, err).await; + let elapsed = start.elapsed(); + let messages = msgs.lock().unwrap_or_else(|e| e.into_inner()).clone(); + (elapsed, messages) + } + #[tokio::test(start_paused = true)] + async fn report_hub_connect_failure_sets_ready_failed_auth_and_dwells() { + let handle = DiagHandle::new(Some("nonce-auth".to_owned())); + let bound = diag_server::serve(diag_server::DiagListener::Tcp(0), handle.clone(), None) + .await + .expect("bind"); + let port = bound.port.expect("tcp port"); + let err = WorkspaceError::HubError("handshake auth failed: HTTP 401".into()); + let (elapsed, messages) = report_with_captured_messages(&handle, &err).await; + assert_eq!(elapsed, HUB_CONNECT_FAILED_DWELL); + assert!( + messages + .iter() + .any(|m| m == WORKSPACE_HUB_AUTH_FAILED_MARKER), + "auth path must emit marker, got {messages:?}" + ); + let response = reqwest::get(format!("http://127.0.0.1:{port}/ready")) + .await + .expect("request"); + assert_eq!(response.status().as_u16(), 503); + let body: serde_json::Value = response.json().await.expect("json"); + assert_eq!(body["state"], "failed"); + assert_eq!(body["error_class"], "hub_auth"); + assert_eq!(body["error_detail"], "handshake auth failed: HTTP 401"); + assert_eq!(body["launch_id"], "nonce-auth"); + } + #[tokio::test(start_paused = true)] + async fn report_hub_connect_failure_sets_ready_failed_hub_connect() { + let handle = DiagHandle::new(None); + let bound = diag_server::serve(diag_server::DiagListener::Tcp(0), handle.clone(), None) + .await + .expect("bind"); + let port = bound.port.expect("tcp port"); + let err = WorkspaceError::HubError("network error: connection refused".into()); + let (elapsed, messages) = report_with_captured_messages(&handle, &err).await; + assert_eq!(elapsed, HUB_CONNECT_FAILED_DWELL); + assert!( + messages + .iter() + .any(|m| m == "failed to connect workspace to hub"), + "non-auth path must emit connect failure line, got {messages:?}" + ); + assert!( + messages + .iter() + .all(|m| m != WORKSPACE_HUB_AUTH_FAILED_MARKER), + "non-auth path must not emit auth marker, got {messages:?}" + ); + let response = reqwest::get(format!("http://127.0.0.1:{port}/ready")) + .await + .expect("request"); + assert_eq!(response.status().as_u16(), 503); + let body: serde_json::Value = response.json().await.expect("json"); + assert_eq!(body["state"], "failed"); + assert_eq!(body["error_class"], "hub_connect"); + assert_eq!(body["error_detail"], "network error: connection refused"); + } + #[test] fn capabilities_flag_parses_and_defaults_off() { let args = Args::try_parse_from(["xai-workspace-server"]).unwrap(); assert!(!args.capabilities); diff --git a/crates/codegen/xai-grok-workspace/src/diag_server.rs b/crates/codegen/xai-grok-workspace/src/diag_server.rs index 1cc3f66..6d9ab84 100644 --- a/crates/codegen/xai-grok-workspace/src/diag_server.rs +++ b/crates/codegen/xai-grok-workspace/src/diag_server.rs @@ -51,8 +51,21 @@ pub enum DiagState { Starting, Connected, Disconnected, + Failed, } +/// `/ready` `error_class` when [`DiagState::Failed`] (`hub_auth` / `hub_connect` / `unknown`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorClass { + HubAuth, + HubConnect, + Unknown, +} + +/// Soft cap on `/ready` `error_detail` so guest-local messages stay short. +const MAX_ERROR_DETAIL_BYTES: usize = 256; + /// Response body for `/ready`. The field set is a frozen contract with the /// sandbox readiness gate: never rename or remove fields; additions are /// backward-compatible. @@ -66,6 +79,10 @@ struct ReadyBody { connected_at: Option<u64>, state_changed_at: u64, version: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + error_class: Option<ErrorClass>, + #[serde(skip_serializing_if = "Option::is_none")] + error_detail: Option<String>, } /// Response body for `/statusz`: the `/ready` fields plus debug extras. @@ -82,6 +99,14 @@ struct Inner { connected_at: Option<u64>, state_changed_at: u64, shutting_down: bool, + error_class: Option<ErrorClass>, + error_detail: Option<String>, +} + +impl Inner { + fn is_failed(&self) -> bool { + matches!(self.state, DiagState::Failed) + } } /// Cloneable handle publishing hub lifecycle transitions to the server. @@ -102,16 +127,17 @@ impl DiagHandle { connected_at: None, state_changed_at: now_ms(), shutting_down: false, + error_class: None, + error_detail: None, })), } } /// Initial hello completed, or a reconnect's serve replay settled. - /// Ignored after [`Self::set_shutting_down`]: a reconnect that settles - /// during the shutdown drain must not republish `connected`. + /// No-op after [`Self::set_shutting_down`] or [`Self::set_failed`]. pub fn set_connected(&self) { let mut inner = self.lock(); - if inner.shutting_down { + if inner.shutting_down || inner.is_failed() { return; } inner.state = DiagState::Connected; @@ -120,28 +146,44 @@ impl DiagHandle { inner.state_changed_at = now; } - /// Server socket dropped. + /// Server socket dropped. No-op after [`Self::set_failed`]. pub fn set_disconnected(&self) { let mut inner = self.lock(); + if inner.is_failed() { + return; + } inner.state = DiagState::Disconnected; inner.state_changed_at = now_ms(); } - /// Latch `disconnected` for process shutdown: reported as `disconnected` - /// on `/ready`, and later `set_connected` calls become no-ops. + /// Latch disconnected for process shutdown; later `set_connected` no-ops. + /// No-op after [`Self::set_failed`]. pub fn set_shutting_down(&self) { let mut inner = self.lock(); + if inner.is_failed() { + return; + } inner.shutting_down = true; inner.state = DiagState::Disconnected; inner.state_changed_at = now_ms(); } + /// Terminal connect failure on `/ready`. Sticky; callers dwell before exit. + pub fn set_failed(&self, error_class: ErrorClass, error_detail: impl Into<String>) { + let mut inner = self.lock(); + inner.state = DiagState::Failed; + inner.error_class = Some(error_class); + inner.error_detail = Some(truncate_error_detail(error_detail.into())); + inner.state_changed_at = now_ms(); + } + fn lock(&self) -> MutexGuard<'_, Inner> { self.inner.lock().unwrap_or_else(PoisonError::into_inner) } fn ready_body(&self) -> ReadyBody { let inner = self.lock(); + let failed = inner.is_failed(); ReadyBody { launch_id: self.launch_id.clone(), state: inner.state, @@ -149,6 +191,12 @@ impl DiagHandle { connected_at: inner.connected_at, state_changed_at: inner.state_changed_at, version: xai_grok_version::VERSION, + error_class: failed.then_some(inner.error_class).flatten(), + error_detail: if failed { + inner.error_detail.clone() + } else { + None + }, } } @@ -160,6 +208,17 @@ impl DiagHandle { } } +fn truncate_error_detail(detail: String) -> String { + if detail.len() <= MAX_ERROR_DETAIL_BYTES { + return detail; + } + let mut end = MAX_ERROR_DETAIL_BYTES; + while end > 0 && !detail.is_char_boundary(end) { + end -= 1; + } + detail[..end].to_owned() +} + fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -412,6 +471,115 @@ mod tests { assert_eq!(body["state"], "disconnected"); } + #[tokio::test] + async fn ready_reports_failed_with_error_fields() { + let handle = DiagHandle::new(Some("nonce-fail".to_owned())); + let bound = serve(DiagListener::Tcp(0), handle.clone(), None) + .await + .expect("bind"); + let port = bound.port.expect("tcp port"); + + handle.set_failed(ErrorClass::HubAuth, "handshake auth failed: HTTP 401"); + let (status, body) = get_json(port, "/ready").await; + + assert_eq!(status, 503, "failed is not ready"); + assert_eq!(body["launch_id"], "nonce-fail"); + assert_eq!(body["state"], "failed"); + assert_eq!(body["error_class"], "hub_auth"); + assert_eq!(body["error_detail"], "handshake auth failed: HTTP 401"); + assert!(body["state_changed_at"].is_u64()); + assert!(body["pid"].is_u64()); + assert!(body["version"].is_string()); + let starting = DiagHandle::new(None); + let bound2 = serve(DiagListener::Tcp(0), starting, None) + .await + .expect("bind"); + let (_, start_body) = get_json(bound2.port.expect("tcp port"), "/ready").await; + assert_eq!(start_body["state"], "starting"); + assert!( + start_body.get("error_class").is_none(), + "error_class must be omitted unless failed" + ); + assert!( + start_body.get("error_detail").is_none(), + "error_detail must be omitted unless failed" + ); + } + + #[tokio::test] + async fn ready_failed_hub_connect_and_unknown_classes() { + let handle = DiagHandle::new(None); + let bound = serve(DiagListener::Tcp(0), handle.clone(), None) + .await + .expect("bind"); + let port = bound.port.expect("tcp port"); + + handle.set_failed(ErrorClass::HubConnect, "network error: connection refused"); + let (status, body) = get_json(port, "/ready").await; + assert_eq!(status, 503); + assert_eq!(body["state"], "failed"); + assert_eq!(body["error_class"], "hub_connect"); + assert_eq!(body["error_detail"], "network error: connection refused"); + + handle.set_failed(ErrorClass::Unknown, "something else"); + let (status, body) = get_json(port, "/ready").await; + assert_eq!(status, 503); + assert_eq!(body["state"], "failed"); + assert_eq!(body["error_class"], "unknown"); + assert_eq!(body["error_detail"], "something else"); + } + + #[tokio::test] + async fn failed_is_sticky_against_later_lifecycle_transitions() { + let handle = DiagHandle::new(None); + let bound = serve(DiagListener::Tcp(0), handle.clone(), None) + .await + .expect("bind"); + let port = bound.port.expect("tcp port"); + + handle.set_failed(ErrorClass::HubAuth, "handshake auth failed: HTTP 401"); + handle.set_connected(); + handle.set_disconnected(); + handle.set_shutting_down(); + + let (status, body) = get_json(port, "/ready").await; + assert_eq!(status, 503); + assert_eq!(body["state"], "failed"); + assert_eq!(body["error_class"], "hub_auth"); + assert_eq!(body["error_detail"], "handshake auth failed: HTTP 401"); + } + + #[test] + fn error_detail_is_truncated_to_cap() { + let handle = DiagHandle::new(None); + let long = "x".repeat(MAX_ERROR_DETAIL_BYTES + 64); + handle.set_failed(ErrorClass::Unknown, long); + let body = handle.ready_body(); + let detail = body.error_detail.expect("detail"); + assert_eq!(detail.len(), MAX_ERROR_DETAIL_BYTES); + } + + #[test] + fn error_detail_truncation_respects_utf8_char_boundary() { + let mut long = "a".repeat(MAX_ERROR_DETAIL_BYTES - 1); + long.push('é'); + assert_eq!(long.len(), MAX_ERROR_DETAIL_BYTES + 1); + + let handle = DiagHandle::new(None); + handle.set_failed(ErrorClass::Unknown, long); + let detail = handle.ready_body().error_detail.expect("detail"); + assert!( + detail.len() <= MAX_ERROR_DETAIL_BYTES, + "truncated length {}", + detail.len() + ); + assert!( + detail.is_char_boundary(detail.len()), + "must not split a multi-byte char" + ); + assert!(detail.ends_with('a') || detail.ends_with('é')); + } + #[cfg(unix)] #[tokio::test] async fn unix_socket_serves_ready_and_rebinds_over_stale_socket() { diff --git a/crates/codegen/xai-grok-workspace/src/handle.rs b/crates/codegen/xai-grok-workspace/src/handle.rs index 072d938..797fb32 100644 --- a/crates/codegen/xai-grok-workspace/src/handle.rs +++ b/crates/codegen/xai-grok-workspace/src/handle.rs @@ -5356,6 +5356,7 @@ pub(crate) mod tests { foreground_block_budget: None, kind: xai_grok_tools::computer::types::TaskKind::Bash, owner_session_id: None, + description: None, } } /// Start a `sleep 30` background task on `session`'s owned backend and diff --git a/crates/codegen/xai-grok-workspace/src/hub.rs b/crates/codegen/xai-grok-workspace/src/hub.rs index 60ea654..35c1d66 100644 --- a/crates/codegen/xai-grok-workspace/src/hub.rs +++ b/crates/codegen/xai-grok-workspace/src/hub.rs @@ -1077,6 +1077,7 @@ mod tests { block_waited: false, explicitly_killed: false, owner_session_id: None, + description: None, }) } fn started_id(n: &ToolNotification) -> &str { diff --git a/crates/codegen/xai-grok-workspace/src/hub_server.rs b/crates/codegen/xai-grok-workspace/src/hub_server.rs index fd7be86..ae06115 100644 --- a/crates/codegen/xai-grok-workspace/src/hub_server.rs +++ b/crates/codegen/xai-grok-workspace/src/hub_server.rs @@ -277,6 +277,7 @@ async fn tasks_snapshot(toolset: &FinalizedToolset) -> TasksSnapshotResponse { TaskKind::Monitor => "monitor".to_owned(), }, started_at: DateTime::<Utc>::from(t.start_time).to_rfc3339(), + description: t.description, } }) .collect(), @@ -1440,6 +1441,11 @@ mod tests { let task = &snap.background_tasks[0]; assert_eq!(task.task_id, bg.task_id); assert_eq!(task.kind, "bash"); + assert!( + task.description.is_none(), + "start_background_sleep does not set description: {:?}", + task.description + ); assert!( DateTime::parse_from_rfc3339(&task.started_at).is_ok(), "started_at must be RFC3339: {}", @@ -1450,6 +1456,24 @@ mod tests { "no scheduler resource in this toolset: {:?}", snap.scheduled_tasks ); + { + use crate::handle::tests::terminal_run_request; + let mut req = terminal_run_request("sleep 30", out_dir.path(), "snap-desc-task"); + req.description = Some("build frontend".into()); + let desc_bg = session + .terminal_backend() + .run_background(req) + .await + .expect("start described background task"); + let snap = snapshot(&handler).await; + let described = snap + .background_tasks + .iter() + .find(|t| t.task_id == desc_bg.task_id) + .expect("described task in snapshot"); + assert_eq!(described.description.as_deref(), Some("build frontend")); + session.terminal_backend().kill_task(&desc_bg.task_id).await; + } session.terminal_backend().kill_task(&bg.task_id).await; let snap = snapshot(&handler).await; assert!( diff --git a/crates/codegen/xai-grok-workspace/src/session/tool_config.rs b/crates/codegen/xai-grok-workspace/src/session/tool_config.rs index 5f9dfe9..e3ebbc6 100644 --- a/crates/codegen/xai-grok-workspace/src/session/tool_config.rs +++ b/crates/codegen/xai-grok-workspace/src/session/tool_config.rs @@ -425,6 +425,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory { image_gen_enabled: true, image_edit_enabled: true, model_override: None, + edit_model_override: None, tier_restricted: false, }, VideoGenConfig::Enabled { @@ -467,6 +468,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory { session_env, notification_handle, owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: self.resolve_state_path(session_id), @@ -590,6 +592,7 @@ pub mod test_support { session_env, notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, + subagent: None, parent_scheduler_handle: None, skills: vec![], state_path: session_root.join("tool_state.json"), diff --git a/crates/codegen/xai-tty-utils/src/lib.rs b/crates/codegen/xai-tty-utils/src/lib.rs index c3c6e5c..8dc5b78 100644 --- a/crates/codegen/xai-tty-utils/src/lib.rs +++ b/crates/codegen/xai-tty-utils/src/lib.rs @@ -402,18 +402,24 @@ pub const GIT_AUTH_SUPPRESSION_ENVS: [(&str, &str); 4] = [ /// /// Respects `GIT_BIN_PATH` for hermetic git in Bazel test sandboxes. pub fn git_command() -> std::process::Command { + let mut hermetic_exec_path: Option<std::path::PathBuf> = None; let git = match std::env::var("GIT_BIN_PATH") { Ok(p) => { let p = std::path::PathBuf::from(p); - if p.is_relative() { - std::env::current_dir() - .unwrap_or_default() - .join(&p) - .to_string_lossy() - .into_owned() + let p = if p.is_relative() { + std::env::current_dir().unwrap_or_default().join(&p) } else { - p.to_string_lossy().into_owned() + p + }; + // git-minimal spawns subcommands (`git stash` → `git + // update-index`) through its exec path, which is baked to a + // build-machine prefix. Helpers live next to the binary, so point + // the exec path there. Skip the host-fallback wrapper: host git + // must keep its own exec path. + if p.file_name().is_some_and(|name| name == "git") { + hermetic_exec_path = p.parent().map(std::path::Path::to_path_buf); } + p.to_string_lossy().into_owned() } Err(_) => "git".to_string(), }; @@ -424,6 +430,9 @@ pub fn git_command() -> std::process::Command { for &(key, val) in &GIT_AUTH_SUPPRESSION_ENVS { cmd.env(key, val); } + if let Some(exec_path) = hermetic_exec_path { + cmd.env("GIT_EXEC_PATH", exec_path); + } cmd.arg("--no-optional-locks"); cmd } diff --git a/crates/common/xai-circuit-breaker/src/lib.rs b/crates/common/xai-circuit-breaker/src/lib.rs index 7db6512..ce95033 100644 --- a/crates/common/xai-circuit-breaker/src/lib.rs +++ b/crates/common/xai-circuit-breaker/src/lib.rs @@ -24,3 +24,5 @@ pub use observer::{NoopObserver, Observer}; pub use registry::CircuitBreakerRegistry; pub use retry_policy::{Disposition, RetryPolicy}; pub use state::{BreakerOpen, BreakerState, Outcome}; + +// The crate's public surface is the re-exports above. diff --git a/crates/common/xai-test-utils/src/git.rs b/crates/common/xai-test-utils/src/git.rs index cd2bdb7..27ec498 100644 --- a/crates/common/xai-test-utils/src/git.rs +++ b/crates/common/xai-test-utils/src/git.rs @@ -30,6 +30,15 @@ pub fn ensure_hermetic_git_on_path() { // SAFETY: called once via `Once` before any child processes are spawned. unsafe { std::env::set_var("PATH", format!("{}:{}", bin_dir.display(), current_path)); + // git-minimal spawns subcommands (`git stash` → `git + // update-index`) through its exec path, which is baked to + // a build-machine prefix. Helpers live next to the binary, + // so point the exec path there. Skip the host-fallback + // wrapper (`git-host-fallback.sh`): host git must keep its + // own exec path. + if git_path.file_name().is_some_and(|name| name == "git") { + std::env::set_var("GIT_EXEC_PATH", bin_dir); + } } } }