test(10-02c): add InProcessAwareDispatchRoutingTests for B-01b regression coverage

- 5 cases at the dispatch routing boundary: in-process route, stdio fallthrough,
  confirmation gate preservation, late-registration timing, observer parity.
- All FAIL until InProcessAwareToolDispatcher composite + InProcessConfirmationCache land.
- Bug: B-01 wired catalog enumeration (10-02b) but missed dispatch routing —
  MCPToolDispatcher only knows the 3 stdio tools, so when the model emitted
  tool_use for get_active_audio_route, dispatch failed and the failure
  surfaced as visible chat text.
This commit is contained in:
james.maes committed 2026-05-07 11:15:54 -05:00
1 parent 9a59648f60
commit 3cf0d396ea
1 file changed
+322
@@ -0,0 +1,322 @@
// InProcessAwareDispatchRoutingTests.swift
//
// Plan 10-02c (B-01b) — regression coverage for the dispatch routing bug.
//
// Bug: Plan 10-02b wired tool catalog ENUMERATION (model receives schemas
// for in-process tools) but missed dispatch ROUTING. When the model emitted
// a real `tool_use` for `get_active_audio_route`, the inner MCPToolDispatcher
// only knew about stdio tools (get_time/get_clipboard/run_applescript) and
// failed with "tool unknown", which the model surfaced as visible chat text.
//
// Fix: InProcessAwareToolDispatcher composite that name-routes to either
// the InProcessToolRegistry or the inner stdio dispatcher. Wraps the inner
// dispatcher; itself wrapped by ConfirmingToolDispatcher so confirmation
// gating, bus emission, and observer hooks fire identically on both paths.
import XCTest
import AgentCore
import AgentOrchestrator
@testable import JarvisMCP
// MARK: - Test fixtures
/// Canned in-process tool that records every call() and returns a fixed payload.
private actor CannedInProcessTool: InProcessTool {
nonisolated let name: String
nonisolated let toolDescription: String
nonisolated let schemaJSON: Data
nonisolated let requiresConfirmation: Bool
private let payload: Data
private(set) var callCount: Int = 0
private(set) var lastArgs: Data?
init(
name: String,
toolDescription: String = "canned tool",
requiresConfirmation: Bool = false,
payload: Data = Data("canned-payload".utf8),
schemaJSON: Data = Data("{}".utf8)
) {
self.name = name
self.toolDescription = toolDescription
self.requiresConfirmation = requiresConfirmation
self.payload = payload
self.schemaJSON = schemaJSON
}
func call(args: Data) async throws -> Data {
callCount += 1
lastArgs = args
return payload
}
func snapshot() -> (count: Int, lastArgs: Data?) { (callCount, lastArgs) }
}
/// Stub inner ToolDispatcher (the stdio fallback). Records calls; returns canned bytes.
private actor StubInnerDispatcher: ToolDispatcher {
struct Call: Equatable, Sendable { let id: String; let name: String }
private(set) var calls: [Call] = []
private let scriptedResult: Result<Data, Error>
private let confirmationFor: [String: Bool]
init(
confirmationFor: [String: Bool] = [:],
scriptedResult: Result<Data, Error> = .success(Data("stdio-result".utf8))
) {
self.confirmationFor = confirmationFor
self.scriptedResult = scriptedResult
}
func dispatch(toolUse: ToolUseRequest) async throws -> Data {
calls.append(.init(id: toolUse.id, name: toolUse.name))
switch scriptedResult {
case .success(let d): return d
case .failure(let e): throw e
}
}
nonisolated func requiresConfirmation(toolName: String) -> Bool {
confirmationFor[toolName] ?? false
}
func snapshot() -> [Call] { calls }
}
/// Recording observer (mirror of MCPToolDispatcherTests.RecordingObserver).
private actor RecordingObserver: ToolResultObserver {
struct Record: Sendable, Equatable {
let toolUseId: String
let toolName: String
let rawBytes: Data
let sanitizedBytes: Data
}
private(set) var records: [Record] = []
func record(toolUseId: String, toolName: String, rawBytes: Data, sanitizedBytes: Data) async {
records.append(.init(
toolUseId: toolUseId,
toolName: toolName,
rawBytes: rawBytes,
sanitizedBytes: sanitizedBytes
))
}
}
/// Bus spy for confirmation-gate test (mirrors ConfirmingToolDispatcherTests.SpyBus).
private actor SpyBus: BusGateway {
enum Event: Sendable, Equatable {
case start(id: String, name: String, preview: String)
case update(id: String, name: String, preview: String)
case end(id: String, name: String, ok: Bool, preview: String)
}
private(set) var events: [Event] = []
func emitToolCallStart(toolUseId: String, name: String, argsPreview: String) async {
events.append(.start(id: toolUseId, name: name, preview: argsPreview))
}
func updateArgsPreview(toolUseId: String, name: String, argsPreview: String) async {
events.append(.update(id: toolUseId, name: name, preview: argsPreview))
}
func emitToolCallEnd(toolUseId: String, name: String, ok: Bool, previewOrError: String) async {
events.append(.end(id: toolUseId, name: name, ok: ok, preview: previewOrError))
}
func snapshot() -> [Event] { events }
}
/// Scripted presenter (mirror of ConfirmingToolDispatcherTests.ScriptedPresenter).
private final class ScriptedPresenter: ConfirmationPresenting, @unchecked Sendable {
private let outcome: ConfirmationOutcome
private weak var broker: ConfirmationBroker?
private let lock = NSLock()
init(outcome: ConfirmationOutcome) { self.outcome = outcome }
func setBroker(_ b: ConfirmationBroker) {
lock.lock(); defer { lock.unlock() }
self.broker = b
}
nonisolated func show(id: UUID, toolName: String, argsPreview: String) async {
let outcome = self.outcome
let broker = self.broker
Task { await broker?.response(id: id, outcome: outcome) }
}
nonisolated func dismiss(id: UUID) async {}
}
// MARK: - Tests
final class InProcessAwareDispatchRoutingTests: XCTestCase {
private func req(name: String, id: String = "tu-1", args: Data = Data()) -> ToolUseRequest {
ToolUseRequest(id: id, name: name, argsJSON: args)
}
// MARK: 1. routes in-process tool to registry
func test_routesInProcessToolToRegistry() async throws {
let registry = InProcessToolRegistry()
let canned = CannedInProcessTool(
name: "canned_in_process_tool",
payload: Data("from-in-process-registry".utf8)
)
await registry.register(canned)
let inner = StubInnerDispatcher(
scriptedResult: .success(Data("from-stdio-fallback".utf8))
)
let composite = InProcessAwareToolDispatcher(
inProcessRegistry: registry,
confirmationCache: registry.confirmationCache,
inner: inner
)
let result = try await composite.dispatch(
toolUse: req(name: "canned_in_process_tool")
)
// Sanitized payload (under 8 KB, ASCII, no zero-width chars) round-trips intact.
XCTAssertEqual(String(decoding: result, as: UTF8.self), "from-in-process-registry")
let (count, _) = await canned.snapshot()
XCTAssertEqual(count, 1, "in-process tool MUST be called exactly once")
let stdioCalls = await inner.snapshot()
XCTAssertTrue(stdioCalls.isEmpty, "stdio fallback MUST NOT be invoked for in-process tool")
}
// MARK: 2. routes unknown to inner stdio path
func test_routesUnknownToInnerStdioPath() async throws {
let registry = InProcessToolRegistry() // EMPTY — every dispatch falls through.
let inner = StubInnerDispatcher(
scriptedResult: .success(Data("stdio-time-result".utf8))
)
let composite = InProcessAwareToolDispatcher(
inProcessRegistry: registry,
confirmationCache: registry.confirmationCache,
inner: inner
)
let result = try await composite.dispatch(toolUse: req(name: "get_time"))
XCTAssertEqual(String(decoding: result, as: UTF8.self), "stdio-time-result")
let stdioCalls = await inner.snapshot()
XCTAssertEqual(stdioCalls, [.init(id: "tu-1", name: "get_time")])
}
// MARK: 3. confirmation gate preserved on in-process path
func test_confirmationGatePreservedOnInProcessPath() async throws {
// forget_fact-style tool: in-process AND requires confirmation.
let registry = InProcessToolRegistry()
let confirmTool = CannedInProcessTool(
name: "fake_forget_fact",
requiresConfirmation: true,
payload: Data("forget-ack".utf8)
)
await registry.register(confirmTool)
let inner = StubInnerDispatcher() // never called
let composite = InProcessAwareToolDispatcher(
inProcessRegistry: registry,
confirmationCache: registry.confirmationCache,
inner: inner
)
// Composite reports confirmation required (sync read).
XCTAssertTrue(composite.requiresConfirmation(toolName: "fake_forget_fact"),
"composite MUST surface in-process tool's requiresConfirmation:true")
// Wrap with ConfirmingToolDispatcher (the production outer wrapper).
let presenter = ScriptedPresenter(outcome: .approve)
let broker = ConfirmationBroker(timeoutSeconds: 60, presenter: presenter)
presenter.setBroker(broker)
let bus = SpyBus()
let outer = ConfirmingToolDispatcher(
inner: composite,
broker: broker,
bus: bus,
argsPreviewSanitizer: { _ in "{}" }
)
let result = try await outer.dispatch(
toolUse: req(name: "fake_forget_fact", id: "tu-confirm-1")
)
XCTAssertEqual(String(decoding: result, as: UTF8.self), "forget-ack")
// Bus saw the awaiting-approval seal.
let events = await bus.snapshot()
XCTAssertTrue(events.contains(where: {
if case .start(_, let name, let preview) = $0 {
return name == "fake_forget_fact" && preview == #"{"awaitingApproval":true}"#
}
return false
}), "ConfirmingToolDispatcher MUST emit awaiting-approval seal even for in-process tools")
// Tool was invoked exactly once (after broker approved).
let (count, _) = await confirmTool.snapshot()
XCTAssertEqual(count, 1)
}
// MARK: 4. tools registered AFTER dispatcher built are still routable
func test_toolsRegisteredAfterDispatcherBuiltAreRoutable() async throws {
let registry = InProcessToolRegistry() // empty at construction.
let inner = StubInnerDispatcher()
let composite = InProcessAwareToolDispatcher(
inProcessRegistry: registry,
confirmationCache: registry.confirmationCache,
inner: inner
)
// Register the tool LATER — simulating installSelfKnowledgeTools after MCPRuntime build.
let lateTool = CannedInProcessTool(
name: "late_registered_tool",
payload: Data("late-payload".utf8)
)
await registry.register(lateTool)
let result = try await composite.dispatch(toolUse: req(name: "late_registered_tool"))
XCTAssertEqual(String(decoding: result, as: UTF8.self), "late-payload")
let (count, _) = await lateTool.snapshot()
XCTAssertEqual(count, 1, "tool registered AFTER dispatcher init MUST still be routable")
}
// MARK: 5. observer fires on in-process path
func test_inProcessResultFlowsThroughObserver() async throws {
let registry = InProcessToolRegistry()
let canned = CannedInProcessTool(
name: "audited_tool",
payload: Data("audited-result".utf8)
)
await registry.register(canned)
let observer = RecordingObserver()
let inner = StubInnerDispatcher()
let composite = InProcessAwareToolDispatcher(
inProcessRegistry: registry,
confirmationCache: registry.confirmationCache,
inner: inner,
observer: observer
)
_ = try await composite.dispatch(
toolUse: req(name: "audited_tool", id: "tu-audit", args: Data(#"{"k":"v"}"#.utf8))
)
let recs = await observer.records
XCTAssertEqual(recs.count, 1, "observer MUST fire on in-process success path")
XCTAssertEqual(recs.first?.toolName, "audited_tool")
XCTAssertEqual(recs.first?.toolUseId, "tu-audit")
XCTAssertEqual(String(decoding: recs.first?.rawBytes ?? Data(), as: UTF8.self), "audited-result")
XCTAssertEqual(String(decoding: recs.first?.sanitizedBytes ?? Data(), as: UTF8.self), "audited-result")
}
}