Synced from monorepo

Changes:
- Gate session-lifecycle heap steady state with a dhat soak
- Unbreak merge lifecycle e2e after default model → grok-4.5
- Scan home-scope rules dirs at <root>/rules
- Complete text-input paste and terminal parity
- Gate project roles and personas
- Use canonical editing in dialogs
- Use canonical editing in search bars
- Reject ambiguous MCP tool IDs
- Harden Git operands for plugins
- Simplify queue drain API
- Pass RFC 9207 iss through MCP OAuth token exchange
- Show leader roster when local agents map is empty
- Use canonical editing in Persona views
- Remove marketplace default-skills auto-install and purge old installs
- Use canonical editing in extension forms
- Add canonical dashboard text editing
- Use canonical editing in settings
- Add /summarize as a /recap alias
- Restore previous agent when exiting dashboard
- Use tool_choice auto for compaction
- Settings toggle for snap-prompt-to-top on send
- Update default models to grok-4.5
- Source login shell once for local bash (env + alias/function snapshot)
- Template hardcoded param names in server-native tool descriptions
- Fix System-Reminder XML tag injection in CLAUDE.md via agents_md
- Fix remote workspace-server hardcoding LSP trust (repo code execution risk)
- Clear orphaned tool-call updates at turn end
- Suppress task wake after cancel
- Send x-grok-client-identifier on direct API tool calls
- Harden dashboard peek lease transitions
- Host /btw side panel in live region (minimal mode)
- Bound scroll presentation latency
- Highlight multi-line constructs correctly in diffs and the file viewer
- Block web_fetch non-public IPs; local opt-in is explicit-host only
- Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness
- Follow up clipboard delivery feedback
- Use canonical editing in pickers
- Route TextArea through canonical editor
- Persistent "watching" status row; quieter turn markers
- Gate sensitive edit targets
- Expose agent registry counts and gate session churn on them
- Default coding data sharing to opt-out until server preference applies
- Wire chat attachment ids through gateway prompts
- On auth refresh failure, issue retry
- Forward preview provenance and computer lifecycle state
- Document independent privacy controls and scope /privacy output
- Strip SamplingError Display prefix on rate-limit UI copy
- Stop dumping Cloudflare HTML into Retry failed
- Disable in-place prompt edit (scroll jank on enter)
- Strip forced ANSI color from gh pr view JSON
- Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

