From b4b66f8ecbe3cb6e8d49297e97764505fa0b8ecb Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 22:39:20 -0700 Subject: [PATCH 1/5] feat(agent): emit friendly tool titles with async fast-pass summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Goose two-stage tool-title pattern for individual tool calls. emit_pending now sends a deterministic friendly title (server: tool plus a detail excerpt from path/command/query-style args, 60-char first-line cap, multibyte-safe) alongside an explicit toolName so the desktop never loses tool identity. For runnable tools a fire-and-forget fast pass asks the LLM (64 output tokens, 300-char args excerpt, one retry) for a friendlier title and publishes it as a title-only tool_call_update tagged _meta.buzz.toolSummary — deliberately without a status field so it can never clobber pending/executing/terminal state. Cancel-aware via the session watch channel; silent on failure. Kill switch BUZZ_AGENT_NO_TOOL_SUMMARY=1 disables the fast pass; model override via BUZZ_AGENT_TOOL_SUMMARY_MODEL. The regression harness sets the kill switch since those tests queue exact LLM response sequences and count requests; golden_transcripts.rs carries the explicit summary-path coverage (pending title + toolName, title-only async update, kill switch). No chain summaries in this slice. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- crates/buzz-agent/src/agent.rs | 260 +++++++++++++++++- crates/buzz-agent/src/config.rs | 13 + crates/buzz-agent/src/llm.rs | 2 + crates/buzz-agent/tests/golden_transcripts.rs | 195 +++++++++++++ crates/buzz-agent/tests/regressions.rs | 7 +- 5 files changed, 474 insertions(+), 3 deletions(-) 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); } From a04972c448a99b2176260d9bdf830920e90de958 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 22:45:09 -0700 Subject: [PATCH 2/5] feat(desktop): prefer ACP summary titles for tool rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingest the Buzz ACP fast-pass tool summaries: tool_call_update events tagged _meta.buzz.toolSummary route to applyToolSummaryTitle, which stores summaryTitle as a separate field — never mutating status, args, result, or timestamps — and drops out-of-order summaries that arrive before their tool_call. buildCompactToolSummary prefers summaryTitle as the row label; failure labels always win (failed rows ignore the friendly phrasing), the classifier stays as fallback for older/raw events without the marker, and exact command/path receipts remain available on expand. File-size exception for agentSessionTranscript.ts bumped via its documented ledger; split still queued. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/scripts/check-file-sizes.mjs | 7 +- .../ui/agentSessionToolSummary.test.mjs | 38 +++++++ .../agents/ui/agentSessionToolSummary.ts | 7 +- .../agents/ui/agentSessionTranscript.test.mjs | 101 ++++++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 63 +++++++---- .../ui/agentSessionTranscriptHelpers.ts | 16 +++ .../features/agents/ui/agentSessionTypes.ts | 7 ++ 7 files changed, 219 insertions(+), 20 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index fba7c58b7c..8ad5570311 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -217,7 +217,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/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index fab1f539ae..f38af15946 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -386,3 +386,41 @@ 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", + }), + ); + + assert.equal(summary.label, "checking repository state"); +}); + +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", + }), + ); + + 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: " ", + }), + ); + + 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..3e72e93304 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -78,10 +78,15 @@ 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. + const summaryTitle = item.summaryTitle?.trim(); + const label = !failed && summaryTitle ? summaryTitle : statusLabel; return { action: descriptor.action ?? null, kind: descriptor.renderClass, - label: labelForStatus(descriptor, item.status, failed, running), + label, 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/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; From 8720b5d1ef14f7367edc6f8b4da07c2923ab47a2 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 23:16:39 -0700 Subject: [PATCH 3/5] fix(desktop): paint ACP summary titles on action-bearing tool rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marge's review caught that the friendly summaries never reached the screen for the tools that matter most: getCompactToolActionLabel took the structured action-descriptor branch first and never consulted the label where summaryTitle lived, so shell/file-read rows kept rendering raw 'Ran ' descriptors after the friendly title landed. buildCompactToolSummary now exposes summaryTitle (null when the row failed) and failed alongside label, and CompactToolSummaryRow renders the friendly phrase as the visible row label ahead of the action descriptor. The exact command/path receipt stays on the hover title and on expand. Failed rows bypass the descriptor branch entirely so the failure label is painted instead of the descriptor repainting 'Ran ' over it — this also fixes the previously-dead failure precedence for action-bearing tools that Marge flagged as a minor. New render-layer tests (renderToStaticMarkup through the real buildCompactToolSummary pipeline) assert the user-visible HTML: the friendly title paints for shell and file-read rows, failure beats the friendly phrase, and rows without a summary keep today's descriptor label. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../CompactToolSummaryRow.render.test.mjs | 121 ++++++++++++++++++ .../CompactToolSummaryRow.tsx | 26 +++- .../ui/AgentSessionToolItem/ToolItem.tsx | 2 + .../agents/ui/agentSessionToolSummary.ts | 14 +- 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs 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..6ffba76212 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs @@ -0,0 +1,121 @@ +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) { + const summary = buildCompactToolSummary(item); + 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")); +}); 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 ? ( diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 3e72e93304..c180f81e0a 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; @@ -81,12 +89,14 @@ 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 = item.summaryTitle?.trim(); - const label = !failed && summaryTitle ? summaryTitle : statusLabel; + const summaryTitle = (!failed && item.summaryTitle?.trim()) || null; + const label = summaryTitle ?? statusLabel; return { action: descriptor.action ?? null, kind: descriptor.renderClass, label, + summaryTitle, + failed, preview: fileEditSummary?.filename ?? descriptor.preview, fileEditSummary, fileEditDiff, From d4b625037eda0d2bef29ae3bb83d0151dc36708a Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 00:28:45 -0700 Subject: [PATCH 4/5] feat(desktop): gate ACP tool summaries behind a preview experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-layer prod-safe gate for the acpToolSummaries experiment, default OFF: UI gate: buildCompactToolSummary now takes summaryTitleEnabled (default off) and only consults the agent-provided friendly phrase when the experiment is on. ToolItem and BotActivityBar/getActivityHeadline read useFeatureEnabled('acpToolSummaries') and thread the flag through. Failure-precedence behavior is unchanged: failed rows always paint the failure label regardless of the gate. Runtime/cost gate: the frontend mirrors preview-experiment overrides to experiments.json via the new set_desktop_experiments command (AppShell mirrors on boot and on every toggle); spawn_agent_child sets BUZZ_AGENT_NO_TOOL_SUMMARY=1 when the experiment is off so disabled agents never spend the async LLM summary call. All spawn paths (start, create, app-startup restore) funnel through spawn_agent_child, and missing/unknown mirror state resolves to off — restore-time respawns before the webview loads stay on the safe side. Experiment-on leaves the env untouched, so a user-provided kill switch still wins. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/scripts/check-file-sizes.mjs | 9 +- .../src-tauri/src/commands/agent_settings.rs | 16 ++- desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/experiments.rs | 118 ++++++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 23 ++++ .../src/managed_agents/runtime/tests.rs | 51 ++++++++ desktop/src/app/AppShell.tsx | 7 +- .../CompactToolSummaryRow.render.test.mjs | 45 ++++++- .../ui/AgentSessionToolItem/ToolItem.tsx | 6 +- .../ui/agentSessionToolSummary.test.mjs | 28 +++++ .../agents/ui/agentSessionToolSummary.ts | 25 +++- ...gentSessionTranscriptPresentation.test.mjs | 16 +++ .../ui/agentSessionTranscriptPresentation.ts | 9 +- .../features/channels/ui/BotActivityBar.tsx | 7 +- desktop/src/shared/api/tauri.ts | 11 ++ desktop/src/shared/features/index.ts | 1 + .../features/useDesktopExperimentsMirror.ts | 23 ++++ preview-features.json | 8 ++ 19 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/experiments.rs create mode 100644 desktop/src/shared/features/useDesktopExperimentsMirror.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8ad5570311..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 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 index 6ffba76212..ac2f40ce21 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.render.test.mjs @@ -28,8 +28,8 @@ function makeTool(overrides = {}) { } /** Build the row via the real summary pipeline, then render it to HTML. */ -function renderRow(item) { - const summary = buildCompactToolSummary(item); +function renderRow(item, { summaryTitleEnabled = true } = {}) { + const summary = buildCompactToolSummary(item, { summaryTitleEnabled }); return renderToStaticMarkup( React.createElement(CompactToolSummaryRow, { action: summary.action, @@ -119,3 +119,44 @@ test("render: rows without a summary keep today's descriptor label", () => { assert.ok(html.includes(">Ran<"), `descriptor verb should render: ${html}`); assert.ok(html.includes("git status")); }); + +test("render: experiment off ignores the friendly title and paints the raw descriptor", () => { + const html = renderRow( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + { summaryTitleEnabled: false }, + ); + + assert.ok( + !html.includes("checking repository state"), + `friendly title must not paint when the experiment is off: ${html}`, + ); + assert.ok( + html.includes(">Ran<"), + `raw descriptor verb must render when the experiment is off: ${html}`, + ); + assert.ok(html.includes("git status")); +}); + +test("render: experiment off keeps the raw file-read label", () => { + const html = renderRow( + makeTool({ + toolName: "read_file", + args: { path: "crates/buzz-agent/src/agent.rs" }, + summaryTitle: "reading agent source module", + }), + { summaryTitleEnabled: false }, + ); + + assert.ok( + !html.includes("reading agent source module"), + `friendly title must not paint when the experiment is off: ${html}`, + ); + assert.ok( + html.includes(">Read<"), + `raw descriptor verb must render when the experiment is off: ${html}`, + ); +}); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index 9fa31f51ea..9ef99f186d 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -4,6 +4,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { TranscriptItem } from "../agentSessionTypes"; @@ -38,7 +39,10 @@ export function ToolItem({ const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; const buzzTool = getBuzzToolInfo(canonicalToolName); - const compactSummary = buildCompactToolSummary(item); + // Preview experiment: friendly ACP summary titles only paint when the + // user opted in. Off (default) keeps the raw classifier/status labels. + const summaryTitleEnabled = useFeatureEnabled("acpToolSummaries"); + const compactSummary = buildCompactToolSummary(item, { summaryTitleEnabled }); const duration = getToolDurationDisplay(item); const messageLink = getSentMessageLink(item); const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index f38af15946..0a44c960e9 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -394,11 +394,37 @@ test("buildCompactToolSummary prefers the ACP summaryTitle over the classifier l args: { command: "git status" }, summaryTitle: "checking repository state", }), + { summaryTitleEnabled: true }, ); assert.equal(summary.label, "checking repository state"); }); +test("buildCompactToolSummary ignores the ACP summaryTitle when the experiment is off", () => { + // Default (no options) and explicit false are both the off state: the + // friendly phrase never reaches the label and the raw classifier wins. + const byDefault = buildCompactToolSummary( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + ); + const explicitOff = buildCompactToolSummary( + makeTool({ + toolName: "developer__shell", + args: { command: "git status" }, + summaryTitle: "checking repository state", + }), + { summaryTitleEnabled: false }, + ); + + assert.equal(byDefault.label, "Ran command"); + assert.equal(byDefault.summaryTitle, null); + assert.equal(explicitOff.label, "Ran command"); + assert.equal(explicitOff.summaryTitle, null); +}); + test("buildCompactToolSummary lets failure labels win over the ACP summaryTitle", () => { const summary = buildCompactToolSummary( makeTool({ @@ -408,6 +434,7 @@ test("buildCompactToolSummary lets failure labels win over the ACP summaryTitle" isError: true, summaryTitle: "running a quick command", }), + { summaryTitleEnabled: true }, ); assert.match(summary.label, /failed$/); @@ -420,6 +447,7 @@ test("buildCompactToolSummary falls back to the classifier when summaryTitle is args: { command: "echo hi" }, summaryTitle: " ", }), + { summaryTitleEnabled: true }, ); assert.equal(summary.label, "Ran command"); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index c180f81e0a..44670a30fd 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -66,8 +66,23 @@ type ToolItem = Extract; export type CompactFileEditSummary = FileEditDiffSummary; +export type CompactToolSummaryOptions = { + /** + * Preview gate for the `acpToolSummaries` experiment. When false (the + * default), the agent-provided friendly phrase is ignored entirely and + * rows keep the raw classifier/status labels — the pre-experiment + * behavior. Callers with React context read `useFeatureEnabled` and + * thread the boolean here; default OFF keeps every other call site on + * plain labels. + */ + summaryTitleEnabled?: boolean; +}; + /** Build the muted compact summary label and preview for any tool row. */ -export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { +export function buildCompactToolSummary( + item: ToolItem, + options?: CompactToolSummaryOptions, +): CompactToolSummary { const descriptor = item.descriptor ?? classifyToolItem(item); const fileEditDiff = buildFileEditDiff(item, descriptor); const fileEditSummary = fileEditDiff @@ -89,7 +104,13 @@ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { const statusLabel = labelForStatus(descriptor, item.status, failed, running); // Prefer the agent-provided friendly phrase (Buzz ACP tool summary) as the // row label — but failure labels always win so errors stay unmistakable. - const summaryTitle = (!failed && item.summaryTitle?.trim()) || null; + // Gated behind the acpToolSummaries preview experiment: off (default) + // means the friendly phrase is never consulted. + const summaryTitle = + (options?.summaryTitleEnabled === true && + !failed && + item.summaryTitle?.trim()) || + null; const label = summaryTitle ?? statusLabel; return { action: descriptor.action ?? null, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 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/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/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" + ] } ] } From de96c07acdfa2eda037a0e122847322022163cfa Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 13:55:00 -0700 Subject: [PATCH 5/5] feat(settings): confirm agent restart when toggling spawn-env experiments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acpToolSummaries experiment is enforced at agent spawn time (BUZZ_AGENT_NO_TOOL_SUMMARY in the child env), so flipping the toggle repaints the UI but leaves running agents on their spawn-time behavior — the 'enabled but still raw labels' confusion. Toggling such an experiment now opens a shared AlertDialog. Confirm applies the toggle, awaits an explicit set_desktop_experiments mirror write (so respawns read the NEW env, not a racy passive-effect write), then restarts the local agents that were running at confirmation time. Cancel leaves the setting unchanged; stopped agents stay stopped. Partial restart failures surface a failed-agent toast without rolling the experiment back; a failed mirror write aborts the restart but keeps the toggle applied (best-effort mirror convention). Orchestration lives in a pure lib module with node tests covering toggle→mirror→restart ordering, mirror-failure abort, local-running-only selection, partial-failure collection, and outcome messaging. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../lib/experimentAgentRestart.test.mjs | 149 ++++++++++++++++++ .../settings/lib/experimentAgentRestart.ts | 139 ++++++++++++++++ .../settings/ui/ExperimentalFeaturesCard.tsx | 136 +++++++++++++++- 3 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/settings/lib/experimentAgentRestart.test.mjs create mode 100644 desktop/src/features/settings/lib/experimentAgentRestart.ts 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. + + + + + + + + + + + +
); }