ConfirmingToolDispatcher emits tool-call events through `BusGateway`. The
prior `NoopBusGateway` (AppDelegate.swift:503 + dead struct at :1894-1904)
silently dropped every emission, so the HUD's chat-panel ToolCallCard
never rendered a Running / Completed transition for any tool dispatch.
This is the canonical "test mock and prod stub diverge in the same
direction" pattern called out in `.planning/audit-2026-05-03/tests-audit.md`
§6: SpyBus in MCPRuntimeWiringTests counted invocations; production
shipped with empty bodies — both satisfied the protocol; only the latter
was load-bearing in production.
The replacement `MCPBusGatewayAdapter` (App/MCPBusGatewayAdapter.swift):
- Conforms to `BusGateway`, forwards to `OutboundBatcher.flushAndSend`
with `.toolCallStart` / `.toolCallEnd` envelopes.
- `updateArgsPreview` re-emits `.toolCallStart` with the same id; the
HUD's `upsertToolCall` (webview/packages/hud/src/bus/client.ts) patches
the existing card by id, transitioning awaiting-approval → running with
the post-approval sanitized args.
- `String → UUID` mapping via SHA256-derived RFC 4122 v5-shape UUID, so
`emitToolCallStart(id="toolu_01X")` and `emitToolCallEnd(id="toolu_01X")`
produce IDENTICAL UUIDs for HUD reconciliation across start/update/end.
The dispatcher protocol uses `String` (CR-03 in ConfirmingToolDispatcher.swift
— Anthropic toolu_01ABCD… / Ollama free-form, never UUID); the bus
protocol uses `UUID` (Phase 2 closed schema). Bridge is one-way; the
audit-trail correlation by String id already lives at
ReplayingToolResultObserver.
- Late binding via `@Sendable () async -> OutboundBatcher?` resolver:
the batcher is built in installAgent (step 13), AFTER mcpInstallTask
captures the gateway in step 10. The closure resolves it at emit-time;
if the batcher is nil (early window before installAgent finishes,
or shutdown), emissions drop silently — the replay log path still
records the call so audit isn't lost.
Tests: App/Tests/AppTests/MCPBusGatewayAdapterTests.swift (+5):
- stableUUID determinism (same input → same UUID)
- stableUUID distinguishes different ids
- emitToolCallStart reaches the sink with right name/argsPreview/id
- emitToolCallEnd id matches the start id (reconciliation invariant)
- updateArgsPreview re-emits .toolCallStart with same id, updated args
- nil batcher drops events silently (no trap)
The recording sink + OutboundBatcher path the test exercises is the
same path production uses end-to-end through to WebviewBridge.send.
The remaining seam (sink → JS-side store) is already covered by
RealWKWebViewIntegrationTests + webview/hud streaming.test.tsx D5/D6/D7.
App build clean. Bus tests 58/58 (was 57/58 before F-A2-01 fix).
MCP tests 85/85 unchanged. All 5 boundary gates green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>