diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 3e2361a11..e80e55e1f 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -321,6 +321,34 @@ impl AcpClient { }) } + /// Send `session/new` for a goose agent, passing `systemPrompt` via `_meta` + /// per the ACP+ extensibility convention (goose reads it from `_meta["systemPrompt"]` + /// rather than as a first-class field). + pub async fn session_new_full_goose( + &mut self, + cwd: &str, + mcp_servers: Vec, + system_prompt: Option<&str>, + ) -> Result { + let mut params = serde_json::json!({ + "cwd": cwd, + "mcpServers": mcp_servers, + }); + if let Some(sp) = system_prompt { + params["_meta"] = serde_json::json!({ "systemPrompt": sp }); + } + let result = self.send_request("session/new", params).await?; + let session_id = result["sessionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))? + .to_owned(); + tracing::info!(target: "acp::session", "session created: {session_id}"); + Ok(SessionNewResponse { + session_id, + raw: result, + }) + } + /// Send `session/new` and return only the `sessionId` string. /// /// Convenience wrapper around [`session_new_full`]. @@ -2068,4 +2096,67 @@ mod tests { "systemPrompt should NOT be in params when value is None" ); } + + #[tokio::test] + async fn session_new_full_goose_includes_system_prompt_in_meta_when_some() { + // Goose path: systemPrompt must appear in _meta, NOT as a top-level field. + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_goose","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full_goose("/tmp", vec![], Some("Be concise.")) + .await + .expect("session_new_full_goose should succeed"); + + assert_eq!(resp.session_id, "ses_goose"); + let received = &resp.raw["_receivedRequest"]; + assert_eq!( + received["params"]["_meta"]["systemPrompt"].as_str(), + Some("Be concise."), + "systemPrompt should be in _meta when Some" + ); + assert!( + received["params"]["systemPrompt"].is_null(), + "systemPrompt should NOT appear as a top-level field" + ); + } + + #[tokio::test] + async fn session_new_full_goose_omits_meta_when_none() { + // Goose path: when system_prompt is None, _meta should not appear in params. + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_goose","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full_goose("/tmp", vec![], None) + .await + .expect("session_new_full_goose should succeed"); + + assert_eq!(resp.session_id, "ses_goose"); + let received = &resp.raw["_receivedRequest"]; + assert!( + received["params"]["_meta"].is_null(), + "_meta should NOT be in params when system_prompt is None" + ); + } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 759a09b09..f7d4ed3ca 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -492,7 +492,7 @@ fn validate_allowlist(entries: &[String]) -> Result, ConfigError Ok(validated) } -fn normalize_agent_command_identity(command: &str) -> String { +pub(crate) fn normalize_agent_command_identity(command: &str) -> String { let normalized = command.trim().replace('\\', "/"); let trimmed = normalized.trim_end_matches('/'); let basename = trimmed diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4c180e801..bfdceabea 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1277,6 +1277,7 @@ async fn tokio_main() -> Result<()> { .as_deref() .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, + agent_command: config.agent_command.clone(), }); if !config.memory_enabled { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 523d5cf74..19327c32c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -32,7 +32,7 @@ use crate::acp::{ extract_model_config_options, extract_model_state, resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, }; -use crate::config::{DedupMode, PermissionMode}; +use crate::config::{normalize_agent_command_identity, DedupMode, PermissionMode}; use crate::observer; use crate::queue::{ ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, @@ -250,6 +250,9 @@ pub struct PromptContext { /// `[Agent Memory — core]` section. On by default; disabled via /// `--no-memory` / `BUZZ_ACP_NO_MEMORY`. pub memory_enabled: bool, + /// Agent command (binary name or path). Used to detect goose agents and + /// route `systemPrompt` via `_meta` rather than as a first-class field. + pub agent_command: String, } impl AgentPool { @@ -438,14 +441,25 @@ async fn create_session_and_apply_model( None }; - let resp = agent - .acp - .session_new_full( - &ctx.cwd, - ctx.mcp_servers.clone(), - combined_system_prompt.as_deref(), - ) - .await?; + let resp = if normalize_agent_command_identity(&ctx.agent_command) == "goose" { + agent + .acp + .session_new_full_goose( + &ctx.cwd, + ctx.mcp_servers.clone(), + combined_system_prompt.as_deref(), + ) + .await? + } else { + agent + .acp + .session_new_full( + &ctx.cwd, + ctx.mcp_servers.clone(), + combined_system_prompt.as_deref(), + ) + .await? + }; // Populate model capabilities on first session creation. if agent.model_capabilities.is_none() {