Skip to content
Merged
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
58 changes: 58 additions & 0 deletions crates/buzz-acp/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Pi adapter integration

Buzz's Pi preset uses [salman1993/pi-acp](https://github.com/salman1993/pi-acp).
Requires Node.js 22 or newer. Install Pi and configure its model provider,
then install the adapter directly from the fork:

```sh
npm install -g @earendil-works/pi-coding-agent
pi
npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main
```

Restart Buzz, then select **Pi** as the agent harness. Buzz starts `buzz-pi-acp`
automatically. Run the same adapter install command again to update it.
The unscoped `npm install -g pi-acp` command installs the upstream package,
without these extensions. Use fresh sessions to replace old user-framed
standing instructions.

Buzz adds `-- --skill <harness-cwd>/.agents/skills` when launching `buzz-pi-acp`.
An existing separator and explicit Pi options are preserved. Managed agents
run from the Buzz nest (normally `~/.buzz`): Desktop sets the `buzz-acp` child
CWD through `default_agent_workdir()`, and adapters inherit it. The default
skill directory is that launch workspace's `.agents/skills`. Standalone CLI
launches use the caller's working directory.
The path is fixed at adapter launch and applies to every Pi subprocess.

The full composed session prompt is sent as a replacement string through the
provisional `session/new.params._meta.systemPrompt` field. Buzz recognizes
`buzz-pi-acp` by the agent name returned during initialization, regardless of
protocol version. Upstream `pi-acp` does not receive this fork-specific metadata.
No custom capability negotiation or legacy Pi prompt fallback is used.
Session titles continue to use `_meta.sessionTitle`.

## Validation

Activate Hermit from the Buzz repository root, then run the package tests:

```sh
. ./bin/activate-hermit
cargo test -p buzz-acp
```

To exercise the real adapter through Buzz's production session composer:

```sh
BUZZ_TEST_PI_ACP=/absolute/pi-acp/dist/index.js \
cargo test -p buzz-acp real_pi_preserves -- --ignored
```

This test requires Node and Pi on PATH. It isolates HOME and Pi settings,
disables extensions and context files, and uses a synthetic transcript without
model calls. It inspects Pi's effective prompt through RPC HTML export after
switching sessions and restarting the adapter. Base, persona, team, core memory,
huddle, canvas, and the extra skill must each appear once, without another
session's instructions or Pi's default coding preamble.

