Skip to content

fix(acp): state the reply destination in mid-turn steer prompts - #6633

Draft
odedlaz wants to merge 2 commits into
block:mainfrom
odedlaz:bob/acp-native-steer-anchor
Draft

fix(acp): state the reply destination in mid-turn steer prompts#6633
odedlaz wants to merge 2 commits into
block:mainfrom
odedlaz:bob/acp-native-steer-anchor

Conversation

@odedlaz

@odedlaz odedlaz commented Aug 23, 2026

Copy link
Copy Markdown

buzz-acp has two mid-turn steer transports and only one told the agent where to
reply.

The cancel+merge fallback builds its prompt through queue::format_prompt, so it
states the thread scope and a --reply-to anchor. The native path hand-assembled
its body from framing plus one event block and called neither
format_context_hints nor resolve_reply_anchor. The agent got no destination at
all, which left the previous subject's thread as the only live one in its
context — so a message steered in from thread B got answered in thread A.

That is the default path. --multiple-event-handling defaults to steer
(config.rs) and native is attempted first, with cancel+merge as the failure
fallback — an unadvertised steer transport, but also transport failure, a missing
run ID, a rejected cross-adapter outcome, or method-not-found. It is also not
goose-specific: the read loop picks between _goose/unstable/session/steer and the cross-adapter
_session/steering at write time, and both carry the same body. Any adapter
advertising _meta.steering.supported is affected, including claude-agent-acp
— Claude Code sessions are in the affected population.

