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>
97 lines
5.0 KiB
Swift
97 lines
5.0 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
import Bus // OutboundBatcher, BusOutbound
|
|
import JarvisMCP // BusGateway
|
|
|
|
/// BLOCKER-INT-1 fix: real `BusGateway` implementation that forwards
|
|
/// `ConfirmingToolDispatcher`'s tool-call events to the HUD via
|
|
/// `OutboundBatcher`. Replaces the prior `NoopBusGateway` in `AppDelegate.swift`
|
|
/// (line ~503) which silently dropped every tool-call card.
|
|
///
|
|
/// Wire mapping:
|
|
/// - `emitToolCallStart(toolUseId:name:argsPreview:)` →
|
|
/// `BusOutbound.toolCallStart(id: stableUUID(toolUseId), name:, argsPreview:)`
|
|
/// - `updateArgsPreview(toolUseId:name:argsPreview:)` →
|
|
/// second `.toolCallStart` with the SAME id; the HUD's `upsertToolCall`
|
|
/// reconciler patches the existing card with the post-approval
|
|
/// `argsPreview`. Drift the audit calls out in `streaming.test.tsx D5b`
|
|
/// is preserved (whitespace-insensitive parser on JS side).
|
|
/// - `emitToolCallEnd(toolUseId:name:ok:previewOrError:)` →
|
|
/// `BusOutbound.toolCallEnd(id: stableUUID(toolUseId), ok:, previewOrError:)`
|
|
///
|
|
/// `BusOutbound.toolCallStart/End` use `UUID` for `id` (Phase 2 closed schema);
|
|
/// the dispatcher's protocol uses `String` (Anthropic `toolu_01ABCD…` /
|
|
/// Ollama free-form, never UUID — see CR-03 in
|
|
/// `ConfirmingToolDispatcher.swift`). We bridge with a deterministic SHA256
|
|
/// → UUID derivation so:
|
|
/// 1. `emitToolCallStart(id="toolu_01X")` and `emitToolCallEnd(id="toolu_01X")`
|
|
/// produce the IDENTICAL `UUID`, allowing the HUD to reconcile by id.
|
|
/// 2. The mapping is one-way (we never decode UUID back to original id);
|
|
/// the audit-trail correlation across bus / replay / orchestrator
|
|
/// already lives at the String layer in `ReplayingToolResultObserver`.
|
|
/// 3. Two distinct dispatcher String ids hash to two distinct UUIDs with
|
|
/// negligible collision probability (SHA256 truncated to 128 bits).
|
|
///
|
|
/// Late binding: at the time `mcpInstallTask` captures the gateway (step 10
|
|
/// of `applicationWillFinishLaunching`), `OutboundBatcher` is not yet
|
|
/// constructed — `installAgent` (step 13) builds it. The gateway uses a
|
|
/// `@Sendable` resolver closure so `OutboundBatcher?` is read at emit-time,
|
|
/// not capture-time. Until the batcher exists, emissions are dropped (the
|
|
/// observer-side replay log still records the call).
|
|
public actor MCPBusGatewayAdapter: BusGateway {
|
|
private let resolveBatcher: @Sendable () async -> OutboundBatcher?
|
|
|
|
/// `resolveBatcher` returns the live `OutboundBatcher` (or nil if not
|
|
/// yet constructed / app shutting down). Production path: closure
|
|
/// hops to MainActor and reads `appDelegate.outboundBatcher`.
|
|
public init(resolveBatcher: @escaping @Sendable () async -> OutboundBatcher?) {
|
|
self.resolveBatcher = resolveBatcher
|
|
}
|
|
|
|
// MARK: BusGateway
|
|
|
|
public func emitToolCallStart(toolUseId: String, name: String, argsPreview: String) async {
|
|
guard let batcher = await resolveBatcher() else { return }
|
|
let uuid = Self.stableUUID(from: toolUseId)
|
|
try? await batcher.flushAndSend(.toolCallStart(id: uuid, name: name, argsPreview: argsPreview))
|
|
}
|
|
|
|
public func updateArgsPreview(toolUseId: String, name: String, argsPreview: String) async {
|
|
guard let batcher = await resolveBatcher() else { return }
|
|
let uuid = Self.stableUUID(from: toolUseId)
|
|
// Re-emit toolCallStart with same id; HUD upserts the args field
|
|
// by id (see webview/packages/hud/src/bus/client.ts case
|
|
// 'toolCallStart' → `store.upsertToolCall(msg.id, …)`).
|
|
try? await batcher.flushAndSend(.toolCallStart(id: uuid, name: name, argsPreview: argsPreview))
|
|
}
|
|
|
|
public func emitToolCallEnd(toolUseId: String, name: String, ok: Bool, previewOrError: String) async {
|
|
guard let batcher = await resolveBatcher() else { return }
|
|
let uuid = Self.stableUUID(from: toolUseId)
|
|
try? await batcher.flushAndSend(.toolCallEnd(id: uuid, ok: ok, previewOrError: previewOrError))
|
|
}
|
|
|
|
// MARK: - Stable id derivation
|
|
|
|
/// Deterministic `String → UUID` derivation: SHA256 of UTF-8 bytes, take
|
|
/// the first 16 bytes, set the version (5) and variant (RFC 4122)
|
|
/// bits per RFC 4122 §4.4 so the resulting UUID is well-formed. The
|
|
/// hash is the load-bearing property — RFC compliance is incidental
|
|
/// but makes the values valid `UUID(uuidString:)` round-trips.
|
|
static func stableUUID(from toolUseId: String) -> UUID {
|
|
let digest = SHA256.hash(data: Data(toolUseId.utf8))
|
|
var bytes = [UInt8](digest.prefix(16))
|
|
// RFC 4122 §4.4: set version (top nibble of byte 6) to 5
|
|
bytes[6] = (bytes[6] & 0x0F) | 0x50
|
|
// RFC 4122 §4.4: set variant (top two bits of byte 8) to 10
|
|
bytes[8] = (bytes[8] & 0x3F) | 0x80
|
|
let tuple = (
|
|
bytes[0], bytes[1], bytes[2], bytes[3],
|
|
bytes[4], bytes[5], bytes[6], bytes[7],
|
|
bytes[8], bytes[9], bytes[10], bytes[11],
|
|
bytes[12], bytes[13], bytes[14], bytes[15]
|
|
)
|
|
return UUID(uuid: tuple)
|
|
}
|
|
}
|