diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index beffc294408..fb291a7db54 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -26,6 +26,7 @@ "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", "BUZZ_ACP_RELAY_OBSERVER": "true", + "BUZZ_ACP_SESSION_POLICY": "channel", "BUZZ_ACP_SESSION_TITLE": "worker", "GOOSE_MODE": "auto" } diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 4c2b6cd7fcc..c2a5a3c60c4 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -144,6 +144,7 @@ export default defineConfig({ "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", "**/settings-section-layout.spec.ts", + "**/experimental-features.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 9cbb4444ab3..11b0728c91a 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -36,8 +36,8 @@ pub struct AppState { pub workspace_apply_generation: AtomicU64, /// Defers managed-agent restore until `apply_workspace` installs relay and identity. pub managed_agent_restore_pending: AtomicBool, - /// Disabled by agent-managed profiles so agent profile updates survive start/restore. - pub managed_agent_profile_reconcile_enabled: AtomicBool, + /// Experiment state applied to managed-agent starts and profile reconciliation. + pub managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState, /// Shared shutdown signal checked by launch-time agent restoration. pub shutdown_started: AtomicBool, /// Serializes every managed-runtime transition that changes the protected @@ -207,7 +207,7 @@ pub fn build_app_state() -> AppState { workspace_apply_lock: Arc::new(AsyncMutex::new(())), workspace_apply_generation: AtomicU64::new(0), managed_agent_restore_pending: AtomicBool::new(false), - managed_agent_profile_reconcile_enabled: AtomicBool::new(true), + managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState::default(), shutdown_started: AtomicBool::new(false), managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 6135c671606..1371abba2c6 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -13,10 +13,17 @@ use crate::{ #[tauri::command] pub fn set_agent_managed_profiles(enabled: bool, state: State<'_, AppState>) { state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .store(!enabled, Ordering::Release); } +#[tauri::command] +pub fn set_thread_scoped_acp_sessions(enabled: bool, state: State<'_, AppState>) { + state + .thread_scoped_acp_sessions_enabled() + .store(enabled, Ordering::Release); +} + #[tauri::command] pub async fn set_managed_agent_start_on_app_launch( pubkey: String, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 33b6ae44620..fd650cdaf8e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1014,7 +1014,7 @@ pub async fn start_managed_agent( // with no persisted avatar, this also backfills the avatar from the relay. if result.is_ok() && state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) { let reconcile_pubkey = pubkey.clone(); diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index da5bb3ba5c0..5193ffb0d24 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -43,16 +43,17 @@ pub(crate) fn resolve_deploy_model_provider( /// Serialize the portable launch contract shared with provider-backed agents. /// -/// `descriptor.env` is the authoritative six-layer environment. Policy values -/// are deliberately separate because providers apply them below that layered -/// environment, preserving the local spawn's power-user override semantics. -pub(super) fn build_launch_block( +/// `descriptor.env` is the authoritative six-layer environment for ordinary +/// values. Desktop-owned settings are reserved, stripped from that layer, and +/// emitted through `policy_env` so local and provider launches agree. +fn build_launch_block_for_policy( record: &ManagedAgentRecord, descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, teams: &[crate::managed_agents::TeamRecord], effective_prompt: Option<&str>, effective_model: Option<&str>, owner_pubkey: &str, + session_policy: crate::managed_agents::AcpSessionPolicy, ) -> serde_json::Value { use crate::managed_agents::{ known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, @@ -78,6 +79,7 @@ pub(super) fn build_launch_block( "BUZZ_ACP_AGENTS".into(), crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism), ); + crate::managed_agents::insert_acp_session_policy_env(&mut policy_env, session_policy); if let Some(value) = effective_prompt { policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); @@ -138,7 +140,8 @@ pub(super) fn build_launch_block( // matching local, where `apply_claude_model_env(None)` removes both. let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); let strip_key = |k: &str| { - (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) + k.eq_ignore_ascii_case(crate::managed_agents::ACP_SESSION_POLICY_ENV_VAR) + || (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) || (is_claude && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) @@ -159,6 +162,26 @@ pub(super) fn build_launch_block( }) } +#[cfg(test)] +pub(super) fn build_launch_block( + record: &ManagedAgentRecord, + descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, + teams: &[crate::managed_agents::TeamRecord], + effective_prompt: Option<&str>, + effective_model: Option<&str>, + owner_pubkey: &str, +) -> serde_json::Value { + build_launch_block_for_policy( + record, + descriptor, + teams, + effective_prompt, + effective_model, + owner_pubkey, + crate::managed_agents::AcpSessionPolicy::Channel, + ) +} + pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result<(), String> { if provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { return Err( @@ -198,13 +221,14 @@ pub(crate) fn build_deploy_payload( crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; let owner_pubkey = super::workspace_owner_hex(state)?; - let launch = build_launch_block( + let launch = build_launch_block_for_policy( record, &descriptor, &teams, effective.system_prompt.value.as_deref(), effective.model.value.as_deref(), &owner_pubkey, + crate::managed_agents::acp_session_policy(state), ); let effective_parallelism = @@ -342,9 +366,40 @@ mod tests { assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "channel"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + #[test] + fn launch_block_thread_policy_is_authoritative_and_preserves_unrelated_env() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([ + ("BUZZ_ACP_SESSION_POLICY".to_string(), "channel".to_string()), + ("KEEP_ME".to_string(), "yes".to_string()), + ]), + }; + + let launch = build_launch_block_for_policy( + &record, + &descriptor, + &[], + None, + None, + "owner-hex", + crate::managed_agents::AcpSessionPolicy::Thread, + ); + + assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "thread"); + assert!( + launch["env"]["BUZZ_ACP_SESSION_POLICY"].is_null(), + "desktop policy must not be shadowed by descriptor env" + ); + assert_eq!(launch["env"]["KEEP_ME"], "yes"); + } + #[test] fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() { // B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL, diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 16a1538c753..721581db60e 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -200,7 +200,7 @@ pub(crate) async fn reconcile_agent_profile( ); if !state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) { return Ok(ProfileReconcileOutcome::SkippedDisabled); @@ -262,7 +262,7 @@ pub(crate) async fn reconcile_agent_profile( .map_err(|e| format!("failed to parse agent keys: {e}"))?; if !state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) { return Ok(ProfileReconcileOutcome::SkippedDisabled); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 77d519b94ba..418f994fb3e 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -155,6 +155,7 @@ pub async fn apply_workspace( nsec: Option, repos_dir: Option, agent_managed_profiles: Option, + thread_scoped_acp_sessions: Option, app: AppHandle, ) -> Result<(), String> { let state = app.state::(); @@ -228,8 +229,15 @@ pub async fn apply_workspace( // experiment before launch-time restore can spawn any agents. Missing // means the stable behavior: desktop remains authoritative. state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .store(!agent_managed_profiles.unwrap_or(false), Ordering::Release); + // Persisted frontend experiment state must land before launch-time + // restore so every restored agent starts with the selected ACP policy. + // Missing preserves the stable channel-scoped behavior. + state.thread_scoped_acp_sessions_enabled().store( + thread_scoped_acp_sessions.unwrap_or(false), + Ordering::Release, + ); // ── Filesystem side-effect (non-fatal) ──────────────────────────────── // Persist the *effective* repos_dir (None when the candidate failed diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b8095..fcd1681e8cf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -699,6 +699,7 @@ pub fn run() { start_managed_agent, stop_managed_agent, set_agent_managed_profiles, + set_thread_scoped_acp_sessions, set_managed_agent_start_on_app_launch, set_managed_agent_auto_restart, delete_managed_agent, diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index f3de11ad242..dc38c3d126f 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -175,6 +175,13 @@ fn reserved_keys_include_remote_lifetime_policy() { } } +#[test] +fn reserved_keys_include_desktop_acp_session_policy() { + assert!(is_reserved_env_key("BUZZ_ACP_SESSION_POLICY")); + let agent = map(&[("BUZZ_ACP_SESSION_POLICY", "thread")]); + assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty()); +} + #[test] fn reserved_keys_include_code_execution_surface() { // The agent/MCP command + args are what Buzz actually exec's. diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 272c03348b9..5177aebdb7a 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -35,6 +35,7 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +mod session_policy; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; @@ -85,6 +86,10 @@ pub use restore::*; pub use runtime::*; pub use runtime_commands::*; pub use runtime_types::*; +pub(crate) use session_policy::{ + acp_session_policy, apply_app_acp_session_policy_env, insert_acp_session_policy_env, + AcpSessionPolicy, ManagedAgentExperimentState, ACP_SESSION_POLICY_ENV_VAR, +}; pub use storage::*; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb3..4c10fc8a366 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -62,6 +62,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Desktop-owned pool lifetime policy: user env must not disable or reset // the idle worker-reclamation window while the desktop launcher sets it. "BUZZ_ACP_IDLE_POOL_SLEEP", + // Desktop experiment policy: the Settings toggle is the sole authority + // for whether channel threads receive independent ACP sessions. + "BUZZ_ACP_SESSION_POLICY", "BUZZ_ACP_NO_PRESENCE", // Readiness handoff: desktop is the ONLY readiness source. A saved or // ambient env var must not be able to forge setup mode (NotReady) on a diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a225f492d33..ef5f0ff0e74 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -490,7 +490,7 @@ fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) { let state = app.state::(); if !state - .managed_agent_profile_reconcile_enabled + .managed_agent_profile_reconcile_enabled() .load(Ordering::Acquire) { return; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..9a987a2bcb6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env}; @@ -23,8 +23,8 @@ pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondT mod metadata; pub(crate) use metadata::{ - apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars, - DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, child_rust_log_filter, resolve_session_title, + runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -109,7 +109,6 @@ pub(crate) fn workspace_pair_key( app: &AppHandle, record: &ManagedAgentRecord, ) -> Option { - use tauri::Manager; let state = app.state::(); resolve_workspace_pair_key( &record.pubkey, @@ -254,6 +253,7 @@ pub fn build_managed_agent_summary( &key.relay_url, global_config, super::owner_only_access_build(), + super::acp_session_policy(app.state::().inner()), ); (runtime, current) }); @@ -809,7 +809,8 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } - + // Resolve once and stamp the same value onto the snapshot below. + let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); // B5: carry persisted effort; harness resolves thought_level configId at first session. // Written AFTER descriptor.env so the canonical persisted value wins over any // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern @@ -861,6 +862,7 @@ pub fn spawn_agent_child( model: effective_model.as_deref(), provider: effective_provider.as_deref(), enforced_owner_only: super::owner_only_access_build(), + session_policy: acp_session_policy, }, ); @@ -928,14 +930,6 @@ pub fn spawn_agent_child( }) } -fn child_rust_log_filter() -> String { - match std::env::var("RUST_LOG") { - Ok(existing) if existing.contains("buzz_acp") => existing, - Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), - _ => "buzz_acp=info".to_string(), - } -} - /// Spawn (or adopt) the runtime pair for `record` on the caller's bound /// workspace relay. `workspace_relay` can only be produced by /// `bind_expected_relay_scope`, so this spawn consumes — by construction — the diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 5aef424ea61..a94e71b222f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -74,6 +74,17 @@ pub(crate) fn resolve_session_title(display_name: Option<&str>, name: &str) -> O .find(|value| !value.is_empty()) } +/// Build the `RUST_LOG` value forwarded to the agent child: keep an existing +/// filter that already mentions `buzz_acp`, append `buzz_acp=info` to any other +/// non-empty filter, and default to `buzz_acp=info` when unset. +pub(crate) fn child_rust_log_filter() -> String { + match std::env::var("RUST_LOG") { + Ok(existing) if existing.contains("buzz_acp") => existing, + Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), + _ => "buzz_acp=info".to_string(), + } +} + #[cfg(test)] mod tests { use super::resolve_session_title; diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 8bedfe53207..9a854ee81a0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1210,12 +1210,11 @@ fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord { fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRuntime { use std::process::{Command, Stdio}; - // Spawn a real child so ManagedAgentProcess's Child field is satisfied. - // `true` exits immediately with 0 — just a handle we need for type purposes. - // Absolute `/usr/bin/true` on unix (present on both macOS and Linux): - // parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a - // bare `true` lookup during that window fails with NotFound (observed - // flake). Windows keeps the PATH lookup — no test there swaps PATH. + // Spawn a real child so ManagedAgentProcess's Child field is satisfied; + // `true` exits immediately with 0. Absolute `/usr/bin/true` on unix (both + // macOS and Linux): parallel tests holding `lock_path_mutex` swap PATH to a + // tempdir, and a bare `true` lookup during that window fails NotFound + // (observed flake). Windows keeps the PATH lookup — no test there swaps it. #[cfg(unix)] let program = "/usr/bin/true"; #[cfg(windows)] @@ -1236,6 +1235,7 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun "wss://relay.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ), setup_mode: false, adapter_availability: None, diff --git a/desktop/src-tauri/src/managed_agents/session_policy.rs b/desktop/src-tauri/src/managed_agents/session_policy.rs new file mode 100644 index 00000000000..eb723908cab --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/session_policy.rs @@ -0,0 +1,140 @@ +use std::{ + collections::BTreeMap, + sync::atomic::{AtomicBool, Ordering}, +}; + +use tauri::{AppHandle, Manager}; + +use crate::app_state::AppState; + +pub(crate) const ACP_SESSION_POLICY_ENV_VAR: &str = "BUZZ_ACP_SESSION_POLICY"; + +/// Desktop experiment state that influences managed-agent lifecycle behavior. +pub struct ManagedAgentExperimentState { + pub(crate) profile_reconcile_enabled: AtomicBool, + pub(crate) thread_scoped_acp_sessions_enabled: AtomicBool, +} + +impl Default for ManagedAgentExperimentState { + fn default() -> Self { + Self { + profile_reconcile_enabled: AtomicBool::new(true), + thread_scoped_acp_sessions_enabled: AtomicBool::new(false), + } + } +} + +impl AppState { + pub(crate) fn managed_agent_profile_reconcile_enabled(&self) -> &AtomicBool { + &self.managed_agent_experiments.profile_reconcile_enabled + } + + pub(crate) fn thread_scoped_acp_sessions_enabled(&self) -> &AtomicBool { + &self + .managed_agent_experiments + .thread_scoped_acp_sessions_enabled + } +} + +/// Desktop-owned ACP session policy applied to every managed-agent launch. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AcpSessionPolicy { + Channel, + Thread, +} + +impl AcpSessionPolicy { + pub(crate) fn from_thread_scoped_enabled(enabled: bool) -> Self { + if enabled { + Self::Thread + } else { + Self::Channel + } + } + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Channel => "channel", + Self::Thread => "thread", + } + } +} + +/// Resolve the persisted experiment state at the shared launch boundary. +pub(crate) fn acp_session_policy(state: &AppState) -> AcpSessionPolicy { + AcpSessionPolicy::from_thread_scoped_enabled( + state + .thread_scoped_acp_sessions_enabled() + .load(Ordering::Acquire), + ) +} + +pub(crate) fn apply_acp_session_policy_env( + command: &mut std::process::Command, + policy: AcpSessionPolicy, +) { + command.env(ACP_SESSION_POLICY_ENV_VAR, policy.as_str()); +} + +/// Resolve the effective policy, apply it to `command`, and return it so the +/// caller can stamp the same value onto the spawn snapshot (env and badge can +/// never disagree about what the child launched with). +pub(crate) fn apply_app_acp_session_policy_env( + app: &AppHandle, + command: &mut std::process::Command, +) -> AcpSessionPolicy { + let policy = acp_session_policy(app.state::().inner()); + apply_acp_session_policy_env(command, policy); + policy +} + +pub(crate) fn insert_acp_session_policy_env( + policy_env: &mut BTreeMap, + policy: AcpSessionPolicy, +) { + policy_env.insert( + ACP_SESSION_POLICY_ENV_VAR.to_string(), + policy.as_str().to_string(), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command_policy(command: &std::process::Command) -> Option<&str> { + command + .get_envs() + .find(|(key, _)| *key == ACP_SESSION_POLICY_ENV_VAR) + .and_then(|(_, value)| value) + .and_then(std::ffi::OsStr::to_str) + } + + #[test] + fn absent_or_disabled_experiment_selects_channel_policy() { + assert_eq!( + AcpSessionPolicy::from_thread_scoped_enabled(false), + AcpSessionPolicy::Channel + ); + assert_eq!(AcpSessionPolicy::Channel.as_str(), "channel"); + } + + #[test] + fn enabled_experiment_selects_thread_policy() { + assert_eq!( + AcpSessionPolicy::from_thread_scoped_enabled(true), + AcpSessionPolicy::Thread + ); + assert_eq!(AcpSessionPolicy::Thread.as_str(), "thread"); + } + + #[test] + fn local_launch_env_receives_the_selected_policy() { + let mut command = std::process::Command::new("true"); + command.env(ACP_SESSION_POLICY_ENV_VAR, "ambient"); + + apply_acp_session_policy_env(&mut command, AcpSessionPolicy::Thread); + + assert_eq!(command_policy(&command), Some("thread")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 8a6f68a693d..c201c0a8a55 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -38,7 +38,7 @@ use super::{ readiness::EffectiveHarnessDescriptor, runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, - GlobalAgentConfig, + AcpSessionPolicy, GlobalAgentConfig, }; pub(crate) mod diff; @@ -76,6 +76,12 @@ pub(crate) struct SpawnConfigInputs<'a> { /// Compile-time distribution capability projected at this runtime boundary. /// The stored record remains portable; only effective spawned access is stamped. pub enforced_owner_only: bool, + /// The effective ACP session policy (`channel`/`thread`) the launch applies. + /// Resolved from the desktop experiment toggle at the shared launch + /// boundary; captured here so flipping the experiment while an agent runs + /// drives the existing restart-required path (the harness only reads + /// `BUZZ_ACP_SESSION_POLICY` at launch). + pub session_policy: AcpSessionPolicy, } /// The effective spawn configuration of one managed-agent process. @@ -136,6 +142,14 @@ pub(crate) struct SpawnConfigSnapshot { /// user env `low`, or the reverse) produces no spurious drift entry, and an /// env-only edit still surfaces as exactly one `effort_level` entry. pub effort_level: Option, + /// The effective ACP session policy this launch applies (`channel` or + /// `thread`). The harness reads `BUZZ_ACP_SESSION_POLICY` only at launch, so + /// capturing the resolved policy here lets a toggle flip while an agent runs + /// raise the restart-required badge instead of silently leaving the running + /// process on the old policy. Written directly on the spawn `Command` (not + /// via layered env), so it must be captured explicitly rather than read back + /// out of `env`. + pub session_policy: String, } /// The startup effort a spawn would actually apply, mirroring `apply_effort_env` @@ -166,6 +180,7 @@ impl SpawnConfigSnapshot { model, provider, enforced_owner_only, + session_policy, } = inputs; let (respond_to, respond_to_allowlist) = super::projected_access_with_policy(record, enforced_owner_only); @@ -219,6 +234,7 @@ impl SpawnConfigSnapshot { // raw descriptor env (before the strip), so a user-seeded env value // is preserved as the effective effort when no canonical is set. effort_level: effective_effort(record, &descriptor.env), + session_policy: session_policy.as_str().to_string(), } } @@ -259,6 +275,7 @@ pub(crate) fn prospective_spawn_config_snapshot( workspace_relay: &str, global: &GlobalAgentConfig, enforced_owner_only: bool, + session_policy: AcpSessionPolicy, ) -> SpawnConfigSnapshot { // Prospective re-snapshot: apply the same `apply_persona_snapshot` the // start/restore paths run right before spawning, so this describes what a @@ -309,6 +326,7 @@ pub(crate) fn prospective_spawn_config_snapshot( model: model.as_deref(), provider: provider.as_deref(), enforced_owner_only, + session_policy, }) } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index e21dc4735c7..fb95b16ba9b 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -29,6 +29,7 @@ fn base() -> SpawnConfigSnapshot { max_turn_duration_seconds: Some(7200), parallelism: 1, effort_level: Some("high".into()), + session_policy: "channel".into(), } } @@ -72,6 +73,7 @@ fn mutations() -> Vec { }), ("parallelism", |s| s.parallelism = 8), ("effort_level", |s| s.effort_level = None), + ("session_policy", |s| s.session_policy = "thread".into()), ] } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index b007e0b2ffa..fc82943c65c 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -20,6 +20,7 @@ fn snapshot_with_policy( workspace_relay, global, enforced_owner_only, + AcpSessionPolicy::Channel, ) .canonical() } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index dd708b6e59e..18b2fac0746 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -187,3 +187,77 @@ fn canonical_effort_edit_changes_snapshot() { "a canonical effort edit must trip the restart badge" ); } + +use crate::managed_agents::spawn_snapshot::{ + eligible_restart_diff, prospective_spawn_config_snapshot, RestartDiffEntry, + SpawnConfigSnapshot, TrackedSpawnState, +}; +use crate::managed_agents::AcpSessionPolicy; + +/// Build the prospective snapshot for a bare record under one session policy. +fn snapshot_under(policy: AcpSessionPolicy) -> SpawnConfigSnapshot { + prospective_spawn_config_snapshot( + &record(), + &[], + &[], + "wss://ws.example", + &Default::default(), + false, + policy, + ) +} + +/// Restart-badge entries for a stamped→current session-policy transition, +/// exercising the real badge path (`eligible_restart_diff`). +fn policy_transition_diff( + stamped: &SpawnConfigSnapshot, + current: &SpawnConfigSnapshot, +) -> Vec { + eligible_restart_diff( + false, + Some(TrackedSpawnState { + stamped, + current, + stamped_availability: None, + current_availability: None, + }), + ) +} + +#[test] +fn toggling_session_policy_while_running_requires_restart() { + // Regression: flipping the desktop experiment must reach the config-drift + // path so a running agent restarts onto the new policy. The harness reads + // BUZZ_ACP_SESSION_POLICY only at launch, so without the snapshot field the + // badge stayed dark and the process silently kept the old policy. + let channel = snapshot_under(AcpSessionPolicy::Channel); + let thread = snapshot_under(AcpSessionPolicy::Thread); + + // channel -> thread lights exactly the session_policy entry. + let forward = policy_transition_diff(&channel, &thread); + assert_eq!( + forward.iter().map(|e| e.field.as_str()).collect::>(), + vec!["session_policy"], + ); + + // thread -> channel is equally visible (rollback also restarts). + let reverse = policy_transition_diff(&thread, &channel); + assert_eq!( + reverse.iter().map(|e| e.field.as_str()).collect::>(), + vec!["session_policy"], + ); +} + +#[test] +fn unchanged_session_policy_does_not_require_restart() { + // An unchanged policy must not badge — the default (channel) case must stay + // byte-for-byte inert so existing running agents don't flash a spurious + // restart badge after this change ships. + let channel = snapshot_under(AcpSessionPolicy::Channel); + assert!( + policy_transition_diff(&channel, &snapshot_under(AcpSessionPolicy::Channel)).is_empty() + ); + + let thread = snapshot_under(AcpSessionPolicy::Thread); + assert!(policy_transition_diff(&thread, &snapshot_under(AcpSessionPolicy::Thread)).is_empty()); +} diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 754a40769c1..eeb8e68cbeb 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -138,6 +138,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -155,6 +156,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); assert_eq!( @@ -190,6 +192,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -207,6 +210,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { "wss://ws.example", &Default::default(), false, + crate::managed_agents::AcpSessionPolicy::Channel, ); assert_eq!(before.canonical(), after.canonical()); diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index c565ee0f7b4..7e45d0deb04 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -5,10 +5,10 @@ import { isMacPlatform } from "@/shared/lib/platform"; import { relayClient } from "@/shared/api/relayClient"; import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate"; import { - applyCommunity, autoConnectDefaultRelayEnabled, getDefaultRelayUrl, } from "@/shared/api/tauri"; +import { applyCommunity } from "@/shared/api/tauriWorkspace"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; @@ -300,6 +300,7 @@ export function useCommunityInit( activeCommunity.token, activeCommunity.reposDir, getOverrides().agentManagedProfiles === true, + getOverrides().threadScopedAcpSessions === true, ); } catch (error) { // A bad `repos_dir` no longer reaches here — `apply_workspace` treats diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 50052c014fe..c1a1564c9c2 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,4 +1,7 @@ -import { setAgentManagedProfiles } from "@/shared/api/tauri"; +import { + setAgentManagedProfiles, + setThreadScopedAcpSessions, +} from "@/shared/api/tauriWorkspace"; import { desktopFeatures, useFeatureToggle } from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; import { Switch } from "@/shared/ui/switch"; @@ -33,6 +36,14 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) { ); }); } + if (feature.id === "threadScopedAcpSessions") { + void setThreadScopedAcpSessions(value).catch((error) => { + console.error( + "Failed to apply thread-scoped ACP sessions setting:", + error, + ); + }); + } }} /> diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 6c5b59c9837..504057b4291 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1071,22 +1071,6 @@ export async function cancelPairing(): Promise { await invokeTauri("cancel_pairing"); } -export async function applyCommunity( - relayUrl: string, - nsec?: string, - token?: string, - reposDir?: string, - agentManagedProfiles?: boolean, -): Promise { - await invokeTauri("apply_workspace", { - relayUrl, - nsec: nsec ?? null, - token: token ?? null, - reposDir: reposDir ?? null, - agentManagedProfiles: agentManagedProfiles ?? false, - }); -} - // Validate a candidate repos dir without mutating the filesystem. Rejects // with a human-readable reason; resolves for a valid or empty path. export async function validateReposDir(dir: string): Promise { @@ -1096,9 +1080,6 @@ export async function validateReposDir(dir: string): Promise { export const setPreventSleepActive = (active: boolean) => invokeTauri("set_prevent_sleep_active", { active }); -export const setAgentManagedProfiles = (enabled: boolean) => - invokeTauri("set_agent_managed_profiles", { enabled }); - /** Returns true on macOS, Windows, and Linux AppImage installs. * Returns false on Linux non-AppImage packages (e.g. .deb) where * Tauri's updater cannot swap the binary. */ diff --git a/desktop/src/shared/api/tauriWorkspace.ts b/desktop/src/shared/api/tauriWorkspace.ts new file mode 100644 index 00000000000..4d856668d8b --- /dev/null +++ b/desktop/src/shared/api/tauriWorkspace.ts @@ -0,0 +1,25 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export async function applyCommunity( + relayUrl: string, + nsec?: string, + token?: string, + reposDir?: string, + agentManagedProfiles?: boolean, + threadScopedAcpSessions?: boolean, +): Promise { + await invokeTauri("apply_workspace", { + relayUrl, + nsec: nsec ?? null, + token: token ?? null, + reposDir: reposDir ?? null, + agentManagedProfiles: agentManagedProfiles ?? false, + threadScopedAcpSessions: threadScopedAcpSessions ?? false, + }); +} + +export const setAgentManagedProfiles = (enabled: boolean) => + invokeTauri("set_agent_managed_profiles", { enabled }); + +export const setThreadScopedAcpSessions = (enabled: boolean) => + invokeTauri("set_thread_scoped_acp_sessions", { enabled }); diff --git a/desktop/src/shared/features/manifest.test.mjs b/desktop/src/shared/features/manifest.test.mjs new file mode 100644 index 00000000000..72d4f58ae28 --- /dev/null +++ b/desktop/src/shared/features/manifest.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const manifest = JSON.parse( + readFileSync( + new URL("../../../../preview-features.json", import.meta.url), + "utf8", + ), +); + +test("thread-scoped ACP sessions is a default-off desktop experiment", () => { + const feature = manifest.features.find( + ({ id }) => id === "threadScopedAcpSessions", + ); + + assert.deepEqual(feature, { + id: "threadScopedAcpSessions", + name: "Thread Scoped ACP Sessions", + description: + "Give each channel thread isolated agent context. Applies when managed agents next start; DMs stay conversation-scoped.", + platforms: ["desktop"], + }); + assert.equal(feature.defaultEnabled, undefined); +}); + +test("existing Projects and Workflows experiments remain unchanged", () => { + const existing = Object.fromEntries( + manifest.features + .filter(({ id }) => id === "projects" || id === "workflows") + .map((feature) => [feature.id, feature]), + ); + + assert.deepEqual(existing, { + projects: { + id: "projects", + name: "Projects", + description: "Git repository browser and collaboration", + platforms: ["desktop"], + }, + workflows: { + id: "workflows", + name: "Workflows", + description: "YAML-defined automations with approval gates", + platforms: ["desktop"], + }, + }); +}); diff --git a/desktop/src/shared/features/store.test.mjs b/desktop/src/shared/features/store.test.mjs index ae7d0c0ad19..ab465b9b3d4 100644 --- a/desktop/src/shared/features/store.test.mjs +++ b/desktop/src/shared/features/store.test.mjs @@ -46,6 +46,21 @@ test("setOverride persists filtered overrides", () => { ); }); +test("thread-scoped ACP session preference persists without changing existing experiments", () => { + const { values } = installStorage({ workflows: true, projects: false }); + + setOverride("threadScopedAcpSessions", true); + + assert.equal( + values.get(OVERRIDES_KEY), + JSON.stringify({ + workflows: true, + projects: false, + threadScopedAcpSessions: true, + }), + ); +}); + test("getOverrides drops non-boolean values", () => { installStorage({ workflows: "yes", projects: false }); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bf16dd3b00a..7e2e18b0564 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13182,6 +13182,8 @@ export function maybeInstallE2eTauriMocks() { return []; case "set_agent_managed_profiles": return undefined; + case "set_thread_scoped_acp_sessions": + return undefined; case "set_managed_agent_auto_restart": return handleSetManagedAgentAutoRestart( payload as Parameters[0], diff --git a/desktop/tests/e2e/experimental-features.spec.ts b/desktop/tests/e2e/experimental-features.spec.ts new file mode 100644 index 00000000000..b9069822609 --- /dev/null +++ b/desktop/tests/e2e/experimental-features.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; +import { openSettings } from "../helpers/settings"; + +const DESCRIPTION = + "Give each channel thread isolated agent context. Applies when managed agents next start; DMs stay conversation-scoped."; + +test("thread-scoped ACP sessions is default-off, persists, and applies on reload", async ({ + page, +}) => { + await installMockBridge(page, undefined, { seedPreviewFeatures: false }); + await page.goto("/"); + + await expect + .poll(() => + page.evaluate(() => { + const call = window.__BUZZ_E2E_COMMAND_LOG__?.find( + ({ command }) => command === "apply_workspace", + ); + return (call?.payload as { threadScopedAcpSessions?: boolean } | null) + ?.threadScopedAcpSessions; + }), + ) + .toBe(false); + + await openSettings(page, "experimental"); + const toggle = page.getByTestId("feature-toggle-threadScopedAcpSessions"); + + await expect( + page.getByText("Thread Scoped ACP Sessions", { exact: true }), + ).toBeVisible(); + await expect(page.getByText(DESCRIPTION, { exact: true })).toBeVisible(); + await expect(toggle).not.toBeChecked(); + await expect(page.getByTestId("feature-toggle-projects")).not.toBeChecked(); + await expect(page.getByTestId("feature-toggle-workflows")).not.toBeChecked(); + + await toggle.click(); + await expect(toggle).toBeChecked(); + await expect + .poll(() => + page.evaluate(() => { + const calls = window.__BUZZ_E2E_COMMAND_LOG__ ?? []; + const call = calls.findLast( + ({ command }) => command === "set_thread_scoped_acp_sessions", + ); + return (call?.payload as { enabled?: boolean } | null)?.enabled; + }), + ) + .toBe(true); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FEATURE_OVERRIDES_STORAGE_KEY, + ), + ) + .toContain('"threadScopedAcpSessions":true'); + + await page.reload(); + // Settings route/section state survives reload, so wait for that restored + // view rather than trying to reopen settings from the app chrome. + await expect(page.getByTestId("settings-view")).toBeVisible(); + await expect( + page.getByTestId("feature-toggle-threadScopedAcpSessions"), + ).toBeChecked(); + await expect + .poll(() => + page.evaluate(() => { + const call = window.__BUZZ_E2E_COMMAND_LOG__?.find( + ({ command }) => command === "apply_workspace", + ); + return (call?.payload as { threadScopedAcpSessions?: boolean } | null) + ?.threadScopedAcpSessions; + }), + ) + .toBe(true); +}); diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index c26c0e91951..5eb1560bd2a 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -7,6 +7,7 @@ type SettingsSection = | "agents" | "channel-templates" | "compute" + | "experimental" | "appearance" | "shortcuts" | "hosted-communities" diff --git a/preview-features.json b/preview-features.json index 388f1c39b04..3b9de66ca82 100644 --- a/preview-features.json +++ b/preview-features.json @@ -7,6 +7,12 @@ "description": "YAML-defined automations with approval gates", "platforms": ["desktop"] }, + { + "id": "threadScopedAcpSessions", + "name": "Thread Scoped ACP Sessions", + "description": "Give each channel thread isolated agent context. Applies when managed agents next start; DMs stay conversation-scoped.", + "platforms": ["desktop"] + }, { "id": "projects", "name": "Projects",