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:
1 parent
46590cd9aa
commit
48ad8d631c
8 files changed
+609
-28
No files matched your search
+75
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+226
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user