diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..593b4989b5 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -259,6 +259,16 @@ Forum event kinds: Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. +### ACP output compatibility fallback + +Some ACP adapters return assistant text over `session/update` but do not call +the Buzz CLI themselves. For a deliberately isolated adapter canary, set +`--auto-publish-responses` (or `BUZZ_ACP_AUTO_PUBLISH_RESPONSES=true`) to have +`buzz-acp` publish one signed kind:9 reply to the originating thread after a +successful channel turn. It is off by default: do not enable it for an agent +that already publishes through Buzz CLI, or replies will be duplicated. Empty +responses, heartbeats, and failed turns are never published. + > **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel. ## Bring Your Own Harness (BYOH) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 93109fa94d..028ff54e3b 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -211,6 +211,11 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. goose_usage: UsageTracker, + /// ACP assistant text accumulated from `agent_message_chunk` updates for + /// the current prompt. The normal contract is that the agent publishes + /// through Buzz CLI; the opt-in harness fallback consumes this buffer when + /// an adapter only returns text over ACP. + turn_response: String, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -550,6 +555,7 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + turn_response: String::new(), }) } @@ -768,6 +774,7 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { + self.turn_response.clear(); let params = build_prompt_params(session_id, prompt_blocks); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -867,6 +874,14 @@ impl AcpClient { self.steering_supported } + /// Take the assistant text captured during the most recent ACP turn. + /// Empty/whitespace-only turns are not publishable responses. + pub fn take_turn_response(&mut self) -> Option { + let response = std::mem::take(&mut self.turn_response); + let trimmed = response.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + /// Consume and return the per-turn usage record computed from the most /// recent `_goose/unstable/session/update` notification. /// @@ -1732,6 +1747,7 @@ impl AcpClient { match update_type { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { + self.turn_response.push_str(text); tracing::info!(target: "acp::stream", "{text}"); } false @@ -3591,6 +3607,27 @@ mod tests { .expect("spawn cat as inert client") } + #[tokio::test] + async fn agent_message_chunks_are_captured_for_relay_fallback() { + let mut client = spawn_inert_client().await; + client.handle_session_update(&serde_json::json!({ + "params": {"update": { + "sessionUpdate": "agent_message_chunk", + "content": {"text": "hello "} + }} + })); + client.handle_session_update(&serde_json::json!({ + "params": {"update": { + "sessionUpdate": "agent_message_chunk", + "content": {"text": "world"} + }} + })); + + assert_eq!(client.take_turn_response().as_deref(), Some("hello world")); + assert_eq!(client.take_turn_response(), None); + client.shutdown().await; + } + /// Build a `session/update` JSON-RPC notification carrying a /// `session_info_update` with the given `_meta.goose.activeRunId` value. /// Pass `None` to omit the `activeRunId` field entirely. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d959685846..5c9e23e760 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -468,6 +468,13 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + /// Publish non-empty ACP assistant text as a Buzz reply after a successful + /// channel turn. Disabled by default because most agents publish through + /// the Buzz CLI themselves; enable only for an adapter/runtime that has + /// explicitly been tested for this fallback. + #[arg(long, env = "BUZZ_ACP_AUTO_PUBLISH_RESPONSES", default_value_t = false)] + pub auto_publish_responses: bool, + /// Exit after this many seconds with no dispatched events and no turn in flight. /// 0 disables inactivity self-termination. #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)] @@ -549,6 +556,9 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Whether to publish ACP assistant text as a channel reply. Off by default + /// to preserve the agent-owned Buzz CLI publish contract. + pub auto_publish_responses: bool, /// Seconds without dispatched events before an idle harness exits. 0 = disabled. pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. @@ -1099,6 +1109,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + auto_publish_responses: args.auto_publish_responses, exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), @@ -1125,7 +1136,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} auto_publish_responses={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1146,6 +1157,7 @@ impl Config { self.memory_enabled, self.model.as_deref().unwrap_or("(agent default)"), self.permission_mode, + self.auto_publish_responses, respond_to_detail, allowed_respond_to_detail, ) @@ -1470,6 +1482,7 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + auto_publish_responses: false, exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 65c9dd6203..b4851aadf6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1843,6 +1843,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(), + auto_publish_responses: config.auto_publish_responses, }); if !config.memory_enabled { @@ -6206,6 +6207,7 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + auto_publish_responses: false, exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, @@ -6428,6 +6430,7 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + auto_publish_responses: false, exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 8430307d9c..e4aacee3c7 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -564,6 +564,11 @@ 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, + /// Opt-in compatibility fallback for ACP adapters that return assistant + /// text but do not call `buzz messages send` themselves. Disabled by + /// default to preserve the normal CLI-publish contract and avoid duplicate + /// messages from agents that already publish through Buzz. + pub auto_publish_responses: bool, } impl AgentPool { @@ -2110,6 +2115,8 @@ pub async fn run_prompt_task( &source, &control_signal, ); + let response = agent.acp.take_turn_response(); + maybe_publish_auto_response(&ctx, &source, batch.as_ref(), response).await; let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( &ctx, @@ -2139,6 +2146,9 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + let response = agent.acp.take_turn_response(); + maybe_publish_auto_response(&ctx, &source, batch.as_ref(), response).await; + let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -3865,6 +3875,100 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str } } +/// Build a signed kind:9 reply for the ACP-output compatibility fallback. +/// +/// A top-level trigger becomes the root of a new thread; an existing thread +/// keeps its root and replies to the latest parent. The caller controls whether +/// this fallback is enabled, so the default agent/CLI publish contract remains +/// unchanged. +fn build_auto_response_event( + keys: &nostr::Keys, + channel_id: Uuid, + triggering_event: &nostr::Event, + content: &str, +) -> Result { + let parsed = crate::queue::parse_thread_tags(triggering_event); + let thread_ref = match (parsed.root_event_id, parsed.parent_event_id) { + (Some(root), parent) => { + let root_event_id = nostr::EventId::from_hex(&root) + .map_err(|e| format!("invalid thread root event ID: {e}"))?; + let parent_event_id = parent + .as_deref() + .and_then(|id| nostr::EventId::from_hex(id).ok()) + .unwrap_or(root_event_id); + Some(buzz_sdk::ThreadRef { + root_event_id, + parent_event_id, + }) + } + (None, None) => Some(buzz_sdk::ThreadRef { + root_event_id: triggering_event.id, + parent_event_id: triggering_event.id, + }), + (None, Some(_)) => unreachable!("parse_thread_tags normalizes reply-only tags"), + }; + let builder = + buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) + .map_err(|e| format!("build response event: {e}"))?; + builder + .sign_with_keys(keys) + .map_err(|e| format!("sign response event: {e}")) +} + +/// Best-effort: post the ACP response back into the originating Buzz thread. +/// This is only called when `auto_publish_responses` is explicitly enabled. +async fn post_auto_response( + rest: &crate::relay::RestClient, + channel_id: Uuid, + triggering_event: &nostr::Event, + content: &str, +) { + let event = match build_auto_response_event(&rest.keys, channel_id, triggering_event, content) { + Ok(event) => event, + Err(error) => { + tracing::warn!(channel = %channel_id, "ACP auto-response build failed: {error}"); + return; + } + }; + match tokio::time::timeout(Duration::from_secs(5), rest.submit_event(&event)).await { + Ok(Ok(_)) => { + tracing::info!(channel = %channel_id, event_id = %event.id, "ACP response published to Buzz") + } + Ok(Err(error)) => { + tracing::warn!(channel = %channel_id, "ACP auto-response publish failed: {error}") + } + Err(_) => tracing::warn!(channel = %channel_id, "ACP auto-response publish timed out"), + } +} + +/// Best-effort: publish one ACP response when the opt-in compatibility fallback +/// is enabled. Heartbeats and empty responses are deliberately ignored. +async fn maybe_publish_auto_response( + ctx: &PromptContext, + source: &PromptSource, + batch: Option<&FlushBatch>, + response: Option, +) { + if !ctx.auto_publish_responses { + return; + } + let (PromptSource::Channel(channel_id), Some(batch), Some(response)) = + (source, batch, response) + else { + return; + }; + let Some(triggering_event) = batch.events.last() else { + return; + }; + post_auto_response( + &ctx.rest_client, + *channel_id, + &triggering_event.event, + &response, + ) + .await; +} + /// Best-effort: post a visible failure notice (kind:9) to a channel after a /// batch is dead-lettered. Replies into the thread of `thread_tags` when the /// triggering event was threaded. Errors are logged and swallowed — the @@ -6542,9 +6646,48 @@ mod tests { memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + auto_publish_responses: false, } } + #[test] + fn auto_response_event_replies_to_triggering_message() { + let channel_id = Uuid::new_v4(); + let trigger_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let trigger = buzz_sdk::build_message( + channel_id, + "@Bumble canary", + None, + &[&agent_keys.public_key().to_hex()], + false, + &[], + ) + .expect("trigger builder") + .sign_with_keys(&trigger_keys) + .expect("trigger event"); + + let response = build_auto_response_event( + &agent_keys, + channel_id, + &trigger, + "BUMBLE_CANARY_OK — SHADOW ONLY", + ) + .expect("auto response event"); + + assert_eq!(response.kind, nostr::Kind::Custom(9)); + assert_eq!(response.content, "BUMBLE_CANARY_OK — SHADOW ONLY"); + assert!(response + .tags + .iter() + .any(|tag| { tag.as_slice() == ["h".to_string(), channel_id.to_string()] })); + assert!(response.tags.iter().any(|tag| { + tag.as_slice()[0] == "e" + && tag.as_slice()[1] == trigger.id.to_hex() + && tag.as_slice()[3] == "reply" + })); + } + // ── render_canvas_section ──────────────────────────────────────────────── #[test] diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index cb188dd008..dfdac96c24 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -402,12 +402,16 @@ export function UserProfilePanel({ }); const handleEditAgent = React.useCallback(() => { + if (managedAgent) { + setEditAgentOpen(true); + return; + } if (resolvedPersona) { setPersonaDialogState(editPersonaDialogState(resolvedPersona)); return; } setEditAgentOpen(true); - }, [resolvedPersona]); + }, [managedAgent, resolvedPersona]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({