From d4b625037eda0d2bef29ae3bb83d0151dc36708a Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 00:28:45 -0700 Subject: [PATCH] feat(desktop): gate ACP tool summaries behind a preview experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-layer prod-safe gate for the acpToolSummaries experiment, default OFF: UI gate: buildCompactToolSummary now takes summaryTitleEnabled (default off) and only consults the agent-provided friendly phrase when the experiment is on. ToolItem and BotActivityBar/getActivityHeadline read useFeatureEnabled('acpToolSummaries') and thread the flag through. Failure-precedence behavior is unchanged: failed rows always paint the failure label regardless of the gate. Runtime/cost gate: the frontend mirrors preview-experiment overrides to experiments.json via the new set_desktop_experiments command (AppShell mirrors on boot and on every toggle); spawn_agent_child sets BUZZ_AGENT_NO_TOOL_SUMMARY=1 when the experiment is off so disabled agents never spend the async LLM summary call. All spawn paths (start, create, app-startup restore) funnel through spawn_agent_child, and missing/unknown mirror state resolves to off — restore-time respawns before the webview loads stay on the safe side. Experiment-on leaves the env untouched, so a user-provided kill switch still wins. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/scripts/check-file-sizes.mjs | 9 +- .../src-tauri/src/commands/agent_settings.rs | 16 ++- desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/experiments.rs | 118 ++++++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 23 ++++ .../src/managed_agents/runtime/tests.rs | 51 ++++++++ desktop/src/app/AppShell.tsx | 7 +- .../CompactToolSummaryRow.render.test.mjs | 45 ++++++- .../ui/AgentSessionToolItem/ToolItem.tsx | 6 +- .../ui/agentSessionToolSummary.test.mjs | 28 +++++ .../agents/ui/agentSessionToolSummary.ts | 25 +++- ...gentSessionTranscriptPresentation.test.mjs | 16 +++ .../ui/agentSessionTranscriptPresentation.ts | 9 +- .../features/channels/ui/BotActivityBar.tsx | 7 +- desktop/src/shared/api/tauri.ts | 11 ++ desktop/src/shared/features/index.ts | 1 + .../features/useDesktopExperimentsMirror.ts | 23 ++++ preview-features.json | 8 ++ 19 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/experiments.rs create mode 100644 desktop/src/shared/features/useDesktopExperimentsMirror.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8ad557031..6eef303d5 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -99,7 +99,10 @@ const overrides = new Map([ // +1 for agent_pubkey field in setup payload (config-nudge card wire). // persona-blank-fallback: resolve_effective_prompt_model_provider gains a // record_provider param + applies persona_field_with_record_fallback. +5 lines. - ["src-tauri/src/managed_agents/runtime.rs", 2213], + // acp-tool-summaries experiment gate: apply_tool_summary_gate helper + + // spawn-time kill-switch wiring (~23 lines incl. off/on env tests context). + // Load-bearing prod-safety gate, queued to split. + ["src-tauri/src/managed_agents/runtime.rs", 2236], // config-bridge setup-payload env-boundary fix adds readiness wiring in // spawn_agent_child; load-bearing security fix, queued to split. ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], @@ -127,7 +130,9 @@ const overrides = new Map([ // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split. // restart-badge: started the queued split — start/stopManagedAgent moved to // tauriManagedAgents.ts; limit ratcheted down 1388 → 1380 to bank the headroom. - ["src/shared/api/tauri.ts", 1380], + // acp-tool-summaries: setDesktopExperiments binding (+11 lines) mirrors + // preview-experiment overrides to Rust for spawn-time gating. + ["src/shared/api/tauri.ts", 1391], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index b1cb92822..45f056f31 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -4,12 +4,24 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, current_instance_id, find_managed_agent_mut, - load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, - ManagedAgentSummary, + load_managed_agents, load_personas, save_experiments, save_managed_agents, + sync_managed_agent_processes, ManagedAgentSummary, }, util::now_iso, }; +/// Mirror the frontend preview-experiment overrides to disk so spawn-time +/// code (which cannot read the webview's localStorage) can consult them. +/// The frontend calls this on boot and on every experiment toggle. See +/// `managed_agents::experiments` for read-side semantics (unknown = off). +#[tauri::command] +pub fn set_desktop_experiments( + experiments: std::collections::BTreeMap, + app: AppHandle, +) -> Result<(), String> { + save_experiments(&app, &experiments) +} + #[tauri::command] pub fn set_managed_agent_start_on_app_launch( pubkey: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 341c99d7b..976d9677b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -525,6 +525,7 @@ pub fn run() { start_managed_agent, stop_managed_agent, set_managed_agent_start_on_app_launch, + set_desktop_experiments, delete_managed_agent, get_managed_agent_log, get_agent_models, diff --git a/desktop/src-tauri/src/managed_agents/experiments.rs b/desktop/src-tauri/src/managed_agents/experiments.rs new file mode 100644 index 000000000..4574af084 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/experiments.rs @@ -0,0 +1,118 @@ +//! Desktop mirror of the frontend preview-experiment overrides. +//! +//! The feature-flag source of truth lives in the webview's localStorage +//! (`desktop/src/shared/features/store.ts`), which the Rust spawn path cannot +//! read. The frontend mirrors the overrides map to `experiments.json` in the +//! app data dir via the `set_desktop_experiments` command; spawn-time code +//! reads it back with [`experiment_enabled`]. +//! +//! Semantics match `resolveEnabled` on the frontend: an experiment is enabled +//! ONLY on an explicit `true`. Missing file, malformed JSON, absent key, or +//! `false` all resolve to disabled — the app-startup restore path respawns +//! agents before the webview loads, and unknown state must stay on the safe +//! (off) side. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use tauri::{AppHandle, Manager}; + +use super::storage::atomic_write_json_restricted; + +/// Preview experiment id for agent-provided friendly tool summaries. +/// Must match the `id` in `preview-features.json`. +pub const ACP_TOOL_SUMMARIES_EXPERIMENT: &str = "acpToolSummaries"; + +fn experiments_store_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data dir: {error}"))?; + std::fs::create_dir_all(&dir) + .map_err(|error| format!("failed to create app data dir: {error}"))?; + Ok(dir.join("experiments.json")) +} + +/// Persist the full experiment-overrides map (replaces the previous file). +pub fn save_experiments( + app: &AppHandle, + experiments: &BTreeMap, +) -> Result<(), String> { + let path = experiments_store_path(app)?; + let payload = serde_json::to_vec_pretty(experiments) + .map_err(|error| format!("failed to serialize experiments: {error}"))?; + atomic_write_json_restricted(&path, &payload) +} + +/// Whether `experiment_id` is explicitly enabled in the mirrored overrides. +/// Any failure to read or parse resolves to `false` (experiment off). +pub fn experiment_enabled(app: &AppHandle, experiment_id: &str) -> bool { + let raw = experiments_store_path(app) + .ok() + .and_then(|path| std::fs::read_to_string(path).ok()); + resolve_experiment_enabled(raw.as_deref(), experiment_id) +} + +/// Pure resolution: enabled ONLY on an explicit `true` in well-formed JSON. +/// `None` (missing/unreadable file), malformed JSON, absent key, and `false` +/// all resolve to disabled. +pub(crate) fn resolve_experiment_enabled(raw: Option<&str>, experiment_id: &str) -> bool { + let Some(raw) = raw else { + return false; + }; + parse_experiments(raw) + .get(experiment_id) + .copied() + .unwrap_or(false) +} + +/// Parse the mirrored overrides map; malformed input yields an empty map +/// (everything off). +pub(crate) fn parse_experiments(raw: &str) -> BTreeMap { + serde_json::from_str(raw).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::{parse_experiments, resolve_experiment_enabled, ACP_TOOL_SUMMARIES_EXPERIMENT}; + + #[test] + fn parse_reads_explicit_true() { + let map = parse_experiments(r#"{"acpToolSummaries":true}"#); + assert_eq!(map.get("acpToolSummaries"), Some(&true)); + } + + #[test] + fn parse_reads_explicit_false() { + let map = parse_experiments(r#"{"acpToolSummaries":false}"#); + assert_eq!(map.get("acpToolSummaries"), Some(&false)); + } + + #[test] + fn parse_malformed_json_yields_empty_map() { + assert!(parse_experiments("not json").is_empty()); + assert!(parse_experiments(r#"{"acpToolSummaries":"yes"}"#).is_empty()); + assert!(parse_experiments("").is_empty()); + } + + #[test] + fn resolve_enabled_only_on_explicit_true() { + let id = ACP_TOOL_SUMMARIES_EXPERIMENT; + assert!(resolve_experiment_enabled( + Some(r#"{"acpToolSummaries":true}"#), + id + )); + assert!(!resolve_experiment_enabled( + Some(r#"{"acpToolSummaries":false}"#), + id + )); + // Absent key, missing file, and malformed JSON all resolve OFF. + assert!(!resolve_experiment_enabled(Some(r#"{"other":true}"#), id)); + assert!(!resolve_experiment_enabled(None, id)); + assert!(!resolve_experiment_enabled(Some("not json"), id)); + assert!(!resolve_experiment_enabled( + Some(r#"{"acpToolSummaries":"yes"}"#), + id + )); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 4dad5ea6d..252a8c215 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -7,6 +7,7 @@ mod backend; pub(crate) mod config_bridge; mod discovery; mod env_vars; +mod experiments; mod nest; mod persona_avatars; mod persona_card; @@ -32,6 +33,7 @@ mod types; pub use backend::*; pub use discovery::*; pub use env_vars::*; +pub use experiments::*; pub use nest::*; pub use persona_card::*; pub use personas::*; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index db093d11d..84ad60a6d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1490,6 +1490,16 @@ pub(crate) fn build_respond_to_env( Ok((set, remove)) } +/// Apply the acpToolSummaries experiment gate to a spawn command's env. +/// Experiment off → set the buzz-agent kill switch (`BUZZ_AGENT_NO_TOOL_SUMMARY=1`) +/// so the child never spends the async LLM summary call. Experiment on → +/// leave the env untouched (a user-provided kill switch still wins). +fn apply_tool_summary_gate(command: &mut std::process::Command, experiment_enabled: bool) { + if !experiment_enabled { + command.env("BUZZ_AGENT_NO_TOOL_SUMMARY", "1"); + } +} + /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -1844,6 +1854,19 @@ pub fn spawn_agent_child( command.env(key, value); } + // ── acpToolSummaries preview experiment gate ──────────────────────── + // + // When the experiment is OFF (default, including unknown state during + // app-startup restore), force the buzz-agent tool-summary kill switch so + // disabled agents never spend the async LLM summary call. Written AFTER + // user env so the off-state is authoritative. When the experiment is ON + // we leave the env untouched: summaries run, and a user-provided + // BUZZ_AGENT_NO_TOOL_SUMMARY=1 opt-out still wins. + apply_tool_summary_gate( + &mut command, + super::experiments::experiment_enabled(app, super::ACP_TOOL_SUMMARIES_EXPERIMENT), + ); + // Mark as Buzz-managed *and* which desktop instance owns us, so the // system-wide orphan sweep only reaps this instance's own agents and never // another live Buzz's (e.g. a `just dev` build won't kill a DMG build's diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index f675ed0a1..32c5098cf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -608,3 +608,54 @@ fn grandchild_inherits_pgid_of_process_group_leader() { unsafe { libc::kill(-harness_pid, libc::SIGTERM) }; let _ = harness.wait(); } + +// ── apply_tool_summary_gate tests ─────────────────────────────────────── +// +// The acpToolSummaries experiment gate: off-spawns must carry the buzz-agent +// kill switch so disabled agents never spend the async LLM summary call; +// on-spawns must leave the env alone. + +fn command_env(command: &std::process::Command, key: &str) -> Option { + command.get_envs().find_map(|(k, v)| { + (k == key).then(|| { + v.map(|v| v.to_string_lossy().into_owned()) + .unwrap_or_default() + }) + }) +} + +#[test] +fn tool_summary_gate_off_sets_kill_switch() { + let mut command = std::process::Command::new("true"); + super::apply_tool_summary_gate(&mut command, false); + assert_eq!( + command_env(&command, "BUZZ_AGENT_NO_TOOL_SUMMARY").as_deref(), + Some("1"), + "experiment-off spawn must carry the tool-summary kill switch" + ); +} + +#[test] +fn tool_summary_gate_on_leaves_env_untouched() { + let mut command = std::process::Command::new("true"); + super::apply_tool_summary_gate(&mut command, true); + assert_eq!( + command_env(&command, "BUZZ_AGENT_NO_TOOL_SUMMARY"), + None, + "experiment-on spawn must not set the kill switch" + ); +} + +#[test] +fn tool_summary_gate_on_preserves_user_opt_out() { + // A user-provided kill switch (record env vars, written before the gate) + // must survive the experiment-on path untouched. + let mut command = std::process::Command::new("true"); + command.env("BUZZ_AGENT_NO_TOOL_SUMMARY", "1"); + super::apply_tool_summary_gate(&mut command, true); + assert_eq!( + command_env(&command, "BUZZ_AGENT_NO_TOOL_SUMMARY").as_deref(), + Some("1"), + "user opt-out must win even when the experiment is on" + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e35c41c0d..2ab8a9a7f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -70,7 +70,10 @@ import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { relayClient } from "@/shared/api/relayClient"; -import { useFeatureEnabled } from "@/shared/features"; +import { + useDesktopExperimentsMirror, + useFeatureEnabled, +} from "@/shared/features"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; @@ -96,6 +99,8 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); + // Keep the Rust-side experiments mirror fresh for agent spawn gating. + useDesktopExperimentsMirror(); const workspacesHook = useWorkspaces(); const workspaceRailEnabled = useFeatureEnabled("workspaceRail"); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs index 6ffba7621..ac2f40ce2 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs @@ -28,8 +28,8 @@ function makeTool(overrides = {}) { } /** Build the row via the real summary pipeline, then render it to HTML. */ -function renderRow(item) { - const summary = buildCompactToolSummary(item); +function renderRow(item, { summaryTitleEnabled = true } = {}) { + const summary = buildCompactToolSummary(item, { summaryTitleEnabled }); return renderToStaticMarkup( React.createElement(CompactToolSummaryRow, { action: summary.action, @@ -119,3 +119,44 @@ test("render: rows without a summary keep today's descriptor label", () => { assert.ok(html.includes(">Ran<"), `descriptor verb should render: ${html}`); assert.ok(html.includes("git status")); }); + +test("render: experiment off ignores the friendly title and paints the raw descriptor", () => { + const html = renderRow( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + { summaryTitleEnabled: false }, + ); + + assert.ok( + !html.includes("checking repository state"), + `friendly title must not paint when the experiment is off: ${html}`, + ); + assert.ok( + html.includes(">Ran<"), + `raw descriptor verb must render when the experiment is off: ${html}`, + ); + assert.ok(html.includes("git status")); +}); + +test("render: experiment off keeps the raw file-read label", () => { + const html = renderRow( + makeTool({ + toolName: "read_file", + args: { path: "crates/buzz-agent/src/agent.rs" }, + summaryTitle: "reading agent source module", + }), + { summaryTitleEnabled: false }, + ); + + assert.ok( + !html.includes("reading agent source module"), + `friendly title must not paint when the experiment is off: ${html}`, + ); + assert.ok( + html.includes(">Read<"), + `raw descriptor verb must render when the experiment is off: ${html}`, + ); +}); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index 9fa31f51e..9ef99f186 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -4,6 +4,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { TranscriptItem } from "../agentSessionTypes"; @@ -38,7 +39,10 @@ export function ToolItem({ const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; const buzzTool = getBuzzToolInfo(canonicalToolName); - const compactSummary = buildCompactToolSummary(item); + // Preview experiment: friendly ACP summary titles only paint when the + // user opted in. Off (default) keeps the raw classifier/status labels. + const summaryTitleEnabled = useFeatureEnabled("acpToolSummaries"); + const compactSummary = buildCompactToolSummary(item, { summaryTitleEnabled }); const duration = getToolDurationDisplay(item); const messageLink = getSentMessageLink(item); const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index f38af1594..0a44c960e 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -394,11 +394,37 @@ test("buildCompactToolSummary prefers the ACP summaryTitle over the classifier l args: { command: "git status" }, summaryTitle: "checking repository state", }), + { summaryTitleEnabled: true }, ); assert.equal(summary.label, "checking repository state"); }); +test("buildCompactToolSummary ignores the ACP summaryTitle when the experiment is off", () => { + // Default (no options) and explicit false are both the off state: the + // friendly phrase never reaches the label and the raw classifier wins. + const byDefault = buildCompactToolSummary( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + ); + const explicitOff = buildCompactToolSummary( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + { summaryTitleEnabled: false }, + ); + + assert.equal(byDefault.label, "Ran command"); + assert.equal(byDefault.summaryTitle, null); + assert.equal(explicitOff.label, "Ran command"); + assert.equal(explicitOff.summaryTitle, null); +}); + test("buildCompactToolSummary lets failure labels win over the ACP summaryTitle", () => { const summary = buildCompactToolSummary( makeTool({ @@ -408,6 +434,7 @@ test("buildCompactToolSummary lets failure labels win over the ACP summaryTitle" isError: true, summaryTitle: "running a quick command", }), + { summaryTitleEnabled: true }, ); assert.match(summary.label, /failed$/); @@ -420,6 +447,7 @@ test("buildCompactToolSummary falls back to the classifier when summaryTitle is args: { command: "echo hi" }, summaryTitle: " ", }), + { summaryTitleEnabled: true }, ); assert.equal(summary.label, "Ran command"); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index c180f81e0..44670a30f 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -66,8 +66,23 @@ type ToolItem = Extract; export type CompactFileEditSummary = FileEditDiffSummary; +export type CompactToolSummaryOptions = { + /** + * Preview gate for the `acpToolSummaries` experiment. When false (the + * default), the agent-provided friendly phrase is ignored entirely and + * rows keep the raw classifier/status labels — the pre-experiment + * behavior. Callers with React context read `useFeatureEnabled` and + * thread the boolean here; default OFF keeps every other call site on + * plain labels. + */ + summaryTitleEnabled?: boolean; +}; + /** Build the muted compact summary label and preview for any tool row. */ -export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { +export function buildCompactToolSummary( + item: ToolItem, + options?: CompactToolSummaryOptions, +): CompactToolSummary { const descriptor = item.descriptor ?? classifyToolItem(item); const fileEditDiff = buildFileEditDiff(item, descriptor); const fileEditSummary = fileEditDiff @@ -89,7 +104,13 @@ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { const statusLabel = labelForStatus(descriptor, item.status, failed, running); // Prefer the agent-provided friendly phrase (Buzz ACP tool summary) as the // row label — but failure labels always win so errors stay unmistakable. - const summaryTitle = (!failed && item.summaryTitle?.trim()) || null; + // Gated behind the acpToolSummaries preview experiment: off (default) + // means the friendly phrase is never consulted. + const summaryTitle = + (options?.summaryTitleEnabled === true && + !failed && + item.summaryTitle?.trim()) || + null; const label = summaryTitle ?? statusLabel; return { action: descriptor.action ?? null, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 3118538b5..a8ffe9dcb 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -49,6 +49,22 @@ test("getActivityHeadline formats tool titles and assistant text", () => { assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding"); }); +test("getActivityHeadline gates the friendly summaryTitle on the experiment", () => { + const tool = makeTool({ summaryTitle: "sending a status update" }); + + // Off (default): raw tool label, friendly phrase never consulted. + assert.equal(getActivityHeadline(tool), "Send Message · abc"); + assert.equal( + getActivityHeadline(tool, { summaryTitleEnabled: false }), + "Send Message · abc", + ); + // On: friendly phrase wins the headline. + assert.equal( + getActivityHeadline(tool, { summaryTitleEnabled: true }), + "sending a status update · abc", + ); +}); + test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => { assert.equal( isMeaningfulItem({ diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index a77a21ff8..f1d230a41 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -27,9 +27,14 @@ const LIFECYCLE_NOISE = new Set([ ]); /** Human-readable headline for a single transcript item. */ -export function getActivityHeadline(item: TranscriptItem): string | null { +export function getActivityHeadline( + item: TranscriptItem, + options?: { summaryTitleEnabled?: boolean }, +): string | null { if (item.type === "tool") { - const summary = buildCompactToolSummary(item); + const summary = buildCompactToolSummary(item, { + summaryTitleEnabled: options?.summaryTitleEnabled === true, + }); return [summary.label, summary.preview].filter(Boolean).join(" · "); } diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d7f7146f4..e7f92e140 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -9,6 +9,7 @@ import { } from "@/features/agents/ui/agentSessionTranscriptPresentation"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; @@ -57,6 +58,8 @@ export function BotActivityComposerAction({ Boolean(singleWorkingAgent), singleWorkingAgent?.pubkey, ); + // Preview experiment: friendly ACP summary titles in the headline scan. + const summaryTitleEnabled = useFeatureEnabled("acpToolSummaries"); const activityHeadlines = React.useMemo(() => { if (!singleWorkingAgent) { return []; @@ -79,7 +82,7 @@ export function BotActivityComposerAction({ if (!passFilter(item)) { continue; } - const headline = getActivityHeadline(item); + const headline = getActivityHeadline(item, { summaryTitleEnabled }); if (!headline || seen.has(headline)) { continue; } @@ -92,7 +95,7 @@ export function BotActivityComposerAction({ } return headlines; - }, [channelId, singleWorkingAgent, transcript]); + }, [channelId, singleWorkingAgent, summaryTitleEnabled, transcript]); const [headlineIndex, setHeadlineIndex] = React.useState(0); const clearHoverTimer = React.useCallback(() => { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index eb06371b0..dd7906594 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1163,6 +1163,17 @@ export async function createManagedAgent(input: CreateManagedAgentInput) { }; } +/** + * Mirror the preview-experiment overrides to the Rust side so agent + * spawn-time code (which cannot read localStorage) can consult them. + * Called on app boot and on every experiment toggle. + */ +export async function setDesktopExperiments( + experiments: Record, +): Promise { + await invokeTauri("set_desktop_experiments", { experiments }); +} + export async function deleteManagedAgent( pubkey: string, forceRemoteDelete?: boolean, diff --git a/desktop/src/shared/features/index.ts b/desktop/src/shared/features/index.ts index 9f6db1b15..80d077b9b 100644 --- a/desktop/src/shared/features/index.ts +++ b/desktop/src/shared/features/index.ts @@ -1,6 +1,7 @@ export { FeatureGate } from "./FeatureGate"; export { allFeatures, desktopFeatures, getFeature, manifest } from "./manifest"; export { getOverrides, setOverride, clearOverride } from "./store"; +export { useDesktopExperimentsMirror } from "./useDesktopExperimentsMirror"; export type { FeatureDefinition, FeaturesManifest, diff --git a/desktop/src/shared/features/useDesktopExperimentsMirror.ts b/desktop/src/shared/features/useDesktopExperimentsMirror.ts new file mode 100644 index 000000000..a6cf6106e --- /dev/null +++ b/desktop/src/shared/features/useDesktopExperimentsMirror.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +import { setDesktopExperiments } from "@/shared/api/tauri"; +import { useFeatureSnapshot } from "./useFeatureEnabled"; + +/** + * Mirrors the preview-experiment overrides (localStorage) to the Rust side + * so agent spawn-time code can consult them — e.g. the acpToolSummaries + * experiment decides whether spawned agents get the tool-summary kill + * switch. Runs on mount (app boot) and again whenever any toggle changes. + * + * Best-effort: a failed mirror only logs. The Rust read side treats missing + * or stale state as "all experiments off" (the safe default). + */ +export function useDesktopExperimentsMirror(): void { + const overrides = useFeatureSnapshot(); + + useEffect(() => { + void setDesktopExperiments(overrides).catch((error) => { + console.warn("[FeatureFlags] failed to mirror experiments", error); + }); + }, [overrides]); +} diff --git a/preview-features.json b/preview-features.json index bc5f6d16c..ba1d09930 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,6 +40,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "acpToolSummaries", + "name": "Friendly Tool Summaries", + "description": "Agent activity rows show intent-level summaries instead of raw tool commands", + "platforms": [ + "desktop" + ] } ] }