From a5e7c146572dead0a1f83fdc7a52646a22ec2781 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Wed, 22 Jul 2026 10:47:35 -0400 Subject: [PATCH] fix(acp): title Codex/ACP sessions with Buzz agent display name Codex and similar ACP UIs label sessions from the opening of systemPrompt / the first user message. Managed Buzz agents all started with the shared [Base] platform prefix, so session lists showed identical truncated base text instead of Fizz/Varys/etc. Desktop now passes BUZZ_ACP_DISPLAY_NAME (display_name, else name). buzz-acp puts a short [Session] section first in session/new systemPrompt framing and on the legacy user-message path, without rewriting base-prompt substance or breaking [Base]/[System]/[Workspace] observer splits. Closes #2334 Signed-off-by: Bartok9 --- crates/buzz-acp/src/config.rs | 13 ++ crates/buzz-acp/src/lib.rs | 9 +- crates/buzz-acp/src/pool.rs | 152 ++++++++++++++++-- crates/buzz-acp/src/queue.rs | 11 +- .../src-tauri/src/managed_agents/runtime.rs | 20 +++ 5 files changed, 184 insertions(+), 21 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..fc68ca3bf8 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -288,6 +288,11 @@ pub struct CliArgs { )] pub system_prompt_file: Option, + /// Human display name for ACP session list titles (Codex sidebar, etc.). + /// Desktop sets this from the managed agent `display_name` / `name`. + #[arg(long, env = "BUZZ_ACP_DISPLAY_NAME")] + agent_display_name: Option, + /// Number of parallel agent subprocesses. #[arg(long, env = "BUZZ_ACP_AGENTS", default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=32))] @@ -499,6 +504,8 @@ pub struct Config { pub turn_liveness_secs: u64, pub heartbeat_prompt: Option, pub system_prompt: Option, + /// Buzz display name for session titles (#2334). + pub agent_display_name: Option, /// Team-owned instructions layered separately from the agent system prompt. pub team_instructions: Option, pub initial_message: Option, @@ -971,6 +978,11 @@ impl Config { turn_liveness_secs, heartbeat_prompt, system_prompt, + agent_display_name: args + .agent_display_name + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), team_instructions: args .team_instructions .as_deref() @@ -1345,6 +1357,7 @@ mod tests { turn_liveness_secs: 10, heartbeat_prompt: None, system_prompt: None, + agent_display_name: None, team_instructions: None, initial_message: None, subscribe_mode: mode, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 862732f478..52c0f3a3ea 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1512,6 +1512,7 @@ async fn tokio_main() -> Result<()> { memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + agent_display_name: config.agent_display_name.clone(), }); if !config.memory_enabled { @@ -4098,7 +4099,8 @@ mod heartbeat_base_prompt_tests { // protocol_version 1 + Some(base_prompt): heartbeat prompt is prefixed // with the [Base] section exactly as the legacy session/new path would. let prompt = "[System: Heartbeat]\nrun feed get"; - let composed = pool::prepend_base_for_legacy(1, Some("you are a helpful agent"), prompt); + let composed = + pool::prepend_base_for_legacy(1, Some("you are a helpful agent"), prompt, None); assert_eq!( composed, "[Base]\nyou are a helpful agent\n\n[System: Heartbeat]\nrun feed get" @@ -4111,7 +4113,8 @@ mod heartbeat_base_prompt_tests { // protocol_version 2 gets base_prompt via session/new; the heartbeat // prompt is sent verbatim. let prompt = "[System: Heartbeat]\nrun feed get"; - let composed = pool::prepend_base_for_legacy(2, Some("you are a helpful agent"), prompt); + let composed = + pool::prepend_base_for_legacy(2, Some("you are a helpful agent"), prompt, None); assert_eq!(composed, prompt); } } @@ -4622,6 +4625,7 @@ mod build_mcp_servers_tests { turn_liveness_secs: 10, heartbeat_prompt: None, system_prompt: None, + agent_display_name: None, team_instructions: None, initial_message: None, subscribe_mode: config::SubscribeMode::All, @@ -4788,6 +4792,7 @@ mod error_outcome_emission_tests { turn_liveness_secs: 10, heartbeat_prompt: None, system_prompt: None, + agent_display_name: None, team_instructions: None, initial_message: None, subscribe_mode: config::SubscribeMode::All, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index fb08096792..94fa3c83f8 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -477,6 +477,8 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Human-visible Buzz agent name for ACP session list titles (#2334). + pub agent_display_name: Option, } impl AgentPool { @@ -765,7 +767,12 @@ async fn create_session_and_apply_model( let combined_system_prompt = with_canvas( with_core( with_team( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt( + &ctx.cwd, + ctx.base_prompt, + ctx.system_prompt.as_deref(), + ctx.agent_display_name.as_deref(), + ), ctx.team_instructions.as_deref(), ), agent_core, @@ -1039,13 +1046,22 @@ pub(crate) fn prepend_base_for_legacy( protocol_version: u32, base_prompt: Option<&str>, body: &str, + agent_display_name: Option<&str>, ) -> String { - match base_prompt { + let with_base = match base_prompt { Some(bp) if protocol_version < 2 => { format!("{}\n\n{body}", crate::queue::base_section(bp)) } _ => body.to_string(), + }; + // Legacy path: title before [Base] so Codex-style UIs that title from the + // first user message see the Buzz name (modern gets this via systemPrompt). + if protocol_version < 2 { + if let Some(title) = session_title_section(agent_display_name) { + return format!("{title}\n\n{with_base}"); + } } + with_base } /// Prepend the `[Channel Canvas]` section to the legacy initial-message body. @@ -1086,6 +1102,7 @@ fn framed_system_prompt( cwd: &str, base_prompt: Option<&str>, system_prompt: Option<&str>, + agent_display_name: Option<&str>, ) -> Option { let body = match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( @@ -1099,9 +1116,56 @@ fn framed_system_prompt( // Anchor the workspace only when a base prompt is present — the workspace // section grounds the base prompt's layout description, so it is meaningless // for a persona-only (`[System]`-only) agent that never received that layout. - match (base_prompt, workspace_section(cwd)) { - (Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")), - _ => Some(body), + let with_workspace = match (base_prompt, workspace_section(cwd)) { + (Some(_), Some(workspace)) => format!("{workspace}\n\n{body}"), + _ => body, + }; + // Session title FIRST so Codex-style UIs that title from systemPrompt open + // text show the Buzz agent name instead of the shared [Base] prefix (#2334). + match session_title_section(agent_display_name) { + Some(title) => Some(format!("{title}\n\n{with_workspace}")), + None => Some(with_workspace), + } +} + +fn session_title_section(agent_display_name: Option<&str>) -> Option { + let name = session_title_for_prompt(agent_display_name)?; + Some(format!("[Session]\n{name}")) +} + +/// Sanitized one-line agent title for ACP session lists (`[Session]` body). +pub(crate) fn session_title_for_prompt(agent_display_name: Option<&str>) -> Option { + sanitize_session_title(agent_display_name?) +} + +fn sanitize_session_title(raw: &str) -> Option { + let collapsed: String = raw + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + let trimmed = collapsed.trim(); + if trimmed.is_empty() { + return None; + } + const MAX: usize = 80; + if trimmed.chars().count() <= MAX { + return Some(trimmed.to_string()); + } + let mut out = String::new(); + for (i, ch) in trimmed.chars().enumerate() { + if i >= MAX { + break; + } + out.push(ch); + } + let out = out.trim().to_string(); + if out.is_empty() { + None + } else { + Some(out) } } @@ -1547,6 +1611,7 @@ pub async fn run_prompt_task( }, ctx.base_prompt, initial_msg, + ctx.agent_display_name.as_deref(), ); let init_msg = prepend_canvas_for_legacy( if agent.has_system_prompt_support() { @@ -1685,6 +1750,7 @@ pub async fn run_prompt_task( }, ctx.base_prompt, &text, + ctx.agent_display_name.as_deref(), ); vec![text] } else if let Some(ref b) = batch { @@ -1735,6 +1801,7 @@ pub async fn run_prompt_task( system_prompt: ctx.system_prompt.as_deref(), team_instructions: ctx.team_instructions.as_deref(), agent_canvas: agent_canvas.as_deref(), + agent_display_name: ctx.agent_display_name.as_deref(), }, ) } else { @@ -3624,7 +3691,8 @@ mod tests { fn test_initial_message_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): [Base] rides along in the // user message, composed as `[Base]\n{bp}\n\n{initial_msg}`. - let composed = prepend_base_for_legacy(1, Some("you are a helpful agent"), "hello channel"); + let composed = + prepend_base_for_legacy(1, Some("you are a helpful agent"), "hello channel", None); assert_eq!(composed, "[Base]\nyou are a helpful agent\n\nhello channel"); assert!(composed.starts_with("[Base]\nyou are a helpful agent\n\n")); } @@ -3633,7 +3701,8 @@ mod tests { fn test_initial_message_modern_agent_omits_base() { // protocol_version 2 receives base_prompt via session/new, so the user // message is left untouched even when a base_prompt is present. - let composed = prepend_base_for_legacy(2, Some("you are a helpful agent"), "hello channel"); + let composed = + prepend_base_for_legacy(2, Some("you are a helpful agent"), "hello channel", None); assert_eq!(composed, "hello channel"); } @@ -3661,7 +3730,7 @@ mod tests { #[test] fn test_initial_message_legacy_agent_without_base_is_unchanged() { // No base_prompt configured: nothing to prepend regardless of version. - let composed = prepend_base_for_legacy(1, None, "hello channel"); + let composed = prepend_base_for_legacy(1, None, "hello channel", None); assert_eq!(composed, "hello channel"); } @@ -3715,7 +3784,7 @@ mod tests { // Verify the full composition order when both base and canvas are present: // [Base] → canvas section → initial-message body. let canvas = "[Channel Canvas]\ncanvas content"; - let base_composed = prepend_base_for_legacy(1, Some("be helpful"), "do the thing"); + let base_composed = prepend_base_for_legacy(1, Some("be helpful"), "do the thing", None); let full = prepend_canvas_for_legacy(1, Some(canvas), &base_composed); assert!( full.starts_with("[Channel Canvas]"), @@ -3744,14 +3813,15 @@ mod tests { #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { - let framed = framed_system_prompt("/", Some("base text"), Some("persona text")) + let framed = framed_system_prompt("/", Some("base text"), Some("persona text"), None) .expect("both present yields Some"); assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); } #[test] fn test_framed_system_prompt_base_only_labels_base() { - let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); + let framed = + framed_system_prompt("/", Some("base text"), None, None).expect("base yields Some"); assert_eq!(framed, "[Base]\nbase text"); } @@ -3759,19 +3829,19 @@ mod tests { fn test_framed_system_prompt_persona_only_labels_system() { // A bare persona would be mislabeled "Base" downstream — it must carry // its own [System] header even when no base prompt exists. - let framed = - framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some"); + let framed = framed_system_prompt("/", None, Some("persona text"), None) + .expect("persona yields Some"); assert_eq!(framed, "[System]\npersona text"); } #[test] fn test_framed_system_prompt_neither_is_none() { - assert!(framed_system_prompt("/", None, None).is_none()); + assert!(framed_system_prompt("/", None, None, None).is_none()); } #[test] fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() { - let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None) + let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None, None) .expect("base yields Some"); assert!( framed.starts_with("[Workspace]\n"), @@ -3788,7 +3858,7 @@ mod tests { fn test_framed_system_prompt_persona_only_omits_workspace() { // The workspace section grounds the base prompt's layout; a persona-only // agent never received that layout, so no [Workspace] anchor is emitted. - let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text")) + let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text"), None) .expect("persona yields Some"); assert_eq!(framed, "[System]\npersona text"); } @@ -3796,10 +3866,57 @@ mod tests { #[test] fn test_framed_system_prompt_root_cwd_omits_workspace() { // The "/" fallback must never be named — it would invite a $HOME scan. - let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); + let framed = + framed_system_prompt("/", Some("base text"), None, None).expect("base yields Some"); assert_eq!(framed, "[Base]\nbase text"); } + #[test] + fn test_framed_system_prompt_session_title_leads_base() { + let framed = framed_system_prompt("/", Some("base text"), None, Some("Fizz")) + .expect("base yields Some"); + assert_eq!(framed, "[Session]\nFizz\n\n[Base]\nbase text"); + } + + #[test] + fn test_framed_system_prompt_session_title_before_workspace() { + let framed = + framed_system_prompt("/Users/me/.buzz", Some("base text"), None, Some("Varys")) + .expect("ok"); + assert!( + framed.starts_with("[Session]\nVarys\n\n[Workspace]\n"), + "{framed}" + ); + assert!(framed.contains("\n\n[Base]\nbase text"), "{framed}"); + } + + #[test] + fn test_sanitize_session_title_strips_controls_and_caps() { + assert_eq!( + sanitize_session_title(" Fizz\nBee ").as_deref(), + Some("Fizz Bee") + ); + assert_eq!(sanitize_session_title(" ").as_deref(), None); + assert_eq!(sanitize_session_title("").as_deref(), None); + let long = "x".repeat(100); + let s = sanitize_session_title(&long).unwrap(); + assert_eq!(s.chars().count(), 80); + } + + #[test] + fn test_prepend_base_legacy_includes_session_title() { + let composed = prepend_base_for_legacy( + 1, + Some("you are a helpful agent"), + "hello channel", + Some("Hive"), + ); + assert_eq!( + composed, + "[Session]\nHive\n\n[Base]\nyou are a helpful agent\n\nhello channel" + ); + } + #[test] fn test_workspace_section_relative_cwd_is_none() { assert!(workspace_section("relative/path").is_none()); @@ -5262,6 +5379,7 @@ mod tests { memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + agent_display_name: None, } } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..914dddc2ab 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1372,6 +1372,8 @@ pub struct FormatPromptArgs<'a> { /// For legacy agents it rides in the user message on every turn of the /// session, alongside `[Base]`/`[System]`/`[Agent Memory — core]`. pub agent_canvas: Option<&'a str>, + /// Buzz agent display name for Codex-style session list titles (#2334). + pub agent_display_name: Option<&'a str>, } /// Format the `[Base]` section for the base prompt. @@ -1386,8 +1388,9 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 0. `[Base]` — base prompt (only for legacy agents without systemPrompt support) -/// 1. `[System]` — system prompt (only for legacy agents without systemPrompt support) +/// 0. `[Session]` — Buzz display name (legacy only; titles Codex/ACP session lists) +/// 1. `[Base]` — base prompt (only for legacy agents without systemPrompt support) +/// 2. `[System]` — system prompt (only for legacy agents without systemPrompt support) /// 2. `[Agent Memory — core]` — if agent core memory is set /// 3. `[Context]` — scope, channel name, and contextual hints for the agent /// 4. `[Thread Context]` or `[Conversation Context]` — if fetched @@ -1427,6 +1430,10 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec