Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpServer>,
system_prompt: Option<&str>,
) -> Result<SessionNewResponse, AcpError> {
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`].
Expand Down Expand Up @@ -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"
);
}
}
2 changes: 1 addition & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ fn validate_allowlist(entries: &[String]) -> Result<HashSet<String>, 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
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 23 additions & 9 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
Loading