HTML export reports the exporting process's current system prompt. It cannot
recover a historical prompt from an old transcript alone.
153 changes: 28 additions & 125 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ use crate::usage::{
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB

/// Package and binary name used by Buzz's Pi ACP fork.
pub(crate) const BUZZ_PI_ACP_NAME: &str = "buzz-pi-acp";

/// An MCP server configuration passed to `session/new`.
///
/// Corresponds to the `McpServerStdio` variant in the ACP schema.
Expand Down Expand Up @@ -460,8 +463,17 @@ impl AcpClient {
use std::process::Stdio;

let mut cmd = tokio::process::Command::new(command);
cmd.args(args)
.stdin(Stdio::piped())
cmd.args(args);
if crate::config::normalize_agent_command_identity(command) == BUZZ_PI_ACP_NAME {
if !args.iter().any(|arg| arg == "--") {
cmd.arg("--");
}
// Desktop launches buzz-acp in the Buzz nest; adapters inherit that
// workspace. Keep managed skills tied to launch CWD across sessions.
cmd.arg("--skill")
.arg(std::env::current_dir()?.join(".agents/skills"));
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
// Inherit stderr so agent logs are visible in the harness terminal.
.stderr(Stdio::inherit())
Expand Down Expand Up @@ -637,14 +649,16 @@ impl AcpClient {
///
/// - `None` — no system-prompt field in the request (legacy framing).
/// - `Some(SystemPromptTransport::Field(text))` — bare `systemPrompt` field
/// (ACP protocol v2, buzz-agent, goose unused).
/// (ACP protocol v2, buzz-agent; goose unused).
/// - `Some(SystemPromptTransport::PiMeta(text))` — `_meta.systemPrompt`
/// as a replacement string for the Buzz pi-acp fork.
/// - `Some(SystemPromptTransport::ClaudeMeta(text))` — `_meta.systemPrompt`
/// as `{"append": text}`, keeping claude-agent-acp's native preset intact.
///
/// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is
/// omitted entirely otherwise, since adapters may distinguish an absent
/// member from a null one. When both `ClaudeMeta` and `session_title` are
/// present the two `_meta` members are merged into a single object.
/// member from a null one. Metadata prompt transports and the title are
/// merged into a single object.
///
/// Callers use [`extract_model_config_options`] and [`extract_model_state`]
/// to pull model info from the raw result.
Expand All @@ -663,14 +677,17 @@ impl AcpClient {
Some(SystemPromptTransport::Field(sp)) => {
params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
}
Some(SystemPromptTransport::PiMeta(sp)) => {
params["_meta"]["systemPrompt"] = serde_json::Value::String(sp.to_owned());
}
Some(SystemPromptTransport::ClaudeMeta(sp)) => {
// Merge into _meta so sessionTitle (set below) is not clobbered.
params["_meta"]["systemPrompt"] = serde_json::json!({ "append": sp });
}
None => {}
}
if let Some(title) = session_title {
// Merge — _meta may already carry systemPrompt from ClaudeMeta above.
// Merge — _meta may already carry a system prompt from an adapter extension.
params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned());
}
let result = self.send_request("session/new", params).await?;
Expand Down Expand Up @@ -2131,16 +2148,18 @@ pub struct SessionNewResponse {

/// How to deliver a system prompt on `session/new`.
///
/// The two variants match the two mechanisms supported by current adapters:
///
/// - **`Field`** — bare `systemPrompt` field (ACP protocol v2, buzz-agent).
/// - **`PiMeta`** — `_meta.systemPrompt: text`, used by the Buzz pi-acp fork
/// to replace Pi's native system prompt.
/// - **`ClaudeMeta`** — `_meta.systemPrompt: {"append": text}`, used by
/// `claude-agent-acp` to append to the adapter's own native system prompt
/// while keeping its tool-use preset intact.
#[derive(Debug, Clone, PartialEq)]
pub enum SystemPromptTransport<'a> {
/// Deliver as a bare top-level `systemPrompt` field.
Field(&'a str),
/// Deliver as `_meta.systemPrompt: text`.
PiMeta(&'a str),
/// Deliver as `_meta.systemPrompt: {"append": text}`.
ClaudeMeta(&'a str),
}
Expand Down Expand Up @@ -3631,123 +3650,7 @@ mod tests {

// ── claude-agent-acp _meta.systemPrompt transport ─────────────────────

#[tokio::test]
async fn session_new_full_sends_claude_meta_system_prompt_when_claude_meta_transport() {
// When ClaudeMeta transport is requested, the prompt must appear as
// _meta.systemPrompt: {"append": text} — never as a bare systemPrompt 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_claude","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");

let resp = client
.session_new_full(
"/tmp",
vec![],
Some(SystemPromptTransport::ClaudeMeta("Be concise")),
None,
)
.await
.expect("session_new_full should succeed");

let received = &resp.raw["_receivedRequest"];
assert!(
received["params"].get("systemPrompt").is_none(),
"bare systemPrompt must not be present for ClaudeMeta transport"
);
assert_eq!(
received["params"]["_meta"]["systemPrompt"]["append"].as_str(),
Some("Be concise"),
"_meta.systemPrompt.append must carry the prompt text"
);
}

#[tokio::test]
async fn session_new_full_merges_claude_meta_and_session_title_into_single_meta_object() {
// Both ClaudeMeta prompt and session_title must coexist under _meta —
// the prompt must not clobber sessionTitle or vice versa.
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_merged","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");

let resp = client
.session_new_full(
"/tmp",
vec![],
Some(SystemPromptTransport::ClaudeMeta("Be concise")),
Some("Fizz · #buzz-dev"),
)
.await
.expect("session_new_full should succeed");

let received = &resp.raw["_receivedRequest"];
assert_eq!(
received["params"]["_meta"]["systemPrompt"]["append"].as_str(),
Some("Be concise"),
"_meta.systemPrompt.append must be present"
);
assert_eq!(
received["params"]["_meta"]["sessionTitle"].as_str(),
Some("Fizz · #buzz-dev"),
"_meta.sessionTitle must be present alongside systemPrompt"
);
}

// ── Goose-native steer scaffold (PR follow-up to #1160) ──────────────

/// Helper: spawn an inert `cat` subprocess so we have a real AcpClient
/// to drive `handle_session_update` against. `cat` never writes back,
/// which is fine — these tests don't read from the agent, they just
/// feed JSON into the parser.
async fn spawn_inert_client() -> AcpClient {
AcpClient::spawn("cat", &[], &[], false)
.await
.expect("spawn cat as inert client")
}

/// 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.
///
/// `_meta` is nested inside the `update` object (per the ACP
/// `SessionInfoUpdate` schema), matching what goose and buzz-agent
/// emit on the wire.
fn session_info_update_msg(active_run_id: Option<serde_json::Value>) -> serde_json::Value {
let mut goose = serde_json::Map::new();
if let Some(v) = active_run_id {
goose.insert("activeRunId".to_string(), v);
}
let mut meta = serde_json::Map::new();
meta.insert("goose".to_string(), serde_json::Value::Object(goose));
serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session",
"update": {
"sessionUpdate": "session_info_update",
"_meta": serde_json::Value::Object(meta),
},
}
})
}
include!("acp/system_prompt_tests.rs");

#[tokio::test]
async fn active_run_id_sets_on_string() {
Expand Down
Loading