fix(b-02): thread recent turns into AgentOrchestrator messages array

Carry-forward bug B-02 (live evidence 2026-05-07): conversation
continuity broken — "yes" / "why 4.5?" lose prior turn context because
AgentOrchestrator.process(_:) built [system, user(input.userText)]
with no history fetch.

Tactical patch outside the M-0..M-7 migration sequence, per
JARVIS-API-MIGRATION-PLAN.md §3 and UQ-2 (option B). Lands on develop
directly. M-4 (Memory surface) will reframe this path against the new
API contract but the user-visible bug closes today.

Substrate gap discovered + addressed in-scope. The `turns` table was
unwritten (schema present at MemorySchema.swift, FTS5 trigger ready,
read API at MemoryStore.recentTurnsForSession, but no INSERT site
anywhere in the codebase). The migration plan assumed turn writes
existed; they did not. Patch adds the missing writer plus the read
side wiring.

Changes:

- packages/Memory/Sources/Memory/MemoryQueries.swift
    + turnInsertSQL — single-row INSERT into turns
- packages/Memory/Sources/Memory/MemoryStore.swift
    + appendTurn(sessionId:role:content:source:createdAt:)
    Single-row INSERT; FTS5 trigger mirrors content into turns_fts
    automatically. T-06-05-03: function MUST NOT log content.
- packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
    + PriorTurn struct + SessionHistoryLookup typealias
    + emptySessionHistoryLookup default (preserves pre-B-02 test setups)
    + sessionHistoryLookup ctor parameter
    Replaces the [system, user] messages-array build with prior-history
    hydration: system + chronological prior user/assistant rows + current
    user. Lookup throws -> warning log + empty history (matches D-3
    PriorFactsLookup resilience contract).
- App/AppDelegate.swift
    + sessionHistoryLookup closure: recentTurnsForSession(limit: 10),
    reverse DESC -> chronological, filter to user/assistant only, return
    [PriorTurn]
    + extends existing turnContent flush closure with appendTurn calls
    (rides on memory-extraction drain path; assistant row offset +1 ms
    so DESC + id-tiebreaker preserves user->assistant order)

Tests:

- packages/AgentCore/Tests/AgentOrchestratorTests/HistoryThreadingRegressionTests.swift
    HT-1: third turn includes prior user/assistant pairs in chronological
    order. 3/3 PASS.
    HT-2: empty-history default produces [system, user] (pre-B-02
    baseline preserved).
    HT-3: three-turn happy path emits zero LLMProviderError
    .streamTruncatedFinal events. Pins R-006 / Phase E gate behavior:
    patch grows messages array, not finalSystem, so
    CacheHints.eligibleForSystemPrompt(finalSystem) is unchanged.
- packages/Memory/Tests/MemoryTests/MemoryStoreAppendTurnTests.swift
    AT-1: appendTurn round-trips via recentTurnsForSession.
    AT-2: user/assistant pair reverses to chronological.
    AT-3: session_id scoping isolates rows.
    AT-4: turns_fts trigger fires on append.
    All env-gated on JARVIS_VEC0_STUB_PATH per existing MemoryStore-test
    convention; skip cleanly without vec0.dylib.

Verification:

- check-app-builds.sh PASS.
- Boundary gates sampled (check-no-leftover-stubs, applescript-confirm,
  corpus-secrets, bus-protocol-version, single-memory-mutated-emit,
  single-writer-hudstate) PASS.
- AgentCore affected-subset (Submit/Retry/TextEnd2End/CapRecovery/
  HistoryThreading) 26/26 PASS, 0 failures.
- Memory full suite 92 run, 23 skipped (env-gated), 0 failures.

Degradation contract: when MemoryStore is nil (dormant memory — vec0
or Ollama unavailable), turn persistence is also dormant; the
appendTurn call is guarded by memoryStore != nil. When D-5/D-6 close
and memory comes up live, persistence activates without further code
changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 4.7 committed 2026-05-11 08:50:40 -05:00
1 parent 46590cd9aa
commit 48ad8d631c
8 files changed
+609 -28

No files matched your search

