Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -241,31 +241,42 @@ fn internal_reload_request_line(id: &str, method: &str, params: serde_json::Valu
/// Start a skills file watcher and wire it to inject `x.ai/internal/reload_skills`
/// messages into the shared ACP incoming stream when SKILL.md files change on disk.
///
/// Returns the watcher guard (must be kept alive for the lifetime of the session)
/// or `None` if no directories could be watched.
fn spawn_skills_file_watcher<W>(
acp_incoming_tx: &Arc<TokioMutex<W>>,
skills_paths: &[String],
) -> Option<crate::config::watcher::SkillsFileWatcher>
) -> Option<tokio::task::JoinHandle<()>>
where
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let cwd = std::env::current_dir().unwrap_or_default();
let workspace_user_dir = xai_grok_agent::prompt::workspace_user::optional_workspace_user_dir();
let (watcher, mut skills_rx) = crate::config::watcher::SkillsFileWatcher::start(
let (mut watcher, mut skills_rx) = crate::config::watcher::SkillsFileWatcher::start(
Some(cwd.as_path()),
workspace_user_dir.as_deref(),
skills_paths,
)?;
let skills_tx = acp_incoming_tx.clone();
tokio::spawn(async move {
while skills_rx.recv().await.is_some() {
info!("Skill directory changed on disk, reloading skills for all sessions");
let line = internal_reload_request_line(
"skills-reload",
"x.ai/internal/reload_skills",
serde_json::json!({}),
);
let task = tokio::spawn(async move {
while let Some(change) = skills_rx.recv().await {
let created_discovery_dir = watcher.refresh_new_discovery_dirs();
let (id, method) = match change {
crate::config::watcher::DiscoveryChange::Skills if !created_discovery_dir => {
info!("Skill directory changed on disk, reloading skills for all sessions");
("skills-reload", "x.ai/internal/reload_skills")
}
crate::config::watcher::DiscoveryChange::Skills => {
info!("Discovery directory created on disk, reloading skills and workflows");
("skills-reload", "x.ai/internal/reload_skills")
}
crate::config::watcher::DiscoveryChange::Workflows => {
info!(
"Workflow directory changed on disk, re-advertising commands for all sessions"
);
("workflows-reload", "x.ai/internal/reload_workflows")
}
};
let line = internal_reload_request_line(id, method, serde_json::json!({}));
let mut tx = skills_tx.lock().await;
if let Err(e) = tx.write_all(line.as_bytes()).await {
warn!(
@ -275,7 +286,7 @@ where
}
}
});
Some(watcher)
Some(task)
}
/// Register the process-lifetime runtime so shared filesystem watchers

View file

@ -1,4 +1,7 @@
use crate::agent::auth_method::ModelByok;
use crate::agent::model_providers::{
ModelProviderConfig, auth_config_issues, model_provider_auth_name, parse_model_providers,
};
use crate::auth::{AuthManager, GrokComConfig, OidcAuthConfig};
use crate::remote::DEFAULT_CONTEXT_WINDOW;
use crate::{config::StorageMode, sampling::ApiBackend, tools::config::ShellToolsetConfig};
@ -947,7 +950,30 @@ impl PluginsConfig {
/// Feedback submission configuration (`[feedback]` in config.toml).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct FeedbackConfig {}
pub struct FeedbackConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<FeedbackUserConfig>,
}
/// Self-reported feedback author identity (never used for authorization).
/// Merged only from trusted config tiers, so a cloned repo can't inject the
/// `command` escape hatch.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct FeedbackUserConfig {
/// Sources tried in order for the name. `os_user` yields the OS user name;
/// any other entry is a literal (`$VAR` expanded at load).
pub name: Vec<String>,
/// Sources tried in order for the email. `git_email` yields the global git
/// email; any other entry is a literal (`$VAR` expanded at load) needing `@`.
pub email: Vec<String>,
/// Fallback domain for `<name>@<domain>` when no `email` source resolves.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_domain: Option<String>,
/// Optional `sh -c` script printing `{"name","email"}` JSON; its fields win
/// over the lists above, with per-field fallback. Trusted config tiers only.
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CompactionConfig {
@ -1121,6 +1147,13 @@ pub struct WorktreePoolConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub parallelism: Option<usize>,
}
/// `[worktree]` section from config.toml (auto-GC policy lives under `auto_gc`).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WorktreeConfigSection {
#[serde(default)]
pub auto_gc: crate::util::config::WorktreeAutoGcSettings,
}
/// `[sandbox]` section from config.toml.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
@ -1288,19 +1321,22 @@ pub struct Config {
/// `[goal]` section: canonical `/goal` configuration. See [`GoalConfig`].
#[serde(default)]
pub goal: GoalConfig,
#[serde(default)]
pub workflows: WorkflowsConfig,
/// `[doom_loop_recovery]` section: the shared settings struct — ONE type
/// serves this TOML table and the remote remote settings `doom_loop_recovery`
/// object. See [`crate::util::config::DoomLoopRecoverySettings`].
#[serde(default)]
pub doom_loop_recovery: crate::util::config::DoomLoopRecoverySettings,
/// `[worktree]` section (currently `[worktree.auto_gc]` only).
#[serde(default)]
pub worktree: WorktreeConfigSection,
/// `[auto_mode]` section: Auto permission-mode configuration. See [`AutoModeConfig`].
#[serde(default)]
pub auto_mode: AutoModeConfig,
/// `[model.*]` overrides from config.toml. Resolve via `resolve_model_list()`.
#[serde(skip)]
pub config_models: IndexMap<String, ConfigModelOverride>,
/// Warnings from `[model.*]` and `[auth_provider.*]` parsing; surfaced by
/// `grok inspect`.
#[serde(skip)]
pub config_warnings: Vec<super::config_model_override_parse::ConfigWarning>,
pub grok_com_config: GrokComConfig,
@ -1308,6 +1344,8 @@ pub struct Config {
/// [`parse_auth_providers`] from trusted config layers only.
#[serde(skip)]
pub auth_providers: IndexMap<String, crate::auth::AuthProviderConfig>,
#[serde(skip)]
pub model_providers: IndexMap<String, ModelProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcuts: Option<toml::Value>,
/// Written by the client via `config_toml_edit`; absorbed so it isn't
@ -1729,12 +1767,15 @@ impl Default for Config {
let mut cfg = Self {
features: Features::default(),
goal: GoalConfig::default(),
workflows: WorkflowsConfig::default(),
doom_loop_recovery: crate::util::config::DoomLoopRecoverySettings::default(),
worktree: WorktreeConfigSection::default(),
auto_mode: AutoModeConfig::default(),
config_models: IndexMap::new(),
config_warnings: Vec::new(),
grok_com_config: GrokComConfig::default(),
auth_providers: IndexMap::new(),
model_providers: IndexMap::new(),
shortcuts: None,
hints: None,
ui: UiConfig::default(),
@ -1858,41 +1899,12 @@ fn parse_auth_providers(
"unrecognized key; field ignored".to_owned(),
));
}
if !provider.is_usable() {
for (field, kind, reason) in auth_config_issues(&provider) {
warnings.push(ConfigWarning::auth_provider(
name,
Some("command"),
ConfigWarningKind::InvalidValue,
"missing or empty command; referencing models resolve \
with no credential"
.to_owned(),
));
}
let skew = crate::auth::PROVIDER_TOKEN_EXPIRY_SKEW_SECS;
if provider.token_ttl_secs.is_some_and(|ttl| ttl <= skew) {
warnings.push(ConfigWarning::auth_provider(
name,
Some("token_ttl_secs"),
ConfigWarningKind::InvalidValue,
format!(
"at or below the {skew}s refresh margin; the command will \
run before every turn"
),
));
}
if let Some(timeout) = provider.timeout_secs
&& !(1..=crate::auth::PROVIDER_TIMEOUT_CEILING_SECS).contains(&timeout)
{
let ceiling = crate::auth::PROVIDER_TIMEOUT_CEILING_SECS;
warnings.push(ConfigWarning::auth_provider(
name,
Some("timeout_secs"),
ConfigWarningKind::InvalidValue,
if timeout == 0 {
"below the 1 second minimum; clamped to 1".to_owned()
} else {
format!("above the {ceiling}s maximum; clamped to {ceiling}")
},
Some(field),
kind,
reason,
));
}
providers.insert(name.clone(), provider);
@ -1969,7 +1981,29 @@ impl Config {
models: config_models,
warnings: config_warnings,
} = super::config_model_override_parse::parse_model_overrides(raw_config);
let (auth_providers, auth_provider_warnings) = parse_auth_providers(raw_config);
let (mut auth_providers, auth_provider_warnings) = parse_auth_providers(raw_config);
let (model_providers, mut model_provider_warnings) = parse_model_providers(raw_config);
for (id, provider) in &model_providers {
if let Some(auth) = &provider.auth {
let synthetic = model_provider_auth_name(id);
if auth_providers.contains_key(&synthetic) {
model_provider_warnings
.push(
super::config_model_override_parse::ConfigWarning::model_provider(
id,
Some("auth"),
super::config_model_override_parse::ConfigWarningKind::ConflictingFields,
format!(
"inline auth overwrites a hand-written \
[auth_provider.\"{synthetic}\"]; the `model_provider:` prefix is \
a reserved namespace"
),
),
);
}
auth_providers.insert(synthetic, auth.clone());
}
}
let mut base = toml::Value::try_from(Self::default()).map_err(|e| e.to_string())?;
if let toml::Value::Table(ref mut t) = base {
t.remove("model");
@ -1978,6 +2012,7 @@ impl Config {
if let toml::Value::Table(ref mut t) = raw_without_model_sections {
t.remove("model");
t.remove("auth_provider");
t.remove("model_providers");
}
crate::config::deep_merge_toml(&mut base, &raw_without_model_sections);
let (mut config, user_unused) =
@ -1991,12 +2026,19 @@ impl Config {
config.config_models = config_models;
config.config_warnings = config_warnings;
config.auth_providers = auth_providers;
config.model_providers = model_providers;
config.config_warnings.extend(auth_provider_warnings);
config.config_warnings.extend(model_provider_warnings);
let declared_provider_names: std::collections::HashSet<&str> = raw_config
.get("auth_provider")
.and_then(toml::Value::as_table)
.map(|t| t.keys().map(String::as_str).collect())
.unwrap_or_default();
let declared_model_provider_names: std::collections::HashSet<&str> = raw_config
.get("model_providers")
.and_then(toml::Value::as_table)
.map(|t| t.keys().map(String::as_str).collect())
.unwrap_or_default();
for (model_key, model) in &config.config_models {
if let Some(ref name) = model.auth_provider
&& !config.auth_providers.contains_key(name)
@ -2014,6 +2056,41 @@ impl Config {
),
);
}
if let Some(ref id) = model.model_provider
&& !config.model_providers.contains_key(id)
&& !declared_model_provider_names.contains(id.as_str())
{
config.config_warnings.push(
super::config_model_override_parse::ConfigWarning::model(
model_key,
Some("model_provider"),
super::config_model_override_parse::ConfigWarningKind::InvalidValue,
format!(
"references [model_providers.{id}], which is not defined; \
provider defaults are not applied the model uses its own \
credential if set, otherwise fails closed on a custom endpoint"
),
),
);
}
}
for (id, provider) in &config.model_providers {
if let Some(ref name) = provider.auth_provider
&& !config.auth_providers.contains_key(name)
&& !declared_provider_names.contains(name.as_str())
{
config.config_warnings.push(
super::config_model_override_parse::ConfigWarning::model_provider(
id,
Some("auth_provider"),
super::config_model_override_parse::ConfigWarningKind::InvalidValue,
format!(
"references [auth_provider.{name}], which is not defined; \
inheriting models fail closed with no provider credential"
),
),
);
}
}
super::config_model_override_parse::log_config_warnings(&config.config_warnings);
if config.grok_com_config.oidc.is_none() {
@ -2345,6 +2422,18 @@ impl Config {
.map_or(Policy::DEFAULT_MAX_RETRIES, Policy::clamp_max_retries),
})
}
/// Automatic worktree GC policy. Precedence: env kill/dry-run >
/// `[worktree.auto_gc]` TOML > remote `worktree_auto_gc` > defaults.
/// Platform age-expiry (non-Linux dead-only) is enforced inside
/// `xai_fast_worktree::maybe_auto_gc`, not here.
pub fn resolve_worktree_auto_gc(&self) -> xai_fast_worktree::ResolvedWorktreeAutoGc {
crate::util::config::resolve_worktree_auto_gc_from_settings(
Some(&self.worktree.auto_gc),
self.remote_settings
.as_ref()
.and_then(|s| s.worktree_auto_gc.as_ref()),
)
}
/// Gate first-run auto-registration of the official xAI marketplace source.
/// Precedence: env `GROK_OFFICIAL_MARKETPLACE_AUTO_REGISTER` > remote settings >
/// default off (so only remote settings-targeted teams get it pre-public). No
@ -2474,16 +2563,31 @@ impl Config {
/// reach cli-chat-proxy `/v1/settings` (custom `models_base_url`, external
/// `auth_provider_command`, air-gapped proxies) never receive the
/// remote settings `goal_enabled` flag, so the default must not carve them out.
/// Env, `[goal] enabled`, and the remote flag (`Some(false)` kill-switch)
/// all still override.
pub(crate) fn resolve_goal(&self) -> Resolved<bool> {
let ff = self.remote_settings.as_ref().and_then(|s| s.goal_enabled);
if ff == Some(false) {
return Resolved::new(false, ConfigSource::Remote);
}
BoolFlag::env("GROK_GOAL")
.config(self.goal.enabled)
.feature_flag(ff)
.default(true)
.resolve()
}
pub(crate) fn resolve_workflows(&self) -> Resolved<bool> {
let ff = self
.remote_settings
.as_ref()
.and_then(|s| s.workflows_enabled);
if ff == Some(false) {
return Resolved::new(false, ConfigSource::Remote);
}
BoolFlag::env("GROK_WORKFLOWS")
.config(self.workflows.enabled)
.feature_flag(ff)
.default(false)
.resolve()
}
/// Classifier, planner, and summary all default to goal mode itself: when
/// `/goal` is on they are on unless config/env/remote says otherwise.
/// `goal_enabled` is the session's already-resolved master switch (the same
@ -3340,22 +3444,46 @@ pub fn resolve_model_list(
);
}
}
let entry = model_override.apply(key, base, &cfg.endpoints);
let with_provider = model_override.model_provider.as_deref().map(|pid| {
match cfg.model_providers.get(pid) {
Some(provider) => model_override.with_provider_defaults(provider, pid),
None => model_override.with_missing_provider(),
}
});
let effective = with_provider.as_ref().unwrap_or(model_override);
let mut entry = effective.apply(key, base, &cfg.endpoints);
let session_bearer_unsafe = !crate::util::is_xai_api_bearer_url(&entry.info.base_url)
|| entry
.api_base_url
.as_deref()
.is_some_and(|url| !crate::util::is_xai_api_bearer_url(url));
if let Some(pid) = model_override.model_provider.as_deref()
&& entry.auth_provider.is_none()
&& session_bearer_unsafe
{
entry.auth_provider = Some(crate::auth::AuthProviderRef::fail_closed(format!(
"model_provider:{pid} (fail-closed)"
)));
}
tracing::debug!(
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
.api_key.is_some(), env_key = ? entry.env_key, auth_provider = entry
.auth_provider.as_ref().map(| p | p.name.as_str()), had_base,
.auth_provider.as_ref().map(| p | p.name.as_str()), model_provider =
model_override.model_provider.as_deref(), had_base,
"config model override applied"
);
resolved.insert(key.clone(), entry);
}
for (key, entry) in resolved.iter_mut() {
if let Some(ref mut provider) = entry.auth_provider {
if provider.is_fail_closed() {
continue;
}
let config = cfg.auth_providers.get(&provider.name);
if config.is_none() {
tracing::debug!(
model_key = % key, provider = % provider.name,
"model references an undefined [auth_provider.*] table"
"provider ref has no trusted config; failing closed with an empty command"
);
}
provider.attach_trusted_config(config);
@ -3746,6 +3874,7 @@ pub struct ConfigModelOverride {
/// this model's bearer token. Static `api_key` / `env_key` win when both
/// are set.
pub auth_provider: Option<String>,
pub model_provider: Option<String>,
pub api_base_url: Option<String>,
pub max_completion_tokens: Option<u32>,
pub temperature: Option<f32>,
@ -4270,6 +4399,12 @@ pub struct GoalConfig {
)]
pub skeptic_models: Vec<crate::util::config::GoalRoleModel>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WorkflowsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
/// `[auto_mode]` section: server-side configuration for Auto permission mode.
/// ONE struct serves both the local `[auto_mode]` TOML table and the remote
/// remote settings `auto_mode` JSON object (coerced via `serde_json::from_value`), so
@ -5789,8 +5924,7 @@ if n == name && f.as_deref() == field
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue
&& matches!(& w.target, WarningTarget::Model
{ field, .. }
if field.as_deref() == Some("auth_provider"))
{ field, .. } if field.as_deref() == Some("auth_provider"))
}),
"undefined reference warns at parse time: {:?}",
cfg.config_warnings
@ -6050,6 +6184,32 @@ if field.as_deref() == Some("auth_provider"))
let provider = resolved["cached-model"].auth_provider.as_ref().unwrap();
assert_eq!(provider.config.command, "printf local");
}
#[test]
fn provider_model_fails_closed_on_prefetched_custom_base_url() {
let mut cfg = Config::default();
cfg.model_providers.insert(
"gw".to_string(),
crate::agent::model_providers::ModelProviderConfig::default(),
);
cfg.config_models.insert(
"m".to_string(),
ConfigModelOverride {
model_provider: Some("gw".to_string()),
..Default::default()
},
);
let mut prefetched = IndexMap::new();
prefetched.insert(
"m".to_string(),
test_model_entry("m", "https://evil.example/v1", None, None, None),
);
let resolved = resolve_model_list(&cfg, Some(prefetched));
assert_eq!(
resolve_credentials(&resolved["m"], Some("session-jwt")).api_key,
None,
"a prefetched custom base_url must fail closed, not leak the session token",
);
}
fn test_model_entry(
model: &str,
base_url: &str,
@ -8710,6 +8870,47 @@ if field.as_deref() == Some("auth_provider"))
assert_eq!(p.max_threshold, 12);
assert_eq!(p.max_retries, 1);
}
/// `[worktree.auto_gc]` deserializes through Config and resolve honors it.
#[test]
#[serial]
fn worktree_auto_gc_section_parses_from_toml() {
unsafe {
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC);
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC_DRY_RUN);
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC_MAX_AGE);
}
let raw: toml::Value = toml::from_str(
r#"
[worktree.auto_gc]
enabled = true
max_age_secs = 7200
min_interval_secs = 120
dry_run = true
[worktree.auto_gc.max_age_by_kind]
subagent = 3600
manual = "never"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).unwrap();
assert_eq!(cfg.worktree.auto_gc.enabled, Some(true));
assert_eq!(cfg.worktree.auto_gc.max_age_secs, Some(7200));
let p = cfg.resolve_worktree_auto_gc();
assert!(p.enabled);
assert_eq!(p.max_age_secs, 7200);
assert_eq!(p.min_interval_secs, 120);
assert!(p.dry_run);
assert_eq!(
p.max_age_by_kind
.get(&xai_fast_worktree::WorktreeKind::Subagent),
Some(&Some(3600))
);
assert_eq!(
p.max_age_by_kind
.get(&xai_fast_worktree::WorktreeKind::Manual),
Some(&None)
);
}
/// Out-of-range tunables clamp instead of being honored or dropped.
#[test]
#[serial]
@ -8867,14 +9068,10 @@ if field.as_deref() == Some("auth_provider"))
}
#[test]
#[serial]
fn resolve_goal_env_overrides_config() {
fn resolve_goal_env_overrides_config_without_remote_kill_switch() {
unsafe { std::env::set_var("GROK_GOAL", "1") };
let mut cfg = Config::default();
cfg.goal.enabled = Some(false);
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
goal_enabled: Some(false),
..Default::default()
});
let r = cfg.resolve_goal();
assert_eq!(r.source, ConfigSource::Env);
assert!(r.value);
@ -8882,8 +9079,8 @@ if field.as_deref() == Some("auth_provider"))
}
#[test]
#[serial]
fn resolve_goal_config_overrides_remote_settings() {
unsafe { std::env::remove_var("GROK_GOAL") };
fn resolve_goal_remote_false_kills_local_opt_in() {
unsafe { std::env::set_var("GROK_GOAL", "1") };
let mut cfg = Config::default();
cfg.goal.enabled = Some(true);
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
@ -8891,8 +9088,9 @@ if field.as_deref() == Some("auth_provider"))
..Default::default()
});
let r = cfg.resolve_goal();
assert_eq!(r.source, ConfigSource::Config);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
assert!(!r.value);
unsafe { std::env::remove_var("GROK_GOAL") };
}
#[test]
#[serial]
@ -8928,6 +9126,54 @@ if field.as_deref() == Some("auth_provider"))
}
#[test]
#[serial]
fn background_workflows_default_off_without_affecting_goal() {
unsafe { std::env::remove_var("GROK_WORKFLOWS") };
let cfg = Config::default();
assert!(!cfg.resolve_workflows().value);
assert!(cfg.resolve_goal().value);
}
#[test]
#[serial]
fn resolve_workflows_remote_settings_opt_in() {
unsafe { std::env::remove_var("GROK_WORKFLOWS") };
let cfg = Config {
remote_settings: Some(crate::util::config::RemoteSettings {
workflows_enabled: Some(true),
..Default::default()
}),
..Default::default()
};
let r = cfg.resolve_workflows();
assert_eq!(r.source, ConfigSource::Remote);
assert!(r.value);
}
#[test]
#[serial]
fn resolve_workflows_remote_false_kills_local_opt_in() {
unsafe { std::env::set_var("GROK_WORKFLOWS", "1") };
let mut cfg = Config::default();
cfg.workflows.enabled = Some(true);
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
workflows_enabled: Some(false),
..Default::default()
});
let r = cfg.resolve_workflows();
assert_eq!(r.source, ConfigSource::Remote);
assert!(!r.value);
unsafe { std::env::remove_var("GROK_WORKFLOWS") };
}
#[test]
#[serial]
fn resolve_workflows_env_wins() {
unsafe { std::env::set_var("GROK_WORKFLOWS", "1") };
let cfg = Config::default();
let r = cfg.resolve_workflows();
assert_eq!(r.source, ConfigSource::Env);
assert!(r.value);
unsafe { std::env::remove_var("GROK_WORKFLOWS") };
}
#[test]
#[serial]
fn resolve_ask_user_question_defaults_to_true_when_unset() {
unsafe { std::env::remove_var("GROK_ASK_USER_QUESTION") };
let cfg = Config::default();
@ -9882,6 +10128,11 @@ agent_type = "cursor"
enabled = true
[harness]
block_for_upload = true
[feedback.user]
name = ["os_user"]
email = ["git_email", "team@example.com"]
email_domain = "example.com"
command = "/opt/bin/grok-identity"
[repo_changes_dedup]
enabled = false
[relay]

View file

@ -63,6 +63,12 @@ pub enum WarningTarget {
#[serde(skip_serializing_if = "Option::is_none")]
field: Option<String>,
},
ModelProviderSection,
ModelProvider {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
field: Option<String>,
},
}
impl WarningTarget {
@ -73,13 +79,17 @@ impl WarningTarget {
Self::Model { key, .. } => format!("model.\"{key}\""),
Self::AuthProviderSection => "auth_provider".to_owned(),
Self::AuthProvider { name, .. } => format!("auth_provider.\"{name}\""),
Self::ModelProviderSection => "model_providers".to_owned(),
Self::ModelProvider { id, .. } => format!("model_providers.\"{id}\""),
}
}
pub(crate) fn field(&self) -> Option<&str> {
match self {
Self::Model { field, .. } | Self::AuthProvider { field, .. } => field.as_deref(),
Self::ModelSection | Self::AuthProviderSection => None,
Self::Model { field, .. }
| Self::AuthProvider { field, .. }
| Self::ModelProvider { field, .. } => field.as_deref(),
Self::ModelSection | Self::AuthProviderSection | Self::ModelProviderSection => None,
}
}
}
@ -145,6 +155,30 @@ impl ConfigWarning {
}
}
pub(crate) fn model_provider(
id: &str,
field: Option<&str>,
kind: ConfigWarningKind,
reason: String,
) -> Self {
Self {
target: WarningTarget::ModelProvider {
id: id.to_owned(),
field: field.map(str::to_owned),
},
kind,
reason,
}
}
pub(crate) fn model_provider_section(kind: ConfigWarningKind, reason: String) -> Self {
Self {
target: WarningTarget::ModelProviderSection,
kind,
reason,
}
}
pub(crate) fn field(&self) -> Option<&str> {
self.target.field()
}
@ -623,10 +657,10 @@ mod tests {
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ConfigWarningKind::NotATable);
assert!(matches!(
&warnings[0].target,
WarningTarget::Model { key, field: None }
if key == "oops"
));
&warnings[0].target,
WarningTarget::Model { key, field: None }
if key == "oops"
));
}
/// Exhaustive literal (no `..`): a new struct field is a compile error
@ -640,6 +674,7 @@ mod tests {
api_key: Some("key".into()),
env_key: Some(crate::agent::config::EnvKeys::single("ENV_KEY")),
auth_provider: Some("corp-gateway".into()),
model_provider: Some("gateway".into()),
api_base_url: Some("https://api.example.com".into()),
max_completion_tokens: Some(1024),
temperature: Some(0.5),

View file

@ -80,6 +80,14 @@ pub(super) fn parse_queue_edit_command(
editor: owner,
})
}
"x.ai/queue/hold_edit" => {
let id = params.get("id").and_then(|v| v.as_str())?.to_string();
Some(SessionCommand::HoldCombineEdit { id })
}
"x.ai/queue/release_edit" => {
let id = params.get("id").and_then(|v| v.as_str())?.to_string();
Some(SessionCommand::ReleaseCombineEdit { id })
}
_ => None,
}
}

