The new cancel test caught two real bugs, both from breaking out of the select
loop the moment the token fired.
Dropping `stream` drops the futures goose is awaiting, so
`mcp_client.rs:688` never reaches its `cancel_token.cancelled()` arm and never
sends `notifications/cancelled`. Consequences: the MCP child keeps running its
tool after the turn is over, and any announced `tool_call` never reaches a
terminal state -- the desktop renders that as a spinner forever, which is the
invariant buzz-agent held at agent.rs:470-477.
Cancellation is cooperative, so treat it that way: keep polling the stream and
let goose unwind (emit tool responses, send the MCP cancellations, end the
stream), bounded by CANCEL_DRAIN_TIMEOUT = 5s. Track announced-minus-resolved
tool call ids and synthesise terminal updates for any stragglers if the drain
times out -- a wrong status beats a stuck spinner.
3 new tests: cancel mid-tool-call returns stopReason=cancelled with every tool
call resolved and activeRunId cleared; notifications/cancelled actually reaches
the MCP server (asserted via FAKE_MCP_CANCEL_LOG); cancel for an unknown
session doesn't kill the process.
28 tests green, fmt + clippy -D warnings clean.
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Every other test in this crate drives `fake-mcp`, which answers whatever the
test tells it to. That proves our plumbing, not that it matches the server buzz
actually ships. These two drive the real binary: real tool names, real schemas,
real in-process todo state, real _Stop/_PostCompact semantics.
real_dev_mcp_stop_hook_blocks_end_of_turn scripts the exact scenario the veto
exists for -- the model records an open todo item via the real `todo` tool, then
tries to stop. Asserts the turn is extended and that buzz-dev-mcp's own
objection text ("open todo items") reaches the model.
real_dev_mcp_advertises_its_tools pins the shipped tool surface (shell,
read_file, str_replace, todo) and locks in the KNOWN DEVIATION: _Stop stays
visible to the model, with system-prompt guidance not to call it.
Both skip cleanly if buzz-dev-mcp isn't built.
25 tests green, fmt + clippy -D warnings clean.
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Last known behavioural gap. buzz-agent appended a reflection nudge to every
failed tool result (agent.rs:21-22, :364) so the model diagnoses the failure
instead of blindly retrying.
Goose gives no interception point for that: PostToolUseFailure is
fire-and-forget and its output is discarded (agent.rs:589-620). So we deliver
the same text via steer(), which goose drains at the round boundary
(agent.rs:1951-1974) -- exactly when the model would next act on the failed
result. Agent-visible, user-invisible. Capped at 8 per turn so a tool failing
in a loop cannot flood the conversation.
Tested against the real provider wire rather than trusting that steer() was
called: the fake provider records every chat-completions body, and the test
asserts [Reflect] is absent from the first generation and present in a later
one. Negative test confirms a successful tool call injects nothing.
Adds FAKE_MCP_TOOL_ERROR to the fake MCP server. 23 tests green.
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Closes the last behavioural gap. buzz-agent's most load-bearing non-standard
behaviour -- the agent may not end its turn while its todo list has open items
-- now works, with no changes to goose.
Why not goose's hook system. Goose has a blocking Stop hook with exactly these
semantics (agent.rs:2891-2917), but it is unreachable: hook_manager is private
with a #[cfg(test)] setter, and hooks are otherwise discovered as subprocesses
from <root>/.goose/plugins/*/hooks/hooks.json. The subprocess route looked
viable until you notice where the answer lives -- buzz-dev-mcp's todo list is
in-process state (todo.rs:49, a Mutex<Vec<Item>>) and that process's stdio is
owned by goose. A hook binary goose spawns cannot see it, so a materialised
hooks.json would produce a hook that always answers "no objection": worse than
no hook, because it looks like it works.
Instead we own the outer loop, so we ask the tool ourselves.
Agent::dispatch_tool_call is public (agent.rs:1059). Between rounds we call
_Stop on the same extension the model uses; on objection we re-enter reply()
with the objection as an agent-visible/user-invisible message. Capped at 3
consecutive vetoes, mirroring goose's own stop_hook_block_cap.
_PostCompact is wired to AgentEvent::HistoryReplaced and re-injects via steer(),
which goose drains at the round boundary (agent.rs:1951-1974).
Extension name is discovered by "___Stop" suffix rather than hardcoded --
buzz-acp derives it from the MCP binary's file stem (buzz-acp/src/lib.rs:4145),
so it is not a fixed string.
KNOWN DEVIATION: buzz-agent hid _-prefixed tools from the model
(agent.rs:328-336) while still calling them itself. Goose's available_tools
allowlist gates advertising and dispatch through the same cache
(extension_manager.rs:1421, :1698), so hiding them would make them
undispatchable and break the veto. They stay visible; a system-prompt
extension tells the model not to call them.
4 new tests against the real fake-mcp binary (copied from crates/buzz-agent)
counting provider generations: 2 objections => 3 calls, permanent objection
capped at 4, no hook => 1 call, and discovery under a non-obvious extension
name. 21 tests green.
Signed-off-by: Michael Neale <michael.neale@gmail.com>
session/new returned only `sessionId`, so the desktop ModelPicker degraded to
"current model only" and buzz-acp could not resolve session/set_model targets
(resolve_model_switch_method, buzz-acp/src/acp.rs:1876).
This was a regression I introduced by deleting buzz-agent's catalog.rs without
replacing what it fed -- not a limitation of driving goose as a library. The
picker is the same UI and the same buzz-acp code path for every agent; goose's
CLI fills it via build_model_state, buzz-agent filled it via Databricks
discovery, and this crate filled it with nothing.
Cannot reuse goose's builder: build_model_state is pub(super)
(acp/response_builder.rs:130), invisible outside goose::acp. The underlying
data is public, so discover_models() rebuilds the same shape from
Provider::fetch_supported_models (goose-provider-types/base.rs:425) via
Agent::provider(), including goose's rule that the current model is prepended
when the provider's list omits it.
Absent catalog stays degraded UX, never a session failure -- matching
buzz-agent's Databricks fallback (catalog.rs:52-80).
New stdio test asserts the shape buzz-acp actually parses: currentModelId plus
availableModels entries keyed by `modelId`. 16 tests green.
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Makes the spike reproducible off this machine and closes the model-switch
stub.
- Depend on aaif-goose/goose @ 305849b71 (v1.44.0, ancestor of origin/main)
instead of a local path. Verified: cargo fetch + full test run from a clean
git checkout of the dep.
- session/set_model now takes effect. The id is staged in `pending_model` and
consumed by the next session/prompt, matching buzz-agent's "applies from the
next prompt" contract (lib.rs:494-502) so an in-flight turn is never mutated.
Rebuilds the provider and hot-swaps it via update_provider; SharedProvider is
an Arc<Mutex<Option<..>>> for exactly this.
- New stdio test covers unknown-session, empty-modelId, and a real switch
followed by a completing turn.
15 tests green. Measured release binary (macOS arm64):
new 32.5 MiB raw / 9.9 MiB gzip -9
old 9.8 MiB raw / 3.9 MiB gzip -9
delta +22.7 MiB raw / +6.0 MiB gzip
Corroborates PR #1526's +22.9 MiB raw / +6.2 MiB gzip.
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Feasibility spike: keep buzz-agent's ACP wire contract exactly as-is, but
replace the hand-written agent loop with goose used as a Rust library.
13,259 -> 1,562 src LOC (88% cut), plus ~5,800 test LOC that covered the
deleted loop.
Deleted, now goose's:
llm.rs 3846 -> goose::providers (superset of the 4 providers)
mcp.rs 1139 -> goose::agents::extension_manager
auth.rs 845 -> goose::providers (incl. Databricks OAuth)
hints.rs 726 -> prompt_manager::with_hints
catalog.rs 631 -> goose::providers::init
builtin.rs 575 -> goose skills platform extension
handoff.rs 430 -> goose::context_mgmt (auto-compaction)
Kept deliberately (the contract buzz-acp depends on):
wire.rs 293 verbatim; agentInfo.name = "buzz-agent" (kind-44200 harness
attribution); 6-method surface; activeRunId with _meta nested inside
update; usage_update before the session/prompt response; keepalive
ticker; error -> JSON-RPC code mapping; single-flight; size caps.
This is NOT "goose as the harness". Picking Goose from the harness gallery
still shells out to a user-installed goose CLI. This is buzz-agent's own
identity with a goose-powered loop.
Why a separate crate excluded from the workspace: crates/buzz-agent is a
library linked into sprig AND desktop/src-tauri, so goose's ~700-crate graph
would land in the Tauri build and the workspace lockfile. Own Cargo.lock,
same isolation trick as PR #1526.
Notable: driving the library is what makes the persona work at all. Goose's
own ACP server never reads systemPrompt (zero hits in goose/crates/goose/src)
and both PRs that would have wired it -- buzz#1290, goose#9971 -- are closed
unmerged. tests/stdio_turn.rs asserts Fizz's prompt reaches the provider.
Also: GOOSE_MODE is left at goose's default rather than forced to "auto"
(auto-approve every tool call), which is what the desktop catalog ships for
the external goose runtime.
Signed-off-by: Michael Neale <michael.neale@gmail.com>