mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(acp): make thread session scope the durable default
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
ae1bbc8d64
commit
2444b17b37
@@ -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<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<AcpSessionScope>,
|
||||
// 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<bool>,
|
||||
}
|
||||
|
||||
fn experiments_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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<DesktopExperiments, String> {
|
||||
fn load_settings(path: &Path) -> Result<DesktopSettings, String> {
|
||||
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<DesktopExperiments, String> {
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<SessionScope>("thread");
|
||||
const [pending, setPending] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void invokeTauri<SessionScope>("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 (
|
||||
<section className="min-w-0" data-testid="settings-acp-session-scope">
|
||||
<SettingsSectionHeader
|
||||
title="Agent session scope"
|
||||
description="Choose how local ACP agents isolate ongoing conversations."
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/70 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" id="acp-session-scope-label">
|
||||
Thread-scoped sessions
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run separate threads concurrently. Turn this off for one legacy
|
||||
session per channel.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby="acp-session-scope-label"
|
||||
checked={scope === "thread"}
|
||||
data-testid="acp-session-scope-toggle"
|
||||
disabled={pending}
|
||||
onCheckedChange={(value) => void setThreadScoped(value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean>("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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<div className="space-y-12">
|
||||
<PreventSleepSettingsCard />
|
||||
<GlobalAgentConfigSettingsCard />
|
||||
<AcpSessionScopeSettingsCard />
|
||||
</div>
|
||||
);
|
||||
case "channel-templates":
|
||||
|
||||
+15
-15
@@ -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"],
|
||||
+14
-14
@@ -1,20 +1,20 @@
|
||||
export type ExperimentAgent = {
|
||||
export type SessionScopeAgent = {
|
||||
pubkey: string;
|
||||
status: string;
|
||||
backend: { type: string };
|
||||
};
|
||||
|
||||
export type ExperimentToggleDependencies = {
|
||||
setBackend: (enabled: boolean) => Promise<void>;
|
||||
listAgents: () => Promise<ExperimentAgent[]>;
|
||||
export type SessionScopeDependencies = {
|
||||
setBackend: (scope: "thread" | "channel") => Promise<void>;
|
||||
listAgents: () => Promise<SessionScopeAgent[]>;
|
||||
stopAgent: (pubkey: string) => Promise<unknown>;
|
||||
startAgent: (pubkey: string) => Promise<unknown>;
|
||||
setUi: (enabled: boolean) => void;
|
||||
setUi: (threadScoped: boolean) => void;
|
||||
};
|
||||
|
||||
async function restartRunningLocalAgents(
|
||||
agents: ExperimentAgent[],
|
||||
deps: ExperimentToggleDependencies,
|
||||
agents: SessionScopeAgent[],
|
||||
deps: SessionScopeDependencies,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user