mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
8720b5d1ef
commit
d4b625037e
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+43
-2
@@ -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(() => {
|
||||
|
||||
@@ -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,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]);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user