fix(acp): state the reply destination in mid-turn steer prompts - #6633
Draft
odedlaz wants to merge 2 commits into
Draft
fix(acp): state the reply destination in mid-turn steer prompts#6633odedlaz wants to merge 2 commits into
odedlaz wants to merge 2 commits into
Conversation
odedlaz
marked this pull request as draft
August 23, 2026 21:29
odedlaz
force-pushed
the
bob/acp-native-steer-anchor
branch
from
August 24, 2026 08:58
436e5bd to
37b0a34
Compare
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
force-pushed
the
bob/acp-native-steer-anchor
branch
from
August 24, 2026 09:59
37b0a34 to
c8b98ab
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 itstates the thread scope and a
--reply-toanchor. The native path hand-assembledits body from framing plus one event block and called neither
format_context_hintsnorresolve_reply_anchor. The agent got no destination atall, 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-handlingdefaults tosteer(
config.rs) and native is attempted first, with cancel+merge as the failurefallback — 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/steerand the cross-adapter_session/steeringat write time, and both carry the same body. Any adapteradvertising
_meta.steering.supportedis affected, includingclaude-agent-acp— Claude Code sessions are in the affected population.
Regressed in
e567491a1(#1160), which introducedtry_native_steer.The fix
queue::format_native_steer_promptowns the whole body.native_steer_framingand
format_event_blockare now private toqueue, so the hand-assembly thisreplaced no longer compiles outside the module: a caller wanting the shared
framing has to come through here.
resolve_reply_anchorabsorbed the DM branchformat_prompthad inline, so bothtransports 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_facingtreats an unknown identity as human, so everynon-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.
DmClassificationis therefore aclosed
Dm | NonDm | Unresolved;gates_as_dmanswers for every state whilenative_steer_scopeyieldsOption<NativeSteerScope>over the resolved statesonly, 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_dmresolves the channel once because
ChannelInfoResolverdoes not cache the unresolvedcase and a second call would pay a second lazy REST fetch on the main loop for
exactly the channels that already failed.
is_dm_channelis a wrapper over thegate projection; its behaviour, tests and
setup_mode.rscaller 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 resolvedscope, and
native_steer_prompt_blocksturns that scope into the formatter'sboolean. Handing the formatter the gate's reading previously compiled and
passed every test.
pool.send_steer, the synchronousmark_native_steer_pending, and the watcherspawn keep their existing order.
One inherited subtlety worth stating, since the steer body now surfaces it:
Scope: channelmeans no markedreplytag, not that the event carries noetags.
parse_thread_tagsdelegates tobuzz_core::nip10, whoseresolve()maps alone
rootmarker with noreplytoNone(pinned byresolve_root_only_is_top_level), so such an event takes the channel branch andanchors a new thread at the steering event. That is the rule
docs/nips/NIP-CW.md§Top-level Classification already states for kind 9 — themodule 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 andnative_steer_prompt_blocksarepub(crate)or private. Nopubsignaturechanges, so
sprig, the only other crate with abuzz-acppath 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-unitenumeratespackages explicitly and buzz-acp was not among them, and nothing in CI runs
cargo test --workspace. The new routing tests would have guarded nothing, sothis PR adds buzz-acp to that recipe and to its
scripts/run-tests.shmirror.That enrolls 821 tests — the recipe's own
cargo nextest run -p buzz-acp— ofwhich 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.rsasserts clap defaults throughCliArgs::parse_from, which reads#[arg(env)]variables unconditionally, and its test module deliberately avoidsstd::env::set_varto dodge parallelism races — so those tests assume a fixedenvironment. 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 atc8b98ab92, on the repository's ownpinned Hermit toolchain (
just1.46.0, Dart 3.11.5, Flutter 3.41.7). The buzz-acpstep this PR adds ran all 821 of those tests green.
On a host with
cargo-nextestinstalled,just test-unittakes the nextest arm,so the
scripts/run-tests.shfallback this PR also edits is never reached by thegate. 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 itsunsetfails,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 reportsthe suite green — the outcome the check exists to refuse.
The mobile lanes are worth stating because CI will not reproduce them: the
mobilepath filter matches nothing in this diff and the Mobile job isconditional on it, so
dart format,flutter analyzeandflutter testare allskipped 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 waschecked against a mutant passing enriched channel metadata — it fails. Two
mutants were run against the classification seam: mapping
Unresolvedonto aresolved scope, and picking the wrong scope for a resolved classification. Both
fail the seam test, which asserts on the request
try_native_steeractually sentrather 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-acpsession mid-turn carried the steerheader, the event block and the closing note, with no
[Context], noThread rootand no--reply-to, and no[What you were working on](whosepresence 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
etag resolves to the steering thread. That needs a harness running thisbranch, 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.