+75 -1
View File
@@ -1325,6 +1325,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
return catalog
}
// B-02 (carry-forward bug, tactical patch outside v1.0 migration):
// hydrate prior turn history from the `turns` table so multi-turn
// conversations preserve context. `recentTurnsForSession` returns
// rows in DESC order; reverse to chronological so the LLM sees
// user → assistant → user → assistant alternation. Filter to
// user/assistant only — tool-role rows (if any are written later)
// do not belong in the prior-history prefix. Cap at 10 turn rows
// (~5 prior user/assistant pairs) to bound prompt growth.
let sessionIdString = sessionId.rawValue
let memoryStoreRef = self.memoryStore
let historyLogger = self.systemLogger
let sessionHistoryLookup: AgentOrchestrator.SessionHistoryLookup = {
guard let store = memoryStoreRef else { return [] }
do {
let rows = try await store.recentTurnsForSession(
sessionId: sessionIdString, limit: 10
)
let chronological = Array(rows.reversed())
return chronological.compactMap { row in
switch row.role {
case "user":
return AgentOrchestrator.PriorTurn(role: .user, content: row.content)
case "assistant":
return AgentOrchestrator.PriorTurn(role: .assistant, content: row.content)
default:
return nil
}
}
} catch {
// T-06-05-03: log the error, never the row content.
historyLogger?.warning("sessionHistoryLookup failed: \(error)")
return []
}
}
let orchestrator = AgentOrchestrator(
configStore: configStore,
providerFactory: providerFactory,
@@ -1343,7 +1378,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
availableTools: [],
availableToolsResolver: toolCatalogResolver,
visionRouter: self.visionRouter,
presenceSnapshot: PresenceStateSnapshot.shared
presenceSnapshot: PresenceStateSnapshot.shared,
sessionHistoryLookup: sessionHistoryLookup
)
self.agentOrchestrator = orchestrator
@@ -1388,6 +1424,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// and Plan 4's submit-time user-side append) rather
// than the Phase-7-era nil stub.
if let pair = await self?.turnTranscriptStore?.flushPair(turnId) {
// B-02 (carry-forward bug, tactical patch outside
// v1.0 migration): persist the completed turn pair
// to the `turns` table so subsequent turns'
// sessionHistoryLookup can hydrate prior context.
// Persistence rides on the existing turnContent
// flush path because TurnTranscriptStore.flushPair
// is destructive — single-consumer invariant
// preserved. Both rows share the turn's
// chronological position; we offset assistant by
// +1 ms so the DESC + id-tiebreaker ordering in
// sessionHistorySQL reconstructs the pair in
// user→assistant order.
// T-06-05-03: log only the turn id hash and op
// outcome; NEVER log row content.
if let store = await self?.memoryStore {
let sessionIdValue = sessionIdString
let now = Int64(Date().timeIntervalSince1970 * 1000)
do {
try await store.appendTurn(
sessionId: sessionIdValue,
role: "user",
content: pair.userText,
source: "userText",
createdAt: now
)
try await store.appendTurn(
sessionId: sessionIdValue,
role: "assistant",
content: pair.assistantText,
source: "assistant",
createdAt: now + 1
)
} catch {
await self?.systemLogger?.warning(
"B-02 turn-persistence appendTurn failed: \(error)"
)
}
}
return (user: pair.userText, assistant: pair.assistantText)
}
return nil
+24 -9
View File
@@ -1,19 +1,34 @@
# Session State
**Last Updated:** 2026-05-07 (evening — swarm complete; v1.0 contract locked; migration plan + test contract shipped)
**Last Updated:** 2026-05-11 (morning — B-02 tactical patch shipped; migration M-0 next)
## ▶ Next Session: Cross-AI peer review + B-02 patch + M-0 — read FIRST
## ▶ Next Session: M-0 (pre-migration gates) — read FIRST
The architectural-pivot design swarm executed end-to-end on 2026-05-07. The v1.0 API contract is **locked** as the pair `JARVIS-API-DESIGN-v0.1.md` + `JARVIS-API-DESIGN-v0.2.md` (UQ-1..UQ-5 answered in v0.2 §5). All artifacts under `.planning/architecture/`; index in `.planning/architecture/README.md`.
The architectural-pivot design swarm executed end-to-end on 2026-05-07; B-02 tactical patch shipped 2026-05-11. The v1.0 API contract is **locked** as the pair `JARVIS-API-DESIGN-v0.1.md` + `JARVIS-API-DESIGN-v0.2.md` (UQ-1..UQ-5 answered in v0.2 §5). All artifacts under `.planning/architecture/`; index in `.planning/architecture/README.md`.
**Next session's actions in order:**
1. Cross-AI peer review of v1.0 (GPT-5 Pro / Gemini 2.5 Pro / fresh Opus). Iterate to v1.1 if findings warrant.
2. **B-02 tactical patch on `develop` directly** — single commit + regression test for `streamTruncated`. Specified in `JARVIS-API-MIGRATION-PLAN.md §3`.
3. **M-0** (pre-migration gates) directly on `develop`. Creates `packages/JarvisAPI/`, lifts `RealWKWebViewIntegrationTests` to a public harness runner, promotes grep gates to behavioral, dual-version handshake. ~2.5–3.5 engineer-days.
4. **M-1** (Self surface) on its own feature branch + PR — first surface migration; closes B-08.
5. M-2..M-7 in sequence on per-step feature branches per the migration plan.
1. **M-0** (pre-migration gates) directly on `develop`. Creates `packages/JarvisAPI/`, lifts `RealWKWebViewIntegrationTests` to a public harness runner, promotes grep gates to behavioral, dual-version handshake. ~2.5–3.5 engineer-days.
2. **M-1** (Self surface) on its own feature branch + PR — first surface migration; closes B-08.
3. M-2..M-7 in sequence on per-step feature branches per the migration plan.
4. Optional: cross-AI peer review of v1.0 (GPT-5 Pro / Gemini 2.5 Pro / fresh Opus). Deferred unless a migration step surfaces a design question.
Total estimated migration: **19–26 engineer-days (~4–5.5 calendar weeks at solo pace).**
Total estimated migration remaining: **18.5–25.5 engineer-days (~4–5.5 calendar weeks at solo pace).** B-02 closed today.
## ▶ 2026-05-11 morning — B-02 tactical patch
`develop` advances past `46590cd` with one commit closing B-02. Substrate gap discovered + addressed in-scope: the `turns` table was unwritten (schema present, FTS5 trigger ready, but no `INSERT` site anywhere). Patch adds `MemoryStore.appendTurn` writer and wires both sides — orchestrator `sessionHistoryLookup` closure (read) and AppDelegate `turnContent` (write piggybacked on existing memory-extraction flush path).
- **Patch site (read side):** `packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift` — adds `PriorTurn` struct, `SessionHistoryLookup` typealias with `emptySessionHistoryLookup` default (preserves pre-B-02 test setups), ctor parameter, hydration logic that prepends prior turns between system prompt and current user message in chronological order.
- **Patch site (write side):** `packages/Memory/Sources/Memory/MemoryStore.swift` `appendTurn(sessionId:role:content:source:createdAt:)` + `MemoryQueries.turnInsertSQL`. Single-row INSERT; FTS5 trigger mirrors `content` into `turns_fts` automatically.
- **AppDelegate wiring:** `App/AppDelegate.swift` constructs `sessionHistoryLookup` closure calling `recentTurnsForSession(limit: 10)` and reversing DESC → chronological; passes it to `AgentOrchestrator` init. `turnContent` closure (existing memory-extraction path) gains a side-effect: write the flushed pair via `appendTurn` before returning. Assistant row offset by +1 ms so DESC + id-tiebreaker preserves user→assistant order.
- **Regression coverage:**
- `HistoryThreadingRegressionTests.HT-1` — three-turn integration; asserts third call's messages array is `[system, user("ok"), assistant("ack"), user("yes"), assistant("ack2"), user("why?")]`.
- `HistoryThreadingRegressionTests.HT-2` — empty-history default produces `[system, user]` (pre-B-02 baseline preserved).
- `HistoryThreadingRegressionTests.HT-3` — three-turn happy path emits zero `LLMProviderError.streamTruncatedFinal` events. Pins R-006 / Phase E gate behavior — the patch grows the messages array, not the system prompt, so `CacheHints.eligibleForSystemPrompt(finalSystem)` is unchanged.
- `MemoryStoreAppendTurnTests.AT-1..4` — append round-trip, user/assistant pair chronological reconstruction, session scoping, FTS5 trigger fires. Env-gated on `JARVIS_VEC0_STUB_PATH` per existing MemoryStore-test convention; skip without vec0.dylib.
- **Honesty note:** in dormant-memory mode (no vec0/no Ollama), turn persistence is also dormant — the `appendTurn` call is guarded by `memoryStore != nil`. When D-5/D-6 land and memory comes up live, persistence activates without further code changes.
## ▶ 2026-05-07 evening — Design swarm output
## ▶ Earlier this session — Design swarm output (2026-05-07 evening)
+14 -14
View File
@@ -2,30 +2,30 @@
Live triage list. Higher up = higher priority. Move done items to a dated archive section if the file gets long.
## ▶ TOP — Migration to v1.0 API begins (2026-05-07 evening)
## ▶ TOP — Migration to v1.0 API in flight (2026-05-11)
**v1.0 contract LOCKED** as the pair `JARVIS-API-DESIGN-v0.1.md` + `JARVIS-API-DESIGN-v0.2.md` (UQ-1..UQ-5 answered). Index: `.planning/architecture/README.md`. Migration plan: `JARVIS-API-MIGRATION-PLAN.md`. Test contract: `JARVIS-API-TEST-CONTRACT.md`. Skeleton: `Tools/jarvis-diag/` (compiles green).
- [x] **2026-05-07 evening:** 6-agent design swarm executed end-to-end. v0.1 (1036 lines, 7 surfaces) + v0.2 (synthesis + UQ answers) + 3 reviews + migration plan + test contract + skeleton, all under `.planning/architecture/`.
- [ ] **Next session — cross-AI peer review of v1.0** (GPT-5 Pro / Gemini 2.5 Pro / fresh Opus). Iterate to v1.1 only if findings warrant.
- [ ] **After peer review — B-02 tactical patch on `develop` directly** — single commit + `streamTruncated` regression test. Specified in `JARVIS-API-MIGRATION-PLAN.md §3`.
- [ ] **Then — M-0 (pre-migration gates) directly on `develop`** — `packages/JarvisAPI/`, lift `RealWKWebViewIntegrationTests` to public harness runner, promote 3 grep gates to behavioral, dual-version handshake. ~2.5–3.5 engineer-days.
- [x] **2026-05-07 evening:** 6-agent design swarm executed end-to-end. All artifacts in `.planning/architecture/` (`46590cd`).
- [x] **2026-05-11 morning:** **B-02 tactical patch on `develop`** — `MemoryStore.appendTurn` + orchestrator `sessionHistoryLookup` + AppDelegate wiring of both write and read sides. Substrate gap (turns table was unwritten) discovered and addressed in-scope: `appendTurn` now persists every turn pair to `jarvis.db`. HT-1/2/3 regression tests cover the messages array shape + Phase E `streamTruncated` gate (R-006).
- [ ] **Next — M-0 (pre-migration gates) directly on `develop`** — `packages/JarvisAPI/`, lift `RealWKWebViewIntegrationTests` to public harness runner, promote 3 grep gates to behavioral, dual-version handshake. ~2.5–3.5 engineer-days.
- [ ] **Then — M-1 (Self surface) on a feature branch + PR** — first surface migration; closes B-08.
- [ ] **Then — M-2..M-7** per migration plan, each on own feature branch + PR.
- [ ] **Optional — cross-AI peer review of v1.0** (GPT-5 Pro / Gemini 2.5 Pro / fresh Opus). Deferred unless a migration step surfaces design questions.
**Until v1.0 implementation begins, DO NOT fix B-02..B-08 piecemeal** — they re-scope against the v1.0 contract per the migration plan. (B-02 has its own one-commit tactical patch path per UQ-2.)
**Until v1.0 implementation begins, DO NOT fix B-03..B-08 piecemeal** — they re-scope against the v1.0 contract per the migration plan.
## Carry-forward bugs (re-scope against new API)
## Carry-forward bugs
Live evidence collected during 2026-05-07 user testing. All re-scope against the new API contract — do NOT fix piecemeal in the next session.
Live evidence collected during 2026-05-07 user testing.
- [ ] B-02 — Conversation continuity broken (prior turn context lost across turns)
- [ ] B-03 — HUD camera button click does nothing
- [ ] B-04 — Voice input dead (wake-word + STT silent)
- [ ] B-05 — TTS silent (text replies not spoken)
- [ ] B-06 — Chat panel doesn't auto-scroll
- [x] **B-02 — Conversation continuity** — closed by tactical patch 2026-05-11 (this commit). HT-1/2/3 regression coverage in `HistoryThreadingRegressionTests`; `AT-1..4` in `MemoryStoreAppendTurnTests` (env-gated on `JARVIS_VEC0_STUB_PATH`).
- [ ] B-03 — HUD camera button click does nothing (lands M-5)
- [ ] B-04 — Voice input dead (wake-word + STT silent) (lands M-6)
- [ ] B-05 — TTS silent (text replies not spoken) (lands M-6, UQ-3 contract: `Voice.synthesizeTurn`)
- [ ] B-06 — Chat panel doesn't auto-scroll (lands M-7; webview-DOM out of harness scope)
- [ ] B-07 — Voice Log menu item missing (Plan 10-05 deferred)
- [ ] B-08 — `get_self_state` correct ID but Claude paraphrases as "Opus 4.5" — minor
- [ ] B-08 — `get_self_state` correct ID but Claude paraphrases as "Opus 4.5" — minor (lands M-1)
## Substrate fixes shipped 2026-05-07
@@ -28,6 +28,43 @@ import Logging
/// **SEC-06:** `turnNonce` never appears in any `OrchestratorEvent`. Only
/// the model-facing prompt and the replay log row see it.
public actor AgentOrchestrator {
// MARK: - B-02 history threading
/// One prior turn entry hydrated from the `turns` table (or any caller-
/// supplied source). Role is constrained to user/assistant — the
/// orchestrator's prior-history prefix never contains tool blocks
/// (those are reconstructed locally per current turn).
public struct PriorTurn: Sendable, Equatable {
public enum Role: String, Sendable, Equatable {
case user
case assistant
}
public let role: Role
public let content: String
public init(role: Role, content: String) {
self.role = role
self.content = content
}
}
/// B-02 (carry-forward bug, closed by tactical patch outside the v1.0
/// migration sequence): closure returning the most recent N prior
/// turn rows in CHRONOLOGICAL order (oldest first). Production
/// wiring lives in `App/AppDelegate.swift` and calls
/// `MemoryStore.recentTurnsForSession`. The default implementation
/// `emptySessionHistoryLookup` returns [] to preserve pre-B-02
/// behavior in any test setup that doesn't supply a lookup.
///
/// Failure mode: the closure throws → orchestrator degrades to empty
/// history with a warning log; the turn proceeds without prior
/// context (same as pre-B-02 behavior). Matches the D-3
/// `PriorFactsLookup` resilience contract.
public typealias SessionHistoryLookup = @Sendable () async throws -> [PriorTurn]
public static let emptySessionHistoryLookup: SessionHistoryLookup = { [] }
// MARK: - Injected dependencies
private let configStore: ConfigStore
@@ -62,6 +99,12 @@ public actor AgentOrchestrator {
/// the orchestrator side. VISION-03 boundary: the orchestrator only
/// touches the `String?` return type — never the underlying PresenceEvent.
private let presenceSnapshot: PresenceStateSnapshot?
/// B-02 history lookup closure (see `SessionHistoryLookup` doc on the
/// enclosing actor). Defaulted to `emptySessionHistoryLookup` so test
/// sites that don't care about history still compile unchanged.
private let sessionHistoryLookup: SessionHistoryLookup
private let logger: Logger
// MARK: - Outbound channel
@@ -107,7 +150,8 @@ public actor AgentOrchestrator {
availableTools: [ToolSchema] = [],
availableToolsResolver: (@Sendable () async -> [ToolSchema])? = nil,
visionRouter: VisionRouter? = nil,
presenceSnapshot: PresenceStateSnapshot? = nil
presenceSnapshot: PresenceStateSnapshot? = nil,
sessionHistoryLookup: @escaping SessionHistoryLookup = AgentOrchestrator.emptySessionHistoryLookup
) {
self.configStore = configStore
self.providerFactory = providerFactory
@@ -119,6 +163,7 @@ public actor AgentOrchestrator {
self.availableToolsResolver = availableToolsResolver
self.visionRouter = visionRouter
self.presenceSnapshot = presenceSnapshot
self.sessionHistoryLookup = sessionHistoryLookup
self.logger = Logger(label: JarvisLogChannel.agent.rawValue)
self.events = BoundedAsyncChannel<OrchestratorEvent>(capacity: 256, policy: .suspend)
}
@@ -280,10 +325,42 @@ public actor AgentOrchestrator {
} else {
finalSystem = composedSystem
}
let initialMessages: [LLMMessage] = [
LLMMessage(role: .system, content: [.text(finalSystem)]),
LLMMessage(role: .user, content: [.text(input.userText)]),
// B-02 (carry-forward bug, fixed by tactical patch outside the
// v1.0 migration sequence): hydrate prior turn history from the
// session-history lookup and prepend between system prompt and
// current user message. Without this, every turn looked like a
// first message to the model — "yes" / "why?" / pronoun
// resolution lost prior turn context.
//
// Failure mode: lookup throws → warning log + empty history.
// Matches the D-3 priorFactsLookup pattern; drain task survives.
//
// The lookup returns turns in CHRONOLOGICAL order (oldest first)
// so the LLM sees the natural alternation user → assistant →
// user → assistant → … → current user.
//
// T-06-05-03: do NOT log row content; turn text is user-bearing
// and may contain PII. Count only.
let priorTurns: [PriorTurn]
do {
priorTurns = try await sessionHistoryLookup()
} catch {
logger.warning("sessionHistoryLookup failed: \(error) — proceeding with empty history")
priorTurns = []
}
let priorMessages: [LLMMessage] = priorTurns.map { turn in
switch turn.role {
case .user:
return LLMMessage(role: .user, content: [.text(turn.content)])
case .assistant:
return LLMMessage(role: .assistant, content: [.text(turn.content)])
}
}
var initialMessages: [LLMMessage] = [
LLMMessage(role: .system, content: [.text(finalSystem)])
]
initialMessages.append(contentsOf: priorMessages)
initialMessages.append(LLMMessage(role: .user, content: [.text(input.userText)]))
// Phase E (2026-05-03 audit fix): gate `extended1h` cache hints on
// system-prompt size. Anthropic returns 200 OK + immediate-EOF for
@@ -0,0 +1,226 @@
import XCTest
import AgentCore
import Config
import Replay
@testable import AgentOrchestrator
/// **B-02 carry-forward bug — tactical patch regression coverage.**
///
/// Pinned by `JARVIS-API-MIGRATION-PLAN.md §3` outside the M-0..M-7
/// migration sequence: orchestrator must prepend prior turn history to
/// the messages array before the current user message, and the prepend
/// must NOT trigger Phase E's `streamTruncated` retry (R-006 mitigation
/// pinning the 2026-05-03 audit's cache-eligibility gate behavior).
///
/// The patch threads history via an injected `SessionHistoryLookup`
/// closure (no `Memory` package dep — AgentCore stays decoupled).
/// Production wiring lives in `AppDelegate.installAgent` and reads from
/// `MemoryStore.recentTurnsForSession`.
@MainActor
final class HistoryThreadingRegressionTests: XCTestCase {
var tempHome: TempReplayHome!
override func setUp() async throws {
try await super.setUp()
tempHome = TempReplayHome.make()
}
override func tearDown() async throws {
tempHome?.cleanup()
try await super.tearDown()
}
private func buildOrchestrator(
provider mock: MockLLMProvider,
history: [AgentOrchestrator.PriorTurn]
) async throws -> (AgentOrchestrator, ReplayLog, SessionID) {
let replay = try ReplayLog(databaseURL: tempHome.dbURL)
let session = try await replay.beginSession(appVersion: "test", buildSHA: "deadbeef")
let frozen = history // value-capture for the @Sendable closure
let orch = AgentOrchestrator(
configStore: makeConfigStore(),
providerFactory: { _ in mock },
toolDispatcher: StubToolDispatcher(),
replayLog: replay,
sessionId: session,
systemPrompt: "you are jarvis",
availableTools: [],
sessionHistoryLookup: { frozen }
)
return (orch, replay, session)
}
/// Drain `events` until `predicate` matches, with a timeout.
private func collectUntil(
orch: AgentOrchestrator,
timeout: TimeInterval = 5.0,
predicate: @escaping (OrchestratorEvent) -> Bool
) async -> [OrchestratorEvent] {
var collected: [OrchestratorEvent] = []
let deadline = Date().addingTimeInterval(timeout)
for await event in orch.events {
collected.append(event)
if predicate(event) { return collected }
if Date() > deadline { return collected }
}
return collected
}
// MARK: - HT-1
/// HT-1: a turn with two prior pairs in the lookup MUST send a messages
/// array containing `[system, .user("ok"), .assistant("ack"), .user("yes"),
/// .assistant("ack2"), .user("why?")]` — chronological, alternating, with
/// the current user message LAST. This is the B-02 fix made falsifiable.
func test_HT1_thirdTurnIncludesPriorTwoUserAssistantPairs() async throws {
let mock = MockLLMProvider(script: .init(events: [
.messageStart(LLMMessageStart(messageId: "m1", model: "claude-opus-4-7", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]))
let history: [AgentOrchestrator.PriorTurn] = [
.init(role: .user, content: "ok"),
.init(role: .assistant, content: "ack"),
.init(role: .user, content: "yes"),
.init(role: .assistant, content: "ack2"),
]
let (orch, _, _) = try await buildOrchestrator(provider: mock, history: history)
let outcome = await orch.submit(.text("why?"))
guard case .ran = outcome else { return XCTFail("expected .ran, got \(outcome)") }
// Drain to idle so the call is recorded.
_ = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }
return false
}
let recorded = await mock.getRecordedCalls()
XCTAssertEqual(recorded.count, 1, "expected one stream call for the single turn")
let msgs = recorded[0].messages
// System first.
XCTAssertEqual(msgs.first?.role, .system, "first message must be .system")
// Roles after system: user, assistant, user, assistant, user.
let nonSystem = msgs.dropFirst().map { $0.role }
XCTAssertEqual(
Array(nonSystem),
[.user, .assistant, .user, .assistant, .user],
"prior history must precede current user message in chronological order"
)
// Content equality on the prior-history slice.
let textOf: (LLMMessage) -> String? = { msg in
for block in msg.content {
if case let .text(s) = block { return s }
}
return nil
}
XCTAssertEqual(textOf(msgs[1]), "ok")
XCTAssertEqual(textOf(msgs[2]), "ack")
XCTAssertEqual(textOf(msgs[3]), "yes")
XCTAssertEqual(textOf(msgs[4]), "ack2")
XCTAssertEqual(textOf(msgs[5]), "why?")
}
// MARK: - HT-2
/// HT-2: with an empty history (the default `emptySessionHistoryLookup`),
/// behavior is identical to pre-B-02: `[system, current-user]`. Regression
/// guard so the default-closure path doesn't silently inject anything.
func test_HT2_emptyHistoryProducesSystemPlusUserOnly() async throws {
let mock = MockLLMProvider(script: .init(events: [
.messageStart(LLMMessageStart(messageId: "m", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]))
let (orch, _, _) = try await buildOrchestrator(provider: mock, history: [])
_ = await orch.submit(.text("hello"))
_ = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }
return false
}
let recorded = await mock.getRecordedCalls()
XCTAssertEqual(recorded.count, 1)
let roles = recorded[0].messages.map { $0.role }
XCTAssertEqual(roles, [.system, .user])
}
// MARK: - HT-3 (R-006 mitigation — Phase E gate behavior)
/// HT-3: three short turns. The B-02 patch MUST NOT cause any
/// `.streamTruncatedFinal` event to land on the bus. The patch grows
/// the *messages* array, not the system prompt — Phase E's
/// `CacheHints.eligibleForSystemPrompt(finalSystem)` gate is computed
/// from the system prompt alone, so the cache-eligibility flip the
/// 2026-05-03 audit fixed is unaffected by this change. This test
/// pins that property: a normal happy-path sequence emits ZERO
/// `streamTruncated` retry events.
///
/// Note: MockLLMProvider doesn't produce real Anthropic responses, so
/// this test cannot reproduce the *network-side* streamTruncated.
/// It pins the orchestrator-side invariant: no retry path is
/// reached when the provider behaves normally. The full integration
/// signal lives in the env-flag-gated AnthropicProviderTests
/// (`JARVIS_REAL_MODELS=1`).
func test_HT3_twoShortTurnsThenThird_NoStreamTruncatedRetries() async throws {
// Build with one prior pair to exercise the patched path while
// keeping the system prompt small (the cache-eligibility gate
// input).
let history: [AgentOrchestrator.PriorTurn] = [
.init(role: .user, content: "ok"),
.init(role: .assistant, content: "ack"),
]
let mock = MockLLMProvider(scripts: [
.init(events: [
.messageStart(LLMMessageStart(messageId: "m1", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]),
.init(events: [
.messageStart(LLMMessageStart(messageId: "m2", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]),
.init(events: [
.messageStart(LLMMessageStart(messageId: "m3", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]),
])
let (orch, _, _) = try await buildOrchestrator(provider: mock, history: history)
// Drain to idle between submits — orchestrator rejects concurrent
// submits, so we serialize.
var allEvents: [OrchestratorEvent] = []
for text in ["ok", "yes", "why?"] {
let outcome = await orch.submit(.text(text))
guard case .ran = outcome else {
return XCTFail("submit(\(text)) expected .ran, got \(outcome)")
}
let drained = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }
return false
}
allEvents.append(contentsOf: drained)
}
// R-006 regression: ZERO streamTruncatedFinal across the three turns.
// The orchestrator surfaces a terminal streamTruncated via
// `.error(turnId:, error: .streamTruncatedFinal)` (LLMProviderError),
// not via `.turnEnd`.
for event in allEvents {
if case let .error(_, providerError) = event {
XCTAssertNotEqual(
providerError,
LLMProviderError.streamTruncatedFinal,
"B-02 patch must not introduce streamTruncatedFinal — pins Phase E gate behavior (R-006)"
)
}
}
}
}
@@ -71,6 +71,15 @@ public enum MemoryQueries {
LIMIT ?;
"""
/// B-02 turn-row writer. Single row per (role, content) pair.
/// Bindings (positional): session_id, role, content, source, created_at.
/// The schema's FTS5 trigger (MemorySchema.swift:74) mirrors `content`
/// into `turns_fts` automatically — no separate insert needed.
public static let turnInsertSQL: String = """
INSERT INTO turns(session_id, role, content, source, created_at)
VALUES (?, ?, ?, ?, ?);
"""
/// D-02 forget tool dispatch. Bindings: now, now, fact_id.
/// `WHERE valid_to IS NULL AND forgotten_at IS NULL` makes this idempotent
/// (second call matches 0 rows). NEVER DELETEs.
@@ -342,6 +342,37 @@ public actor MemoryStore {
}
}
/// B-02 turn-row writer. Appends a single row to the `turns` table.
/// The schema's FTS5 trigger mirrors `content` into `turns_fts`
/// automatically; no separate FTS insert is needed.
///
/// `role` is "user" | "assistant" | "tool" (matches TurnRow.role).
/// `source` is the TurnSource rawValue ("userText", "wakeWord",
/// "assistant", etc.); arbitrary string — the schema doesn't enforce
/// an enum and consumers treat it as informational.
/// `createdAt` is unix-ms; callers MUST pass distinct timestamps for
/// user vs assistant rows of the same turn so the DESC + id-tiebreaker
/// ordering in `sessionHistorySQL` reconstructs chronological order
/// correctly.
///
/// T-06-05-03: this function MUST NOT log `content` — turn text is
/// user-bearing and may contain PII.
public func appendTurn(
sessionId: String,
role: String,
content: String,
source: String,
createdAt: Int64
) throws {
try conn.exec(MemoryQueries.turnInsertSQL, bindings: [
.text(sessionId),
.text(role),
.text(content),
.text(source),
.int(createdAt),
])
}
/// TEXT-03 chat-panel hydration source. Returns the most recent `limit`
/// turns for the given session, ordered by created_at DESC.
public func recentTurnsForSession(sessionId: String, limit: Int) throws -> [TurnRow] {
@@ -0,0 +1,149 @@
import XCTest
import Foundation
@testable import Memory
/// **B-02 carry-forward bug — turn-row writer unit test.**
///
/// Exercises the new `MemoryStore.appendTurn` writer that backs the
/// orchestrator's `sessionHistoryLookup` path. Without a writer, the
/// `turns` table stays empty and B-02's `recentTurnsForSession` read
/// returns []. This suite proves the writer round-trips through the
/// schema's FTS5 trigger correctly.
///
/// Tests are env-gated (`JARVIS_VEC0_STUB_PATH`) because `MemoryStore.init`
/// requires vec0.dylib loaded; without the env var the suite skips —
/// matching the existing MemoryStore test corpus convention.
final class MemoryStoreAppendTurnTests: XCTestCase {
// MARK: - AT-1
/// AT-1: appendTurn writes one row that recentTurnsForSession surfaces.
/// Round-trips role/content/source verbatim and increments the row count.
func testAT1_appendTurnRoundTripsViaRecentTurnsForSession() async throws {
try XCTSkipUnless(
ProcessInfo.processInfo.environment["JARVIS_VEC0_STUB_PATH"] != nil,
"Set JARVIS_VEC0_STUB_PATH to a vec0.dylib to exercise appendTurn."
)
let tmp = try Self.tempDB()
let store = try MemoryStore(databaseURL: tmp)
let sessionId = "S-AT1"
try await store.appendTurn(
sessionId: sessionId,
role: "user",
content: "hello jarvis",
source: "userText",
createdAt: 1_000
)
let rows = try await store.recentTurnsForSession(sessionId: sessionId, limit: 10)
XCTAssertEqual(rows.count, 1)
XCTAssertEqual(rows[0].sessionId, sessionId)
XCTAssertEqual(rows[0].role, "user")
XCTAssertEqual(rows[0].content, "hello jarvis")
XCTAssertEqual(rows[0].source, "userText")
XCTAssertEqual(rows[0].createdAt, 1_000)
}
// MARK: - AT-2
/// AT-2: appendTurn for a (user, assistant) pair produces DESC ordering by
/// created_at + id-tiebreaker that, when reversed, reconstructs
/// chronological user→assistant order — the contract the AppDelegate
/// production wiring relies on.
func testAT2_userAssistantPairReversesToChronological() async throws {
try XCTSkipUnless(
ProcessInfo.processInfo.environment["JARVIS_VEC0_STUB_PATH"] != nil,
"Set JARVIS_VEC0_STUB_PATH to a vec0.dylib to exercise appendTurn."
)
let tmp = try Self.tempDB()
let store = try MemoryStore(databaseURL: tmp)
let sessionId = "S-AT2"
// Production callers offset assistant by +1 ms so the DESC ordering
// is unambiguous; pin that contract here too.
try await store.appendTurn(
sessionId: sessionId, role: "user",
content: "hi", source: "userText", createdAt: 100
)
try await store.appendTurn(
sessionId: sessionId, role: "assistant",
content: "hello back", source: "assistant", createdAt: 101
)
let descRows = try await store.recentTurnsForSession(sessionId: sessionId, limit: 10)
XCTAssertEqual(descRows.count, 2)
// DESC: assistant first, user second.
XCTAssertEqual(descRows[0].role, "assistant")
XCTAssertEqual(descRows[1].role, "user")
// Reverse to chronological — the orchestrator's sessionHistoryLookup
// contract.
let chrono = Array(descRows.reversed())
XCTAssertEqual(chrono.map(\.role), ["user", "assistant"])
XCTAssertEqual(chrono.map(\.content), ["hi", "hello back"])
}
// MARK: - AT-3
/// AT-3: appendTurn is scoped by session_id. Two sessions don't bleed.
func testAT3_sessionScopingIsolatesRows() async throws {
try XCTSkipUnless(
ProcessInfo.processInfo.environment["JARVIS_VEC0_STUB_PATH"] != nil,
"Set JARVIS_VEC0_STUB_PATH to a vec0.dylib to exercise appendTurn."
)
let tmp = try Self.tempDB()
let store = try MemoryStore(databaseURL: tmp)
try await store.appendTurn(
sessionId: "S-A", role: "user", content: "a-content",
source: "userText", createdAt: 1
)
try await store.appendTurn(
sessionId: "S-B", role: "user", content: "b-content",
source: "userText", createdAt: 2
)
let rowsA = try await store.recentTurnsForSession(sessionId: "S-A", limit: 10)
let rowsB = try await store.recentTurnsForSession(sessionId: "S-B", limit: 10)
XCTAssertEqual(rowsA.count, 1)
XCTAssertEqual(rowsB.count, 1)
XCTAssertEqual(rowsA[0].content, "a-content")
XCTAssertEqual(rowsB[0].content, "b-content")
}
// MARK: - AT-4 (FTS5 trigger sanity)
/// AT-4: the schema's `INSERT INTO turns_fts(rowid, content)` trigger
/// fires on appendTurn so the FTS5 index is populated. Verifies the
/// trigger isn't dormant — without this, future chat-search features
/// on `turns` would silently return empty results.
func testAT4_turnsFtsTriggerFiresOnAppend() async throws {
try XCTSkipUnless(
ProcessInfo.processInfo.environment["JARVIS_VEC0_STUB_PATH"] != nil,
"Set JARVIS_VEC0_STUB_PATH to a vec0.dylib to exercise appendTurn."
)
let tmp = try Self.tempDB()
let store = try MemoryStore(databaseURL: tmp)
try await store.appendTurn(
sessionId: "S-AT4", role: "user", content: "the rain in spain",
source: "userText", createdAt: 1
)
// FTS5 MATCH should find the row by content token.
let count = try await store.queryRowCount(
"SELECT 1 FROM turns_fts WHERE turns_fts MATCH 'spain'"
)
XCTAssertEqual(count, 1, "turns_fts trigger must mirror content from inserts")
}
// MARK: - Helpers
private static func tempDB() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("appendturn-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("jarvis.db")
}
}