Files
Jarvis/packages/Memory/Tests/MemoryTests/MemoryExtractionOrchestratorTests.swift
james.maes f59f5d12e3 fix(memory): v0.1 CRIT cluster — facts_vec / replay correlation / FTS5 sanitize / search_memory preamble (#12, #13, #14, #15, #16, #153)
#14 (CRIT): ContextBuilder.selfAwarePreamble names search_memory + forget_fact with explicit cue 'call search_memory FIRST. Never claim to remember without searching.'

#16 (HIGH): MemoryQueries.sanitizeFTS5Query strips non-alphanumeric, quotes tokens; HybridSearch.searchFacts calls it before binding to MATCH ?. Embedding still runs on raw query.

#12 (CRIT): MemoryStore.init accepts optional embedder; insertNewFact writes facts_vec after the facts commit. Vec failures log + leave FTS row (no rollback). installMemory shares one embedder between writer + reader.

#15 (HIGH): drop stableHash; pass sentinel 0 as sourceTurnId (option 1 per audit — smaller change surface than threading real rowid through 5 files). Schema comment updated.

#13 (CRIT): two-layer fix. Layer 1: AppDelegate owns a per-process sentinel TurnID; installAgent writes the matching turns row after beginSession; AppDelegateMemoryReplaySink attributes events to it. Layer 2: ReplayLog.flushPending retries failed batches event-by-event so one poisoned row only drops itself.

Memory 98 + Replay 34 + AgentCore 243 tests pass; all 18 boundary gates green.
2026-05-13 06:30:35 -05:00

612 lines
25 KiB
Swift

import XCTest
import Foundation
import AgentCore
import AgentOrchestrator
import Replay
@testable import Memory
/// Plan 07-02 Task 4: MemoryExtractionOrchestrator (bounded channel +
/// serial drain) + MemoryExtractionCoordinator (turnEnd subscriber,
/// non-blocking).
///
/// Most tests use mock collaborators because constructing a real
/// `MemoryStore` requires `vec0.dylib` (env-gated). The orchestrator's
/// test seam (init takes any-LLMProvider extractor + a callback for
/// applyOp) lets us drive D-01/MEM-06 invariants without touching
/// SQLite.
final class MemoryExtractionOrchestratorTests: XCTestCase {
// MARK: - Mock collaborators
/// Mock extractor that mirrors the real MemoryExtractor's public API
/// shape (extract(...) -> [MemoryOp]) but does not call an LLMProvider.
/// We construct it via a private `MemoryExtractor` initialized with
/// a MockLLMProvider — but to control timing precisely we instead
/// provide a TimedExtractorBox that the orchestrator can call directly.
/// The simplest way to do this without bloating the API: drive the real
/// MemoryExtractor through a controllable mock provider.
final class TimedMockProvider: LLMProvider, @unchecked Sendable {
let lock = NSLock()
var sleepNs: UInt64 = 0
var throwError: Error?
/// Pre-encoded apply_memory_ops argsJSON. Empty Data → no tool call.
var argsJSON: Data = Data()
// Records start/end so tests can assert serial drain.
nonisolated(unsafe) static var calls: [(start: Date, end: Date)] = []
nonisolated(unsafe) static var callsLock = NSLock()
static func resetCalls() {
callsLock.lock(); defer { callsLock.unlock() }
calls = []
}
static func snapshot() -> [(start: Date, end: Date)] {
callsLock.lock(); defer { callsLock.unlock() }
return calls
}
/// Sync helper safe to call from async contexts (NSLock isn't, but a
/// non-async wrapper that does the lock + mutate + unlock is).
nonisolated static func appendCall(start: Date, end: Date) {
callsLock.lock(); defer { callsLock.unlock() }
calls.append((start, end))
}
nonisolated func flipToPassing(argsJSON: Data) {
lock.lock(); defer { lock.unlock() }
self.throwError = nil
self.argsJSON = argsJSON
}
func stream(
messages: [LLMMessage],
tools: [ToolSchema],
toolChoice: ToolChoice,
model: ModelID,
maxOutputTokens: Int,
cacheHints: CacheHints?
) -> AsyncThrowingStream<LLMEvent, Error> {
lock.lock()
let s = sleepNs
let err = throwError
let args = self.argsJSON
lock.unlock()
return AsyncThrowingStream { continuation in
Task {
let start = Date()
if s > 0 {
try? await Task.sleep(nanoseconds: s)
}
let end = Date()
Self.appendCall(start: start, end: end)
if let err = err {
continuation.finish(throwing: err)
return
}
if !args.isEmpty {
continuation.yield(.toolUseRequested(
ToolUseRequest(id: "t1", name: "apply_memory_ops", argsJSON: args)
))
}
continuation.yield(.messageStop)
continuation.finish()
}
}
}
}
/// Spy MemoryStore-shaped sink: counts applyOp invocations.
/// We can't construct a real MemoryStore without vec0.dylib.
/// MemoryExtractionOrchestrator must accept an injectable applyOp
/// callback (test seam) for non-DB tests to assert reach-through.
final class SpyApplyOp: @unchecked Sendable {
let lock = NSLock()
nonisolated(unsafe) private var _calls: [(op: MemoryOp, sourceTurnId: Int64)] = []
var calls: [(op: MemoryOp, sourceTurnId: Int64)] {
lock.lock(); defer { lock.unlock() }
return _calls
}
func record(_ op: MemoryOp, sourceTurnId: Int64) {
lock.lock(); defer { lock.unlock() }
_calls.append((op, sourceTurnId))
}
}
// MARK: - Tests
/// MEM-06 non-blocking: 100 enqueues against a stalled extractor
/// must complete in well under 500 ms. The dropOldest policy absorbs
/// overflow without ever suspending the producer.
func testEnqueueDoesNotBlockUnderOverflow() async throws {
let provider = TimedMockProvider()
provider.sleepNs = UInt64(60_000_000_000) // 60 s
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
await orchestrator.start()
let start = Date()
for i in 0..<100 {
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-\(i)"),
userText: "u\(i)",
assistantText: "a\(i)"
))
}
let elapsed = Date().timeIntervalSince(start)
XCTAssertLessThan(elapsed, 0.5,
"MEM-06 non-blocking: 100 enqueues must complete in well under 500ms regardless of extractor latency. Got \(elapsed)s")
await orchestrator.shutdown()
}
/// RESEARCH §7: serial drain — never two extract() calls in flight at once.
func testDrainTaskIsSerial() async throws {
TimedMockProvider.resetCalls()
let provider = TimedMockProvider()
provider.sleepNs = UInt64(50_000_000) // 50 ms per call
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
await orchestrator.start()
for i in 0..<5 {
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-\(i)"),
userText: "u\(i)",
assistantText: "a\(i)"
))
}
// Wait long enough for all 5 to drain serially: 5 * 50 ms + slack.
try await Task.sleep(nanoseconds: 800_000_000)
let snap = TimedMockProvider.snapshot().sorted { $0.start < $1.start }
XCTAssertEqual(snap.count, 5,
"Expected all 5 jobs to drain. Saw \(snap.count) calls.")
for i in 1..<snap.count {
XCTAssertGreaterThanOrEqual(snap[i].start, snap[i-1].end,
"Drain must be serial: call \(i) started before call \(i-1) ended.")
}
await orchestrator.shutdown()
}
// MARK: - Track-D D-3 (priorFacts wiring)
/// D-3: when `priorFactsLookup` returns a non-empty list, the extractor
/// receives those priors and the model is free to emit UPDATE (instead
/// of always ADDing). This test drives the path with a stub provider
/// that, when it sees prior facts in the system prompt, returns an
/// UPDATE op referencing the prior fact's id. Pre-D-3 this was
/// impossible — the orchestrator hardcoded `priorFacts: []` so the
/// model never knew prior facts existed and therefore never UPDATEd.
func testD3_priorFactsLookupDrivesUpdateOp() async throws {
// Stub provider that emits an UPDATE op with supersedes_fact_id=99
// when it sees "[99]" in the system prompt (the priorFacts renderer
// formats each prior fact as "[id] subject predicate object").
// Otherwise it ADDs.
final class PriorAwareProvider: LLMProvider, @unchecked Sendable {
func stream(
messages: [LLMMessage],
tools: [ToolSchema],
toolChoice: ToolChoice,
model: ModelID,
maxOutputTokens: Int,
cacheHints: CacheHints?
) -> AsyncThrowingStream<LLMEvent, Error> {
let systemText = messages
.filter { $0.role == .system }
.flatMap { $0.content }
.compactMap { block -> String? in
if case let .text(s) = block { return s }
return nil
}
.joined(separator: "\n")
let sawPriors = systemText.contains("[99]")
return AsyncThrowingStream { cont in
Task {
let ops: [[String: Any]]
if sawPriors {
ops = [[
"op": "UPDATE",
"supersedes_fact_id": 99,
"subject": "Sarah",
"predicate": "works_at",
"object": "Initech",
]]
} else {
ops = [[
"op": "ADD",
"subject": "Sarah",
"predicate": "works_at",
"object": "Initech",
]]
}
let json = try! JSONSerialization.data(withJSONObject: ["ops": ops])
cont.yield(.toolUseRequested(
ToolUseRequest(id: "t-d3", name: "apply_memory_ops", argsJSON: json)
))
cont.yield(.messageStop)
cont.finish()
}
}
}
}
let prior = Fact(
id: 99, subject: "Sarah", predicate: "works_at", object: "Acme",
sourceTurnId: 1, validFrom: 1, createdAt: 1
)
let extractor = MemoryExtractor(provider: PriorAwareProvider())
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) },
priorFactsLookup: { _ in [prior] }
)
await orchestrator.start()
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-d3"),
userText: "Sarah now works at Initech.",
assistantText: "Got it."
))
try await Task.sleep(nanoseconds: 250_000_000)
XCTAssertEqual(spy.calls.count, 1, "applyOp must be invoked exactly once")
guard case let .update(supersedes, s, p, o, _) = spy.calls.first?.op else {
return XCTFail("D-3: priorFacts presence must enable UPDATE op; got \(String(describing: spy.calls.first?.op))")
}
XCTAssertEqual(supersedes, 99, "UPDATE must reference the prior fact's id")
XCTAssertEqual(s, "Sarah")
XCTAssertEqual(p, "works_at")
XCTAssertEqual(o, "Initech")
await orchestrator.shutdown()
}
/// D-3: when `priorFactsLookup` throws, the orchestrator degrades to
/// empty priors and still processes the job — the lookup failure must
/// never be a death sentence for the drain task.
func testD3_priorFactsLookupErrorDegradesToEmpty() async throws {
struct LookupError: Error {}
let provider = TimedMockProvider()
provider.argsJSON = try JSONSerialization.data(withJSONObject: ["ops": [
["op": "ADD", "subject": "X", "predicate": "Y", "object": "Z"]
]])
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) },
priorFactsLookup: { _ in throw LookupError() }
)
await orchestrator.start()
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-d3-err"),
userText: "u", assistantText: "a"
))
try await Task.sleep(nanoseconds: 250_000_000)
XCTAssertEqual(spy.calls.count, 1,
"D-3: lookup errors must degrade to empty priors, never abort the job")
await orchestrator.shutdown()
}
/// Audit 2026-05-12 / M-4 (Issue #15) — `MemoryExtractionOrchestrator.process`
/// must NOT pass an FNV-1a hash of the TurnID UUID as `sourceTurnId`.
/// Pre-fix that value was a meaningless 64-bit hash that joined nothing
/// in the `turns` table. The sentinel 0 is the cleanest signal that
/// fact-to-turn correlation is "not yet wired" without storing nonsense.
/// (Audit option 1: drop the hash; future change may thread the real
/// `turns.id` rowid via `appendTurn` → `flushPair` → `ExtractionJob`.)
func testProcessUsesSentinelSourceTurnIdNotFNVHash() async throws {
let provider = TimedMockProvider()
provider.argsJSON = try JSONSerialization.data(withJSONObject: ["ops": [
["op": "ADD", "subject": "user", "predicate": "has_dog_named", "object": "Toby"]
]])
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
await orchestrator.start()
// A real-shape TurnID — the kind that produced
// `7747877241524896942` (FNV hash) in today's live DB pre-fix.
let realTurnId = TurnID(rawValue: "B0E2A6FC-6B0A-4F92-A3F1-D2D8AC8C0D52")
await orchestrator.enqueue(ExtractionJob(
turnId: realTurnId,
userText: "My dog's name is Toby.",
assistantText: "Got it — Toby."
))
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(spy.calls.count, 1)
let sourceTurnId = spy.calls.first?.sourceTurnId ?? -1
XCTAssertEqual(
sourceTurnId, 0,
"Issue #15: orchestrator must pass sentinel 0 — not an FNV hash — as sourceTurnId."
)
// Defense in depth: the FNV-1a hash of this UUID would be a huge
// positive Int64; assert we never see one.
XCTAssertLessThan(
sourceTurnId, 1_000_000,
"Issue #15: sourceTurnId must not look like an FNV hash."
)
await orchestrator.shutdown()
}
/// Process applies returned ops to the store via the callback.
func testProcessAppliesOpsToStore() async throws {
let provider = TimedMockProvider()
provider.argsJSON = try JSONSerialization.data(withJSONObject: ["ops": [
["op": "ADD", "subject": "Sarah", "predicate": "works_at", "object": "Acme"]
]])
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
await orchestrator.start()
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-1"),
userText: "Sarah works at Acme.",
assistantText: "Got it."
))
// wait for drain
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(spy.calls.count, 1)
guard case .add(let s, _, let o, _) = spy.calls.first?.op else {
return XCTFail("expected applyOp(.add)")
}
XCTAssertEqual(s, "Sarah")
XCTAssertEqual(o, "Acme")
await orchestrator.shutdown()
}
/// Extractor errors must NOT kill the drain task.
func testProcessNeverThrowsToProducer() async throws {
struct DummyError: Error {}
let provider = TimedMockProvider()
provider.throwError = DummyError()
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
await orchestrator.start()
// Three failing jobs.
for i in 0..<3 {
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "fail-\(i)"),
userText: "u\(i)", assistantText: "a\(i)"
))
}
try await Task.sleep(nanoseconds: 200_000_000)
// Now flip the provider to passing, enqueue a fourth, assert it processes.
let okArgs = try JSONSerialization.data(withJSONObject: ["ops": [
["op": "ADD", "subject": "X", "predicate": "Y", "object": "Z"]
]])
provider.flipToPassing(argsJSON: okArgs)
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "ok-1"),
userText: "u4", assistantText: "a4"
))
try await Task.sleep(nanoseconds: 250_000_000)
XCTAssertEqual(spy.calls.count, 1, "Drain task survived prior errors and processed the new job.")
await orchestrator.shutdown()
}
// MARK: - Coordinator tests
/// Successful turn (.endTurn) → enqueue.
func testCoordinatorEnqueuesOnTurnEndWithEndTurn() async throws {
let provider = TimedMockProvider()
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let memOrch = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
let events = BoundedAsyncChannel<OrchestratorEvent>(capacity: 16, policy: .suspend)
let captured = JobCapture()
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memOrch)
await coord.start(orchestratorEvents: events) { _ in
captured.markFetch()
return ("the user said x", "the assistant said y")
}
// Override enqueue path — give the test a way to observe the
// orchestrator's enqueue input. Easiest is to wrap our own
// orchestrator-shaped sink on top of memOrch by reading `spy.calls`
// after a tick.
let turnId = TurnID(rawValue: "turn-77")
await events.send(.turnEnd(turnId: turnId, stopReason: .endTurn))
await events.finish()
// wait for processing
try await Task.sleep(nanoseconds: 250_000_000)
await memOrch.start()
try await Task.sleep(nanoseconds: 250_000_000)
XCTAssertGreaterThanOrEqual(captured.fetchCount, 1,
"Coordinator must call turnContent for an .endTurn event.")
await coord.stop()
await memOrch.shutdown()
}
/// Refusal / max-tokens turns are dropped (no enqueue).
func testCoordinatorIgnoresTurnEndWithRefusalOrMaxTokens() async throws {
let provider = TimedMockProvider()
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let memOrch = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
let events = BoundedAsyncChannel<OrchestratorEvent>(capacity: 16, policy: .suspend)
let captured = JobCapture()
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memOrch)
await coord.start(orchestratorEvents: events) { _ in
captured.markFetch()
return ("u", "a")
}
await events.send(.turnEnd(turnId: TurnID(rawValue: "tA"), stopReason: .refusal))
await events.send(.turnEnd(turnId: TurnID(rawValue: "tB"), stopReason: .maxTokens))
await events.send(.turnEnd(turnId: TurnID(rawValue: "tC"), stopReason: .toolUse))
await events.finish()
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(captured.fetchCount, 0,
"Coordinator must NOT enqueue for non-endTurn stops.")
await coord.stop()
await memOrch.shutdown()
}
/// turnContent closure receives the matching turnId.
func testCoordinatorReadsUserAssistantViaCallback() async throws {
let provider = TimedMockProvider()
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let memOrch = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
let events = BoundedAsyncChannel<OrchestratorEvent>(capacity: 16, policy: .suspend)
let captured = JobCapture()
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memOrch)
await coord.start(orchestratorEvents: events) { id in
captured.lastTurnId = id
return ("u", "a")
}
await events.send(.turnEnd(turnId: TurnID(rawValue: "match-me"), stopReason: .endTurn))
await events.finish()
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(captured.lastTurnId?.rawValue, "match-me")
await coord.stop()
await memOrch.shutdown()
}
/// stop() cancels the subscription Task — no further events are processed.
func testCoordinatorStopCancelsTask() async throws {
let provider = TimedMockProvider()
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let memOrch = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
let events = BoundedAsyncChannel<OrchestratorEvent>(capacity: 16, policy: .suspend)
let captured = JobCapture()
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memOrch)
await coord.start(orchestratorEvents: events) { _ in
captured.markFetch()
return ("u", "a")
}
await coord.stop()
// Send an event AFTER stop — turnContent must not be called again.
let initial = captured.fetchCount
await events.send(.turnEnd(turnId: TurnID(rawValue: "post-stop"), stopReason: .endTurn))
await events.finish()
try await Task.sleep(nanoseconds: 150_000_000)
XCTAssertEqual(captured.fetchCount, initial,
"Post-stop events must not invoke turnContent.")
await memOrch.shutdown()
}
// MARK: - Phase 9 / Plan 1 — generic AsyncSequence acceptance (S-4)
/// `start(orchestratorEvents:)` now accepts any `AsyncSequence` whose
/// Element is `OrchestratorEvent`, not just `BoundedAsyncChannel`.
/// Drives the coordinator with an `AsyncStream` (the type the
/// OrchestratorEventBroadcaster's child subscriptions emit) and asserts
/// the drain loop still fires `turnContent` on `.turnEnd(.endTurn)`.
func testStartAcceptsAsyncStream() async throws {
let provider = TimedMockProvider()
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let memOrch = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memOrch)
let captured = JobCapture()
let (stream, cont) = AsyncStream<OrchestratorEvent>.makeStream()
await coord.start(orchestratorEvents: stream) { id in
captured.lastTurnId = id
captured.markFetch()
return ("u-text", "a-text")
}
let turnId = TurnID(rawValue: "asyncstream-1")
cont.yield(.turnEnd(turnId: turnId, stopReason: .endTurn))
cont.finish()
// Wait until turnContent has been called (the closure is the only
// observable side effect short of running the full extractor stack).
let deadline = Date().addingTimeInterval(2.0)
while captured.fetchCount == 0 && Date() < deadline {
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertGreaterThanOrEqual(captured.fetchCount, 1,
"S-4 generalization broke: AsyncStream input failed to drive coord.start")
XCTAssertEqual(captured.lastTurnId?.rawValue, "asyncstream-1")
await coord.stop()
await memOrch.shutdown()
}
// MARK: - shared capture
final class JobCapture: @unchecked Sendable {
let lock = NSLock()
nonisolated(unsafe) private var _fetchCount: Int = 0
nonisolated(unsafe) var lastTurnId: TurnID?
var fetchCount: Int {
lock.lock(); defer { lock.unlock() }
return _fetchCount
}
func markFetch() {
lock.lock(); defer { lock.unlock() }
_fetchCount += 1
}
}
}