View file

@ -121,7 +121,7 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig
cfg
}
/// Initialize process-level singletons (deployment sync, bundled files,
/// Initialize process-level singletons (deployment sync, built-in metadata,
/// telemetry). `Once`-guarded: only the first call takes effect.
/// Telemetry user ID is updated separately via [`update_telemetry_config`].
fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
@ -135,12 +135,12 @@ 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::builtin::extract_builtin_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
// of built-in extraction so the gate can read the resolved
// remote_settings, which resolve_config has populated by now.
if cfg.resolve_official_marketplace_auto_register().value {
crate::extensions::marketplace::ensure_official_marketplace_source(&grok_home);

View file

@ -9,6 +9,7 @@ pub mod feedback_client;
pub mod folder_trust;
pub(crate) mod handlers;
pub mod init;
pub mod model_providers;
pub mod models;
pub mod mvp_agent;
pub(crate) mod proxy;

View file

@ -0,0 +1,917 @@
use indexmap::IndexMap;
use super::config::{ConfigModelOverride, EnvKeys};
use super::config_model_override_parse::{ConfigWarning, ConfigWarningKind};
use crate::sampling::ApiBackend;
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct ModelProviderConfig {
pub base_url: Option<String>,
pub api_base_url: Option<String>,
pub env_key: Option<EnvKeys>,
pub api_key: Option<String>,
pub api_backend: Option<ApiBackend>,
pub extra_headers: IndexMap<String, String>,
pub auth_provider: Option<String>,
pub auth: Option<crate::auth::AuthProviderConfig>,
pub context_window: Option<u64>,
}
pub(crate) fn model_provider_auth_name(provider_id: &str) -> String {
format!("model_provider:{provider_id}")
}
pub(crate) fn auth_config_issues(
config: &crate::auth::AuthProviderConfig,
) -> Vec<(&'static str, ConfigWarningKind, String)> {
let mut issues = Vec::new();
if !config.is_usable() {
issues.push((
"command",
ConfigWarningKind::InvalidValue,
"missing or empty command; models resolve with no credential".to_owned(),
));
}
let skew = crate::auth::PROVIDER_TOKEN_EXPIRY_SKEW_SECS;
if config.token_ttl_secs.is_some_and(|ttl| ttl <= skew) {
issues.push((
"token_ttl_secs",
ConfigWarningKind::InvalidValue,
format!(
"at or below the {skew}s refresh margin; the command will run before every turn"
),
));
}
if let Some(timeout) = config.timeout_secs
&& !(1..=crate::auth::PROVIDER_TIMEOUT_CEILING_SECS).contains(&timeout)
{
let ceiling = crate::auth::PROVIDER_TIMEOUT_CEILING_SECS;
issues.push((
"timeout_secs",
ConfigWarningKind::InvalidValue,
if timeout == 0 {
"below the 1 second minimum; clamped to 1".to_owned()
} else {
format!("above the {ceiling}s maximum; clamped to {ceiling}")
},
));
}
issues
}
pub(crate) fn parse_model_providers(
raw_config: &toml::Value,
) -> (IndexMap<String, ModelProviderConfig>, Vec<ConfigWarning>) {
let mut providers = IndexMap::new();
let mut warnings = Vec::new();
let Some(section) = raw_config.get("model_providers") else {
return (providers, warnings);
};
let Some(table) = section.as_table() else {
warnings.push(ConfigWarning::model_provider_section(
ConfigWarningKind::NotATable,
format!(
"`model_providers` must be a table of [model_providers.<id>] entries, got {}; \
all model providers ignored",
section.type_str()
),
));
return (providers, warnings);
};
for (id, value) in table {
let mut unknown = Vec::new();
match serde_ignored::deserialize::<_, _, ModelProviderConfig>(value.clone(), |path| {
unknown.push(path.to_string());
}) {
Ok(provider) => {
for key in unknown {
warnings.push(ConfigWarning::model_provider(
id,
Some(key.as_str()),
ConfigWarningKind::UnknownField,
"unrecognized key; field ignored".to_owned(),
));
}
if let Some(auth) = &provider.auth {
for (field, kind, reason) in auth_config_issues(auth) {
warnings.push(ConfigWarning::model_provider(
id,
Some(&format!("auth.{field}")),
kind,
reason,
));
}
}
let has_helper = provider.auth.is_some() || provider.auth_provider.is_some();
let has_static_api_key = provider
.api_key
.as_deref()
.map(str::trim)
.is_some_and(|k| !k.is_empty());
if has_helper && has_static_api_key {
warnings.push(ConfigWarning::model_provider(
id,
Some("api_key"),
ConfigWarningKind::ConflictingFields,
"api_key shadows this provider's auth helper; the static key always \
takes precedence, so the helper never runs for inheriting models"
.to_owned(),
));
} else if has_helper
&& provider
.env_key
.as_ref()
.and_then(EnvKeys::primary)
.is_some()
{
warnings.push(ConfigWarning::model_provider(
id,
Some("env_key"),
ConfigWarningKind::ConflictingFields,
"env_key may shadow this provider's auth helper; env_key takes precedence \
when its variable resolves, otherwise the helper runs"
.to_owned(),
));
}
if provider.auth_provider.is_some() && provider.auth.is_some() {
warnings.push(ConfigWarning::model_provider(
id,
Some("auth"),
ConfigWarningKind::ConflictingFields,
"inline auth is shadowed by auth_provider on this provider; the referenced \
provider takes precedence, so the inline helper never runs"
.to_owned(),
));
}
providers.insert(id.clone(), provider);
}
Err(error) => {
warnings.push(ConfigWarning::model_provider(
id,
None,
ConfigWarningKind::InvalidValue,
format!(
"failed to parse ({error}); provider skipped, inheriting models \
resolve with defaults"
),
));
}
}
}
(providers, warnings)
}
impl ConfigModelOverride {
pub(crate) fn with_provider_defaults(
&self,
provider: &ModelProviderConfig,
provider_id: &str,
) -> Self {
let ModelProviderConfig {
base_url,
api_base_url,
env_key,
api_key,
api_backend,
extra_headers,
auth_provider,
auth,
context_window,
} = provider;
let mut merged = self.clone();
merged.model_provider = None;
merged.base_url = merged.base_url.or_else(|| base_url.clone());
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);
if merged.extra_headers.is_empty() {
merged.extra_headers = extra_headers.clone();
}
let model_sets_own_api_key = self
.api_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty());
let model_sets_own_env_key = self.env_key.as_ref().and_then(EnvKeys::primary).is_some();
let model_has_own_auth =
model_sets_own_api_key || model_sets_own_env_key || self.auth_provider.is_some();
if !model_has_own_auth {
merged.api_key = api_key.clone();
merged.env_key = env_key.clone();
merged.auth_provider = auth_provider
.clone()
.or_else(|| auth.as_ref().map(|_| model_provider_auth_name(provider_id)));
}
merged
}
pub(crate) fn with_missing_provider(&self) -> Self {
let mut merged = self.clone();
merged.model_provider = None;
merged
}
}
#[cfg(test)]
mod tests {
use crate::agent::config::{Config, resolve_credentials, resolve_model_list};
#[test]
fn model_inherits_provider_connection_defaults() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
context_window = 123456
[model_providers.gateway.extra_headers]
X-Corp = "yes"
[model.via-gateway]
model = "m"
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert!(cfg.model_providers.contains_key("gateway"));
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
assert_eq!(model.info.base_url, "https://gateway.example/v1");
assert_eq!(model.info.context_window.get(), 123456);
assert_eq!(
model.info.extra_headers.get("X-Corp").map(String::as_str),
Some("yes")
);
assert!(
model.has_own_credentials(),
"a custom endpoint without a credential is BYOK, not session-authed"
);
assert_eq!(
resolve_credentials(model, Some("session-jwt")).api_key,
None,
"the session token must not leak to the provider's custom endpoint"
);
}
#[test]
fn model_fields_override_provider_defaults() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
context_window = 100000
[model.override-url]
model = "m"
model_provider = "gateway"
base_url = "https://model-specific.example/v1"
context_window = 200000
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("override-url").expect("model should exist");
assert_eq!(model.info.base_url, "https://model-specific.example/v1");
assert_eq!(model.info.context_window.get(), 200000);
}
#[test]
fn model_provider_inline_auth_registers_synthetic_provider() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
context_window = 200000
[model_providers.gateway.auth]
command = "printf gw-token"
token_ttl_secs = 3600
[model.byok-via-gateway]
model = "m"
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert_eq!(
cfg.auth_providers
.get("model_provider:gateway")
.map(|c| c.command.as_str()),
Some("printf gw-token"),
"inline auth registers a synthetic provider keyed by the id"
);
let resolved = resolve_model_list(&cfg, None);
let model = resolved
.get("byok-via-gateway")
.expect("model should exist");
let provider = model
.auth_provider
.as_ref()
.expect("the model inherits the provider's auth");
assert_eq!(provider.name, "model_provider:gateway");
assert_eq!(provider.config.command, "printf gw-token");
assert!(
model.has_own_credentials(),
"a provider-backed model is BYOK (session token must not leak)"
);
}
#[test]
fn model_with_own_key_ignores_provider_auth() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
context_window = 200000
[model_providers.gateway.auth]
command = "printf gw-token"
[model.own-key]
model = "m"
model_provider = "gateway"
api_key = "sk-model-own"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("own-key").expect("model should exist");
assert_eq!(
model.info.base_url, "https://gateway.example/v1",
"non-auth connection fields are still inherited"
);
assert_eq!(
model.effective_auth_provider().map(|p| p.name.as_str()),
None,
"the model's own key shadows the provider's auth"
);
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(creds.api_key.as_deref(), Some("sk-model-own"));
}
#[test]
fn undefined_model_provider_fails_closed() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[model.dangling]
model = "m"
base_url = "https://third-party.example/v1"
context_window = 200000
model_provider = "ghost"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert!(
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue
&& matches!(
&w.target,
WarningTarget::Model { field, .. }
if field.as_deref() == Some("model_provider")
)
}),
"an undefined provider reference warns: {:?}",
cfg.config_warnings
);
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("dangling").expect("model should exist");
assert_eq!(
model.info.base_url, "https://third-party.example/v1",
"the model keeps its own connection fields"
);
assert!(
model.has_own_credentials(),
"an undefined provider leaves the model BYOK, not session-authed"
);
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(
creds.api_key, None,
"no credential resolves and the session token does not leak to the model's base_url"
);
}
#[test]
fn undefined_model_provider_keeps_model_own_key() {
let raw_config: toml::Value = toml::from_str(
r#"
[model.own-key]
model = "m"
base_url = "https://third-party.example/v1"
context_window = 200000
api_key = "sk-model-own"
model_provider = "ghost"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("own-key").expect("model should exist");
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(creds.api_key.as_deref(), Some("sk-model-own"));
}
#[test]
fn model_provider_parse_warnings_are_lenient_and_specific() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.good]
base_url = "https://good.example/v1"
[model_providers.bad-type]
context_window = "not-a-number"
[model_providers.typo]
base_url = "https://typo.example/v1"
unknown_field = 5
[model.on-broken-provider]
model = "m"
base_url = "https://x.example/v1"
context_window = 200000
model_provider = "bad-type"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config)
.expect("one bad provider must not fail the config");
assert!(cfg.model_providers.contains_key("good"));
assert!(
!cfg.model_providers.contains_key("bad-type"),
"a malformed provider is skipped"
);
let has_provider = |id: &str, field: Option<&str>, kind: ConfigWarningKind| {
cfg.config_warnings.iter().any(|w| {
w.kind == kind
&& matches!(
&w.target,
WarningTarget::ModelProvider { id: i, field: f }
if i == id && f.as_deref() == field
)
})
};
assert!(has_provider(
"bad-type",
None,
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"typo",
Some("unknown_field"),
ConfigWarningKind::UnknownField
));
assert!(
!cfg.config_warnings.iter().any(|w| {
matches!(
&w.target,
WarningTarget::Model { field, .. }
if field.as_deref() == Some("model_provider")
)
}),
"a declared-but-malformed provider must not also warn as undefined: {:?}",
cfg.config_warnings
);
let raw_config: toml::Value = toml::from_str(r#"model_providers = "oops""#).unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config)
.expect("a non-table model_providers must not fail the config");
assert!(cfg.model_providers.is_empty());
assert!(
cfg.config_warnings.iter().any(|w| {
matches!(w.target, WarningTarget::ModelProviderSection)
&& w.kind == ConfigWarningKind::NotATable
}),
"non-table section warns: {:?}",
cfg.config_warnings
);
}
#[test]
fn model_provider_conflicting_credentials_warn() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.static-shadows]
base_url = "https://a.example/v1"
api_key = "sk-static"
[model_providers.static-shadows.auth]
command = "printf tok"
[model_providers.env-shadows]
base_url = "https://b.example/v1"
env_key = "SOME_VAR"
[model_providers.env-shadows.auth]
command = "printf tok"
[model_providers.two-helpers]
base_url = "https://c.example/v1"
auth_provider = "corp"
[model_providers.two-helpers.auth]
command = "printf tok"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let has = |id: &str, field: &str| {
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::ConflictingFields
&& matches!(
&w.target,
WarningTarget::ModelProvider { id: i, field: f }
if i == id && f.as_deref() == Some(field)
)
})
};
assert!(
has("static-shadows", "api_key"),
"a static api_key shadows the helper: {:?}",
cfg.config_warnings
);
assert!(
has("env-shadows", "env_key"),
"an env_key may shadow the helper: {:?}",
cfg.config_warnings
);
assert!(
has("two-helpers", "auth"),
"auth_provider shadows the inline auth helper: {:?}",
cfg.config_warnings
);
}
#[test]
fn model_provider_undefined_auth_provider_warns() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
auth_provider = "nonexistent"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert!(
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue
&& matches!(
&w.target,
WarningTarget::ModelProvider { id, field }
if id == "gateway" && field.as_deref() == Some("auth_provider")
)
}),
"an undefined provider auth_provider reference warns: {:?}",
cfg.config_warnings
);
}
#[test]
fn model_provider_inline_auth_namespace_collision_warns() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider."model_provider:gateway"]
command = "printf hand-written"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
[model_providers.gateway.auth]
command = "printf inline"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert!(
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::ConflictingFields
&& matches!(
&w.target,
WarningTarget::ModelProvider { id, field }
if id == "gateway" && field.as_deref() == Some("auth")
)
}),
"a reserved-namespace collision warns: {:?}",
cfg.config_warnings
);
assert_eq!(
cfg.auth_providers
.get("model_provider:gateway")
.map(|c| c.command.as_str()),
Some("printf inline"),
"inline auth wins the reserved name"
);
}
#[test]
fn model_inherits_provider_named_auth_provider() {
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.corp]
command = "printf corp-token"
token_ttl_secs = 3600
[model_providers.gateway]
base_url = "https://gateway.example/v1"
auth_provider = "corp"
[model.via-gateway]
model = "m"
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
let provider = model
.auth_provider
.as_ref()
.expect("the model inherits the provider's named auth_provider");
assert_eq!(provider.name, "corp");
assert_eq!(provider.config.command, "printf corp-token");
assert!(model.has_own_credentials());
}
#[test]
fn model_inherits_provider_static_key() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
api_key = "sk-provider"
[model.via-gateway]
model = "m"
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
assert_eq!(
resolve_credentials(model, Some("session-jwt"))
.api_key
.as_deref(),
Some("sk-provider"),
"the provider's static key resolves for the inheriting model"
);
}
#[test]
fn declared_unresolved_credential_fails_closed_on_provider_endpoint() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
[model.via-gateway]
model = "m"
model_provider = "gateway"
env_key = "DEFINITELY_UNSET_MODEL_PROVIDER_TEST_VAR"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
assert_eq!(
resolve_credentials(model, Some("session-jwt")).api_key,
None,
"an unresolved declared credential must not fall back to the session token"
);
}
#[test]
fn model_inherits_provider_api_backend_and_base_url() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
api_base_url = "https://gateway.example/api"
api_backend = "responses"
api_key = "sk-provider"
[model.via-gateway]
model = "m"
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).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.api_backend,
crate::sampling::ApiBackend::Responses
);
assert_eq!(
model.api_base_url.as_deref(),
Some("https://gateway.example/api")
);
}
#[test]
fn model_own_unresolved_key_ignores_provider_inline_auth() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
[model_providers.gateway.auth]
command = "printf gw-token"
[model.own-env]
model = "m"
model_provider = "gateway"
env_key = "DEFINITELY_UNSET_MODEL_PROVIDER_INLINE_VAR"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("own-env").expect("model should exist");
let effective = model
.effective_auth_provider()
.expect("an unresolved own credential fails closed via a provider ref");
assert!(
effective.name.contains("fail-closed"),
"must pin the unusable fail-closed ref, not the live inline auth: {}",
effective.name
);
assert!(
effective.config.command.is_empty(),
"the fail-closed ref is unusable"
);
assert_eq!(
resolve_credentials(model, Some("session-jwt")).api_key,
None,
"must not fall back to the session token"
);
}
#[test]
fn fail_closed_ref_ignores_a_colliding_auth_provider_table() {
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider."model_provider:gateway (fail-closed)"]
command = "printf sneaky-token"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
[model.via-gateway]
model = "m"
context_window = 200000
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
assert_eq!(
resolve_credentials(model, Some("session-jwt")).api_key,
None,
"a fail-closed ref must never resolve a colliding auth_provider table"
);
let effective = model
.effective_auth_provider()
.expect("fails closed via a provider ref");
assert!(
effective.config.command.is_empty(),
"the fail-closed ref stays unusable despite the name collision"
);
}
#[test]
fn model_headers_shadow_provider_headers() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
api_key = "sk-provider"
[model_providers.gateway.extra_headers]
X-Corp = "yes"
[model.via-gateway]
model = "m"
context_window = 200000
model_provider = "gateway"
[model.via-gateway.extra_headers]
X-Model = "own"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).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.extra_headers.get("X-Model").map(String::as_str),
Some("own")
);
assert!(
model.info.extra_headers.get("X-Corp").is_none(),
"a model that sets any header inherits none of the provider's"
);
}
#[test]
fn model_provider_inline_auth_ttl_and_timeout_warn() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
[model_providers.gateway.auth]
command = "printf tok"
token_ttl_secs = 5
timeout_secs = 0
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let has = |field: &str| {
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue
&& matches!(
&w.target,
WarningTarget::ModelProvider { id, field: f }
if id == "gateway" && f.as_deref() == Some(field)
)
})
};
assert!(
has("auth.token_ttl_secs"),
"inline auth ttl below the refresh margin warns: {:?}",
cfg.config_warnings
);
assert!(
has("auth.timeout_secs"),
"inline auth timeout out of range warns: {:?}",
cfg.config_warnings
);
}
#[test]
fn blank_api_key_does_not_shadow_provider_auth() {
let raw_config: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
[model_providers.gateway.auth]
command = "printf tok"
[model.m]
model = "m"
model_provider = "gateway"
api_key = " "
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let provider = resolved["m"]
.auth_provider
.as_ref()
.expect("blank api_key must not fail-close a working gateway");
assert_eq!(provider.name.as_str(), "model_provider:gateway");
assert!(!provider.is_fail_closed());
}
}