@ -630,7 +630,6 @@ pub struct Requirements {
pub struct RuntimeResolutionContext<'a> {
pub raw_config: &'a toml::Value,
pub remote_settings: Option<&'a crate::util::config::RemoteSettings>,
pub cwd: Option<&'a std::path::Path>,
pub is_headless: bool,
/// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote.
pub cli_subagents: Option<bool>,
@ -1151,6 +1150,9 @@ pub struct MarketplaceConfig {
/// Written/read out-of-band by `extensions::marketplace`, opaque so a wrong-typed value can't fail load.
#[serde(default)]
pub official_marketplace_auto_installed: Option<toml::Value>,
/// Written/read out-of-band by `extensions::marketplace`, opaque so a wrong-typed value can't fail load.
#[serde(default)]
pub default_skills_installs_purged: Option<toml::Value>,
}
/// A single `[[marketplace.sources]]` entry.
#[derive(Clone, Debug, Deserialize)]
@ -1455,11 +1457,11 @@ pub struct Config {
/// Keys are agent names, values are booleans. Omitted agents default to enabled.
#[serde(skip)]
pub subagent_toggle: std::collections::HashMap<String, bool>,
/// Per-subagent role definitions from `[subagents.roles]` in config.toml
/// and `.grok/roles/*.toml` file discovery.
/// Trust-independent roles from inline, user, and bundled sources.
#[serde(skip)]
pub subagent_roles:
std::collections::HashMap<String, xai_grok_subagent_resolution::config::SubagentRole>,
/// Trust-independent personas from inline, user, and bundled sources.
#[serde(skip)]
pub subagent_personas:
std::collections::HashMap<String, xai_grok_subagent_resolution::config::SubagentPersona>,
@ -1885,18 +1887,13 @@ impl Config {
config.apply_env_overrides();
Ok(config)
}
/// Populate `#[serde(skip)]` subagent fields from `SubagentsConfig::resolve()`.
/// Populate trust-independent `#[serde(skip)]` subagent base fields.
///
/// Must be called after `new_from_toml_cfg` on the **primary startup path**
/// before the config is handed to `MvpAgent`. Model-reload and API-key-reload
/// paths only read model/key fields and do not need this call.
pub fn resolve_subagents(
&mut self,
cli_flag: bool,
raw_config: &toml::Value,
cwd: Option<&std::path::Path>,
) {
let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config, cwd);
/// before the config is handed to `MvpAgent`. Project definitions are overlaid
/// per cwd after that cwd's authoritative folder-trust resolve.
pub fn resolve_subagents(&mut self, cli_flag: bool, raw_config: &toml::Value) {
let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config);
self.subagents_enabled = sa.enabled;
self.subagent_model_overrides = sa.models;
self.subagent_toggle = sa.toggle;
@ -1906,7 +1903,7 @@ impl Config {
/// Resolve all `#[serde(skip)]` runtime fields that have resolver functions.
///
/// Call immediately after `new_from_toml_cfg()`. Fields resolved:
/// - subagents (6 fields) via `SubagentsConfig::resolve`
/// - subagents base layers (6 fields) via `SubagentsConfig::resolve`
/// - respect_gitignore via `ToolsConfig::resolve`
/// - disable_zdr_incompatible_tools via `ToolsConfig::resolve`
/// - managed_mcps_enabled via `ManagedMcpsConfig::resolve`
@ -1924,7 +1921,7 @@ impl Config {
self.web_search_model_override = ctx.cli_web_search_model.map(|s| s.to_owned());
self.session_summary_model_override = ctx.cli_session_summary_model.map(|s| s.to_owned());
let cli_flag = ctx.cli_subagents.unwrap_or(false);
self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd);
self.resolve_subagents(cli_flag, ctx.raw_config);
let tools = crate::config::ToolsConfig::resolve(ctx.raw_config);
self.respect_gitignore = match self.requirements.respect_gitignore.pinned() {
Some(pinned) => pinned,
@ -1975,16 +1972,11 @@ impl Config {
self.compat_resolved = resolve_compat_config(&self.compat, ctx.remote_settings);
}
/// Re-resolve eagerly-resolved runtime fields using the current `Config`
/// state and fresh `raw_config` + `cwd`. Builds a
/// [`RuntimeResolutionContext`] from the CLI flags already stored on this
/// `Config` so callers don't need to manually extract each field.
/// state and fresh `raw_config`. Builds a [`RuntimeResolutionContext`] from
/// the CLI flags already stored on this `Config`.
///
/// Integration test coverage: `tests/test_settings_refresh.rs`.
pub fn re_resolve_runtime_fields(
&mut self,
raw_config: &toml::Value,
cwd: Option<&std::path::Path>,
) {
pub fn re_resolve_runtime_fields(&mut self, raw_config: &toml::Value) {
let remote_settings = self.remote_settings.clone();
let cli_web_search_model = self.web_search_model_override.clone();
let cli_session_summary_model = self.session_summary_model_override.clone();
@ -1992,7 +1984,6 @@ impl Config {
let ctx = RuntimeResolutionContext {
raw_config,
remote_settings: remote_settings.as_ref(),
cwd,
is_headless: self.mode == AgentMode::Headless,
cli_subagents: self.cli_subagents,
cli_web_search_model: cli_web_search_model.as_deref(),
@ -2599,6 +2590,17 @@ impl Config {
.resolve()
.value
}
pub(crate) fn resolve_compaction_tool_choice(
&self,
) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE).as_deref(),
self.features.compaction_tool_choice.as_deref(),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Precedence: env `GROK_COMPACTION_DETAIL`, then config
/// `features.compaction_detail`, then remote settings
/// `remote_settings.compaction_detail`, then default (`verbose`). Drives the
@ -3368,6 +3370,10 @@ struct DefaultModelJson {
compaction_at_tokens: Option<CompactionAtTokens>,
#[serde(default)]
show_model_fingerprint: bool,
#[serde(default)]
auto_compact_threshold_percent: Option<u8>,
#[serde(default)]
system_prompt_label: Option<String>,
}
fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryConfig> {
let root: serde_json::Value = serde_json::from_str(crate::models::DEFAULT_MODELS_JSON)
@ -3402,8 +3408,8 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
name: m.name,
description: m.description,
context_window,
auto_compact_threshold_percent: None,
system_prompt_label: None,
auto_compact_threshold_percent: m.auto_compact_threshold_percent,
system_prompt_label: m.system_prompt_label,
temperature: m.temperature,
top_p: m.top_p,
max_completion_tokens: m.max_completion_tokens,
@ -4192,6 +4198,8 @@ pub struct Features {
/// Feed the summarizer the verbatim conversation instead of the lossy rewrite; `None` = defer to env/remote settings/default (true).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_verbatim_input: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_tool_choice: Option<String>,
/// Snapshot a completed subagent's isolated worktree into a durable git ref
/// and delete its directory (resume rehydrates from the ref). This is the
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
@ -5153,7 +5161,6 @@ reasoning_effort = "low"
RuntimeResolutionContext {
raw_config: raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -5182,7 +5189,6 @@ reasoning_effort = "low"
RuntimeResolutionContext {
raw_config: raw,
remote_settings: None,
cwd: None,
is_headless: true,
cli_subagents: None,
cli_web_search_model: None,
@ -9215,6 +9221,7 @@ agent_type = "cursor"
url = "https://mcp.test.com"
[toolset.bash]
timeout_secs = 120
login_shell_capture = true
[shortcuts]
ctrl_k = "search"
[grok_com_config]
@ -9325,6 +9332,7 @@ agent_type = "cursor"
let toml_str = r#"
[marketplace]
official_marketplace_auto_installed = "yes"
default_skills_installs_purged = "yes"
"#;
let unused = unused_keys_from_toml(toml_str);
assert!(unused.is_empty(), "got: {unused:?}");
@ -9342,6 +9350,7 @@ agent_type = "cursor"
deny = ["Bash(rm *)"]
[marketplace]
official_marketplace_auto_installed = true
default_skills_installs_purged = true
[ui]
yollo = true
"#,
@ -10108,7 +10117,6 @@ hooks = true
config.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: Some(&remote),
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10134,7 +10142,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10170,7 +10177,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: true,
cli_subagents: None,
cli_web_search_model: None,
@ -10202,7 +10208,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: Some(&remote),
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10225,7 +10230,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10248,7 +10252,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: Some(true),
cli_web_search_model: None,
@ -10272,7 +10275,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10296,7 +10298,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: Some("custom-ws"),
@ -10324,7 +10325,6 @@ hooks = true
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
raw_config: &raw,
remote_settings: Some(&remote),
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10347,7 +10347,6 @@ hooks = true
let ctx = RuntimeResolutionContext {
raw_config: &raw,
remote_settings: None,
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
@ -10872,12 +10871,13 @@ default = "grok-4.5"
#[test]
fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() {
let cfg = Config::default();
let dm = crate::models::default_model();
let default_cw = DEFAULT_CONTEXT_WINDOW;
let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default());
let entry = prefetch_model_entry(dm, default_cw, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("grok-build".to_owned(), entry);
prefetched.insert(dm.to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let entry = resolved.get("grok-build").expect("model must exist");
let entry = resolved.get(dm).expect("model must exist");
assert_ne!(
entry.info.context_window.get(),
default_cw,
@ -10887,12 +10887,13 @@ default = "grok-4.5"
#[test]
fn resolve_model_list_does_not_override_explicitly_set_context_window() {
let cfg = Config::default();
let dm = crate::models::default_model();
let explicit_cw = 65_536;
let entry = prefetch_model_entry("grok-build", explicit_cw, ApiBackend::default());
let entry = prefetch_model_entry(dm, explicit_cw, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("grok-build".to_owned(), entry);
prefetched.insert(dm.to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let entry = resolved.get("grok-build").expect("model must exist");
let entry = resolved.get(dm).expect("model must exist");
assert_eq!(
entry.info.context_window.get(),
explicit_cw,
@ -10902,14 +10903,15 @@ default = "grok-4.5"
#[test]
fn resolve_model_list_inherits_agent_type_and_api_backend() {
let cfg = Config::default();
let dm = crate::models::default_model();
let default_cw = DEFAULT_CONTEXT_WINDOW;
let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default());
let entry = prefetch_model_entry(dm, default_cw, ApiBackend::default());
let mut prefetched = IndexMap::new();
prefetched.insert("grok-build".to_owned(), entry);
prefetched.insert(dm.to_owned(), entry);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let entry = resolved.get("grok-build").expect("model must exist");
let entry = resolved.get(dm).expect("model must exist");
let defaults = default_model_entries(&EndpointsConfig::default());
if let Some(default) = defaults.get("grok-build") {
if let Some(default) = defaults.get(dm) {
if default.info.agent_type != DEFAULT_AGENT_TYPE {
assert_eq!(
entry.info.agent_type, default.info.agent_type,
@ -10947,23 +10949,25 @@ default = "grok-4.5"
#[test]
fn resolve_model_list_prunes_bundled_entries_not_in_prefetch() {
let cfg = Config::default();
let dm = crate::models::default_model();
let mut defs = default_model_entries(&EndpointsConfig::default());
let mut p = IndexMap::new();
if let Some(e) = defs.shift_remove("grok-build") {
p.insert("grok-build".to_string(), e);
if let Some(e) = defs.shift_remove(dm) {
p.insert(dm.to_string(), e);
}
let resolved = resolve_model_list(&cfg, Some(p));
assert!(resolved.contains_key("grok-build"));
assert!(resolved.contains_key(dm));
let no_p = resolve_model_list(&cfg, None);
assert!(no_p.contains_key("grok-build"));
assert!(no_p.contains_key(dm));
}
#[test]
fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() {
let cfg = Config::default();
let dm = crate::models::default_model();
let mut defs = default_model_entries(&EndpointsConfig::default());
let mut p = IndexMap::new();
if let Some(e) = defs.shift_remove("grok-build") {
p.insert("grok-build".to_string(), e);
if let Some(e) = defs.shift_remove(dm) {
p.insert(dm.to_string(), e);
}
let resolved = resolve_model_list(&cfg, Some(p));
let sess: Vec<_> = resolved
@ -10975,27 +10979,29 @@ default = "grok-4.5"
.filter(|e| e.visible_for_auth(false))
.collect();
assert_eq!(sess.len(), 1);
assert!(api.is_empty());
assert_eq!(api.len(), 1);
}
#[test]
fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() {
let cfg = Config::default();
let dm = crate::models::default_model();
let mut p = IndexMap::new();
let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default());
p.insert("secret-xyz".to_string(), e);
let resolved = resolve_model_list(&cfg, Some(p));
assert!(resolved.contains_key("secret-xyz"));
assert!(!resolved.contains_key("grok-build"));
assert!(!resolved.contains_key(dm));
}
#[test]
fn resolve_model_list_prefetch_replaces_bundled_entirely() {
let cfg = Config::default();
let dm = crate::models::default_model();
let mut p = IndexMap::new();
let e = prefetch_model_entry("grok-4.5", 500_000, ApiBackend::Responses);
p.insert("grok-4.5".to_string(), e);
let e = prefetch_model_entry("other-model", 500_000, ApiBackend::Responses);
p.insert("other-model".to_string(), e);
let resolved = resolve_model_list(&cfg, Some(p));
assert!(resolved.contains_key("grok-4.5"));
assert!(!resolved.contains_key("grok-build"));
assert!(resolved.contains_key("other-model"));
assert!(!resolved.contains_key(dm));
}
#[test]
fn resolve_model_list_empty_prefetch_yields_empty_base() {
@ -11003,14 +11009,14 @@ default = "grok-4.5"
let resolved = resolve_model_list(&cfg, Some(IndexMap::new()));
assert!(resolved.is_empty());
}
/// Regression: enterprise managed config aliases grok-build to their own
/// endpoint with env_key. The bundled grok-build has supported_in_api=false.
/// The config overlay must be visible to API-key users (env_key = BYOK).
/// Regression: enterprise managed config overlays env_key on an oauth-only
/// catalog entry. BYOK must force visibility for API-key users so a
/// base `supported_in_api: false` does not leak into the overlay.
#[test]
fn byok_config_overlay_visible_to_api_key_users() {
let raw: toml::Value = toml::from_str(
r#"
[model.grok-build]
[model.enterprise-alias]
model = "grok-4.5"
base_url = "https://inference.company.com/v1"
env_key = "COMPANY_TOKEN"
@ -11018,31 +11024,48 @@ default = "grok-4.5"
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let entry = resolved.get("grok-build").expect("grok-build must exist");
let mut base = prefetch_model_entry("enterprise-alias", 200_000, ApiBackend::default());
base.info.supported_in_api = false;
let mut prefetched = IndexMap::new();
prefetched.insert("enterprise-alias".to_owned(), base);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let entry = resolved
.get("enterprise-alias")
.expect("enterprise-alias must exist");
assert!(
entry.visible_for_auth(false),
"BYOK config entry must be visible to API-key users — \
bundled supported_in_api=false must not leak into credentialed overlays"
env_key must override base supported_in_api=false"
);
}
/// Guard: config overlay WITHOUT credentials must NOT override the
/// bundled supported_in_api flag. Only BYOK triggers the override.
/// Guard: config overlay WITHOUT credentials must NOT flip the
/// bundled supported_in_api flag. Only BYOK triggers that override.
#[test]
fn plain_config_overlay_preserves_bundled_visibility() {
let raw: toml::Value = toml::from_str(
let dm = crate::models::default_model();
let bundled = default_model_entries(&EndpointsConfig::default())
.get(dm)
.expect("bundled default must exist")
.clone();
let raw: toml::Value = toml::from_str(&format!(
r#"
[model.grok-build]
[model."{dm}"]
context_window = 300000
"#,
)
"#
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let entry = resolved.get("grok-build").expect("grok-build must exist");
assert!(
!entry.visible_for_auth(false),
"non-BYOK config overlay must preserve bundled supported_in_api=false"
let entry = resolved.get(dm).expect("bundled default must exist");
assert_eq!(
entry.visible_for_auth(false),
bundled.visible_for_auth(false),
"non-BYOK config overlay must preserve bundled supported_in_api"
);
assert_eq!(
entry.visible_for_auth(true),
bundled.visible_for_auth(true),
"non-BYOK config overlay must preserve bundled OAuth visibility"
);
}
#[test]

View file

@ -137,6 +137,8 @@ fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
let grok_home = crate::util::grok_home::grok_home();
crate::builtin::extract_bundled_files(&grok_home);
crate::extensions::marketplace::purge_default_skills_installs(&grok_home);
// Auto-register is gated (default off; env/remote settings enables). Kept out
// of extract_bundled_files so the gate can read the resolved
// remote_settings, which resolve_config has populated by now.

View file

@ -2314,6 +2314,7 @@ impl acp::Agent for MvpAgent {
traceparent: xai_file_utils::trace_context::current_traceparent(),
json_schema,
send_now,
admission: None,
respond_to: tx,
persist_ack: None,
parsed_prompt_tx,

View file

@ -873,7 +873,6 @@ impl MvpAgent {
auth: &crate::auth::GrokAuth,
) {
self.refresh_remote_settings(auth).await;
let cwd = std::env::current_dir().ok();
{
let mut cfg = self.cfg.borrow_mut();
crate::util::config::sync_campaign_fields(&mut cfg);
@ -884,7 +883,7 @@ impl MvpAgent {
);
toml::Value::Table(toml::map::Map::new())
});
cfg.re_resolve_runtime_fields(&raw_config, cwd.as_deref());
cfg.re_resolve_runtime_fields(&raw_config);
}
self.sync_collection_config_gate();
self.emit_settings_update_notification();
@ -1401,6 +1400,7 @@ impl MvpAgent {
/// Params resolution (TOML > env > remote settings > default):
/// - `proxy_endpoint`: `[toolset.web_fetch] proxy_endpoint` > `GROK_WEB_FETCH_PROXY` > remote settings > None
/// - `allowed_domains`: `[toolset.web_fetch] allowed_domains` > remote settings > built-in defaults
/// - `allow_local`: `[toolset.web_fetch] allow_local` > `GROK_WEB_FETCH_ALLOW_LOCAL` > false
pub(super) fn prepare_web_fetch_config(
&self,
) -> xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig {
@ -1507,10 +1507,6 @@ impl MvpAgent {
resident_roster_titles: RefCell::new(HashMap::new()),
initialize_request: OnceLock::new(),
gateway,
subagent_model_overrides: cfg.subagent_model_overrides.clone(),
subagent_toggle: cfg.subagent_toggle.clone(),
subagent_roles: cfg.subagent_roles.clone(),
subagent_personas: cfg.subagent_personas.clone(),
launch_cwd: std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from(".")),
launch_dir_trust: std::cell::OnceCell::new(),
@ -1519,11 +1515,6 @@ impl MvpAgent {
cfg.plugins.cli_plugin_dirs.clone(),
),
plugin_registry_initialized: std::cell::Cell::new(false),
persona_io_summaries: cfg
.subagent_personas
.iter()
.map(|(name, p)| p.render_io_summary(name))
.collect(),
models_manager,
chat_modes: {
let chat_modes = crate::agent::chat_modes::ChatModesManager::new(
@ -3182,6 +3173,7 @@ impl MvpAgent {
.cfg
.borrow()
.resolve_compaction_verbatim_input();
let compaction_tool_choice = self.cfg.borrow().resolve_compaction_tool_choice();
let two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled();
let auto_update = self.cfg.borrow().cli.auto_update;
let client_type = *self.client_type.borrow();
@ -3384,7 +3376,7 @@ impl MvpAgent {
let laziness_debug_log_for_spawn = self.cfg.borrow().laziness_debug_log.clone();
let respect_gitignore = self.cfg.borrow().respect_gitignore;
let path_not_found_hints = self.cfg.borrow().path_not_found_hints;
let subagent_toggle = self.subagent_toggle.clone();
let subagent_toggle = self.cfg.borrow().subagent_toggle.clone();
let handle_display_cwd = prompt_display_cwd.clone();
let auth_manager = Some(self.auth_manager.clone());
let bash_params_json = {
@ -3527,6 +3519,7 @@ impl MvpAgent {
.as_ref()
.and_then(|m| m.get("x.ai/gitHeadChanged"))
.and_then(|v| v.as_bool());
let session_cwd = std::path::Path::new(&session_info.cwd);
let fs_watch_caps = crate::session::fs_watch::FsWatchCapabilities::resolve(crate::session::fs_watch::CapabilityInputs {
client_notify: fs_notify_config.is_some(),
hunk_tracking: hunk_plan.enabled(),
@ -3561,6 +3554,7 @@ impl MvpAgent {
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client.clone(),
@ -3608,7 +3602,7 @@ impl MvpAgent {
client_hooks,
prompt_display_cwd,
subagent_toggle,
self.persona_io_summaries.clone(),
Vec::new(),
xai_grok_agent::prompt::context::PromptAudience::Primary,
None,
None,
@ -3618,7 +3612,6 @@ impl MvpAgent {
path_not_found_hints,
tool_params_json,
{
let session_cwd = std::path::Path::new(&session_info.cwd);
let disk_cfg = crate::config::resolve_effective_plugins_config(
session_cwd,
)

View file

@ -794,20 +794,6 @@ pub struct MvpAgent {
/// notification has `Next` priority. Drained by the session turn loop
/// (`inject_pending_monitor_events`) into a hidden synthetic user message.
monitor_event_buffer: xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer,
/// Per-subagent model ID overrides from config.toml `[subagents.models]`.
/// Populated from `SubagentsConfig.models` during `with_models()`.
subagent_model_overrides: std::collections::HashMap<String, String>,
/// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`.
/// Populated from `SubagentsConfig.toggle` during `with_models()`.
subagent_toggle: std::collections::HashMap<String, bool>,
subagent_roles: std::collections::HashMap<
String,
xai_grok_subagent_resolution::config::SubagentRole,
>,
subagent_personas: std::collections::HashMap<
String,
xai_grok_subagent_resolution::config::SubagentPersona,
>,
/// The process launch directory, captured once at construction so the
/// deferred launch-dir init paths share one source of truth instead of each
/// re-calling `std::env::current_dir()` (which could drift if the process
@ -827,7 +813,6 @@ pub struct MvpAgent {
/// the first session-creating call via [`Self::ensure_plugin_registry`];
/// this flag keeps that to a single discovery walk.
plugin_registry_initialized: std::cell::Cell<bool>,
persona_io_summaries: Vec<String>,
/// Single-flight guard for the proactive bundle sync background task.
///
/// `maybe_sync_bundle_in_background` is invoked from each post-auth path
@ -1176,6 +1161,9 @@ fn inject_proxy_headers(
.map(String::from)
.unwrap_or_else(|| xai_grok_version::VERSION.to_string())
});
headers
.entry("x-grok-client-identifier".to_string())
.or_insert_with(crate::http::process_client_identifier);
if crate::util::is_cli_chat_proxy_url(base_url) {
headers
.entry("X-XAI-Token-Auth".to_string())
@ -1264,6 +1252,7 @@ mod session_lifecycle;
mod subagent_coordinator;
mod agent_ops;
mod acp_agent;
pub(crate) use session_lifecycle::RegistrySnapshot;
pub(super) use super::ext_parsers;
/// Emit the `auth.lifecycle` login span with optional user id and error
/// category. Named `auth.lifecycle` (not `auth`) to avoid colliding with the

View file

@ -402,4 +402,47 @@ impl MvpAgent {
.await
.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();
RegistrySnapshot {
sessions: self.sessions.borrow().len(),
session_threads: self.session_threads.borrow().len(),
dispatch_locks: self.dispatch_locks.borrow().len(),
session_turn_numbers: self.session_turn_numbers.borrow().len(),
permission_event_receivers: self.permission_event_receivers.borrow().len(),
model_unavailable_sessions: self.model_unavailable_sessions.borrow().len(),
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,
workspace_bindings: self
.workspace_ops
.borrow()
.as_ref()
.and_then(|ops| ops.workspace_handle().map(|h| h.session_count())),
}
}
}
/// Field names are the wire contract of `x.ai/debug/agent`'s `registries`
/// object; each maps to the same-named registry.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RegistrySnapshot {
pub sessions: usize,
pub session_threads: usize,
pub dispatch_locks: usize,
pub session_turn_numbers: usize,
pub permission_event_receivers: usize,
pub model_unavailable_sessions: usize,
pub session_live_state: usize,
pub session_index_claims: usize,
pub require_gateway_sessions: usize,
pub subagent_pending: usize,
pub subagent_active: usize,
pub subagent_completed: usize,
pub workspace_bindings: Option<usize>,
}

View file

@ -268,14 +268,17 @@ impl MvpAgent {
ps.and_then(|h| h.allowed_subagent_types.clone()),
)
};
let cli_agent_names: Vec<String> = {
let (cli_agent_names, subagent_toggle) = {
let cfg = self.cfg.borrow();
cfg.cli_agents.iter().map(|d| d.name.clone()).collect()
(
cfg.cli_agents.iter().map(|d| d.name.clone()).collect(),
cfg.subagent_toggle.clone(),
)
};
crate::agent::subagent::SubagentValidationContext {
parent_cwd,
plugin_registry: self.plugin_registry_handle.snapshot(),
subagent_toggle: self.subagent_toggle.clone(),
subagent_toggle,
allowed_subagent_types,
cli_agent_names,
}
@ -432,6 +435,23 @@ impl MvpAgent {
}
None => (None, None),
};
let project_trusted = crate::agent::folder_trust::project_scope_allowed(&parent_cwd);
let (base_roles, base_personas, subagent_model_overrides, subagent_toggle) = {
let cfg = self.cfg.borrow();
(
cfg.subagent_roles.clone(),
cfg.subagent_personas.clone(),
cfg.subagent_model_overrides.clone(),
cfg.subagent_toggle.clone(),
)
};
let (subagent_roles, subagent_personas) =
crate::config::SubagentsConfig::effective_definition_maps(
&base_roles,
&base_personas,
&parent_cwd,
project_trusted,
);
Some(crate::agent::subagent::SubagentSpawnContext {
lsp: parent_lsp,
gateway: self.gateway.clone(),
@ -484,11 +504,10 @@ impl MvpAgent {
parent_chat_state,
parent_max_turns,
available_models,
subagent_model_overrides: self.subagent_model_overrides.clone(),
subagent_toggle: self.subagent_toggle.clone(),
subagent_roles: self.subagent_roles.clone(),
subagent_personas: self.subagent_personas.clone(),
persona_io_summaries: self.persona_io_summaries.clone(),
subagent_model_overrides,
subagent_toggle,
subagent_roles,
subagent_personas,
disable_web_search: self.cfg.borrow().disable_web_search,
todo_gate: self.cfg.borrow().todo_gate,
remote_settings: self.cfg.borrow().remote_settings.clone(),
@ -549,11 +568,11 @@ impl MvpAgent {
parent_skills: None,
parent_skills_config: self.cfg.borrow().skills.clone(),
parent_compat: self.cfg.borrow().compat_resolved,
auto_wake_delivered: {
task_completion_reservations: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.and_then(|h| h.tool_context.auto_wake_delivered.clone())
.and_then(|h| h.tool_context.task_completion_reservations.clone())
},
synthetic_trace_tx: {
let sessions = self.sessions.borrow();

View file

@ -2517,6 +2517,45 @@ async fn prepare_image_gen_config_fails_open_without_auth() {
"no resolved auth ⇒ fail open (tools not tier-restricted)"
);
}
/// The imagine tools bypass cli-chat-proxy (direct API calls), so the server
/// can only scope the coding data-retention opt-out (`/privacy opt-out`) to
/// Build traffic via the `x-grok-client-identifier` header. If this header is
/// dropped, opted-out users' imagine prompts are logged/retained server-side.
#[tokio::test(flavor = "current_thread")]
async fn prepare_image_gen_config_sends_client_identifier_header() {
use xai_grok_tools::implementations::grok_build::image_gen::ImageGenConfig;
let agent = build_minimal_agent_for_tests();
agent.sampling_config.borrow_mut().api_key = Some("test-key".to_string());
let ImageGenConfig::Enabled { extra_headers, .. } = agent.prepare_image_gen_config() else {
panic!("expected Enabled");
};
assert_eq!(
extra_headers
.get("x-grok-client-identifier")
.map(String::as_str),
Some(crate::http::process_client_identifier().as_str()),
"imagine API calls must carry the client identifier so the server \
applies the coding ZDR opt-out to Build traffic"
);
}
/// Same contract for video generation (also a direct API call).
#[tokio::test(flavor = "current_thread")]
async fn prepare_video_gen_config_sends_client_identifier_header() {
use xai_grok_tools::implementations::grok_build::video_gen::VideoGenConfig;
let agent = build_minimal_agent_for_tests();
agent.sampling_config.borrow_mut().api_key = Some("test-key".to_string());
let VideoGenConfig::Enabled { extra_headers, .. } = agent.prepare_video_gen_config() else {
panic!("expected Enabled");
};
assert_eq!(
extra_headers
.get("x-grok-client-identifier")
.map(String::as_str),
Some(crate::http::process_client_identifier().as_str()),
"video gen API calls must carry the client identifier so the server \
applies the coding ZDR opt-out to Build traffic"
);
}
#[tokio::test]
async fn data_collection_enabled_for_normal_user() {
let agent = build_agent_with_auth(crate::auth::GrokAuth::test_default());
@ -3642,12 +3681,148 @@ fn repo_with_project_mcp_server() -> tempfile::TempDir {
.unwrap();
tmp
}
fn write_project_subagent_definitions(cwd: &std::path::Path) {
let roles = cwd.join(".grok/roles");
let personas = cwd.join(".grok/personas");
std::fs::create_dir_all(&roles).unwrap();
std::fs::create_dir_all(&personas).unwrap();
std::fs::write(roles.join("probe.toml"), "description = \"Project role\"").unwrap();
std::fs::write(
personas.join("probe.toml"),
"instructions = \"Project persona\"",
)
.unwrap();
}
fn folder_trust_on() -> crate::util::config::RemoteSettings {
crate::util::config::RemoteSettings {
folder_trust_enabled: Some(true),
..Default::default()
}
}
#[test]
#[serial_test::serial]
fn subagent_spawn_context_reloads_project_definitions_after_trust_changes() {
let repo = tempfile::tempdir().unwrap();
git2::Repository::init(repo.path()).unwrap();
write_project_subagent_definitions(repo.path());
run_local_for_bridge_test(|| async {
let (agent, _rx) = build_agent_with_gateway_rx();
let sid = acp::SessionId::new("roles-personas-trust-transition");
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo.path().display().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
{
let mut cfg = agent.cfg.borrow_mut();
cfg.subagent_roles.insert(
"refreshed".into(),
xai_grok_subagent_resolution::config::SubagentRole {
description: "Refreshed user role".into(),
source_dir: Some(repo.path().join("user-roles")),
..Default::default()
},
);
cfg.subagent_model_overrides
.insert("probe".into(), "refreshed-model".into());
cfg.subagent_toggle.insert("probe".into(), false);
}
crate::agent::folder_trust::record_for_test(repo.path(), false);
let untrusted = agent.build_subagent_spawn_context(sid.0.as_ref());
assert!(!untrusted.subagent_roles.contains_key("probe"));
assert!(!untrusted.subagent_personas.contains_key("probe"));
assert_eq!(
untrusted
.subagent_roles
.get("refreshed")
.map(|role| role.description.as_str()),
Some("Refreshed user role")
);
assert_eq!(
untrusted
.subagent_model_overrides
.get("probe")
.map(String::as_str),
Some("refreshed-model")
);
assert_eq!(untrusted.subagent_toggle.get("probe"), Some(&false));
crate::agent::folder_trust::record_for_test(repo.path(), true);
let trusted = agent.build_subagent_spawn_context(sid.0.as_ref());
assert_eq!(
trusted
.subagent_roles
.get("probe")
.map(|role| role.description.as_str()),
Some("Project role")
);
assert!(trusted.subagent_personas.contains_key("probe"));
crate::agent::folder_trust::record_for_test(repo.path(), false);
let revoked = agent.build_subagent_spawn_context(sid.0.as_ref());
assert!(!revoked.subagent_roles.contains_key("probe"));
assert!(!revoked.subagent_personas.contains_key("probe"));
});
}
/// End-to-end gate wiring: project `.grok/roles` / `personas` alone must drive
/// real `resolve_and_record` untrusted (not a forced `record_for_test` verdict),
/// keep project defs out of Task spawn context, then re-admit them after grant.
#[test]
#[serial_test::serial]
fn project_roles_personas_gated_via_resolve_and_record_chain() {
use xai_grok_test_support::EnvGuard;
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("GROK_HOME", home.path());
let _sim = EnvGuard::set(xai_grok_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("GROK_FOLDER_TRUST");
let repo = tempfile::tempdir().unwrap();
git2::Repository::init(repo.path()).unwrap();
write_project_subagent_definitions(repo.path());
run_local_for_bridge_test(|| async {
let (agent, _rx) = build_agent_with_gateway_rx();
let sid = acp::SessionId::new("roles-personas-resolve-chain");
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo.path().display().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let allowed = crate::agent::folder_trust::resolve_and_record(
repo.path(),
Some(&folder_trust_on()),
false,
);
assert!(
!allowed,
"roles/personas markers alone must resolve untrusted without a grant"
);
assert!(
!crate::agent::folder_trust::project_scope_allowed(repo.path()),
"cached verdict after resolve_and_record must stay untrusted"
);
let untrusted = agent.build_subagent_spawn_context(sid.0.as_ref());
assert!(
!untrusted.subagent_roles.contains_key("probe"),
"untrusted: project role must stay out of spawn context"
);
assert!(
!untrusted.subagent_personas.contains_key("probe"),
"untrusted: project persona must stay out of spawn context"
);
crate::agent::folder_trust::grant_folder_trust(repo.path());
let allowed = crate::agent::folder_trust::resolve_and_record(
repo.path(),
Some(&folder_trust_on()),
false,
);
assert!(allowed, "store-granted folder must resolve trusted");
let trusted = agent.build_subagent_spawn_context(sid.0.as_ref());
assert_eq!(
trusted
.subagent_roles
.get("probe")
.map(|role| role.description.as_str()),
Some("Project role")
);
assert!(
trusted.subagent_personas.contains_key("probe"),
"trusted: project persona must enter spawn context after grant"
);
});
}
/// Pull the next `x.ai/folder_trust/request` reverse-request off the gateway and
/// answer it with `outcome`. Returns the request's decoded params.
async fn answer_folder_trust_request(
@ -4559,3 +4734,5 @@ mod soft_default_settings_emit {
.await;
}
}
#[cfg(feature = "dhat-heap")]
mod dhat_soak;

View file

@ -0,0 +1,101 @@
//! Heap-leak test for the session lifecycle: create and remove many sessions,
//! then fail if heap memory grows per session. Run:
//! leader_session_lifecycle_heap_steady_state -- --ignored --nocapture
use super::*;
use xai_grok_workspace::permission::PermissionEvent;
const MAX_BLOCKS_PER_SESSION: f64 = 0.5;
const MAX_BYTES_PER_SESSION: f64 = 1024.0;
/// Creates the per-session state that `remove_session` must clean up, then
/// removes the session. A full `SessionHandle` would allocate so much
/// unrelated memory that a small leak would be lost in the noise.
fn populate_and_evict(agent: &MvpAgent, i: usize) {
let sid = acp::SessionId::new(format!("soak-{i}"));
{
let ops = agent.workspace_ops.borrow();
let ops = ops.as_ref().expect("test installs workspace ops");
let toolset = std::sync::Arc::new(
xai_grok_tools::registry::types::FinalizedToolset::empty_for_test(),
);
ops.bind_local_session(
sid.0.as_ref(),
std::env::temp_dir(),
xai_hunk_tracker::HunkTrackerHandle::noop(),
toolset,
None,
)
.expect("bind_local_session must succeed");
}
let (_ptx, prx) = tokio::sync::mpsc::unbounded_channel::<PermissionEvent>();
agent
.permission_event_receivers
.borrow_mut()
.insert(sid.clone(), prx);
agent
.session_turn_numbers
.borrow_mut()
.insert(sid.clone(), i as u64);
agent.model_unavailable_sessions.borrow_mut().insert(
sid.0.to_string(),
acp::ModelId::new(std::sync::Arc::from("gone-model")),
);
agent.remove_session(&sid);
}
/// Waits for background tasks to finish before reading heap stats.
async fn quiesce() {
const YIELD_ROUNDS: usize = 50;
const SETTLE: std::time::Duration = std::time::Duration::from_millis(50);
for _ in 0..YIELD_ROUNDS {
tokio::task::yield_now().await;
}
tokio::time::sleep(SETTLE).await;
for _ in 0..YIELD_ROUNDS {
tokio::task::yield_now().await;
}
}
/// Creating and removing N sessions must not grow the heap.
///
/// Only one `dhat::Profiler` can exist at a time, and the test harness runs
/// tests in parallel, so keep this the only test that creates one.
#[test]
fn leader_session_lifecycle_heap_steady_state() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
*agent.workspace_ops.borrow_mut() = Some(xai_grok_workspace::WorkspaceOps::for_test());
let _profiler = dhat::Profiler::builder().testing().build();
const WARMUP: usize = 16;
const MEASURE: usize = 256;
for i in 0..WARMUP {
populate_and_evict(&agent, i);
}
quiesce().await;
let before = dhat::HeapStats::get();
for i in WARMUP..(WARMUP + MEASURE) {
populate_and_evict(&agent, i);
}
quiesce().await;
let after = dhat::HeapStats::get();
let d_blocks = after.curr_blocks as i64 - before.curr_blocks as i64;
let d_bytes = after.curr_bytes as i64 - before.curr_bytes as i64;
let blocks_per = d_blocks as f64 / MEASURE as f64;
let bytes_per = d_bytes as f64 / MEASURE as f64;
eprintln!(
"DHAT_SOAK_SUMMARY {}",
serde_json::json!({ "warmup_sessions" : WARMUP,
"measured_sessions" : MEASURE, "before_blocks" : before.curr_blocks,
"before_bytes" : before.curr_bytes, "after_blocks" : after.curr_blocks,
"after_bytes" : after.curr_bytes, "blocks_per_session" : blocks_per,
"bytes_per_session" : bytes_per, "max_blocks_per_session" :
MAX_BLOCKS_PER_SESSION, "max_bytes_per_session" : MAX_BYTES_PER_SESSION,
"pass" : blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per <
MAX_BYTES_PER_SESSION })
);
assert!(
blocks_per < MAX_BLOCKS_PER_SESSION,
"block-count leak: {blocks_per:.3} blocks/session retained ({d_blocks} over {MEASURE} cycles) exceeds the {MAX_BLOCKS_PER_SESSION} gate"
);
assert!(
bytes_per < MAX_BYTES_PER_SESSION,
"byte leak: {bytes_per:.1} bytes/session retained ({d_bytes} over {MEASURE} cycles) exceeds the {MAX_BYTES_PER_SESSION} gate"
);
});
}

View file

@ -302,6 +302,10 @@ impl SubagentCoordinator {
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 {

View file

@ -1081,6 +1081,7 @@ pub(crate) async fn handle_subagent_request(
xai_grok_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
xai_chat_state::CompactionMode::Summary,
ctx.resolve_compaction_verbatim_input(),
ctx.resolve_compaction_tool_choice(),
false,
None,
None,
@ -1156,7 +1157,7 @@ pub(crate) async fn handle_subagent_request(
ctx.client_hooks.clone(),
None,
std::collections::HashMap::new(),
ctx.persona_io_summaries.clone(),
Vec::new(),
xai_grok_agent::prompt::context::PromptAudience::Subagent,
effective_runtime.role_prompt.clone(),
None,
@ -1304,6 +1305,7 @@ pub(crate) async fn handle_subagent_request(
traceparent: xai_file_utils::trace_context::current_traceparent(),
json_schema: None,
send_now: false,
admission: None,
respond_to: prompt_tx,
persist_ack: None,
parsed_prompt_tx: None,
@ -2005,7 +2007,7 @@ pub(crate) async fn handle_subagent_request(
&request.id,
&result,
&request,
&ctx.auto_wake_delivered,
&ctx.task_completion_reservations,
ctx.parent_cmd_tx.as_ref(),
&ctx.task_output_tool_name,
&ctx.synthetic_trace_tx,

View file

@ -243,9 +243,6 @@ pub(crate) struct SubagentSpawnContext {
/// Subagent personas config for persona/SOUL layering.
pub subagent_personas:
std::collections::HashMap<String, xai_grok_subagent_resolution::config::SubagentPersona>,
/// Pre-rendered persona IO summaries for the task tool description.
/// Threaded through to child sessions for recursive persona discovery.
pub persona_io_summaries: Vec<String>,
/// Parent session's ChatStateHandle — used to read the actual live
/// sampling config and credentials from the parent session actor (async).
/// Cheap Clone (mpsc sender). `None` when parent SessionHandle not found.
@ -348,9 +345,9 @@ pub(crate) struct SubagentSpawnContext {
/// Parent's resolved vendor-compat config, inherited by the child so its
/// skills / rules / AGENTS.md discovery honors the same vendor toggles.
pub parent_compat: xai_grok_tools::types::compat::CompatConfig,
/// Shared set of IDs delivered via auto-wake synthetic prompts.
pub auto_wake_delivered:
Option<xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds>,
/// Shared completion reservations held by auto-wake prompts.
pub task_completion_reservations:
Option<xai_grok_tools::reminders::task_completion::TaskCompletionReservations>,
/// Channel for requesting trace uploads for synthetic auto-wake turns.
pub synthetic_trace_tx:
Option<tokio::sync::mpsc::UnboundedSender<crate::upload::turn::SyntheticTurnTraceRequest>>,
@ -422,6 +419,18 @@ impl SubagentSpawnContext {
.resolve()
.value
}
pub fn resolve_compaction_tool_choice(&self) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
crate::agent::config::env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE)
.as_deref(),
self.agent_config
.as_ref()
.and_then(|c| c.features.compaction_tool_choice.as_deref()),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Whether a completed subagent's worktree is snapshotted into a durable ref
/// and its directory deleted. Resolution mirrors the other subagent gates
/// (env > config > remote settings > default). Default `false` so it ships dark;
@ -1955,7 +1964,7 @@ fn cancellation_error_message(
/// result has not already been consumed (via block-wait or explicit kill).
/// Also suppressed while the parent's goal loop is active (mirrors the bash
/// gate in `notification_bridge`); skipping the inject also skips the
/// `auto_wake_delivered.insert`, leaving surfaces 2/3 free to drain it.
/// the completion reservation, leaving surfaces 2/3 free to drain it.
/// `parent_channel_open` folds `inject_subagent_completed_prompt`'s own
/// no-channel bail into the decision, so the `will_wake` stamped on the
/// completion notification can never promise a wake the inject won't do.
@ -1992,7 +2001,9 @@ fn inject_subagent_completed_prompt(
subagent_id: &str,
result: &SubagentResult,
request: &SubagentRequest,
auto_wake_delivered: &Option<xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds>,
task_completion_reservations: &Option<
xai_grok_tools::reminders::task_completion::TaskCompletionReservations,
>,
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
task_output_tool_name: &str,
synthetic_trace_tx: &Option<
@ -2002,8 +2013,8 @@ fn inject_subagent_completed_prompt(
let Some(cmd_tx) = parent_cmd_tx else {
return;
};
if let Some(auto_wake) = auto_wake_delivered {
auto_wake.insert(subagent_id.to_string());
if let Some(reservations) = task_completion_reservations {
reservations.reserve(subagent_id.to_string());
}
let summary = SubagentCompletionSummary {
subagent_id: subagent_id.to_string(),
@ -2032,21 +2043,30 @@ fn inject_subagent_completed_prompt(
};
let (respond_to, completion_rx) = tokio::sync::oneshot::channel();
let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(wrapped))];
let _ = cmd_tx.send(SessionCommand::Prompt {
prompt_id: prompt_id.clone(),
prompt_blocks,
prompt_mode: crate::session::plan_mode::PromptMode::Agent,
artifact_upload_ctx: None,
client_identifier: None,
screen_mode: None,
verbatim: true,
traceparent: None,
json_schema: None,
send_now: false,
respond_to,
persist_ack: None,
parsed_prompt_tx: None,
});
if cmd_tx
.send(SessionCommand::Prompt {
prompt_id: prompt_id.clone(),
prompt_blocks,
prompt_mode: crate::session::plan_mode::PromptMode::Agent,
artifact_upload_ctx: None,
client_identifier: None,
screen_mode: None,
verbatim: true,
traceparent: None,
json_schema: None,
send_now: false,
admission: None,
respond_to,
persist_ack: None,
parsed_prompt_tx: None,
})
.is_err()
{
if let Some(reservations) = task_completion_reservations {
reservations.release(subagent_id);
}
return;
}
if let Some(trace_tx) = synthetic_trace_tx {
let _ = trace_tx.send(crate::upload::turn::SyntheticTurnTraceRequest {
session_id: acp::SessionId::new(request.parent_session_id.clone()),

View file

@ -556,7 +556,7 @@ fn should_auto_wake_subagent_suppressed_by_block_waited_or_killed() {
}
/// A goal loop active in the parent suppresses the subagent
/// auto-wake synthetic prompt — the structural sibling of the bash gate.
/// Skipping the inject here also skips `auto_wake_delivered.insert`, so the
/// Skipping the inject here also skips its completion reservation, so the
/// per-tool-call / between-turn surfaces stay free to drain the completion.
#[test]
fn should_auto_wake_subagent_suppressed_by_goal_loop() {
@ -587,7 +587,7 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest {
}
/// Behavior-level: the action half of the subagent auto-wake.
/// When the gate lets it run, `inject_subagent_completed_prompt` sends the
/// synthetic `Prompt` to the parent AND marks the id auto-wake-delivered.
/// synthetic `Prompt` to the parent and reserves its completion ID.
/// Paired with `should_auto_wake_subagent_suppressed_by_goal_loop`, this
/// proves the full Gap-1 contract on the subagent surface: goal active →
/// gate false → this never runs (no prompt, not marked, so surfaces 2/3
@ -595,7 +595,7 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest {
#[test]
fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
let auto_wake = xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds::default();
let reservations = xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default();
let request = auto_wake_test_request("sa-1");
let result = SubagentResult {
success: true,
@ -607,7 +607,7 @@ fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
"sa-1",
&result,
&request,
&Some(auto_wake.clone()),
&Some(reservations.clone()),
Some(&cmd_tx),
"get_command_or_subagent_output",
&None,
@ -619,7 +619,36 @@ fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
}
_ => panic!("expected SessionCommand::Prompt"),
}
assert_eq!(auto_wake.snapshot(), vec!["sa-1".to_string()]);
assert_eq!(reservations.snapshot(), vec!["sa-1".to_string()]);
}
#[test]
fn inject_subagent_completed_prompt_releases_reservation_when_parent_closed() {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
drop(cmd_rx);
let reservations = xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default();
reservations.reserve("sa-closed".into());
let (trace_tx, mut trace_rx) = mpsc::unbounded_channel();
inject_subagent_completed_prompt(
"sa-closed",
&SubagentResult {
success: true,
subagent_id: "sa-closed".into(),
child_session_id: "sa-closed".into(),
..Default::default()
},
&auto_wake_test_request("sa-closed"),
&Some(reservations.clone()),
Some(&cmd_tx),
"get_command_or_subagent_output",
&Some(trace_tx),
);
assert!(
reservations.contains("sa-closed"),
"send failure must release only the reservation acquired by this attempt"
);
reservations.release("sa-closed");
assert!(! reservations.contains("sa-closed"));
assert!(trace_rx.try_recv().is_err());
}
#[test]
fn mark_explicitly_killed_sets_flag_on_completed() {