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:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-18 16:56:48 -04:00
parent ae1bbc8d64
commit 2444b17b37
12 changed files with 286 additions and 189 deletions
+37 -8
View File
@@ -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
+3 -3
View File
@@ -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,
}
+97 -46
View File
@@ -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());
}
}
+2 -2
View File
@@ -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,
+12 -13
View File
@@ -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":
@@ -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"],
@@ -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,
);
}
-8
View File
@@ -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"
]
}
]
}