Files
Jarvis/App/Tests/AppTests/MemoryInstallWiringTests.swift
james.maesandClaude Opus 4.7 707c45b355 feat(track-d 2): register SearchMemoryTool + ForgetFactTool with MCP runtime
Closes the audit's "tools exist but unregistered" finding from
.planning/audit-2026-05-03/memory-audit.md §4-5: SearchMemoryTool +
ForgetFactTool source files have lived in `packages/MCP/Sources/MCP/
InProcess/` since Plan 07-03 with zero non-test instantiation. The agent
loop has had no way to call into memory.

Wiring:
  - New `App/MCP/InProcessMemoryAdapters.swift` bridges MCP's tool
    dispatch protocols to Memory's actors:
      * `HybridSearchAdapter: HybridSearchDispatching` wraps
        `Memory.HybridSearch` and translates `[FactRef]` → `[SearchMemoryHit]`
        (so MCP doesn't need to import Memory).
      * `ForgetFactStoreAdapter: ForgetFactDispatching` wraps
        `MemoryStore.forgetFact`.
  - `installMemory` builds an `InProcessToolRegistry` and registers tools
    based on preconditions:
      * `forget_fact` registers when `memoryStore != nil` (writes need DB)
      * `search_memory` registers when `memorySearchAvailable && embedder
        init succeeds` (reads need DB + vec0 + Ollama loopback)
  - Embedder construction (`OllamaEmbeddingClient` loopback assertion)
    failure is caught + logged; degrades to "writes ok, no search"
    rather than failing the whole subsystem.
  - New `inProcessToolRegistry: InProcessToolRegistry?` ivar, exposed
    @testable.

Test seam: `AppDelegate.buildInProcessToolRegistry(forgetDispatcher:
searchDispatcher:)` is the pure registry-construction helper — takes
already-built dispatchers (or nil) and applies the gating rules. Tests
inject stub dispatchers to assert positive registration without needing
a real `MemoryStore` (which requires vec0.dylib, env-gated in CI).

Tests added (compile-time gates per
.planning/debug/xctest-launch-runningboard-error-5.md):
  - `test_D2_noToolsRegisteredWhenStoreUnavailable` — production reality
    on every cold launch (vec0 placeholder)
  - `test_D2_bothToolsRegisterWhenBothDispatchersPresent` — store + search
    available
  - `test_D2_onlyForgetRegistersWhenSearchUnavailable` — store ok, vec
    failed (the future state once custom libsqlite3 lands but vec0.dylib
    is missing)
  - `test_D2_emptyRegistryWhenBothDispatchersNil` — degenerate case

Out of scope: exposing the registered tools to the LLM via
`AgentOrchestrator.availableTools` (currently `[]` — separate audit gap,
affects helper tools too). The "tools exist but unregistered" gap is
closed; the "tools exposed to LLM" gap is independent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:09:46 -05:00

141 lines
6.1 KiB
Swift

// MemoryInstallWiringTests.swift
//
// Track-D D-1 regression: `installMemory` must NOT short-circuit
// extractor / orchestrator / coordinator construction when MemoryStore.init
// fails (today's reality on every cold launch — vec0.dylib is a 2-line
// placeholder, libsqlite3 is symbol-stripped, MemoryStore.init throws).
//
// Pre-D-1 behavior: a single `return` at the catch site after store init
// failure left `memoryExtractionOrchestrator == nil` AND
// `memoryExtractionCoordinator == nil` for the whole process — which made
// the .turnEnd subscriber a no-op AND meant nothing about memory extraction
// was observable in system log.
//
// Post-D-1: store nil, but orchestrator + coordinator are constructed. The
// applyOp closure routes to a debug log when there's no store.
import XCTest
@testable import Jarvis
@testable import Keychain
@testable import Config
@testable import Memory
@testable import JarvisMCP
@MainActor
final class MemoryInstallWiringTests: XCTestCase {
private struct EntitlementYes: EntitlementGateProbe { func isVerified() -> Bool { true } }
private struct FakeKeychain: KeychainStore {
func set(_ value: String, for item: KeychainItem) throws {}
func get(_ item: KeychainItem) throws -> String { "sk-ant-fake" }
func delete(_ item: KeychainItem) throws {}
}
private struct FakeHIDProbe: HIDAccessProbe {
func requestListenEventAccess() -> Bool { true }
}
private func cleanUp(_ delegate: AppDelegate) {
if let item = delegate.statusItem {
NSStatusBar.system.removeStatusItem(item)
}
delegate.wizardController?.close()
}
/// D-1: when MemoryStore.init throws (vec0.dylib unavailable on this host
/// — the production reality), the early-return cascade is gone:
/// `memoryExtractionOrchestrator` AND `memoryExtractionCoordinator` MUST
/// both be non-nil. `memoryStore` stays nil and `memorySearchAvailable`
/// stays false.
func test_D1_extractorAndCoordinatorConstructWhenVecFails() async {
let delegate = AppDelegate()
delegate.entitlementProbe = EntitlementYes()
delegate.keychainStore = FakeKeychain()
// Drive installMemory directly. Today, vec0.dylib is a placeholder
// text file (Resources/PLACEHOLDER.txt) and macOS's libsqlite3 strips
// sqlite3_enable_load_extension — so MemoryStore.init throws on every
// host. That's exactly the failure path we need to exercise.
await delegate.installMemory()
// D-1 invariants:
XCTAssertNil(delegate.memoryStore,
"Pre-D-5 reality: MemoryStore.init should fail (vec0 dylib placeholder)")
XCTAssertFalse(delegate.memorySearchAvailable,
"memorySearchAvailable must be false when store is nil")
XCTAssertNotNil(delegate.memoryExtractionOrchestrator,
"D-1 fix: orchestrator must construct even when vec fails")
XCTAssertNotNil(delegate.memoryExtractionCoordinator,
"D-1 fix: coordinator must construct even when vec fails")
cleanUp(delegate)
}
/// D-2: when MemoryStore.init fails (today's reality), neither
/// SearchMemoryTool nor ForgetFactTool registers — they have no DB
/// to dispatch against. The registry itself IS constructed (empty).
func test_D2_noToolsRegisteredWhenStoreUnavailable() async {
let delegate = AppDelegate()
delegate.entitlementProbe = EntitlementYes()
delegate.keychainStore = FakeKeychain()
await delegate.installMemory()
XCTAssertNil(delegate.memoryStore, "Production reality: store nil on every host")
XCTAssertNotNil(delegate.inProcessToolRegistry,
"D-2: registry constructs unconditionally")
let tools = await delegate.inProcessToolRegistry?.registered() ?? []
let names = Set(tools.map { $0.name })
XCTAssertFalse(names.contains("forget_fact"),
"D-2: forget_fact must not register when store is nil (writes need DB)")
XCTAssertFalse(names.contains("search_memory"),
"D-2: search_memory must not register when search unavailable (reads need DB + vec)")
cleanUp(delegate)
}
// MARK: - D-2 positive paths via test seam
private struct StubForget: ForgetFactDispatching {
func forgetFact(id: Int64, triggerTurnId: Int64) async throws -> Bool { false }
}
private struct StubHybrid: HybridSearchDispatching {
func searchFacts(query: String, k: Int, triggerTurnId: Int64) async throws -> [SearchMemoryHit] { [] }
}
/// D-2: both dispatchers present → both tools register.
func test_D2_bothToolsRegisterWhenBothDispatchersPresent() async {
let registry = await AppDelegate.buildInProcessToolRegistry(
forgetDispatcher: StubForget(),
searchDispatcher: StubHybrid()
)
let names = Set(await registry.registered().map { $0.name })
XCTAssertTrue(names.contains("forget_fact"))
XCTAssertTrue(names.contains("search_memory"))
}
/// D-2: store ok but search unavailable → forget registers, search does not.
func test_D2_onlyForgetRegistersWhenSearchUnavailable() async {
let registry = await AppDelegate.buildInProcessToolRegistry(
forgetDispatcher: StubForget(),
searchDispatcher: nil
)
let names = Set(await registry.registered().map { $0.name })
XCTAssertTrue(names.contains("forget_fact"),
"D-2: forget_fact registers when store exists")
XCTAssertFalse(names.contains("search_memory"),
"D-2: search_memory gated on searchAvailable")
}
/// D-2: nothing wired (store nil) → empty registry.
func test_D2_emptyRegistryWhenBothDispatchersNil() async {
let registry = await AppDelegate.buildInProcessToolRegistry(
forgetDispatcher: nil,
searchDispatcher: nil
)
let names = Set(await registry.registered().map { $0.name })
XCTAssertEqual(names, [], "D-2: empty registry when no dispatchers — neither tool registers")
}
}