View file

@ -25,8 +25,28 @@ impl acp::Agent for MvpAgent {
tracing::debug!(target : "sampling_log", "Received initialize request");
xai_grok_telemetry::unified_log::info("agent initialized", None, None);
self.start_subagent_coordinator();
tokio::task::spawn_blocking(|| {
let (auto_gc_policy, run_auto_gc) = {
let cfg = self.cfg.borrow();
let has_remote = cfg.remote_settings.is_some();
let run = has_remote || !crate::util::config::resolve_remote_fetch_enabled();
(cfg.resolve_worktree_auto_gc(), run)
};
if !run_auto_gc {
tracing::debug!(
"auto worktree gc deferred until remote_settings are available"
);
}
tokio::task::spawn_blocking(move || {
crate::session::worktree_pool::cleanup_stale_pool_worktrees(None);
if !run_auto_gc {
return;
}
let opts = xai_fast_worktree::AutoGcOptions::from_resolved(auto_gc_policy);
if let Err(e) = xai_fast_worktree::WorktreeDb::open_default()
.and_then(|db| xai_fast_worktree::maybe_auto_gc(&db, &opts))
{
tracing::warn!(error = % e, "auto worktree gc failed");
}
});
tokio::task::spawn_blocking(|| {
crate::session::persistence::cleanup_stale_sessions(None);
@ -1072,6 +1092,7 @@ impl acp::Agent for MvpAgent {
persisted_signals: None,
persisted_plan_mode: None,
persisted_goal_mode: None,
persisted_workflow_runs: Vec::new(),
persisted_announcement_state: None,
session_meta: arguments.meta.as_ref(),
managed_mcp_expires_at,
@ -1361,6 +1382,7 @@ impl acp::Agent for MvpAgent {
signals: persisted_signals,
announcement_state: persisted_announcement_state,
goal_mode_state: _persisted_goal_mode,
workflow_runs: persisted_workflow_runs,
} = persistence_info;
let restored_compaction_count = persisted_signals
.as_ref()
@ -1612,6 +1634,7 @@ impl acp::Agent for MvpAgent {
persisted_signals,
persisted_plan_mode,
persisted_goal_mode: _persisted_goal_mode,
persisted_workflow_runs,
persisted_announcement_state,
session_meta: request_meta.as_ref(),
managed_mcp_expires_at,
@ -2169,7 +2192,6 @@ impl acp::Agent for MvpAgent {
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| self.cfg.borrow().client_version.clone());
let agent_config = self.cfg.borrow().clone();
let plugin_registry = self.plugin_registry_snapshot();
let prompt_images: Vec<agent_client_protocol::ImageContent> = arguments
.prompt
@ -2251,11 +2273,9 @@ impl acp::Agent for MvpAgent {
let before_workspace_fut = async {};
futures::join!(
upload_session_state(& ctx, "before", session_copy_rx,
UploadWait::Confirm), before_workspace_fut, upload_config(& ctx,
& agent_config), crate
::upload::config_files::upload_config_files(& ctx),
upload_images(& ctx, & prompt_images), upload_plugin_state(& ctx,
plugin_registry.as_deref()),
UploadWait::Confirm), before_workspace_fut, upload_images(& ctx,
& prompt_images), upload_plugin_state(& ctx, plugin_registry
.as_deref()),
);
},
);
@ -3205,12 +3225,13 @@ impl acp::Agent for MvpAgent {
| "x.ai/session/update_mcp_servers" | "x.ai/session/fork"
| "x.ai/internal/reload_all_mcp_servers"
| "x.ai/internal/reload_project_mcp_servers" | "x.ai/internal/reload_skills"
| "x.ai/internal/reload_models" | "x.ai/internal/reload_models_cache"
| "x.ai/internal/auth_cleared" | "x.ai/plugins/reload"
| "x.ai/commands/list" => {
| "x.ai/internal/reload_workflows" | "x.ai/internal/reload_models"
| "x.ai/internal/reload_models_cache" | "x.ai/internal/auth_cleared"
| "x.ai/plugins/reload" | "x.ai/commands/list" => {
crate::extensions::session_admin::handle(self, &args).await
}
"x.ai/session/repair" => crate::extensions::repair::handle(self, &args).await,
"x.ai/session/usage" => crate::extensions::usage::handle(self, &args).await,
"x.ai/memory/flush" | "x.ai/memory/rewrite" => {
crate::extensions::memory::handle(self, &args).await
}
@ -3497,9 +3518,10 @@ impl acp::Agent for MvpAgent {
let ops = self.resolve_workspace_ops()?;
crate::extensions::code_nav::handle(self, &ops, &args).await
}
s if s.starts_with("x.ai/skills/") => {
s if s.starts_with("x.ai/skills/") || s == "x.ai/workflows/list" => {
let compat = self.cfg.borrow().compat_resolved;
crate::extensions::skills::handle(
self,
&args,
self.plugin_registry_handle.snapshot().as_deref(),
compat,

View file

@ -16,6 +16,34 @@ fn byok_from_models(
.or_else(|| models.values().find_map(|m| m.own_credential()))
}
impl MvpAgent {
pub fn reload_skills_all_sessions(&self) -> usize {
let session_ids: Vec<agent_client_protocol::SessionId> = self
.sessions
.borrow()
.keys()
.cloned()
.collect();
for sid in &session_ids {
if let Some(handle) = self.sessions.borrow().get(sid).cloned() {
let _ = handle.cmd_tx.send(SessionCommand::ReloadSkills);
}
}
session_ids.len()
}
pub fn advertise_commands_all_sessions(&self) -> usize {
let session_ids: Vec<agent_client_protocol::SessionId> = self
.sessions
.borrow()
.keys()
.cloned()
.collect();
for session_id in &session_ids {
if let Some(handle) = self.sessions.borrow().get(session_id).cloned() {
let _ = handle.cmd_tx.send(SessionCommand::AdvertiseCommands);
}
}
session_ids.len()
}
pub(super) fn resolve_image_description_model(&self) -> String {
self.cfg
.borrow()
@ -550,17 +578,13 @@ impl MvpAgent {
/// `acp_session.rs` fill in the real per-model gating as soon as a
/// session starts.
///
/// Exception: `/goal` is gated on the `resolve_goal()` feature flag
/// (a config/managed-settings switch known at initialize time) plus
/// the `update_goal` tool, which is part of the default coding-agent
/// toolset. So when the flag is on we advertise `/goal` pre-session;
/// otherwise it wouldn't appear in the slash menu until after the
/// first user turn created a session.
pub(crate) fn command_availability(
&self,
) -> crate::session::slash_commands::CommandAvailability {
crate::session::slash_commands::CommandAvailability {
goal: self.cfg.borrow().resolve_goal().value,
workflows: self.cfg.borrow().resolve_workflows().value,
..crate::session::slash_commands::CommandAvailability::default()
}
}
@ -800,6 +824,7 @@ impl MvpAgent {
let is_xai = auth.is_xai_auth();
let user_id = auth.user_id.clone();
let team_id = auth.team_id.clone();
let remote_was_absent = self.cfg.borrow().remote_settings.is_none();
let Some(settings) = self.fetch_remote_settings(auth.clone()).await else {
tracing::warn!("post-auth settings refresh failed (HTTP or parse error)");
return;
@ -861,6 +886,9 @@ impl MvpAgent {
crate::auth::credential_provider::sync_external_otel_identity();
self.emit_announcements(AnnouncementsPushMode::IfChanged);
self.reconfigure_heap_profile_monitor();
if remote_was_absent {
self.spawn_auto_worktree_gc();
}
}
/// Refresh remote settings settings and re-resolve eagerly-resolved config fields.
///
@ -1471,9 +1499,9 @@ impl MvpAgent {
raw,
cfg.remote_settings.as_ref(),
);
let session_registry_local = config_root
.as_ref()
.and_then(crate::util::config::session_registry_from_toml_opt);
let session_registry_local = crate::util::config::session_registry_local_override(
config_root.as_ref(),
);
tracing::info!(
worktree_type = ? worktree_type, source = wt_source,
"WORKTREE_CONFIG_SHELL: resolved worktree type at agent startup"
@ -2892,6 +2920,7 @@ impl MvpAgent {
persisted_signals,
persisted_plan_mode,
persisted_goal_mode,
persisted_workflow_runs,
persisted_announcement_state,
session_meta,
managed_mcp_expires_at,
@ -3039,6 +3068,7 @@ impl MvpAgent {
let resolved = cfg.resolve_feedback();
let flags = crate::session::feedback_manager::FeedbackFlags {
enabled: resolved.value,
user: cfg.feedback.user.clone(),
};
(resolved, flags)
};
@ -3353,6 +3383,7 @@ impl MvpAgent {
let web_fetch_config = self.prepare_web_fetch_config();
let write_file_enabled = self.cfg.borrow().resolve_write_file().value;
let goal_enabled = self.cfg.borrow().resolve_goal().value;
let background_workflows_enabled = self.cfg.borrow().resolve_workflows().value;
let subagents_enabled = self.cfg.borrow().subagents_enabled;
let ask_user_question_enabled = crate::upload::turn::parse_ask_user_question_from_meta(
session_meta,
@ -3566,6 +3597,7 @@ impl MvpAgent {
persisted_signals,
persisted_plan_mode,
persisted_goal_mode,
persisted_workflow_runs,
persisted_announcement_state,
self.memory_config.clone(),
loc_tracking_enabled,
@ -3586,6 +3618,7 @@ impl MvpAgent {
app_builder_deployer_config,
write_file_enabled,
goal_enabled,
background_workflows_enabled,
subagents_enabled,
ask_user_question_enabled,
client_hooks,

View file

@ -84,10 +84,9 @@ use crate::tools::ToolContext;
use crate::upload::manifest::write_error_manifest;
use crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, TurnResultMetadata,
build_chat_history_session_state, local_sandbox_telemetry, upload_config,
upload_full_prompt_txt, upload_harness_session_archive, upload_images,
upload_metadata, upload_plugin_state, upload_session_state, upload_turn_messages,
upload_turn_result, upload_unified_log,
build_chat_history_session_state, local_sandbox_telemetry, upload_full_prompt_txt,
upload_harness_session_archive, upload_images, upload_metadata, upload_plugin_state,
upload_session_state, upload_turn_messages, upload_turn_result, upload_unified_log,
};
use crate::upload::turn::{
PromptTraceContext, UploadWait, complete_prompt_trace, spawn_upload_task,
@ -204,6 +203,9 @@ pub(crate) struct SessionSpawnOptions<'a> {
pub persisted_signals: Option<crate::session::signals::SessionSignals>,
pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
pub persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
pub persisted_workflow_runs: Vec<
crate::session::workflow::store::RestoredWorkflowRun,
>,
pub persisted_announcement_state: Option<
crate::session::announcement_state::AnnouncementState,
>,
@ -342,6 +344,7 @@ pub(crate) fn chat_session_spawn_options<'a>(
persisted_signals: None,
persisted_plan_mode: None,
persisted_goal_mode: None,
persisted_workflow_runs: Vec::new(),
persisted_announcement_state: None,
session_meta,
managed_mcp_expires_at: None,
@ -753,7 +756,8 @@ pub struct MvpAgent {
pub(crate) worktree_type: crate::util::config::WorktreeType,
/// Restore codebase state on worktree resume (resolved: local config > remote > default false).
pub(crate) restore_code: bool,
/// Local config.toml override for session registry (`[cli] session_registry`).
/// Local session-registry override: `GROK_SESSION_REGISTRY` env, else
/// `[cli] session_registry`.
/// `Some(true)` enables, `Some(false)` disables, `None` defers to remote settings.
session_registry_local: Option<bool>,
/// Managed MCP configs and gateway tool catalog; lazily fetched.
@ -1813,6 +1817,7 @@ impl MvpAgent {
),
);
if let Some(settings) = unblocked.settings {
let remote_was_absent = self.cfg.borrow().remote_settings.is_none();
{
let mut cfg = self.cfg.borrow_mut();
cfg.remote_settings = Some(settings);
@ -1823,6 +1828,9 @@ impl MvpAgent {
self.sync_collection_config_gate();
self.emit_announcements(AnnouncementsPushMode::IfChanged);
self.reconfigure_heap_profile_monitor();
if remote_was_absent {
self.spawn_auto_worktree_gc();
}
}
if crate::util::config::resolve_remote_fetch_enabled()
&& !settings_allow_access(self.cfg.borrow().remote_settings.as_ref())
@ -2035,6 +2043,19 @@ impl MvpAgent {
self.emit_settings_update_notification();
self.emit_announcements(AnnouncementsPushMode::IfChanged);
self.reconfigure_heap_profile_monitor();
self.spawn_auto_worktree_gc();
}
/// Resolve current auto-GC policy and run it on the blocking pool.
pub(super) fn spawn_auto_worktree_gc(&self) {
let auto_gc_policy = self.cfg.borrow().resolve_worktree_auto_gc();
tokio::task::spawn_blocking(move || {
let opts = xai_fast_worktree::AutoGcOptions::from_resolved(auto_gc_policy);
if let Err(e) = xai_fast_worktree::WorktreeDb::open_default()
.and_then(|db| xai_fast_worktree::maybe_auto_gc(&db, &opts))
{
tracing::warn!(error = % e, "auto worktree gc failed");
}
});
}
/// Fire-and-forget `x.ai/settings/update` from the current remote snapshot.
pub(super) fn emit_settings_update_notification(&self) {
@ -2217,16 +2238,7 @@ async fn handle_synthetic_turn_trace(
use crate::session::SessionCommand;
use crate::upload::turn::{UploadWait, complete_prompt_trace, spawn_upload_task};
let turn_started_at = chrono::Utc::now().to_rfc3339();
let (
info,
turn_number,
agent_config,
user_id,
user_email,
client_source,
client_version,
model,
) = {
let (info, turn_number, user_id, user_email, client_source, client_version, model) = {
let this = agent_ref.get();
let session_info = {
let sessions = this.sessions.borrow();
@ -2265,17 +2277,7 @@ async fn handle_synthetic_turn_trace(
.map(|h| h.model_id.0.to_string())
.unwrap_or_else(|| this.models_manager.current_model_id().0.to_string())
};
let agent_config = this.cfg.borrow().clone();
(
info,
turn_number,
agent_config,
user_id,
user_email,
client_source,
client_version,
model,
)
(info, turn_number, user_id, user_email, client_source, client_version, model)
};
let this = agent_ref.get();
let trace_context = this.get_trace_context(&info, turn_number).await;
@ -2323,8 +2325,7 @@ async fn handle_synthetic_turn_trace(
futures::join!(
upload_session_state(& before_ctx, "before", request
.before_session_copy_rx, UploadWait::Confirm,), upload_metadata(&
before_ctx, metadata), upload_config(& before_ctx, & agent_config), crate
::upload::config_files::upload_config_files(& before_ctx),
before_ctx, metadata),
);
},
);

View file

@ -26,26 +26,43 @@ impl MvpAgent {
match event {
SubagentEvent::Spawn(boxed) => {
let mut request = *boxed;
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
{
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 Some(root) = this
.subagent_coordinator
.borrow()
.parent_of_child_session(&request.parent_session_id)
{
tracing::info!(
child_session_id = % request.parent_session_id,
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 !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)
@ -55,6 +72,9 @@ impl MvpAgent {
.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.",
@ -164,23 +184,57 @@ impl MvpAgent {
}
});
}
SubagentEvent::Cancel(request) => {
let this = agent_ref.get();
let outcome = {
let mut coord = this.subagent_coordinator.borrow_mut();
match request.target {
SubagentCancelTarget::SubagentId(ref subagent_id) => {
coord.mark_explicitly_killed(subagent_id);
coord.cancel_with_outcome(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;
}
SubagentCancelTarget::ParentPromptId(ref parent_prompt_id) => {
coord.cancel_by_parent_prompt_id(parent_prompt_id);
SubagentCancelOutcome::Cancelled
});
}
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);
}
};
let _ = request.respond_to.send(outcome);
}
},
SubagentEvent::ListActive(request) => {
let this = agent_ref.get();
let summaries = this
@ -257,6 +311,14 @@ impl MvpAgent {
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);
}
}
}
}
@ -519,6 +581,7 @@ impl MvpAgent {
app_builder_deployer_config: self.prepare_app_builder_deployer_config(),
write_file_enabled: self.cfg.borrow().resolve_write_file().value,
goal_enabled: self.cfg.borrow().resolve_goal().value,
background_workflows_enabled: self.cfg.borrow().resolve_workflows().value,
ask_user_question_enabled,
parent_cmd_tx: parent_cmd_tx.clone(),
parent_session_info: {
@ -634,7 +697,9 @@ impl MvpAgent {
sessions
.get(&parent_sid)
.map(|h| h.tool_context.blocking_wait_depth.clone())
.unwrap_or_else(|| std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)))
.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(),

View file

@ -1611,6 +1611,38 @@ fn build_minimal_agent_for_tests() -> MvpAgent {
let cfg = AgentConfig::default();
MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
}
fn session_usage_request(session_id: &str) -> acp::ExtRequest {
acp::ExtRequest::new(
"x.ai/session/usage",
serde_json::value::to_raw_value(&serde_json::json!({ "sessionId" : session_id }))
.unwrap()
.into(),
)
}
#[tokio::test(flavor = "current_thread")]
async fn session_usage_unknown_session_is_resource_not_found() {
let agent = build_minimal_agent_for_tests();
let err = crate::extensions::usage::handle(&agent, &session_usage_request("no-such-session"))
.await
.expect_err("unknown session");
assert_eq!(
err.code,
acp::Error::resource_not_found(None::<String>).code
);
}
#[tokio::test(flavor = "current_thread")]
async fn session_usage_dead_chat_state_actor_fails_closed() {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("usage-dead-actor-sess");
let mut handle = make_test_handle("test-model", false, None);
handle.info.id = sid.clone();
agent.sessions.borrow_mut().insert(sid, handle);
let err =
crate::extensions::usage::handle(&agent, &session_usage_request("usage-dead-actor-sess"))
.await
.expect_err("dead chat-state actor");
assert_eq!(err.code, acp::Error::internal_error().code);
}
/// Build a minimal MvpAgent with pre-loaded auth for gate tests.
fn build_agent_with_auth(auth: crate::auth::GrokAuth) -> MvpAgent {
use crate::agent::config::Config as AgentConfig;

View file

@ -509,25 +509,25 @@ where
let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_INTERVAL_SECS));
loop {
tokio::select! {
_ = cancel_write.cancelled() => break, msg_opt = from_agent_rx.recv() =>
{ match msg_opt { Some(msg) => { if
tracing::enabled!(tracing::Level::DEBUG) { if let Ok(json_val) =
serde_json::from_str::< serde_json::Value > (& msg) { let method =
json_val.get("method").and_then(| m | m.as_str()); let line_to_print =
match method { Some("session/update") => { let params = json_val
.get("params").unwrap_or(& serde_json::Value::Null);
format!("acp_outbound::session/update::{params}") } Some(m) =>
format!("acp_outbound::{m}"), None => "acp_outbound::response"
.to_string(), }; debug!("{line_to_print}"); } else {
debug!("acp_outbound::response"); } }
if ! msg.is_empty() && let Err(e) =
ws_outbound.send(Message::Text(Utf8Bytes::from(msg))). await {
warn!(error = ? e, "failed to send to WS"); break; } } None => {
info!("Agent outbound channel closed"); break; } } } _ = keepalive.tick()
=> { tprintln!("ws::keep_alive_tick"); if let Err(e) = ws_outbound
.send(Message::Ping(Vec::new().into())). await {
tprintln!("ws::keep_alive::error::{:?}", & e); break; } }
}
_ = cancel_write.cancelled() => break, msg_opt = from_agent_rx.recv() =>
{ match msg_opt { Some(msg) => { if
tracing::enabled!(tracing::Level::DEBUG) { if let Ok(json_val) =
serde_json::from_str::< serde_json::Value > (& msg) { let method =
json_val.get("method").and_then(| m | m.as_str()); let line_to_print =
match method { Some("session/update") => { let params = json_val
.get("params").unwrap_or(& serde_json::Value::Null);
format!("acp_outbound::session/update::{params}") } Some(m) =>
format!("acp_outbound::{m}"), None => "acp_outbound::response"
.to_string(), }; debug!("{line_to_print}"); } else {
debug!("acp_outbound::response"); } }
if ! msg.is_empty() && let Err(e) =
ws_outbound.send(Message::Text(Utf8Bytes::from(msg))). await {
warn!(error = ? e, "failed to send to WS"); break; } } None => {
info!("Agent outbound channel closed"); break; } } } _ = keepalive.tick()
=> { tprintln!("ws::keep_alive_tick"); if let Err(e) = ws_outbound
.send(Message::Ping(Vec::new().into())). await {
tprintln!("ws::keep_alive::error::{:?}", & e); break; } }
}
}
anyhow::Ok(())
};

View file

@ -59,6 +59,7 @@ fn effort_label(effort: ReasoningEffort) -> String {
ReasoningEffort::Medium => "Medium",
ReasoningEffort::High => "High",
ReasoningEffort::Xhigh => "X-High",
ReasoningEffort::Max => "Max",
}
.to_string()
}

View file

@ -71,7 +71,7 @@ pub struct UpdateRequest {
// Response types
// ============================================================================
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionRecord {
pub session_id: String,

View file

@ -16,7 +16,7 @@ use crate::terminal::AsyncTerminalRunner;
use crate::tools::ToolContext;
use crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
local_sandbox_telemetry, upload_config, upload_metadata, upload_session_state,
local_sandbox_telemetry, upload_metadata, upload_session_state,
upload_subagent_metadata, upload_turn_result,
};
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
@ -38,6 +38,7 @@ impl SubagentCoordinator {
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) {
@ -76,14 +77,6 @@ impl SubagentCoordinator {
std::sync::atomic::Ordering::Relaxed,
);
}
/// Returns a handle to the completion [`Notify`].
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "used from tests only; remove expect when wired in production"
)
)]
pub fn completion_notify(&self) -> Arc<Notify> {
Arc::clone(&self.completion_notify)
}
@ -236,6 +229,7 @@ impl SubagentCoordinator {
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,
@ -261,6 +255,7 @@ impl SubagentCoordinator {
description: String,
parent_prompt_id: Option<String>,
parent_session_id: String,
owner: SubagentOwner,
error: &str,
surface_completion: bool,
) {
@ -270,6 +265,7 @@ impl SubagentCoordinator {
description,
parent_prompt_id,
parent_session_id,
owner,
persona: None,
started_at: std::time::Instant::now(),
error,
@ -281,6 +277,7 @@ impl SubagentCoordinator {
/// 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,
@ -288,6 +285,7 @@ impl SubagentCoordinator {
description,
parent_prompt_id,
parent_session_id,
owner,
persona,
started_at,
error,
@ -309,6 +307,7 @@ impl SubagentCoordinator {
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(),
@ -359,6 +358,7 @@ impl SubagentCoordinator {
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()
@ -373,6 +373,7 @@ impl SubagentCoordinator {
.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()
@ -394,6 +395,7 @@ impl SubagentCoordinator {
subagent_id: id.to_string(),
parent_session_id,
parent_prompt_id,
owner,
child_session_id,
description,
subagent_type,
@ -481,6 +483,31 @@ impl SubagentCoordinator {
}
}
}
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

View file

@ -16,7 +16,7 @@ use crate::terminal::AsyncTerminalRunner;
use crate::tools::ToolContext;
use crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
local_sandbox_telemetry, upload_config, upload_metadata, upload_session_state,
local_sandbox_telemetry, upload_metadata, upload_session_state,
upload_subagent_metadata, upload_turn_result,
};
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
@ -37,6 +37,9 @@ impl SubagentCoordinator {
/// - `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(),
@ -50,6 +53,9 @@ impl SubagentCoordinator {
);
}
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(),
@ -92,6 +98,9 @@ impl SubagentCoordinator {
);
}
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(),
@ -291,6 +300,27 @@ impl SubagentCoordinator {
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
@ -337,7 +367,6 @@ impl SubagentCoordinator {
}
/// Snapshot all currently-running subagents for compaction state context.
///
/// Returns one `ActiveSubagentSummary` per entry in the `active` map.
/// 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).
@ -347,7 +376,11 @@ impl SubagentCoordinator {
/// compaction since it happens once and the reminder is static.
#[cfg(test)]
pub fn active_summaries(&self) -> Vec<ActiveSubagentSummary> {
self.active.values().map(tracker_to_summary).collect()
self.active
.values()
.filter(|t| !t.owner.is_workflow())
.map(tracker_to_summary)
.collect()
}
pub fn active_summaries_for(
&self,
@ -355,11 +388,12 @@ impl SubagentCoordinator {
) -> Vec<ActiveSubagentSummary> {
self.active
.values()
.filter(|t| t.parent_session_id == parent_session_id)
.filter(|t| {
t.parent_session_id == parent_session_id && !t.owner.is_workflow()
})
.map(tracker_to_summary)
.collect()
}
/// Return seeds for all running subagents belonging to `parent_session_id`.
///
/// Each seed carries copied identity metadata plus a cloned
/// `SessionSignalsHandle` so the caller can resolve live progress
@ -375,7 +409,9 @@ impl SubagentCoordinator {
) -> Vec<RunningSubagentListSeed> {
self.active
.values()
.filter(|t| t.parent_session_id == parent_session_id)
.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(),

View file

@ -16,7 +16,7 @@ use crate::terminal::AsyncTerminalRunner;
use crate::tools::ToolContext;
use crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
local_sandbox_telemetry, upload_config, upload_metadata, upload_session_state,
local_sandbox_telemetry, upload_metadata, upload_session_state,
upload_subagent_metadata, upload_turn_result,
};
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
@ -43,6 +43,17 @@ pub(super) fn strip_task_tools_at_max_depth(
prune_orphaned_background_task_tools(tool_config);
stripped
}
pub(super) fn canonical_total_tokens(totals: &xai_chat_state::UsageTotals) -> u64 {
totals.total_tokens()
}
pub(super) fn usage_is_incomplete(
ledger_incomplete: bool,
cancellation_may_hide_usage: bool,
_known_total_tokens: u64,
_has_usage_entries: bool,
) -> bool {
ledger_incomplete || cancellation_may_hide_usage
}
pub(super) fn task_model_override_error(
requested: Option<&str>,
provenance: ModelOverrideProvenance,
@ -81,10 +92,15 @@ pub(crate) async fn handle_subagent_request(
gateway: &GatewaySender,
) {
let start = std::time::Instant::now();
let mut parent_wait_guard = (!request.run_in_background)
let mut parent_wait_guard = subagent_blocks_parent_turn(&request)
.then(|| crate::tools::tool_context::BlockingWaitGuard::enter(
ctx.parent_blocking_wait_depth.clone(),
));
if request.owner.is_workflow() && request.cancel_token.is_cancelled() {
parent_wait_guard.take();
send_pre_spawn_cancelled(request, "Subagent was cancelled");
return;
}
let Some(mut definition) = resolve_agent_definition(&request.subagent_type, &ctx)
else {
let msg = format!("Unknown subagent type: {}", request.subagent_type);
@ -112,7 +128,7 @@ pub(crate) async fn handle_subagent_request(
}
let run_in_background = request.run_in_background
|| definition.background.unwrap_or(false);
let cancel_token = CancellationToken::new();
let cancel_token = request.cancel_token.clone();
coordinator
.borrow_mut()
.insert_pending(PendingSubagent {
@ -122,6 +138,7 @@ pub(crate) async fn handle_subagent_request(
persona: request.runtime_overrides.persona.clone(),
parent_prompt_id: request.parent_prompt_id.clone(),
parent_session_id: ctx.parent_session_id.clone(),
owner: request.owner.clone(),
started_at: start,
run_in_background,
surface_completion: request.surface_completion,
@ -411,6 +428,10 @@ pub(crate) async fn handle_subagent_request(
"Resolved runtime overrides for subagent"
);
}
effective_runtime.capability_mode = xai_grok_subagent_resolution::intersect_capability_modes(
effective_runtime.capability_mode,
definition.capability_mode,
);
if let Some(mode) = effective_runtime.capability_mode {
mode.filter_tool_config(&mut definition.tool_config);
tracing::info!(
@ -429,6 +450,17 @@ pub(crate) async fn handle_subagent_request(
"Stripped task tool from child at max depth"
);
}
if request.owner.is_workflow() {
definition
.tool_config
.tools
.retain(|tool| {
!matches!(
tool.id.rsplit(':').next(), Some("scheduler_create" |
"scheduler_list" | "scheduler_delete")
)
});
}
if request.fork_context {
effective_runtime.model = Some(ctx.model_id.0.to_string());
}
@ -661,6 +693,7 @@ pub(crate) async fn handle_subagent_request(
role: effective_runtime.role_name.clone(),
model: Some(effective_model_id.0.to_string()),
resumed_from: request.resume_from.clone(),
workflow_run_id: request.owner.workflow_run_id().map(str::to_string),
},
ctx.parent_cmd_tx.as_ref(),
);
@ -759,6 +792,12 @@ pub(crate) async fn handle_subagent_request(
)
.with_hunk_tracking_enabled(ctx.hunk_tracking_enabled);
tool_ctx.subagent_event_tx = Some(ctx.subagent_event_tx.clone());
let task_output_budget = request
.runtime_overrides
.output_token_budget
.map(crate::tools::tool_context::TaskOutputTokenBudget::limited);
tool_ctx.task_output_token_budget = task_output_budget.clone();
tool_ctx.sampler_retry_only_before_output = task_output_budget.is_some();
tool_ctx.monitor_event_buffer = Some(MonitorEventBuffer::default());
tool_ctx.subagent_depth = child_depth;
tool_ctx.lsp = ctx.lsp.clone();
@ -859,7 +898,7 @@ pub(crate) async fn handle_subagent_request(
memory_dir.display()
);
definition.prompt_body = Some(
definition.prompt_body.unwrap_or_default() + &injection,
definition.prompt_body.unwrap_or_default() + injection.as_str(),
);
}
}
@ -1051,7 +1090,7 @@ pub(crate) async fn handle_subagent_request(
agent_name: Some(definition.name.clone()),
reasoning_effort: Some(effective_sampling_config.reasoning_effort),
});
let forked_tool_override = if verbatim_mirror_fork {
let forked_tool_override = if verbatim_mirror_fork && !request.owner.is_workflow() {
ctx.parent_tool_snapshot.clone()
} else {
None
@ -1124,6 +1163,7 @@ pub(crate) async fn handle_subagent_request(
None,
None,
None,
Vec::new(),
None,
if verbatim_mirror_fork {
None
@ -1164,6 +1204,7 @@ pub(crate) async fn handle_subagent_request(
ctx.app_builder_deployer_config.clone(),
ctx.write_file_enabled,
ctx.goal_enabled,
ctx.background_workflows_enabled,
true,
ctx.ask_user_question_enabled,
ctx.client_hooks.clone(),
@ -1192,7 +1233,11 @@ pub(crate) async fn handle_subagent_request(
std::mem::take(&mut ctx.remote_settings),
std::mem::take(&mut ctx.laziness_debug_log),
ctx.parent_terminal_backend.clone(),
ctx.parent_scheduler_handle.clone(),
if request.owner.is_workflow() {
None
} else {
ctx.parent_scheduler_handle.clone()
},
subagent_max_turns,
forked_tool_override,
)
@ -1245,6 +1290,7 @@ pub(crate) async fn handle_subagent_request(
subagent_id: request.id.clone(),
parent_session_id: ctx.parent_session_id.clone(),
parent_prompt_id: request.parent_prompt_id.clone(),
owner: request.owner.clone(),
child_session_id: child_session_id.clone(),
subagent_type: request.subagent_type.clone(),
persona: effective_runtime.persona.clone(),
@ -1316,7 +1362,7 @@ pub(crate) async fn handle_subagent_request(
screen_mode: None,
verbatim: true,
traceparent: xai_file_utils::trace_context::current_traceparent(),
json_schema: None,
json_schema: request.runtime_overrides.output_schema.clone(),
send_now: false,
admission: None,
respond_to: prompt_tx,
@ -1347,10 +1393,16 @@ pub(crate) async fn handle_subagent_request(
None => std::future::pending::<()>().await,
}
};
let budget = async {
if request.await_to_completion {
std::future::pending::<()>().await
} else {
tokio::time::sleep(subagent_await_budget()).await
}
};
tokio::select! {
biased; outcome = & mut fut => ForegroundWait::Done(outcome), _ =
parent_await_dropped => ForegroundWait::ParentGone, _ =
tokio::time::sleep(subagent_await_budget()) =>
parent_await_dropped => ForegroundWait::ParentGone, _ = budget =>
ForegroundWait::Budget,
}
};
@ -1363,13 +1415,22 @@ pub(crate) async fn handle_subagent_request(
}
ForegroundWait::ParentGone => {
parent_wait_guard.take();
tracing::info!(
subagent_id = % request.id,
"foreground subagent await abandoned by its parent turn; detaching child to background (child keeps running)",
);
if !cancel_token.is_cancelled() {
request.run_in_background = true;
coordinator.borrow_mut().mark_backgrounded(&request.id);
if request.owner.is_workflow() {
tracing::info!(
subagent_id = % request.id, workflow_run_id = ? request.owner
.workflow_run_id(),
"workflow subagent result receiver dropped; cancelling child",
);
cancel_token.cancel();
} else {
tracing::info!(
subagent_id = % request.id,
"foreground subagent await abandoned by its parent turn; detaching child to background (child keeps running)",
);
if !cancel_token.is_cancelled() {
request.run_in_background = true;
coordinator.borrow_mut().mark_backgrounded(&request.id);
}
}
fut.await
}
@ -1400,9 +1461,11 @@ pub(crate) async fn handle_subagent_request(
};
let duration_ms = start.elapsed().as_millis() as u64;
let mut turn_token_totals: Option<(u64, u64, u64)> = None;
let mut cancellation_may_hide_usage = false;
let mut result = match wait_outcome {
SubagentWaitOutcome::Cancelled => {
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
cancellation_may_hide_usage = turns > 0 || tool_calls > 0;
SubagentResult {
success: false,
cancelled: true,
@ -1456,6 +1519,7 @@ pub(crate) async fn handle_subagent_request(
},
),
) => {
cancellation_may_hide_usage = true;
let reason = cancellation_error_message(category, context.as_ref());
SubagentResult {
success: false,
@ -1478,6 +1542,9 @@ pub(crate) async fn handle_subagent_request(
turns,
duration_ms,
tokens_used: result_tokens,
output_tokens_used: 0,
output_usage_incomplete: true,
total_tokens_used: 0,
worktree_path: worktree_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
@ -1515,32 +1582,76 @@ pub(crate) async fn handle_subagent_request(
turns,
duration_ms,
tokens_used: result_tokens,
output_tokens_used: 0,
output_usage_incomplete: true,
total_tokens_used: 0,
worktree_path: worktree_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
backgrounded: false,
}
}
Ok(Ok(_)) => {
SubagentResult {
success: true,
output: if final_text.is_empty() {
std::sync::Arc::from(
format!(
"Subagent '{}' ({}) completed successfully. {} tool calls, {} turns.",
request.description, request.subagent_type, tool_calls,
turns
),
Ok(
Ok(crate::session::commands::PromptTurnOk { structured_output, .. }),
) => {
let wanted_schema = request
.runtime_overrides
.output_schema
.is_some();
let (success, error, output) = match (
wanted_schema,
structured_output,
) {
(true, Some(Ok(value))) => {
(true, None, std::sync::Arc::from(value.to_string()))
}
(true, Some(Err(e))) => {
(
false,
Some(format!("structured output validation failed: {e}")),
std::sync::Arc::from(final_text),
)
} else {
std::sync::Arc::from(final_text)
},
}
(true, None) => {
(
false,
Some(
"structured output requested but none produced".to_string(),
),
std::sync::Arc::from(final_text),
)
}
(false, _) => {
(
true,
None,
if final_text.is_empty() {
std::sync::Arc::from(
format!(
"Subagent '{}' ({}) completed successfully. {} tool calls, {} turns.",
request.description, request.subagent_type, tool_calls,
turns
),
)
} else {
std::sync::Arc::from(final_text)
},
)
}
};
SubagentResult {
success,
error,
output,
subagent_id: request.id.clone(),
child_session_id: child_session_id.0.to_string(),
tool_calls,
turns,
duration_ms,
tokens_used: result_tokens,
output_tokens_used: 0,
output_usage_incomplete: true,
total_tokens_used: 0,
worktree_path: worktree_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
@ -1548,6 +1659,7 @@ pub(crate) async fn handle_subagent_request(
}
}
Ok(Err(e)) => {
cancellation_may_hide_usage = was_cancelled;
SubagentResult {
success: false,
cancelled: was_cancelled,
@ -1570,6 +1682,7 @@ pub(crate) async fn handle_subagent_request(
}
}
Err(_) => {
cancellation_may_hide_usage = was_cancelled;
SubagentResult {
success: false,
cancelled: was_cancelled,
@ -1719,10 +1832,6 @@ pub(crate) async fn handle_subagent_request(
sandbox: local_sandbox_telemetry(),
};
upload_metadata(&trace_ctx, metadata).await;
if let Some(ref agent_config) = ctx.agent_config {
upload_config(&trace_ctx, agent_config).await;
}
crate::upload::config_files::upload_config_files(&trace_ctx).await;
let resolved_model = child_handle
.get_model_metadata()
.await
@ -1789,14 +1898,42 @@ pub(crate) async fn handle_subagent_request(
} else {
0
};
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
.chat_state_handle
.try_get_session_usage()
.await
{
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
Err(()) => (None, true),
let task_budget_usage = task_output_budget.as_ref().map(|budget| budget.usage());
let (
subagent_usage_by_model,
subagent_usage_incomplete,
output_tokens_used,
total_tokens_used,
) = match child_handle.chat_state_handle.try_get_session_usage().await {
Ok(u) => {
let output_tokens = u.totals.output_tokens;
let total_tokens = canonical_total_tokens(&u.totals);
let has_usage_entries = !u.by_model.is_empty();
let usage_incomplete = usage_is_incomplete(
u.incomplete,
cancellation_may_hide_usage,
total_tokens,
has_usage_entries,
);
(
Some(u.by_model.into_iter().collect::<Vec<_>>()),
usage_incomplete,
(!usage_incomplete).then_some(output_tokens),
Some(total_tokens),
)
}
Err(()) => (None, true, None, None),
};
result.total_tokens_used = total_tokens_used.unwrap_or(0);
if let Some((task_spent, task_incomplete)) = task_budget_usage {
result.output_tokens_used = output_tokens_used.unwrap_or(task_spent);
result.output_usage_incomplete = task_incomplete || subagent_usage_incomplete
|| output_tokens_used.is_none();
} else {
result.output_tokens_used = output_tokens_used.unwrap_or(0);
result.output_usage_incomplete = subagent_usage_incomplete
|| output_tokens_used.is_none();
}
let fold_acked = match subagent_usage_by_model {
None => false,
Some(ref by_model) if by_model.is_empty() && !subagent_usage_incomplete => true,

View file

@ -22,8 +22,8 @@ use crate::terminal::AsyncTerminalRunner;
use crate::tools::ToolContext;
use crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
local_sandbox_telemetry, upload_config, upload_metadata, upload_session_state,
upload_subagent_metadata, upload_turn_result,
local_sandbox_telemetry, upload_metadata, upload_session_state, upload_subagent_metadata,
upload_turn_result,
};
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
use agent_client_protocol as acp;
@ -59,6 +59,7 @@ pub(crate) struct SubagentTracker {
pub subagent_id: String,
pub parent_session_id: String,
pub parent_prompt_id: Option<String>,
pub owner: SubagentOwner,
pub child_session_id: acp::SessionId,
pub subagent_type: String,
pub persona: Option<String>,
@ -227,6 +228,7 @@ pub(crate) struct SubagentSpawnContext {
pub write_file_enabled: bool,
/// Whether goal mode (`/goal`) is enabled.
pub goal_enabled: bool,
pub background_workflows_enabled: bool,
/// Whether the `ask_user_question` tool is exposed to this subagent,
/// inherited from the parent session (see `build_subagent_spawn_context`).
pub ask_user_question_enabled: bool,
@ -363,7 +365,7 @@ pub(crate) struct SubagentSpawnContext {
/// Parent's `blocking_wait_depth` (same `Arc`). A foreground spawn holds a
/// `BlockingWaitGuard` on it for the blocking await so `queue_input` routes
/// a prompt sent during the wait onto send-now; never for background spawns.
pub parent_blocking_wait_depth: Arc<std::sync::atomic::AtomicUsize>,
pub parent_blocking_wait_depth: Arc<crate::tools::tool_context::BlockingWaitState>,
}
impl SubagentSpawnContext {
/// Check if a subagent is enabled via the toggle config.
@ -478,6 +480,7 @@ pub(crate) struct CompletedSubagent {
pub subagent_id: String,
pub parent_session_id: String,
pub parent_prompt_id: Option<String>,
pub owner: SubagentOwner,
pub child_session_id: String,
pub description: String,
pub subagent_type: String,
@ -539,6 +542,7 @@ pub(crate) struct PendingSubagent {
pub persona: Option<String>,
pub parent_prompt_id: Option<String>,
pub parent_session_id: String,
pub owner: SubagentOwner,
pub started_at: std::time::Instant,
pub run_in_background: bool,
/// Mirrors `SubagentRequest::surface_completion`.
@ -559,6 +563,7 @@ struct FailureCompletion<'a> {
description: String,
parent_prompt_id: Option<String>,
parent_session_id: String,
owner: SubagentOwner,
persona: Option<String>,
started_at: std::time::Instant,
error: &'a str,
@ -618,6 +623,7 @@ pub(crate) struct SubagentCoordinator {
/// marks ledgers by itself (a true apply-miss marks them at fold time).
/// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model.
subagent_usage_not_applied_prompts: std::collections::HashSet<String>,
loop_owned: HashMap<String, String>,
}
/// Cap on the completed map (entries are small: identity, counts, and an
/// error string; successful output text lives in `output.json`).
@ -768,6 +774,9 @@ pub(crate) struct SubagentProvenance {
/// ID of the source subagent this session was resumed from.
pub(crate) resumed_from: Option<String>,
}
fn subagent_blocks_parent_turn(request: &SubagentRequest) -> bool {
!request.run_in_background && !request.owner.is_workflow()
}
/// Convert a `std::time::Instant` to approximate epoch milliseconds.
///
/// `Instant` has no absolute epoch, so we compute the offset from
@ -2110,6 +2119,15 @@ pub(crate) fn send_failure(request: SubagentRequest, error: &str) {
..Default::default()
});
}
fn send_pre_spawn_cancelled(request: SubagentRequest, error: &str) {
let _ = request.result_tx.send(SubagentResult {
success: false,
cancelled: true,
error: Some(error.to_string()),
subagent_id: request.id,
..Default::default()
});
}
/// Fail BEFORE `insert_pending`. Sends via oneshot; for background-mode
/// requests also records a synthetic `CompletedSubagent` + emits a
/// `SubagentFinished` notification (persisted + live).
@ -2125,6 +2143,7 @@ fn send_pre_spawn_failure(
subagent_type,
description,
parent_prompt_id,
owner,
result_tx,
run_in_background,
surface_completion,
@ -2138,6 +2157,7 @@ fn send_pre_spawn_failure(
description,
parent_prompt_id,
ctx.parent_session_id.clone(),
owner,
error,
surface_completion,
);

View file

@ -1,8 +1,26 @@
#![cfg_attr(rustfmt, rustfmt::skip)]
use super::*;
use super::handle_request::{canonical_total_tokens, usage_is_incomplete};
use crate::test_support::lsp_runtime::{
DummyLspDispatch, ctx_with_toggle, make_request, test_gateway,
};
#[test]
fn canonical_total_tokens_does_not_double_count_reasoning() {
let totals = xai_chat_state::UsageTotals {
input_tokens: 100,
output_tokens: 40,
reasoning_tokens: 25,
..Default::default()
};
assert_eq!(canonical_total_tokens(& totals), 140);
}
#[test]
fn cancellation_makes_an_otherwise_complete_usage_snapshot_incomplete() {
assert!(usage_is_incomplete(false, true, 0, false));
assert!(usage_is_incomplete(false, true, 10, false));
assert!(! usage_is_incomplete(false, false, 0, false));
assert!(usage_is_incomplete(true, false, 0, false));
}
/// Invariant: resolving a subagent applies the parent session's
/// `--tools`/`--disallowed-tools`/`--permission-mode` — driven through
/// `resolve_agent_definition` so the spawn path can't skip them.
@ -336,6 +354,7 @@ fn lookup_returns_initializing_for_pending_subagent() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -367,6 +386,7 @@ async fn running_gauge_tracks_pending_and_active() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -396,6 +416,7 @@ async fn running_gauge_tracks_pending_and_active() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -414,6 +435,7 @@ async fn running_gauge_tracks_pending_and_active() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -581,7 +603,10 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest {
runtime_overrides: Default::default(),
run_in_background: true,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: SubagentOwner::Task,
cancel_token: CancellationToken::new(),
result_tx,
}
}
@ -738,6 +763,7 @@ fn fail_pending(coordinator: &mut SubagentCoordinator, id: &str, surface: bool)
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: surface,
@ -786,6 +812,7 @@ fn remove_pending_clears_entry() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -810,6 +837,7 @@ fn move_pending_to_failed_creates_completed_entry() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -848,6 +876,7 @@ fn move_pending_to_failed_fires_completion_notify() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -877,6 +906,7 @@ fn move_pending_to_cancelled_creates_cancelled_entry() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -905,6 +935,7 @@ fn completed_with_output(
CompletedSubagent {
subagent_id: id.into(),
parent_session_id: String::new(),
owner: SubagentOwner::Task,
parent_prompt_id: None,
child_session_id: String::new(),
description: "task".into(),
@ -1076,6 +1107,7 @@ fn cancel_with_outcome_fires_pending_token() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -1115,8 +1147,7 @@ async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() {
);
assert!(
matches!(coordinator.cancel_with_outcome("sub-done"),
SubagentCancelOutcome::AlreadyFinished { status }
if status == "completed")
SubagentCancelOutcome::AlreadyFinished { status } if status == "completed")
);
assert!(
matches!(coordinator.cancel_with_outcome("nonexistent"),
@ -1136,6 +1167,7 @@ fn cancel_by_parent_prompt_id_fires_matching_pending_token() {
persona: None,
parent_prompt_id: Some("prompt-A".to_string()),
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -1150,6 +1182,7 @@ fn cancel_by_parent_prompt_id_fires_matching_pending_token() {
persona: None,
parent_prompt_id: Some("prompt-B".to_string()),
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -1176,6 +1209,7 @@ fn completed_takes_precedence_over_pending_in_lookup() {
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -1294,6 +1328,7 @@ fn dummy_tracker(
SubagentTracker {
subagent_id: subagent_id.into(),
parent_session_id: parent_session_id.into(),
owner: SubagentOwner::Task,
parent_prompt_id: None,
child_session_id: acp::SessionId::new(subagent_id),
subagent_type: subagent_type.into(),
@ -1419,7 +1454,7 @@ fn explicit_override_takes_precedence_over_role() {
);
assert_eq!(resolved.model.as_deref(), Some("explicit-model"));
assert_eq!(
resolved.capability_mode, Some(xai_tool_types::SubagentCapabilityMode::All)
resolved.capability_mode, Some(xai_tool_types::SubagentCapabilityMode::ReadOnly)
);
}
#[test]
@ -1914,8 +1949,7 @@ fn verbatim_fork_keeps_items_byte_for_byte_when_small() {
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p |
matches!(p, ContentPart::Text { text }
if text.contains(needle)))
matches!(p, ContentPart::Text { text } if text.contains(needle)))
)
})
};
@ -1999,8 +2033,7 @@ fn verbatim_fork_falls_back_to_summary_when_oversize() {
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p | matches!(p,
ContentPart::Text { text }
if text.contains("<background_context>")))
ContentPart::Text { text } if text.contains("<background_context>")))
)
});
assert!(has_blob, "oversize fallback must produce a background_context blob");
@ -2079,7 +2112,10 @@ fn bootstrap_test_request(fork_context: bool) -> SubagentRequest {
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: false,
await_to_completion: false,
fork_context,
owner: SubagentOwner::Task,
cancel_token: CancellationToken::new(),
result_tx,
}
}
@ -2940,6 +2976,7 @@ async fn cancel_pending_subagent_at_promote_emits_exactly_one_cancelled_finish()
persona: None,
parent_prompt_id: None,
parent_session_id: ctx.parent_session_id.clone(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -3041,6 +3078,7 @@ async fn run_promote_cancel_with_worktree(
persona: None,
parent_prompt_id: None,
parent_session_id: ctx.parent_session_id.clone(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -3156,6 +3194,7 @@ fn record_pre_spawn_failure_populates_completed_and_summary() {
"bg job".to_string(),
Some("prompt-1".to_string()),
"parent-1".to_string(),
SubagentOwner::Task,
"Unknown subagent type: invented",
true,
);
@ -3189,6 +3228,7 @@ fn record_pre_spawn_failure_skips_buffer_when_flag_false() {
"bg job".to_string(),
None,
"parent-1".to_string(),
SubagentOwner::Task,
"Unknown subagent type: invented",
false,
);
@ -3207,6 +3247,7 @@ async fn record_pre_spawn_failure_notifies_waiters() {
"bg job".to_string(),
None,
"parent-1".to_string(),
SubagentOwner::Task,
"error",
true,
);
@ -3227,6 +3268,7 @@ async fn record_pre_spawn_failure_notifies_all_waiters() {
"bg job".to_string(),
None,
"parent-1".to_string(),
SubagentOwner::Task,
"error",
true,
);
@ -3245,6 +3287,7 @@ fn record_pre_spawn_failure_clears_stale_pending_entry() {
persona: None,
parent_prompt_id: Some("prompt-X".to_string()),
parent_session_id: "parent-1".to_string(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
@ -3259,6 +3302,7 @@ fn record_pre_spawn_failure_clears_stale_pending_entry() {
"bg job".to_string(),
Some("prompt-X".to_string()),
"parent-1".to_string(),
SubagentOwner::Task,
"Unknown subagent type: invented",
true,
);

View file

@ -327,6 +327,7 @@ fn resumable_source_returns_info_for_completed_subagent() {
subagent_id: "sub-resume".into(),
parent_session_id: "parent-1".into(),
parent_prompt_id: Some("prompt-1".into()),
owner: SubagentOwner::Task,
child_session_id: "child-resume".into(),
description: "resumable task".into(),
subagent_type: "general-purpose".into(),
@ -713,6 +714,35 @@ fn coordinator_with_completed(id: &str) -> SubagentCoordinator {
);
coordinator
}
#[tokio::test]
async fn loop_unit_active_tracks_and_prunes_owned_subagents() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert(
dummy_tracker("iter-1", "root-sess", "general-purpose", "loop: watch ci"),
);
coordinator.record_loop_owner("iter-1", "task-42");
assert!(coordinator.loop_unit_active("task-42"));
assert!(! coordinator.loop_unit_active("other-task"));
assert_eq!(
coordinator.loop_task_id_of_child_session("iter-1"), Some("task-42".to_string())
);
assert_eq!(coordinator.loop_task_id_of_child_session("unknown"), None);
coordinator
.move_to_completed(
"iter-1",
"loop: watch ci".into(),
"general-purpose".into(),
SubagentResult {
success: true,
subagent_id: "iter-1".into(),
child_session_id: "iter-1".into(),
..Default::default()
},
None,
);
assert!(! coordinator.loop_unit_active("task-42"));
}
/// End-to-end glue: gate ON + a worktree present runs the completion
/// sequence (snapshot → persist ref to meta.json AND in-memory → remove)
/// and asserts all three post-conditions hold together.
@ -1343,6 +1373,7 @@ fn resumable_source_rejects_cross_session_lookup() {
subagent_id: "sub-other".into(),
parent_session_id: "session-A".into(),
parent_prompt_id: None,
owner: SubagentOwner::Task,
child_session_id: "child-other".into(),
description: "other task".into(),
subagent_type: "explore".into(),
@ -1640,6 +1671,7 @@ fn reconcile_orphan_skips_pending_ids_in_live_registry() {
persona: None,
parent_prompt_id: None,
parent_session_id: "parent-x".to_string(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -2098,6 +2130,7 @@ fn notification_subagent_spawned_includes_resumed_from() {
role: None,
model: None,
resumed_from: Some("prev-agent-id".into()),
workflow_run_id: None,
};
let json = serde_json::to_value(&notification).unwrap();
assert_eq!(json["resumed_from"], "prev-agent-id");
@ -2118,6 +2151,7 @@ fn notification_subagent_spawned_includes_resumed_from() {
role: None,
model: None,
resumed_from: None,
workflow_run_id: None,
};
let json = serde_json::to_value(&fresh).unwrap();
assert!(json.get("resumed_from").is_none());
@ -2162,6 +2196,7 @@ fn completed_subagent_propagates_resumed_from() {
subagent_id: "sub-prov".into(),
parent_session_id: "parent".into(),
parent_prompt_id: Some("prompt-1".into()),
owner: SubagentOwner::Task,
child_session_id: "child-prov".into(),
description: "provenance test".into(),
subagent_type: "general-purpose".into(),
@ -2299,6 +2334,7 @@ async fn outstanding_for_prompt_includes_pending_and_active() {
persona: None,
parent_prompt_id: Some("prompt-X".to_string()),
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -2411,6 +2447,7 @@ fn outstanding_for_prompt_returns_sorted_ids() {
persona: None,
parent_prompt_id: Some("p".to_string()),
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
@ -2425,6 +2462,7 @@ fn outstanding_for_prompt_returns_sorted_ids() {
persona: None,
parent_prompt_id: Some("p".to_string()),
parent_session_id: String::new(),
owner: SubagentOwner::Task,
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,