fix(mcp): v0.1 CRIT cluster — turnID resolver wired + tool-result double-cap #157

Merged
KofTwentyTwo merged 2 commits from fix/mcp-crit-v0.1 into develop 2026-05-13 11:31:46 +00:00
7 changed files with 427 additions and 90 deletions

No files matched your search

+16
View File
@@ -1900,6 +1900,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)
self.agentOrchestrator = orchestrator
// #20 (audit-2026-05-12 CRIT-1): wire the live `TurnID` resolver
// into `ReplayingToolResultObserver`. `MCPRuntimeWiring.build`
// constructed the observer with the `{ nil }` placeholder back in
// `applicationWillFinishLaunching` because the orchestrator didn't
// yet exist. Now that it does, flip the resolver to read
// `orchestrator.currentTurnID()`. Without this attach, the SEC-07
// dual-write + ME-04 channel + drain Task stay dead in production
// (the observer's guard returns at every `record(...)` call).
if let observer = mcpRuntime.toolResultObserver as? ReplayingToolResultObserver {
observer.attach(turnIDResolver: { [weak orchestrator] in
await orchestrator?.currentTurnID()
})
} else {
systemLogger?.warning("installAgent: toolResultObserver is not ReplayingToolResultObserver — turnIDResolver not wired; SEC-07 dual-write + ME-04 channel will stay dead")
}
// 4. Broadcaster + transcript store (D-05 / D-08 / BLOCKER-1).
let broadcaster = OrchestratorEventBroadcaster(upstream: orchestrator.events)
await broadcaster.start()
+49 -14
View File
@@ -21,6 +21,7 @@
// in production traffic.
import Foundation
import os.lock
import AgentCore
import Replay
import JarvisMCP
@@ -32,26 +33,58 @@ import Logging
/// AppDelegate consumes from this channel and writes to ReplayLog.
///
/// The observer needs a TurnID context to correlate replay rows with the
/// in-flight turn — the orchestrator owns that context, so the observer
/// holds a closure resolver that the orchestrator updates per-turn. In
/// the (current) AppDelegate boot — where AgentOrchestrator wiring lands
/// in Phase 6 voice / Phase 7 follow-ups — the resolver returns nil and
/// the observer logs without writing. The compile-time wiring is in
/// place; future plans flip the resolver to read the orchestrator's
/// active TurnID.
/// in-flight turn — the orchestrator owns that context. Since the observer
/// is constructed inside `MCPRuntimeWiring.build` (which runs BEFORE the
/// orchestrator is installed, because the orchestrator consumes the
/// dispatcher chain we return), the resolver is built with a placeholder
/// returning `nil`. After `installAgent` constructs the orchestrator,
/// AppDelegate calls `attach(turnIDResolver:)` to flip the resolver to a
/// closure that reads `orchestrator.currentTurnID()`. The lock-protected
/// holder mirrors `ConfirmationPresenterHolder.attach(_:)` — same pattern,
/// same lifecycle invariant.
///
/// **#20 (audit-2026-05-12 CRIT-1):** before this fix, the resolver was a
/// `let` set once at init to `{ nil }`. The four-seam AGENT-10 contract
/// (SEC-07 dual-write + ME-04 channel + drain Task + ReplayLog writer) was
/// compiled but dead — every `record(...)` returned at the guard. Closing
/// the seam at install time is the production wiring fix.
public final class ReplayingToolResultObserver: ToolResultObserver, @unchecked Sendable {
/// Box for the resolver closure so it can ride inside
/// `OSAllocatedUnfairLock`'s initialState (which requires `Sendable`,
/// and a bare closure type is fine; the box just shortens the type).
private struct ResolverBox: Sendable {
let resolver: @Sendable () async -> TurnID?
}
private let replayChannel: BoundedAsyncChannel<ReplayEnvelope>
private let turnIDResolver: @Sendable () async -> TurnID?
private let logger: Logger
/// Lock-protected resolver slot. AppDelegate calls `attach(...)`
/// post-orchestrator-install to flip from the `{ nil }` placeholder to
/// the live closure. Uses `OSAllocatedUnfairLock` (the async-safe
/// scoped-locking primitive); same pattern as
/// `InProcessConfirmationCache` in JarvisMCP.
private let resolverSlot: OSAllocatedUnfairLock<ResolverBox>
private let logger: Logging.Logger
public init(
replayChannel: BoundedAsyncChannel<ReplayEnvelope>,
turnIDResolver: @escaping @Sendable () async -> TurnID? = { nil }
) {
self.replayChannel = replayChannel
self.turnIDResolver = turnIDResolver
self.logger = Logger(label: JarvisLogChannel.replay.rawValue)
self.resolverSlot = OSAllocatedUnfairLock(
initialState: ResolverBox(resolver: turnIDResolver)
)
self.logger = Logging.Logger(label: JarvisLogChannel.replay.rawValue)
}
/// Replace the TurnID resolver. Called by AppDelegate once the
/// `AgentOrchestrator` is constructed inside `installAgent` so the
/// observer can correlate replay rows with the in-flight turn.
/// Mirrors `ConfirmationPresenterHolder.attach(_:)` — same lifecycle
/// invariant (observer outlives all calls; attach happens exactly once
/// per process).
public func attach(turnIDResolver: @escaping @Sendable () async -> TurnID?) {
resolverSlot.withLock { $0 = ResolverBox(resolver: turnIDResolver) }
}
public func record(
@@ -61,9 +94,11 @@ public final class ReplayingToolResultObserver: ToolResultObserver, @unchecked S
sanitizedBytes: Data
) async {
// Resolve the active TurnID — production orchestrator wiring
// supplies this; pre-orchestrator boots return nil and the
// observer logs without writing (ReplayEvent requires a TurnID).
guard let turnId = await turnIDResolver() else {
// supplies this via `attach(turnIDResolver:)`; pre-attach calls
// return nil and the observer logs without writing (ReplayEvent
// requires a TurnID).
let resolver = resolverSlot.withLock { $0.resolver }
guard let turnId = await resolver() else {
logger.debug("ToolResultObserver invoked without active TurnID — skipping replay write")
return
}
@@ -309,6 +309,171 @@ final class MCPRuntimeWiringTests: XCTestCase {
XCTAssertGreaterThanOrEqual(lastIdx, cap * 10 - cap - 5,
"last drained envelope should be among the most recent bursts")
}
// MARK: - #20 (audit-2026-05-12 CRIT-1): turnIDResolver wiring
/// #20 invariant 1: under the production construction shape — the
/// observer is built with the `{ nil }` placeholder resolver, then
/// `attach(turnIDResolver:)` flips it post-orchestrator-construction
/// — `record(...)` MUST produce envelopes into the channel after the
/// attach, and MUST stay silent before it. Pre-#20 the observer's
/// `turnIDResolver` was a `let` set once at init, so `attach(...)`
/// did not exist and production calls returned at the guard forever.
func test_attach_turnIDResolver_flipsObserverFromDeadToLive() async throws {
let channel = BoundedAsyncChannel<ReplayEnvelope>(capacity: 64, policy: .dropOldest)
// Mirror MCPRuntimeWiring.build's construction: placeholder { nil }
// resolver. This is what production set up before #20.
let observer = ReplayingToolResultObserver(replayChannel: channel)
// Pre-attach record: the guard returns, nothing lands in the channel.
await observer.record(
toolUseId: "toolu_pre",
toolName: "get_time",
rawBytes: Data("RAW0".utf8),
sanitizedBytes: Data("SAN0".utf8)
)
// Drain non-blocking: peek the channel via a finish + iterate path.
// Since pre-attach should produce zero envelopes, finishing now and
// iterating must yield zero.
await channel.finish()
var preCount = 0
for await _ in channel { preCount += 1 }
XCTAssertEqual(preCount, 0,
"pre-attach: observer with { nil } resolver must produce zero envelopes")
// Now exercise the post-attach side with a fresh channel.
let liveChannel = BoundedAsyncChannel<ReplayEnvelope>(capacity: 64, policy: .dropOldest)
let liveObserver = ReplayingToolResultObserver(replayChannel: liveChannel)
let expectedTurn = TurnID.fresh()
// Production wiring: AppDelegate's installAgent calls this with a
// closure that reads orchestrator.currentTurnID().
liveObserver.attach(turnIDResolver: { expectedTurn })
await liveObserver.record(
toolUseId: "toolu_post",
toolName: "get_time",
rawBytes: Data("RAW".utf8),
sanitizedBytes: Data("SAN".utf8)
)
var liveIter = liveChannel.makeAsyncIterator()
let first = await liveIter.next()
let second = await liveIter.next()
guard
case .toolResultFull(let id1, let bytes1) = first?.event,
case .toolResultFull(let id2, let bytes2) = second?.event
else {
XCTFail("post-attach: expected two .toolResultFull envelopes")
return
}
XCTAssertEqual(id1, "toolu_post")
XCTAssertEqual(bytes1, Data("SAN".utf8))
XCTAssertEqual(id2, "toolu_post:raw")
XCTAssertEqual(bytes2, Data("RAW".utf8))
XCTAssertEqual(first?.turnId, expectedTurn,
"envelope must carry the attached resolver's TurnID")
XCTAssertEqual(second?.turnId, expectedTurn)
}
/// #20 invariant 2: end-to-end through the production observer wiring —
/// observer constructed with the `{ nil }` placeholder (like
/// `MCPRuntimeWiring.build` does), then `attach(turnIDResolver:)` flips
/// it (like `installAgent` does), then a simulated dispatcher call
/// `record(...)` drives the SEC-07 dual-write into the channel; the
/// drain Task pulls envelopes onto a real ephemeral `ReplayLog` and we
/// assert by reading the SQLite `events` table directly that both the
/// sanitized and the `:raw` rows landed correlated to the attached
/// `TurnID`. Pre-#20 the guard returned, the channel stayed empty, and
/// the table had zero `tool_result_full` rows.
func test_attach_replayLogReceivesSECDualWriteCorrelatedToTurnID() async throws {
let tmpDB = FileManager.default.temporaryDirectory
.appendingPathComponent("issue20-\(UUID().uuidString).sqlite")
defer { try? FileManager.default.removeItem(at: tmpDB) }
let log = try ReplayLog(databaseURL: tmpDB)
let sessionId = try await log.beginSession(appVersion: "test", buildSHA: "abc")
let turnId = TurnID.fresh()
try await log.startTurn(
turnId: turnId, sessionId: sessionId, retryOf: nil,
turnNonce: UUID().uuidString, source: .text,
provider: "test", modelId: "test"
)
// Production-shape composition. The observer is built with the
// `{ nil }` placeholder, matching `MCPRuntimeWiring.build`'s
// construction-time call. Pre-attach, the observer's guard returns
// at every `record(...)` call.
let channel = BoundedAsyncChannel<ReplayEnvelope>(capacity: 64, policy: .dropOldest)
let observer = ReplayingToolResultObserver(replayChannel: channel)
// Mirror the AppDelegate drain Task — pulls envelopes off the
// channel and writes them to ReplayLog under the per-envelope TurnID.
let drainTask = Task.detached {
for await env in channel {
await log.record(env.event, for: env.turnId)
}
}
// STEP 1: pre-attach record. Simulates a dispatcher's
// `observer.record(...)` call before `installAgent` had a chance
// to wire the resolver. The observer logs and returns; nothing
// lands in the channel.
await observer.record(
toolUseId: "pre-id",
toolName: "get_time",
rawBytes: Data("raw-pre".utf8),
sanitizedBytes: Data("san-pre".utf8)
)
// STEP 2: simulate installAgent's wiring — attach a resolver that
// yields the known `turnId` (the production closure reads
// `orchestrator.currentTurnID()` instead).
observer.attach(turnIDResolver: { turnId })
// STEP 3: post-attach record. The same code path; this time the
// resolver yields a non-nil TurnID and envelopes flow through the
// channel to the drain Task to ReplayLog.
await observer.record(
toolUseId: "live-id",
toolName: "get_time",
rawBytes: Data("raw-live".utf8),
sanitizedBytes: Data("san-live".utf8)
)
// Allow drain + ReplayLog batch flush.
try await Task.sleep(nanoseconds: 300_000_000)
await channel.finish()
_ = await drainTask.value
await log.endTurn(turnId, stopReason: "test")
await log.flush()
try await log.close()
// Open the SQLite file directly and assert exactly the post-attach
// envelopes landed (two: sanitized + `:raw`). Pre-attach must have
// produced zero rows for `pre-id` because the guard returned.
let conn = try SQLiteConnection.open(at: tmpDB)
defer { try? conn.close() }
let rows: [(turnId: String, payload: Data)] = try conn.query(
"SELECT turn_id, payload_bytes FROM events WHERE kind='tool_result_full';",
map: { ($0.columnText(at: 0) ?? "", $0.columnBlob(at: 1) ?? Data()) }
)
XCTAssertEqual(rows.count, 2,
"post-attach: SEC-07 dual-write produces 2 rows (sanitized + :raw); pre-attach produced 0")
for r in rows {
XCTAssertEqual(r.turnId, turnId.rawValue,
"every row must carry the attached resolver's TurnID")
}
let ids: [String] = rows.compactMap { row in
guard let json = try? JSONSerialization.jsonObject(with: row.payload) as? [String: String] else {
return nil
}
return json["tool_use_id"]
}.sorted()
XCTAssertEqual(ids, ["live-id", "live-id:raw"],
"rows must reference the post-attach record's toolUseId — pre-attach `pre-id` was silently dropped")
}
}
// Actor extension to set broker — pattern matches the JarvisMCP test fixtures.
@@ -1,6 +1,7 @@
import Foundation
/// AGENT-08 8 KB tool-result cap.
/// AGENT-08 8 KB tool-result cap — wire-up between MCP boundary defense and
/// the LLM message history.
///
/// **Why 8 KB?** Opus 4.7 tokenizer produces ~1.35× tokens vs Opus 3.x;
/// 8 KB at the new ratio ≈ 12 K tokens. Three tool calls / turn × 12 K
@@ -8,52 +9,67 @@ import Foundation
/// against context blowout. Revisit empirically via DevOverlay's
/// cache-ratio signal in P5+.
///
/// The cap is **model-facing only**. The full blob still flows to
/// `ReplayLog` via `ReplayEvent.toolResultFull(toolUseId:bytes:)` —
/// observability is not lossy (OBS-02).
/// **Where the cap actually fires:** at the MCP dispatcher boundary via
/// `SanitizeForModel.prepareForBoundary(_, capBytes: 8192)` (lives in the
/// JarvisMCP package). By the time bytes reach this packer, they are
/// already sanitized + head-truncated to ≤ 8 KB. `pack(_:)` does NOT cap
/// again — that was the CRIT-2 audit-2026-05-12 double-cap, fixed by
/// closing #21. The packer's job today is reduced to: decode the
/// already-prepared bytes into a `String` for the LLM message history and
/// pass the same bytes through to `ReplayLog` via
/// `ReplayEvent.toolResultFull(toolUseId:bytes:)`. The audit-trail row
/// reflects the boundary-prepared bytes — not the original helper output
/// (per the audit's stated v0.1 acceptance: replay row = 8 KB cap, not the
/// full original blob).
public enum ToolResultPacker {
/// Cap for model-facing tool_result content in bytes.
/// Cap for model-facing tool_result content in bytes. Kept for the
/// (informational) `wasCapped` / `omittedByteCount` fields and to
/// document the contract; the cap itself is enforced at the MCP
/// dispatcher boundary (`SanitizeForModel.prepareForBoundary`).
public static let modelFacingCapBytes: Int = 8 * 1024
/// Result of packing a single tool result.
public struct Packed: Sendable {
/// String the orchestrator embeds in the next `LLMMessage` of role
/// `.tool`. UTF-8 decoded from the (possibly capped) byte prefix; if
/// truncated, ends with `\n\n[TRUNCATED: …]` marker text.
/// `.tool`. UTF-8 decoded from `fullBytes` as-is — the MCP
/// dispatcher boundary already applied the 8 KB head-truncate +
/// the `…[tool-result-truncated at 8192 bytes]` marker.
public let modelFacing: String
/// Original bytes — destined for `ReplayEvent.toolResultFull`.
/// The dispatcher-prepared bytes — destined for
/// `ReplayEvent.toolResultFull`. After #21 these are the same
/// bytes the model sees (no double-cap).
public let fullBytes: Data
/// True if `fullBytes.count > modelFacingCapBytes`.
/// True if the dispatcher truncated. Detected by looking at
/// the input size relative to `modelFacingCapBytes` — informational
/// only since `pack(_:)` no longer truncates.
public let wasCapped: Bool
/// Number of bytes omitted from `modelFacing`. Zero when not capped.
/// Always zero after #21 — kept for API stability. The dispatcher
/// strips bytes before the packer sees them; the omitted count is
/// not knowable here.
public let omittedByteCount: Int
}
/// Pack one tool result. The caller (`AgentOrchestrator`) routes the
/// `modelFacing` string through `UntrustedWrapper` before appending to
/// the LLM message history, and emits `fullBytes` to `ReplayLog`.
/// Pack one tool result. Input bytes have already been sanitized +
/// 8-KB-head-truncated by `SanitizeForModel.prepareForBoundary` at the
/// MCP dispatcher boundary; this packer does NOT cap again (per #21
/// closure). The caller (`AgentOrchestrator`) routes `modelFacing`
/// through `UntrustedWrapper` before appending to the LLM message
/// history and emits `fullBytes` to `ReplayLog`.
public static func pack(_ raw: Data) -> Packed {
let cap = modelFacingCapBytes
let capped = raw.prefix(cap)
let wasCapped = raw.count > capped.count
let omitted = raw.count - capped.count
let modelFacing: String
if wasCapped {
// String(decoding:as:) replaces invalid bytes with U+FFFD; we may
// truncate mid-codepoint at the byte boundary, that's acceptable.
let head = String(decoding: capped, as: UTF8.self)
modelFacing = head + "\n\n[TRUNCATED: \(omitted) bytes omitted; full blob in replay log]"
} else {
modelFacing = String(decoding: capped, as: UTF8.self)
}
// The boundary cap fires upstream in
// `SanitizeForModel.prepareForBoundary(_:capBytes:)`. We just
// decode + pass through. `wasCapped` is reported informationally
// from the size signal so callers / dashboards that previously
// read it keep working; `omittedByteCount` is no longer knowable
// here and is always zero.
return Packed(
modelFacing: modelFacing,
modelFacing: String(decoding: raw, as: UTF8.self),
fullBytes: raw,
wasCapped: wasCapped,
omittedByteCount: omitted
wasCapped: raw.count >= modelFacingCapBytes,
omittedByteCount: 0
)
}
}
@@ -206,6 +206,16 @@ public actor AgentOrchestrator {
voiceOriginatedTurns.contains(turnId)
}
/// #20 (audit-2026-05-12 CRIT-1) — the in-flight `TurnID`, or `nil` if
/// no turn is active. `ReplayingToolResultObserver.attach(...)` reads
/// this from a `@Sendable () async -> TurnID?` closure so the SEC-07
/// dual-write + ME-04 channel can correlate replay rows with the
/// current turn. One actor-hop per tool dispatch — acceptable cost
/// (tools fire 1–3× per turn).
public func currentTurnID() -> TurnID? {
currentTurn?.id
}
/// BLOCKER-2 actor-internal populator. Called from `runTurn` after
/// allocating the `TurnID` when `input.source == .voice`.
private func recordVoiceTurn(_ turnId: TurnID) {
@@ -1,8 +1,16 @@
import XCTest
@testable import AgentCore
// Tests reflect the #21 (audit-2026-05-12 CRIT-2) closure: `ToolResultPacker`
// no longer truncates. The 8 KB cap fires at the MCP dispatcher boundary via
// `SanitizeForModel.prepareForBoundary`; the packer is a pass-through that
// decodes the dispatcher-prepared bytes for the LLM message history and
// hands the same bytes to ReplayLog. `wasCapped` is informational only
// (derived from input size); `omittedByteCount` is always zero since the
// dispatcher already stripped the omitted bytes before the packer sees them.
final class ToolResultPackerTests: XCTestCase {
/// TP1: 4 KB input → no truncation; full string returned; full bytes preserved.
/// TP1: under-cap input flows through unchanged; no truncation marker.
func test_TP1_smallInputNotCapped() {
let raw = Data(repeating: UInt8(ascii: "a"), count: 4 * 1024)
let packed = ToolResultPacker.pack(raw)
@@ -13,54 +21,60 @@ final class ToolResultPackerTests: XCTestCase {
XCTAssertFalse(packed.modelFacing.contains("[TRUNCATED"))
}
/// TP2: 16 KB input → first 8192 bytes + truncation marker; full bytes preserved.
func test_TP2_largeInputCappedWithMarker() {
/// TP2 (post-#21): packer does NOT truncate. The dispatcher already
/// truncated upstream — bytes flowing in are ≤ 8 KB. The packer passes
/// them through unchanged, and `fullBytes == raw` regardless of size.
func test_TP2_packerDoesNotTruncate() {
// Simulate the rare/buggy case where a caller hands us larger bytes
// (the production path won't, but the API stability matters): packer
// still doesn't truncate.
let raw = Data(repeating: UInt8(ascii: "x"), count: 16 * 1024)
let packed = ToolResultPacker.pack(raw)
XCTAssertTrue(packed.wasCapped)
XCTAssertEqual(packed.omittedByteCount, 16 * 1024 - 8192)
XCTAssertEqual(packed.fullBytes.count, 16 * 1024)
XCTAssertTrue(packed.modelFacing.hasPrefix(String(repeating: "x", count: 8192)),
"modelFacing must begin with the first 8192 bytes")
XCTAssertTrue(packed.modelFacing.contains("[TRUNCATED:"),
"must contain truncation marker")
XCTAssertTrue(packed.modelFacing.contains("full blob in replay log"),
"must reference replay log")
// After #21 the packer never inserts a TRUNCATED marker; the
// dispatcher's `SanitizeForModel.headTruncate` is the only marker
// source.
XCTAssertFalse(packed.modelFacing.contains("[TRUNCATED:"))
XCTAssertEqual(packed.fullBytes.count, 16 * 1024,
"fullBytes is a pass-through of the input")
XCTAssertEqual(packed.modelFacing.count, 16 * 1024,
"modelFacing is a pass-through decoding of the input")
XCTAssertEqual(packed.omittedByteCount, 0,
"packer no longer knows omitted-count; always zero")
}
/// TP3: exactly 8192 bytes — equal-to-cap is NOT over.
func test_TP3_exactlyAtCapNotTruncated() {
/// TP3: input exactly at cap reports `wasCapped == true` (size >= cap)
/// but no marker is added — the packer is purely informational.
func test_TP3_exactlyAtCapInformational() {
let raw = Data(repeating: UInt8(ascii: "y"), count: 8192)
let packed = ToolResultPacker.pack(raw)
XCTAssertFalse(packed.wasCapped)
XCTAssertTrue(packed.wasCapped,
"size >= cap reports true informationally")
XCTAssertEqual(packed.omittedByteCount, 0)
XCTAssertFalse(packed.modelFacing.contains("[TRUNCATED"))
}
/// TP4: multi-byte UTF-8 boundary at byte 8192 — String(decoding:as:) replaces
/// invalid bytes with U+FFFD and does not crash.
func test_TP4_utf8BoundaryDoesNotCrash() {
// Build a buffer of size 16K where bytes 8190..8192 sit on a 4-byte
// emoji boundary. emoji "😀" is F0 9F 98 80; place at offset 8190 so
// bytes 8190,8191 are F0 9F (start of emoji) and the cut at 8192 cuts
// mid-codepoint.
var raw = [UInt8](repeating: UInt8(ascii: "z"), count: 16 * 1024)
/// TP4: UTF-8 decode of input never crashes — `String(decoding:as:)`
/// replaces invalid sequences with U+FFFD. Production input has been
/// scrubbed by `SanitizeForModel.sanitize` upstream so invalid sequences
/// are vanishingly rare, but the API must not crash on adversarial input.
func test_TP4_utf8DecodeNeverCrashes() {
// Bytes 8190..8191 are the start of a 4-byte emoji — incomplete by
// themselves. `String(decoding:as:)` substitutes U+FFFD; no crash.
var bytes = [UInt8](repeating: UInt8(ascii: "z"), count: 16 * 1024)
let emoji: [UInt8] = [0xF0, 0x9F, 0x98, 0x80]
for (i, b) in emoji.enumerated() {
raw[8190 + i] = b
bytes[8190 + i] = b
}
let data = Data(raw)
// Should not crash even though bytes 8190..8191 form an incomplete prefix.
let packed = ToolResultPacker.pack(data)
XCTAssertTrue(packed.wasCapped)
XCTAssertNotNil(packed.modelFacing)
let packed = ToolResultPacker.pack(Data(bytes))
XCTAssertFalse(packed.modelFacing.isEmpty)
}
/// TP5: truncation marker is grep-able and deterministic.
func test_TP5_truncationMarkerIsGreppable() {
/// TP5 (post-#21): the packer never adds the `[TRUNCATED: …]` marker.
/// The dispatcher's `…[tool-result-truncated at 8192 bytes]` marker is
/// the only one a downstream observer should ever see.
func test_TP5_packerNeverAddsTruncatedMarker() {
let raw = Data(repeating: UInt8(ascii: "a"), count: 9000)
let packed = ToolResultPacker.pack(raw)
XCTAssertTrue(packed.modelFacing.contains("TRUNCATED"))
XCTAssertTrue(packed.modelFacing.contains("full blob in replay log"))
XCTAssertFalse(packed.modelFacing.contains("[TRUNCATED:"))
}
}
@@ -234,14 +234,22 @@ final class OrchestratorSubmitTests: XCTestCase {
// MARK: - OS6
/// OS6: ReplayLog gets a `.toolResultFull` with the FULL bytes (not the
/// 8KB-capped model-facing string). We assert this by sending a 16 KB blob
/// from the dispatcher and checking the model-facing message in the second
/// provider call has the truncation marker (proving cap was applied) AND
/// the full bytes are still preserved in `Packed.fullBytes`.
func test_OS6_replayGetsFullBlobModelGetsCapped() async throws {
/// OS6 (post-#21 / audit-2026-05-12 CRIT-2): the 8 KB cap is the MCP
/// dispatcher's responsibility (`SanitizeForModel.prepareForBoundary`).
/// `ToolResultPacker.pack` is now a pass-through. The orchestrator
/// writes the dispatcher's prepared bytes to ReplayLog as
/// `.toolResultFull` and embeds them in the model-facing tool_result.
/// This test simulates a dispatcher that has already applied the cap
/// (returns exactly 8 KB) and asserts both the replay-bound bytes and
/// the model-facing message are sized at the cap — NOT 4 KB (which a
/// remnant per-line cap would produce) and NOT 16 KB (which would
/// indicate the cap was bypassed entirely).
func test_OS6_replayAndModelSeeDispatcherPreparedBytes() async throws {
let toolReq = ToolUseRequest(id: "tu1", name: "big", argsJSON: Data("{}".utf8))
let bigBlob = Data(repeating: UInt8(ascii: "X"), count: 16 * 1024)
// Dispatcher returns exactly 8 KB — matching the contract that the
// MCP boundary applies `prepareForBoundary(_, capBytes: 8192)`
// upstream of the orchestrator.
let preparedBlob = Data(repeating: UInt8(ascii: "X"), count: 8 * 1024)
let mock = MockLLMProvider(scripts: [
.init(events: [
@@ -256,7 +264,7 @@ final class OrchestratorSubmitTests: XCTestCase {
.messageStop,
]),
])
let dispatcher = StubToolDispatcher(dispatch: { _ in bigBlob })
let dispatcher = StubToolDispatcher(dispatch: { _ in preparedBlob })
let (orch, _, _) = try await buildOrchestrator(provider: mock, dispatcher: dispatcher)
@@ -271,12 +279,85 @@ final class OrchestratorSubmitTests: XCTestCase {
guard case .toolResult(_, let content) = secondCallToolMsg.content[0] else {
return XCTFail("expected toolResult content")
}
XCTAssertTrue(content.contains("[TRUNCATED:"),
"model-facing tool result must be capped + carry truncation marker")
// Model-facing is wrapped in nonce envelope; total length is wrapper +
// 8192 bytes + marker text. Assert it's far smaller than 16 KB.
XCTAssertLessThan(content.utf8.count, 9000,
"model-facing must be < 9 KB (8 KB cap + small marker + envelope)")
// The packer no longer adds a `[TRUNCATED: …]` marker — the
// dispatcher's `…[tool-result-truncated at 8192 bytes]` marker is
// the only one a downstream consumer should ever see. The
// dispatcher stub in this test does NOT add one (its job is to
// simulate already-prepared bytes), so the model-facing string
// should not contain either marker.
XCTAssertFalse(content.contains("[TRUNCATED:"),
"packer must not double-mark — dispatcher owns the truncation marker")
// The 8 KB cap bytes flow through; the model-facing content is the
// dispatcher payload wrapped in the SEC-06 nonce envelope. Assert
// the payload byte count is preserved (>= 8 KB, accounting for the
// wrapper text; NOT 4 KB or less).
XCTAssertGreaterThanOrEqual(content.utf8.count, 8 * 1024,
"model-facing must carry the full 8 KB dispatcher payload — not 4 KB or less")
}
/// OS6b (#21 regression — audit-2026-05-12 CRIT-2): the persisted
/// ReplayLog row for `.toolResultFull` is keyed to exactly the bytes
/// the dispatcher returned (no second cap, no per-line strip applied
/// on top). A dispatcher that returns 8 KB → replay row contains
/// exactly 8 KB of payload. Pre-fix, `ToolResultPacker.pack`'s redundant
/// inner cap re-truncated already-capped bytes (no-op for size but the
/// design was wrong); a per-line variant could have dropped to 4 KB
/// under different inputs. This test pins the post-fix invariant by
/// driving end-to-end through the orchestrator and reading the
/// `payload_bytes` BLOB from the SQLite `events` table.
func test_OS6b_replayRowMatchesDispatcherBytesNoSecondCap() async throws {
let toolReq = ToolUseRequest(id: "tu-21", name: "big", argsJSON: Data("{}".utf8))
// Exactly 8 KB — what a real dispatcher emits after
// `SanitizeForModel.prepareForBoundary(_, capBytes: 8192)`.
let preparedBlob = Data(repeating: UInt8(ascii: "X"), count: 8 * 1024)
let mock = MockLLMProvider(scripts: [
.init(events: [
.messageStart(LLMMessageStart(messageId: "m1", model: "x", usagePrefix: nil)),
.toolUseRequested(toolReq),
.stopReason(.toolUse),
.messageStop,
]),
.init(events: [
.messageStart(LLMMessageStart(messageId: "m2", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]),
])
let dispatcher = StubToolDispatcher(dispatch: { _ in preparedBlob })
let (orch, replay, _) = try await buildOrchestrator(provider: mock, dispatcher: dispatcher)
_ = await orch.submit(.text("fetch"))
_ = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }; return false
}
// Flush + close so the row is on disk before we open a competing
// connection.
await replay.flush()
try await replay.close()
let conn = try SQLiteConnection.open(at: tempHome.dbURL)
defer { try? conn.close() }
let blob: Data? = try conn.query(
"SELECT payload_bytes FROM events WHERE kind='tool_result_full';",
map: { $0.columnBlob(at: 0) }
).first ?? nil
XCTAssertNotNil(blob, "must find a tool_result_full row")
guard let blob else { return }
// The envelope is `{"tool_use_id": "...", "bytes": "<base64>"}`.
let json = try JSONSerialization.jsonObject(with: blob) as? [String: String]
XCTAssertEqual(json?["tool_use_id"], "tu-21")
let recovered = Data(base64Encoded: json?["bytes"] ?? "") ?? Data()
// #21 invariant: the replay row carries EXACTLY the dispatcher's
// 8 KB payload — not 4 KB (which a residual per-line strip would
// produce) and not less.
XCTAssertEqual(recovered.count, 8 * 1024,
"replay row must be exactly 8 KB — not 4 KB or less (#21)")
XCTAssertEqual(recovered, preparedBlob,
"replay row must be byte-for-byte identical to the dispatcher payload")
}
// MARK: - OS7