Regressed in e567491a1 (#1160), which introduced try_native_steer.

The fix

queue::format_native_steer_prompt owns the whole body. native_steer_framing
and format_event_block are now private to queue, so the hand-assembly this
replaced no longer compiles outside the module: a caller wanting the shared
framing has to come through here.

resolve_reply_anchor absorbed the DM branch format_prompt had inline, so both
transports resolve the anchor through one function instead of two copies of the
same rule. Duplicating that rule is how this defect happened, so the fix folds it
rather than adding a third copy.

Standing context, channel name and description, profile labels and conversation
history stay omitted: a steer is a delta into a live turn, the turn usually holds
them already, and re-sending them defeats the delta. Where it does not hold them,
the hints tell the agent to fetch rather than claiming they were delivered. Only
per-event routing context is added.

Two constraints shaped it:

  • Identity is unavailable here. The native path is synchronous on the main
    event loop; the only producer of a profile lookup is an async relay query with
    no cache. turn_is_human_facing treats an unknown identity as human, so every
    non-DM native steer is anchored — including the agent-to-agent ones the
    fallback deliberately leaves free to nest deeply. Accepted: losing a human's
    reply destination is the worse of the two failures, and the alternative is a
    network call on the hot path.

  • Unresolved metadata has no safe native reading. The author gate needs
    fail-closed (unresolved ⇒ treat as DM, so allowlist/anyone modes cannot be
    exercised inside an unclassified channel). Native steering cannot fail open to
    a guess: the non-DM rule points a DM reply at its conversation root instead of
    the message being answered and forces an anchor on a top-level DM that should
    have none, the DM rule makes the inverse mistakes in a channel, and an
    anchorless prompt reproduces the bug above. DmClassification is therefore a
    closed Dm | NonDm | Unresolved; gates_as_dm answers for every state while
    native_steer_scope yields Option<NativeSteerScope> over the resolved states
    only, so the native path declines rather than guesses and the caller's
    cancel+merge fallback takes the event, resolving the channel again at flush
    time. If that retry also fails the fallback keeps its pre-existing non-DM
    default, which is the remaining limit and predates this PR. classify_dm
    resolves the channel once because ChannelInfoResolver does not cache the unresolved
    case and a second call would pay a second lazy REST fetch on the main loop for
    exactly the channels that already failed. is_dm_channel is a wrapper over the
    gate projection; its behaviour, tests and setup_mode.rs caller are unchanged.

    Choosing a reading is not left to the call site: production callers hand the
    whole classification to try_native_steer, which declines or yields a resolved
    scope, and native_steer_prompt_blocks turns that scope into the formatter's
    boolean. Handing the formatter the gate's reading previously compiled and
    passed every test.

pool.send_steer, the synchronous mark_native_steer_pending, and the watcher
spawn keep their existing order.

One inherited subtlety worth stating, since the steer body now surfaces it:
Scope: channel means no marked reply tag, not that the event carries no e
tags. parse_thread_tags delegates to buzz_core::nip10, whose resolve() maps a
lone root marker with no reply to None (pinned by
resolve_root_only_is_top_level), so such an event takes the channel branch and
anchors a new thread at the steering event. That is the rule
docs/nips/NIP-CW.md §Top-level Classification already states for kind 9 — the
module implements it with NIP-10-shaped markers — so the steer body inherits the
judgement rather than defining its own.

No item's visibility widens here. The two narrowed helpers become private, and
nothing added is reachable outside the crate: DmClassification,
NativeSteerScope, their projections and native_steer_prompt_blocks are
pub(crate) or private. No pub signature
changes, so sprig, the only other crate with a buzz-acp path dependency,
cannot see this diff.
Workspace clippy compiles it anyway and is clean; that confirms rather than
decides.

Test enrollment

buzz-acp was compiled by CI and executed by no job: just test-unit enumerates
packages explicitly and buzz-acp was not among them, and nothing in CI runs
cargo test --workspace. The new routing tests would have guarded nothing, so
this PR adds buzz-acp to that recipe and to its scripts/run-tests.sh mirror.
That enrolls 821 tests — the recipe's own cargo nextest run -p buzz-acp — of
which 810 were already in the package before this PR and have never been executed
in CI.

Both commands clear the BUZZ_* environment family in a subshell first.
config.rs asserts clap defaults through CliArgs::parse_from, which reads
#[arg(env)] variables unconditionally, and its test module deliberately avoids
std::env::set_var to dodge parallelism races — so those tests assume a fixed
environment. A buzz-acp-hosted agent sets the very variables the package under
test reads. Clearing the whole family — rather than a list of names — gives those
tests a deterministic no-Buzz-config environment. It does not reproduce CI's
ambient environment, and is not meant to: a name list only approximates even the
deterministic case, and the list is a function of the test set, so it drifts in
both directions as tests are added.

Verification

Full just ci — all eight lanes — exits 0 at c8b98ab92, on the repository's own
pinned Hermit toolchain (just 1.46.0, Dart 3.11.5, Flutter 3.41.7). The buzz-acp
step this PR adds ran all 821 of those tests green.

On a host with cargo-nextest installed, just test-unit takes the nextest arm,
so the scripts/run-tests.sh fallback this PR also edits is never reached by the
gate. It was run directly, both ways, against a blob differing from the one here
only by a deleted comment. An ordinary run clears the family, reaches
buzz-acp and passes. Making one BUZZ_* variable readonly, so its unset fails,
aborts the step before Cargo starts. With the postcondition removed the same run
leaks that variable into cargo test, every test passes, and the script reports
the suite green — the outcome the check exists to refuse.

The mobile lanes are worth stating because CI will not reproduce them: the
mobile path filter matches nothing in this diff and the Mobile job is
conditional on it, so dart format, flutter analyze and flutter test are all
skipped upstream for these five files. All three ran here — 454 files formatted
with none changed, no analyzer issues, and 1661 mobile tests passed.

The four routing tests were checked against a mutant restoring the pre-fix body
shape (framing + event, no [Context]) — all four fail. The delta-scope test was
checked against a mutant passing enriched channel metadata — it fails. Two
mutants were run against the classification seam: mapping Unresolved onto a
resolved scope, and picking the wrong scope for a resolved classification. Both
fail the seam test, which asserts on the request try_native_steer actually sent
rather than on an intermediate the test supplies — the second leaves the
formatter-only test green, which is why the seam test exists. The nested-reply,
author-gate-split and classified-agent tests postdate the routing runs and are not
covered by them.

The pre-fix behaviour was observed directly rather than only inferred: native
steers delivered to a live claude-agent-acp session mid-turn carried the steer
header, the event block and the closing note, with no [Context], no
Thread root and no --reply-to, and no [What you were working on] (whose
presence would have meant the cancel+merge fallback ran instead).

Not verified: an end-to-end live steer on a patched build, confirming the posted
reply's e tag resolves to the steering thread. That needs a harness running this
branch, and the harness is the thing under test. The post-fix body is pinned by
these eleven tests and shares its anchor resolution with the fallback path's
existing test_steer_cross_thread_reply_targets_steering_message.

@odedlaz
odedlaz requested a review from a team as a code owner August 23, 2026 21:25
@odedlaz
odedlaz marked this pull request as draft August 23, 2026 21:29
@odedlaz
odedlaz force-pushed the bob/acp-native-steer-anchor branch from 436e5bd to 37b0a34 Compare August 24, 2026 08:58
buzz-acp has two mid-turn steer transports. The cancel+merge fallback builds its
prompt through `queue::format_prompt`, so it states the thread scope and a
`--reply-to` anchor. The native path hand-assembled its body from framing plus
one event block and called neither `format_context_hints` nor
`resolve_reply_anchor`, so the agent received no destination at all — leaving the
previous subject's thread as the only live one in its context, and replies landed
there.

That path is the default: `--multiple-event-handling` defaults to `steer` and
native is tried first, with cancel+merge only as the fallback. It affects every
adapter on either transport (`_goose/unstable/session/steer` and the
cross-adapter `_session/steering`), not only goose.

`queue::format_native_steer_prompt` now owns the whole body, and
`native_steer_framing` / `format_event_block` are private so the hand-assembly
this replaced no longer compiles outside the module. `resolve_reply_anchor`
absorbed the DM branch `format_prompt` had inline, so both transports resolve the
anchor through one function. Standing context, channel metadata, profile labels
and conversation history stay omitted: re-sending what the turn usually holds
defeats the delta, and where it may not hold them the hints tell the agent to
fetch.

Two constraints shaped the fix:

- The native path is synchronous on the main event loop and the only producer of
  a profile lookup is an async relay query, so identities are unknown here.
  `turn_is_human_facing` treats an unknown identity as human, which anchors every
  non-DM native steer — including the agent-to-agent ones the fallback leaves
  free to nest. Deliberate: losing a human's reply destination is the worse
  failure of the two.
- Unresolved channel metadata has no safe native reading. The authorization gate
  fails closed (unresolved means DM) so permissive `respond_to` modes cannot be
  exercised in a channel we failed to classify. Native steering cannot pick an
  anchor rule at all: the non-DM rule points a DM reply at its conversation root
  instead of the triggering message and forces an anchor on a top-level DM that
  should have none, the DM rule makes the inverse mistakes in a channel, and an
  anchorless prompt recreates the cross-thread bug above. `classify_dm` resolves
  the channel once because `ChannelInfoResolver` does not cache the unresolved
  case and a second call would pay a second lazy REST fetch on the main loop.

`DmClassification` is a closed `Dm | NonDm | Unresolved`. `gates_as_dm` answers
for every state; `native_steer_scope` returns `Option<NativeSteerScope>`, whose
two variants are the resolved states only. `try_native_steer` declines on `None` before
building a prompt, sending, or withholding. The caller's existing fallback then
issues cancel+merge with the event still queued, and that path resolves the
channel again at flush time — so declining buys a second attempt at definitive
metadata rather than discarding the steer. Production call sites hand the whole
classification to `try_native_steer`; that seam declines or yields a resolved
scope, and `native_steer_prompt_blocks` turns that scope into the formatter's
boolean. No call site names a reading.

Verification beyond CI: the four routing tests were run against a mutant that
restores the pre-fix body shape (framing + event, no `[Context]`) and all four
fail; the omissions test was run against a mutant that enriches the channel
metadata and fails. Mapping `Unresolved` onto a resolved native scope fails the
decline guard, and choosing the wrong scope for a resolved classification fails
it too: the assertions read the request `try_native_steer` actually sent, so the
chain is pinned from classification to anchor rather than from an intermediate
the test supplied. The resolved arms double as the decline's control — they
steer on the same fixture, so a declining `false` is not a pool that could never
have steered. The defect itself was observed first-hand — two native steers delivered to a claude-agent-acp session
mid-turn carried the steer header, the event block and the closing note, and no
`[Context]`, `Thread root` or `--reply-to`.

Co-authored-by: Oded Lazar <olazar@neo.ai>
Signed-off-by: Oded Lazar <olazar@neo.ai>
Adds buzz-acp to `just test-unit` and its `scripts/run-tests.sh` mirror. Its
tests were compiled by CI and executed by no job, so the routing assertions in
the parent commit would not have guarded anything.

Both steps run the package behind a subshell that unsets every `BUZZ_*` variable
first. `config.rs` asserts clap defaults through `CliArgs::parse_from`, which
reads `#[arg(env)]` unconditionally, and its test module deliberately avoids
`std::env::set_var` to dodge parallelism races — so it assumes a fixed
environment, and inside a buzz-acp-hosted agent that assumption does not hold.
Clearing the whole family rather than a list of names gives those tests a
deterministic no-Buzz-config environment; a name list is a function of the test
set, so it drifts in both directions as tests are added. It does not reproduce
CI's environment: `.github/workflows/ci.yml` sets `BUZZ_TEST_POSTGRES_PASSWORD`
for every job.

The `run-tests.sh` copy asserts the outcome rather than each `unset`, because
`run_test_step` invokes it from an `if` — which suppresses `errexit` for the
whole call, so a variable that cannot be unset would leak into the test
environment and the step would still report success. The `Justfile` copy sits
in an `if` body rather than a condition, so it already aborts on a failed unset.

Scoped to a subshell so the package steps above keep their environment.

Co-authored-by: Oded Lazar <olazar@neo.ai>
Signed-off-by: Oded Lazar <olazar@neo.ai>
@odedlaz
odedlaz force-pushed the bob/acp-native-steer-anchor branch from 37b0a34 to c8b98ab Compare August 24, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant