diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 2bac14ead..8b45496e7 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -60,6 +60,15 @@ pub enum DedupMode { Queue, } +/// Scope used for scheduling and retained ACP session identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum SessionScope { + /// One independent execution lane and session per outermost conversation root. + Thread, + /// Legacy behavior: one serialized execution lane and session per channel. + Channel, +} + /// How to handle new @mentions while a turn is already in-flight for that channel. #[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] pub enum MultipleEventHandling { @@ -468,10 +477,15 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, - /// Isolate managed-agent ACP sessions by human conversation root. - /// Disabled by default; Desktop enables it through its in-app experiment. - #[arg(long, env = "BUZZ_ACP_TOP_LEVEL_SESSIONS", default_value_t = false)] - pub top_level_sessions: bool, + /// Scheduling and retained-session scope. Thread scope is the durable default; + /// channel scope preserves the legacy one-session-per-channel behavior. + #[arg( + long, + env = "BUZZ_ACP_SESSION_SCOPE", + default_value = "thread", + value_enum + )] + pub session_scope: SessionScope, } /// Merged NIP-01 subscription filter for a single channel. @@ -542,8 +556,8 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, - /// Whether channel turns use conversation-root-scoped ACP sessions. - pub top_level_sessions: bool, + /// Scheduling and retained-session scope. + pub session_scope: SessionScope, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -1003,7 +1017,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, - top_level_sessions: args.top_level_sessions, + session_scope: args.session_scope, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1377,7 +1391,7 @@ mod tests { has_generated_codex_config: false, relay_observer: false, agent_owner: None, - top_level_sessions: false, + session_scope: SessionScope::Channel, no_base_prompt: false, base_prompt_content: None, } @@ -2389,6 +2403,21 @@ channels = "ALL" // ── Multiple-event-handling validation + default ────────────────────────── + #[test] + fn test_session_scope_defaults_to_thread_and_accepts_legacy_channel_mode() { + let default_args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]); + assert_eq!(default_args.session_scope, SessionScope::Thread); + + let channel_args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-scope", + "channel", + ]); + assert_eq!(channel_args.session_scope, SessionScope::Channel); + } + #[test] fn test_multiple_event_handling_default_is_steer() { // Parse a minimal arg set; the default for --multiple-event-handling diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index f291c0edd..44309138d 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2031,7 +2031,7 @@ async fn tokio_main() -> Result<()> { event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, - conversation_root: if config.top_level_sessions { + conversation_root: if matches!(config.session_scope, config::SessionScope::Thread) { let tags = queue::parse_thread_tags(&event_for_steer); Some(tags.root_event_id.unwrap_or_else(|| event_id_hex.clone())) } else { @@ -4181,7 +4181,7 @@ mod build_mcp_servers_tests { has_generated_codex_config: false, relay_observer: false, agent_owner: None, - top_level_sessions: false, + session_scope: config::SessionScope::Channel, no_base_prompt: false, base_prompt_content: None, } @@ -4347,7 +4347,7 @@ mod error_outcome_emission_tests { has_generated_codex_config: false, relay_observer: false, agent_owner: None, - top_level_sessions: false, + session_scope: config::SessionScope::Channel, no_base_prompt: false, base_prompt_content: None, } diff --git a/desktop/src-tauri/src/commands/experiments.rs b/desktop/src-tauri/src/commands/experiments.rs index a696a2145..e7c971d3f 100644 --- a/desktop/src-tauri/src/commands/experiments.rs +++ b/desktop/src-tauri/src/commands/experiments.rs @@ -1,7 +1,7 @@ use std::{ fs, path::{Path, PathBuf}, - sync::atomic::{AtomicBool, Ordering}, + sync::atomic::{AtomicU8, Ordering}, sync::Once, }; @@ -9,32 +9,69 @@ use atomic_write_file::AtomicWriteFile; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; -const EXPERIMENTS_FILE: &str = "desktop-experiments.json"; +const SETTINGS_FILE: &str = "desktop-experiments.json"; +const THREAD_SCOPE: u8 = 0; +const CHANNEL_SCOPE: u8 = 1; -/// Process-local experiment state, lazily hydrated from the Rust-owned store on -/// first read so every reader (spawn boundary, frontend) sees persisted state -/// without an app-setup ordering requirement. A corrupt store fails closed: -/// the error is logged and the disabled default stays. -static ACP_TOP_LEVEL_SESSIONS: AtomicBool = AtomicBool::new(false); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AcpSessionScope { + Thread, + Channel, +} + +impl AcpSessionScope { + fn atomic_value(self) -> u8 { + match self { + Self::Thread => THREAD_SCOPE, + Self::Channel => CHANNEL_SCOPE, + } + } +} + +/// Process-local setting, lazily hydrated from the Rust-owned store on first +/// read. Thread scope is the durable default when no explicit choice exists. +static ACP_SESSION_SCOPE: AtomicU8 = AtomicU8::new(THREAD_SCOPE); static HYDRATE: Once = Once::new(); #[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] -struct DesktopExperiments { - acp_top_level_sessions: bool, +struct DesktopSettings { + #[serde(skip_serializing_if = "Option::is_none")] + acp_session_scope: Option, + // Migration from the preview experiment. Remove after existing installs + // have had a release cycle to persist `acp_session_scope`. + #[serde(skip_serializing)] + acp_top_level_sessions: Option, } -fn experiments_path(app: &AppHandle) -> Result { +impl DesktopSettings { + fn session_scope(&self) -> AcpSessionScope { + self.acp_session_scope.unwrap_or_else(|| { + self.acp_top_level_sessions + .map(|enabled| { + if enabled { + AcpSessionScope::Thread + } else { + AcpSessionScope::Channel + } + }) + .unwrap_or(AcpSessionScope::Thread) + }) + } +} + +fn settings_path(app: &AppHandle) -> Result { Ok(app .path() .app_data_dir() .map_err(|error| format!("app data dir: {error}"))? - .join(EXPERIMENTS_FILE)) + .join(SETTINGS_FILE)) } -fn load_experiments(path: &Path) -> Result { +fn load_settings(path: &Path) -> Result { if !path.exists() { - return Ok(DesktopExperiments::default()); + return Ok(DesktopSettings::default()); } let payload = fs::read(path).map_err(|error| format!("failed to read {}: {error}", path.display()))?; @@ -42,13 +79,13 @@ fn load_experiments(path: &Path) -> Result { .map_err(|error| format!("failed to parse {}: {error}", path.display())) } -fn save_experiments(path: &Path, experiments: &DesktopExperiments) -> Result<(), String> { +fn save_settings(path: &Path, settings: &DesktopSettings) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; } - let payload = serde_json::to_vec_pretty(experiments) - .map_err(|error| format!("failed to serialize experiments: {error}"))?; + let payload = serde_json::to_vec_pretty(settings) + .map_err(|error| format!("failed to serialize settings: {error}"))?; let mut file = AtomicWriteFile::open(path) .map_err(|error| format!("open {} for atomic write: {error}", path.display()))?; use std::io::Write; @@ -58,70 +95,84 @@ fn save_experiments(path: &Path, experiments: &DesktopExperiments) -> Result<(), .map_err(|error| format!("commit {}: {error}", path.display())) } -/// Read the experiment, hydrating from the store on first access. -pub(crate) fn acp_top_level_sessions_enabled(app: &AppHandle) -> bool { +pub(crate) fn acp_session_scope(app: &AppHandle) -> AcpSessionScope { HYDRATE.call_once( - || match experiments_path(app).and_then(|path| load_experiments(&path)) { - Ok(experiments) => { - ACP_TOP_LEVEL_SESSIONS.store(experiments.acp_top_level_sessions, Ordering::Release); + || match settings_path(app).and_then(|path| load_settings(&path)) { + Ok(settings) => { + ACP_SESSION_SCOPE.store(settings.session_scope().atomic_value(), Ordering::Release) } Err(error) => { - eprintln!("buzz-desktop: failed to hydrate desktop experiments: {error}"); + eprintln!("buzz-desktop: failed to hydrate desktop settings: {error}"); } }, ); - ACP_TOP_LEVEL_SESSIONS.load(Ordering::Acquire) + match ACP_SESSION_SCOPE.load(Ordering::Acquire) { + CHANNEL_SCOPE => AcpSessionScope::Channel, + _ => AcpSessionScope::Thread, + } } #[tauri::command] -pub fn get_acp_top_level_sessions_experiment(app: AppHandle) -> bool { - acp_top_level_sessions_enabled(&app) +pub fn get_acp_session_scope(app: AppHandle) -> AcpSessionScope { + acp_session_scope(&app) } -/// Durably apply the experiment before exposing it to subsequently spawned agents. #[tauri::command] -pub fn set_acp_top_level_sessions_experiment(enabled: bool, app: AppHandle) -> Result<(), String> { - let path = experiments_path(&app)?; - let mut experiments = load_experiments(&path)?; - experiments.acp_top_level_sessions = enabled; - save_experiments(&path, &experiments)?; - // Persisted first: even if first-read hydration races this store, it - // re-reads the same on-disk value. - ACP_TOP_LEVEL_SESSIONS.store(enabled, Ordering::Release); +pub fn set_acp_session_scope(scope: AcpSessionScope, app: AppHandle) -> Result<(), String> { + let path = settings_path(&app)?; + let mut settings = load_settings(&path)?; + settings.acp_session_scope = Some(scope); + settings.acp_top_level_sessions = None; + save_settings(&path, &settings)?; + ACP_SESSION_SCOPE.store(scope.atomic_value(), Ordering::Release); Ok(()) } #[cfg(test)] mod tests { - use super::{load_experiments, save_experiments, DesktopExperiments}; + use super::{load_settings, save_settings, AcpSessionScope, DesktopSettings}; #[test] - fn missing_store_defaults_disabled() { + fn missing_store_defaults_to_thread_scope() { let dir = tempfile::tempdir().unwrap(); - let loaded = load_experiments(&dir.path().join("missing.json")).unwrap(); - assert!(!loaded.acp_top_level_sessions); + let loaded = load_settings(&dir.path().join("missing.json")).unwrap(); + assert_eq!(loaded.session_scope(), AcpSessionScope::Thread); } #[test] - fn persisted_enabled_state_round_trips_for_fresh_launch() { + fn explicit_channel_scope_round_trips() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("desktop-experiments.json"); - save_experiments( + save_settings( &path, - &DesktopExperiments { - acp_top_level_sessions: true, + &DesktopSettings { + acp_session_scope: Some(AcpSessionScope::Channel), + acp_top_level_sessions: None, }, ) .unwrap(); - let loaded = load_experiments(&path).unwrap(); - assert!(loaded.acp_top_level_sessions); + assert_eq!( + load_settings(&path).unwrap().session_scope(), + AcpSessionScope::Channel + ); } #[test] - fn malformed_store_fails_closed_instead_of_enabling() { + fn migrates_explicit_preview_opt_out_to_channel_scope() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + std::fs::write(&path, br#"{"acp_top_level_sessions":false}"#).unwrap(); + assert_eq!( + load_settings(&path).unwrap().session_scope(), + AcpSessionScope::Channel + ); + } + + #[test] + fn malformed_store_returns_error_and_keeps_process_default() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("desktop-experiments.json"); std::fs::write(&path, b"not json").unwrap(); - assert!(load_experiments(&path).is_err()); + assert!(load_settings(&path).is_err()); } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2aadaf54c..139bef68e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -746,8 +746,8 @@ pub fn run() { list_managed_agents, create_managed_agent, start_managed_agent, - get_acp_top_level_sessions_experiment, - set_acp_top_level_sessions_experiment, + get_acp_session_scope, + set_acp_session_scope, stop_managed_agent, set_agent_managed_profiles, set_managed_agent_start_on_app_launch, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ba951a6cd..611364b0d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1714,7 +1714,7 @@ pub fn spawn_agent_child( // Legacy default. User env may override this while the experiment is off; // enabled experiment state is authoritatively finalized at the spawn boundary. command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer"); - let top_level_sessions = crate::commands::experiments::acp_top_level_sessions_enabled(app); + let session_scope = crate::commands::experiments::acp_session_scope(app); command.env("BUZZ_ACP_DEDUP", "queue"); if let Some(meta) = runtime_meta { for (key, value) in meta.default_env { @@ -1871,7 +1871,7 @@ pub fn spawn_agent_child( // Finalize after every default and user env write. Enabled experiment state // must be authoritative; disabled state only removes the new variable and // preserves the legacy handling override chosen above or supplied by users. - finalize_acp_top_level_sessions_env(&mut command, top_level_sessions); + finalize_acp_session_scope_env(&mut command, session_scope); // Spawn the harness in its own process group so we can kill the entire // tree (harness + MCP servers + agent subprocesses) on shutdown. @@ -2128,17 +2128,16 @@ pub(crate) fn resolve_effective_prompt_model_provider( } } -fn finalize_acp_top_level_sessions_env(command: &mut std::process::Command, enabled: bool) { - if enabled { - command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "true"); - // Conversation roots remain channel-serialized but must never steer - // into another root's in-flight ACP session. - command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "queue"); - } else { - // The experiment's variable is never user-controlled while disabled. - // Do not touch MULTIPLE_EVENT_HANDLING: legacy user overrides remain valid. - command.env_remove("BUZZ_ACP_TOP_LEVEL_SESSIONS"); - } +fn finalize_acp_session_scope_env( + command: &mut std::process::Command, + scope: crate::commands::experiments::AcpSessionScope, +) { + let value = match scope { + crate::commands::experiments::AcpSessionScope::Thread => "thread", + crate::commands::experiments::AcpSessionScope::Channel => "channel", + }; + command.env("BUZZ_ACP_SESSION_SCOPE", value); + command.env_remove("BUZZ_ACP_TOP_LEVEL_SESSIONS"); } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 9c5bdf107..817abb1e0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -771,35 +771,42 @@ fn command_env( } #[test] -fn top_level_sessions_enabled_finalization_overrides_conflicting_later_env() { +fn thread_scope_finalization_overrides_legacy_env_without_forcing_queue() { let mut command = std::process::Command::new("buzz-acp"); - // Simulate conflicting runtime defaults/user env written after Buzz's legacy defaults. command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "false"); command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer"); - super::finalize_acp_top_level_sessions_env(&mut command, true); + super::finalize_acp_session_scope_env( + &mut command, + crate::commands::experiments::AcpSessionScope::Thread, + ); let env = command_env(&command); assert_eq!( - env.get(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS")) + env.get(std::ffi::OsStr::new("BUZZ_ACP_SESSION_SCOPE")) .unwrap(), - "true" + "thread" ); + assert!(!env.contains_key(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS"))); assert_eq!( env.get(std::ffi::OsStr::new("BUZZ_ACP_MULTIPLE_EVENT_HANDLING")) .unwrap(), - "queue" + "steer" ); } #[test] -fn top_level_sessions_disabled_removes_flag_but_preserves_handling_override() { +fn channel_scope_finalization_preserves_handling_override() { let mut command = std::process::Command::new("buzz-acp"); - // Simulate conflicting later user env: the new flag is suppressed while the - // pre-existing handling override remains compatible when experiment is off. - command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "true"); command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "interrupt"); - super::finalize_acp_top_level_sessions_env(&mut command, false); + super::finalize_acp_session_scope_env( + &mut command, + crate::commands::experiments::AcpSessionScope::Channel, + ); let env = command_env(&command); - assert!(!env.contains_key(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS"))); + assert_eq!( + env.get(std::ffi::OsStr::new("BUZZ_ACP_SESSION_SCOPE")) + .unwrap(), + "channel" + ); assert_eq!( env.get(std::ffi::OsStr::new("BUZZ_ACP_MULTIPLE_EVENT_HANDLING")) .unwrap(), diff --git a/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx b/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx new file mode 100644 index 000000000..3579533a2 --- /dev/null +++ b/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { invokeTauri, listManagedAgents } from "@/shared/api/tauri"; +import { + startManagedAgent, + stopManagedAgent, +} from "@/shared/api/tauriManagedAgents"; +import { Switch } from "@/shared/ui/switch"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import { applyAcpSessionScopeSetting } from "./acpSessionScopeSetting"; + +type SessionScope = "thread" | "channel"; + +export function AcpSessionScopeSettingsCard() { + const [scope, setScope] = useState("thread"); + const [pending, setPending] = useState(true); + + useEffect(() => { + let cancelled = false; + void invokeTauri("get_acp_session_scope") + .then((persisted) => { + if (!cancelled) setScope(persisted); + }) + .catch((error) => + console.error("Failed to hydrate ACP session scope", error), + ) + .finally(() => { + if (!cancelled) setPending(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const setThreadScoped = async (threadScoped: boolean) => { + setPending(true); + try { + await applyAcpSessionScopeSetting(scope === "thread", threadScoped, { + setBackend: (next) => + invokeTauri("set_acp_session_scope", { scope: next }), + listAgents: listManagedAgents, + stopAgent: stopManagedAgent, + startAgent: startManagedAgent, + setUi: (enabled) => setScope(enabled ? "thread" : "channel"), + }); + } catch (error) { + console.error("Failed to apply ACP session scope", error); + } finally { + setPending(false); + } + }; + + return ( +
+ +
+
+

