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
34 changes: 34 additions & 0 deletions crates/buzz-acp/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Testing buzz-acp

Activate Hermit from the repository root, then run the complete package suite:

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

## Native Pi prompt transport

The pool tests exercise the production session composer, native transport setup,
legacy-fallback decision, and session invalidation. The executable tests check
new-session and restore argument selection, missing snapshots, and terminal login.

With Pi installed on PATH, also run:

```sh
cargo test -p buzz-acp --test pi_native_launcher -- --ignored
```

This starts the real Pi CLI through the built Buzz launcher and exports its live
system prompt over RPC. It checks exactly one framed base, profile, and core-memory
section. It uses a synthetic transcript and isolated Pi settings, disables extensions
and workspace context files, makes no model calls, and deletes its temporary files.
It does not change a running agent or its registration.

Do not reconstruct a production session's system prompt by reopening its transcript
with a profile-only `--system-prompt` override. Pi's HTML export reports the exporting
process's current system prompt, not a historical system prompt from the transcript.

Prompt snapshots live for a Buzz session. Subprocess restoration reuses that snapshot;
new Buzz sessions fetch and compose fresh standing context. Retiring a session removes
its snapshot. Existing transcript content is not rewritten by this transport.
90 changes: 65 additions & 25 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! # Lifecycle
//! 1. [`AcpClient::spawn`] — launch agent binary as subprocess
//! 2. [`AcpClient::initialize`] — protocol version negotiation
//! 3. [`AcpClient::session_new`] — create session with MCP server config
//! 3. [`AcpClient::session_new_full`] — create session with MCP server config
//! 4. [`AcpClient::session_prompt_with_idle_timeout`] — send prompt with idle/hard deadline, return stop reason
//! 5. [`AcpClient::session_cancel`] / [`AcpClient::cancel_with_cleanup`] — cancel in-flight turn

