Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -630,7 +630,6 @@ pub struct Requirements {
|
|||
pub struct RuntimeResolutionContext<'a> {
|
||||
pub raw_config: &'a toml::Value,
|
||||
pub remote_settings: Option<&'a crate::util::config::RemoteSettings>,
|
||||
pub cwd: Option<&'a std::path::Path>,
|
||||
pub is_headless: bool,
|
||||
/// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote.
|
||||
pub cli_subagents: Option<bool>,
|
||||
|
|
@ -1151,6 +1150,9 @@ pub struct MarketplaceConfig {
|
|||
/// Written/read out-of-band by `extensions::marketplace`, opaque so a wrong-typed value can't fail load.
|
||||
#[serde(default)]
|
||||
pub official_marketplace_auto_installed: Option<toml::Value>,
|
||||
/// Written/read out-of-band by `extensions::marketplace`, opaque so a wrong-typed value can't fail load.
|
||||
#[serde(default)]
|
||||
pub default_skills_installs_purged: Option<toml::Value>,
|
||||
}
|
||||
/// A single `[[marketplace.sources]]` entry.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -1455,11 +1457,11 @@ pub struct Config {
|
|||
/// Keys are agent names, values are booleans. Omitted agents default to enabled.
|
||||
#[serde(skip)]
|
||||
pub subagent_toggle: std::collections::HashMap<String, bool>,
|
||||
/// Per-subagent role definitions from `[subagents.roles]` in config.toml
|
||||
/// and `.grok/roles/*.toml` file discovery.
|
||||
/// Trust-independent roles from inline, user, and bundled sources.
|
||||
#[serde(skip)]
|
||||
pub subagent_roles:
|
||||
std::collections::HashMap<String, xai_grok_subagent_resolution::config::SubagentRole>,
|
||||
/// Trust-independent personas from inline, user, and bundled sources.
|
||||
#[serde(skip)]
|
||||
pub subagent_personas:
|
||||
std::collections::HashMap<String, xai_grok_subagent_resolution::config::SubagentPersona>,
|
||||
|
|
@ -1885,18 +1887,13 @@ impl Config {
|
|||
config.apply_env_overrides();
|
||||
Ok(config)
|
||||
}
|
||||
/// Populate `#[serde(skip)]` subagent fields from `SubagentsConfig::resolve()`.
|
||||
/// Populate trust-independent `#[serde(skip)]` subagent base fields.
|
||||
///
|
||||
/// Must be called after `new_from_toml_cfg` on the **primary startup path**
|
||||
/// before the config is handed to `MvpAgent`. Model-reload and API-key-reload
|
||||
/// paths only read model/key fields and do not need this call.
|
||||
pub fn resolve_subagents(
|
||||
&mut self,
|
||||
cli_flag: bool,
|
||||
raw_config: &toml::Value,
|
||||
cwd: Option<&std::path::Path>,
|
||||
) {
|
||||
let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config, cwd);
|
||||
/// before the config is handed to `MvpAgent`. Project definitions are overlaid
|
||||
/// per cwd after that cwd's authoritative folder-trust resolve.
|
||||
pub fn resolve_subagents(&mut self, cli_flag: bool, raw_config: &toml::Value) {
|
||||
let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config);
|
||||
self.subagents_enabled = sa.enabled;
|
||||
self.subagent_model_overrides = sa.models;
|
||||
self.subagent_toggle = sa.toggle;
|
||||
|
|
@ -1906,7 +1903,7 @@ impl Config {
|
|||
/// Resolve all `#[serde(skip)]` runtime fields that have resolver functions.
|
||||
///
|
||||
/// Call immediately after `new_from_toml_cfg()`. Fields resolved:
|
||||
/// - subagents (6 fields) via `SubagentsConfig::resolve`
|
||||
/// - subagents base layers (6 fields) via `SubagentsConfig::resolve`
|
||||
/// - respect_gitignore via `ToolsConfig::resolve`
|
||||
/// - disable_zdr_incompatible_tools via `ToolsConfig::resolve`
|
||||
/// - managed_mcps_enabled via `ManagedMcpsConfig::resolve`
|
||||
|
|
@ -1924,7 +1921,7 @@ impl Config {
|
|||
self.web_search_model_override = ctx.cli_web_search_model.map(|s| s.to_owned());
|
||||
self.session_summary_model_override = ctx.cli_session_summary_model.map(|s| s.to_owned());
|
||||
let cli_flag = ctx.cli_subagents.unwrap_or(false);
|
||||
self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd);
|
||||
self.resolve_subagents(cli_flag, ctx.raw_config);
|
||||
let tools = crate::config::ToolsConfig::resolve(ctx.raw_config);
|
||||
self.respect_gitignore = match self.requirements.respect_gitignore.pinned() {
|
||||
Some(pinned) => pinned,
|
||||
|
|
@ -1975,16 +1972,11 @@ impl Config {
|
|||
self.compat_resolved = resolve_compat_config(&self.compat, ctx.remote_settings);
|
||||
}
|
||||
/// Re-resolve eagerly-resolved runtime fields using the current `Config`
|
||||
/// state and fresh `raw_config` + `cwd`. Builds a
|
||||
/// [`RuntimeResolutionContext`] from the CLI flags already stored on this
|
||||
/// `Config` so callers don't need to manually extract each field.
|
||||
/// state and fresh `raw_config`. Builds a [`RuntimeResolutionContext`] from
|
||||
/// the CLI flags already stored on this `Config`.
|
||||
///
|
||||
/// Integration test coverage: `tests/test_settings_refresh.rs`.
|
||||
pub fn re_resolve_runtime_fields(
|
||||
&mut self,
|
||||
raw_config: &toml::Value,
|
||||
cwd: Option<&std::path::Path>,
|
||||
) {
|
||||
pub fn re_resolve_runtime_fields(&mut self, raw_config: &toml::Value) {
|
||||
let remote_settings = self.remote_settings.clone();
|
||||
let cli_web_search_model = self.web_search_model_override.clone();
|
||||
let cli_session_summary_model = self.session_summary_model_override.clone();
|
||||
|
|
@ -1992,7 +1984,6 @@ impl Config {
|
|||
let ctx = RuntimeResolutionContext {
|
||||
raw_config,
|
||||
remote_settings: remote_settings.as_ref(),
|
||||
cwd,
|
||||
is_headless: self.mode == AgentMode::Headless,
|
||||
cli_subagents: self.cli_subagents,
|
||||
cli_web_search_model: cli_web_search_model.as_deref(),
|
||||
|
|
@ -2599,6 +2590,17 @@ impl Config {
|
|||
.resolve()
|
||||
.value
|
||||
}
|
||||
pub(crate) fn resolve_compaction_tool_choice(
|
||||
&self,
|
||||
) -> crate::util::config::CompactionToolChoice {
|
||||
crate::util::config::resolve_compaction_tool_choice_from(
|
||||
env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE).as_deref(),
|
||||
self.features.compaction_tool_choice.as_deref(),
|
||||
self.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|r| r.compaction_tool_choice.as_deref()),
|
||||
)
|
||||
}
|
||||
/// Precedence: env `GROK_COMPACTION_DETAIL`, then config
|
||||
/// `features.compaction_detail`, then remote settings
|
||||
/// `remote_settings.compaction_detail`, then default (`verbose`). Drives the
|
||||
|
|
@ -3368,6 +3370,10 @@ struct DefaultModelJson {
|
|||
compaction_at_tokens: Option<CompactionAtTokens>,
|
||||
#[serde(default)]
|
||||
show_model_fingerprint: bool,
|
||||
#[serde(default)]
|
||||
auto_compact_threshold_percent: Option<u8>,
|
||||
#[serde(default)]
|
||||
system_prompt_label: Option<String>,
|
||||
}
|
||||
fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryConfig> {
|
||||
let root: serde_json::Value = serde_json::from_str(crate::models::DEFAULT_MODELS_JSON)
|
||||
|
|
@ -3402,8 +3408,8 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryCon
|
|||
name: m.name,
|
||||
description: m.description,
|
||||
context_window,
|
||||
auto_compact_threshold_percent: None,
|
||||
system_prompt_label: None,
|
||||
auto_compact_threshold_percent: m.auto_compact_threshold_percent,
|
||||
system_prompt_label: m.system_prompt_label,
|
||||
temperature: m.temperature,
|
||||
top_p: m.top_p,
|
||||
max_completion_tokens: m.max_completion_tokens,
|
||||
|
|
@ -4192,6 +4198,8 @@ pub struct Features {
|
|||
/// Feed the summarizer the verbatim conversation instead of the lossy rewrite; `None` = defer to env/remote settings/default (true).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compaction_verbatim_input: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compaction_tool_choice: Option<String>,
|
||||
/// Snapshot a completed subagent's isolated worktree into a durable git ref
|
||||
/// and delete its directory (resume rehydrates from the ref). This is the
|
||||
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
|
||||
|
|
@ -5153,7 +5161,6 @@ reasoning_effort = "low"
|
|||
RuntimeResolutionContext {
|
||||
raw_config: raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -5182,7 +5189,6 @@ reasoning_effort = "low"
|
|||
RuntimeResolutionContext {
|
||||
raw_config: raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: true,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -9215,6 +9221,7 @@ agent_type = "cursor"
|
|||
url = "https://mcp.test.com"
|
||||
[toolset.bash]
|
||||
timeout_secs = 120
|
||||
login_shell_capture = true
|
||||
[shortcuts]
|
||||
ctrl_k = "search"
|
||||
[grok_com_config]
|
||||
|
|
@ -9325,6 +9332,7 @@ agent_type = "cursor"
|
|||
let toml_str = r#"
|
||||
[marketplace]
|
||||
official_marketplace_auto_installed = "yes"
|
||||
default_skills_installs_purged = "yes"
|
||||
"#;
|
||||
let unused = unused_keys_from_toml(toml_str);
|
||||
assert!(unused.is_empty(), "got: {unused:?}");
|
||||
|
|
@ -9342,6 +9350,7 @@ agent_type = "cursor"
|
|||
deny = ["Bash(rm *)"]
|
||||
[marketplace]
|
||||
official_marketplace_auto_installed = true
|
||||
default_skills_installs_purged = true
|
||||
[ui]
|
||||
yollo = true
|
||||
"#,
|
||||
|
|
@ -10108,7 +10117,6 @@ hooks = true
|
|||
config.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: Some(&remote),
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10134,7 +10142,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10170,7 +10177,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: true,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10202,7 +10208,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: Some(&remote),
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10225,7 +10230,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10248,7 +10252,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: Some(true),
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10272,7 +10275,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10296,7 +10298,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: Some("custom-ws"),
|
||||
|
|
@ -10324,7 +10325,6 @@ hooks = true
|
|||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: Some(&remote),
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10347,7 +10347,6 @@ hooks = true
|
|||
let ctx = RuntimeResolutionContext {
|
||||
raw_config: &raw,
|
||||
remote_settings: None,
|
||||
cwd: None,
|
||||
is_headless: false,
|
||||
cli_subagents: None,
|
||||
cli_web_search_model: None,
|
||||
|
|
@ -10872,12 +10871,13 @@ default = "grok-4.5"
|
|||
#[test]
|
||||
fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||
let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default());
|
||||
let entry = prefetch_model_entry(dm, default_cw, ApiBackend::default());
|
||||
let mut prefetched = IndexMap::new();
|
||||
prefetched.insert("grok-build".to_owned(), entry);
|
||||
prefetched.insert(dm.to_owned(), entry);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
let entry = resolved.get("grok-build").expect("model must exist");
|
||||
let entry = resolved.get(dm).expect("model must exist");
|
||||
assert_ne!(
|
||||
entry.info.context_window.get(),
|
||||
default_cw,
|
||||
|
|
@ -10887,12 +10887,13 @@ default = "grok-4.5"
|
|||
#[test]
|
||||
fn resolve_model_list_does_not_override_explicitly_set_context_window() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let explicit_cw = 65_536;
|
||||
let entry = prefetch_model_entry("grok-build", explicit_cw, ApiBackend::default());
|
||||
let entry = prefetch_model_entry(dm, explicit_cw, ApiBackend::default());
|
||||
let mut prefetched = IndexMap::new();
|
||||
prefetched.insert("grok-build".to_owned(), entry);
|
||||
prefetched.insert(dm.to_owned(), entry);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
let entry = resolved.get("grok-build").expect("model must exist");
|
||||
let entry = resolved.get(dm).expect("model must exist");
|
||||
assert_eq!(
|
||||
entry.info.context_window.get(),
|
||||
explicit_cw,
|
||||
|
|
@ -10902,14 +10903,15 @@ default = "grok-4.5"
|
|||
#[test]
|
||||
fn resolve_model_list_inherits_agent_type_and_api_backend() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||
let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default());
|
||||
let entry = prefetch_model_entry(dm, default_cw, ApiBackend::default());
|
||||
let mut prefetched = IndexMap::new();
|
||||
prefetched.insert("grok-build".to_owned(), entry);
|
||||
prefetched.insert(dm.to_owned(), entry);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
let entry = resolved.get("grok-build").expect("model must exist");
|
||||
let entry = resolved.get(dm).expect("model must exist");
|
||||
let defaults = default_model_entries(&EndpointsConfig::default());
|
||||
if let Some(default) = defaults.get("grok-build") {
|
||||
if let Some(default) = defaults.get(dm) {
|
||||
if default.info.agent_type != DEFAULT_AGENT_TYPE {
|
||||
assert_eq!(
|
||||
entry.info.agent_type, default.info.agent_type,
|
||||
|
|
@ -10947,23 +10949,25 @@ default = "grok-4.5"
|
|||
#[test]
|
||||
fn resolve_model_list_prunes_bundled_entries_not_in_prefetch() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||||
let mut p = IndexMap::new();
|
||||
if let Some(e) = defs.shift_remove("grok-build") {
|
||||
p.insert("grok-build".to_string(), e);
|
||||
if let Some(e) = defs.shift_remove(dm) {
|
||||
p.insert(dm.to_string(), e);
|
||||
}
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
assert!(resolved.contains_key("grok-build"));
|
||||
assert!(resolved.contains_key(dm));
|
||||
let no_p = resolve_model_list(&cfg, None);
|
||||
assert!(no_p.contains_key("grok-build"));
|
||||
assert!(no_p.contains_key(dm));
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||||
let mut p = IndexMap::new();
|
||||
if let Some(e) = defs.shift_remove("grok-build") {
|
||||
p.insert("grok-build".to_string(), e);
|
||||
if let Some(e) = defs.shift_remove(dm) {
|
||||
p.insert(dm.to_string(), e);
|
||||
}
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
let sess: Vec<_> = resolved
|
||||
|
|
@ -10975,27 +10979,29 @@ default = "grok-4.5"
|
|||
.filter(|e| e.visible_for_auth(false))
|
||||
.collect();
|
||||
assert_eq!(sess.len(), 1);
|
||||
assert!(api.is_empty());
|
||||
assert_eq!(api.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let mut p = IndexMap::new();
|
||||
let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default());
|
||||
p.insert("secret-xyz".to_string(), e);
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
assert!(resolved.contains_key("secret-xyz"));
|
||||
assert!(!resolved.contains_key("grok-build"));
|
||||
assert!(!resolved.contains_key(dm));
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_prefetch_replaces_bundled_entirely() {
|
||||
let cfg = Config::default();
|
||||
let dm = crate::models::default_model();
|
||||
let mut p = IndexMap::new();
|
||||
let e = prefetch_model_entry("grok-4.5", 500_000, ApiBackend::Responses);
|
||||
p.insert("grok-4.5".to_string(), e);
|
||||
let e = prefetch_model_entry("other-model", 500_000, ApiBackend::Responses);
|
||||
p.insert("other-model".to_string(), e);
|
||||
let resolved = resolve_model_list(&cfg, Some(p));
|
||||
assert!(resolved.contains_key("grok-4.5"));
|
||||
assert!(!resolved.contains_key("grok-build"));
|
||||
assert!(resolved.contains_key("other-model"));
|
||||
assert!(!resolved.contains_key(dm));
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_empty_prefetch_yields_empty_base() {
|
||||
|
|
@ -11003,14 +11009,14 @@ default = "grok-4.5"
|
|||
let resolved = resolve_model_list(&cfg, Some(IndexMap::new()));
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
/// Regression: enterprise managed config aliases grok-build to their own
|
||||
/// endpoint with env_key. The bundled grok-build has supported_in_api=false.
|
||||
/// The config overlay must be visible to API-key users (env_key = BYOK).
|
||||
/// Regression: enterprise managed config overlays env_key on an oauth-only
|
||||
/// catalog entry. BYOK must force visibility for API-key users so a
|
||||
/// base `supported_in_api: false` does not leak into the overlay.
|
||||
#[test]
|
||||
fn byok_config_overlay_visible_to_api_key_users() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[model.grok-build]
|
||||
[model.enterprise-alias]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://inference.company.com/v1"
|
||||
env_key = "COMPANY_TOKEN"
|
||||
|
|
@ -11018,31 +11024,48 @@ default = "grok-4.5"
|
|||
)
|
||||
.unwrap();
|
||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
let entry = resolved.get("grok-build").expect("grok-build must exist");
|
||||
let mut base = prefetch_model_entry("enterprise-alias", 200_000, ApiBackend::default());
|
||||
base.info.supported_in_api = false;
|
||||
let mut prefetched = IndexMap::new();
|
||||
prefetched.insert("enterprise-alias".to_owned(), base);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
let entry = resolved
|
||||
.get("enterprise-alias")
|
||||
.expect("enterprise-alias must exist");
|
||||
assert!(
|
||||
entry.visible_for_auth(false),
|
||||
"BYOK config entry must be visible to API-key users — \
|
||||
bundled supported_in_api=false must not leak into credentialed overlays"
|
||||
env_key must override base supported_in_api=false"
|
||||
);
|
||||
}
|
||||
/// Guard: config overlay WITHOUT credentials must NOT override the
|
||||
/// bundled supported_in_api flag. Only BYOK triggers the override.
|
||||
/// Guard: config overlay WITHOUT credentials must NOT flip the
|
||||
/// bundled supported_in_api flag. Only BYOK triggers that override.
|
||||
#[test]
|
||||
fn plain_config_overlay_preserves_bundled_visibility() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
let dm = crate::models::default_model();
|
||||
let bundled = default_model_entries(&EndpointsConfig::default())
|
||||
.get(dm)
|
||||
.expect("bundled default must exist")
|
||||
.clone();
|
||||
let raw: toml::Value = toml::from_str(&format!(
|
||||
r#"
|
||||
[model.grok-build]
|
||||
[model."{dm}"]
|
||||
context_window = 300000
|
||||
"#,
|
||||
)
|
||||
"#
|
||||
))
|
||||
.unwrap();
|
||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
let entry = resolved.get("grok-build").expect("grok-build must exist");
|
||||
assert!(
|
||||
!entry.visible_for_auth(false),
|
||||
"non-BYOK config overlay must preserve bundled supported_in_api=false"
|
||||
let entry = resolved.get(dm).expect("bundled default must exist");
|
||||
assert_eq!(
|
||||
entry.visible_for_auth(false),
|
||||
bundled.visible_for_auth(false),
|
||||
"non-BYOK config overlay must preserve bundled supported_in_api"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.visible_for_auth(true),
|
||||
bundled.visible_for_auth(true),
|
||||
"non-BYOK config overlay must preserve bundled OAuth visibility"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
|
|||
let grok_home = crate::util::grok_home::grok_home();
|
||||
crate::builtin::extract_bundled_files(&grok_home);
|
||||
|
||||
crate::extensions::marketplace::purge_default_skills_installs(&grok_home);
|
||||
|
||||
// Auto-register is gated (default off; env/remote settings enables). Kept out
|
||||
// of extract_bundled_files so the gate can read the resolved
|
||||
// remote_settings, which resolve_config has populated by now.
|
||||
|
|
|
|||
|
|
@ -2314,6 +2314,7 @@ impl acp::Agent for MvpAgent {
|
|||
traceparent: xai_file_utils::trace_context::current_traceparent(),
|
||||
json_schema,
|
||||
send_now,
|
||||
admission: None,
|
||||
respond_to: tx,
|
||||
persist_ack: None,
|
||||
parsed_prompt_tx,
|
||||
|
|
|
|||
|
|
@ -873,7 +873,6 @@ impl MvpAgent {
|
|||
auth: &crate::auth::GrokAuth,
|
||||
) {
|
||||
self.refresh_remote_settings(auth).await;
|
||||
let cwd = std::env::current_dir().ok();
|
||||
{
|
||||
let mut cfg = self.cfg.borrow_mut();
|
||||
crate::util::config::sync_campaign_fields(&mut cfg);
|
||||
|
|
@ -884,7 +883,7 @@ impl MvpAgent {
|
|||
);
|
||||
toml::Value::Table(toml::map::Map::new())
|
||||
});
|
||||
cfg.re_resolve_runtime_fields(&raw_config, cwd.as_deref());
|
||||
cfg.re_resolve_runtime_fields(&raw_config);
|
||||
}
|
||||
self.sync_collection_config_gate();
|
||||
self.emit_settings_update_notification();
|
||||
|
|
@ -1401,6 +1400,7 @@ impl MvpAgent {
|
|||
/// Params resolution (TOML > env > remote settings > default):
|
||||
/// - `proxy_endpoint`: `[toolset.web_fetch] proxy_endpoint` > `GROK_WEB_FETCH_PROXY` > remote settings > None
|
||||
/// - `allowed_domains`: `[toolset.web_fetch] allowed_domains` > remote settings > built-in defaults
|
||||
/// - `allow_local`: `[toolset.web_fetch] allow_local` > `GROK_WEB_FETCH_ALLOW_LOCAL` > false
|
||||
pub(super) fn prepare_web_fetch_config(
|
||||
&self,
|
||||
) -> xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig {
|
||||
|
|
@ -1507,10 +1507,6 @@ impl MvpAgent {
|
|||
resident_roster_titles: RefCell::new(HashMap::new()),
|
||||
initialize_request: OnceLock::new(),
|
||||
gateway,
|
||||
subagent_model_overrides: cfg.subagent_model_overrides.clone(),
|
||||
subagent_toggle: cfg.subagent_toggle.clone(),
|
||||
subagent_roles: cfg.subagent_roles.clone(),
|
||||
subagent_personas: cfg.subagent_personas.clone(),
|
||||
launch_cwd: std::env::current_dir()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||||
launch_dir_trust: std::cell::OnceCell::new(),
|
||||
|
|
@ -1519,11 +1515,6 @@ impl MvpAgent {
|
|||
cfg.plugins.cli_plugin_dirs.clone(),
|
||||
),
|
||||
plugin_registry_initialized: std::cell::Cell::new(false),
|
||||
persona_io_summaries: cfg
|
||||
.subagent_personas
|
||||
.iter()
|
||||
.map(|(name, p)| p.render_io_summary(name))
|
||||
.collect(),
|
||||
models_manager,
|
||||
chat_modes: {
|
||||
let chat_modes = crate::agent::chat_modes::ChatModesManager::new(
|
||||
|
|
@ -3182,6 +3173,7 @@ impl MvpAgent {
|
|||
.cfg
|
||||
.borrow()
|
||||
.resolve_compaction_verbatim_input();
|
||||
let compaction_tool_choice = self.cfg.borrow().resolve_compaction_tool_choice();
|
||||
let two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled();
|
||||
let auto_update = self.cfg.borrow().cli.auto_update;
|
||||
let client_type = *self.client_type.borrow();
|
||||
|
|
@ -3384,7 +3376,7 @@ impl MvpAgent {
|
|||
let laziness_debug_log_for_spawn = self.cfg.borrow().laziness_debug_log.clone();
|
||||
let respect_gitignore = self.cfg.borrow().respect_gitignore;
|
||||
let path_not_found_hints = self.cfg.borrow().path_not_found_hints;
|
||||
let subagent_toggle = self.subagent_toggle.clone();
|
||||
let subagent_toggle = self.cfg.borrow().subagent_toggle.clone();
|
||||
let handle_display_cwd = prompt_display_cwd.clone();
|
||||
let auth_manager = Some(self.auth_manager.clone());
|
||||
let bash_params_json = {
|
||||
|
|
@ -3527,6 +3519,7 @@ impl MvpAgent {
|
|||
.as_ref()
|
||||
.and_then(|m| m.get("x.ai/gitHeadChanged"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let session_cwd = std::path::Path::new(&session_info.cwd);
|
||||
let fs_watch_caps = crate::session::fs_watch::FsWatchCapabilities::resolve(crate::session::fs_watch::CapabilityInputs {
|
||||
client_notify: fs_notify_config.is_some(),
|
||||
hunk_tracking: hunk_plan.enabled(),
|
||||
|
|
@ -3561,6 +3554,7 @@ impl MvpAgent {
|
|||
system_prompt_label,
|
||||
compaction_mode,
|
||||
compaction_verbatim_input,
|
||||
compaction_tool_choice,
|
||||
two_pass_enabled,
|
||||
buffering_settings,
|
||||
origin_client.clone(),
|
||||
|
|
@ -3608,7 +3602,7 @@ impl MvpAgent {
|
|||
client_hooks,
|
||||
prompt_display_cwd,
|
||||
subagent_toggle,
|
||||
self.persona_io_summaries.clone(),
|
||||
Vec::new(),
|
||||
xai_grok_agent::prompt::context::PromptAudience::Primary,
|
||||
None,
|
||||
None,
|
||||
|
|
@ -3618,7 +3612,6 @@ impl MvpAgent {
|
|||
path_not_found_hints,
|
||||
tool_params_json,
|
||||
{
|
||||
let session_cwd = std::path::Path::new(&session_info.cwd);
|
||||
let disk_cfg = crate::config::resolve_effective_plugins_config(
|
||||
session_cwd,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -794,20 +794,6 @@ pub struct MvpAgent {
|
|||
/// notification has `Next` priority. Drained by the session turn loop
|
||||
/// (`inject_pending_monitor_events`) into a hidden synthetic user message.
|
||||
monitor_event_buffer: xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer,
|
||||
/// Per-subagent model ID overrides from config.toml `[subagents.models]`.
|
||||
/// Populated from `SubagentsConfig.models` during `with_models()`.
|
||||
subagent_model_overrides: std::collections::HashMap<String, String>,
|
||||
/// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`.
|
||||
/// Populated from `SubagentsConfig.toggle` during `with_models()`.
|
||||
subagent_toggle: std::collections::HashMap<String, bool>,
|
||||
subagent_roles: std::collections::HashMap<
|
||||
String,
|
||||
xai_grok_subagent_resolution::config::SubagentRole,
|
||||
>,
|
||||
subagent_personas: std::collections::HashMap<
|
||||
String,
|
||||
xai_grok_subagent_resolution::config::SubagentPersona,
|
||||
>,
|
||||
/// The process launch directory, captured once at construction so the
|
||||
/// deferred launch-dir init paths share one source of truth instead of each
|
||||
/// re-calling `std::env::current_dir()` (which could drift if the process
|
||||
|
|
@ -827,7 +813,6 @@ pub struct MvpAgent {
|
|||
/// the first session-creating call via [`Self::ensure_plugin_registry`];
|
||||
/// this flag keeps that to a single discovery walk.
|
||||
plugin_registry_initialized: std::cell::Cell<bool>,
|
||||
persona_io_summaries: Vec<String>,
|
||||
/// Single-flight guard for the proactive bundle sync background task.
|
||||
///
|
||||
/// `maybe_sync_bundle_in_background` is invoked from each post-auth path
|
||||
|
|
@ -1176,6 +1161,9 @@ fn inject_proxy_headers(
|
|||
.map(String::from)
|
||||
.unwrap_or_else(|| xai_grok_version::VERSION.to_string())
|
||||
});
|
||||
headers
|
||||
.entry("x-grok-client-identifier".to_string())
|
||||
.or_insert_with(crate::http::process_client_identifier);
|
||||
if crate::util::is_cli_chat_proxy_url(base_url) {
|
||||
headers
|
||||
.entry("X-XAI-Token-Auth".to_string())
|
||||
|
|
@ -1264,6 +1252,7 @@ mod session_lifecycle;
|
|||
mod subagent_coordinator;
|
||||
mod agent_ops;
|
||||
mod acp_agent;
|
||||
pub(crate) use session_lifecycle::RegistrySnapshot;
|
||||
pub(super) use super::ext_parsers;
|
||||
/// Emit the `auth.lifecycle` login span with optional user id and error
|
||||
/// category. Named `auth.lifecycle` (not `auth`) to avoid colliding with the
|
||||
|
|
|
|||
|
|
@ -402,4 +402,47 @@ impl MvpAgent {
|
|||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
/// Entry counts for every collection [`Self::remove_session`] drains,
|
||||
/// plus the workspace binding and subagent maps.
|
||||
pub(crate) fn registry_snapshot(&self) -> RegistrySnapshot {
|
||||
let (subagent_pending, subagent_active, subagent_completed) =
|
||||
self.subagent_coordinator.borrow().registry_snapshot();
|
||||
RegistrySnapshot {
|
||||
sessions: self.sessions.borrow().len(),
|
||||
session_threads: self.session_threads.borrow().len(),
|
||||
dispatch_locks: self.dispatch_locks.borrow().len(),
|
||||
session_turn_numbers: self.session_turn_numbers.borrow().len(),
|
||||
permission_event_receivers: self.permission_event_receivers.borrow().len(),
|
||||
model_unavailable_sessions: self.model_unavailable_sessions.borrow().len(),
|
||||
session_live_state: self.session_live_state.borrow().len(),
|
||||
session_index_claims: self.session_index_claims.borrow().len(),
|
||||
require_gateway_sessions: self.require_gateway_sessions.borrow().len(),
|
||||
subagent_pending,
|
||||
subagent_active,
|
||||
subagent_completed,
|
||||
workspace_bindings: self
|
||||
.workspace_ops
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|ops| ops.workspace_handle().map(|h| h.session_count())),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Field names are the wire contract of `x.ai/debug/agent`'s `registries`
|
||||
/// object; each maps to the same-named registry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct RegistrySnapshot {
|
||||
pub sessions: usize,
|
||||
pub session_threads: usize,
|
||||
pub dispatch_locks: usize,
|
||||
pub session_turn_numbers: usize,
|
||||
pub permission_event_receivers: usize,
|
||||
pub model_unavailable_sessions: usize,
|
||||
pub session_live_state: usize,
|
||||
pub session_index_claims: usize,
|
||||
pub require_gateway_sessions: usize,
|
||||
pub subagent_pending: usize,
|
||||
pub subagent_active: usize,
|
||||
pub subagent_completed: usize,
|
||||
pub workspace_bindings: Option<usize>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,14 +268,17 @@ impl MvpAgent {
|
|||
ps.and_then(|h| h.allowed_subagent_types.clone()),
|
||||
)
|
||||
};
|
||||
let cli_agent_names: Vec<String> = {
|
||||
let (cli_agent_names, subagent_toggle) = {
|
||||
let cfg = self.cfg.borrow();
|
||||
cfg.cli_agents.iter().map(|d| d.name.clone()).collect()
|
||||
(
|
||||
cfg.cli_agents.iter().map(|d| d.name.clone()).collect(),
|
||||
cfg.subagent_toggle.clone(),
|
||||
)
|
||||
};
|
||||
crate::agent::subagent::SubagentValidationContext {
|
||||
parent_cwd,
|
||||
plugin_registry: self.plugin_registry_handle.snapshot(),
|
||||
subagent_toggle: self.subagent_toggle.clone(),
|
||||
subagent_toggle,
|
||||
allowed_subagent_types,
|
||||
cli_agent_names,
|
||||
}
|
||||
|
|
@ -432,6 +435,23 @@ impl MvpAgent {
|
|||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let project_trusted = crate::agent::folder_trust::project_scope_allowed(&parent_cwd);
|
||||
let (base_roles, base_personas, subagent_model_overrides, subagent_toggle) = {
|
||||
let cfg = self.cfg.borrow();
|
||||
(
|
||||
cfg.subagent_roles.clone(),
|
||||
cfg.subagent_personas.clone(),
|
||||
cfg.subagent_model_overrides.clone(),
|
||||
cfg.subagent_toggle.clone(),
|
||||
)
|
||||
};
|
||||
let (subagent_roles, subagent_personas) =
|
||||
crate::config::SubagentsConfig::effective_definition_maps(
|
||||
&base_roles,
|
||||
&base_personas,
|
||||
&parent_cwd,
|
||||
project_trusted,
|
||||
);
|
||||
Some(crate::agent::subagent::SubagentSpawnContext {
|
||||
lsp: parent_lsp,
|
||||
gateway: self.gateway.clone(),
|
||||
|
|
@ -484,11 +504,10 @@ impl MvpAgent {
|
|||
parent_chat_state,
|
||||
parent_max_turns,
|
||||
available_models,
|
||||
subagent_model_overrides: self.subagent_model_overrides.clone(),
|
||||
subagent_toggle: self.subagent_toggle.clone(),
|
||||
subagent_roles: self.subagent_roles.clone(),
|
||||
subagent_personas: self.subagent_personas.clone(),
|
||||
persona_io_summaries: self.persona_io_summaries.clone(),
|
||||
subagent_model_overrides,
|
||||
subagent_toggle,
|
||||
subagent_roles,
|
||||
subagent_personas,
|
||||
disable_web_search: self.cfg.borrow().disable_web_search,
|
||||
todo_gate: self.cfg.borrow().todo_gate,
|
||||
remote_settings: self.cfg.borrow().remote_settings.clone(),
|
||||
|
|
@ -549,11 +568,11 @@ impl MvpAgent {
|
|||
parent_skills: None,
|
||||
parent_skills_config: self.cfg.borrow().skills.clone(),
|
||||
parent_compat: self.cfg.borrow().compat_resolved,
|
||||
auto_wake_delivered: {
|
||||
task_completion_reservations: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.tool_context.auto_wake_delivered.clone())
|
||||
.and_then(|h| h.tool_context.task_completion_reservations.clone())
|
||||
},
|
||||
synthetic_trace_tx: {
|
||||
let sessions = self.sessions.borrow();
|
||||
|
|
|
|||
|
|
@ -2517,6 +2517,45 @@ async fn prepare_image_gen_config_fails_open_without_auth() {
|
|||
"no resolved auth ⇒ fail open (tools not tier-restricted)"
|
||||
);
|
||||
}
|
||||
/// The imagine tools bypass cli-chat-proxy (direct API calls), so the server
|
||||
/// can only scope the coding data-retention opt-out (`/privacy opt-out`) to
|
||||
/// Build traffic via the `x-grok-client-identifier` header. If this header is
|
||||
/// dropped, opted-out users' imagine prompts are logged/retained server-side.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn prepare_image_gen_config_sends_client_identifier_header() {
|
||||
use xai_grok_tools::implementations::grok_build::image_gen::ImageGenConfig;
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
agent.sampling_config.borrow_mut().api_key = Some("test-key".to_string());
|
||||
let ImageGenConfig::Enabled { extra_headers, .. } = agent.prepare_image_gen_config() else {
|
||||
panic!("expected Enabled");
|
||||
};
|
||||
assert_eq!(
|
||||
extra_headers
|
||||
.get("x-grok-client-identifier")
|
||||
.map(String::as_str),
|
||||
Some(crate::http::process_client_identifier().as_str()),
|
||||
"imagine API calls must carry the client identifier so the server \
|
||||
applies the coding ZDR opt-out to Build traffic"
|
||||
);
|
||||
}
|
||||
/// Same contract for video generation (also a direct API call).
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn prepare_video_gen_config_sends_client_identifier_header() {
|
||||
use xai_grok_tools::implementations::grok_build::video_gen::VideoGenConfig;
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
agent.sampling_config.borrow_mut().api_key = Some("test-key".to_string());
|
||||
let VideoGenConfig::Enabled { extra_headers, .. } = agent.prepare_video_gen_config() else {
|
||||
panic!("expected Enabled");
|
||||
};
|
||||
assert_eq!(
|
||||
extra_headers
|
||||
.get("x-grok-client-identifier")
|
||||
.map(String::as_str),
|
||||
Some(crate::http::process_client_identifier().as_str()),
|
||||
"video gen API calls must carry the client identifier so the server \
|
||||
applies the coding ZDR opt-out to Build traffic"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn data_collection_enabled_for_normal_user() {
|
||||
let agent = build_agent_with_auth(crate::auth::GrokAuth::test_default());
|
||||
|
|
@ -3642,12 +3681,148 @@ fn repo_with_project_mcp_server() -> tempfile::TempDir {
|
|||
.unwrap();
|
||||
tmp
|
||||
}
|
||||
fn write_project_subagent_definitions(cwd: &std::path::Path) {
|
||||
let roles = cwd.join(".grok/roles");
|
||||
let personas = cwd.join(".grok/personas");
|
||||
std::fs::create_dir_all(&roles).unwrap();
|
||||
std::fs::create_dir_all(&personas).unwrap();
|
||||
std::fs::write(roles.join("probe.toml"), "description = \"Project role\"").unwrap();
|
||||
std::fs::write(
|
||||
personas.join("probe.toml"),
|
||||
"instructions = \"Project persona\"",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fn folder_trust_on() -> crate::util::config::RemoteSettings {
|
||||
crate::util::config::RemoteSettings {
|
||||
folder_trust_enabled: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn subagent_spawn_context_reloads_project_definitions_after_trust_changes() {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo.path()).unwrap();
|
||||
write_project_subagent_definitions(repo.path());
|
||||
run_local_for_bridge_test(|| async {
|
||||
let (agent, _rx) = build_agent_with_gateway_rx();
|
||||
let sid = acp::SessionId::new("roles-personas-trust-transition");
|
||||
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
|
||||
handle.info.cwd = repo.path().display().to_string();
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
{
|
||||
let mut cfg = agent.cfg.borrow_mut();
|
||||
cfg.subagent_roles.insert(
|
||||
"refreshed".into(),
|
||||
xai_grok_subagent_resolution::config::SubagentRole {
|
||||
description: "Refreshed user role".into(),
|
||||
source_dir: Some(repo.path().join("user-roles")),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
cfg.subagent_model_overrides
|
||||
.insert("probe".into(), "refreshed-model".into());
|
||||
cfg.subagent_toggle.insert("probe".into(), false);
|
||||
}
|
||||
crate::agent::folder_trust::record_for_test(repo.path(), false);
|
||||
let untrusted = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
assert!(!untrusted.subagent_roles.contains_key("probe"));
|
||||
assert!(!untrusted.subagent_personas.contains_key("probe"));
|
||||
assert_eq!(
|
||||
untrusted
|
||||
.subagent_roles
|
||||
.get("refreshed")
|
||||
.map(|role| role.description.as_str()),
|
||||
Some("Refreshed user role")
|
||||
);
|
||||
assert_eq!(
|
||||
untrusted
|
||||
.subagent_model_overrides
|
||||
.get("probe")
|
||||
.map(String::as_str),
|
||||
Some("refreshed-model")
|
||||
);
|
||||
assert_eq!(untrusted.subagent_toggle.get("probe"), Some(&false));
|
||||
crate::agent::folder_trust::record_for_test(repo.path(), true);
|
||||
let trusted = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
assert_eq!(
|
||||
trusted
|
||||
.subagent_roles
|
||||
.get("probe")
|
||||
.map(|role| role.description.as_str()),
|
||||
Some("Project role")
|
||||
);
|
||||
assert!(trusted.subagent_personas.contains_key("probe"));
|
||||
crate::agent::folder_trust::record_for_test(repo.path(), false);
|
||||
let revoked = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
assert!(!revoked.subagent_roles.contains_key("probe"));
|
||||
assert!(!revoked.subagent_personas.contains_key("probe"));
|
||||
});
|
||||
}
|
||||
/// End-to-end gate wiring: project `.grok/roles` / `personas` alone must drive
|
||||
/// real `resolve_and_record` untrusted (not a forced `record_for_test` verdict),
|
||||
/// keep project defs out of Task spawn context, then re-admit them after grant.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn project_roles_personas_gated_via_resolve_and_record_chain() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _env = EnvGuard::set("GROK_HOME", home.path());
|
||||
let _sim = EnvGuard::set(xai_grok_version::TEST_VERSION_ENV, "0.0-sim");
|
||||
let _flag = EnvGuard::unset("GROK_FOLDER_TRUST");
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo.path()).unwrap();
|
||||
write_project_subagent_definitions(repo.path());
|
||||
run_local_for_bridge_test(|| async {
|
||||
let (agent, _rx) = build_agent_with_gateway_rx();
|
||||
let sid = acp::SessionId::new("roles-personas-resolve-chain");
|
||||
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
|
||||
handle.info.cwd = repo.path().display().to_string();
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
let allowed = crate::agent::folder_trust::resolve_and_record(
|
||||
repo.path(),
|
||||
Some(&folder_trust_on()),
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!allowed,
|
||||
"roles/personas markers alone must resolve untrusted without a grant"
|
||||
);
|
||||
assert!(
|
||||
!crate::agent::folder_trust::project_scope_allowed(repo.path()),
|
||||
"cached verdict after resolve_and_record must stay untrusted"
|
||||
);
|
||||
let untrusted = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
assert!(
|
||||
!untrusted.subagent_roles.contains_key("probe"),
|
||||
"untrusted: project role must stay out of spawn context"
|
||||
);
|
||||
assert!(
|
||||
!untrusted.subagent_personas.contains_key("probe"),
|
||||
"untrusted: project persona must stay out of spawn context"
|
||||
);
|
||||
crate::agent::folder_trust::grant_folder_trust(repo.path());
|
||||
let allowed = crate::agent::folder_trust::resolve_and_record(
|
||||
repo.path(),
|
||||
Some(&folder_trust_on()),
|
||||
false,
|
||||
);
|
||||
assert!(allowed, "store-granted folder must resolve trusted");
|
||||
let trusted = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
assert_eq!(
|
||||
trusted
|
||||
.subagent_roles
|
||||
.get("probe")
|
||||
.map(|role| role.description.as_str()),
|
||||
Some("Project role")
|
||||
);
|
||||
assert!(
|
||||
trusted.subagent_personas.contains_key("probe"),
|
||||
"trusted: project persona must enter spawn context after grant"
|
||||
);
|
||||
});
|
||||
}
|
||||
/// Pull the next `x.ai/folder_trust/request` reverse-request off the gateway and
|
||||
/// answer it with `outcome`. Returns the request's decoded params.
|
||||
async fn answer_folder_trust_request(
|
||||
|
|
@ -4559,3 +4734,5 @@ mod soft_default_settings_emit {
|
|||
.await;
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
mod dhat_soak;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
//! Heap-leak test for the session lifecycle: create and remove many sessions,
|
||||
//! then fail if heap memory grows per session. Run:
|
||||
//! leader_session_lifecycle_heap_steady_state -- --ignored --nocapture
|
||||
use super::*;
|
||||
use xai_grok_workspace::permission::PermissionEvent;
|
||||
const MAX_BLOCKS_PER_SESSION: f64 = 0.5;
|
||||
const MAX_BYTES_PER_SESSION: f64 = 1024.0;
|
||||
/// Creates the per-session state that `remove_session` must clean up, then
|
||||
/// removes the session. A full `SessionHandle` would allocate so much
|
||||
/// unrelated memory that a small leak would be lost in the noise.
|
||||
fn populate_and_evict(agent: &MvpAgent, i: usize) {
|
||||
let sid = acp::SessionId::new(format!("soak-{i}"));
|
||||
{
|
||||
let ops = agent.workspace_ops.borrow();
|
||||
let ops = ops.as_ref().expect("test installs workspace ops");
|
||||
let toolset = std::sync::Arc::new(
|
||||
xai_grok_tools::registry::types::FinalizedToolset::empty_for_test(),
|
||||
);
|
||||
ops.bind_local_session(
|
||||
sid.0.as_ref(),
|
||||
std::env::temp_dir(),
|
||||
xai_hunk_tracker::HunkTrackerHandle::noop(),
|
||||
toolset,
|
||||
None,
|
||||
)
|
||||
.expect("bind_local_session must succeed");
|
||||
}
|
||||
let (_ptx, prx) = tokio::sync::mpsc::unbounded_channel::<PermissionEvent>();
|
||||
agent
|
||||
.permission_event_receivers
|
||||
.borrow_mut()
|
||||
.insert(sid.clone(), prx);
|
||||
agent
|
||||
.session_turn_numbers
|
||||
.borrow_mut()
|
||||
.insert(sid.clone(), i as u64);
|
||||
agent.model_unavailable_sessions.borrow_mut().insert(
|
||||
sid.0.to_string(),
|
||||
acp::ModelId::new(std::sync::Arc::from("gone-model")),
|
||||
);
|
||||
agent.remove_session(&sid);
|
||||
}
|
||||
/// Waits for background tasks to finish before reading heap stats.
|
||||
async fn quiesce() {
|
||||
const YIELD_ROUNDS: usize = 50;
|
||||
const SETTLE: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
for _ in 0..YIELD_ROUNDS {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::sleep(SETTLE).await;
|
||||
for _ in 0..YIELD_ROUNDS {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
/// Creating and removing N sessions must not grow the heap.
|
||||
///
|
||||
/// Only one `dhat::Profiler` can exist at a time, and the test harness runs
|
||||
/// tests in parallel, so keep this the only test that creates one.
|
||||
#[test]
|
||||
fn leader_session_lifecycle_heap_steady_state() {
|
||||
run_local_for_bridge_test(|| async {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
*agent.workspace_ops.borrow_mut() = Some(xai_grok_workspace::WorkspaceOps::for_test());
|
||||
let _profiler = dhat::Profiler::builder().testing().build();
|
||||
const WARMUP: usize = 16;
|
||||
const MEASURE: usize = 256;
|
||||
for i in 0..WARMUP {
|
||||
populate_and_evict(&agent, i);
|
||||
}
|
||||
quiesce().await;
|
||||
let before = dhat::HeapStats::get();
|
||||
for i in WARMUP..(WARMUP + MEASURE) {
|
||||
populate_and_evict(&agent, i);
|
||||
}
|
||||
quiesce().await;
|
||||
let after = dhat::HeapStats::get();
|
||||
let d_blocks = after.curr_blocks as i64 - before.curr_blocks as i64;
|
||||
let d_bytes = after.curr_bytes as i64 - before.curr_bytes as i64;
|
||||
let blocks_per = d_blocks as f64 / MEASURE as f64;
|
||||
let bytes_per = d_bytes as f64 / MEASURE as f64;
|
||||
eprintln!(
|
||||
"DHAT_SOAK_SUMMARY {}",
|
||||
serde_json::json!({ "warmup_sessions" : WARMUP,
|
||||
"measured_sessions" : MEASURE, "before_blocks" : before.curr_blocks,
|
||||
"before_bytes" : before.curr_bytes, "after_blocks" : after.curr_blocks,
|
||||
"after_bytes" : after.curr_bytes, "blocks_per_session" : blocks_per,
|
||||
"bytes_per_session" : bytes_per, "max_blocks_per_session" :
|
||||
MAX_BLOCKS_PER_SESSION, "max_bytes_per_session" : MAX_BYTES_PER_SESSION,
|
||||
"pass" : blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per <
|
||||
MAX_BYTES_PER_SESSION })
|
||||
);
|
||||
assert!(
|
||||
blocks_per < MAX_BLOCKS_PER_SESSION,
|
||||
"block-count leak: {blocks_per:.3} blocks/session retained ({d_blocks} over {MEASURE} cycles) exceeds the {MAX_BLOCKS_PER_SESSION} gate"
|
||||
);
|
||||
assert!(
|
||||
bytes_per < MAX_BYTES_PER_SESSION,
|
||||
"byte leak: {bytes_per:.1} bytes/session retained ({d_bytes} over {MEASURE} cycles) exceeds the {MAX_BYTES_PER_SESSION} gate"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -302,6 +302,10 @@ impl SubagentCoordinator {
|
|||
will_wake: false,
|
||||
})
|
||||
}
|
||||
/// Lifecycle-map entry counts as `(pending, active, completed)`.
|
||||
pub(crate) fn registry_snapshot(&self) -> (usize, usize, usize) {
|
||||
(self.pending.len(), self.active.len(), self.completed.len())
|
||||
}
|
||||
/// Oldest completions are evicted first; their `output.json` stays on disk.
|
||||
pub fn enforce_completed_cap(&mut self) {
|
||||
if self.completed.len() <= MAX_COMPLETED_ENTRIES {
|
||||
|
|
|
|||
|
|
@ -1081,6 +1081,7 @@ pub(crate) async fn handle_subagent_request(
|
|||
xai_grok_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
|
||||
xai_chat_state::CompactionMode::Summary,
|
||||
ctx.resolve_compaction_verbatim_input(),
|
||||
ctx.resolve_compaction_tool_choice(),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
|
|
@ -1156,7 +1157,7 @@ pub(crate) async fn handle_subagent_request(
|
|||
ctx.client_hooks.clone(),
|
||||
None,
|
||||
std::collections::HashMap::new(),
|
||||
ctx.persona_io_summaries.clone(),
|
||||
Vec::new(),
|
||||
xai_grok_agent::prompt::context::PromptAudience::Subagent,
|
||||
effective_runtime.role_prompt.clone(),
|
||||
None,
|
||||
|
|
@ -1304,6 +1305,7 @@ pub(crate) async fn handle_subagent_request(
|
|||
traceparent: xai_file_utils::trace_context::current_traceparent(),
|
||||
json_schema: None,
|
||||
send_now: false,
|
||||
admission: None,
|
||||
respond_to: prompt_tx,
|
||||
persist_ack: None,
|
||||
parsed_prompt_tx: None,
|
||||
|
|
@ -2005,7 +2007,7 @@ pub(crate) async fn handle_subagent_request(
|
|||
&request.id,
|
||||
&result,
|
||||
&request,
|
||||
&ctx.auto_wake_delivered,
|
||||
&ctx.task_completion_reservations,
|
||||
ctx.parent_cmd_tx.as_ref(),
|
||||
&ctx.task_output_tool_name,
|
||||
&ctx.synthetic_trace_tx,
|
||||
|
|
|
|||
|
|
@ -243,9 +243,6 @@ pub(crate) struct SubagentSpawnContext {
|
|||
/// Subagent personas config for persona/SOUL layering.
|
||||
pub subagent_personas:
|
||||
std::collections::HashMap<String, xai_grok_subagent_resolution::config::SubagentPersona>,
|
||||
/// Pre-rendered persona IO summaries for the task tool description.
|
||||
/// Threaded through to child sessions for recursive persona discovery.
|
||||
pub persona_io_summaries: Vec<String>,
|
||||
/// Parent session's ChatStateHandle — used to read the actual live
|
||||
/// sampling config and credentials from the parent session actor (async).
|
||||
/// Cheap Clone (mpsc sender). `None` when parent SessionHandle not found.
|
||||
|
|
@ -348,9 +345,9 @@ pub(crate) struct SubagentSpawnContext {
|
|||
/// Parent's resolved vendor-compat config, inherited by the child so its
|
||||
/// skills / rules / AGENTS.md discovery honors the same vendor toggles.
|
||||
pub parent_compat: xai_grok_tools::types::compat::CompatConfig,
|
||||
/// Shared set of IDs delivered via auto-wake synthetic prompts.
|
||||
pub auto_wake_delivered:
|
||||
Option<xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds>,
|
||||
/// Shared completion reservations held by auto-wake prompts.
|
||||
pub task_completion_reservations:
|
||||
Option<xai_grok_tools::reminders::task_completion::TaskCompletionReservations>,
|
||||
/// Channel for requesting trace uploads for synthetic auto-wake turns.
|
||||
pub synthetic_trace_tx:
|
||||
Option<tokio::sync::mpsc::UnboundedSender<crate::upload::turn::SyntheticTurnTraceRequest>>,
|
||||
|
|
@ -422,6 +419,18 @@ impl SubagentSpawnContext {
|
|||
.resolve()
|
||||
.value
|
||||
}
|
||||
pub fn resolve_compaction_tool_choice(&self) -> crate::util::config::CompactionToolChoice {
|
||||
crate::util::config::resolve_compaction_tool_choice_from(
|
||||
crate::agent::config::env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE)
|
||||
.as_deref(),
|
||||
self.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.features.compaction_tool_choice.as_deref()),
|
||||
self.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|r| r.compaction_tool_choice.as_deref()),
|
||||
)
|
||||
}
|
||||
/// Whether a completed subagent's worktree is snapshotted into a durable ref
|
||||
/// and its directory deleted. Resolution mirrors the other subagent gates
|
||||
/// (env > config > remote settings > default). Default `false` so it ships dark;
|
||||
|
|
@ -1955,7 +1964,7 @@ fn cancellation_error_message(
|
|||
/// result has not already been consumed (via block-wait or explicit kill).
|
||||
/// Also suppressed while the parent's goal loop is active (mirrors the bash
|
||||
/// gate in `notification_bridge`); skipping the inject also skips the
|
||||
/// `auto_wake_delivered.insert`, leaving surfaces 2/3 free to drain it.
|
||||
/// the completion reservation, leaving surfaces 2/3 free to drain it.
|
||||
/// `parent_channel_open` folds `inject_subagent_completed_prompt`'s own
|
||||
/// no-channel bail into the decision, so the `will_wake` stamped on the
|
||||
/// completion notification can never promise a wake the inject won't do.
|
||||
|
|
@ -1992,7 +2001,9 @@ fn inject_subagent_completed_prompt(
|
|||
subagent_id: &str,
|
||||
result: &SubagentResult,
|
||||
request: &SubagentRequest,
|
||||
auto_wake_delivered: &Option<xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds>,
|
||||
task_completion_reservations: &Option<
|
||||
xai_grok_tools::reminders::task_completion::TaskCompletionReservations,
|
||||
>,
|
||||
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
||||
task_output_tool_name: &str,
|
||||
synthetic_trace_tx: &Option<
|
||||
|
|
@ -2002,8 +2013,8 @@ fn inject_subagent_completed_prompt(
|
|||
let Some(cmd_tx) = parent_cmd_tx else {
|
||||
return;
|
||||
};
|
||||
if let Some(auto_wake) = auto_wake_delivered {
|
||||
auto_wake.insert(subagent_id.to_string());
|
||||
if let Some(reservations) = task_completion_reservations {
|
||||
reservations.reserve(subagent_id.to_string());
|
||||
}
|
||||
let summary = SubagentCompletionSummary {
|
||||
subagent_id: subagent_id.to_string(),
|
||||
|
|
@ -2032,21 +2043,30 @@ fn inject_subagent_completed_prompt(
|
|||
};
|
||||
let (respond_to, completion_rx) = tokio::sync::oneshot::channel();
|
||||
let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(wrapped))];
|
||||
let _ = cmd_tx.send(SessionCommand::Prompt {
|
||||
prompt_id: prompt_id.clone(),
|
||||
prompt_blocks,
|
||||
prompt_mode: crate::session::plan_mode::PromptMode::Agent,
|
||||
artifact_upload_ctx: None,
|
||||
client_identifier: None,
|
||||
screen_mode: None,
|
||||
verbatim: true,
|
||||
traceparent: None,
|
||||
json_schema: None,
|
||||
send_now: false,
|
||||
respond_to,
|
||||
persist_ack: None,
|
||||
parsed_prompt_tx: None,
|
||||
});
|
||||
if cmd_tx
|
||||
.send(SessionCommand::Prompt {
|
||||
prompt_id: prompt_id.clone(),
|
||||
prompt_blocks,
|
||||
prompt_mode: crate::session::plan_mode::PromptMode::Agent,
|
||||
artifact_upload_ctx: None,
|
||||
client_identifier: None,
|
||||
screen_mode: None,
|
||||
verbatim: true,
|
||||
traceparent: None,
|
||||
json_schema: None,
|
||||
send_now: false,
|
||||
admission: None,
|
||||
respond_to,
|
||||
persist_ack: None,
|
||||
parsed_prompt_tx: None,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
if let Some(reservations) = task_completion_reservations {
|
||||
reservations.release(subagent_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(trace_tx) = synthetic_trace_tx {
|
||||
let _ = trace_tx.send(crate::upload::turn::SyntheticTurnTraceRequest {
|
||||
session_id: acp::SessionId::new(request.parent_session_id.clone()),
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ fn should_auto_wake_subagent_suppressed_by_block_waited_or_killed() {
|
|||
}
|
||||
/// A goal loop active in the parent suppresses the subagent
|
||||
/// auto-wake synthetic prompt — the structural sibling of the bash gate.
|
||||
/// Skipping the inject here also skips `auto_wake_delivered.insert`, so the
|
||||
/// Skipping the inject here also skips its completion reservation, so the
|
||||
/// per-tool-call / between-turn surfaces stay free to drain the completion.
|
||||
#[test]
|
||||
fn should_auto_wake_subagent_suppressed_by_goal_loop() {
|
||||
|
|
@ -587,7 +587,7 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest {
|
|||
}
|
||||
/// Behavior-level: the action half of the subagent auto-wake.
|
||||
/// When the gate lets it run, `inject_subagent_completed_prompt` sends the
|
||||
/// synthetic `Prompt` to the parent AND marks the id auto-wake-delivered.
|
||||
/// synthetic `Prompt` to the parent and reserves its completion ID.
|
||||
/// Paired with `should_auto_wake_subagent_suppressed_by_goal_loop`, this
|
||||
/// proves the full Gap-1 contract on the subagent surface: goal active →
|
||||
/// gate false → this never runs (no prompt, not marked, so surfaces 2/3
|
||||
|
|
@ -595,7 +595,7 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest {
|
|||
#[test]
|
||||
fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
|
||||
let auto_wake = xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds::default();
|
||||
let reservations = xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default();
|
||||
let request = auto_wake_test_request("sa-1");
|
||||
let result = SubagentResult {
|
||||
success: true,
|
||||
|
|
@ -607,7 +607,7 @@ fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
|
|||
"sa-1",
|
||||
&result,
|
||||
&request,
|
||||
&Some(auto_wake.clone()),
|
||||
&Some(reservations.clone()),
|
||||
Some(&cmd_tx),
|
||||
"get_command_or_subagent_output",
|
||||
&None,
|
||||
|
|
@ -619,7 +619,36 @@ fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
|
|||
}
|
||||
_ => panic!("expected SessionCommand::Prompt"),
|
||||
}
|
||||
assert_eq!(auto_wake.snapshot(), vec!["sa-1".to_string()]);
|
||||
assert_eq!(reservations.snapshot(), vec!["sa-1".to_string()]);
|
||||
}
|
||||
#[test]
|
||||
fn inject_subagent_completed_prompt_releases_reservation_when_parent_closed() {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
|
||||
drop(cmd_rx);
|
||||
let reservations = xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default();
|
||||
reservations.reserve("sa-closed".into());
|
||||
let (trace_tx, mut trace_rx) = mpsc::unbounded_channel();
|
||||
inject_subagent_completed_prompt(
|
||||
"sa-closed",
|
||||
&SubagentResult {
|
||||
success: true,
|
||||
subagent_id: "sa-closed".into(),
|
||||
child_session_id: "sa-closed".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&auto_wake_test_request("sa-closed"),
|
||||
&Some(reservations.clone()),
|
||||
Some(&cmd_tx),
|
||||
"get_command_or_subagent_output",
|
||||
&Some(trace_tx),
|
||||
);
|
||||
assert!(
|
||||
reservations.contains("sa-closed"),
|
||||
"send failure must release only the reservation acquired by this attempt"
|
||||
);
|
||||
reservations.release("sa-closed");
|
||||
assert!(! reservations.contains("sa-closed"));
|
||||
assert!(trace_rx.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn mark_explicitly_killed_sets_flag_on_completed() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue