diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd18179ee4..d4826d985f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,15 +48,21 @@ jobs:
- 'scripts/run-tests.sh'
- 'justfile'
desktop:
+ - 'scripts/check-file-sizes-core.mjs'
+ - 'scripts/check-file-sizes-core.test.mjs'
- 'desktop/**'
- '!desktop/src-tauri/**'
- 'pnpm-lock.yaml'
desktop-rust:
- 'desktop/src-tauri/**'
web:
+ - 'scripts/check-file-sizes-core.mjs'
+ - 'scripts/check-file-sizes-core.test.mjs'
- 'web/**'
- 'pnpm-lock.yaml'
mobile:
+ - 'scripts/check-file-sizes-core.mjs'
+ - 'scripts/check-file-sizes-core.test.mjs'
- 'mobile/**'
- 'scripts/mobile-release.sh'
- 'scripts/mobile-worktree-overrides.sh'
@@ -76,6 +82,8 @@ jobs:
scripts/test-mobile-release-candidate-publisher.sh
- name: Mobile worktree identity contract
run: scripts/test-mobile-worktree-overrides.sh
+ - name: File size ratchet unit tests
+ run: node --test scripts/check-file-sizes-core.test.mjs
rust-lint:
name: Rust Lint
@@ -130,6 +138,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
@@ -213,7 +223,7 @@ jobs:
desktop-smoke-e2e:
name: Desktop Smoke E2E (${{ matrix.shard }})
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 30
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
strategy:
@@ -751,6 +761,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Get pnpm store directory
id: pnpm-cache
@@ -784,6 +796,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Compute Hermit cache key
id: hermit-bin-hash
@@ -822,6 +836,8 @@ jobs:
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
+ - name: File size ratchet
+ run: node mobile/scripts/check-file-sizes.mjs
- name: Format check
run: cd mobile && dart format --output=none --set-exit-if-changed .
- name: Analyze
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 53ea0f11c8..db0aea637f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -98,6 +98,37 @@ Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in
upfront. If you don't use Hermit, ensure your toolchain meets the minimum
versions in the table above.
+#### Linux: Tauri system libraries
+
+Hermit pins language toolchains, not system libraries. On Linux, the desktop
+app's Rust crates link against GTK and WebKitGTK, so `just ci` (and any
+`just desktop-tauri-*` recipe) needs these installed system-wide first. On
+Debian/Ubuntu:
+
+```bash
+sudo apt-get install -y --no-install-recommends \
+ build-essential curl file libasound2-dev libayatana-appindicator3-dev \
+ libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \
+ patchelf wget
+```
+
+This is the same list CI installs (see `.github/workflows/ci.yml`), so matching
+it locally keeps your results comparable to CI. Other distributions ship these
+under different package names — see the
+[Tauri prerequisites](https://tauri.app/start/prerequisites/) for the
+equivalents.
+
+Without them, `just ci` fails partway through `just check` with a pkg-config
+error such as:
+
+```
+The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found.
+```
+
+If you're only touching the relay, CLI, or other server-side crates, you can
+skip this and run the narrower recipes instead — `just fmt-check`, `just
+clippy`, `just test-unit`, and `just test` need no GTK.
+
### First-Time Setup
```bash
diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs
index c0147baf1b..d629e6a037 100644
--- a/crates/buzz-acp/src/acp.rs
+++ b/crates/buzz-acp/src/acp.rs
@@ -20,6 +20,10 @@ use crate::usage::{TurnUsage, UsageTracker};
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB
+/// Env var that tells a goose ACP child not to start its cron scheduler.
+/// Injected unconditionally by [`AcpClient::spawn`]; see the call site for why.
+pub(crate) const GOOSE_SCHEDULER_DISABLED_ENV: &str = "GOOSE_ACP_SCHEDULER_DISABLED";
+
/// An MCP server configuration passed to `session/new`.
///
/// Corresponds to the `McpServerStdio` variant in the ACP schema.
@@ -187,6 +191,17 @@ pub struct AcpClient {
/// Other agents may leave this unset — readers must treat `None` as
/// "no active run to steer into" and fall back to cancel+merge.
active_run_id: Option,
+ /// Whether the agent advertised `_meta.steering.supported: true` in its
+ /// `initialize` response, meaning it implements the cross-adapter
+ /// [`ACP_STEER_METHOD`] extension.
+ ///
+ /// Set once by [`initialize`](Self::initialize); `false` for agents that
+ /// omit the key. This is the **only** gate on writing an
+ /// [`ACP_STEER_METHOD`] request. It must never be replaced by error-code
+ /// probing: codex-acp answers unrecognized extension methods with `{}` —
+ /// a JSON-RPC *success*, not `-32601` — which the main loop would read as
+ /// a delivered steer and drop the user's message from the queue.
+ steering_supported: bool,
/// Per-turn channel for receiving goose-native non-cancelling steer
/// requests from the main loop. Installed by
/// [`install_steer_rx`](Self::install_steer_rx) at dispatch and
@@ -344,6 +359,38 @@ pub(crate) fn build_codex_config_env(
Ok(Some(serde_json::Value::Object(base).to_string()))
}
+/// goose's non-standard mid-turn steer method. Requires `expectedRunId`, so it
+/// is only usable once a `session_info_update` has supplied
+/// `_meta.goose.activeRunId`. Emitted by goose and buzz-agent only.
+const GOOSE_STEER_METHOD: &str = "_goose/unstable/session/steer";
+
+/// The cross-adapter mid-turn steer method, shipped by claude-agent-acp
+/// (`src/acp-agent.ts:200`) and codex-acp (`src/AcpExtensions.ts:11`).
+/// Params are `{sessionId, prompt}` — no run id — and the result is
+/// `{outcome}`. Gated on [`AcpClient::steering_supported`].
+const ACP_STEER_METHOD: &str = "_session/steering";
+
+/// `outcome` value meaning the steer was applied to the turn Buzz is waiting
+/// on, which therefore keeps running.
+const STEER_OUTCOME_INJECTED: &str = "injected";
+
+/// `outcome` value meaning the turn Buzz was steering had already finished, so
+/// the adapter began a fresh turn carrying the message. Still a delivery
+/// success, but the awaited turn is over — see the steer-response arm for why
+/// this must not renew the hard deadline.
+const STEER_OUTCOME_STARTED_NEW_TURN: &str = "startedNewTurn";
+
+/// Which wire method carried an in-flight steer request, recorded so the
+/// response arm decodes the shape that method actually returns.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SteerTransport {
+ /// [`GOOSE_STEER_METHOD`] — any success result is a delivered steer.
+ Goose,
+ /// [`ACP_STEER_METHOD`] — success carries an `outcome` that must be
+ /// positively recognized before the steer counts as delivered.
+ AcpExtension,
+}
+
fn build_client_capabilities() -> serde_json::Value {
serde_json::json!({
// Signal to ACP adapters that Buzz can hand users to terminal-native
@@ -460,6 +507,16 @@ impl AcpClient {
cmd.env("CODEX_CONFIG", merged);
}
+ // Buzz-managed agents must never execute the operator's personal cron
+ // schedule. A goose ACP child starts a scheduler over the shared
+ // `schedule.json`, so a pool of N children fires every scheduled job N
+ // times — under the wrong identity and racing standalone goose.
+ //
+ // Set last, and with no operator-wins escape hatch, so it beats both a
+ // conflicting persona `extra_env` entry and any inherited parent value.
+ // Agent builds that don't recognize the variable ignore it.
+ cmd.env(GOOSE_SCHEDULER_DISABLED_ENV, "true");
+
// Spawn the agent in its own process group so SIGKILL doesn't propagate
// to the harness's own process group on Unix.
// tokio::process::Command::process_group is a stable tokio API (no extra imports needed).
@@ -494,6 +551,7 @@ impl AcpClient {
observer_agent_index: None,
observer_context: ObserverContext::default(),
active_run_id: None,
+ steering_supported: false,
steer_rx: None,
goose_usage: UsageTracker::default(),
})
@@ -536,11 +594,20 @@ impl AcpClient {
///
/// Must be called exactly once, before any other ACP method.
/// The caller may inspect `agentCapabilities` in the returned value.
+ ///
+ /// Records `_meta.steering.supported` into
+ /// [`steering_supported`](Self::steering_supported) so the read loop's steer
+ /// arm can choose [`ACP_STEER_METHOD`] for adapters that implement it.
+ /// Parsed here rather than at each call site so no caller can forget it.
pub async fn initialize(&mut self) -> Result {
// Requesting version 2 is an intentional temporary pin — we are squatting
// on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges.
let params = build_initialize_params();
let result = self.send_request("initialize", params).await?;
+ self.steering_supported = result
+ .pointer("/_meta/steering/supported")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
tracing::debug!(target: "acp::init", "initialize response: {result}");
Ok(result)
}
@@ -778,6 +845,15 @@ impl AcpClient {
self.active_run_id.as_deref()
}
+ /// Whether the agent advertised the [`ACP_STEER_METHOD`] extension at
+ /// `initialize` time (`_meta.steering.supported`).
+ ///
+ /// The read loop's steer arm reads the field directly; this accessor exists
+ /// for the supervisor's post-initialize log line.
+ pub fn steering_supported(&self) -> bool {
+ self.steering_supported
+ }
+
/// Consume and return the per-turn usage record computed from the most
/// recent `_goose/unstable/session/update` notification.
///
@@ -1219,14 +1295,18 @@ impl AcpClient {
// so the ack_tx oneshot is never leaked silently).
let mut steer_rx = self.steer_rx.take();
- // Tracks the in-flight steer write: `(request_id, ack_tx)`. While
- // `Some`, the steer arm is gated off so we don't stack writes,
+ // Tracks the in-flight steer write: `(request_id, transport, ack_tx)`.
+ // While `Some`, the steer arm is gated off so we don't stack writes,
// and a response matching `id` is routed to the ack_tx instead
- // of being treated as the prompt result. Drained on every return
- // path with `PromptCompletedNeutral` so callers are never left
- // hanging.
- let mut pending_steer: Option<(u64, tokio::sync::oneshot::Sender)> =
- None;
+ // of being treated as the prompt result. `transport` records which
+ // method was written so the response arm decodes the result shape
+ // that method actually returns. Drained on every return path with
+ // `PromptCompletedNeutral` so callers are never left hanging.
+ let mut pending_steer: Option<(
+ u64,
+ SteerTransport,
+ tokio::sync::oneshot::Sender,
+ )> = None;
let now = Instant::now();
let mut idle_deadline = now + idle_timeout;
@@ -1251,7 +1331,7 @@ impl AcpClient {
// exists). Check the classified deadline here so a steady-
// stream agent is still bounded.
if Instant::now() >= next_deadline {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
// Prompt is timing out — release the withheld event via
// PromptCompletedNeutral (no fallback signal: there is
// no in-flight turn to signal once we return, and
@@ -1286,39 +1366,64 @@ impl AcpClient {
None => None,
}
}, if pending_steer.is_none() => {
- // Selected: build steer params at write time using the
- // lexical `session_id` and the freshest `active_run_id`.
+ // Selected: choose the steer transport and build its
+ // params at write time using the lexical `session_id`
+ // and the freshest `active_run_id`.
//
// `active_run_id` is updated by `session/update`
// notifications inside this very loop; reading it here
// (rather than snapshotting at dispatch) guarantees the
// value matches what goose's run-id check will compare
- // against. If it's `None`, no `session/update` has
- // arrived yet so we cannot form a valid `expectedRunId`
- // — ack `ExpectedRunIdMissing` and drop the request
- // without writing anything. The main loop maps this to
- // the universal cancel+merge `Steer` fallback.
- match self.active_run_id.clone() {
+ // against.
+ //
+ // Transport precedence:
+ // Some(run_id) → GOOSE_STEER_METHOD. goose
+ // wins whenever a run id exists: `expectedRunId` is
+ // strictly more precise about *which* run is steered.
+ // None + steering_supported → ACP_STEER_METHOD, the
+ // cross-adapter extension (claude-agent-acp,
+ // codex-acp), which takes no run id.
+ // None + !steering_supported → write nothing and ack
+ // `ExpectedRunIdMissing`; the main loop maps this to
+ // the universal cancel+merge `Steer` fallback.
+ //
+ // The capability flag is the ONLY gate on writing
+ // ACP_STEER_METHOD. Probing an unknown method is unsafe:
+ // codex-acp answers unrecognized extension methods with
+ // `{}` — a JSON-RPC success — which would be read as a
+ // delivered steer and silently drop the user's message.
+ let prompt_block_refs: Vec<&str> =
+ req.prompt_blocks.iter().map(String::as_str).collect();
+ let selected = match (&self.active_run_id, self.steering_supported) {
+ (Some(run_id), _) => Some((
+ SteerTransport::Goose,
+ GOOSE_STEER_METHOD,
+ build_goose_steer_params(session_id, run_id, &prompt_block_refs),
+ )),
+ (None, true) => Some((
+ SteerTransport::AcpExtension,
+ ACP_STEER_METHOD,
+ build_acp_steer_params(session_id, &prompt_block_refs),
+ )),
+ (None, false) => None,
+ };
+ match selected {
None => {
tracing::warn!(
- "goose-native steer: no active_run_id at write time \
- (no session/update seen yet) — falling back to cancel+merge"
+ "steer: no active_run_id and agent did not advertise \
+ {ACP_STEER_METHOD} — falling back to cancel+merge"
);
let _ = req.ack_tx.send(crate::pool::SteerAck::Err(
crate::pool::SteerError::ExpectedRunIdMissing,
));
}
- Some(run_id) => {
+ Some((transport, method, params)) => {
let id = self.next_id;
self.next_id += 1;
- let prompt_block_refs: Vec<&str> =
- req.prompt_blocks.iter().map(String::as_str).collect();
- let params =
- build_steer_params(session_id, &run_id, &prompt_block_refs);
let msg = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
- "method": "_goose/unstable/session/steer",
+ "method": method,
"params": params,
});
tracing::debug!(
@@ -1328,11 +1433,11 @@ impl AcpClient {
);
match self.write_ndjson(&msg).await {
Ok(()) => {
- pending_steer = Some((id, req.ack_tx));
+ pending_steer = Some((id, transport, req.ack_tx));
}
Err(e) => {
tracing::warn!(
- "goose-native steer write failed: {e} — releasing withheld event"
+ "steer write failed ({method}): {e} — releasing withheld event"
);
let _ = req.ack_tx.send(crate::pool::SteerAck::Err(
crate::pool::SteerError::Transport(e.to_string()),
@@ -1351,7 +1456,7 @@ impl AcpClient {
// would catch this anyway, but firing the deadline arm
// here makes the wakeup immediate (no extra reader poll
// round-trip when stdout is idle).
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
if idle_fires_first {
@@ -1375,13 +1480,13 @@ impl AcpClient {
match read_result {
None => {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(AcpError::AgentExited);
}
Some(Err(LinesCodecError::MaxLineLengthExceeded)) => {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(AcpError::Protocol(
@@ -1389,7 +1494,7 @@ impl AcpClient {
));
}
Some(Err(e)) => {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(AcpError::Io(std::io::Error::other(e)));
@@ -1432,13 +1537,14 @@ impl AcpClient {
// share the `no method` guard.
if let Some(id) = msg.get("id") {
if msg.get("method").is_none() {
- if let Some((steer_id, _)) = pending_steer.as_ref() {
+ if let Some((steer_id, _, _)) = pending_steer.as_ref() {
if *id == serde_json::json!(*steer_id) {
// Take the ack_tx out and route the
// response. We do not return — keep
// reading until the prompt response
// arrives.
- let (_, ack_tx) = pending_steer.take().expect("just checked");
+ let (_, transport, ack_tx) =
+ pending_steer.take().expect("just checked");
let ack = if let Some(error) = msg.get("error") {
let code = error
.get("code")
@@ -1449,16 +1555,83 @@ impl AcpClient {
crate::pool::SteerError::AgentError { code, message },
)
} else {
- let renew_now = Instant::now();
- let new_deadline = renew_now + max_duration;
- if new_deadline > hard_deadline {
- hard_deadline = new_deadline;
- self.current_hard_deadline = Some(new_deadline);
- tracing::info!(
- "steer success: renewed hard deadline ({max_duration:?} from now)"
- );
+ // Success result. Whether it counts as
+ // a delivered steer — and whether the
+ // turn Buzz awaits is still running —
+ // depends on the transport.
+ let outcome = match transport {
+ // goose returns no outcome field;
+ // a success response means the
+ // steer landed in the live run.
+ SteerTransport::Goose => Some(STEER_OUTCOME_INJECTED),
+ // The outcome must be positively
+ // recognized. An unknown or absent
+ // value (codex-acp answers
+ // unrecognized ext methods with a
+ // bare `{}`) is a rejection, never
+ // a delivery — treating it as
+ // success would drop the event.
+ SteerTransport::AcpExtension => msg
+ .pointer("/result/outcome")
+ .and_then(|v| v.as_str())
+ .filter(|o| {
+ *o == STEER_OUTCOME_INJECTED
+ || *o == STEER_OUTCOME_STARTED_NEW_TURN
+ }),
+ };
+ match outcome {
+ Some(STEER_OUTCOME_STARTED_NEW_TURN) => {
+ // Delivered, but into a NEW
+ // turn: the one this read loop
+ // is awaiting had already
+ // finished. Renewing the hard
+ // deadline here would extend
+ // the clock on a settled turn,
+ // so leave it alone and let the
+ // prompt response land on its
+ // original budget.
+ tracing::info!(
+ "steer accepted as {STEER_OUTCOME_STARTED_NEW_TURN}: \
+ awaited turn had ended — hard deadline not renewed"
+ );
+ crate::pool::SteerAck::Success
+ }
+ Some(_) => {
+ let renew_now = Instant::now();
+ let new_deadline = renew_now + max_duration;
+ if new_deadline > hard_deadline {
+ hard_deadline = new_deadline;
+ self.current_hard_deadline = Some(new_deadline);
+ tracing::info!(
+ "steer success: renewed hard deadline ({max_duration:?} from now)"
+ );
+ }
+ crate::pool::SteerAck::Success
+ }
+ None => {
+ // Report the raw string when
+ // there is one, so logs read
+ // `failed` not `"failed"`;
+ // fall back to the JSON for a
+ // non-string value.
+ let reported = match msg.pointer("/result/outcome")
+ {
+ None => "".to_string(),
+ Some(serde_json::Value::String(s)) => s.clone(),
+ Some(other) => other.to_string(),
+ };
+ tracing::warn!(
+ "steer rejected: {ACP_STEER_METHOD} returned \
+ unrecognized outcome {reported} — releasing \
+ withheld event for cancel+merge"
+ );
+ crate::pool::SteerAck::Err(
+ crate::pool::SteerError::OutcomeRejected {
+ outcome: reported,
+ },
+ )
+ }
}
- crate::pool::SteerAck::Success
};
let _ = ack_tx.send(ack);
continue;
@@ -1466,13 +1639,13 @@ impl AcpClient {
}
if *id == serde_json::json!(expected_id) {
if let Some(error) = msg.get("error") {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx
.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(agent_error_from_json(error));
}
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ =
ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
@@ -1531,7 +1704,10 @@ impl AcpClient {
/// Takes `&mut self` (not `&self`) because some updates carry agent state
/// the client must observe — notably goose's `session_info_update` with
/// `_meta.goose.activeRunId`, which seeds [`active_run_id`](Self::active_run_id)
- /// so callers can target `_goose/unstable/session/steer` at the correct run.
+ /// so the steer arm can target `_goose/unstable/session/steer` at the
+ /// correct run. Agents that never emit it (claude-agent-acp, codex-acp)
+ /// leave it `None` and are steered via `_session/steering` instead, which
+ /// needs no run id.
fn handle_session_update(&mut self, msg: &serde_json::Value) -> bool {
let update = &msg["params"]["update"];
let update_type = update
@@ -1796,22 +1972,44 @@ fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::
/// matches goose's *current* run (it advances on each `session/update`).
/// See [`crate::pool::SteerRequest`] for why this is the read loop's job
/// and not the main loop's.
-fn build_steer_params(
+fn build_goose_steer_params(
session_id: &str,
expected_run_id: &str,
prompt_blocks: &[&str],
) -> serde_json::Value {
- let blocks: Vec = prompt_blocks
- .iter()
- .map(|text| serde_json::json!({ "type": "text", "text": text }))
- .collect();
serde_json::json!({
"sessionId": session_id,
"expectedRunId": expected_run_id,
- "prompt": blocks,
+ "prompt": steer_prompt_blocks(prompt_blocks),
})
}
+/// Build the params for an [`ACP_STEER_METHOD`] request.
+///
+/// Wire shape:
+/// ```json
+/// { "sessionId": "...", "prompt": [{"type":"text","text":"..."}, ...] }
+/// ```
+///
+/// Deliberately carries **no** `expectedRunId`: the cross-adapter method
+/// steers whatever turn is currently running and neither claude-agent-acp nor
+/// codex-acp emits a run id to target.
+fn build_acp_steer_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value {
+ serde_json::json!({
+ "sessionId": session_id,
+ "prompt": steer_prompt_blocks(prompt_blocks),
+ })
+}
+
+/// Render steer body strings as ACP `text` content blocks. Shared by both
+/// steer transports so the prompt shape cannot drift between them.
+fn steer_prompt_blocks(prompt_blocks: &[&str]) -> Vec {
+ prompt_blocks
+ .iter()
+ .map(|text| serde_json::json!({ "type": "text", "text": text }))
+ .collect()
+}
+
/// Build a JSON-RPC permission response with `outcome: "selected"`.
fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value {
serde_json::json!({
@@ -2653,6 +2851,77 @@ mod tests {
.expect("failed to spawn test script")
}
+ /// Spawn a script that echoes the named env vars as the child observes
+ /// them, one per line. `` means the child did not receive the var.
+ async fn spawn_and_read_child_env(
+ vars: &[&str],
+ extra_env: &[(String, String)],
+ ) -> Vec {
+ let script = vars
+ .iter()
+ .map(|var| format!("printf '%s\\n' \"${{{var}:-}}\""))
+ .collect::>()
+ .join("\n");
+ let mut client = AcpClient::spawn("bash", &["-c".into(), script], extra_env, false)
+ .await
+ .expect("failed to spawn env probe script");
+ let mut observed = Vec::with_capacity(vars.len());
+ for var in vars {
+ observed.push(
+ client
+ .reader
+ .next()
+ .await
+ .unwrap_or_else(|| panic!("child produced no output for {var}"))
+ .expect("child stdout was not readable"),
+ );
+ }
+ observed
+ }
+
+ /// Every spawned agent must be told not to run the operator's cron
+ /// schedule, without the caller having to opt in.
+ #[tokio::test]
+ async fn spawn_injects_scheduler_disabled_env_by_default() {
+ let observed = spawn_and_read_child_env(&[GOOSE_SCHEDULER_DISABLED_ENV], &[]).await;
+ assert_eq!(
+ observed,
+ vec!["true"],
+ "{GOOSE_SCHEDULER_DISABLED_ENV} must be injected into every spawn"
+ );
+ }
+
+ /// Persona config must not be able to re-enable the scheduler: this is a
+ /// correctness invariant, not an operator-tunable default, so the
+ /// injection is set after (and therefore wins over) the `extra_env` loop.
+ ///
+ /// The control var pins that `extra_env` really did reach the child, so a
+ /// pass here means the conflicting entry lost the fight rather than
+ /// `extra_env` being dropped wholesale.
+ #[tokio::test]
+ async fn spawn_scheduler_disabled_env_overrides_conflicting_extra_env() {
+ let extra_env = vec![
+ (
+ GOOSE_SCHEDULER_DISABLED_ENV.to_string(),
+ "false".to_string(),
+ ),
+ (
+ "BUZZ_ENV_PROBE_CONTROL".to_string(),
+ "delivered".to_string(),
+ ),
+ ];
+ let observed = spawn_and_read_child_env(
+ &[GOOSE_SCHEDULER_DISABLED_ENV, "BUZZ_ENV_PROBE_CONTROL"],
+ &extra_env,
+ )
+ .await;
+ assert_eq!(
+ observed,
+ vec!["true", "delivered"],
+ "a persona extra_env entry must not override {GOOSE_SCHEDULER_DISABLED_ENV}"
+ );
+ }
+
#[tokio::test]
async fn idle_timeout_fires_on_silent_process() {
let mut client = spawn_script("sleep 10").await;
@@ -3465,6 +3734,412 @@ mod tests {
}
}
+ // ── Cross-harness steer transport tests ───────────────────────────────
+ //
+ // These cover the `_session/steering` transport added alongside the
+ // goose-native method: capability capture at `initialize`, write-time
+ // transport selection, and outcome decoding. Wire-shape assertions read
+ // the actual serialized request bytes via `capture_steer_request` rather
+ // than inferring the shape from response-id routing.
+
+ /// Spawn a client whose script captures the first line written to its
+ /// stdin into `capture_path`, then emits `response` (already-serialized
+ /// JSON-RPC) and idles.
+ ///
+ /// The steer request is the first thing this read loop writes, so the
+ /// captured line IS the steer request bytes.
+ async fn spawn_steer_capture_script(
+ capture_path: &std::path::Path,
+ response: &str,
+ ) -> AcpClient {
+ let script = format!(
+ "read -r line; printf '%s' \"$line\" > {capture}; \
+ printf '%s\\n' '{response}'; sleep 10",
+ capture = capture_path.display(),
+ response = response,
+ );
+ spawn_script(&script).await
+ }
+
+ /// Drive one steer through the read loop and return
+ /// `(captured_request_bytes, ack)`.
+ ///
+ /// `capture_path` may be absent afterwards when the arm wrote nothing —
+ /// callers assert on that. The read loop is expected to exit via a
+ /// timeout or EOF; the ack is what these tests care about.
+ async fn run_one_steer(
+ client: &mut AcpClient,
+ capture_path: &std::path::Path,
+ ) -> (Option, crate::pool::SteerAck) {
+ let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1);
+ client.install_steer_rx(steer_rx);
+
+ let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::();
+ let send_task = tokio::spawn(async move {
+ steer_tx
+ .send(crate::pool::SteerRequest {
+ prompt_blocks: vec!["steer body".into()],
+ ack_tx,
+ })
+ .await
+ .expect("steer_tx send should succeed");
+ });
+
+ let idle = std::time::Duration::from_millis(800);
+ let max_dur = std::time::Duration::from_secs(10);
+ let hard_deadline = tokio::time::Instant::now() + max_dur;
+ let _ = client
+ .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur)
+ .await;
+ send_task.await.expect("send_task should complete");
+
+ let ack = ack_rx
+ .await
+ .expect("ack oneshot must have received a SteerAck");
+ (std::fs::read_to_string(capture_path).ok(), ack)
+ }
+
+ /// Unique temp path for one test's captured request bytes.
+ fn capture_path(name: &str) -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join("buzz-acp-steer-capture");
+ std::fs::create_dir_all(&dir).expect("create capture dir");
+ let path = dir.join(format!("{name}.json"));
+ let _ = std::fs::remove_file(&path);
+ path
+ }
+
+ /// Mark a client as having advertised `_meta.steering.supported` without
+ /// running a real `initialize` handshake. The capability-parsing tests
+ /// cover the handshake itself.
+ fn set_steering_supported(client: &mut AcpClient) {
+ client.steering_supported = true;
+ }
+
+ /// Run `initialize` against a script that replies with `init_result` as
+ /// the JSON-RPC result, and return the resulting `steering_supported`.
+ async fn steering_supported_after_initialize(init_result: &str) -> bool {
+ let script = format!(
+ "read -r _init; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{result}}}'; \
+ sleep 5",
+ result = init_result,
+ );
+ let mut client = spawn_script(&script).await;
+ client
+ .initialize()
+ .await
+ .expect("initialize should succeed");
+ client.steering_supported()
+ }
+
+ /// Test 1a: an adapter advertising `_meta.steering.supported: true`
+ /// (claude-agent-acp `src/acp-agent.ts:1444`, codex-acp
+ /// `src/CodexAcpServer.ts:247`) is recorded as steering-capable.
+ #[tokio::test]
+ async fn initialize_records_steering_supported_when_advertised() {
+ let supported = steering_supported_after_initialize(
+ r#"{"protocolVersion":2,"agentCapabilities":{},"_meta":{"steering":{"supported":true}}}"#,
+ )
+ .await;
+ assert!(
+ supported,
+ "_meta.steering.supported: true must set steering_supported"
+ );
+ }
+
+ /// Test 1b: no `_meta` at all (goose, buzz-agent, any older adapter) must
+ /// leave the capability off — this is what keeps a steer off the wire for
+ /// agents that never implemented it.
+ #[tokio::test]
+ async fn initialize_leaves_steering_unsupported_when_meta_absent() {
+ let supported =
+ steering_supported_after_initialize(r#"{"protocolVersion":2,"agentCapabilities":{}}"#)
+ .await;
+ assert!(
+ !supported,
+ "absent _meta must leave steering_supported false"
+ );
+ }
+
+ /// Test 1c: an explicit `supported: false` is respected, not treated as
+ /// "the key exists so it must work".
+ #[tokio::test]
+ async fn initialize_leaves_steering_unsupported_when_explicitly_false() {
+ let supported = steering_supported_after_initialize(
+ r#"{"protocolVersion":2,"_meta":{"steering":{"supported":false}}}"#,
+ )
+ .await;
+ assert!(
+ !supported,
+ "_meta.steering.supported: false must leave steering_supported false"
+ );
+ }
+
+ /// Test 2: no `active_run_id` + capability advertised → the bytes on the
+ /// wire are an `_session/steering` request carrying `sessionId` and
+ /// `prompt`, and carrying **no** `expectedRunId` (the adapters reject
+ /// unknown required fields, and there is no run id to report anyway).
+ #[tokio::test]
+ async fn acp_steer_request_omits_expected_run_id_and_carries_session_and_prompt() {
+ let capture = capture_path("acp_shape");
+ let mut client = spawn_steer_capture_script(
+ &capture,
+ r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"injected"}}"#,
+ )
+ .await;
+ set_steering_supported(&mut client);
+ assert!(
+ client.active_run_id().is_none(),
+ "precondition: no active_run_id"
+ );
+
+ let (written, ack) = run_one_steer(&mut client, &capture).await;
+
+ let written = written.expect("steer request must have been written");
+ let msg: serde_json::Value =
+ serde_json::from_str(&written).expect("written line must be valid JSON");
+ assert_eq!(
+ msg["method"].as_str(),
+ Some(ACP_STEER_METHOD),
+ "must use the cross-adapter steer method; wrote: {written}"
+ );
+ assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess-test"));
+ assert_eq!(
+ msg["params"]["prompt"][0]["text"].as_str(),
+ Some("steer body"),
+ "prompt must carry the steer body as a text block"
+ );
+ assert!(
+ msg["params"].get("expectedRunId").is_none(),
+ "_session/steering must not carry expectedRunId; wrote: {written}"
+ );
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "injected outcome must ack Success, got {ack:?}"
+ );
+ }
+
+ /// Test 3: goose keeps priority. With both an `active_run_id` and the
+ /// advertised capability, the goose method wins — `expectedRunId` is
+ /// strictly more precise about which run is being steered.
+ #[tokio::test]
+ async fn goose_transport_wins_when_both_run_id_and_capability_present() {
+ let capture = capture_path("goose_priority");
+ let mut client =
+ spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await;
+ set_steering_supported(&mut client);
+ let update = session_info_update_msg(Some(serde_json::json!("run-77")));
+ let _ = client.handle_session_update(&update);
+
+ let (written, ack) = run_one_steer(&mut client, &capture).await;
+
+ let written = written.expect("steer request must have been written");
+ let msg: serde_json::Value =
+ serde_json::from_str(&written).expect("written line must be valid JSON");
+ assert_eq!(
+ msg["method"].as_str(),
+ Some(GOOSE_STEER_METHOD),
+ "goose method must win when a run id exists; wrote: {written}"
+ );
+ assert_eq!(msg["params"]["expectedRunId"].as_str(), Some("run-77"));
+ // A bare `{}` result is a success on the goose transport (goose sends
+ // no `outcome`) — the OutcomeRejected guard applies only to
+ // `_session/steering`.
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "goose success result must ack Success, got {ack:?}"
+ );
+ }
+
+ /// Test 7: codex-acp's third outcome, `failed`
+ /// (`src/AcpExtensions.ts:92`), is a delivery rejection despite being a
+ /// JSON-RPC success — release the event and fall back.
+ #[tokio::test]
+ async fn acp_steer_failed_outcome_acks_outcome_rejected() {
+ let capture = capture_path("outcome_failed");
+ let mut client = spawn_steer_capture_script(
+ &capture,
+ r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"failed"}}"#,
+ )
+ .await;
+ set_steering_supported(&mut client);
+
+ let (_written, ack) = run_one_steer(&mut client, &capture).await;
+
+ match ack {
+ crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => {
+ assert_eq!(
+ outcome, "failed",
+ "rejected outcome must report what the agent said, unquoted"
+ );
+ }
+ other => panic!("expected Err(OutcomeRejected), got {other:?}"),
+ }
+ }
+
+ /// Test 8: **codex `extMethod` silent-loss regression guard.** codex-acp's
+ /// ext dispatcher answers unrecognized methods with a bare `{}` — a
+ /// JSON-RPC *success*, not `-32601` (`src/CodexAcpServer.ts:255-258`).
+ /// Buzz maps `SteerAck::Success` to `queue.remove_event`, so decoding
+ /// `{}` as success would delete the user's message with no error, no
+ /// fallback, and no log. An absent `outcome` must therefore be a
+ /// rejection, which releases the event and fires cancel+merge.
+ #[tokio::test]
+ async fn acp_steer_missing_outcome_acks_outcome_rejected_and_never_drops_event() {
+ let capture = capture_path("outcome_absent");
+ let mut client =
+ spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await;
+ set_steering_supported(&mut client);
+
+ let (_written, ack) = run_one_steer(&mut client, &capture).await;
+
+ match ack {
+ crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => {
+ assert_eq!(
+ outcome, "",
+ "a result with no outcome field must be reported as absent"
+ );
+ }
+ other => panic!(
+ "expected Err(OutcomeRejected) for a bare {{}} success — \
+ anything else risks dropping the event, got {other:?}"
+ ),
+ }
+ }
+
+ /// Test 5: `injected` renews the hard deadline, so the turn survives past
+ /// its original one. Mirrors
+ /// `steer_success_renews_hard_deadline_and_survives_past_original` for
+ /// the `_session/steering` transport.
+ ///
+ /// Timeline: original hard deadline at t≈1s; steer response at t≈0.5s
+ /// renews it to t≈3.5s; prompt response at t≈1.5s lands inside it.
+ #[tokio::test]
+ async fn acp_steer_injected_renews_hard_deadline_and_survives_past_original() {
+ let script = "sleep 0.5; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"injected\"}}'; \
+ sleep 1; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'";
+ let mut client = spawn_script(script).await;
+ set_steering_supported(&mut client);
+
+ let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1);
+ client.install_steer_rx(steer_rx);
+ let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::();
+ let send_task = tokio::spawn(async move {
+ steer_tx
+ .send(crate::pool::SteerRequest {
+ prompt_blocks: vec!["steer body".into()],
+ ack_tx,
+ })
+ .await
+ .expect("steer_tx send should succeed");
+ });
+
+ let idle = std::time::Duration::from_secs(10);
+ let max_dur = std::time::Duration::from_secs(3);
+ let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
+ let result = client
+ .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur)
+ .await;
+ send_task.await.expect("send_task should complete");
+
+ assert!(
+ result.is_ok(),
+ "injected must renew the deadline so the prompt response still lands, got {result:?}"
+ );
+ assert_eq!(result.unwrap()["done"], serde_json::json!(true));
+ let ack = ack_rx.await.expect("ack must be received");
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "injected must ack Success, got {ack:?}"
+ );
+ }
+
+ /// Test 6: **red/green for the no-renewal rule.** `startedNewTurn` means
+ /// the turn Buzz was steering had already ended and the adapter began a
+ /// fresh, detached one. It acks `Success` (the message WAS delivered, so
+ /// the event must not be redelivered) but must NOT renew the hard
+ /// deadline — that clock belongs to a turn which is already settled.
+ ///
+ /// Same timeline as the `injected` test, so the only difference is the
+ /// outcome string: original hard deadline at t≈1s, steer response at
+ /// t≈0.5s, prompt response at t≈1.5s. With renewal the prompt response
+ /// would land and this returns `Ok`; without renewal the original
+ /// deadline fires first and we get `HardTimeout`.
+ #[tokio::test]
+ async fn acp_steer_started_new_turn_acks_success_without_renewing_hard_deadline() {
+ let script = "sleep 0.5; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"startedNewTurn\"}}'; \
+ sleep 1; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'";
+ let mut client = spawn_script(script).await;
+ set_steering_supported(&mut client);
+
+ let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1);
+ client.install_steer_rx(steer_rx);
+ let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::();
+ let send_task = tokio::spawn(async move {
+ steer_tx
+ .send(crate::pool::SteerRequest {
+ prompt_blocks: vec!["steer body".into()],
+ ack_tx,
+ })
+ .await
+ .expect("steer_tx send should succeed");
+ });
+
+ let idle = std::time::Duration::from_secs(10);
+ let max_dur = std::time::Duration::from_secs(3);
+ let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
+ let result = client
+ .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur)
+ .await;
+ send_task.await.expect("send_task should complete");
+
+ // The original deadline must still fire — renewal here would extend
+ // the clock on a turn the adapter has already finished.
+ assert!(
+ matches!(result, Err(AcpError::HardTimeout { .. })),
+ "startedNewTurn must NOT renew the hard deadline, so the original \
+ one must still fire; got {result:?}"
+ );
+ // Delivery still succeeded, so the withheld event must be dropped
+ // rather than released — hence Success, not an Err.
+ let ack = ack_rx.await.expect("ack must be received");
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "startedNewTurn is a delivery success, got {ack:?}"
+ );
+ }
+
+ /// Test 4 (companion to the existing
+ /// `native_steer_with_no_active_run_id_acks_expected_run_id_missing`):
+ /// no run id AND no advertised capability means nothing is written at
+ /// all. This is the gate that keeps a steer off the wire for adapters
+ /// that never implemented either method.
+ #[tokio::test]
+ async fn steer_writes_nothing_when_no_run_id_and_capability_absent() {
+ let capture = capture_path("no_transport");
+ let mut client =
+ spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await;
+ assert!(!client.steering_supported(), "precondition: not advertised");
+ assert!(
+ client.active_run_id().is_none(),
+ "precondition: no active_run_id"
+ );
+
+ let (written, ack) = run_one_steer(&mut client, &capture).await;
+
+ assert!(
+ written.is_none(),
+ "no transport available must write nothing; wrote: {written:?}"
+ );
+ match ack {
+ crate::pool::SteerAck::Err(crate::pool::SteerError::ExpectedRunIdMissing) => {}
+ other => panic!("expected Err(ExpectedRunIdMissing), got {other:?}"),
+ }
+ }
+
// ── Goose usage notification integration ──────────────────────────────
/// Build a `_goose/unstable/session/update` JSON-RPC notification.
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index b11d96d8f7..d63f720c65 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1140,9 +1140,7 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool {
/// Result of a background respawn task.
struct RespawnResult {
index: usize,
- /// Tuple: (initialized client, protocol version, supports_goose_steer).
- /// The third element is always `true` — the supervisor uses
- /// try-and-tolerate for the steer extension.
+ /// Tuple: (initialized client, protocol version, agent name).
result: Result<(AcpClient, u32, String)>,
}
@@ -2228,18 +2226,18 @@ async fn tokio_main() -> Result<()> {
owner_cache.get(),
);
if let Some(signal) = signal {
- // Try-and-tolerate fork: when the mode
- // wants a Steer, attempt the non-cancelling
- // path first for any agent. On accept,
+ // Non-cancelling fork: when the mode
+ // wants a Steer, attempt the
+ // non-cancelling path first. On accept,
// withhold the queued event and spawn an
// ack watcher; the main loop's
// `PoolEvent::SteerAck` arm decides
// success/release/fallback. On reject
- // (including `-32601 method_not_found`
- // from agents that don't implement the
- // extension), fall through to the universal
- // cancel+merge `Steer` signal so the event
- // still reaches the agent.
+ // (including agents that advertise no
+ // steer transport at all), fall through
+ // to the universal cancel+merge `Steer`
+ // signal so the event still reaches the
+ // agent.
let native_attempted = matches!(signal, ControlSignal::Steer)
&& try_native_steer(
&mut pool,
@@ -2419,14 +2417,26 @@ async fn tokio_main() -> Result<()> {
event_id,
ack,
})) => {
- // Goose-native steer attempt resolved. Locked semantics
- // (Eva + Max + Perci, unanimous on Option X):
+ // Mid-turn steer attempt resolved (either transport:
+ // `_goose/unstable/session/steer` or `_session/steering`).
+ // Locked semantics (Eva + Max + Perci, unanimous on Option X):
//
// Success
// The agent received the steer via the non-cancelling
// path. Drop the withheld event so normal dispatch
// never redelivers it.
//
+ // Also covers `_session/steering`'s `startedNewTurn`
+ // outcome: the message was delivered, but into a fresh
+ // turn because the one being steered had already
+ // finished. Delivery is what this arm keys on, so the
+ // event is still dropped. The read loop deliberately
+ // does NOT renew its hard deadline in that case (the
+ // awaited turn is settled), while
+ // `extend_in_flight_deadline` below still applies —
+ // the agent really is running more work, so the
+ // channel's in-flight budget should reflect it.
+ //
// Err(_) where the write never landed (Transport /
// ExpectedRunIdMissing):
// Delivery state of the underlying message is "never
@@ -2434,6 +2444,16 @@ async fn tokio_main() -> Result<()> {
// queue front AND issue the cancel+merge fallback so
// the message still reaches the agent.
//
+ // Err(OutcomeRejected { .. })
+ // A `_session/steering` request returned a JSON-RPC
+ // success whose `outcome` was not `injected` or
+ // `startedNewTurn` (codex's `failed`, an unknown value,
+ // or a bare `{}` with no `outcome` at all). The steer
+ // did not land, so this is treated exactly like a write
+ // that never happened: release withheld AND fire the
+ // cancel+merge fallback. Handled by the catch-all
+ // `Err(_)` arm below.
+ //
// Err(AgentError { code: -32601, .. })
// The agent returned method_not_found — it does not
// implement the steer extension. Release withheld AND
@@ -2490,9 +2510,9 @@ async fn tokio_main() -> Result<()> {
Ok(pool::SteerAck::Err(pool::SteerError::AgentError { .. })) => {
(true, false, false)
}
- // Transport / ExpectedRunIdMissing: write never landed.
- // Release and fire the cancel+merge fallback so the
- // message still reaches the agent.
+ // Transport / ExpectedRunIdMissing / OutcomeRejected: the
+ // steer did not land. Release and fire the cancel+merge
+ // fallback so the message still reaches the agent.
Ok(pool::SteerAck::Err(_)) => (true, false, true),
Ok(pool::SteerAck::PromptCompletedNeutral) => (true, false, false),
Err(_recv_err) => (true, false, false),
@@ -2926,15 +2946,15 @@ fn dispatch_pending(
let ctx_clone = Arc::clone(ctx);
let agent_index = agent.index;
- // Goose-native non-cancelling steer seam: snapshot capability before
- // the agent moves into `run_prompt_task`, and install the per-turn
- // steer receiver on the read loop so the main loop's mode-gate fork
+ // Mid-turn non-cancelling steer seam: install the per-turn steer
+ // receiver on the read loop so the main loop's mode-gate fork
// (see the `if accepted && queue.is_channel_in_flight(...)` block
// in the relay event branch of the main `select!` loop) can drive
// it via the matching sender stored in `TaskMeta.steer_tx`.
- // Install the steer channel for every prompt task — the supervisor
- // uses try-and-tolerate: it attempts the steer for any agent and
- // treats `-32601 method_not_found` as "fall back to cancel+merge".
+ // Installed for every prompt task: the read loop picks the steer
+ // transport at write time from `active_run_id` and the agent's
+ // advertised `_session/steering` capability, and acks
+ // `ExpectedRunIdMissing` (→ cancel+merge) when it has neither.
let (tx, rx) = tokio::sync::mpsc::channel::(1);
agent.acp.install_steer_rx(rx);
let steer_tx = Some(tx);
@@ -3783,7 +3803,8 @@ async fn initialize_agent_pool(
.and_then(|info| info.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown"),
- "agent initialized — non-cancelling steer enabled (try-and-tolerate)"
+ steering_supported = acp.steering_supported(),
+ "agent initialized"
);
acp.observe(
"agent_initialized",
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 0c51fe954f..b1fd68d044 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -309,10 +309,13 @@ pub enum ControlSignal {
/// for that — only a function parameter pass-through.
///
/// If `active_run_id` is `None` at write time (no `session/update` seen yet
-/// — e.g. agents that never emit run-id metadata), the steer cannot form a
-/// valid `expectedRunId` and the read loop acks
-/// [`SteerError::ExpectedRunIdMissing`]. The main loop maps this to the
-/// "Err-before-pending" bucket: no withhold/mark was established at
+/// — e.g. agents that never emit run-id metadata), the goose-native method
+/// cannot form a valid `expectedRunId`, and the read loop falls back to the
+/// cross-adapter `_session/steering` method when the agent advertised
+/// `_meta.steering.supported` at `initialize`. That method takes no run id, so
+/// no freshness concern applies to it. When neither transport is available the
+/// read loop acks [`SteerError::ExpectedRunIdMissing`]. The main loop maps that
+/// to the "Err-before-pending" bucket: no withhold/mark was established at
/// `pool::send_steer` time because the request was rejected before any
/// write, so the watcher only needs to release nothing and fall back to the
/// universal `ControlSignal::Steer` cancel+merge path.
@@ -326,7 +329,8 @@ pub struct SteerRequest {
pub ack_tx: tokio::sync::oneshot::Sender,
}
-/// Why a goose-native steer failed.
+/// Why a mid-turn steer failed, on either transport
+/// (`_goose/unstable/session/steer` or `_session/steering`).
///
/// String and integer fields are intentionally `Debug`-only — read by
/// `tracing` macros in the main loop's `PoolEvent::SteerAck` arm via
@@ -349,14 +353,28 @@ pub enum SteerError {
/// Transport-level failure: write error, read EOF, JSON-RPC framing
/// violation, etc. The string carries the underlying `AcpError`'s display.
Transport(String),
- /// At steer-write time `AcpClient::active_run_id` was `None`, so the
- /// read loop couldn't form a valid `expectedRunId`. The read loop drops
- /// the request without writing anything; the main loop should release
- /// any withheld event and fall back to the universal cancel+merge
+ /// At steer-write time neither steer transport was available: no
+ /// `expectedRunId` (`AcpClient::active_run_id` was `None`, so the
+ /// goose-native method could not be formed) and the agent did not
+ /// advertise the cross-adapter `_session/steering` extension. The read
+ /// loop drops the request without writing anything; the main loop should
+ /// release any withheld event and fall back to the universal cancel+merge
/// `ControlSignal::Steer` path. This is in the same "Err-before-pending"
/// bucket as `Transport` write failures: no in-process state was
/// established, so no in-process cleanup is needed.
ExpectedRunIdMissing,
+ /// A `_session/steering` request returned a JSON-RPC *success* whose
+ /// `outcome` was not one of the two recognized delivery outcomes
+ /// (`injected`, `startedNewTurn`) — including `failed` (codex-acp) and a
+ /// missing `outcome` entirely. `outcome` carries what the agent actually
+ /// reported, for logs.
+ ///
+ /// The steer did NOT land, so the main loop must release the withheld
+ /// event and fire the cancel+merge fallback — exactly like a write that
+ /// never happened. Treating an unrecognized success as delivery would
+ /// drop the user's message: codex-acp answers unrecognized extension
+ /// methods with a bare `{}` success rather than `-32601`.
+ OutcomeRejected { outcome: String },
/// The read loop never got to dispatch the steer because the prompt
/// completed first. Delivery state for the underlying message is
/// unknown after prompt completion — the main loop must treat this as
@@ -369,7 +387,7 @@ pub enum SteerError {
PromptCompleted,
}
-/// Outcome of a goose-native steer, sent from the read loop back to the
+/// Outcome of a mid-turn steer, sent from the read loop back to the
/// main loop's ack watcher.
#[derive(Debug)]
pub enum SteerAck {
diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md
index 2e4d378203..5411e6913a 100644
--- a/deploy/charts/buzz/README.md
+++ b/deploy/charts/buzz/README.md
@@ -12,7 +12,7 @@ This chart has two operating profiles selected by values:
## Quickstart (eval only)
```sh
-helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.0 \
+helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \
--create-namespace --namespace buzz \
--set quickstart=true \
--set postgresql.enabled=true \
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 0d89b8e2d2..459fa75743 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -126,6 +126,7 @@ export default defineConfig({
"**/observer-archive-policy.spec.ts",
"**/harness-management.spec.ts",
"**/harness-catalog-screenshots.spec.ts",
+ "**/inline-custom-harness.spec.ts",
],
use: {
...devices["Desktop Chrome"],
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index f8ee32dfab..326587b87f 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -46,673 +46,8 @@ const rules = [
},
];
-// TEMP — these files exceed the 1000-line limit and are queued to be split.
-// Do not add to this list; split the file instead. Remove each entry as its
-// file is broken up. Tracked as a follow-up.
-const overrides = new Map([
- // Inherited from origin/main: #2630 (agent emoji picker search) grew this
- // file to 1026 lines with no override; this branch does not touch the file.
- // Narrow ratchet so unrelated branches stay green; queued to split upstream.
- ["src/features/agents/ui/AgentCreationPreview.tsx", 1026],
- // Native Builderlab auth/community commands add a small registration surface
- // to the existing Tauri composition root. The implementation lives in
- // builderlab.rs; this narrowly ratchets the command wiring while lib.rs is
- // queued for a broader composition-root split. Bumped for the
- // archive/unarchive/transfer community-management commands (web parity).
- ["src-tauri/src/lib.rs", 1013],
- // persona-events rebase: build_deploy_payload threads `state` for the
- // read-time relay-URL workspace fallback while keeping the create-time env
- // pin (the credential-leak guard). Load-bearing feature growth from the
- // rebase, queued to split with the rest of this list.
- // persona-refresh-on-spawn: re-snapshot + retain_managed_agent_pending call
- // in start_local_agent_with_preflight adds ~23 lines. Queued to split.
- // rebase onto main (2026-06-25): main's agents.rs grew by ~17 lines since
- // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config
- // commands add ~40 lines. Queued to split.
- // branch cut; override bumped to cover the merged total. Queued to split.
- // persona-blank-fallback: persona_snapshot_with_agent_config_fallback call
- // sites add ~4 lines (extra fallback params + inline comments). build_deploy_payload
- // fix (blank-persona provider/model fallback) adds ~6 lines. Bug fix.
- // archive/mod_tests.rs carries the full test module for archive/mod.rs:
- // unit tests + 4 real-relay integration tests (ignored, live-relay only).
- // Production logic in mod.rs is now ~527 lines (under 1000). mod_tests.rs
- // is test-only content; the override covers the test growth accumulated
- // across the local-archive + agent-metric-archive PR series. store_tests.rs
- // (~731 lines) is under 1000 so needs no override.
- ["src-tauri/src/archive/mod_tests.rs", 1208],
- // unified-agent-model 1A.1: profile reconcile split to agents_profile.rs,
- // ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
- // global-agent-config: resolve_deploy_model_provider + visibility exports
- // add ~40 lines on top of the 1A.1 ratchet. Queued to split.
- // +29 (1340 -> 1369, main): agent-config-resolver — start_local_agent_with_preflight
- // uses resolve_effective_relay_mesh_model_id at both preflight call sites;
- // preview_prospective_persona_snapshot helper extracted; orphan guard threaded
- // through restore path; start_local_agent_pairs_with_preflight resolver
- // preflight. Load-bearing feature changes; queued to split.
- // +47 (#2773): review fix — load_global_agent_config hoisted out of
- // build_managed_agent_summary into callers, dangling-harness summaries render
- // the deleted id, and spawn errors surface as sentences (tests included).
- // +1: merge of the two deltas above (actual post-merge count).
- ["src-tauri/src/commands/agents.rs", 1418],
- // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into
- // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
- // retry-safety. Load-bearing reviewer-required change; queued to split.
- // Consolidation removed the legacy persona-card import/export codecs.
- // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
- // const + build_thread_replies_filter helper, mirroring the channel sibling so
- // the two p-gate filters can't drift) plus two guard unit tests. The file was
- // already at 995; this load-bearing correctness fix crossed 1000. Not generic
- // debt growth. Approved override; queued to split with the rest of this list.
- ["src-tauri/src/commands/messages.rs", 1082],
- // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned
- // outside NEST_DIRS (it may be a symlink), so it needs its own create +
- // chmod-only-when-real-dir handling plus integration test coverage. The
- // self-contained repos_dir functions and their unit tests live in repos.rs;
- // this is the seam that must stay in nest.rs. Approved override; still queued
- // to split with the rest of this list.
- // dev-nest namespace: OnceLock
+ }
+ onCancel={() => onOpenChange(false)}
+ onSaved={(id) => {
+ // Dismiss on save as well as cancel — both exits belong to this
+ // dialog, so callers only handle the resulting selection.
+ onOpenChange(false);
+ onSaved(id);
+ }}
+ />
+
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
index 45031e0371..5425131448 100644
--- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
@@ -83,6 +83,12 @@ import {
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter";
+import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
+import {
+ ADD_CUSTOM_HARNESS_OPTION,
+ runtimeDropdownAction,
+ usePendingHarnessSelection,
+} from "./addCustomHarness";
type AgentDefinitionDialogProps = {
open: boolean;
@@ -167,6 +173,7 @@ export function AgentDefinitionDialog({
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const [hasUserChanges, setHasUserChanges] = React.useState(false);
+ const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
const {
globalConfig,
inheritedDefaults: {
@@ -308,6 +315,7 @@ export function AgentDefinitionDialog({
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
setHasUserChanges(false);
+ setIsAddHarnessOpen(false);
// isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
// [initialValues, open] effect resets both when the dialog re-opens.
}
@@ -578,6 +586,7 @@ export function AgentDefinitionDialog({
runtimes,
runtimesLoading,
});
+ runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION);
const runtimeSummaryLabel = selectedRuntime
? formatRuntimeOptionLabel(selectedRuntime)
: runtime.trim() || "Not configured";
@@ -662,9 +671,13 @@ export function AgentDefinitionDialog({
}
function handleRuntimeDropdownChange(nextValue: string) {
+ const action = runtimeDropdownAction(nextValue);
+ if (action.kind === "add-custom-harness") {
+ setIsAddHarnessOpen(true);
+ return;
+ }
setHasUserChanges(true);
- const nextRuntime =
- nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue;
+ const nextRuntime = action.runtimeId;
// The user made an explicit choice — no longer auto-seeded.
isRuntimeAutoSeededRef.current = false;
setRuntime(nextRuntime);
@@ -680,6 +693,15 @@ export function AgentDefinitionDialog({
);
}
+ // Routed through the normal change handler so a harness registered inline
+ // resets model/provider exactly as a hand-picked one would. Scoped to `open`
+ // so a pending id can't outlive the dialog that started the registration.
+ const selectSavedHarness = usePendingHarnessSelection(
+ runtimes,
+ handleRuntimeDropdownChange,
+ open,
+ );
+
function handleProviderDropdownChange(nextValue: string) {
setHasUserChanges(true);
const nextProvider =
@@ -944,6 +966,12 @@ export function AgentDefinitionDialog({
returnFocusRef={aiDefaultsTriggerRef}
/>
+
+
{isCreateMode ? createRunSection : null}
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
index 601d57f95d..f3c410e2ff 100644
--- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
@@ -83,6 +83,12 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { resolveModelFieldStatusMessage } from "./agentConfigControls";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning";
+import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
+import {
+ ADD_CUSTOM_HARNESS_OPTION,
+ runtimeDropdownAction,
+ usePendingHarnessSelection,
+} from "./addCustomHarness";
const ADVANCED_FIELDS_MOTION_TRANSITION = {
duration: 0.18,
@@ -157,6 +163,7 @@ export function AgentInstanceEditDialog({
const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? "");
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
+ const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
// Runtime selector: defaults to "custom" until the dialog opens and the
@@ -191,6 +198,7 @@ export function AgentInstanceEditDialog({
setAvatarUrl(agent.avatarUrl ?? "");
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
+ setIsAddHarnessOpen(false);
runtimeTouched.current = false;
const matched =
runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
@@ -244,6 +252,7 @@ export function AgentInstanceEditDialog({
value: selectedRuntimeId,
});
}
+ options.push(ADD_CUSTOM_HARNESS_OPTION);
return options;
}, [sortedRuntimes, selectedRuntimeId]);
@@ -484,8 +493,12 @@ export function AgentInstanceEditDialog({
}
function handleRuntimeDropdownChange(nextValue: string) {
- const nextRuntimeId =
- nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue;
+ const action = runtimeDropdownAction(nextValue);
+ if (action.kind === "add-custom-harness") {
+ setIsAddHarnessOpen(true);
+ return;
+ }
+ const nextRuntimeId = action.runtimeId;
const previousRuntimeId = selectedRuntimeId;
const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId);
@@ -532,6 +545,16 @@ export function AgentInstanceEditDialog({
);
}
+ // Routed through the normal change handler so a harness registered inline
+ // pins its command and resets model/provider like a hand-picked one. Scoped
+ // to `open` so a pending id can't outlive the dialog that started the
+ // registration.
+ const selectSavedHarness = usePendingHarnessSelection(
+ runtimes,
+ handleRuntimeDropdownChange,
+ open,
+ );
+
function handleProviderDropdownChange(nextValue: string) {
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
@@ -949,6 +972,11 @@ export function AgentInstanceEditDialog({
) : null}
+