Expand Down Expand Up @@ -137,8 +137,9 @@ fn build_initialize_params() -> serde_json::Value {
/// ACP client that owns an agent subprocess and communicates over its stdio.
///
/// One `AcpClient` per agent process. Multiple sessions can be created on the
/// same client via repeated calls to [`session_new`](AcpClient::session_new).
/// same client via repeated calls to [`session_new_full`](AcpClient::session_new_full).
pub struct AcpClient {
pi_launcher: Option<std::sync::Arc<crate::pi_launcher::PiLaunchOverride>>,
/// The agent child process (kept alive to prevent zombie).
child: Child,
/// Write end of the agent's stdin pipe.
Expand Down Expand Up @@ -504,6 +505,9 @@ impl AcpClient {
}

for (key, value) in extra_env {
if key.eq_ignore_ascii_case(crate::pi_launcher::PI_ACP_PI_COMMAND_ENV) {
continue;
}
if key == "CODEX_CONFIG" && codex_merge_active {
// Handled by build_codex_config_env; skip here to avoid double-setting.
continue;
Expand Down Expand Up @@ -534,6 +538,13 @@ impl AcpClient {
"codex" | "codex-acp" => Some(StandardAdapterKind::Codex),
_ => None,
};
let pi_launcher = crate::pi_launcher::PiLaunchOverride::prepare(command)?;
if let Some(launcher) = &pi_launcher {
cmd.env(
crate::pi_launcher::PI_ACP_PI_COMMAND_ENV,
launcher.launcher_path(),
);
}
let mut child = cmd.spawn()?;

let stdin = child
Expand All @@ -546,6 +557,7 @@ impl AcpClient {
.ok_or_else(|| AcpError::Protocol("failed to open agent stdout".into()))?;

Ok(Self {
pi_launcher,
child,
stdin,
reader: FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_LINE_SIZE)),
Expand All @@ -566,6 +578,10 @@ impl AcpClient {
})
}

pub(crate) fn has_pi_system_prompt_transport(&self) -> bool {
self.pi_launcher.is_some()
}

/// Attach a local observer feed to this ACP client.
pub fn set_observer(&mut self, observer: Option<ObserverHandle>, agent_index: usize) {
self.observer = observer;
Expand Down Expand Up @@ -648,18 +664,31 @@ impl AcpClient {
///
/// Callers use [`extract_model_config_options`] and [`extract_model_state`]
/// to pull model info from the raw result.
/// For managed Pi, prompt text uses the native launcher instead of the wire
/// field. Retain the returned `pi_prompt` for the lifetime of the session.
pub async fn session_new_full(
&mut self,
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<SystemPromptTransport<'_>>,
session_title: Option<&str>,
) -> Result<SessionNewResponse, AcpError> {
let native_prompt = if let Some(launcher) = &self.pi_launcher {
let text = match &system_prompt {
Some(
SystemPromptTransport::Field(text) | SystemPromptTransport::ClaudeMeta(text),
) => *text,
None => "",
};
Some(launcher.begin(text)?)
} else {
None
};
let mut params = serde_json::json!({
"cwd": cwd,
"mcpServers": mcp_servers,
});
match system_prompt {
match system_prompt.filter(|_| native_prompt.is_none()) {
Some(SystemPromptTransport::Field(sp)) => {
params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
}
Expand All @@ -673,35 +702,44 @@ impl AcpClient {
// Merge — _meta may already carry systemPrompt from ClaudeMeta above.
params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned());
}
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();
let response = async {
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();
Ok::<_, AcpError>((result, session_id))
}
.await;
let (result, session_id) = match response {
Ok(response) => response,
Err(error) => {
// A timed-out request may still start Pi later. Kill this adapter
// before clearing its pending pointer or accepting another create.
if native_prompt.is_some() {
self.shutdown().await;
}
return Err(error);
}
};
let pi_prompt = match native_prompt
.map(|pending| pending.finish(&session_id))
.transpose()
{
Ok(prompt) => prompt,
Err(error) => {
self.shutdown().await;
return Err(error.into());
}
};
tracing::info!(target: "acp::session", "session created: {session_id}");
Ok(SessionNewResponse {
pi_prompt,
session_id,
raw: result,
})
}

/// Send `session/new` and return only the `sessionId` string.
///
/// Convenience wrapper around [`session_new_full`].
#[allow(dead_code)] // Public API — callers outside the harness may use this.
pub async fn session_new(
&mut self,
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<SystemPromptTransport<'_>>,
session_title: Option<&str>,
) -> Result<String, AcpError> {
Ok(self
.session_new_full(cwd, mcp_servers, system_prompt, session_title)
.await?
.session_id)
}

/// Replace Goose's native system prompt after `session/new`.
pub async fn session_set_goose_system_prompt(
&mut self,
Expand Down Expand Up @@ -2124,6 +2162,8 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
///
/// Callers use the extractor helpers to pull model info from `raw`.
pub struct SessionNewResponse {
/// Native prompt lifetime; the pool retains it with its session state.
pub(crate) pi_prompt: Option<crate::pi_launcher::PiSessionPrompt>,
pub session_id: String,
/// The full `result` value from the JSON-RPC response.
pub raw: serde_json::Value,
Expand Down
32 changes: 3 additions & 29 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,9 @@ mod replay_floor_tests {
}

pub fn run() -> Result<()> {
if pi_launcher::try_run()? {
return Ok(());
}
config::propagate_legacy_env_vars();
tokio_main()
}
Expand Down Expand Up @@ -2528,31 +2531,6 @@ async fn tokio_main() -> Result<()> {
),
)
};
// PI_ACP_PI_COMMAND is Buzz-owned. Strip stale/user-provided copies from
// every adapter before optionally installing Buzz's generated Pi launcher.
config
.persona_env_vars
.retain(|(key, _)| !key.eq_ignore_ascii_case(pi_launcher::PI_ACP_PI_COMMAND_ENV));
let managed_skills_dir = std::path::Path::new(&cwd).join(".agents/skills");
let inherited_pi_command_is_set =
std::env::var_os(pi_launcher::PI_ACP_PI_COMMAND_ENV).is_some();
let (pi_launch_override, base_prompt) = pi_launcher::PiLaunchOverride::prepare(
&config.agent_command,
base_prompt,
&managed_skills_dir,
inherited_pi_command_is_set,
)
.context("failed to prepare Pi launch overrides")?;
if let Some(prepared) = pi_launch_override.as_ref() {
config.persona_env_vars.push((
pi_launcher::PI_ACP_PI_COMMAND_ENV.to_string(),
prepared.launcher_path().to_string_lossy().into_owned(),
));
tracing::info!(
skills_dir = %managed_skills_dir.display(),
"configured Pi to consume Buzz standing context and managed skills through native CLI flags"
);
}

let observer = config
.relay_observer
Expand Down Expand Up @@ -4166,10 +4144,6 @@ async fn tokio_main() -> Result<()> {
// for the background task to finish, rather than aborting immediately (#40).
relay.shutdown().await;

// Pi may restore subprocesses throughout the pool lifetime. Remove its
// private prompt and launcher only after every adapter has shut down.
drop(pi_launch_override);

tracing::info!("buzz-acp stopped");
Ok(())
}
Expand Down
Loading
Loading