+ Thread-scoped sessions +

+

+ Run separate threads concurrently. Turn this off for one legacy + session per channel. +

+
+ void setThreadScoped(value)} + /> +
+
+ ); +} diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 16e2e87ef..9e0064ad8 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,80 +1,20 @@ import { setAgentManagedProfiles } from "@/shared/api/tauri"; import { desktopFeatures, useFeatureToggle } from "@/shared/features"; -import { useEffect, useState } from "react"; + import type { FeatureDefinition } from "@/shared/features"; -import { listManagedAgents } from "@/shared/api/tauri"; -import { - startManagedAgent, - stopManagedAgent, -} from "@/shared/api/tauriManagedAgents"; -import { invokeTauri } from "@/shared/api/tauri"; import { Switch } from "@/shared/ui/switch"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; -import { applyAcpTopLevelSessionsExperiment } from "./acpTopLevelSessionsExperiment"; function FeatureRow({ feature }: { feature: FeatureDefinition }) { const [enabled, toggle] = useFeatureToggle(feature.id); - const [pending, setPending] = useState(false); const switchId = `feature-toggle-${feature.id}`; - // Rust persistence is authoritative for this runtime experiment. Hydrate the - // local feature store when the row mounts rather than pushing localStorage - // into Tauri after launch-time agent restore has already run. Keep the switch - // disabled until this one-shot hydration finishes so a stale local value - // cannot race an in-flight toggle. - const isAcpTopLevelSessions = feature.id === "acpTopLevelSessions"; - const [hydrated, setHydrated] = useState(!isAcpTopLevelSessions); - useEffect(() => { - if (!isAcpTopLevelSessions) return; - let cancelled = false; - void invokeTauri("get_acp_top_level_sessions_experiment") - .then((persisted) => { - if (cancelled) return; - toggle(persisted); - setHydrated(true); - }) - .catch((error) => { - if (!cancelled) { - console.error( - "Failed to hydrate ACP top-level sessions experiment", - error, - ); - } + const handleToggle = (value: boolean) => { + toggle(value); + if (feature.id === "agentManagedProfiles") { + void setAgentManagedProfiles(value).catch((error) => { + console.error("Failed to apply agent-managed profiles setting:", error); }); - return () => { - cancelled = true; - }; - }, [isAcpTopLevelSessions, toggle]); - - const handleToggle = async (value: boolean) => { - if (feature.id !== "acpTopLevelSessions") { - toggle(value); - if (feature.id === "agentManagedProfiles") { - void setAgentManagedProfiles(value).catch((error) => { - console.error( - "Failed to apply agent-managed profiles setting:", - error, - ); - }); - } - return; - } - setPending(true); - try { - await applyAcpTopLevelSessionsExperiment(enabled, value, { - setBackend: (next) => - invokeTauri("set_acp_top_level_sessions_experiment", { - enabled: next, - }), - listAgents: listManagedAgents, - stopAgent: stopManagedAgent, - startAgent: startManagedAgent, - setUi: toggle, - }); - } catch (error) { - console.error("Failed to apply ACP top-level sessions experiment", error); - } finally { - setPending(false); } }; @@ -90,8 +30,7 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) { aria-labelledby={`${switchId}-label`} checked={enabled} data-testid={switchId} - disabled={pending || !hydrated} - onCheckedChange={(value) => void handleToggle(value)} + onCheckedChange={handleToggle} /> ); diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 767d75894..40a81b2f5 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -57,6 +57,7 @@ import { import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; +import { AcpSessionScopeSettingsCard } from "./AcpSessionScopeSettingsCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; @@ -717,6 +718,7 @@ export function renderSettingsSection(
+
); case "channel-templates": diff --git a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs similarity index 80% rename from desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs rename to desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs index 1246206cc..f020f64bb 100644 --- a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs +++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { applyAcpTopLevelSessionsExperiment } from "./acpTopLevelSessionsExperiment.ts"; +import { applyAcpSessionScopeSetting } from "./acpSessionScopeSetting.ts"; const localRunning = { pubkey: "local", @@ -23,7 +23,7 @@ function harness(overrides = {}) { return { calls, deps: { - setBackend: async (enabled) => calls.push(["backend", enabled]), + setBackend: async (scope) => calls.push(["backend", scope]), listAgents: async () => [localRunning, remoteRunning, localStopped], stopAgent: async (pubkey) => calls.push(["stop", pubkey]), startAgent: async (pubkey) => calls.push(["start", pubkey]), @@ -33,12 +33,12 @@ function harness(overrides = {}) { }; } -describe("ACP top-level sessions experiment", () => { +describe("ACP session scope setting", () => { it("commits UI only after applying backend state and restarting running local agents", async () => { const { calls, deps } = harness(); - await applyAcpTopLevelSessionsExperiment(false, true, deps); + await applyAcpSessionScopeSetting(false, true, deps); assert.deepEqual(calls, [ - ["backend", true], + ["backend", "thread"], ["stop", "local"], ["start", "local"], ["ui", true], @@ -55,14 +55,14 @@ describe("ACP top-level sessions experiment", () => { }, }); await assert.rejects( - applyAcpTopLevelSessionsExperiment(false, true, deps), + applyAcpSessionScopeSetting(false, true, deps), /restart failed/, ); assert.deepEqual(calls, [ - ["backend", true], + ["backend", "thread"], ["stop", "local"], ["start", "local"], - ["backend", false], + ["backend", "channel"], ["stop", "local"], ["start", "local"], ["ui", false], @@ -71,13 +71,13 @@ describe("ACP top-level sessions experiment", () => { it("rolls UI back when persistence fails before any restart", async () => { const { calls, deps } = harness({ - setBackend: async (enabled) => { - calls.push(["backend", enabled]); - if (enabled) throw new Error("persist failed"); + setBackend: async (scope) => { + calls.push(["backend", scope]); + if (scope === "thread") throw new Error("persist failed"); }, }); await assert.rejects( - applyAcpTopLevelSessionsExperiment(false, true, deps), + applyAcpSessionScopeSetting(false, true, deps), /persist failed/, ); assert.equal(calls.at(-1)[0], "ui"); @@ -112,16 +112,16 @@ describe("ACP top-level sessions experiment", () => { }); await assert.rejects( - applyAcpTopLevelSessionsExperiment(false, true, deps), + applyAcpSessionScopeSetting(false, true, deps), /apply failed/, ); assert.deepEqual(calls, [ - ["backend", true], + ["backend", "thread"], ["stop", "first"], ["start", "first"], ["stop", "second"], ["start", "second"], - ["backend", false], + ["backend", "channel"], ["stop", "first"], ["start", "first"], ["stop", "second"], diff --git a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts similarity index 63% rename from desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts rename to desktop/src/features/settings/ui/acpSessionScopeSetting.ts index b7652c31a..57a004402 100644 --- a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts +++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts @@ -1,20 +1,20 @@ -export type ExperimentAgent = { +export type SessionScopeAgent = { pubkey: string; status: string; backend: { type: string }; }; -export type ExperimentToggleDependencies = { - setBackend: (enabled: boolean) => Promise; - listAgents: () => Promise; +export type SessionScopeDependencies = { + setBackend: (scope: "thread" | "channel") => Promise; + listAgents: () => Promise; stopAgent: (pubkey: string) => Promise; startAgent: (pubkey: string) => Promise; - setUi: (enabled: boolean) => void; + setUi: (threadScoped: boolean) => void; }; async function restartRunningLocalAgents( - agents: ExperimentAgent[], - deps: ExperimentToggleDependencies, + agents: SessionScopeAgent[], + deps: SessionScopeDependencies, ): Promise { for (const agent of agents) { if (agent.status !== "running" || agent.backend.type !== "local") continue; @@ -24,26 +24,26 @@ async function restartRunningLocalAgents( } /** - * Apply the Rust-owned experiment and restart affected processes. The UI is + * Apply the Rust-owned session-scope setting and restart affected processes. The UI is * committed only after every restart succeeds. On failure, both persisted * backend state and already-restarted agents are restored best-effort. */ -export async function applyAcpTopLevelSessionsExperiment( +export async function applyAcpSessionScopeSetting( previous: boolean, next: boolean, - deps: ExperimentToggleDependencies, + deps: SessionScopeDependencies, ): Promise { const agents = await deps.listAgents(); try { - await deps.setBackend(next); + await deps.setBackend(next ? "thread" : "channel"); await restartRunningLocalAgents(agents, deps); deps.setUi(next); } catch (error) { try { - await deps.setBackend(previous); + await deps.setBackend(previous ? "thread" : "channel"); } catch (rollbackError) { console.error( - "Failed to roll back ACP top-level sessions backend state", + "Failed to roll back ACP session-scope backend state", rollbackError, ); } @@ -55,7 +55,7 @@ export async function applyAcpTopLevelSessionsExperiment( await deps.startAgent(agent.pubkey); } catch (rollbackError) { console.error( - `Failed to roll back ACP experiment process ${agent.pubkey}`, + `Failed to roll back ACP session-scope process ${agent.pubkey}`, rollbackError, ); } diff --git a/preview-features.json b/preview-features.json index 43d0035ef..ad8090d5a 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,14 +40,6 @@ "platforms": [ "desktop" ] - }, - { - "id": "acpTopLevelSessions", - "name": "Fresh agent sessions by conversation", - "description": "Start each human top-level agent mention in an isolated ACP session", - "platforms": [ - "desktop" - ] } ] }