Skip to content
Closed
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
57 changes: 57 additions & 0 deletions crates/buzz-acp/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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 `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 `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 top-level `session/new.params.systemPrompt` field. Buzz recognizes
`pi-acp` by the agent name returned during initialization, regardless of protocol
version. 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.
137 changes: 14 additions & 123 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,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) == "pi-acp" {
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,7 +646,7 @@ 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, pi-acp; goose unused).
/// - `Some(SystemPromptTransport::ClaudeMeta(text))` — `_meta.systemPrompt`
/// as `{"append": text}`, keeping claude-agent-acp's native preset intact.
///
Expand Down Expand Up @@ -2131,9 +2140,7 @@ 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).
/// - **`Field`** — bare `systemPrompt` field (ACP protocol v2, buzz-agent, pi-acp).
/// - **`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.
Expand Down Expand Up @@ -3631,123 +3638,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
117 changes: 117 additions & 0 deletions crates/buzz-acp/src/acp/system_prompt_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#[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),
},
}
})
}
Loading
Loading