diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 730e87b2e8..47dd567428 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -28,7 +28,7 @@ pub struct RunCtx<'a> { pub effective_model: &'a str, pub session_id: &'a str, pub system_prompt: &'a str, - pub llm: &'a Llm, + pub llm: &'a Arc, pub mcp: &'a Arc, /// Skills discovered at session creation; used by the built-in `load_skill` tool. pub skills: &'a [SkillEntry], @@ -333,6 +333,20 @@ impl RunCtx<'_> { results[idx] = Some(synthetic_tool_result(call, err)); continue; } + // Second stage of the friendly-title flow: fire-and-forget fast + // summarization for real (runnable) tool calls only — fast-failed + // and built-in calls keep their deterministic title. + if self.cfg.tool_summary_enabled { + spawn_tool_summary( + Arc::clone(self.llm), + self.cfg.clone(), + self.effective_model.to_owned(), + self.wire.clone(), + self.session_id.to_owned(), + call.clone(), + self.cancel.clone(), + ); + } runnable.push(idx); } @@ -557,7 +571,11 @@ async fn emit_pending(wire: &WireSender, sid: &str, call: &ToolCall) { json!({ "sessionUpdate": "tool_call", "toolCallId": call.provider_id, - "title": call.name, + // Deterministic friendly title, available immediately. The + // exact tool identity rides alongside in `toolName` so + // clients never lose it to the friendlier phrasing. + "title": friendly_tool_title(&call.name, &call.arguments), + "toolName": call.name, "kind": "other", "status": "pending", "rawInput": call.arguments, @@ -567,6 +585,175 @@ async fn emit_pending(wire: &WireSender, sid: &str, call: &ToolCall) { .await; } +/// Format a qualified tool name (`server__tool`) as a human-readable base +/// label. Mirrors goose's `format_tool_name`. +fn format_tool_name(tool_name: &str) -> String { + if let Some((server, tool)) = tool_name.split_once("__") { + format!("{}: {}", server.replace('_', " "), tool.replace('_', " ")) + } else { + tool_name.replace('_', " ") + } +} + +/// Build a short deterministic title from the tool name plus the most useful +/// argument value (file path, command, query, url, etc.). Port of goose's +/// `summarize_tool_call` fallback-title builder. +fn friendly_tool_title(tool_name: &str, arguments: &serde_json::Value) -> String { + const DETAIL_KEYS: [&str; 9] = [ + "path", "file", "command", "query", "url", "uri", "name", "pattern", "source", + ]; + const MAX_DETAIL_CHARS: usize = 60; + + let base = format_tool_name(tool_name); + let detail = arguments.as_object().and_then(|obj| { + for key in &DETAIL_KEYS { + if let Some(v) = obj.get(*key) { + let s = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + if !s.is_empty() { + let first_line = s.lines().next().unwrap_or(&s); + let mut out: String = first_line.chars().take(MAX_DETAIL_CHARS).collect(); + if first_line.chars().count() > MAX_DETAIL_CHARS { + out.push('…'); + } + return Some(out); + } + } + } + None + }); + match detail { + Some(d) => format!("{base} · {d}"), + None => base, + } +} + +const TOOL_SUMMARY_SYSTEM_PROMPT: &str = + "Summarize this tool call in a short lowercase phrase (3-8 words). \ + No punctuation. No quotes. Examples: reading project configuration, \ + checking network connectivity, listing files in src directory"; + +/// Max output tokens for a tool-title summary — a short phrase, not prose. +const TOOL_SUMMARY_MAX_OUTPUT_TOKENS: u32 = 64; + +/// Cap on the serialized-arguments excerpt sent to the fast model. +const TOOL_SUMMARY_MAX_ARGS_CHARS: usize = 300; + +/// Cap on the accepted summary phrase; longer responses are rejected as +/// non-conforming rather than truncated mid-thought. +const TOOL_SUMMARY_MAX_TITLE_CHARS: usize = 80; + +/// Normalize a fast-model response into a row-label phrase, or `None` when +/// the response is unusable (empty, multi-paragraph rambling, oversized). +fn sanitize_tool_summary(raw: &str) -> Option { + let first_line = raw.trim().lines().next()?.trim(); + let cleaned = first_line.trim_matches(|c| c == '"' || c == '\'' || c == '`'); + if cleaned.is_empty() || cleaned.chars().count() > TOOL_SUMMARY_MAX_TITLE_CHARS { + return None; + } + Some(cleaned.to_string()) +} + +/// Fire-and-forget fast summarization pass for one tool call (goose's +/// two-stage title pattern). Publishes a title-only `tool_call_update` +/// tagged `_meta.buzz.toolSummary` — deliberately no `status` field, so a +/// late summary can never clobber pending/executing/terminal state. Failures +/// are silent: the deterministic title from `emit_pending` stays. +fn spawn_tool_summary( + llm: Arc, + cfg: Config, + model: String, + wire: WireSender, + sid: String, + call: ToolCall, + cancel: watch::Receiver, +) { + tokio::spawn(async move { + let args_json = { + let s = call.arguments.to_string(); + if s.chars().count() > TOOL_SUMMARY_MAX_ARGS_CHARS { + let mut t: String = s.chars().take(TOOL_SUMMARY_MAX_ARGS_CHARS).collect(); + t.push('…'); + t + } else { + s + } + }; + let user_prompt = format!("Tool: {}\nArguments: {args_json}", call.name); + let effective_model = cfg.tool_summary_model.as_deref().unwrap_or(&model); + + // The fast model occasionally returns an empty/errored response under + // load. One retry with a short backoff recovers the common cases. + let mut title: Option = None; + for attempt in 0..2 { + if *cancel.borrow() { + return; + } + match llm + .summarize( + &cfg, + TOOL_SUMMARY_SYSTEM_PROMPT, + &user_prompt, + TOOL_SUMMARY_MAX_OUTPUT_TOKENS, + effective_model, + ) + .await + { + Ok(s) => { + if let Some(clean) = sanitize_tool_summary(&s) { + title = Some(clean); + break; + } + if attempt == 0 { + tracing::debug!( + "tool summary: empty/unusable response for {} ({}), retrying once", + call.provider_id, + call.name + ); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + } + } + Err(e) => { + if attempt == 0 { + tracing::debug!( + "tool summary: fast pass errored for {} ({}): {e}, retrying once", + call.provider_id, + call.name + ); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + } else { + tracing::debug!( + "tool summary: fast pass errored for {} ({}) after retry: {e}", + call.provider_id, + call.name + ); + } + } + } + } + let Some(title) = title else { return }; + if *cancel.borrow() { + return; + } + wire::send( + &wire, + wire::session_update( + &sid, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": call.provider_id, + "title": title, + "toolName": call.name, + "_meta": { "buzz": { "toolSummary": true } }, + }), + ), + ) + .await; + }); +} + async fn emit_in_progress(wire: &WireSender, sid: &str, call: &ToolCall) { wire::send( wire, @@ -744,3 +931,72 @@ fn map_stop(p: ProviderStop) -> StopReason { ProviderStop::Refusal => StopReason::Refusal, } } + +#[cfg(test)] +mod tests { + use super::{friendly_tool_title, sanitize_tool_summary}; + use serde_json::json; + + #[test] + fn friendly_title_formats_qualified_name_with_detail() { + assert_eq!( + friendly_tool_title("developer__shell", &json!({ "command": "git status" })), + "developer: shell · git status" + ); + } + + #[test] + fn friendly_title_prefers_path_over_later_keys() { + assert_eq!( + friendly_tool_title( + "fs__read_file", + &json!({ "name": "x", "path": "/tmp/a.txt" }) + ), + "fs: read file · /tmp/a.txt" + ); + } + + #[test] + fn friendly_title_without_useful_args_is_just_the_name() { + assert_eq!(friendly_tool_title("do_thing", &json!({})), "do thing"); + assert_eq!(friendly_tool_title("do_thing", &json!(null)), "do thing"); + } + + #[test] + fn friendly_title_truncates_long_first_line_only() { + let long = "x".repeat(200); + let title = friendly_tool_title("t", &json!({ "command": format!("{long}\nsecond") })); + assert!(title.ends_with('…')); + // "t · " + 60 chars + ellipsis. + assert_eq!(title.chars().count(), 4 + 60 + 1); + assert!(!title.contains("second")); + } + + #[test] + fn friendly_title_handles_multibyte_without_panicking() { + let title = friendly_tool_title("t", &json!({ "query": "héllo wörld 🚀".repeat(20) })); + assert!(title.starts_with("t · ")); + } + + #[test] + fn sanitize_accepts_short_phrase_and_strips_quotes() { + assert_eq!( + sanitize_tool_summary("\"checking repository state\"\n"), + Some("checking repository state".to_string()) + ); + } + + #[test] + fn sanitize_rejects_empty_and_oversized() { + assert_eq!(sanitize_tool_summary(" \n "), None); + assert_eq!(sanitize_tool_summary(&"x".repeat(200)), None); + } + + #[test] + fn sanitize_keeps_only_first_line() { + assert_eq!( + sanitize_tool_summary("reading config\nextra rambling"), + Some("reading config".to_string()) + ); + } +} diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 606edf2d25..0eefe266d0 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -637,6 +637,15 @@ pub struct Config { /// Thinking/reasoning effort level. `None` = use provider default (no /// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`. pub thinking_effort: Option, + /// Per-tool friendly title summarization: after emitting a tool call, + /// spawn a fast LLM pass that publishes a short human phrase as a + /// title-only `tool_call_update`. Default on; disable via + /// `BUZZ_AGENT_NO_TOOL_SUMMARY=1`. + pub tool_summary_enabled: bool, + /// Optional model override for tool title summaries (a cheaper/faster + /// model than the session model). Set via `BUZZ_AGENT_TOOL_SUMMARY_MODEL`; + /// `None` falls back to the session's effective model. + pub tool_summary_model: Option, } impl Config { @@ -731,6 +740,8 @@ impl Config { hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, + tool_summary_enabled: parse_env("BUZZ_AGENT_NO_TOOL_SUMMARY", 0u8)? == 0, + tool_summary_model: env("BUZZ_AGENT_TOOL_SUMMARY_MODEL"), }; cfg.validate()?; Ok(cfg) @@ -771,6 +782,8 @@ impl Config { hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, + tool_summary_enabled: false, + tool_summary_model: None, } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 07c2071aa2..672ef657ad 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1193,6 +1193,8 @@ mod tests { openai_api: OpenAiApi::Chat, hints_enabled: true, thinking_effort: None, + tool_summary_enabled: false, + tool_summary_model: None, } } diff --git a/crates/buzz-agent/tests/golden_transcripts.rs b/crates/buzz-agent/tests/golden_transcripts.rs index 4ac3503464..ef9d363eae 100644 --- a/crates/buzz-agent/tests/golden_transcripts.rs +++ b/crates/buzz-agent/tests/golden_transcripts.rs @@ -808,3 +808,198 @@ async fn test_cancel_notification_no_reply() { h.shutdown().await; } + +/// Per-tool friendly titles, stage 1: the immediate `tool_call` (pending) +/// update must carry the deterministic friendly title plus the exact tool +/// identity in `toolName`, and receipts in `rawInput`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_pending_tool_call_carries_friendly_title_and_tool_name() { + let url = spawn_fake_llm(vec![ + openai_tool_call( + "call_t1", + "fake__do_thing", + json!({ "command": "git status" }), + ), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let sid = handshake(&mut h).await; + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "use the tool" }], + }), + ) + .await; + + let pending = h + .recv_until(|v| { + v.get("method") == Some(&json!("session/update")) + && v["params"]["update"]["sessionUpdate"] == "tool_call" + }) + .await; + let update = &pending["params"]["update"]; + assert_eq!(update["title"], "fake: do thing · git status"); + assert_eq!(update["toolName"], "fake__do_thing"); + assert_eq!(update["status"], "pending"); + assert_eq!(update["rawInput"]["command"], "git status"); + + let final_resp = h.recv_for_id(p).await; + assert_eq!(final_resp["result"]["stopReason"], "end_turn"); + h.shutdown().await; +} + +/// Per-tool friendly titles, stage 2: the async fast pass publishes a +/// title-only `tool_call_update` tagged `_meta.buzz.toolSummary` with NO +/// status field, so it can never regress pending/executing/terminal state. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_async_tool_summary_publishes_title_only_update() { + // Queue: round-1 tool call, then the fast summary response (the tool is + // delayed 1s so the summary request reaches the fake LLM first), then the + // round-2 final text. + let url = spawn_fake_llm(vec![ + openai_tool_call( + "call_s1", + "fake__tool_0", + json!({ "command": "git status" }), + ), + openai_text("checking repository state"), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let init_id = h + .send( + "initialize", + json!({ "protocolVersion": 2, "clientCapabilities": {} }), + ) + .await; + let _ = h.recv_for_id(init_id).await; + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let new_id = h + .send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "fake", + "command": fake_mcp, + "args": [], + "env": [ + { "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }, + { "name": "FAKE_MCP_TOOL_DELAY", "value": "1" }, + ], + }], + }), + ) + .await; + let new = h.recv_for_id(new_id).await; + let sid = new["result"]["sessionId"].as_str().unwrap().to_owned(); + + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "use the tool" }], + }), + ) + .await; + + let summary = h + .recv_until(|v| { + v.get("method") == Some(&json!("session/update")) + && v["params"]["update"]["sessionUpdate"] == "tool_call_update" + && v["params"]["update"]["_meta"]["buzz"]["toolSummary"] == json!(true) + }) + .await; + let update = &summary["params"]["update"]; + assert_eq!(update["toolCallId"], "call_s1"); + assert_eq!(update["title"], "checking repository state"); + assert_eq!(update["toolName"], "fake__tool_0"); + assert!( + update.get("status").is_none(), + "summary update must not carry a status: {update}" + ); + + let final_resp = h.recv_for_id(p).await; + assert_eq!(final_resp["result"]["stopReason"], "end_turn"); + h.shutdown().await; +} + +/// Kill switch: BUZZ_AGENT_NO_TOOL_SUMMARY=1 must suppress the fast pass +/// entirely — no summary update on the wire, and no extra LLM request +/// (the canned-queue accounting would break the turn if one fired). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_tool_summary_kill_switch() { + let url = spawn_fake_llm(vec![ + openai_tool_call("call_k1", "fake__tool_0", json!({ "command": "ls" })), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn(&[ + ("OPENAI_COMPAT_BASE_URL", &url), + ("BUZZ_AGENT_NO_TOOL_SUMMARY", "1"), + ]) + .await; + + let init_id = h + .send( + "initialize", + json!({ "protocolVersion": 2, "clientCapabilities": {} }), + ) + .await; + let _ = h.recv_for_id(init_id).await; + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let new_id = h + .send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "fake", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let new = h.recv_for_id(new_id).await; + let sid = new["result"]["sessionId"].as_str().unwrap().to_owned(); + + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "use the tool" }], + }), + ) + .await; + + // Drain everything up to the final response; no notification along the + // way may carry the toolSummary marker. If the fast pass had fired it + // would also have consumed the "done" response and broken the turn. + let mut saw_summary = false; + let final_resp = loop { + let v = h.recv().await; + if v["params"]["update"]["_meta"]["buzz"]["toolSummary"] == json!(true) { + saw_summary = true; + } + if v["id"] == json!(p) { + break v; + } + }; + assert!( + !saw_summary, + "kill switch did not suppress the summary pass" + ); + assert_eq!(final_resp["result"]["stopReason"], "end_turn"); + h.shutdown().await; +} diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 3c2237ac99..d8841f1574 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -102,7 +102,12 @@ impl Harness { .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_MAX_ROUNDS", "8") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); + .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2") + // These tests queue exact LLM response sequences and count + // requests; the async tool-summary fast pass would consume + // queued responses nondeterministically. Covered explicitly + // in golden_transcripts.rs instead. + .env("BUZZ_AGENT_NO_TOOL_SUMMARY", "1"); for (k, v) in extra { cmd.env(k, v); } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index fba7c58b7c..6eef303d55 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 @@ -217,7 +222,12 @@ const overrides = new Map([ // config_option_update) + replaceLifecycleItem helper for usage coalescing + // system-prompt ordering fix (turnId: null for per-channel items). // Load-bearing feature growth; queued to split in next transcript refactor. - ["src/features/agents/ui/agentSessionTranscript.ts", 1167], + // +24: Buzz ACP per-tool friendly titles — toolSummary-tagged + // tool_call_update ingestion (applyToolSummaryTitle) that must not route + // through upsertTool's status merge. Queued to split with the rest. + // +1: rebase auto-merge of main's steer-context bundling alongside the + // toolSummary ingestion — combined growth, same queued split. + ["src/features/agents/ui/agentSessionTranscript.ts", 1192], // catalog module; agent_models.rs retains the thin wrapper (~50 lines). // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess // fallback. Queued to split into dedicated discovery modules. diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index b1cb928220..45f056f31a 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 341c99d7b1..976d9677bb 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 0000000000..4574af0847 --- /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 4dad5ea6d8..252a8c2153 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 db093d11d6..84ad60a6dc 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 f675ed0a10..32c5098cf0 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 e35c41c0d2..2ab8a9a7fa 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 new file mode 100644 index 0000000000..ac2f40ce21 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { buildCompactToolSummary } from "../agentSessionToolSummary.ts"; +import { CompactToolSummaryRow } from "./CompactToolSummaryRow.tsx"; + +const baseTimestamp = "2026-06-14T19:00:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Tool call", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: "2026-06-14T19:00:01.000Z", + ...overrides, + }; +} + +/** Build the row via the real summary pipeline, then render it to HTML. */ +function renderRow(item, { summaryTitleEnabled = true } = {}) { + const summary = buildCompactToolSummary(item, { summaryTitleEnabled }); + return renderToStaticMarkup( + React.createElement(CompactToolSummaryRow, { + action: summary.action, + duration: null, + failed: summary.failed, + fileEditSummary: summary.fileEditSummary, + kind: summary.kind, + label: summary.label, + preview: summary.preview, + summaryTitle: summary.summaryTitle, + thumbnailSrc: summary.thumbnailSrc, + }), + ); +} + +test("render: friendly summary title is the visible label for an action-bearing shell row", () => { + const html = renderRow( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + ); + + assert.ok( + html.includes("checking repository state"), + `friendly title must be painted, got: ${html}`, + ); + // The structured "Ran " descriptor must not win the row label. + assert.ok( + !html.includes(">Ran<"), + `descriptor verb must not render: ${html}`, + ); + // Receipt stays available: the exact command rides the hover title. + assert.ok( + html.includes('title="git status"'), + `exact command receipt must stay on hover: ${html}`, + ); +}); + +test("render: friendly summary title is the visible label for an action-bearing file-read row", () => { + const html = renderRow( + makeTool({ + toolName: "read_file", + args: { path: "crates/buzz-agent/src/agent.rs" }, + summaryTitle: "reading agent source module", + }), + ); + + assert.ok(html.includes("reading agent source module")); + assert.ok( + !html.includes(">Read<"), + `descriptor verb must not render: ${html}`, + ); +}); + +test("render: failed shell row ignores the friendly title and paints the failure label", () => { + const html = renderRow( + makeTool({ + toolName: "developer__shell", + args: { command: "false" }, + status: "failed", + isError: true, + result: "exit 1", + summaryTitle: "running a quick command", + }), + ); + + assert.ok( + !html.includes("running a quick command"), + `friendly phrase must not mask a failure: ${html}`, + ); + assert.ok( + html.includes("failed"), + `failure label must be visible, got: ${html}`, + ); +}); + +test("render: rows without a summary keep today's descriptor label", () => { + const html = renderRow( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + }), + ); + + 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/CompactToolSummaryRow.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx index 2feeb8f971..39b2d06006 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx @@ -22,18 +22,22 @@ export function compactSummaryTone() { export function CompactToolSummaryRow({ action, duration, + failed, fileEditSummary, kind, label, preview, + summaryTitle, thumbnailSrc, }: { action: AgentActivityAction | null; duration: string | null; + failed: boolean; fileEditSummary: CompactFileEditSummary | null; kind: CompactToolKind; label: string; preview: string | null; + summaryTitle: string | null; thumbnailSrc: string | null; }) { const [thumbnailFailed, setThumbnailFailed] = React.useState(false); @@ -44,14 +48,30 @@ export function CompactToolSummaryRow({ if (!thumbnailSrc || thumbnailFailed) return null; return resolveToolImageSrc(thumbnailSrc); }, [thumbnailFailed, thumbnailSrc]); - const actionLabel = fileEditSummary - ? null - : getCompactToolActionLabel(action, kind, label, preview); + // Failed rows bypass the structured action descriptor entirely: the + // descriptor would repaint "Ran " and swallow the failure label. + const actionLabel = + fileEditSummary || failed + ? null + : getCompactToolActionLabel(action, kind, label, preview); return ( <> {fileEditSummary ? ( + ) : summaryTitle && !failed ? ( + // The agent-provided friendly phrase wins the visible row label. + // The exact command/path receipt stays on hover and on expand. + + {summaryTitle} + ) : actionLabel ? ( 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); @@ -120,9 +124,11 @@ export function ToolItem({ diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index fab1f539ae..0a44c960e9 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -386,3 +386,69 @@ test("buildCompactToolSummary trims only trailing blank diff lines", () => { { kind: "add", text: "+const after = true;" }, ]); }); + +test("buildCompactToolSummary prefers the ACP summaryTitle over the classifier label", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "developer__shell", + 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({ + toolName: "developer__shell", + args: { command: "false" }, + status: "failed", + isError: true, + summaryTitle: "running a quick command", + }), + { summaryTitleEnabled: true }, + ); + + assert.match(summary.label, /failed$/); +}); + +test("buildCompactToolSummary falls back to the classifier when summaryTitle is blank", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "shell", + 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 ed7edda934..44670a30fd 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -42,6 +42,14 @@ export type CompactToolSummary = { action: AgentActivityAction | null; kind: CompactToolKind; label: string; + /** + * Agent-provided friendly phrase (Buzz ACP tool summary) when it won the + * row label. Null for failed rows — failure labels always win. The render + * layer must prefer this over the structured `action` descriptor. + */ + summaryTitle: string | null; + /** True when the tool failed; the render layer must keep failure visible. */ + failed: boolean; preview: string | null; fileEditSummary: FileEditDiffSummary | null; fileEditDiff: FileEditDiff | null; @@ -58,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 @@ -78,10 +101,23 @@ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { const thumbnailSrc = imageContent?.src ?? null; const failed = item.isError || item.status === "failed"; const running = item.status === "executing" || item.status === "pending"; + 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. + // 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, kind: descriptor.renderClass, - label: labelForStatus(descriptor, item.status, failed, running), + label, + summaryTitle, + failed, preview: fileEditSummary?.filename ?? descriptor.preview, fileEditSummary, fileEditDiff, diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index a40b4ed6d3..008425564c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -1232,3 +1232,104 @@ test("steer ingress bundles its prompt context into the steer prompt segment, no "steer context must not leak as a standalone metadata row", ); }); + +// --- Buzz ACP friendly tool titles (_meta.buzz.toolSummary) --- + +test("a later ACP tool-summary update improves the row label without touching status or receipts", () => { + const [item] = toolItems([ + acpToolUpdate(200, { + sessionUpdate: "tool_call", + toolCallId: "call-summary", + status: "executing", + title: "developer: shell · git status", + toolName: "developer__shell", + kind: "shell", + rawInput: { command: "git status" }, + }), + acpToolUpdate(201, { + sessionUpdate: "tool_call_update", + toolCallId: "call-summary", + title: "checking repository state", + toolName: "developer__shell", + _meta: { buzz: { toolSummary: true } }, + }), + ]); + + assert.equal(item.summaryTitle, "checking repository state"); + // The summary update carries no status — the row must stay executing. + assert.equal(item.status, "executing"); + // Receipts intact: exact command still available for the expanded view. + assert.deepEqual(item.args, { command: "git status" }); + // Existing normalizeToolName behavior collapses the server separator; + // identity is preserved either way. + assert.equal(item.toolName, "developer_shell"); +}); + +test("an ACP tool-summary update never clobbers a terminal failed row", () => { + const [item] = toolItems([ + acpToolUpdate(210, { + sessionUpdate: "tool_call", + toolCallId: "call-summary-fail", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "false" }, + }), + acpToolUpdate(211, { + sessionUpdate: "tool_call_update", + toolCallId: "call-summary-fail", + status: "failed", + title: "shell", + kind: "shell", + rawOutput: { error: "exit 1" }, + }), + acpToolUpdate(212, { + sessionUpdate: "tool_call_update", + toolCallId: "call-summary-fail", + title: "running a quick command", + _meta: { buzz: { toolSummary: true } }, + }), + ]); + + assert.equal(item.status, "failed"); + assert.equal(item.isError, true); + assert.equal(item.summaryTitle, "running a quick command"); +}); + +test("older/raw tool_call_update events without the toolSummary marker keep today's ingestion path", () => { + const [item] = toolItems([ + acpToolUpdate(220, { + sessionUpdate: "tool_call", + toolCallId: "call-legacy", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo hi" }, + }), + acpToolUpdate(221, { + sessionUpdate: "tool_call_update", + toolCallId: "call-legacy", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + ]); + + assert.equal(item.summaryTitle, undefined); + assert.equal(item.status, "completed"); + assert.equal(item.result, "hi"); +}); + +test("a tool-summary update arriving before its tool_call is dropped, not rendered as a phantom row", () => { + const items = toolItems([ + acpToolUpdate(230, { + sessionUpdate: "tool_call_update", + toolCallId: "call-orphan-summary", + title: "doing something", + _meta: { buzz: { toolSummary: true } }, + }), + ]); + + assert.equal(items.length, 0); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index dcb1ceeda0..41f4520e2f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -19,6 +19,7 @@ import { extractBlockText, extractContentText, extractPromptText, + extractBuzzToolSummaryTitle, extractTriggeringEventIds, extractToolArgs, extractToolIdentity, @@ -594,6 +595,22 @@ function isTerminalToolStatus(status: ToolStatus) { return status === "completed" || status === "failed"; } +/** + * Attach a Buzz ACP friendly title to an existing tool row without touching + * status, args, result, or timestamps — the receipts stay exactly as + * ingested. A summary arriving before its tool_call (out-of-order wire + * delivery) is dropped; the deterministic title still renders. + */ +function applyToolSummaryTitle( + d: TranscriptDraft, + id: string, + summaryTitle: string, +) { + const existing = d.itemsById.get(id); + if (existing?.type !== "tool") return; + replaceItem(d, id, { ...existing, summaryTitle }); +} + function mergeToolStatus(existing: ToolStatus, next: ToolStatus): ToolStatus { if (isTerminalToolStatus(existing) && !isTerminalToolStatus(next)) { return existing; @@ -991,24 +1008,34 @@ export function processTranscriptEvent( ); } else if (updateType === "tool_call_update") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; - const status = normalizeToolStatus( - asString(update.status) ?? "completed", - ); - const identity = extractToolIdentity(update); - upsertTool( - d, - `tool:${ch}:${toolId}`, - identity.title, - identity.toolName, - identity.buzzToolName, - status, - extractToolArgs(update), - extractToolResult(update), - status === "failed", - event.timestamp, - ctx, - updateType, - ); + const summaryTitle = extractBuzzToolSummaryTitle(update); + if (summaryTitle !== null) { + // Buzz ACP friendly-title pass: a title-only update tagged + // `_meta.buzz.toolSummary`. It intentionally carries no status — + // routing it through upsertTool would default the status to + // "completed" and could clobber a pending/executing row, so it + // only ever sets summaryTitle. + applyToolSummaryTitle(d, `tool:${ch}:${toolId}`, summaryTitle); + } else { + const status = normalizeToolStatus( + asString(update.status) ?? "completed", + ); + const identity = extractToolIdentity(update); + upsertTool( + d, + `tool:${ch}:${toolId}`, + identity.title, + identity.toolName, + identity.buzzToolName, + status, + extractToolArgs(update), + extractToolResult(update), + status === "failed", + event.timestamp, + ctx, + updateType, + ); + } } else if (updateType === "plan") { upsertPlan( d, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 5daf00d2b4..d3e38ce1be 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -281,6 +281,22 @@ export function extractToolResult(update: Record): string { return extractBlockText(update.rawOutput); } +/** + * Extract the friendly title from a Buzz ACP tool-summary update — a + * `tool_call_update` tagged `_meta.buzz.toolSummary: true`. Returns `null` + * for ordinary updates (including all older/raw events, which never carry + * the marker). + */ +export function extractBuzzToolSummaryTitle( + update: Record, +): string | null { + const meta = asRecord(update._meta); + const buzz = asRecord(meta.buzz); + if (buzz.toolSummary !== true) return null; + const title = asString(update.title)?.trim(); + return title ? title : null; +} + export function extractTriggeringEventIds(payload: unknown): string[] { const record = asRecord(payload); return Array.isArray(record.triggeringEventIds) diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 3118538b5f..a8ffe9dcb7 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 a77a21ff89..f1d230a418 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/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 4736358ef5..8c8a328467 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -129,6 +129,13 @@ export type TranscriptItem = title: string; toolName: string; buzzToolName: string | null; + /** + * Friendly per-tool phrase published by the agent runtime as a + * title-only `tool_call_update` tagged `_meta.buzz.toolSummary`. + * Preferred as the row label; the classifier label remains the + * fallback and failure labels always win. + */ + summaryTitle?: string | null; status: ToolStatus; args: Record; result: string; diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d7f7146f4b..e7f92e140e 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/features/settings/lib/experimentAgentRestart.test.mjs b/desktop/src/features/settings/lib/experimentAgentRestart.test.mjs new file mode 100644 index 0000000000..03a4d5058a --- /dev/null +++ b/desktop/src/features/settings/lib/experimentAgentRestart.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyExperimentAndRestartAgents, + describeRestartOutcome, + experimentRequiresAgentRestart, + restartAgentsForExperiment, + selectAgentsToRestart, +} from "./experimentAgentRestart.ts"; + +function agent(overrides = {}) { + return { + pubkey: "deadbeef".repeat(8), + name: "Agent", + status: "running", + backend: { type: "local" }, + ...overrides, + }; +} + +test("only spawn-env experiments require a restart confirmation", () => { + assert.equal(experimentRequiresAgentRestart("acpToolSummaries"), true); + assert.equal(experimentRequiresAgentRestart("pulse"), false); +}); + +test("selectAgentsToRestart picks local running agents only", () => { + const running = agent({ pubkey: "a".repeat(64), name: "Running" }); + const stopped = agent({ + pubkey: "b".repeat(64), + name: "Stopped", + status: "stopped", + }); + const deployed = agent({ + pubkey: "c".repeat(64), + name: "Deployed", + status: "deployed", + backend: { type: "relay-mesh" }, + }); + const remoteRunning = agent({ + pubkey: "d".repeat(64), + name: "Remote", + backend: { type: "relay-mesh" }, + }); + + assert.deepEqual( + selectAgentsToRestart([running, stopped, deployed, remoteRunning]), + [running], + ); +}); + +test("confirm restarts the snapshot after toggle + mirror, in order", async () => { + const calls = []; + const agents = [ + agent({ pubkey: "a".repeat(64), name: "One" }), + agent({ pubkey: "b".repeat(64), name: "Two" }), + ]; + + const outcome = await applyExperimentAndRestartAgents({ + applyToggle: () => calls.push("toggle"), + mirrorExperiments: async () => calls.push("mirror"), + agents, + startAgent: async (pubkey) => calls.push(`start:${pubkey.slice(0, 1)}`), + stopAgent: async (pubkey) => calls.push(`stop:${pubkey.slice(0, 1)}`), + }); + + // Toggle then mirror strictly precede any restart traffic — restarted + // agents must spawn against the NEW mirrored env. + assert.deepEqual(calls.slice(0, 2), ["toggle", "mirror"]); + const restartCalls = calls.slice(2); + assert.equal(restartCalls.filter((c) => c.startsWith("stop:")).length, 2); + assert.equal(restartCalls.filter((c) => c.startsWith("start:")).length, 2); + // Each agent stops before it starts. + assert.ok(restartCalls.indexOf("stop:a") < restartCalls.indexOf("start:a")); + assert.ok(restartCalls.indexOf("stop:b") < restartCalls.indexOf("start:b")); + assert.deepEqual(outcome, { restarted: 2, failures: [] }); +}); + +test("a failed mirror write aborts the restart but keeps the toggle applied", async () => { + const calls = []; + + await assert.rejects( + applyExperimentAndRestartAgents({ + applyToggle: () => calls.push("toggle"), + mirrorExperiments: async () => { + throw new Error("ipc down"); + }, + agents: [agent()], + startAgent: async () => calls.push("start"), + stopAgent: async () => calls.push("stop"), + }), + /ipc down/, + ); + + // Toggle applied (no rollback), zero restart traffic. + assert.deepEqual(calls, ["toggle"]); +}); + +test("cancel path: no orchestration call means no toggle, no restarts", () => { + // The dialog's cancel/dismiss handler only clears local pending state and + // never invokes applyExperimentAndRestartAgents — modeled here as: with no + // running agents selected, there is nothing to restart and outcome is empty. + assert.deepEqual(selectAgentsToRestart([]), []); +}); + +test("partial failure: other agents still restart, failures are collected", async () => { + const one = agent({ pubkey: "a".repeat(64), name: "One" }); + const two = agent({ pubkey: "b".repeat(64), name: "Two" }); + const three = agent({ pubkey: "c".repeat(64), name: "Three" }); + + const outcome = await restartAgentsForExperiment({ + agents: [one, two, three], + stopAgent: async (pubkey) => { + if (pubkey === two.pubkey) throw new Error("stop failed"); + }, + startAgent: async (pubkey) => { + if (pubkey === three.pubkey) throw new Error("spawn failed"); + }, + }); + + assert.equal(outcome.restarted, 1); + assert.deepEqual(outcome.failures, [ + { name: "Two", error: "stop failed" }, + { name: "Three", error: "spawn failed" }, + ]); +}); + +test("describeRestartOutcome messaging covers success and partial failure", () => { + assert.deepEqual(describeRestartOutcome({ restarted: 1, failures: [] }), { + kind: "success", + message: "Restarted 1 agent.", + }); + assert.deepEqual(describeRestartOutcome({ restarted: 3, failures: [] }), { + kind: "success", + message: "Restarted 3 agents.", + }); + + const partial = describeRestartOutcome({ + restarted: 1, + failures: [ + { name: "Two", error: "stop failed" }, + { name: "Three", error: "spawn failed" }, + ], + }); + assert.equal(partial.kind, "error"); + assert.match(partial.message, /Restarted 1 of 3 agents/); + assert.match(partial.message, /Two, Three/); + assert.match(partial.message, /still applied/); +}); diff --git a/desktop/src/features/settings/lib/experimentAgentRestart.ts b/desktop/src/features/settings/lib/experimentAgentRestart.ts new file mode 100644 index 0000000000..294561482a --- /dev/null +++ b/desktop/src/features/settings/lib/experimentAgentRestart.ts @@ -0,0 +1,139 @@ +/** + * Restart plumbing for preview experiments whose effect is pinned at agent + * spawn time (env vars set in `spawn_agent_child`). Toggling such an + * experiment repaints the UI immediately, but running agents keep their + * spawn-time env — so the settings card confirms with the user and restarts + * running agents after the toggle is applied. + * + * Pure logic lives here so node tests can cover cancel/confirm/partial- + * failure without a DOM. + */ +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Preview experiments that gate agent behavior via spawn-time env. + * Toggling these prompts for an agent restart. Keep in sync with the + * spawn gates in `desktop/src-tauri/src/managed_agents/runtime.rs`. + */ +const EXPERIMENTS_REQUIRING_AGENT_RESTART: ReadonlySet = new Set([ + "acpToolSummaries", +]); + +export function experimentRequiresAgentRestart(featureId: string): boolean { + return EXPERIMENTS_REQUIRING_AGENT_RESTART.has(featureId); +} + +type RestartCandidate = Pick & { + backend: { type: string }; +}; + +/** + * Only locally spawned, currently running agents carry the spawn-time env + * this restart exists to refresh. Stopped agents stay stopped; provider + * deployments are not spawned through the local env path. + */ +export function selectAgentsToRestart( + agents: readonly T[], +): T[] { + return agents.filter( + (agent) => agent.backend.type === "local" && agent.status === "running", + ); +} + +export type AgentRestartOutcome = { + restarted: number; + failures: { name: string; error: string }[]; +}; + +/** + * Confirm-time orchestration, in the order that matters: + * + * 1. `applyToggle()` — flip the localStorage override (UI updates now). + * 2. `await mirrorExperiments()` — push the override to the Rust side + * BEFORE any agent respawns, otherwise a restarted agent could read + * the stale mirror and spawn with the old env (the exact confusion + * this modal exists to fix). The passive `useDesktopExperimentsMirror` + * effect also fires, but it's async and unordered — hence the + * explicit await here. + * 3. Restart the agents snapshotted at confirmation time. + * + * A failed mirror write aborts the restart (agents would respawn with the + * old env anyway) but does NOT roll back the toggle — matching the + * best-effort mirror semantics elsewhere; the mirror retries on next boot. + */ +export async function applyExperimentAndRestartAgents({ + applyToggle, + mirrorExperiments, + agents, + startAgent, + stopAgent, +}: { + applyToggle: () => void; + mirrorExperiments: () => Promise; + agents: readonly RestartCandidate[]; + startAgent: (pubkey: string) => Promise; + stopAgent: (pubkey: string) => Promise; +}): Promise { + applyToggle(); + await mirrorExperiments(); + return restartAgentsForExperiment({ agents, startAgent, stopAgent }); +} + +/** + * Stop→start each agent (mirrors `respawnManagedAgentWithRules` semantics). + * One agent's failure never blocks the others; failures are collected for + * the caller's messaging. The experiment toggle is NOT rolled back on + * failure — the setting applied, only the process refresh lagged. + */ +export async function restartAgentsForExperiment({ + agents, + startAgent, + stopAgent, +}: { + agents: readonly RestartCandidate[]; + startAgent: (pubkey: string) => Promise; + stopAgent: (pubkey: string) => Promise; +}): Promise { + const results = await Promise.allSettled( + agents.map(async (agent) => { + await stopAgent(agent.pubkey); + await startAgent(agent.pubkey); + }), + ); + + const outcome: AgentRestartOutcome = { restarted: 0, failures: [] }; + results.forEach((result, index) => { + if (result.status === "fulfilled") { + outcome.restarted += 1; + return; + } + const reason = result.reason; + outcome.failures.push({ + name: agents[index]?.name ?? "unknown agent", + error: reason instanceof Error ? reason.message : String(reason), + }); + }); + return outcome; +} + +/** Human-readable toast copy for a restart outcome. */ +export function describeRestartOutcome(outcome: AgentRestartOutcome): { + kind: "success" | "error"; + message: string; +} { + const total = outcome.restarted + outcome.failures.length; + if (outcome.failures.length === 0) { + return { + kind: "success", + message: + outcome.restarted === 1 + ? "Restarted 1 agent." + : `Restarted ${outcome.restarted} agents.`, + }; + } + const names = outcome.failures.map((failure) => failure.name).join(", "); + return { + kind: "error", + message: `Restarted ${outcome.restarted} of ${total} agents. Failed to restart: ${names}. The experiment setting was still applied — restart these agents manually to pick it up.`, + }; +} diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 4684829d32..5f540e41ea 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,12 +1,106 @@ -import { desktopFeatures, useFeatureToggle } from "@/shared/features"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + useStartManagedAgentMutation, + useStopManagedAgentMutation, + useManagedAgentsQuery, +} from "@/features/agents/hooks"; +import { + applyExperimentAndRestartAgents, + describeRestartOutcome, + experimentRequiresAgentRestart, + selectAgentsToRestart, +} from "@/features/settings/lib/experimentAgentRestart"; +import { setDesktopExperiments } from "@/shared/api/tauri"; +import { + desktopFeatures, + getOverrides, + useFeatureToggle, +} from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; import { Switch } from "@/shared/ui/switch"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; function FeatureRow({ feature }: { feature: FeatureDefinition }) { const [enabled, toggle] = useFeatureToggle(feature.id); + const [pendingValue, setPendingValue] = React.useState(null); + const requiresRestart = experimentRequiresAgentRestart(feature.id); + // Only fetch agent state for rows that can trigger a restart. + const agentsQuery = useManagedAgentsQuery({ enabled: requiresRestart }); + const startMutation = useStartManagedAgentMutation(); + const stopMutation = useStopManagedAgentMutation(); const switchId = `feature-toggle-${feature.id}`; + const runningAgents = selectAgentsToRestart(agentsQuery.data ?? []); + + const handleToggle = (value: boolean) => { + // Spawn-env experiments confirm first: the toggle is applied only on + // Confirm, then agents running at confirmation time are restarted so + // their spawn-time env picks up the change. No running agents → nothing + // to restart, apply directly. + if (requiresRestart && runningAgents.length > 0) { + setPendingValue(value); + return; + } + toggle(value); + }; + + const handleConfirm = () => { + if (pendingValue === null) { + return; + } + const value = pendingValue; + setPendingValue(null); + // Snapshot at confirmation time: stopped agents stay stopped. + const agentsToRestart = runningAgents; + // Ordering matters: apply the toggle, await the explicit mirror write to + // the Rust side, THEN restart — so respawned agents read the NEW env. + // (The passive useDesktopExperimentsMirror effect also fires, but it is + // unordered relative to the respawn; see applyExperimentAndRestartAgents.) + void applyExperimentAndRestartAgents({ + applyToggle: () => toggle(value), + mirrorExperiments: () => setDesktopExperiments(getOverrides()), + agents: agentsToRestart, + startAgent: (pubkey) => startMutation.mutateAsync(pubkey), + stopAgent: (pubkey) => stopMutation.mutateAsync(pubkey), + }).then( + (outcome) => { + const { kind, message } = describeRestartOutcome(outcome); + if (kind === "success") { + toast.success(message); + } else { + toast.error(message); + } + }, + (error) => { + // Mirror write failed: the toggle stayed applied (best-effort mirror + // convention), but agents were NOT restarted — tell the user. + toast.error( + `Setting applied, but syncing it to the agent runtime failed (${ + error instanceof Error ? error.message : String(error) + }). Agents were not restarted — restart them manually to pick it up.`, + ); + }, + ); + }; + + const agentCountLabel = + runningAgents.length === 1 + ? "1 running agent" + : `${runningAgents.length} running agents`; + return (
@@ -19,8 +113,46 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) { aria-labelledby={`${switchId}-label`} checked={enabled} data-testid={switchId} - onCheckedChange={toggle} + onCheckedChange={handleToggle} /> + { + if (!open) { + // Cancel / dismiss: leave the setting unchanged. + setPendingValue(null); + } + }} + open={pendingValue !== null} + > + + + + {pendingValue + ? `Enable ${feature.name}?` + : `Disable ${feature.name}?`} + + + This setting takes effect when agents start. Applying it will + restart {agentCountLabel} so they pick up the change; stopped + agents stay stopped. Cancel to leave the setting unchanged. + + + + + + + + + + + +
); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index eb06371b03..dd7906594e 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 9f6db1b156..80d077b9b6 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 0000000000..a6cf6106e0 --- /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 bc5f6d16c0..ba1d09930f 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" + ] } ] }