diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml
index 2f410dadf93..3e8c03ec5cd 100644
--- a/.github/workflows/_ci-relay.yml
+++ b/.github/workflows/_ci-relay.yml
@@ -484,6 +484,19 @@ jobs:
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
ON CONFLICT (lower(host)) DO NOTHING
;"
+ - name: Git default-branch route and clone regressions
+ env:
+ # The preceding step already applied and reconciled schema/schema.sql.
+ BUZZ_TEST_SCHEMA_MODE: desired
+ BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
+ BUZZ_TEST_REDIS_URL: redis://localhost:6379
+ BUZZ_TEST_S3_ENDPOINT: http://localhost:9000
+ BUZZ_TEST_S3_BUCKET: buzz-media
+ run: |
+ cargo nextest run \
+ --archive-file target/ci/backend-integration-tests.tar.zst \
+ -E 'package(buzz-relay) and test(/api::git::settings::tests::external_infra::/)' \
+ --run-ignored ignored-only
- name: Workflow message provenance unit tests
# The relay's workflow_sink suite is not selected by the infra-free
# unit job. Its ignored database cases run in the isolated PostgreSQL
diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md
index 41d9a214bdd..3d011eb8ebb 100644
--- a/crates/buzz-acp/README.md
+++ b/crates/buzz-acp/README.md
@@ -270,6 +270,10 @@ Forum event kinds:
4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz.
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.
+ If the inbound queue overflows, the harness attempts replay for affected
+ subscriptions when capacity and relay quota permit, with at least five seconds
+ between attempts. Recovery depends on available relay history and the consumer
+ making progress; complete delivery is not guaranteed.
Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.
diff --git a/crates/buzz-acp/TESTING.md b/crates/buzz-acp/TESTING.md
new file mode 100644
index 00000000000..8c8fcbee49a
--- /dev/null
+++ b/crates/buzz-acp/TESTING.md
@@ -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 /.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.
diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs
index 0d87bca028c..cd049830688 100644
--- a/crates/buzz-acp/src/acp.rs
+++ b/crates/buzz-acp/src/acp.rs
@@ -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.
@@ -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())
@@ -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.
@@ -663,6 +677,9 @@ 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 });
@@ -670,7 +687,7 @@ impl AcpClient {
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?;
@@ -2131,9 +2148,9 @@ 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.
@@ -2141,6 +2158,8 @@ pub struct SessionNewResponse {
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),
}
@@ -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 {
- 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() {
diff --git a/crates/buzz-acp/src/acp/system_prompt_tests.rs b/crates/buzz-acp/src/acp/system_prompt_tests.rs
new file mode 100644
index 00000000000..b90893e8f50
--- /dev/null
+++ b/crates/buzz-acp/src/acp/system_prompt_tests.rs
@@ -0,0 +1,154 @@
+#[tokio::test]
+async fn session_new_full_sends_pi_replacement_prompt_in_meta() {
+ 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_pi","_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::PiMeta("Buzz instructions")),
+ Some("Pi · #buzz-dev"),
+ )
+ .await
+ .expect("session_new_full should succeed");
+
+ let received = &resp.raw["_receivedRequest"];
+ assert!(received["params"].get("systemPrompt").is_none());
+ assert_eq!(
+ received["params"]["_meta"]["systemPrompt"].as_str(),
+ Some("Buzz instructions")
+ );
+ assert_eq!(
+ received["params"]["_meta"]["sessionTitle"].as_str(),
+ Some("Pi · #buzz-dev")
+ );
+}
+
+#[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 {
+ 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),
+ },
+ }
+ })
+}
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index d67d46d9a73..285c8c4db21 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -5,7 +5,6 @@ mod config;
mod engram_fetch;
mod filter;
mod observer;
-mod pi_launcher;
mod pool;
mod pool_lifecycle;
mod prompt_framing;
@@ -2746,49 +2745,6 @@ async fn tokio_main() -> Result<()> {
tracing::info!("buzz-acp starting: {}", config.summary());
- let cwd = current_working_directory()?;
- let base_prompt_content = config.base_prompt_content.take();
- let base_prompt = if config.no_base_prompt {
- None
- } else {
- // Build standing context once under the configured policy, before any
- // agent process starts. Pi consumes this through its native
- // `--system-prompt`; other ACP agents consume the same bytes through
- // session/new or legacy first-turn framing.
- Some(
- config.session_policy.append_session_model(
- base_prompt_content
- .as_deref()
- .unwrap_or(include_str!("base_prompt.md")),
- ),
- )
- };
- // 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
.then(observer::ObserverHandle::in_process);
@@ -3037,6 +2993,8 @@ async fn tokio_main() -> Result<()> {
);
}
+ let base_prompt_content = config.base_prompt_content.take();
+ let cwd = current_working_directory()?;
let ctx = Arc::new(PromptContext {
mcp_servers: build_mcp_servers(&config),
initial_message: config.initial_message.clone(),
@@ -3047,7 +3005,20 @@ async fn tokio_main() -> Result<()> {
system_prompt: config.system_prompt.clone(),
session_title: config.session_title.clone(),
team_instructions: config.team_instructions.clone(),
- base_prompt,
+ base_prompt: if config.no_base_prompt {
+ None
+ } else {
+ // Build standing context once under the configured policy, before
+ // any session/new. Both modern ACP and legacy first-turn framing
+ // consume this same assembled base (including custom base files).
+ Some(
+ config.session_policy.append_session_model(
+ base_prompt_content
+ .as_deref()
+ .unwrap_or(include_str!("base_prompt.md")),
+ ),
+ )
+ },
heartbeat_prompt: config.heartbeat_prompt.clone(),
cwd,
rest_client: relay.rest_client(),
@@ -4401,10 +4372,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(())
}
@@ -5018,6 +4985,23 @@ fn handle_prompt_result(
} else {
hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)");
}
+ } else if matches!(
+ &result.outcome,
+ PromptOutcome::Error(acp::AcpError::AgentError { code: -32002, message })
+ if message.contains("model not found")
+ ) {
+ // Retrying the same missing model cannot repair its configuration.
+ tracing::warn!(
+ channel_id = %batch.channel_id,
+ events = batch.events.len(),
+ "dead-lettering batch immediately — model not found"
+ );
+ let content = "⚠️ I couldn't process the last request: the configured model \
+ wasn't found at the provider's endpoint. Open agent settings, select a \
+ different model from the dropdown, and save your changes. Restart the agent \
+ to apply the new configuration, then re-send your request."
+ .to_string();
+ spawn_failure_notice(rest_client, &batch, content);
} else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) {
// Auth errors are non-retryable: the token won't self-repair
// between retries, so requeueing only wastes attempt slots and
@@ -11625,10 +11609,192 @@ mod error_outcome_emission_tests {
);
}
+ #[tokio::test]
+ async fn model_not_found_posts_recovery_notice_without_retrying() {
+ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let rest = relay::RestClient {
+ http: reqwest::Client::new(),
+ base_url: format!("http://{}", listener.local_addr().unwrap()),
+ keys: Keys::generate(),
+ auth_tag_json: None,
+ };
+ let keys = Keys::generate();
+ let root = nostr::EventId::from_byte_array([0xaa; 32]);
+ let parent = nostr::EventId::from_byte_array([0xbb; 32]);
+ let event = EventBuilder::new(Kind::Custom(9), "test")
+ .tags([
+ nostr::Tag::parse(["e", &root.to_hex(), "", "root"]).unwrap(),
+ nostr::Tag::parse(["e", &parent.to_hex(), "", "reply"]).unwrap(),
+ ])
+ .sign_with_keys(&keys)
+ .unwrap();
+ let channel_id = uuid::Uuid::new_v4();
+ let batch = FlushBatch {
+ channel_id,
+ scope: scope::SessionScope::Conversation { channel_id },
+ events: vec![BatchEvent {
+ event,
+ prompt_tag: "test".into(),
+ received_at: std::time::Instant::now(),
+ }],
+ cancelled_events: vec![],
+ cancel_reason: None,
+ };
+
+ let raw_error = r#"llm model not found: (gpt-6-astra) 404 Not Found: {"error_code":"NOT_FOUND","message":"'gpt-6-astra' does not exist."}"#;
+ let model_error = AcpError::AgentError {
+ code: -32002,
+ message: raw_error.to_string(),
+ };
+ let expected_error = model_error.to_string();
+ let observer = ObserverHandle::in_process();
+
+ let agent = dummy_agent(0).await;
+ let mut pool = AgentPool::from_slots(vec![None]);
+ let task_id = pool.join_set.spawn(async {}).id();
+ pool.task_map_mut().insert(
+ task_id,
+ crate::pool::TaskMeta {
+ agent_index: 0,
+ channel_id: None,
+ scope: None,
+ turn_id: "test-turn-id".to_string(),
+ recoverable_batch: None,
+ control_tx: None,
+ steer_tx: None,
+ successful_steer_deliveries: HashSet::new(),
+ },
+ );
+ let mut queue = EventQueue::new(config::DedupMode::Queue);
+ let config = test_config();
+ let mut heartbeat_in_flight = false;
+ let removed_channels = std::collections::HashSet::new();
+ let mut crash_history = vec![SlotCircuit {
+ crash_times: Vec::new(),
+ open_until: None,
+ respawn_in_flight: false,
+ }];
+ let (respawn_tx, _respawn_rx) = mpsc::channel(8);
+ let mut respawn_tasks = tokio::task::JoinSet::new();
+ let result = PromptResult {
+ agent,
+ source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }),
+ turn_id: "test-turn-id".to_string(),
+ outcome: PromptOutcome::Error(model_error),
+ batch: Some(batch),
+ };
+ handle_prompt_result(
+ &mut pool,
+ &mut queue,
+ &config,
+ result,
+ &mut heartbeat_in_flight,
+ &removed_channels,
+ &mut crash_history,
+ &respawn_tx,
+ &mut respawn_tasks,
+ Some(observer.clone()),
+ Some(&rest),
+ );
+
+ // The batch must not be requeued: pending_channels returns 0.
+ assert_eq!(
+ queue.pending_channels(),
+ 0,
+ "model-not-found must stop immediately — batch must not be requeued"
+ );
+ assert_eq!(
+ queue.queued_event_count(channel_id),
+ 0,
+ "model-not-found must stop immediately — no events should be pending"
+ );
+
+ assert!(
+ pool.agents_mut()[0].is_some(),
+ "healthy process remains reusable"
+ );
+ assert!(respawn_tasks.is_empty());
+ let errors: Vec<_> = observer
+ .snapshot()
+ .into_iter()
+ .filter(|event| event.kind == "turn_error")
+ .collect();
+ assert_eq!(errors.len(), 1);
+ assert_eq!(errors[0].payload["code"], -32002);
+ assert_eq!(errors[0].payload["error"], expected_error);
+
+ // Capture the real signed notice sent by handle_prompt_result, without a live relay.
+ let notice: nostr::Event = tokio::time::timeout(Duration::from_secs(3), async {
+ let (socket, _) = listener.accept().await.unwrap();
+ let mut reader = BufReader::new(socket);
+ let mut line = String::new();
+ reader.read_line(&mut line).await.unwrap();
+ assert_eq!(line, "POST /events HTTP/1.1\r\n");
+ let mut content_length = None;
+ for _ in 0..64 {
+ line.clear();
+ assert_ne!(reader.read_line(&mut line).await.unwrap(), 0);
+ if line == "\r\n" {
+ break;
+ }
+ if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
+ content_length = Some(value.trim().parse::().unwrap());
+ }
+ }
+ let size = content_length.expect("request Content-Length");
+ assert!(size < 65536);
+ let mut body = vec![0; size];
+ reader.read_exact(&mut body).await.unwrap();
+ reader
+ .get_mut()
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
+ .await
+ .unwrap();
+ serde_json::from_slice(&body).unwrap()
+ })
+ .await
+ .expect("failure notice must be posted on the first failure");
+ notice.verify().unwrap();
+ assert_eq!(notice.pubkey, rest.keys.public_key());
+ assert_eq!(notice.kind, Kind::Custom(9));
+ assert_eq!(
+ notice.content,
+ "⚠️ I couldn't process the last request: the configured model wasn't found at the provider's endpoint. Open agent settings, select a different model from the dropdown, and save your changes. Restart the agent to apply the new configuration, then re-send your request."
+ );
+ let tags = serde_json::to_value(¬ice.tags).unwrap();
+ assert!(tags
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|tag| tag[0] == "h" && tag[1] == channel_id.to_string()));
+ let threading = queue::parse_thread_tags(¬ice);
+ assert_eq!(threading.root_event_id, Some(root.to_hex()));
+ assert_eq!(threading.parent_event_id, Some(parent.to_hex()));
+ }
+
/// A non-auth application error (e.g. usage credits) must still follow the
/// standard requeue path so today's behavior is unchanged.
#[tokio::test]
async fn non_auth_application_error_is_requeued() {
+ assert_application_error_is_requeued(acp::AcpError::AgentError {
+ code: -32000,
+ message: "Usage credits required for 1M context".to_string(),
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn non_model_resource_not_found_is_requeued() {
+ assert_application_error_is_requeued(acp::AcpError::AgentError {
+ code: -32002,
+ message: "Resource not found: session no longer exists".to_string(),
+ })
+ .await;
+ }
+
+ async fn assert_application_error_is_requeued(error: acp::AcpError) {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
.sign_with_keys(&keys)
@@ -11646,12 +11812,6 @@ mod error_outcome_emission_tests {
cancel_reason: None,
};
- // Usage-credits error — AgentError but NOT an auth error.
- let usage_error = acp::AcpError::AgentError {
- code: -32000,
- message: "Usage credits required for 1M context".to_string(),
- };
-
let agent = dummy_agent(0).await;
let mut pool = AgentPool::from_slots(vec![None]);
let task_id = pool.join_set.spawn(async {}).id();
@@ -11683,7 +11843,7 @@ mod error_outcome_emission_tests {
agent,
source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }),
turn_id: "test-turn-id".to_string(),
- outcome: PromptOutcome::Error(usage_error),
+ outcome: PromptOutcome::Error(error),
batch: Some(batch),
};
handle_prompt_result(
diff --git a/crates/buzz-acp/src/pi_launcher.rs b/crates/buzz-acp/src/pi_launcher.rs
deleted file mode 100644
index 892500bb200..00000000000
--- a/crates/buzz-acp/src/pi_launcher.rs
+++ /dev/null
@@ -1,380 +0,0 @@
-//! 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.
-
-use std::fs::{self, OpenOptions};
-use std::io::{self, Write};
-use std::path::{Path, PathBuf};
-
-#[cfg(unix)]
-use std::ffi::OsStr;
-
-use uuid::Uuid;
-
-pub(crate) const PI_ACP_PI_COMMAND_ENV: &str = "PI_ACP_PI_COMMAND";
-
-/// 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.
-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)> {
- if crate::config::normalize_agent_command_identity(agent_command) != "pi-acp" {
- return Ok((None, base_prompt));
- }
-
- if inherited_pi_command_is_set {
- 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))
- }
-
- pub(crate) fn launcher_path(&self) -> &Path {
- &self.launcher
- }
-
- fn create(
- pi_command: &str,
- prompt: Option<&str>,
- managed_skills_dir: &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 {
- directory,
- launcher,
- };
-
- if let Some(prompt) = prompt {
- write_private_file(&prompt_path, prompt.as_bytes(), false)?;
- }
-
- let script = launcher_script(
- pi_command,
- prompt.map(|_| prompt_path.as_path()),
- managed_skills_dir,
- )?;
- write_private_file(&prepared.launcher, script.as_bytes(), true)?;
-
- Ok(prepared)
- }
-}
-
-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"
- );
- }
- }
- }
-}
-
-#[cfg(unix)]
-fn create_private_directory(path: &Path) -> io::Result<()> {
- use std::os::unix::fs::DirBuilderExt;
-
- let mut builder = fs::DirBuilder::new();
- builder.mode(0o700).create(path)
-}
-
-#[cfg(not(unix))]
-fn create_private_directory(path: &Path) -> io::Result<()> {
- fs::create_dir(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"
-}
-
-#[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(),
- };
- Ok(format!(
- "#!/bin/sh\nexec {}{} --skill {} \"$@\"\n",
- shell_quote(OsStr::new(pi_command))?,
- system_prompt_arg,
- shell_quote(managed_skills_dir.as_os_str())?,
- ))
-}
-
-#[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",
- )
- })?;
- 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),
- ))
-}
-
-#[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 {
- 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/pool.rs b/crates/buzz-acp/src/pool.rs
index dbeafedda70..250221badca 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -32,7 +32,7 @@ use uuid::Uuid;
use crate::acp::{
extract_model_config_options, extract_model_state, extract_thought_level_config_id,
model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer,
- ModelSwitchMethod, StopReason, SystemPromptTransport,
+ ModelSwitchMethod, StopReason, SystemPromptTransport, BUZZ_PI_ACP_NAME,
};
use crate::config::{compose_scoped_session_title, DedupMode, PermissionMode};
use crate::observer;
@@ -299,7 +299,7 @@ fn has_system_prompt_support(
) -> bool {
if agent_name == "goose" {
goose_system_prompt_supported == Some(true)
- } else if agent_name == CLAUDE_AGENT_ACP_NAME {
+ } else if agent_name == BUZZ_PI_ACP_NAME || agent_name == CLAUDE_AGENT_ACP_NAME {
true
} else {
protocol_version >= 2
@@ -312,7 +312,11 @@ fn session_new_system_prompt<'a>(
agent_name: &str,
prompt: Option<&'a str>,
) -> Option> {
- if is_goose || (protocol_version < 2 && agent_name != CLAUDE_AGENT_ACP_NAME) {
+ if is_goose {
+ None
+ } else if agent_name == BUZZ_PI_ACP_NAME {
+ prompt.map(SystemPromptTransport::PiMeta)
+ } else if protocol_version < 2 && agent_name != CLAUDE_AGENT_ACP_NAME {
None
} else if agent_name == CLAUDE_AGENT_ACP_NAME {
prompt.map(SystemPromptTransport::ClaudeMeta)
@@ -5485,56 +5489,7 @@ mod tests {
assert_eq!(composed, "\nbe helpful\n\n\ntick");
}
- #[test]
- fn goose_uses_system_prompt_only_after_custom_method_succeeds() {
- assert!(!has_system_prompt_support(2, "goose", None));
- assert!(!has_system_prompt_support(2, "goose", Some(false)));
- assert!(has_system_prompt_support(2, "goose", Some(true)));
- assert!(has_system_prompt_support(1, "goose", Some(true)));
- assert!(has_system_prompt_support(2, "buzz-agent", None));
- // Goose never receives system prompt via session/new (uses post-hoc method).
- assert_eq!(
- session_new_system_prompt(true, 2, "goose", Some("instructions")),
- None
- );
- // Protocol-v2 non-goose gets Field transport.
- assert_eq!(
- session_new_system_prompt(false, 2, "buzz-agent", Some("instructions")),
- Some(SystemPromptTransport::Field("instructions"))
- );
- // Protocol-v1 non-goose, non-claude gets None (legacy user-message framing).
- assert_eq!(
- session_new_system_prompt(false, 1, "codex", Some("instructions")),
- None
- );
- // claude-agent-acp gets ClaudeMeta transport regardless of protocol version.
- assert_eq!(
- session_new_system_prompt(false, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
- Some(SystemPromptTransport::ClaudeMeta("instructions"))
- );
- assert_eq!(
- session_new_system_prompt(true, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
- None,
- "goose path must never produce a transport even when agent_name matches"
- );
- }
-
- #[test]
- fn claude_agent_acp_has_system_prompt_support_regardless_of_protocol_version() {
- // claude-agent-acp declares protocolVersion:1 but supports _meta.systemPrompt;
- // has_system_prompt_support must return true so user-message framing is suppressed.
- assert!(has_system_prompt_support(1, CLAUDE_AGENT_ACP_NAME, None));
- assert!(has_system_prompt_support(2, CLAUDE_AGENT_ACP_NAME, None));
- }
-
- #[test]
- fn old_zed_adapter_name_falls_through_to_protocol_version_gate() {
- // The renamed @zed-industries package predates the _meta.systemPrompt support,
- // so it must not be treated as capable and stays on legacy user-message framing.
- let old_name = "@zed-industries/claude-code-acp";
- assert!(!has_system_prompt_support(1, old_name, None));
- assert!(has_system_prompt_support(2, old_name, None));
- }
+ include!("pool/system_prompt_tests.rs");
#[test]
fn test_initial_message_legacy_agent_without_base_is_unchanged() {
@@ -11214,3 +11169,7 @@ done"#
);
}
}
+
+#[cfg(all(test, unix))]
+#[path = "pool/pi_prompt_tests.rs"]
+mod pi_prompt_tests;
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..a918e3decb6
--- /dev/null
+++ b/crates/buzz-acp/src/pool/pi_prompt_tests.rs
@@ -0,0 +1,371 @@
+use super::*;
+use std::os::unix::fs::PermissionsExt;
+
+fn owned_pi(acp: AcpClient, protocol_version: u32) -> OwnedAgent {
+ 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: BUZZ_PI_ACP_NAME.into(),
+ goose_system_prompt_supported: None,
+ protocol_version,
+ }
+}
+
+fn fixture_dir() -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join(format!("buzz pi transport {}", Uuid::new_v4()));
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+fn script_at(dir: &std::path::Path, script: &str) -> std::path::PathBuf {
+ let path = dir.join(BUZZ_PI_ACP_NAME);
+ std::fs::write(&path, format!("#!/bin/bash\n{script}\n")).unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
+ path
+}
+
+#[tokio::test]
+async fn pi_composed_prompt_uses_meta_without_capability_negotiation() {
+ for version in [1, 2] {
+ let dir = fixture_dir();
+ let init = serde_json::json!({"jsonrpc":"2.0", "id":0, "result": {
+ "protocolVersion":version, "agentInfo":{"name":"buzz-pi-acp", "version":"fixture"}, "agentCapabilities":{}
+ }});
+ let path = script_at(
+ &dir,
+ &format!(
+ r#"
+ printf '%s\n' "$@" > '{dir}/args'
+ read -r request
+ echo '{init}'
+ read -r request
+ printf '%s\n' "$request" > '{dir}/request'
+ echo '{{"jsonrpc":"2.0","id":1,"result":{{"sessionId":"fixture"}}}}'
+ read -r request
+ "#,
+ dir = dir.display()
+ ),
+ );
+ let mut acp = AcpClient::spawn(path.to_str().unwrap(), &[], &[], false)
+ .await
+ .unwrap();
+ acp.initialize().await.unwrap();
+ let mut agent = owned_pi(acp, version);
+ assert!(agent.has_system_prompt_support());
+ let mut ctx = tests::make_prompt_context_no_owner();
+ ctx.base_prompt = Some("BUZZ_BASE".into());
+ ctx.system_prompt = Some("BUZZ_PERSONA".into());
+ ctx.team_instructions = Some("BUZZ_TEAM".into());
+ ctx.session_title = Some("Pi fixture".into());
+ let core = "BUZZ_CORE";
+ let canvas = "BUZZ_CANVAS";
+ create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ Some(core),
+ NewSessionChannelContext {
+ huddle_instructions: Some("BUZZ_HUDDLE"),
+ canvas: Some(canvas),
+ name: Some("channel"),
+ scope: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .unwrap();
+ agent.acp.shutdown().await;
+ let request: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(dir.join("request")).unwrap()).unwrap();
+ let params = &request["params"];
+ assert!(params.get("systemPrompt").is_none());
+ assert!(params["_meta"]["sessionTitle"]
+ .as_str()
+ .unwrap()
+ .contains("Pi fixture"));
+ let standing = crate::queue::StandingContext {
+ 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),
+ huddle_instructions: Some("BUZZ_HUDDLE"),
+ agent_canvas: Some(canvas),
+ };
+ let user = prepend_standing_for_legacy(2, &standing, "EVENT");
+ for marker in [
+ "BUZZ_BASE",
+ "BUZZ_PERSONA",
+ "BUZZ_TEAM",
+ "BUZZ_CORE",
+ "BUZZ_HUDDLE",
+ "BUZZ_CANVAS",
+ ] {
+ assert_eq!(
+ params["_meta"]["systemPrompt"]
+ .as_str()
+ .unwrap()
+ .matches(marker)
+ .count(),
+ 1
+ );
+ assert!(!user.contains(marker));
+ }
+
+ let args = std::fs::read_to_string(dir.join("args")).unwrap();
+ assert_eq!(
+ args,
+ format!(
+ "--\n--skill\n{}\n",
+ std::env::current_dir()
+ .unwrap()
+ .join(".agents/skills")
+ .display()
+ )
+ );
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+}
+
+#[tokio::test]
+async fn pi_launch_preserves_existing_skills_in_explicit_workspace() {
+ const FIXTURE_ENV: &str = "BUZZ_TEST_PI_LAUNCH_WORKSPACE";
+ if let Some(dir) = std::env::var_os(FIXTURE_ENV) {
+ let path = std::path::PathBuf::from(dir).join(BUZZ_PI_ACP_NAME);
+ let mut client = AcpClient::spawn(
+ path.to_str().unwrap(),
+ &["--".into(), "--skill".into(), "/extra skills".into()],
+ &[],
+ false,
+ )
+ .await
+ .unwrap();
+ tokio::time::timeout(std::time::Duration::from_secs(5), client.initialize())
+ .await
+ .unwrap()
+ .unwrap_err();
+ client.shutdown().await;
+ return;
+ }
+
+ let dir = fixture_dir();
+ let workspace = dir.join("chosen workspace");
+ std::fs::create_dir_all(workspace.join(".agents/skills")).unwrap();
+ // Canonicalize macOS's /var -> /private/var before comparing with getcwd.
+ let workspace = workspace.canonicalize().unwrap();
+ script_at(
+ &dir,
+ r#"printf '%s\n' "$@" > "$(dirname "$0")/args"
+pwd -P > "$(dirname "$0")/cwd""#,
+ );
+ // Re-enter only this test in a separate process so parallel tests never
+ // share a mutated CWD. This models Desktop setting its harness child's CWD.
+ let output = tokio::time::timeout(
+ std::time::Duration::from_secs(20),
+ tokio::process::Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "pool::pi_prompt_tests::pi_launch_preserves_existing_skills_in_explicit_workspace",
+ "--nocapture",
+ ])
+ .kill_on_drop(true)
+ .env(FIXTURE_ENV, &dir)
+ .current_dir(&workspace)
+ .output(),
+ )
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "child failed: {}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ assert_eq!(
+ std::fs::read_to_string(dir.join("cwd")).unwrap().trim(),
+ workspace.to_str().unwrap()
+ );
+ assert_eq!(
+ std::fs::read_to_string(dir.join("args")).unwrap(),
+ format!(
+ "--\n--skill\n/extra skills\n--skill\n{}\n",
+ workspace.join(".agents/skills").display()
+ )
+ );
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[tokio::test]
+async fn upstream_pi_acp_launch_does_not_receive_managed_skills() {
+ let dir = fixture_dir();
+ let path = dir.join("pi-acp");
+ std::fs::write(
+ &path,
+ format!(
+ r#"#!/bin/bash
+printf '%s' "$*" > '{dir}/args'
+read -r request
+echo '{{"jsonrpc":"2.0","id":0,"result":{{"protocolVersion":1,"agentInfo":{{"name":"pi-acp","version":"fixture"}},"agentCapabilities":{{}}}}}}'
+read -r request
+"#,
+ dir = dir.display()
+ ),
+ )
+ .unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
+
+ let mut client = AcpClient::spawn(path.to_str().unwrap(), &[], &[], false)
+ .await
+ .unwrap();
+ client.initialize().await.unwrap();
+ client.shutdown().await;
+
+ assert_eq!(std::fs::read_to_string(dir.join("args")).unwrap(), "");
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[tokio::test]
+#[ignore = "requires BUZZ_TEST_PI_ACP pointing to a built fork and Pi on PATH"]
+async fn real_pi_preserves_buzz_prompt_and_launch_skills_on_restore() {
+ use base64::Engine;
+ let adapter = std::env::var("BUZZ_TEST_PI_ACP").expect("set BUZZ_TEST_PI_ACP");
+ let dir = fixture_dir();
+ let home = dir.join("home");
+ let workspace = dir.join("workspace");
+ let skill = dir.join("extra skills");
+ for path in [&home, &workspace, &skill] {
+ std::fs::create_dir_all(path).unwrap();
+ }
+ std::fs::write(
+ skill.join("SKILL.md"),
+ "---\nname: buzz-fixture\ndescription: BUZZ_SKILL_MARKER\n---\nSynthetic instructions.\n",
+ )
+ .unwrap();
+ let path = script_at(&dir, &format!(
+ "export HOME='{}' PI_CODING_AGENT_DIR='{}/agent' ANTHROPIC_API_KEY=synthetic-test-key PI_ACP_PI_COMMAND=pi\nexec node '{}' \"$@\"",
+ home.display(), dir.display(), adapter.replace('\'', "'\\''")
+ ));
+ let args = vec![
+ "--".into(),
+ "--offline".into(),
+ "--no-extensions".into(),
+ "--no-context-files".into(),
+ "--skill".into(),
+ skill.to_string_lossy().into_owned(),
+ ];
+ let mut acp = AcpClient::spawn(path.to_str().unwrap(), &args, &[], false)
+ .await
+ .unwrap();
+ acp.initialize().await.unwrap();
+ let mut agent = owned_pi(acp, 1);
+ assert!(agent.has_system_prompt_support());
+ let mut ctx = tests::make_prompt_context_no_owner();
+ ctx.cwd = workspace.to_string_lossy().into_owned();
+ ctx.base_prompt = Some("BUZZ_BASE".into());
+ ctx.system_prompt = Some("BUZZ_PERSONA".into());
+ ctx.team_instructions = Some("BUZZ_TEAM".into());
+ ctx.session_title = Some("Pi fixture".into());
+ let id = create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ Some("BUZZ_CORE"),
+ NewSessionChannelContext {
+ huddle_instructions: Some("BUZZ_HUDDLE"),
+ canvas: Some("BUZZ_CANVAS"),
+ name: None,
+ scope: None,
+ channel_type: None,
+ },
+ )
+ .await
+ .unwrap();
+ let metadata = std::fs::read_dir(home.join(".pi/buzz-pi-acp/sessions"))
+ .unwrap()
+ .find_map(|entry| {
+ let contents = std::fs::read_to_string(entry.ok()?.path()).ok()?;
+ let metadata: serde_json::Value = serde_json::from_str(&contents).ok()?;
+ (metadata["session"]["sessionId"].as_str() == Some(id.as_str())).then_some(metadata)
+ })
+ .expect("adapter should persist metadata for the new session");
+ let transcript = metadata["session"]["sessionFile"].as_str().unwrap();
+ let timestamp = "2026-01-01T00:00:00.000Z";
+ std::fs::create_dir_all(std::path::Path::new(transcript).parent().unwrap()).unwrap();
+ std::fs::write(transcript, format!("{}\n{}\n",
+ serde_json::json!({"type":"session","version":3,"id":id,"timestamp":timestamp,"cwd":ctx.cwd}),
+ serde_json::json!({"type":"message","id":"00000001","parentId":null,"timestamp":timestamp,"message":{"role":"user","content":[{"type":"text","text":"fixture"}],"timestamp":1767225600000u64}})
+ )).unwrap();
+ agent
+ .acp
+ .session_new_full(
+ &ctx.cwd,
+ vec![],
+ Some(SystemPromptTransport::PiMeta("OTHER_SESSION")),
+ None,
+ )
+ .await
+ .unwrap();
+ for restart in [false, true] {
+ if restart {
+ agent.acp.shutdown().await;
+ agent.acp = AcpClient::spawn(path.to_str().unwrap(), &args, &[], false)
+ .await
+ .unwrap();
+ agent.acp.initialize().await.unwrap();
+ }
+ agent
+ .acp
+ .session_prompt_with_idle_timeout(
+ &id,
+ "/export",
+ Duration::from_secs(15),
+ Duration::from_secs(30),
+ )
+ .await
+ .unwrap();
+ let html =
+ std::fs::read_to_string(workspace.join(format!("pi-session-{id}.html"))).unwrap();
+ let encoded = html
+ .split("id=\"session-data\"")
+ .nth(1)
+ .unwrap()
+ .split_once('>')
+ .unwrap()
+ .1
+ .split("")
+ .next()
+ .unwrap()
+ .trim();
+ let data: serde_json::Value = serde_json::from_slice(
+ &base64::engine::general_purpose::STANDARD
+ .decode(encoded)
+ .unwrap(),
+ )
+ .unwrap();
+ let prompt = data["systemPrompt"].as_str().unwrap();
+ for marker in [
+ "BUZZ_BASE",
+ "BUZZ_PERSONA",
+ "BUZZ_TEAM",
+ "BUZZ_CORE",
+ "BUZZ_HUDDLE",
+ "BUZZ_CANVAS",
+ "BUZZ_SKILL_MARKER",
+ ] {
+ assert_eq!(
+ prompt.matches(marker).count(),
+ 1,
+ "{marker}, restart={restart}"
+ );
+ }
+ assert!(!prompt.contains("OTHER_SESSION"));
+ assert!(!prompt.contains("You are an expert coding assistant"));
+ }
+ agent.acp.shutdown().await;
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/crates/buzz-acp/src/pool/system_prompt_tests.rs b/crates/buzz-acp/src/pool/system_prompt_tests.rs
new file mode 100644
index 00000000000..223065decbc
--- /dev/null
+++ b/crates/buzz-acp/src/pool/system_prompt_tests.rs
@@ -0,0 +1,78 @@
+#[test]
+fn goose_uses_system_prompt_only_after_custom_method_succeeds() {
+ assert!(!has_system_prompt_support(2, "goose", None));
+ assert!(!has_system_prompt_support(2, "goose", Some(false)));
+ assert!(has_system_prompt_support(2, "goose", Some(true)));
+ assert!(has_system_prompt_support(1, "goose", Some(true)));
+ assert!(has_system_prompt_support(2, "buzz-agent", None));
+ // Goose never receives system prompt via session/new (uses post-hoc method).
+ assert_eq!(
+ session_new_system_prompt(true, 2, "goose", Some("instructions")),
+ None
+ );
+ // Protocol-v2 non-goose gets Field transport.
+ assert_eq!(
+ session_new_system_prompt(false, 2, "buzz-agent", Some("instructions")),
+ Some(SystemPromptTransport::Field("instructions"))
+ );
+ // Protocol-v1 non-goose, non-claude gets None (legacy user-message framing).
+ assert_eq!(
+ session_new_system_prompt(false, 1, "codex", Some("instructions")),
+ None
+ );
+ // claude-agent-acp gets ClaudeMeta transport regardless of protocol version.
+ assert_eq!(
+ session_new_system_prompt(false, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
+ Some(SystemPromptTransport::ClaudeMeta("instructions"))
+ );
+ assert_eq!(
+ session_new_system_prompt(true, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
+ None,
+ "goose path must never produce a transport even when agent_name matches"
+ );
+}
+
+#[test]
+fn claude_agent_acp_has_system_prompt_support_regardless_of_protocol_version() {
+ // claude-agent-acp declares protocolVersion:1 but supports _meta.systemPrompt;
+ // has_system_prompt_support must return true so user-message framing is suppressed.
+ assert!(has_system_prompt_support(1, CLAUDE_AGENT_ACP_NAME, None));
+ assert!(has_system_prompt_support(2, CLAUDE_AGENT_ACP_NAME, None));
+}
+
+#[test]
+fn old_zed_adapter_name_falls_through_to_protocol_version_gate() {
+ // The renamed @zed-industries package predates the _meta.systemPrompt support,
+ // so it must not be treated as capable and stays on legacy user-message framing.
+ let old_name = "@zed-industries/claude-code-acp";
+ assert!(!has_system_prompt_support(1, old_name, None));
+ assert!(has_system_prompt_support(2, old_name, None));
+}
+
+#[test]
+fn pi_prompt_support_uses_metadata_regardless_of_protocol_version() {
+ for version in [1, 2] {
+ assert!(has_system_prompt_support(version, BUZZ_PI_ACP_NAME, None));
+ assert_eq!(
+ session_new_system_prompt(false, version, BUZZ_PI_ACP_NAME, Some("instructions")),
+ Some(SystemPromptTransport::PiMeta("instructions"))
+ );
+ assert_eq!(
+ session_new_system_prompt(false, version, BUZZ_PI_ACP_NAME, None),
+ None
+ );
+ }
+}
+
+#[test]
+fn upstream_pi_acp_does_not_receive_fork_specific_prompt_metadata() {
+ assert!(!has_system_prompt_support(1, "pi-acp", None));
+ assert_eq!(
+ session_new_system_prompt(false, 1, "pi-acp", Some("instructions")),
+ None
+ );
+ assert_eq!(
+ session_new_system_prompt(false, 2, "pi-acp", Some("instructions")),
+ Some(SystemPromptTransport::Field("instructions"))
+ );
+}
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index 739a5670128..e1046c4d4a8 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -1167,10 +1167,8 @@ struct BgState {
/// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since).
/// Cleared per-channel after a successful resubscribe.
channel_dropped_since: HashMap,
- /// Set by the backpressure handler when the event channel is full.
- /// The main loop checks this flag and triggers a proactive resubscribe
- /// (without waiting for a disconnect) so dropped events are replayed.
- proactive_resubscribe_needed: bool,
+ /// Rate/fairness bookkeeping only; replay cursors retain baseline semantics.
+ recovery: recovery::RecoverySchedule,
/// Unix timestamp captured just before the relay connection was established.
/// Used as the floor `since` for membership notification replay so events
/// predating this session are never re-delivered.
@@ -1244,7 +1242,7 @@ impl BgState {
membership_sub_active: false,
observer_control_sub_active: false,
channel_dropped_since: HashMap::new(),
- proactive_resubscribe_needed: false,
+ recovery: recovery::RecoverySchedule::default(),
startup_watermark: None,
subscribe_since: HashMap::new(),
rate_limit_gate: None,
@@ -1305,6 +1303,9 @@ impl BgState {
/// Prevents stale replay on re-subscribe and avoids unbounded state growth
/// for channels that are removed and never re-added.
fn clear_channel_state(&mut self, channel_id: &Uuid) {
+ self.recovery
+ .last_attempt
+ .remove(&channel_sub_id(*channel_id));
self.last_seen.remove(channel_id);
self.subscribe_since.remove(channel_id);
self.channel_dropped_since.remove(channel_id);
@@ -1833,82 +1834,6 @@ async fn run_background_task(
let mut drain_pacing_next: Option = None;
loop {
- if state.proactive_resubscribe_needed {
- state.proactive_resubscribe_needed = false;
- info!("proactive resubscribe triggered by backpressure event loss");
- // Proactive resubscribe runs on the EXISTING socket — do NOT clear the
- // rate-limit gate or pending queues.
- match resubscribe_after_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &agent_pubkey_hex,
- false, // existing socket — preserve gate state
- )
- .await
- {
- ResubscribeResult::Ok => {}
- ResubscribeResult::Shutdown => return,
- ResubscribeResult::RetryConnection => {
- warn!("proactive resubscribe had failures — triggering reconnect");
- let _ = event_tx.try_send(None);
- match try_autonomous_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &keys,
- &relay_url,
- &agent_pubkey_hex,
- &event_tx,
- &observer_control_tx,
- auth_tag.as_ref(),
- )
- .await
- {
- ReconnectOutcome::Ok => {
- if matches!(
- drain_post_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &agent_pubkey_hex
- )
- .await,
- ReconnectOutcome::Shutdown
- ) {
- return;
- }
- }
- ReconnectOutcome::Shutdown => return,
- ReconnectOutcome::Failed => {
- if matches!(
- wait_for_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &keys,
- &relay_url,
- &agent_pubkey_hex,
- &event_tx,
- &observer_control_tx,
- true,
- auth_tag.as_ref(),
- )
- .await,
- ReconnectOutcome::Shutdown
- ) {
- return;
- }
- }
- }
- ping_sent = false;
- last_pong = Instant::now();
- connected_since = Instant::now();
- stable_logged = false;
- }
- }
- }
-
// Drain pending subs, one REQ per pacing tick within the relay's
// admission window.
let drain_window_open = drain_pacing_next.is_none_or(|t| tokio::time::Instant::now() >= t);
@@ -1989,7 +1914,11 @@ async fn run_background_task(
}
}
+ let recovery_at = recovery::ready_at(&mut state);
tokio::select! {
+ _ = recovery::ready(&event_tx, recovery_at) => {
+ recovery::recover_one(&mut ws, &mut state, &event_tx, &agent_pubkey_hex).await;
+ }
raw = ws.next() => {
// Determine if the socket is lost.
let socket_lost = match raw {
@@ -2365,12 +2294,10 @@ async fn handle_ws_message(
// replay starts early enough to re-deliver it.
state.membership_dropped_since =
Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts)));
- // Proactively trigger resubscribe without waiting for a disconnect.
- state.proactive_resubscribe_needed = true;
warn!(
channel_id = %channel_uuid,
ts,
- "membership notification dropped (backpressure) — proactive resubscribe queued"
+ "membership notification dropped (backpressure) — targeted recovery pending"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => return false,
@@ -2407,12 +2334,10 @@ async fn handle_ws_message(
.entry(channel_id)
.and_modify(|d| *d = (*d).min(ts))
.or_insert(ts);
- // Proactively trigger resubscribe without waiting for a disconnect.
- state.proactive_resubscribe_needed = true;
warn!(
channel_id = %channel_id,
ts,
- "event channel full — dropping event for channel {channel_id} — proactive resubscribe queued"
+ "event channel full — dropping event for channel {channel_id} — targeted recovery pending"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
@@ -4255,6 +4180,11 @@ async fn wait_for_any_ok(
}
}
+mod recovery;
+
+#[cfg(test)]
+mod recovery_tests;
+
#[cfg(test)]
mod tests {
use super::*;
@@ -4782,7 +4712,7 @@ mod tests {
.expect("signing should succeed")
}
- async fn test_ws_pair() -> (WsStream, WebSocketStream) {
+ pub(super) async fn test_ws_pair() -> (WsStream, WebSocketStream) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test websocket");
@@ -4799,7 +4729,7 @@ mod tests {
(client, server.await.expect("join test websocket server"))
}
- async fn next_test_frame(
+ pub(super) async fn next_test_frame(
server: &mut WebSocketStream,
) -> serde_json::Value {
let message = timeout(Duration::from_secs(1), server.next())
@@ -5042,14 +4972,14 @@ mod tests {
));
}
- fn test_channel_filter() -> ChannelFilter {
+ pub(super) fn test_channel_filter() -> ChannelFilter {
ChannelFilter {
kinds: Some(vec![9]),
require_mention: false,
}
}
- fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) {
+ pub(super) fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) {
apply_command_to_state(
state,
RelayCommand::Subscribe {
diff --git a/crates/buzz-acp/src/relay/recovery.rs b/crates/buzz-acp/src/relay/recovery.rs
new file mode 100644
index 00000000000..62da119a4be
--- /dev/null
+++ b/crates/buzz-acp/src/relay/recovery.rs
@@ -0,0 +1,132 @@
+//! Overflow recovery is an attempted replay, not an EOSE/consumer receipt.
+//! Keep the existing IDs and cursor retirement rules; bound when work is sent.
+use super::*;
+
+pub(super) const RECOVERY_INTERVAL: Duration = Duration::from_secs(5);
+
+#[derive(Default)]
+pub(super) struct RecoverySchedule {
+ next_attempt: Option,
+ pub(super) last_attempt: HashMap,
+}
+
+/// Attempt at most one affected subscription, with space for replay to arrive.
+/// Failed writes retain the loss cursor and are paced too. No EOSE is interpreted
+/// as completion: overlapping requests keep their existing stable wire IDs.
+pub(super) async fn recover_one(
+ ws: &mut WsStream,
+ state: &mut BgState,
+ event_tx: &mpsc::Sender
- {agent?.pubkey ?? "No agent selected"}
+ {agent
+ ? (agentNpub ?? UNAVAILABLE_KEY_LABEL)
+ : "No agent selected"}
- {agent ? (
-
+ {agent && agentNpub ? (
+
) : null}
diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx
index 7db7c32bfa5..89588b57567 100644
--- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx
+++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx
@@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { UserSearchResult } from "@/shared/api/types";
-import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
+import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
- truncatePubkey(user.pubkey)
+ truncateNpub(user.pubkey)
);
}
diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx
index 400f93af7c5..d910bfc8a6f 100644
--- a/desktop/src/features/agents/ui/RespondToField.tsx
+++ b/desktop/src/features/agents/ui/RespondToField.tsx
@@ -4,7 +4,8 @@ import {
mergeAllowlist,
parsePubkeyInput,
} from "@/features/agents/lib/respondToAllowlist";
-import { truncatePubkey } from "@/shared/lib/pubkey";
+import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils";
+import { truncateNpub } from "@/shared/lib/pubkey";
import { PubKey } from "@/shared/ui/PubKey";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
@@ -59,7 +60,7 @@ function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
- truncatePubkey(user.pubkey)
+ truncateNpub(user.pubkey)
);
}
@@ -69,7 +70,7 @@ function formatSearchUserSecondary(user: UserSearchResult) {
if (displayName && nip05Handle) {
return nip05Handle;
}
- return truncatePubkey(user.pubkey);
+ return truncateNpub(user.pubkey);
}
const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
@@ -278,8 +279,6 @@ export function CreateAgentRespondToField({
);
}
-const HEX_64_RE = /^[0-9a-f]{64}$/i;
-
function AllowlistPicker({
allowlist,
deferredQuery,
@@ -325,10 +324,12 @@ function AllowlistPicker({
}) {
const isPersona = variant === "persona";
- // Detect if the query is a valid hex pubkey that's not already in the list.
- const queryIsHexPubkey =
- HEX_64_RE.test(deferredQuery) &&
- !allowlist.some((p) => p.toLowerCase() === deferredQuery.toLowerCase());
+ // Detect if the query is a pubkey (npub or hex) not already in the list;
+ // direct entry offers the canonical hex for storage.
+ const queryPubkey = parseCanonicalPubkey(deferredQuery);
+ const queryIsDirectPubkey =
+ queryPubkey !== null &&
+ !allowlist.some((p) => p.toLowerCase() === queryPubkey);
return (
))}
- ) : queryIsHexPubkey ? (
+ ) : queryIsDirectPubkey ? (