diff --git a/crates/buzz-acp/TESTING.md b/crates/buzz-acp/TESTING.md new file mode 100644 index 00000000000..7a323a854e0 --- /dev/null +++ b/crates/buzz-acp/TESTING.md @@ -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. diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..4429e0a8b63 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -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 @@ -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>, /// The agent child process (kept alive to prevent zombie). child: Child, /// Write end of the agent's stdin pipe. @@ -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; @@ -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 @@ -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)), @@ -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, agent_index: usize) { self.observer = observer; @@ -648,6 +664,8 @@ 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, @@ -655,11 +673,22 @@ impl AcpClient { system_prompt: Option>, session_title: Option<&str>, ) -> Result { + 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()); } @@ -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, - system_prompt: Option>, - session_title: Option<&str>, - ) -> Result { - 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, @@ -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, pub session_id: String, /// The full `result` value from the JSON-RPC response. pub raw: serde_json::Value, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index cc2952a2f8d..34c5a87049c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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() } @@ -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 @@ -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(()) } diff --git a/crates/buzz-acp/src/pi_launcher.rs b/crates/buzz-acp/src/pi_launcher.rs index 892500bb200..fa688d566d0 100644 --- a/crates/buzz-acp/src/pi_launcher.rs +++ b/crates/buzz-acp/src/pi_launcher.rs @@ -1,99 +1,137 @@ -//! Pi-specific native launcher setup. -//! -//! `pi-acp` does not currently consume ACP `session/new.systemPrompt`, but it -//! does let callers replace the `pi` executable through -//! `PI_ACP_PI_COMMAND`. For Pi sessions, Buzz points that variable at a -//! private launcher which adds `--system-prompt ` and the canonical Buzz -//! `--skill ` before forwarding the adapter's RPC/session arguments -//! unchanged. +//! Native Pi system prompts. Each ACP process owns a launcher; each session +//! owns an immutable prompt file, also used when pi-acp restores its subprocess. + +mod native; +#[cfg(test)] +mod tests; use std::fs::{self, OpenOptions}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; - -#[cfg(unix)] -use std::ffi::OsStr; - +use std::sync::Arc; use uuid::Uuid; +pub(crate) use native::try_run; pub(crate) const PI_ACP_PI_COMMAND_ENV: &str = "PI_ACP_PI_COMMAND"; +const LAUNCH_MODE: &str = "--internal-pi-launch"; -/// Files backing the Pi launcher for one `buzz-acp` process. -/// -/// The guard must live as long as the ACP pool because `pi-acp` may start or -/// restore Pi subprocesses after its own initialization. +/// Private files for one adapter process, never shared across pool workers. pub(crate) struct PiLaunchOverride { directory: PathBuf, launcher: PathBuf, } impl PiLaunchOverride { - /// Prepare a Pi launcher when the configured ACP adapter is `pi-acp`. - /// - /// Returns the prompt that still needs ordinary ACP delivery. For Pi, the - /// base prompt moves into Pi's native system role and is therefore removed - /// from first-turn user framing. Other adapters receive it unchanged. - pub(crate) fn prepare( - agent_command: &str, - base_prompt: Option, - managed_skills_dir: &Path, - inherited_pi_command_is_set: bool, - ) -> io::Result<(Option, Option)> { + pub(crate) fn prepare(agent_command: &str) -> io::Result>> { if crate::config::normalize_agent_command_identity(agent_command) != "pi-acp" { - return Ok((None, base_prompt)); + return Ok(None); } - - if inherited_pi_command_is_set { + if std::env::var_os(PI_ACP_PI_COMMAND_ENV).is_some() { return Err(io::Error::new( io::ErrorKind::AlreadyExists, "PI_ACP_PI_COMMAND is managed by Buzz; unset it before starting a managed Pi agent", )); } - - // Buzz owns PI_ACP_PI_COMMAND and always uses it to point pi-acp at - // this generated launcher. The launcher resolves the ordinary `pi` - // command from Buzz's effective PATH. - let prepared = Self::create("pi", base_prompt.as_deref(), managed_skills_dir)?; - Ok((Some(prepared), None)) + Self::create(&std::env::current_exe()?).map(Some) } - pub(crate) fn launcher_path(&self) -> &Path { - &self.launcher - } - - fn create( - pi_command: &str, - prompt: Option<&str>, - managed_skills_dir: &Path, - ) -> io::Result { + pub(crate) fn create(executable: &Path) -> io::Result> { let directory = std::env::temp_dir().join(format!( "buzz-acp-pi-launcher-{}-{}", std::process::id(), Uuid::new_v4() )); create_private_directory(&directory)?; - - let prompt_path = directory.join("SYSTEM.md"); - let launcher = directory.join(launcher_file_name()); - // Construct the cleanup guard before either file write. Any later `?` - // drops it, so a partial setup cannot strand the private prompt file. - let prepared = Self { + let prepared = Arc::new(Self { + launcher: directory.join(if cfg!(windows) { + "pi-with-buzz-context.cmd" + } else { + "pi-with-buzz-context" + }), directory, - launcher, + }); + // Preserve the buzz-acp personality when current_exe resolves to Sprig. + #[cfg(unix)] + let executable = { + let alias = prepared.directory.join("buzz-acp"); + std::os::unix::fs::symlink(executable, &alias)?; + alias }; + write_private_file( + &prepared.launcher, + launcher_script(executable.as_ref(), &prepared.directory)?.as_bytes(), + true, + )?; + Ok(prepared) + } - if let Some(prompt) = prompt { - write_private_file(&prompt_path, prompt.as_bytes(), false)?; - } + pub(crate) fn launcher_path(&self) -> &Path { + &self.launcher + } - let script = launcher_script( - pi_command, - prompt.map(|_| prompt_path.as_path()), - managed_skills_dir, - )?; - write_private_file(&prepared.launcher, script.as_bytes(), true)?; + /// Called while the ACP client is exclusively borrowed for session/new. + /// The pending pointer is only for new sessions; restores use their ID. + pub(crate) fn begin(self: &Arc, prompt: &str) -> io::Result { + let token = Uuid::new_v4().to_string(); + let path = self.directory.join(format!("{token}.md")); + write_private_file(&path, prompt.as_bytes(), false)?; + let snapshot = PiSessionPrompt { + _launcher: Arc::clone(self), + path, + mapping: None, + }; + write_private_file(&self.directory.join("pending"), token.as_bytes(), false)?; + Ok(PendingSession { + snapshot: Some(snapshot), + launcher: Arc::clone(self), + }) + } +} - Ok(prepared) +/// Keeps the prompt available for reload and restore until Buzz retires the session. +pub(crate) struct PiSessionPrompt { + _launcher: Arc, + path: PathBuf, + mapping: Option, +} + +pub(crate) struct PendingSession { + snapshot: Option, + launcher: Arc, +} + +impl PendingSession { + pub(crate) fn finish(mut self, session_id: &str) -> io::Result { + let id = parse_id(session_id)?; + let mut snapshot = self + .snapshot + .take() + .ok_or_else(|| io::Error::other("Pi snapshot already consumed"))?; + let token = snapshot + .path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| io::Error::other("invalid Pi snapshot path"))?; + let mapping = self.launcher.directory.join(format!("session-{id}")); + write_private_file(&mapping, token.as_bytes(), false)?; + snapshot.mapping = Some(mapping); + fs::remove_file(self.launcher.directory.join("pending"))?; + Ok(snapshot) + } +} + +impl Drop for PendingSession { + fn drop(&mut self) { + remove_file(&self.launcher.directory.join("pending")); + } +} + +impl Drop for PiSessionPrompt { + fn drop(&mut self) { + if let Some(mapping) = &self.mapping { + remove_file(mapping); + } + remove_file(&self.path); } } @@ -101,280 +139,95 @@ impl Drop for PiLaunchOverride { fn drop(&mut self) { if let Err(error) = fs::remove_dir_all(&self.directory) { if error.kind() != io::ErrorKind::NotFound { - tracing::warn!( - path = %self.directory.display(), - %error, - "failed to remove temporary Pi launcher" - ); + tracing::warn!(path = %self.directory.display(), %error, "failed to remove temporary Pi launcher"); } } } } -#[cfg(unix)] -fn create_private_directory(path: &Path) -> io::Result<()> { - use std::os::unix::fs::DirBuilderExt; +fn remove_file(path: &Path) { + if let Err(error) = fs::remove_file(path) { + if error.kind() != io::ErrorKind::NotFound { + tracing::warn!(path = %path.display(), %error, "failed to remove temporary Pi prompt file"); + } + } +} - let mut builder = fs::DirBuilder::new(); - builder.mode(0o700).create(path) +fn parse_id(value: &str) -> io::Result { + Uuid::parse_str(value).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "Pi session/snapshot ID must be a UUID", + ) + }) } -#[cfg(not(unix))] fn create_private_directory(path: &Path) -> io::Result<()> { - fs::create_dir(path) + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path) } fn write_private_file(path: &Path, content: &[u8], executable: bool) -> io::Result<()> { let mut options = OpenOptions::new(); options.write(true).create_new(true); - #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(if executable { 0o700 } else { 0o600 }); } - #[cfg(not(unix))] let _ = executable; - let mut file = options.open(path)?; - file.write_all(content)?; - file.sync_all() -} - -#[cfg(unix)] -fn launcher_file_name() -> &'static str { - "pi-with-buzz-context" -} - -#[cfg(windows)] -fn launcher_file_name() -> &'static str { - "pi-with-buzz-context.cmd" -} - -#[cfg(not(any(unix, windows)))] -fn launcher_file_name() -> &'static str { - "pi-with-buzz-context" + if let Err(error) = file.write_all(content).and_then(|()| file.sync_all()) { + drop(file); + remove_file(path); + return Err(error); + } + Ok(()) } #[cfg(unix)] -fn launcher_script( - pi_command: &str, - prompt_path: Option<&Path>, - managed_skills_dir: &Path, -) -> io::Result { - let system_prompt_arg = match prompt_path { - Some(prompt_path) => format!(" --system-prompt {}", shell_quote(prompt_path.as_os_str())?), - None => String::new(), - }; +fn launcher_script(executable: &Path, directory: &Path) -> io::Result { + fn quote(path: &Path) -> io::Result { + let value = path + .to_str() + .ok_or_else(|| io::Error::other("Pi launcher paths must be valid UTF-8"))?; + Ok(format!("'{}'", value.replace('\'', "'\"'\"'"))) + } Ok(format!( - "#!/bin/sh\nexec {}{} --skill {} \"$@\"\n", - shell_quote(OsStr::new(pi_command))?, - system_prompt_arg, - shell_quote(managed_skills_dir.as_os_str())?, + "#!/bin/sh\nexec {} {LAUNCH_MODE} {} \"$@\"\n", + quote(executable)?, + quote(directory)? )) } -#[cfg(unix)] -fn shell_quote(value: &OsStr) -> io::Result { - let value = value.to_str().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Pi launcher paths must be valid UTF-8", - ) - })?; - Ok(format!("'{}'", value.replace('\'', "'\"'\"'"))) -} - #[cfg(windows)] -fn launcher_script( - pi_command: &str, - prompt_path: Option<&Path>, - managed_skills_dir: &Path, -) -> io::Result { - let system_prompt_arg = match prompt_path { - Some(prompt_path) => { - let prompt_path = prompt_path.to_str().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Pi launcher paths must be valid UTF-8", - ) - })?; - format!(" --system-prompt \"{}\"", batch_escape(prompt_path)) - } - None => String::new(), - }; - let managed_skills_dir = managed_skills_dir.to_str().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "Pi skill paths must be valid UTF-8", - ) - })?; +fn launcher_script(executable: &Path, directory: &Path) -> io::Result { + fn quote(path: &Path) -> io::Result { + let value = path + .to_str() + .ok_or_else(|| io::Error::other("Pi launcher paths must be valid UTF-8"))?; + Ok(format!( + "\"{}\"", + value.replace('%', "%%").replace('"', "\"\"") + )) + } Ok(format!( - "@echo off\r\n\"{}\"{} --skill \"{}\" %*\r\nexit /b %ERRORLEVEL%\r\n", - batch_escape(pi_command), - system_prompt_arg, - batch_escape(managed_skills_dir), + "@echo off\r\n{} {LAUNCH_MODE} {} %*\r\nexit /b %ERRORLEVEL%\r\n", + quote(executable)?, + quote(directory)? )) } -#[cfg(windows)] -fn batch_escape(value: &str) -> String { - value.replace('%', "%%").replace('"', "\"\"") -} - #[cfg(not(any(unix, windows)))] -fn launcher_script( - _pi_command: &str, - _prompt_path: Option<&Path>, - _managed_skills_dir: &Path, -) -> io::Result { +fn launcher_script(_: &Path, _: &Path) -> io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, "Pi launch overrides are unsupported on this platform", )) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn non_pi_adapter_keeps_base_prompt_for_acp_delivery() { - let base = Some("Buzz base".to_string()); - let (prepared, remaining) = - PiLaunchOverride::prepare("goose", base.clone(), Path::new("/unused/skills"), true) - .expect("prepare"); - assert!(prepared.is_none()); - assert_eq!(remaining, base); - } - - #[test] - fn pi_adapter_rejects_inherited_pi_command() { - let error = PiLaunchOverride::prepare( - "pi-acp", - Some("Buzz base".to_string()), - Path::new("/unused/skills"), - true, - ) - .err() - .expect("inherited PI_ACP_PI_COMMAND must be rejected"); - - assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - assert!(error.to_string().contains("managed by Buzz")); - } - - #[test] - fn disabled_base_prompt_still_creates_pi_skills_launcher() { - let (prepared, remaining) = - PiLaunchOverride::prepare("pi-acp", None, Path::new("/unused/skills"), false) - .expect("prepare"); - let prepared = prepared.expect("Pi skills launcher"); - assert!(remaining.is_none()); - assert!(!prepared.directory.join("SYSTEM.md").exists()); - - #[cfg(unix)] - assert!(fs::read_to_string(prepared.launcher_path()) - .expect("read launcher") - .contains("--skill '/unused/skills'")); - } - - #[test] - fn pi_adapter_moves_buzz_base_out_of_ordinary_acp_delivery() { - let base = crate::scope::SessionPolicy::Thread - .append_session_model(include_str!("base_prompt.md")); - let (prepared, remaining) = PiLaunchOverride::prepare( - "/opt/bin/pi-acp", - Some(base.clone()), - Path::new("/buzz/.agents/skills"), - false, - ) - .expect("prepare"); - let prepared = prepared.expect("Pi launcher"); - - assert!(remaining.is_none()); - assert_eq!( - fs::read_to_string(prepared.directory.join("SYSTEM.md")).expect("read prompt"), - base - ); - assert!(base.contains("each thread gets its own")); - - #[cfg(unix)] - assert!(fs::read_to_string(prepared.launcher_path()) - .expect("read launcher") - .contains("exec 'pi'")); - } - - #[cfg(unix)] - #[test] - fn pi_launcher_replaces_system_prompt_and_forwards_adapter_args() { - use std::os::unix::fs::PermissionsExt; - use std::process::Command; - - let fixture_dir = - std::env::temp_dir().join(format!("buzz-acp-pi-system-prompt-test-{}", Uuid::new_v4())); - create_private_directory(&fixture_dir).expect("create fixture dir"); - let capture_path = fixture_dir.join("args.txt"); - let fake_pi = fixture_dir.join("fake-pi"); - let managed_skills_dir = fixture_dir.join("managed skills"); - let fake_script = format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\n", - shell_quote(capture_path.as_os_str()).expect("quote capture path") - ); - write_private_file(&fake_pi, fake_script.as_bytes(), true).expect("write fake pi"); - - let prepared = PiLaunchOverride::create( - fake_pi.to_str().expect("UTF-8 fake Pi path"), - Some("Buzz base\n\n## Session Model\nThread scoped"), - &managed_skills_dir, - ) - .expect("prepare Pi launcher"); - let prompt_path = prepared.directory.join("SYSTEM.md"); - - let status = Command::new(prepared.launcher_path()) - .args(["--mode", "rpc", "--session", "/tmp/session.jsonl"]) - .status() - .expect("run launcher"); - assert!(status.success()); - assert_eq!( - fs::read_to_string(&capture_path).expect("read captured args"), - format!( - "--system-prompt\n{}\n--skill\n{}\n--mode\nrpc\n--session\n/tmp/session.jsonl\n", - prompt_path.display(), - managed_skills_dir.display(), - ) - ); - assert_eq!( - fs::read_to_string(&prompt_path).expect("read system prompt"), - "Buzz base\n\n## Session Model\nThread scoped" - ); - assert_eq!( - fs::metadata(&prompt_path) - .expect("prompt metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - assert_eq!( - fs::metadata(prepared.launcher_path()) - .expect("launcher metadata") - .permissions() - .mode() - & 0o777, - 0o700 - ); - assert_eq!( - fs::metadata(&prepared.directory) - .expect("directory metadata") - .permissions() - .mode() - & 0o777, - 0o700 - ); - - drop(prepared); - assert!(!prompt_path.exists()); - fs::remove_dir_all(fixture_dir).expect("remove fixture dir"); - } -} diff --git a/crates/buzz-acp/src/pi_launcher/native.rs b/crates/buzz-acp/src/pi_launcher/native.rs new file mode 100644 index 00000000000..49c1123dd24 --- /dev/null +++ b/crates/buzz-acp/src/pi_launcher/native.rs @@ -0,0 +1,114 @@ +//! Small synchronous launcher entry point. No relay connection or async runtime. +use super::{parse_id, LAUNCH_MODE}; +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) fn try_run() -> io::Result { + let mut args = std::env::args_os().skip(1); + if args.next().as_deref() != Some(std::ffi::OsStr::new(LAUNCH_MODE)) { + return Ok(false); + } + let directory = args + .next() + .ok_or_else(|| io::Error::other("missing Pi launcher directory"))?; + let args: Vec<_> = args.collect(); + let mut command = pi_command()?; + // pi-acp also invokes the launcher without RPC arguments for terminal login. + if args + .windows(2) + .any(|pair| pair[0] == "--mode" && pair[1] == "rpc") + { + command + .arg("--system-prompt") + .arg(select_prompt(Path::new(&directory), &args)?); + } + command + .arg("--skill") + .arg(std::env::current_dir()?.join(".agents/skills")) + .args(args); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + Err(command.exec()) + } + #[cfg(not(unix))] + { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + let status = command.status()?; + std::process::exit(status.code().unwrap_or(1)); + } +} + +fn pi_command() -> io::Result { + #[cfg(windows)] + { + // Rust does not search PATHEXT for an extensionless command. npm's Pi + // installation provides pi.cmd; let std handle its command-line quoting. + for directory in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) { + for name in ["pi.exe", "pi.cmd", "pi.bat"] { + let path = directory.join(name); + if path.is_file() { + return Ok(Command::new(path)); + } + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "Pi was not found on PATH", + )) + } + #[cfg(not(windows))] + { + Ok(Command::new("pi")) + } +} + +/// Restore IDs come from Pi's session header, not a filename convention. +/// Both pointer and header reads are bounded; missing snapshots fail closed. +pub(super) fn select_prompt(directory: &Path, args: &[OsString]) -> io::Result { + let mut restored = None; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--session" { + restored = Some( + iter.next() + .ok_or_else(|| io::Error::other("missing Pi session path"))?, + ); + } + } + let pointer = if let Some(path) = restored { + let mut header = String::new(); + BufReader::new(File::open(path)?.take(64 * 1024)).read_line(&mut header)?; + if !header.ends_with('\n') { + return Err(io::Error::other("Pi session header missing or too large")); + } + let header: serde_json::Value = serde_json::from_str(&header).map_err(io::Error::other)?; + if header["type"] != "session" { + return Err(io::Error::other("invalid Pi session header")); + } + let id = parse_id( + header["id"] + .as_str() + .ok_or_else(|| io::Error::other("missing Pi session ID"))?, + )?; + directory.join(format!("session-{id}")) + } else { + directory.join("pending") + }; + let mut token = String::new(); + File::open(pointer)?.take(128).read_to_string(&mut token)?; + let token = parse_id(&token)?; + let path = directory.join(format!("{token}.md")); + // Pi treats a nonexistent --system-prompt path as literal prompt text. + if !path.is_file() { + return Err(io::Error::other("Pi system prompt snapshot is missing")); + } + Ok(path) +} diff --git a/crates/buzz-acp/src/pi_launcher/tests.rs b/crates/buzz-acp/src/pi_launcher/tests.rs new file mode 100644 index 00000000000..288256d88f2 --- /dev/null +++ b/crates/buzz-acp/src/pi_launcher/tests.rs @@ -0,0 +1,136 @@ +use super::*; +use std::ffi::OsString; + +fn launcher() -> Arc { + PiLaunchOverride::create(Path::new("/unused/buzz-acp")).unwrap() +} + +fn restore_args(launcher: &PiLaunchOverride, id: Uuid) -> Vec { + let path = launcher.directory.join(format!("transcript-{id}.jsonl")); + write_private_file( + &path, + format!("{{\"type\":\"session\",\"id\":\"{id}\"}}\n").as_bytes(), + false, + ) + .unwrap(); + vec![ + "--mode".into(), + "rpc".into(), + "--session".into(), + path.into_os_string(), + ] +} + +#[test] +fn pi_snapshots_are_isolated_and_restore_the_original_after_another_create() { + let launcher = launcher(); + let first = launcher + .begin("first\nA") + .unwrap(); + let first_path = native::select_prompt(&launcher.directory, &[]).unwrap(); + let id = Uuid::new_v4(); + let first = first.finish(&id.to_string()).unwrap(); + let args = restore_args(&launcher, id); + let second = launcher + .begin("second\nB") + .unwrap(); + let second_path = native::select_prompt(&launcher.directory, &[]).unwrap(); + assert_ne!(first_path, second_path); + assert_eq!( + native::select_prompt(&launcher.directory, &args).unwrap(), + first_path + ); + assert!(fs::read_to_string(&first_path) + .unwrap() + .contains("A")); + drop(second); + assert!(!second_path.exists()); + assert!(first_path.exists()); // reload keeps an immutable, existing path + drop(first); + assert!(!first_path.exists()); + assert!(native::select_prompt(&launcher.directory, &args).is_err()); +} + +#[test] +fn pi_pool_workers_do_not_share_pending_prompts() { + let a = launcher(); + let b = launcher(); + let _a = a.begin("worker A").unwrap(); + let _b = b.begin("worker B").unwrap(); + assert_eq!( + fs::read_to_string(native::select_prompt(&a.directory, &[]).unwrap()).unwrap(), + "worker A" + ); + assert_eq!( + fs::read_to_string(native::select_prompt(&b.directory, &[]).unwrap()).unwrap(), + "worker B" + ); +} + +#[test] +fn pi_aborted_and_invalid_session_creates_clean_up() { + let launcher = launcher(); + let pending = launcher.begin("aborted").unwrap(); + let path = native::select_prompt(&launcher.directory, &[]).unwrap(); + drop(pending); + assert!(!path.exists()); + assert!(!launcher.directory.join("pending").exists()); + assert!(launcher + .begin("invalid") + .unwrap() + .finish("../../outside") + .is_err()); + assert!(!launcher.directory.join("pending").exists()); + assert!( + !fs::read_dir(&launcher.directory).unwrap().any(|entry| entry + .unwrap() + .path() + .extension() + .is_some_and(|ext| ext == "md")) + ); +} + +#[test] +fn pi_restore_never_falls_back_to_another_pending_prompt() { + let launcher = launcher(); + let args = restore_args(&launcher, Uuid::new_v4()); + let _pending = launcher.begin("unrelated session").unwrap(); + assert!(native::select_prompt(&launcher.directory, &args).is_err()); + assert!(native::select_prompt(&launcher.directory, &["--session".into()]).is_err()); +} + +#[test] +fn pi_missing_snapshot_is_an_error_not_literal_path_text() { + let launcher = launcher(); + let _pending = launcher.begin("instructions").unwrap(); + fs::remove_file(native::select_prompt(&launcher.directory, &[]).unwrap()).unwrap(); + assert!(native::select_prompt(&launcher.directory, &[]).is_err()); +} + +#[cfg(unix)] +#[test] +fn pi_launcher_quotes_paths_and_prompt_files_are_private() { + use std::os::unix::fs::PermissionsExt; + let script = launcher_script( + Path::new("/path with 'quotes'/buzz-acp"), + Path::new("/private directory"), + ) + .unwrap(); + assert!(script.contains("'\"'\"'")); + assert!(script.ends_with(" \"$@\"\n")); + let launcher = launcher(); + let _pending = launcher.begin("secret memory").unwrap(); + let path = native::select_prompt(&launcher.directory, &[]).unwrap(); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&launcher.directory) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index dbeafedda70..4dd644e5acc 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -118,6 +118,8 @@ pub struct ChannelDeliveryState { /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { + /// Immutable native Pi prompts, released with the corresponding session. + pi_prompts: HashMap, /// session scope → session_id pub sessions: HashMap, pub heartbeat_session: Option, @@ -160,6 +162,9 @@ impl SessionState { self.invalidate_scope(scope); } PromptSource::Heartbeat => { + if let Some(id) = &self.heartbeat_session { + self.pi_prompts.remove(id); + } self.heartbeat_session = None; self.heartbeat_turn_count = 0; self.heartbeat_standing_context_sent = false; @@ -175,7 +180,12 @@ impl SessionState { self.canvas_sections.remove(scope); self.deliveries.remove(scope); self.scope_owner_generations.remove(scope); - self.sessions.remove(scope).is_some() + if let Some(id) = self.sessions.remove(scope) { + self.pi_prompts.remove(&id); + true + } else { + false + } } /// Invalidate every session scope belonging to `channel_id` (channel-wide @@ -206,6 +216,7 @@ impl SessionState { /// Invalidate all sessions and turn counters (e.g. after agent exit). pub fn invalidate_all(&mut self) { + self.pi_prompts.clear(); self.sessions.clear(); self.turn_counts.clear(); self.heartbeat_session = None; @@ -323,11 +334,12 @@ fn session_new_system_prompt<'a>( impl OwnedAgent { pub(crate) fn has_system_prompt_support(&self) -> bool { - has_system_prompt_support( - self.protocol_version, - &self.agent_name, - self.goose_system_prompt_supported, - ) + self.acp.has_pi_system_prompt_transport() + || has_system_prompt_support( + self.protocol_version, + &self.agent_name, + self.goose_system_prompt_supported, + ) } } @@ -1507,19 +1519,21 @@ async fn create_session_and_apply_model( ctx.session_title.as_deref(), ); - let resp = agent - .acp - .session_new_full( - &ctx.cwd, - mcp_servers, - session_new_system_prompt( - is_goose, - agent.protocol_version, - &agent.agent_name, - combined_system_prompt.as_deref(), - ), - session_title.as_deref(), + let transport = if agent.acp.has_pi_system_prompt_transport() { + combined_system_prompt + .as_deref() + .map(SystemPromptTransport::Field) + } else { + session_new_system_prompt( + is_goose, + agent.protocol_version, + &agent.agent_name, + combined_system_prompt.as_deref(), ) + }; + let mut resp = agent + .acp + .session_new_full(&ctx.cwd, mcp_servers, transport, session_title.as_deref()) .await?; if is_goose && agent.goose_system_prompt_supported != Some(false) { @@ -1726,6 +1740,12 @@ async fn create_session_and_apply_model( apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?; } + if let Some(prompt) = resp.pi_prompt.take() { + agent + .state + .pi_prompts + .insert(resp.session_id.clone(), prompt); + } Ok(resp.session_id) } @@ -10259,6 +10279,10 @@ done"# } } +#[cfg(all(test, unix))] +#[path = "pool/pi_prompt_tests.rs"] +mod pi_prompt_tests; + #[cfg(test)] mod startup_effort_tests { use super::*; diff --git a/crates/buzz-acp/src/pool/pi_prompt_tests.rs b/crates/buzz-acp/src/pool/pi_prompt_tests.rs new file mode 100644 index 00000000000..c3721281b58 --- /dev/null +++ b/crates/buzz-acp/src/pool/pi_prompt_tests.rs @@ -0,0 +1,220 @@ +use super::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; + +struct Fixture(std::path::PathBuf); +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +async fn pi_agent(fail: bool) -> (Fixture, OwnedAgent) { + let fixture = + Fixture(std::env::temp_dir().join(format!("buzz-pi-pool-test-{}", Uuid::new_v4()))); + fs::create_dir(&fixture.0).unwrap(); + let script = fixture.0.join("pi-acp"); + // This adapter fixture consumes the launcher's production pending snapshot + // at session/new. It deliberately advertises no ACP system-prompt support. + fs::write(&script, r#"#!/bin/sh +set -eu +count=0 +while IFS= read -r line; do + dir=$(dirname "$PI_ACP_PI_COMMAND") + token=$(cat "$dir/pending") + cp "$dir/$token.md" "$PI_TEST_CAPTURE/prompt-$count" + printf '%s\n' "$dir/$token.md" > "$PI_TEST_CAPTURE/path-$count" + printf '%s\n' "$line" > "$PI_TEST_CAPTURE/request-$count" + if [ "$PI_TEST_FAIL" = true ]; then + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"native launch failed"}}\n' "$count" + else + printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"00000000-0000-4000-8000-%012d"}}\n' "$count" "$count" + fi + count=$((count + 1)) +done +"#).unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).unwrap(); + let acp = AcpClient::spawn( + script.to_str().unwrap(), + &[], + &[ + ( + "PI_TEST_CAPTURE".into(), + fixture.0.to_string_lossy().into_owned(), + ), + ("PI_TEST_FAIL".into(), fail.to_string()), + ], + false, + ) + .await + .unwrap(); + let agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "pi-acp".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + (fixture, agent) +} + +async fn create( + agent: &mut OwnedAgent, + ctx: &PromptContext, + core: Option<&str>, +) -> Result { + create_session_and_apply_model( + agent, + ctx, + core, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + scope: None, + channel_type: None, + }, + ) + .await +} + +#[tokio::test] +async fn pi_session_uses_composed_system_prompt_and_skips_legacy_first_turn() { + let (fixture, mut agent) = pi_agent(false).await; + let mut ctx = tests::make_prompt_context_no_owner(); + ctx.base_prompt = Some("platform instructions".into()); + ctx.system_prompt = Some("### Quality bar\nBe precise.".into()); + ctx.team_instructions = Some("team instructions".into()); + let core = "\nsession memory\n"; + let id = create(&mut agent, &ctx, Some(core)).await.unwrap(); + let system = fs::read_to_string(fixture.0.join("prompt-0")).unwrap(); + let mut last = 0; + for section in [ + "", + "", + "", + "", + ] { + assert_eq!(system.matches(section).count(), 1, "{section}"); + let pos = system.find(section).unwrap(); + assert!(pos >= last); + last = pos; + } + assert!(system.contains("### Quality bar\nBe precise.")); + assert!(system.contains("session memory")); + let wire: serde_json::Value = + serde_json::from_str(&fs::read_to_string(fixture.0.join("request-0")).unwrap()).unwrap(); + assert!(wire["params"].get("systemPrompt").is_none()); + assert!(agent.has_system_prompt_support()); + let channel_id = Uuid::new_v4(); + let batch = crate::queue::FlushBatch { + scope: SessionScope::Conversation { channel_id }, + channel_id, + events: vec![crate::queue::BatchEvent { + event: nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello") + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(), + prompt_tag: "message".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let user = crate::queue::format_prompt( + &batch, + &crate::queue::FormatPromptArgs { + has_system_prompt_support: agent.has_system_prompt_support(), + base_prompt: ctx.base_prompt.as_deref(), + system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_core: Some(core), + ..Default::default() + }, + ) + .join("\n\n"); + assert!(user.starts_with(""), "{user}"); + for tag in [ + "", + "", + "", + "", + ] { + assert!(!user.contains(tag)); + } + let path = fs::read_to_string(fixture.0.join("path-0")).unwrap(); + assert!(std::path::Path::new(path.trim()).is_file()); + agent.state.heartbeat_session = Some(id); + agent.state.invalidate(&PromptSource::Heartbeat); + assert!(!std::path::Path::new(path.trim()).exists()); + agent.acp.shutdown().await; +} + +#[tokio::test] +async fn pi_base_disabled_still_delivers_profile_and_memory_in_system_role() { + let (fixture, mut agent) = pi_agent(false).await; + let mut ctx = tests::make_prompt_context_no_owner(); + ctx.system_prompt = Some("profile only".into()); + create(&mut agent, &ctx, Some("memory")) + .await + .unwrap(); + let system = fs::read_to_string(fixture.0.join("prompt-0")).unwrap(); + assert!(!system.contains("")); + assert!(system.contains("\nprofile only\n")); + assert!(system.contains("")); + agent.acp.shutdown().await; +} + +#[tokio::test] +async fn pi_failed_create_does_not_keep_snapshot_or_live_adapter() { + let (fixture, mut agent) = pi_agent(true).await; + let ctx = tests::make_prompt_context_no_owner(); + assert!(create(&mut agent, &ctx, None).await.is_err()); + assert!(agent.state.pi_prompts.is_empty()); + let path = fs::read_to_string(fixture.0.join("path-0")).unwrap(); + assert!(!std::path::Path::new(path.trim()).exists()); + assert!(create(&mut agent, &ctx, None).await.is_err()); +} + +#[tokio::test] +async fn pi_snapshots_follow_all_scope_invalidation_paths() { + for invalidation in ["scope", "channel", "all"] { + let (fixture, mut agent) = pi_agent(false).await; + let ctx = tests::make_prompt_context_no_owner(); + let channel_id = Uuid::new_v4(); + let scope = SessionScope::Thread { + channel_id, + root_event_id: "a".repeat(64), + }; + let sibling = SessionScope::Thread { + channel_id: Uuid::new_v4(), + root_event_id: "b".repeat(64), + }; + for scope in [&scope, &sibling] { + let id = create(&mut agent, &ctx, None).await.unwrap(); + agent.state.sessions.insert(scope.clone(), id); + } + match invalidation { + "scope" => { + agent.state.invalidate_scope(&scope); + } + "channel" => { + agent.state.invalidate_channel(&channel_id); + } + _ => agent.state.invalidate_all(), + } + let path = |index| fs::read_to_string(fixture.0.join(format!("path-{index}"))).unwrap(); + assert!(!std::path::Path::new(path(0).trim()).exists()); + assert_eq!( + std::path::Path::new(path(1).trim()).exists(), + invalidation != "all" + ); + agent.acp.shutdown().await; + } +} diff --git a/crates/buzz-acp/tests/pi_native_launcher.rs b/crates/buzz-acp/tests/pi_native_launcher.rs new file mode 100644 index 00000000000..ec92062aef9 --- /dev/null +++ b/crates/buzz-acp/tests/pi_native_launcher.rs @@ -0,0 +1,219 @@ +//! Exercise the executable entry point, not a test copy of its argument logic. +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; +use uuid::Uuid; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let result = + Self(std::env::temp_dir().join(format!("buzz-pi-native-test-{}", Uuid::new_v4()))); + fs::create_dir(&result.0).unwrap(); + result + } + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp")); + command + .arg("--internal-pi-launch") + .arg(&self.0) + .current_dir(&self.0); + command + } + fn fake_pi(&self) { + let script = self.0.join("pi"); + fs::write(&script, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n").unwrap(); + fs::set_permissions(script, fs::Permissions::from_mode(0o700)).unwrap(); + } + fn snapshot(&self, name: &str) -> PathBuf { + let token = Uuid::new_v4(); + let path = self.0.join(format!("{token}.md")); + fs::write(&path, name).unwrap(); + fs::write(self.0.join("pending"), token.to_string()).unwrap(); + path + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn native_pi_exec_forwards_arguments_and_selects_original_restore_snapshot() { + let fixture = Fixture::new(); + fixture.fake_pi(); + let original = fixture.snapshot("first"); + let output = fixture + .command() + .env("PATH", &fixture.0) + .args(["--mode", "rpc", "--no-themes"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let args = String::from_utf8(output.stdout).unwrap(); + assert_eq!( + args, + format!( + "--system-prompt\n{}\n--skill\n{}\n--mode\nrpc\n--no-themes\n", + original.display(), + fs::canonicalize(&fixture.0) + .unwrap() + .join(".agents/skills") + .display() + ) + ); + + let id = Uuid::new_v4(); + fs::write( + fixture.0.join(format!("session-{id}")), + original.file_stem().unwrap().to_str().unwrap(), + ) + .unwrap(); + let session = fixture.0.join("arbitrary-name.jsonl"); + fs::write( + &session, + format!("{{\"type\":\"session\",\"id\":\"{id}\"}}\n"), + ) + .unwrap(); + fixture.snapshot("another session"); + let output = fixture + .command() + .env("PATH", &fixture.0) + .args(["--mode", "rpc", "--session"]) + .arg(&session) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8(output.stdout) + .unwrap() + .starts_with(&format!("--system-prompt\n{}\n", original.display()))); + fs::remove_file(&original).unwrap(); + let output = fixture + .command() + .env("PATH", &fixture.0) + .args(["--mode", "rpc", "--session"]) + .arg(&session) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot is missing")); +} + +#[test] +fn native_pi_terminal_login_does_not_require_a_session_prompt() { + let fixture = Fixture::new(); + fixture.fake_pi(); + let output = fixture.command().env("PATH", &fixture.0).output().unwrap(); + assert!(output.status.success()); + assert!(!String::from_utf8(output.stdout) + .unwrap() + .contains("--system-prompt")); +} + +/// No model calls: inspect Pi's actual live state through its RPC HTML export. +/// Run with Pi on PATH: cargo test -p buzz-acp --test pi_native_launcher -- --ignored +#[test] +#[ignore = "requires the Pi CLI on PATH"] +fn real_pi_exports_the_native_system_prompt() { + use base64::Engine; + use std::io::{BufRead, BufReader, Write}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let fixture = Fixture::new(); + let prompt = "\nplatform test\n\n\n\nprofile test\n\n\n\nmemory test\n"; + let snapshot = fixture.snapshot(prompt); + let session_id = Uuid::new_v4(); + fs::write( + fixture.0.join(format!("session-{session_id}")), + snapshot.file_stem().unwrap().to_str().unwrap(), + ) + .unwrap(); + let transcript = fixture.0.join("session.jsonl"); + let timestamp = "2026-01-01T00:00:00.000Z"; + fs::write(&transcript, format!("{}\n{}\n", + serde_json::json!({"type":"session", "version":3, "id":session_id, "timestamp":timestamp, "cwd":fixture.0}), + serde_json::json!({"type":"message", "id":"00000001", "parentId":null, "timestamp":timestamp, + "message":{"role":"user", "content":[{"type":"text", "text":"\nfixture turn\n"}], "timestamp":1767225600000u64}}) + )).unwrap(); + let export = fixture.0.join("live.html"); + let mut child = fixture + .command() + .env("PI_CODING_AGENT_DIR", fixture.0.join("agent")) + .args([ + "--mode", + "rpc", + "--offline", + "--no-extensions", + "--no-context-files", + "--no-skills", + ]) + .arg("--session") + .arg(&transcript) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().take(100) { + if tx.send(line).is_err() { + break; + } + } + }); + writeln!( + stdin, + "{}", + serde_json::json!({"id":"export-test", "type":"export_html", "outputPath":export}) + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + let result = loop { + match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Ok(line)) => { + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + if value["id"] == "export-test" { + break Some(value); + } + } + _ => break None, + } + }; + let _ = child.kill(); + let _ = child.wait(); + drop(rx); + reader.join().unwrap(); + let result = result.expect("Pi did not answer export_html within 30 seconds"); + assert_eq!(result["success"], true, "{result}"); + let html = fs::read_to_string(export).unwrap(); + let start = html.find("id=\"session-data\"").unwrap(); + let data = html[start..] + .split_once('>') + .unwrap() + .1 + .split_once("") + .unwrap() + .0 + .trim(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(); + let data: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + let actual = data["systemPrompt"].as_str().unwrap(); + assert!(actual.starts_with(prompt), "{actual}"); + for tag in ["", "", ""] { + assert_eq!(actual.matches(tag).count(), 1); + } +}