feat(desktop): gate ACP tool summaries behind a preview experiment

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 <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-07 14:26:02 -07:00
co-authored by Taylor Ho
parent 8720b5d1ef
commit d4b625037e
19 changed files with 392 additions and 14 deletions
+7 -2
View File
@@ -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
@@ -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<String, bool>,
app: AppHandle,
) -> Result<(), String> {
save_experiments(&app, &experiments)
}
#[tauri::command]
pub fn set_managed_agent_start_on_app_launch(
pubkey: String,
+1
View File
@@ -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,
@@ -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<PathBuf, String> {
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<String, bool>,
) -> 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<String, bool> {
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
));
}
}
@@ -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::*;
@@ -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
@@ -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<String> {
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"
);
}
+6 -1
View File
@@ -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");
@@ -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}`,
);
});
@@ -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);
@@ -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");
@@ -66,8 +66,23 @@ type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
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,
@@ -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({
@@ -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(" · ");
}
@@ -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(() => {
+11
View File
@@ -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<string, boolean>,
): Promise<void> {
await invokeTauri<void>("set_desktop_experiments", { experiments });
}
export async function deleteManagedAgent(
pubkey: string,
forceRemoteDelete?: boolean,
+1
View File
@@ -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,
@@ -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]);
}
+8
View File
@@ -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"
]
}
]
}