Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: report `/ready` as failed with dwell on hub connect failure
- Refresh OIDC token for the Grok agent in the shell
- ACP terminal output recorder
- Cross-platform provider auth commands in the shell
- Default `/resume` to Grok sessions with a hint for hidden external sessions
- Resume sessions by title with `--resume`
- Limit app-builder archive size
- Data-driven tag labels for slash commands
- Doctor fixes for tmux
- Custom provider gateways and subprocess environment policy in the shell
- `/tutorial` — opt-in onboarding tour of Grok Build
- Soft and required CLI version checks in the shell
- Privacy banner env overrides survive live settings updates
- Add remote flag to override the image-edit model
- Return profile fields from auth info even when the access token is expired
- Add edit control on queued prompt rows
- Keep fail-closed policy when clearing orphans with no team
- Setting to disable the Ctrl+Space/F8 voice shortcut
- Pass `--raw` to pw-record so Linux dictation works on older PipeWire
- Validate git URLs when adding marketplace entries
- Stop shipping stale tool-doc parameter and tool names
- Re-point dashboard attach after `/fork` only when the parent was attached
- Surface Grok Computer media-generation results as file-path chunks
- Clear web background-task tray on kill and keep the task description
- Show privacy upsell banner in agent view until acted on
- Add tools-server client callback surface
- Protect persistent global hook sources

Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -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

View file

@ -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]

View file

@ -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` |

View file

@ -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
}
]

View file

@ -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.

View file

@ -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()
}

View file

@ -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(),

View file

@ -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()),

View file

@ -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"
);
}
}

View file

@ -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

View file

@ -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;

View file

@ -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()),

View file

@ -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()

View file

@ -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(),

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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()
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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,
},
)
}

View file

@ -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")
);
}

View file

@ -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

View file

@ -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();
}

View file

@ -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 {

View file

@ -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

View file

@ -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()))
}

View file

@ -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 {

View file

@ -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()

View file

@ -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()),
};

View file

@ -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

View file

@ -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);
}

View file

@ -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()),

View file

@ -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()),

View file

@ -376,6 +376,7 @@ mod notification_hook_filter_tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
},
will_wake: false,
};

View file

@ -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),

View file

@ -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,
}))

View file

@ -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,

View file

@ -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(

View file

@ -387,6 +387,7 @@ mod stop_gate_snapshot_tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
}
}

View file

@ -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,
}));

View file

@ -1345,6 +1345,7 @@ impl SessionActor {
if tx
.send(SubagentEvent::MarkUsageNotApplied(
SubagentMarkUsageNotAppliedRequest {
parent_session_id: self.session_id_string(),
prompt_id: pid,
respond_to,
},

View file

@ -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(),
},
));

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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"),

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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"),

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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.

View file

@ -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",

View file

@ -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,

View file

@ -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,
})

View file

@ -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,
}))

View file

@ -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();

View file

@ -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));
}
}

View file

@ -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,
&notification_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,
&notification_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,
}
}
}

View file

@ -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);

View file

@ -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)"
);
}
&current[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("", "é!", 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"
);
}
}

View file

@ -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]

View file

@ -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);
}
}
}
}

View file

@ -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,

View file

@ -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.

View file

@ -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)]

View file

@ -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`.

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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());
}
}

View file

@ -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)

View file

@ -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;

View file

@ -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,

View file

@ -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]