Commit Graph
50 Commits
Author SHA1 Message Date
james.maes 00f1075ea5 fix(agent): bundle assistant text with toolUse + document cache hint dormancy (#185)
Two related AgentCore tactical fixes from the 2026-05-12 audit.

#24 — Assistant text blocks alongside tool_use were dropped from message
history. Opus 4.7 commonly narrates ("Let me check the time for
you...") before calling a tool. On the next round-trip the appended
assistant message contained only the .toolUse block, so the model had
no record of the narration — a user follow-up like "what did you say
before checking the time?" would deny it.

Fix: introduce `assistantTextSinceLastFlush` parallel to
`assistantTextSoFar` (the latter stays cumulative for D-02 vision
routing). On every toolUseRequested, flush the per-flush buffer into a
.text(...) block bundled into the same assistant message as the
.toolUse, trim whitespace-only text to avoid Anthropic 400s on empty
text blocks, and reset the flush buffer so subsequent tool_uses in the
same round get only the text emitted between them. D-02 untouched.

#23 — CacheHints documentation flagged that the eligibility threshold
(4096 chars ≈ 1024 tokens) is structurally false on healthy boot
(preamble ~700 chars, empty initial context). Added a paragraph
explicitly documenting the dormancy, the intentional cost-acceptance
rationale, and the natural recovery once M-4 (v0.8) lands
searchFacts-driven hydration. No code change.

Tests: 3 new tests in AssistantTextWithToolUseTests cover text+toolUse
bundling, toolUse-alone (no spurious empty text block), and
whitespace-only-text rejection. Full AgentCore suite: 248 tests, 0
failures, 1 skipped.

Closes #23, #24
2026-05-14 06:53:35 -05:00
james.maes bc74494ada fix(mcp): v0.1 CRIT cluster — turnID resolver wired + tool-result double-cap (#20, #21, #157)
#21 (CRIT): ToolResultPacker.pack(_:) is now a pass-through decoder; redundant inner 8 KB cap removed. MCP dispatcher's SanitizeForModel.prepareForBoundary(_, capBytes: 8192) is the sole cap site. Doc-comment rewritten honestly: replay row reflects boundary-prepared bytes (8 KB), not original helper output. Replay fidelity beyond 8 KB lifted to v0.5/M-3 once SanitizeForModel can move to a shared module without dep cycle.

#20 (CRIT): ReplayingToolResultObserver.turnIDResolver lock-protected via OSAllocatedUnfairLock<ResolverBox> (same Swift-6-async-safe pattern as InProcessConfirmationCache). New attach(turnIDResolver:) method. AgentOrchestrator.currentTurnID() actor-isolated accessor. installAgent attaches the resolver immediately after orchestrator assignment, with defensive systemLogger.warning if observer type mismatches.

Tests: ToolResultPackerTests TP2/TP5 updated to assert no [TRUNCATED] marker; OrchestratorSubmitTests OS6 reframed + new OS6b reads SQLite events table directly to assert 8 KB exact; MCPRuntimeWiringTests adds attach pre/post-attach + replay-log dual-write correlation.

AgentCore 243 + Memory 98 + Replay 34 + MCP all green; all 18 boundary gates green.
2026-05-13 06:31:46 -05:00
james.maes 4b1dd6404b fix(voice): v0.1 CRIT cluster — wake / STT / TTS / boot probes (#28, #29, #30, #31, #32, #33, #34, #36, #158)
#28 (CRIT): setCancelInFlight calls stopFeed() (preserves stream); rebuild consumer re-arms DAG against new ring. Wake-word no longer dies on audio-graph rebuild.

#29 (CRIT): sttFactory reads PerTurnSnapshot.stt.whisperKitFallback; get_self_state returns canonical snake_case backend constants. Factory signature: () -> any STTProvider → () async -> any STTProvider (existing test sites compile unchanged under Swift 6 implicit-async-closure rules).

#30 (CRIT): TTSEngineActor.synthesize emits .ttsStopped after .finished on natural completion (two-emit-site model: barge-in + natural-completion).

#31 (CRIT): VoiceTTSAdapter.tierResolver wired to read snap.tts.tier from configStore.

#32 (HIGH): Rolling-window trim in OpenWakeWordSession.runORT — mel ≤ 84, embeddings ≤ 16, nextEmbStart slides on drop.

#33 (HIGH): three new boot probes — WakeWordBootHealthProbe, STTBootHealthProbe, TTSBootHealthProbe. New WakeWordDAG.isFeedArmed + VoiceTTSAdapter.hasEngine accessors.

#34 (HIGH): vad.reset() at top of startSTTSession clears Silero VAD LSTM hidden state + wasSpeech between sessions.

#36 (HIGH): new Broadcaster.Priority.agentHud (protects stateChange/turnEnd/error); installAgent adds subscriber pumping AgentHudIntent into dormantAgentContinuation.

Voice 90 + AgentCore 242 tests pass; all 18 boundary gates green.
2026-05-13 06:31:17 -05:00
james.maes f59f5d12e3 fix(memory): v0.1 CRIT cluster — facts_vec / replay correlation / FTS5 sanitize / search_memory preamble (#12, #13, #14, #15, #16, #153)
#14 (CRIT): ContextBuilder.selfAwarePreamble names search_memory + forget_fact with explicit cue 'call search_memory FIRST. Never claim to remember without searching.'

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

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

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

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

Memory 98 + Replay 34 + AgentCore 243 tests pass; all 18 boundary gates green.
2026-05-13 06:30:35 -05:00
james.maes 05411f82ff fix(vision): v0.1 cluster — T2 honesty + pendingFrame leak + TCC revoke observation (#1, #2, #3, #152)
#1 (HIGH): new hasRealT2Provider init param on AgentOrchestrator; t2AvailableForThisTurn now requires both decision.tier != .t3Cloud AND hasRealT2Provider. Production passes false (MissingT2Provider only). Pre-existing escalation test still passes true with mock T2.

#2 (MED): new FrameAttachController.releaseAfterRejectedSubmit() routes through the sole discardFrame() emission site (D-15 single-emission grep gate still green). Both handleChatSubmit and handleChatCancelAndSubmit call it on .rejected.

#3 (MED): AVCaptureSessionWasInterruptedNotification observer yields .midSessionRevoked. macOS limitation surfaced: AVCaptureSessionInterruptionReasonKey is API_UNAVAILABLE(macos), so we cannot discriminate by reason — any interruption surfaces the banner. NSApplication.didBecomeActive observer calls becameAuthorized() on non-authorized → .authorized flip.

Vision 75 + AgentCore 243 tests pass; all 18 boundary gates green.
2026-05-13 06:30:09 -05:00
james.maesandClaude Opus 4.7 beb9d51aa5 test(agent-core): update qwen2.5-coder literal to q8_0 variant
Two test assertions still expected `"qwen2.5-coder:32b"` after commit
8d0a959 flipped ModelID.qwen25coder32b to
`"qwen2.5-coder:32b-instruct-q8_0"`. Same shape of fix as commit
5718bdd (qwen36 rename). The other "qwen2.5-coder:32b" occurrences
in NDJSONDecoderTests and OllamaImageBlockEncodingTests are
fixture JSON strings used as decoder input — not asserted against,
so they don't break.

After:
  swift test --package-path packages/AgentCore --filter LLMEventTests
  → 10/10 pass
  swift test --package-path packages/AgentCore --filter OllamaProviderTests
  → 38/38 pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:14:07 -05:00
james.maesandClaude Opus 4.7 23b4ca7712 feat(round-4): agent system prompt knows when subsystems are degraded
Slice 3 of Round 4 — the Toby-the-dog fix. With Slice 1 the probes
report severity and with Slice 2 banners route by severity, but the
critical behavioral change is here: the agent's system prompt itself
must carry the degradation truth so the model can't confidently lie
about features that don't work.

The Toby case in detail: user said "remember my dog Toby is a golden
retriever". The Ollama probe correctly reported missing nomic-embed-text
(degraded). The memory extractor tried to embed Toby, the embed call
failed, no row was inserted in `facts`. But the agent's system prompt
said "you are Jarvis with memory" and the model answered "Got it —
Toby." A lie, dressed up as competence.

Implementation:

- `AgentOrchestrator` gains a private `var degradationSummary: String?`
  + a public `setDegradationSummary(_:)` actor-isolated setter. Mutable
  because installAgent runs BEFORE runBootHealth in the boot order;
  the orchestrator is constructed with `nil` and AppDelegate pushes
  the summary in post-construction. Re-probes from the Status panel
  update the value mid-session.
- `runTurn` prepends the summary BEFORE `composedSystem` (which already
  includes the UntrustedWrapper nonce + locked self-aware preamble),
  so the warning lands at the very top of the system message — outranks
  the preamble, outranks presence/memory enrichment.
- `AppDelegate.runBootHealth` now calls
  `orchestrator.setDegradationSummary(buildDegradationSummary(snapshot:))`
  after every probe sweep. The builder produces text like:

    CRITICAL — SUBSYSTEMS DEGRADED:
    - memory: failed (vec0 missing) — DO NOT promise to use memory
    - ollama: degraded — missing models: nomic-embed-text
    Be honest. Do not claim to use tools that are unavailable. Do not
    pretend memory works if it doesn't.

  When every subsystem is `.ok`, the function returns `nil` and the
  orchestrator's behavior is identical to pre-Round-4.

- Cache-hints invariant preserved: the locked preamble is ~700 chars,
  the worst-case degradation summary is <500 chars; sum stays well
  under the 4096-char Anthropic cache-eligibility boundary so adding
  the summary doesn't unintentionally flip `cache_control` emission.
  CacheHintsEligibilityTests/preambleDoesNotEnableCacheUnintentionally
  remains green.

4 new tests in AgentOrchestratorDegradationPreambleTests (all pass):
  * DP1: initial summary threaded into first turn's system message
  * DP2: nil summary leaves prompt unchanged (no "CRITICAL" / "DO NOT")
  * DP3: setDegradationSummary updates the next turn's prompt
  * DP4: setting summary back to nil clears it from the next turn

Boundary gates 18/18 green. `swift test --package-path packages/AgentCore`
runs 242/240 (4 new minus 2 pre-existing unrelated failures from
qwen2.5-coder model-ID literal drift, present before Round 4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:47:58 -05:00
james.maesandClaude Opus 4.7 cfd469f72b feat(round-4): probe severity model — ok/soft/loud/critical
Round 4 of boot-health work. The Toby-the-dog incident — Jarvis said "Got
it" while the embedder model was missing and the fact never persisted —
proved that Round 2's probe banner was too quiet. Probes were already
reporting `.degraded` correctly; what was missing was a way for the App
layer to route a critical failure differently from a soft one (e.g.,
non-dismissible banner for memory off vs. silent Status row for camera
TCC not yet requested).

This slice introduces a severity axis without changing behavior:

- `FailureSeverity { critical, loud, soft }` on `ProbeStatus`'s non-ok
  cases (degraded/failed/unknown). `.ok` carries no severity.
- `ProbeStatus.severity` accessor (nil for `.ok`).
- `BootHealthSnapshot.overallHealth: OverallHealth { ok, soft, loud,
  critical }` — worst severity wins; `.ok` only if every subsystem ok.
- Each concrete probe annotates its failure modes:
  * Memory: failed → .critical (agent grounding; the Toby case)
  * Anthropic: missing/empty key → .critical (agent can't run)
  * Ollama: missing models → .loud (fact extraction broken, chat fine)
  * Voice: mic denied → .loud; dormant install → .soft
  * Vision: TCC denied → .soft (vision is optional)
  * MCP: 0 tools → .critical; <minimum → .loud
  * Replay: init failed → .critical; missing file → .loud
  * Webview: mismatched/timedOut → .critical; sentHello → .soft
- DormantSubsystemProbe gains `severity` parameter (default .soft);
  AppDelegate wires memory-dormant as .critical.

No behavioral change yet — banners still treat all failures equally;
the agent system prompt is unchanged. Slices 2 + 3 land those.

Tests: 13/13 in BootHealthOrchestratorTests pass, including 4 new tests
for overallHealth + severity accessor + JSON roundtrip with severity.
2 pre-existing failures in LLMEventTests/OllamaRequestBodyTests are
unrelated (qwen model-ID literal drift from commit 8d0a959, present
before this slice — confirmed via git stash).

Boundary gates 18/18 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:24:43 -05:00
james.maesandClaude Opus 4.7 687b80b3f0 feat(round-2): boot-phase health probes — NOT FAKED, structured logs
After all install Tasks join, runs one live probe per subsystem and
emits structured `BootHealth: subsystem=X state=Y latencyMs=Z evidence="..."`
log lines. Any subsystem reporting .failed enqueues a non-modal banner
via HUDBannerCoordinator (priority 3 — sits between hotkey-bind-failed
and ollama-url-rejected). The snapshot is stored on bootHealthOrchestrator
so Round 3's Status menu can read + re-probe.

Anti-fake-status invariant is type-level: ProbeStatus is a sum type with
.ok / .degraded(reason:) / .failed(reason:) / .unknown(reason:) — every
non-ok case carries a reason string, so a snapshot can never report
state=unknown without explaining why. A probe that can't execute MUST
return .unknown — never .ok with a fake evidence line.

New types in packages/AgentCore/Sources/AgentCore/BootHealth.swift:
  - ProbeStatus, ProbeOutcome, BootHealthProbe protocol
  - SubsystemHealth (Codable for Round 3 "Copy report"), BootHealthSnapshot
  - BootHealthOrchestrator actor — parallel TaskGroup fan-out,
    registration-order-preserved snapshot, injectable clock for tests

Eight concrete probes in App/Boot/BootHealthProbes.swift:
  - MemoryBootHealthProbe — MemoryStatsStoreAdapter.getMemoryStats round-trip
  - AnthropicBootHealthProbe — Keychain structural check (no billable
    network call at boot; Round 3 panel offers explicit network re-probe)
  - OllamaBootHealthProbe — GET /api/tags, 3s timeout, asserts qwen3.6 +
    qwen2.5-coder + nomic-embed-text all present
  - VoiceBootHealthProbe — AudioGraphOwner state + mic TCC
  - VisionBootHealthProbe — camera TCC + DiscoverySession (read-only,
    does NOT prompt TCC per RESEARCH §Example 2)
  - MCPBootHealthProbe — runtime.client.registeredToolNames count
  - ReplayBootHealthProbe — replay.sqlite presence + parent-dir writability
  - WebviewBootHealthProbe — WebviewBridge.handshakeState (.armed = live)
  - DormantSubsystemProbe — placeholder for subsystems whose actor was nil
    at probe-registration; reports .unknown honestly rather than skipping

AppDelegate wiring (step 16, runs after selfKnowledgeInstallTask which
transitively awaits every other install Task):
  - bootHealthOrchestrator: BootHealthOrchestrator stored property
  - bootHealthTask: Task<Void, Never>? cancelled in applicationWillTerminate
  - runBootHealth() async: registers probes (idempotent — re-probes from
    Round 3 reuse the existing registry), runs, logs, enqueues banners
  - emitBootHealthLog / enqueueFailedBootHealthBanners helpers

Tests (packages/AgentCore/Tests/AgentCoreTests/BootHealthOrchestratorTests.swift):
  - 8/8 passing (210ms wall — 3×200ms parallel probes prove TaskGroup
    fan-out works; serial would be 600ms+)
  - Coverage: runAll invokes every probe, snapshot preserves registration
    order, parallel execution timing, lastSnapshot persistence, .failed
    propagation, .unknown not coerced to .ok, deterministic clock
    injection for latencyMs, JSON round-trip for all 4 ProbeStatus variants

Verification before commit:
  - swift test --package-path packages/AgentCore --filter
    BootHealthOrchestratorTests: 8/8 PASS (0.210s)
  - swift test --package-path packages/Memory --filter
    MemoryExtractorTests: 11/11 PASS
  - bash scripts/check-app-builds.sh: PASS
  - 18/18 boundary gates: PASS

Dogfooding next: cold-launch + grep system.log for `BootHealth:
subsystem=` lines; force a failure (e.g. kill Ollama) and verify
banner appears. Status menu (Round 3) is next session task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:03:24 -05:00
james.maesandClaude Opus 4.7 8d0a959a19 feat(model): qwen3.6 for memory extractor; q8_0 for agent-loop fallback
Round 1 of the post-D-5/D-6 model strategy split. The user has both
`qwen3.6:latest` (~22GB MoE) and `qwen2.5-coder:32b-instruct-q8_0` (~32GB)
pulled. CLAUDE.md previously pinned the entire local-model strategy to
the base `qwen2.5-coder:32b` because of an April 2026 Ollama tool-call
bug affecting Qwen 3 / 3.5 / Gemma 4. That ban was correct then; today
(2026-05-11) it isn't.

**Empirical retest 2026-05-11:** POST /api/chat (non-streaming) against
qwen3.6:latest with a representative `apply_memory_ops` tool definition
returned a clean tool_calls array, well-formed JSON args, no leaked
<think> tags. Upstream Ollama appears to have closed the relevant
issues (#14493 / #14601 / #14745 / #15315) in the interim. Documented
in ModelID.qwen36 doc comment.

Two-model strategy:

- **MemoryExtractor default → qwen3.6:latest** (`ModelID.qwen36`)
  Single-turn, single-tool, no cap recovery — the lowest-risk place to
  validate the newer model. If extraction stays stable for a few days
  of dogfooding, we revisit the agent-loop fallback.
- **Agent-loop Ollama fallback → qwen2.5-coder:32b-instruct-q8_0**
  (`ModelID.qwen25coder32b`'s rawValue is now the Q8_0 variant the user
  already has — higher fidelity than the default Q4_K_M, same model
  family, same tool-call wire format). The agent loop has multi-turn
  tool chains, cap recovery, tool_choice=none paths, and streaming all
  in scope — kept conservative until Qwen3.6 is verified there too.

Changes:
- packages/AgentCore/Sources/AgentCore/ModelID.swift
    + `qwen36` constant for qwen3.6:latest with empirical retest doc.
    ! `qwen25coder32b` rawValue is now `qwen2.5-coder:32b-instruct-q8_0`
      (same constant name, the Q8_0 raw value). All ModelID.qwen25coder32b
      consumers pick up the change automatically.
- packages/Memory/Sources/Memory/MemoryExtractor.swift
    - `model: ModelID = .qwen25coder32b` → `.qwen36`.
    Docstring updated to explain the empirical retest + why the
    extractor is the safest place for the newer model.
- packages/Harness/Sources/Harness/Runners/LiveOllamaRunner.swift
    `requiredModelID` constant now sources from
    `ModelID.qwen25coder32b.rawValue` so a future single-edit on the
    canonical model name propagates here automatically. Doc updated to
    point at the q8_0 variant + ~32GB pull size.

User-facing note: no model pulls required for this commit; both
models are already on the user's machine.

Round 2 (boot-phase health check) and Round 3 (Status menu dialog)
deferred to a fresh session for context budget — handoff doc to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:36:30 -05:00
james.maesandClaude Opus 4.7 48ad8d631c 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>
2026-05-11 08:50:40 -05:00
james.maesandClaude Opus 4.7 3eeb3c178e feat(10-02b): add availableToolsResolver for late-bound tool catalogs
Plan 10-02b / B-01: AgentOrchestrator gains an optional
`availableToolsResolver: (@Sendable () async -> [ToolSchema])?` init
parameter that, when set, supersedes the static `availableTools:`
array on every outer-loop iteration of runTurnLoop.

The static array continues to work for tests (and is the default), but
production wires the resolver so the in-process registry's late-bound
self-knowledge tools (registered AFTER installAgent runs the
orchestrator constructor) reach the request body's tools[] payload.

Without this, the install-order dependency chain
  installMemory → installVision → installAgent → installVoice
                                              → installSelfKnowledgeTools
would force the orchestrator to capture an empty snapshot of the
in-process registry at construction time. By the time the user submits
their first turn, the four self-knowledge tools have registered — but
the orchestrator's frozen array would not see them.

The resolver is invoked once per outer turn iteration (not per stream
event), so the cost is one actor hop per LLM round-trip — negligible.

Test coverage:
- test_availableToolsResolver_supersedesStaticArrayPerTurn: seeds the
  resolver with empty on turn 1, populates 4 tools before turn 2, and
  asserts the provider sees the correct snapshot for each turn.
- All 222 AgentCore tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:37:01 -05:00
james.maesandClaude Opus 4.7 260350b3dc test(10-02b): add AvailableToolsWiringTests regression for B-01
The 2026-05-07 live verification of Plan 10-02 surfaced that
App/AppDelegate.swift:1274 constructed AgentOrchestrator with a
hardcoded availableTools: [] empty array. The model saw the system
prompt naming tools but had no schemas in the API tools[] field, so
it hallucinated JSON tool definitions into chat instead of dispatching
real tool_use blocks.

These tests assert at the orchestrator → provider boundary: schemas
passed to AgentOrchestrator(availableTools:) MUST reach the
LLMProvider.stream(... tools:) call on the first turn. The previous
in-process registry test asserted at the wrong layer (registry was
correctly populated, but its catalog never reached the orchestrator).

Both tests pass at HEAD because the orchestrator's wiring of
availableToolsList → toolsForCall → provider.stream is sound. They
exist to catch any future regression at this layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:18:45 -05:00
james.maes f22bf2ea3b feat(10-02): add self-aware preamble + ContextBuilder.systemPrompt(for:)
Replaces the hardcoded "You are Jarvis, a personal macOS assistant."
literal at App/AppDelegate.swift:1265 with a locked self-aware preamble
emitted by AgentCore's new ContextBuilder. The preamble names Jarvis,
lists capabilities (mic, camera, speakers), names the seven introspection
tools (list_audio_devices, get_active_audio_route, get_self_state,
list_camera_devices, get_time, get_clipboard, run_applescript) so the
model has handles, and ends with "You are not a text-only assistant."
Closes the 2026-05-05 / 2026-05-06 user-quoted failure modes.

Implements:
- ContextBuilder.selfAwarePreamble: locked Swift string constant (D-13).
  No JSON file, no localization, no runtime mutation. Length ~660 chars,
  well under the 4096-char cache-eligibility boundary (Pitfall #4 / A4).
- ContextBuilder.systemPrompt(for:): composes preamble FIRST, then
  presence enrichment, then memory hydration (D-12 — two distinct
  concatenation steps preserve cache_control breakpoints).
- TurnContext envelope with .empty static for the AppDelegate site
  (per-turn enrichment plumbing through orchestrator init is out of
  scope per D-12 fallback; AppDelegate calls .systemPrompt(for: .empty)).
- AppDelegate.swift orchestrator init now sources systemPrompt from
  ContextBuilder.

Rule 3 deviation (auto-fix blocking issue): the new
AgentCore.ContextBuilder name-collides with the pre-existing
JarvisVision.ContextBuilder (cloud-opt-in / frame-attach detector) at
two call sites that previously used bare `ContextBuilder()`. Both call
sites disambiguated to `JarvisVision.ContextBuilder()`:
- packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift:212
- App/AppDelegate.swift:1620
And the dependent test:
- packages/AgentCore/Tests/AgentOrchestratorTests/AgentOrchestratorVisionDispatchTests.swift:209

Wave-0 test preambleDoesNotEnableCacheUnintentionally flips RED→GREEN.
All 17 boundary gates green; check-app-builds.sh PASS.

Closes SELF-05, SELF-06 (Plan 10-02 implementation tasks).
2026-05-06 18:10:27 -05:00
james.maes 5bb9371e39 test(10-02): add Wave-0 RED preambleDoesNotEnableCacheUnintentionally
VALIDATION map row SELF-06 / T-10-CACHE-01. Asserts the soon-to-land
ContextBuilder.selfAwarePreamble does not flip cache-marker emission
relative to the prior literal "You are Jarvis, a personal macOS
assistant." (Pitfall #4 / Assumption A4 / PATTERNS AP-04), enforces the
D-13 lock anchor (hasPrefix "You are Jarvis"), the D-14 no-tool-catalog
constraint (no Markdown triple-backtick), and the D-12 composition
order (selfAwarePreamble FIRST in systemPrompt(for:)).

RED: ContextBuilder symbol does not exist yet — fails to compile.
2026-05-06 16:08:11 -05:00
james.maesandClaude Opus 4.7 d8983af004 fix(anthropic): add stream:true to request body — root cause of streamTruncatedFinal
Discovered via live launch on 2026-05-05. Every Anthropic call returned
HTTP 200 + zero SSE frames; SSEDecoder emitted streamTruncatedFinal.

Root cause: `/v1/messages` opts into Server-Sent Events via the request
BODY field `"stream": true`, NOT via the `Accept: text/event-stream`
header alone. Our request body had every other field correct (model,
messages, system, tool_choice, max_tokens) but no stream field. Server
defaulted to non-streaming, returned a regular JSON response, our
SSELineReader read zero `data:` frames, decoder yielded
streamTruncatedFinal on EOF.

The 2026-05-03 audit-fix Track A added a StreamOutcome classifier and
the CacheHints.eligibleForSystemPrompt gate — both helpful diagnostic
+ defensive measures, but neither addressed the actual cause. The
audit hypothesis was "cache_control on small prompts triggers silent
rejection"; live testing disproved that and surfaced this simpler bug.

Fix:
- Add `stream: Bool` to AnthropicRequestBody encoding (mapped to JSON key `stream`)
- Pass `stream: true` from `RequestBody.encodeMultimodal`
- Reverted the speculative beta-header gating from this morning — beta
  header is unconditional per AGENT-02 and was never the cause

Tests:
- New `AnthropicProviderTests.LiveAnthropicStreamingTests`:
  - L3 (regression fence, no network): asserts encoded body contains
    `"stream": true`. FAILED before this commit. PASSES after.
  - L1 (live, env-gated on JARVIS_REAL_ANTHROPIC=1 + ANTHROPIC_API_KEY):
    real-API integration test that drives a small prompt and asserts at
    least one textDelta event arrives. Skipped without env.
  - L2 (no network): dumps the encoded body for diff against curl.
- All 49 existing AnthropicProvider tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 09:25:00 -05:00
james.maesandClaude Opus 4.7 508cf924d0 docs(packages): per-package README files
Adds README.md to each of the 13 SPM packages (AgentCore, Bus, Config,
DevOverlay, Harness, Keychain, Logging, MCP, Memory, Replay, Shell, Vision,
Voice). Each covers purpose, key public types, deps, consumers, invariants
(pulled from CLAUDE.md + grep-gates + audit findings), test stack, and
notable files. Memory README explicitly marks the package functionally OFF
until vec0.dylib + Ollama models land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:13:33 -05:00
james.maesandClaude Opus 4.7 08125a46d8 chore(test): remove tautologies and rubber-stamps per tests-audit.md
Per `.planning/audit-2026-05-03/tests-audit.md` section 5, five
`XCTAssertTrue(true)` placeholder tests were identified across the tree
that are mathematically incapable of failing. This commit fixes or
removes each, conservatively. Files touched and disposition:

- App/Tests/AppTests/MCPRuntimeWiringTests.swift:260
  fixed: replaced trailing `XCTAssertTrue(true)` rubber-stamp at end of
  CR-02 channel-drain test with a load-bearing assertion that the drain
  task completed via `finish()`, not cancellation. The implicit `await
  drainTask.value` is the real gate; the new assertion ensures regressions
  that cancel-instead-of-finish surface.

- App/Tests/AppTests/WebviewBundleLoadTests.swift:60
  deleted: `test_assetsDirectoryGitignored_documentedInSmokeScript`. Body
  was `XCTAssertTrue(true)` with comment "intentionally has no assertion
  — it exists to document the split of responsibility". Documentation,
  not a test. Replaced with a top-level comment block documenting the
  same split (smoke-test-hud.sh is the runtime gate).

- packages/AgentCore/Tests/AgentCoreTests/ToolChoiceTests.swift:38
  fixed: removed trailing `XCTAssertTrue(true) // Test compiles → contract
  holds.` from `testLLMProviderStreamRequiresToolChoice`. The closure
  body itself is the contract — if the test compiles, the contract holds.
  Comment updated to make the compile-time semantics explicit.

- packages/Shell/Tests/ShellTests/InputMonitoringDenialTests.swift:36
  deleted: `test_localMonitorOnlyFallback`. Body was `XCTAssertTrue(true)`
  documenting that the real fallback test lives in HotkeyBindingTests.
  Replaced with a top-level comment pointing readers there.

- packages/Vision/Tests/VisionTests/PackageBoundaryTests.swift:14
  deleted: `testVisionModuleCompilesStandalone`. Body was
  `XCTAssertTrue(true)` with comment "compile is the assertion." VISION-03
  isolation is enforced by `scripts/check-vision-isolation.sh` and the
  Package.swift dependency graph, both of which are real gates. Folded
  the documentation into the `testPublicAPISurface` doc-comment so the
  explanation isn't lost.

HudStateEnumTests rubber-stamps (audit items #4–6 in section 5) were
left as-is. The voiceOverLabel literals are user-facing accessibility
text that VoiceOver actually speaks; they are load-bearing in the sense
the audit calls out as worth preserving ("replace with behavioral
assertions if the literal under test is load-bearing"). A regression
that breaks an a11y label IS a real regression. Conservative read; not
worth the blast radius of guessing wrong.

Pre-existing baseline issues unrelated to this batch:
- `packages/Shell/Tests/ShellTests/InputMonitoringDenialTests.swift`
  fails to COMPILE on a clean tree (MockProbe missing
  `isListenEventAccessGranted()`). Verified via `git stash` that the
  failure is independent of this commit. Not addressed here per the
  scope rules — flagged for follow-up.

Net test count: -3 tests (3 deleted, 0 added — the
MCPRuntimeWiringTests assertion replaces the tautology in-place).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 12:34:56 -05:00
james.maesandClaude Opus 4.7 1f8e03edeb fix(track-a): demo-blocker class + audit reports + chat-turn e2e tests
User feedback: 50+ hours of agent work, nothing demonstrably working.
Fanned out 6 deep auditors (voice, HUD, vision, memory, LLM, tests);
all reports landed at .planning/audit-2026-05-03/ with brutal-honesty
mandates. Synthesis (SYNTHESIS.md) confirmed: every modality has at
least one fatal break between code-that-passes-tests and
code-that-runs. Track A is the smallest credible path to one working
modality (text turn + animated rings).

Closed under TDD discipline:

**Item 4 — HUD idle ring static rendering (F-A1-01).**
- webview/packages/hud/src/hud/stateUniforms.ts:30 — was
  `pulse: 0.0, rotate: 0.0` for idle, the all-static configuration
  that rendered STANDBY as dots instead of animated rings. Now
  pulse: 0.4, rotate: 0.05 — gentle ambient distinct from booting +
  thinking.
- webview/packages/hud/tests/stateUniforms.test.ts — table updated;
  new regression-guard `STATE_PARAMS[*] is not all-static` (7 cases,
  one per HudState) prevents anyone from re-zeroing a state.

**Item 1 — CacheHints eligibility gate (likely streamTruncated cause).**
- packages/AgentCore/Sources/AgentCore/CacheHints.swift —
  + static func eligibleForSystemPrompt(_:ttl:). Anthropic's prompt
  cache requires ≥1024 tokens at the breakpoint; below that, the
  server returns 200 OK + immediate-EOF for `extended-cache-ttl`
  hints. The orchestrator's default system prompt is ~10 tokens.
  Helper returns nil for sub-threshold prompts so no cache_control
  marker is emitted.
- packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
  — call sites now pass `CacheHints.eligibleForSystemPrompt(finalSystem)`
  through runTurnLoop instead of unconditional `.extended1h`.
- Tests: CacheHintsEligibilityTests (6 cases), RequestBodyTests R5a/R5b
  (2 cases verifying small-prompt → no cache_control in encoded JSON,
  large-prompt → cache_control present).

**Item 2 — StreamOutcome diagnostic.**
- packages/AgentCore/Sources/AnthropicProvider/StreamOutcome.swift —
  new pure classifier that distinguishes:
    - non-2xx HTTP status
    - 200 OK + 0 bytes (cache_control / auth-header rejection class)
    - 200 OK + bytes + 0 frames (malformed body)
    - 200 OK + frames + EOF before message_stop (mid-stream drop)
    - 200 OK complete (healthy)
  AnthropicProvider's read loop counts frames + bytes and logs the
  summary once per stream. Production logs now distinguish failure
  modes that previously all surfaced as `streamTruncatedFinal`.
- Tests: StreamOutcomeTests (7 cases, all branches).

**Item 3 — KeychainError surfacing.**
- packages/AgentCore/Sources/AnthropicProvider/AnthropicAPIKeyProvider.swift
  — new builder `make(keychain:item:)` returns the
  `@Sendable () async throws -> String` closure with errors
  propagating verbatim. Closes the audit's hidden-401 finding:
  `(try? keychainStoreLocal.get(.anthropic)) ?? ""` swallowed the
  real KeychainError into an empty x-api-key, producing
  `401 x-api-key header is required` indistinguishable from a
  stale-key 401. Now the actual error reaches the chat panel via
  AnthropicProvider → orchestrator.error → BusForwarder →
  submitRejected.
- App/AppDelegate.swift — both Anthropic provider construction sites
  (installAgent, installVision T3 wiring) now use the propagating
  builder.
- Tests: AnthropicAPIKeyProviderTests (6 cases — itemNotFound,
  unexpectedStatus, duplicateItem propagation; happy path; explicit
  item; default item).

**Item 5 — F1 #2 chat-turn round-trip e2e tests.**
- packages/Bus/Tests/BusTests/RealWKWebViewIntegrationTests.swift —
  + 2 new tests using a real WKWebView:
    - test_chatTurnRoundTrip_busOutboundReachesPageHandler — Swift
      `bridge.send(.turnStarted/tokenDelta/turnEnded)` reaches the
      page's onOutbound handler. This is the test the v0.12.0 audit
      called out as catching BLOCKER-INT-1, INT-2, INT-3,
      F-E-RACE-1, F-E-FK-1, F-E-WIRE-1 in one shot.
    - test_chatTurnRoundTrip_busInboundFromJSReachesSwiftHandler —
      JS-side `window.jarvisBus.send({type:'chatSubmit',...})` —
      exactly what ChatInput.tsx does — reaches the bridge's
      onInbound handler with the right body.

**Verification:**
- AgentCore: 215/215 (was 178; +37 across BusForwarder, CacheHints,
  StreamOutcome, AnthropicAPIKeyProvider, RequestBody R5).
- Replay: 33/33.
- Bus: 57/58 (1 pre-existing failure: F-A2-01 stale `JarvisBusWorld`
  assertion documented in AUDIT-FINDINGS, separate scope).
- webview/packages/hud: 94/94 (was 87; +7 stateUniforms regression-
  guards + idle table updates).
- All 15 boundary grep gates: PASS.
- scripts/check-app-builds.sh: PASS.

**Audit reports retained at .planning/audit-2026-05-03/:**
- SYNTHESIS.md (consolidated, with triage plan)
- hud-audit.md, llm-audit.md, memory-audit.md, tests-audit.md,
  vision-audit.md, voice-audit.md, voice.md (per-modality detail)

**Carry-forward (NOT closed this commit):**
- F-A2-01 stale WebviewBridgeOutboundTests assertion (pre-existing).
- Voice (F): models never bundled, audio graph never instantiated,
  STT bridge body is `_ = chunk`, TTS engine nil. ~1 day.
- Vision (F): AVCapturePhotoOutput allocated but capturePhoto never
  called; HUD camera button doesn't exist. ~1 day.
- Memory (F): Apple's stripped libsqlite3 prevents sqlite-vec from
  loading; "future ops plan" has zero commits. ~3 days.
- LLM (D+): NoopBusGateway still drops tool-call events
  (BLOCKER-INT-1).
- Tests (C-): 5 XCTAssertTrue(true) tautologies + multiple
  rubber-stamp tests defending stubs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:23:13 -05:00
james.maesandClaude Opus 4.7 048ab08ef6 fix(app,agent,replay): wire orchestrator install correctly + extract bus forwarder for testability
The 2026-05-03 INT-3 smoke test surfaced three latent runtime bugs the
unit-level GSD verifications all missed because the audits were source-
grep, not runtime-trace:

1. **F-E-RACE-1** — `agentInstallTask` awaited memory + vision installs
   but NOT the MCP runtime build task. MCPRuntime build is ~1s due to
   helper child-process spawn, so installAgent ALWAYS short-circuited on
   `mcpRuntime == nil` and the orchestrator + broadcaster + ALL six
   broadcaster subscribers stayed dormant for the entire process
   lifetime. Fix: capture `mcpInstallTask` Task handle and await it
   before calling installAgent. The v0.12.0 milestone audit's claim of
   "5 broadcaster subscribers wired" was accurate at source-grep level
   and false at runtime.

2. **F-E-FK-1** — installAgent constructed the orchestrator with a
   fresh SessionID but never called `replayLog.beginSession(...)`.
   Every `startTurn` violated the FK and returned
   `SubmitOutcome.rejected(reason: .configError)` — surfacing in the
   chat panel as "Config error — see ~/Library/Logs/Jarvis/system.log."
   Production code only ever called beginSession in tests
   (MCPRuntimeWiringTests). Fix: installAgent now reads
   CFBundleShortVersionString + CFBundleVersion from Info.plist, calls
   beginSession, and passes the returned SessionID to the orchestrator.

3. **F-E-WIRE-1** — `OutboundBatcher` was constructed only inside
   `installVoice`, AFTER OpenWakeWord/Silero models loaded. Voice DAG
   short-circuit on missing models in Debug builds left
   `self.outboundBatcher == nil`, and every `outboundBatcher?...`
   chained-optional in the .bus subscriber silently dropped tokenDelta
   / turnStarted / turnEnded / submitRejected. The chat panel got
   nothing back from the orchestrator. Fix: hoist batcher construction
   into installAgent (only depends on webviewBridge, not voice models);
   installVoice reuses self.outboundBatcher rather than overwriting.

In addition, the .bus subscriber's translation logic — including
StopReason → TurnTerminator mapping, turnStarted synthesis on first
emission for a fresh TurnID, error → submitRejected forwarding for
mid-stream failures — was inline in AppDelegate and therefore
impossible to unit-test (App target xctest harness is upstream-broken
on Xcode 26). Extracted to `BusForwarder` in AgentOrchestrator with a
sink protocol; App provides `AppBusForwarderSink` (~50 LOC) as the
glue. Every translation branch now has unit coverage.

Files:
- packages/AgentCore/Sources/AgentOrchestrator/BusForwarder.swift —
  Pure logic. `BusForwarderSink` protocol; `BusForwarder.terminator(for:)`
  StopReason mapping; `BusForwarder.drain(events:sink:)` async loop with
  per-turn lastEmittedTurnId state. No Bus package dep — keeps the
  layering clean.
- packages/AgentCore/Tests/AgentOrchestratorTests/BusForwarderTests.swift
  — 16 cases covering every branch: terminator mapping (5, exhaustive
  on StopReason), tokenDelta forwarding (3 — synthesize start, dedupe
  start across same-turn deltas, two-turn handoff), turnEnd forwarding
  (4 — mapped terminator, synthesize-start-on-bare-turnEnd,
  streamTruncated→errored, state reset), error forwarding (2), ignored
  events (1), malformed-id guard (1).
- packages/Replay/Tests/ReplayTests/SessionForeignKeyTests.swift —
  3 cases (FK1: startTurn-without-beginSession-throws; FK2: post-
  beginSession-succeeds; FK3: multiple-startTurns-share-one-session).
  Closes the bug class regardless of any future AppDelegate refactor.
- App/AppBusForwarderSink.swift — thin transport adapter. Translates
  `BusForwarder.Terminator` to wire-format `Bus.TurnTerminator` and
  dispatches across OutboundBatcher actor.
- App/AppDelegate.swift — race fix, beginSession call, batcher hoist,
  inline subscriber replaced with `BusForwarder.drain(events: sink:)`.
- Jarvis.xcodeproj/project.pbxproj — xcodegen regen picks up new App
  source file.
- .planning/AUDIT-FINDINGS.md — three new findings (F-E-RACE-1,
  F-E-FK-1, F-E-WIRE-1) documented and marked closed; refactor
  rationale recorded; carry-forward items called out
  (streamTruncatedFinal cause unverified without Keychain access; F1
  chat-turn end-to-end test is next).

Verification:
- AgentCore 194/194 (was 178; +16 BusForwarder).
- Replay 33/33 (was 30; +3 SessionFK).
- Bus 55/56 (F-A2-01 pre-existing).
- All 15 boundary grep gates PASS.
- scripts/check-app-builds.sh PASS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:44:23 -05:00
james.maes 602a95b6c8 fix(app,broadcaster): add .bus subscriber forwarding tokenDelta to OutboundBatcher (closes BLOCKER-INT-2 / tokens half)
Phase E first fix from .planning/v0.12.0-MILESTONE-AUDIT.md. The
orchestrator's events stream had FIVE subscribers (memory, transcript,
devOverlay, frameAttach, voice) and ZERO that forwarded to the JS-side
HUD chat panel. OutboundBatcher.postToken existed with zero production
call sites cross-tree. As a result, the webview chatEvents store was
guaranteed to render an empty array regardless of how many tokens the
orchestrator emitted.

This commit closes the tokenDelta half of BLOCKER-INT-2. turnStarted /
turnEnded forwarding remains open as documented follow-up; the relevant
translation (TurnID → UUID, StopReason → TurnTerminator) is non-trivial
and warrants its own commit. Downgraded F-B2-INT-2 from 🔴 → 🟡 in
AUDIT-FINDINGS.md to reflect partial closure.

Changes:

  packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift
    + Adds `case bus` to Priority enum with doc-comment explaining
      AppDelegate's bus-forwarding subscriber and citing the AUDIT plan.
    + Extends isProtectedForPriority() to include .bus alongside .memory
      and .transcript: protects everything except .tokenDelta /
      .thinkingDelta. Bus tokenDeltas are drop-eligible because
      OutboundBatcher coalesces at ~30 Hz internally; tail-dropping
      individual deltas under extreme overload is preferable to
      back-pressuring the orchestrator. Turn-bookkeeping events
      (.turnEnd, .toolCardUpdate, .usage, .error, .stateChange) are
      protected — those drive HUD chrome transitions that MUST reach
      the user.

  packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift
    + Extends test_protectionMatrixIsCorrect_perPriority with a
      .bus assertion loop mirroring .memory / .transcript. Asserts
      tokenDelta + thinkingDelta drop-eligible, all 5 other event
      classes protected. AgentCore suite: 178/178 → 178/178 (added
      assertions to existing test, no new test method).

  App/AppDelegate.swift
    + busSubscriberTask: Task<Void, Never>? property storing the new
      sixth broadcaster subscriber drain task. Doc-comment cites the
      AUDIT-AND-FIX-PLAN BLOCKER-INT-2 origin.
    + Cancellation in applicationWillTerminate alongside the other 5
      subscriber tasks.
    + In installAgent, after the .voice subscriber drain, subscribes
      to the broadcaster with priority .bus (capacity 256) and starts
      a Task that pattern-matches OrchestratorEvent.tokenDelta and
      forwards the chunk to `await self.outboundBatcher?.postToken(_:)`.
      `[weak self]` capture + nil-coalescing handles the construction-
      order skew where outboundBatcher is constructed in installVoice
      (which runs AFTER installAgent per the locked vision→agent→voice
      install order). `await` is required because OutboundBatcher is
      a public actor.
    + Updated final installAgent log line to list "bus" alongside the
      other 5 subscribers.

Verification:
  • scripts/check-app-builds.sh: PASS — App target compiles cleanly.
  • AgentCore swift test: 178/178 pass.
  • Bus swift test: 56/56 except F-A2-01 (pre-existing stale-assertion
    test bug, unchanged).

What this does NOT yet fix (deferred follow-up):
  • turnStarted / turnEnded forwarding. The Phase 4 SUMMARY's
    "synthesized client-side from first tokenDelta" assumption may
    cover turnStarted webview-side; turnEnded explicitly needs Swift
    translation from OrchestratorEvent.turnEnd → BusOutbound.turnEnded.
  • End-to-end visibility — a user can't actually see the new
    forwarding in action until BLOCKER-INT-3 (HUD chat input UI) is
    closed. Until then, the .bus subscriber correctly forwards an
    empty stream of zero tokenDeltas because no turn ever runs.
  • Tool-call event forwarding remains a separate problem
    (BLOCKER-INT-1 / NoopBusGateway, untouched here).

Refs: .planning/v0.12.0-MILESTONE-AUDIT.md (BLOCKER-INT-2),
.planning/AUDIT-AND-FIX-PLAN.md Phase E.
2026-05-02 20:32:55 -05:00
james.maes 75a4ed6991 feat(09-04): replace NullVoiceAdapters with real voice adapter triad + lock install order (D-09, D-10, BLOCKER-1, BLOCKER-2, WARNING-5)
- New App/Voice/{VoiceOrchestratorAdapter,VoiceTTSAdapter,VoiceBusEmitterAdapter,RejectReasonCopy}.swift
  replaces NullOrchestratorAdapter / NullTTSAdapter / NullBusEmitterAdapter (D-09).
- VoiceOrchestratorAdapter forwards submit/cancelAndSubmit to AgentOrchestrator,
  appends user-side text to TurnTranscriptStore (BLOCKER-1), enqueues a HUDBanner
  on every SubmitOutcome.rejected case via shared RejectReasonCopy (D-10).
- VoiceTTSAdapter wraps an optional TTSEngineActor (engine construction lands
  in a follow-on plan; nil-engine path no-ops gracefully).
- VoiceBusEmitterAdapter wraps an OutboundBatcher whose sink is the WebviewBridge
  so audio-level RMS reaches the HUD's RingMesh pulse.
- AppDelegate.installVoice now uses the production adapters; install order
  in applicationWillFinishLaunching reordered to vision -> agent -> voice
  with voiceInstallTask awaiting agentInstallTask?.value (WARNING-5).
- New broadcaster .voice subscriber in installAgent: per-turn assistantText
  accumulator + turnSourceWasVoice filter so text-originated turns NEVER drive
  emitTurnEnded on the voice adapter (BLOCKER-2).
- SubmitOutcome.superseded extended with newTurnId so cancelAndSubmit callers
  can append user text under the new turn id (BLOCKER-1 user-side closure on
  the cancel path).
- VoiceSubscriberTextTurnFilterTests proves text turns never trigger
  emitTurnEnded; VoiceOrchestratorAdapterTests covers RejectReasonCopy +
  banner id mapping + VoiceBusEmitterAdapter forwarding.
- New scripts/check-install-order.sh + scripts/check-no-null-voice-adapters.sh
  catch regressions structurally.
2026-05-01 20:39:19 -05:00
james.maes d4af611672 feat(09-03): thread PresenceStateSnapshot into AgentOrchestrator + system-prompt enrichment (D-13/D-14, INT-07-03)
- AgentOrchestrator constructor gains optional presenceSnapshot: PresenceStateSnapshot? (after visionRouter, defaulting to nil)
- runTurn appends `await presenceSnapshot?.currentEnrichment()` to the system prompt OUTSIDE UntrustedWrapper.composeSystemPrompt (SEC-06 nonce-region preserved; presence text is trusted)
- AppDelegate.installAgent passes PresenceStateSnapshot.shared so production turns carry ambient enrichment
- 5 new AgentOrchestratorPresenceEnrichmentTests (no-snapshot unenriched / present sentence / 5s suppression / absent away sentence / no-mutation across turns)
- 177 AgentCore tests pass; VISION-03 + orchestrator-events single-consumer gates green; App target builds
2026-05-01 20:08:54 -05:00
james.maesandClaude Opus 4.7 87aec282a0 feat(09-02): wire FrameAttachController + Bus frameAttachRequested + broadcaster release subscriber (D-15, D-16, INT-07-04)
- App/Vision/AppDelegateFrameAttachAdapters.swift (NEW): FrameAttachCaptureSourceAdapter wraps CameraCapture, FrameAttachReplaySinkAdapter wraps Replay.ReplayLog (placeholders log via JarvisLogChannel.replay — see file docstring for FK rationale), FrameAttachControllerReplaySinkBridge bridges Vision's FrameAttachReplaySink actor to FrameAttachController.ReplaySink protocol
- AppDelegate: new frameAttachController + frameAttachReleaseTask properties; installVision now constructs FrameAttachController (D-15); installAgent waits for visionInstallTask before constructing the orchestrator and passes visionRouter:; new step 5d frame-attach release subscriber subscribes broadcaster's .frameAttach priority and fires onAssistantTurnComplete() on .turnEnd for image-bearing turns (D-16)
- AppDelegate: tryPhraseAttachIfMatch(_:) helper exposed for Plan 4's chatSubmit + chatCancelAndSubmit handlers (WARNING-4: must call from BOTH paths)
- AppDelegate.installBus: bridge.onInbound now switches on BusInbound; .frameAttachRequested → frameAttachController.requestAttach(reason: .hudButton)
- BusInbound: additive .frameAttachRequested case + Discriminator + Codable arms (NO default — exhaustive-switch drift preventer); BUS_PROTOCOL_VERSION 2.1.0 → 2.2.0 in Swift + TS mirror
- TS protocol: matching frameAttachRequested case + decoder arm; round-trip.test.ts asserts version 2.2.0 + decodes new fixture
- New fixtures (parity): packages/Bus/Tests/BusTests/Fixtures/frameAttachRequested.json + webview/packages/bus/fixtures/frameAttachRequested.json (byte-identical, no trailing newline per check-bus-protocol-version.sh)
- Memory package bumped from .v13 to .v14 (cascade: Memory depends on AgentCore which now requires v14 due to JarvisVision dependency added in Task 1)
- xcodegen regen for the new App/Vision/AppDelegateFrameAttachAdapters.swift source file
- 2 new FrameAttachReleaseSubscriberTests prove broadcaster→onAssistantTurnComplete glue (image-bearing turns release once; text-only turns NEVER release); subscription uses devOverlay priority for the sync wait to avoid competing with broadcaster's drain on orch.events
- 1 new CodableRoundTripTests entry (test_roundTrip_frameAttachRequested)
- Phase 7 SOLE-emission-site invariant intact: FrameAttachDiscardSiteGrepTests passes; check-presence-vision-isolation.sh exits 0; orchestrator-events single-consumer grep gate exits 0; 65 AgentOrchestratorTests + 51 BusTests + 34 webview tests all green
- App target compiles cleanly (check-app-builds.sh exits 0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 19:41:29 -05:00
james.maesandClaude Opus 4.7 5e50dac503 feat(09-02): add VisionRouter dispatch + post-response escalation + voice-turn tracking (BLOCKER-2)
- ReplayEvent: new .escalationAttempt(turnId:kind:) case with .t1ToT2 EscalationKind (D-02 marker, distinct from AGENT-09 stream_truncated)
- ReplayEventKind: new .escalationAttempt = "escalation_attempt" rawvalue
- AgentOrchestrator: constructor gains optional visionRouter: VisionRouter? = nil
- runTurn pre-stream: route image-bearing turns through VisionRouter, swap provider, track imageBearingTurns (D-01, D-16)
- runTurnLoop: multimodal stream overload for image-bearing turns; assistantTextSoFar accumulator on .textDelta
- runTurnLoop .endTurn arm: post-response evaluatePostResponse hook, .escalateToT2 → discard T1 message, swap to T2, continue outer (WARNING-3 LOCKED labeled-loop strategy); records .escalationAttempt under SAME turnId (D-02)
- BLOCKER-2: voiceOriginatedTurns Set<TurnID> + recordVoiceTurn(_:) populator + turnSourceWasVoice(_:) public accessor (Plan 4 voice subscriber will gate emitTurnEnded on this)
- turnHadImage(_:) public accessor (Plan 2 frame-attach release subscriber consumer)
- AgentCore + Replay + MCP packages bumped from .v13 to .v14 (cascade — JarvisVision is .v14, project deployment target was already 14.0)
- MockLLMProvider extended with multimodal stream overload + RecordedCall.images
- 6 AgentOrchestratorVisionDispatchTests + 3 AgentOrchestratorVoiceTurnTrackingTests + 3 EscalationAttemptMarkerTests
- All 63 existing AgentOrchestratorTests still pass; all 30 Replay tests pass; orchestrator-events single-consumer grep gate exits 0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 18:54:15 -05:00
james.maes fdb56d2785 feat(09-01): installAgent + broadcaster wiring + BLOCKER-1 closure (INT-07-01)
- AppDelegate.installAgent() constructs AgentOrchestrator with the
  existing 7-arg ctor (visionRouter / presenceSnapshot stay nil for
  Plans 2 + 3), starts the OrchestratorEventBroadcaster, and creates
  the TurnTranscriptStore. Memory + transcript + devOverlay subscribers
  drain into MemoryExtractionCoordinator, the assistant-side
  accumulator, and a no-op consumer respectively.
- ConfigStore is now built in applicationWillFinishLaunching from the
  loaded launch + perTurn snapshots (was previously discarded).
- installMemory no longer calls coord.start; the wiring moved to
  installAgent step 5a where the broadcaster's memory child stream is
  available. Old agentOrchestratorEvents() nil-stub is gone.
- MemoryExtractionCoordinator gains a MemoryEnqueueing protocol seam
  so the BLOCKER-1 end-to-end test can spy on enqueue without driving
  the full extractor stack.
- New scripts/check-orchestrator-events-single-consumer.sh enforces
  D-05/D-08 (exactly one production for-await over orchestrator.events;
  WARNING-2 whitelist scoped to OrchestratorEventBroadcaster.swift).
  Wired as a project.yml preBuildScript and exercised in-process by
  PhaseNineGrepGateTests.
- MemoryWiringEndToEndTests proves enqueue receives non-nil userText +
  assistantText through real broadcaster + transcript store + memory
  coordinator wiring (BLOCKER-1 closure for INT-07-01).
- Test home note: MemoryWiringEndToEndTests lives in MemoryTests
  (not AgentOrchestratorTests as the plan suggested) because Memory
  depends on AgentOrchestrator via SPM and the cycle would block
  test compilation. Recorded in the plan SUMMARY.
2026-05-01 14:56:13 -05:00
james.maes 23be18501c feat(09-01): TurnTranscriptStore actor + 7 isolation/concat tests
- Per-turn (userText, assistantText) accumulator keyed by TurnID.
- BLOCKER-1 source of truth: feeds lookupTurnContent so
  MemoryExtractionCoordinator stops short-circuiting on nil pairs.
- flushPair returns the pair only when BOTH halves populated; removes
  the entry on success (bounded memory).
- Multiple assistant deltas concatenate; turn IDs are isolated.
- Placed under packages/AgentCore/Sources/AgentOrchestrator/ rather
  than App/AgentRuntime/ because the App target lacks SPM test
  discovery (plan-recorded fallback).
2026-05-01 14:39:18 -05:00
james.maes b055da68fa feat(09-01): OrchestratorEventBroadcaster fan-out actor + 6 D-invariant tests
- Single drainer of orchestrator.events fans out to N child AsyncStreams (D-05).
- Per-consumer bounded mirror with drop-oldest policy (D-06).
- Per-priority filter — memory/transcript protect everything except
  tokenDelta/thinkingDelta; voice protects turnEnd+error; frameAttach
  protects turnEnd; devOverlay treats all as lossy (D-07).
- start()/stop() lifecycle; drain Task lives for app lifetime (D-08).
- Tests: fan-out, drop-oldest, priority-protection on saturation,
  stuck-consumer isolation, stop-cancels-drain, full protection matrix.
2026-05-01 14:37:41 -05:00
james.maes 86ff3accae feat(07-05): OllamaProvider OpenAI-compat image_url multimodal override (Task 3 GREEN)
- OllamaRequestBody.encodeNativeMultimodal + encodeOpenAICompatMultimodal
  produce the OpenAI-vision data-URL block:
  {type:image_url, image_url:{url:'data:<mediaType>;base64,<b64>'}}
- Both T1 (Ollama gemma4:31b) and T2 (vllm-mlx Qwen3.5 via useOpenAICompat=true)
  consume the same encoder
- OllamaProvider.stream(messages:images:...) routes through the new encoders;
  empty images still produce byte-identical bodies
- All 5 OllamaImageBlockEncodingTests + 147 existing tests pass
2026-04-29 17:45:01 -05:00
james.maes 3c7370fe95 test(07-05): add failing Ollama image_url encoding tests (Task 3 RED)
- Native /api/chat path: data-URL image_url block
- OpenAI-compat /v1/chat/completions path: same data-URL shape
- Text precedes image_url; deterministic ordering
- Empty images byte-identical to single-modal encoders
2026-04-29 17:41:41 -05:00
james.maes 77e07d585b feat(07-05): AnthropicProvider image_block multimodal override (Task 2 GREEN)
- RequestBody.encodeMultimodal interleaves Anthropic Vision API image_block
  blocks: {type:image, source:{type:base64, media_type, data}}
- AnthropicProvider.stream(messages:images:...) overrides default extension
- run() unified to a single multimodal-capable code path; single-modal
  stream forwards via images:[]
- EncodedContentBlock gains optional 'source' field for image_block shape
- All 4 AnthropicImageBlockEncodingTests + 142 existing tests pass
2026-04-29 17:40:49 -05:00
james.maes e4982d50b2 test(07-05): add failing Anthropic image_block encoding tests (Task 2 RED)
- Asserts the exact Anthropic Vision API image_block JSON shape
- media_type sourced from ImageBlock.mediaType (not hard-coded)
- Deterministic ordering: text block precedes image block
- images:[] produces byte-identical body to single-modal path
2026-04-29 17:38:01 -05:00
james.maes 202b7e36a6 feat(07-05): LLMProvider multimodal protocol extension + ImageBlock + TurnInput.images (Task 1 GREEN)
- ImageBlock(mediaType:data:) Sendable+Equatable canonical frame value type
- LLMProvider gains stream(messages:images:tools:...) with default extension
  forwarding to single-modal when images.isEmpty (D-18)
- Non-overriding conformers precondition-fail when images is non-empty
- TurnInput.images defaults to []; existing initializers compile unchanged
- TurnInput.withImages factory for FrameAttachController consumers
- LLMProviderMultimodalTests pass (forward dispatch + override + ImageBlock equality)
2026-04-29 17:37:06 -05:00
james.maes f8f8920bae test(07-05): add failing LLMProvider multimodal tests (Task 1 RED)
- LLMProviderMultimodalTests asserts default forwarding when images.isEmpty
- Override path receives images on multimodal-conformant mock
- ImageBlock equality/Sendable contract
- TurnInput.images round-trip (added in GREEN)
2026-04-29 17:33:03 -05:00
james.maes daae27e720 feat(05-05): App-level MCPRuntimeWiring + ME-04 channel + SEC-07 observer
Closes ME-04 (Plan 04-05 deferred state). AppDelegate.applicationWill
FinishLaunching now instantiates BoundedAsyncChannel<ReplayEvent>(
capacity: 2048, policy: .dropOldest) for the orch→replay seam — the
production instance that Plan 04-05's ChannelTopologyTests CT2 only
verified as a primitive.

- App/MCP/MCPRuntimeWiring.swift: dependency-graph constructor for the
  full chain (MCPClient → ToolRegistry → MCPToolDispatcher →
  ConfirmingToolDispatcher) plus a compose() test seam. The
  ConfirmationPresenterHolder breaks the broker↔presenter cyclic init.
- App/MCP/ReplayingToolResultObserver.swift: SEC-07 observer that
  records pre-/post-sanitize bytes via two ReplayEvent.toolResultFull
  rows (one canonical, one ':raw'-suffixed toolUseId). Uses an
  injected turnIDResolver closure since orchestrator wiring lands in a
  later plan.
- AppDelegate stores the orchToReplayChannel + drain Task and tears
  them down on applicationWillTerminate.
- App/Tests/AppTests/MCPRuntimeWiringTests.swift: 3 structural tests
  (compose-returns-ConfirmingToolDispatcher; channel-capacity-+-policy;
  MCPRuntime-struct-addressable). No xcodebuild test launch — known
  Xcode 26 RunningBoard blocker per .planning/debug/.

Deviations:
- Replay / AgentCore / MCP / Bus Package.swifts had implicit transitive
  swift-log deps that SPM tolerated but Xcode framework-linker rejects
  once the App pulls them in as separate frameworks. Added explicit
  swift-log deps to all four (Rule 3, blocking).
- HudStateCoordinatorBusWiringTests qualified two HudState references
  to Jarvis.HudState — the new framework deps disturbed the App vs Bus
  symbol disambiguation (Rule 1, surgical).
- The plan's plain ReplayEvent.toolResultFull(toolUseId:rawBytes:
  sanitizedBytes:) variant doesn't exist (Phase 4 schema closed); the
  observer writes two rows with a ':raw' toolUseId suffix instead.

xcodebuild build (Debug) succeeds with the new MCP deps wired into the
App target. Test build (build-for-testing) also succeeds; runtime test
execution is the documented Xcode 26 RunningBoard blocker per
03-05-PLAN.
2026-04-25 14:57:01 -05:00
james.maesandClaude Opus 4.7 a38d3a7ecd fix(04-review): HI-02 replace file-size proxies with real DB-row assertions in OC4 + OS5 + TE2
Three tests claimed to verify orchestrator durability behavior but
asserted only "DB file size > 1000/2000 bytes" — a tautology satisfied
by the schema + pragmas alone. The tests would pass even if the
production behavior they advertised had been removed entirely.

Replace the proxies with direct DB queries via SQLiteConnection (same
pattern as ReplayLogTests.test_L4):

OC4 (cancelled-turn-stop-reason):
 - Open closed DB and SELECT ended_at, stop_reason FROM turns WHERE
   turn_id = priorId.
 - Assert ended_at NOT NULL AND stop_reason == 'cancelled'.

OS5 (replay-nonce):
 - SELECT turn_nonce, source FROM turns WHERE turn_id = ?.
 - Assert nonce non-empty, length ≥ 16 (16-byte SecRandomCopyBytes
   base64url ~= 22 chars), and source column matches TurnInput.text.

TE2 (replay receives turn):
 - SELECT started_at, ended_at FROM turns — both NOT NULL.
 - SELECT COUNT(*) FROM events WHERE kind='text_delta' ≥ 1.
 - SELECT COUNT(*) FROM events WHERE kind='tool_call_requested' ≥ 1.

These tests will now FAIL if the orchestrator regresses (e.g., drops
endTurn-on-cancel, fails to persist nonce, or stops emitting toolCall
events to replay). Proves the assertions aren't tautologies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:11:08 -05:00
james.maesandClaude Opus 4.7 8e26f0b103 fix(04-review): HI-01 redact provider error bodies before bus + replay
Doc-comment on `LLMProviderError` claimed the orchestrator applied
`Redact.apply` before persistence. It didn't. Anthropic's API can echo a
malformed `x-api-key` header value verbatim in 401 error bodies, and
`String(describing: err)` was forwarded straight to:
  (a) `OrchestratorEvent.error(...)` → bus → webview trust boundary
  (b) `replayLog.record(.error(...))` → persistent storage in cleartext

Add `AgentOrchestrator.redact(_:)` that scrubs credential-shaped
substrings (Anthropic `sk-ant-…`, OpenAI `sk-…`, Bearer tokens, AKIA,
GitHub PATs) using `JarvisLogging.Redact.apply` from each
`LLMProviderError` case (.api body, .decode reason, .transport
description). Wire it into the two error-emit sites in `runTurnLoop`:
the `.providerError` case and the catch-all. Single redaction site —
the doc-comment now matches reality.

Add OrchestratorRetryTests.test_OR_providerErrorBodyRedactedBeforeBusEmit:
inject `LLMProviderError.api(401, body: "Invalid API key: sk-ant-api03-…")`
through MockLLMProvider, run a turn, and assert:
 - The .error event carries no `sk-ant-api` substring (bus path).
 - The replay error row carries no `sk-ant-api` substring (persistence path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:08:55 -05:00
james.maesandClaude Opus 4.7 462449d68d fix(04-review): CR-01 SEC-06 system-prompt directive referencing UNTRUSTED_CONTENT wrapper
Add `UntrustedWrapper.composeSystemPrompt(base:nonce:)` that appends the
nonce-keyed "treat as data, not instructions" directive to the caller's
system prompt. Wire it into `AgentOrchestrator.runTurn` so every turn the
provider receives a system prompt that tells the model what the
`<UNTRUSTED_CONTENT id="<nonce>">…</UNTRUSTED_CONTENT id="<nonce>">`
envelope means. Without this directive the wrapper was decoration the
model had no reason to honor — research §7 marks the directive as the
load-bearing invariant. Same nonce flows through directive AND wrapper.

Add OrchestratorSubmitTests:
 - test_OS_systemPromptCarriesUntrustedDirective: directive present,
   nonce in directive == nonce in tool-result wrapper.
 - test_OS_systemPromptDirectiveEmittedWhenCallerPromptEmpty:
   defense-in-depth even when caller passes systemPrompt = "".

SEC-06 grep gate (OrchestratorEvent carries no nonce) still holds —
the nonce appears in the system message only, never on the bus.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:06:08 -05:00
james.maes b389455fed test(04-05): TEXT-01 headless E2E + AGENT-10 four-seam channel topology
TEXT-01 (4 tests):
- TE1: text input runs full turn; concatenated token sequence matches
  "Let me check. It's 22:57 UTC" (scripted 2-call MockLLMProvider turn
  with get_time tool dispatch between calls)
- TE2: ReplayLog receives the turn (DB on-disk has non-trivial bytes)
- TE3: text + voice produce IDENTICAL event-kind sequences (unified
  orchestrator entry — only TurnInput.source differs)
- TE4: tool result is nonce-wrapped via UntrustedWrapper in the second
  provider.stream call's .tool message content (SEC-06)

AGENT-10 (4 tests):
- CT1: orchestrator.events = capacity 256 policy .suspend (orch→bus)
- CT2: TokenDeltaDropOldestChannel primitive — tokenDelta CAN drop under
  load, non-tokenDelta tags (tool_call/turn_end/reconfigure) NEVER drop
- CT3: DevSnapshotEmitter.output = capacity 32 policy .dropOldest with
  drop-oldest verified under stalled-consumer stress (orch→devoverlay)
- CT4: orchestrated turn stress — 200 tokenDeltas + 3 tool_calls through
  scripted multi-call turn; ALL 200 tokenDeltas and ALL tool_card
  updates delivered on orch.events (.suspend policy is non-lossy)

Tests live in AgentOrchestratorTests (not AgentCoreTests as plan said —
Rule 3 location fix; MockLLMProvider + Helpers live here). Re-uses the
closure-based StubToolDispatcher from 04-04 rather than a new
MockToolDispatcher (functionally identical).

131 AgentCore tests pass (was 123, +8 new). DevOverlay 14/14,
Replay 25/25, Bus 47/47 — no regression. Release builds green.
2026-04-24 18:41:47 -05:00
james.maes 5e50f4066c feat(04-05): DevSnapshotEmitter aggregates OrchestratorEvent → DevSnapshot (OBS-01)
- actor DevSnapshotEmitter lives in AgentOrchestrator target (not AgentCore as
  plan said — Rule 3 deviation for dep-cycle avoidance, same rationale
  Plan 04-04 used for AgentOrchestrator itself)
- output channel: BoundedAsyncChannel<DevSnapshot> capacity 32 policy
  .dropOldest (AGENT-10 four-seam invariant — observational, stale is fine)
- TTFB tracked via ContinuousClock between .stateChange(.thinking) and
  first .tokenDelta; totalMs between .thinking and .turnEnd
- Last-5 tool call ring dedupes on toolUseId (T-04-05-03 DoS mitigation)
- .turnEnd is idempotent (double-turnEnd does not crash; second is no-op)
- BoundedAsyncChannel.capacity + .policy made `nonisolated` so topology
  tests (AGENT-10 verification) can introspect without actor hop
- 9 DevSnapshotEmitter tests added (123 AgentCore tests total, was 114)
- DevOverlay tests still pass (14/14)
2026-04-24 18:36:29 -05:00
james.maes 1aeec87682 feat(04-05): DevOverlay package + DevSnapshot/ToolCallRow in AgentOrchestrator (OBS-01)
- NSPanel-backed DevOverlayWindow (.floating + .nonactivating) hidden by default
- SwiftUI DevOverlayView renders OBS-01 surface: state/turn, provider/model,
  token counts, cache creation/read + hit %, latency, last-5 tool calls
- @Observable @MainActor DevOverlayViewModel — single write path via apply()
- DevOverlayBridge pumps BoundedAsyncChannel<DevSnapshot> → ViewModel
- DevSnapshot + ToolCallRow live in AgentOrchestrator target (Rule 3
  deviation from plan's files_modified: DevSnapshotEmitter consumes both
  OrchestratorEvent and DevSnapshot, so parking both in AgentOrchestrator
  avoids the circular AgentOrchestrator→DevOverlay→AgentOrchestrator dep)
- Additive OrchestratorEvent.usage(turnId:usage:) — surfaces TurnUsage to
  DevOverlay without reading replay; no nonce (SEC-06 preserved)
- AgentOrchestrator now emits .usage on LLMEvent.usage alongside replay write
- 14 DevOverlay tests pass; 114 AgentCore tests pass (no regression)
2026-04-24 18:32:56 -05:00
james.maes 6c93c57fda test(04-04): stream_truncated retry (AGENT-09) + cap-recovery (AGENT-07)
The retry + cap-recovery state machines landed inline in Task 2's
AgentOrchestrator (the unified turn-loop is too entangled to split). This
commit adds the test matrix that exercises both paths:

OrchestratorRetryTests (5):
- OR1 stream_truncated → 1 retry → completes (turnEnd .endTurn)
- OR2 second back-to-back truncation is terminal (.streamTruncatedFinal),
  zero third attempt
- OR3 retry resets the tool-call budget (10 fresh slots after partial use)
- OR4 retry re-reads PerTurnSnapshot (config change between attempts)
- OR5 .refusal does NOT trigger retry (single-call termination)

OrchestratorCapRecoveryTests (4):
- 10-tool-use budget exhaustion forces an 11th call with toolChoice .none
- AGENT-07 critical: cap-recovery call.toolChoice == .none + tools = []
- Zero .toolUseRequested events emitted on the recovery call (R4-L1 guard)
- .turnEnd with the recovery turn's stop_reason after recovery completes

Verification gates:
- AgentCore: 114/114 (94 base + 20 orchestrator)
- Replay: 25/25 unchanged
- Bus: 47/47 unchanged
- swift build -c release: clean
- SEC-06 grep gate: turnNonce in OrchestratorEvent.swift = 0
- AGENT-06 grep gate: `await priorTask.value` present
- AGENT-09 grep gate: streamTruncatedFinal mentions = 4
2026-04-24 18:19:11 -05:00
james.maes 219532f468 feat(04-04): AgentOrchestrator actor + submit/cancelAndSubmit (AGENT-06)
- AgentOrchestrator actor: turn-lifecycle state machine; LLMProvider
  protocol-driven; injection via providerFactory closure for testability
- submit(_:) returns .ran or .rejected(.turnInFlight)
- cancelAndSubmit(_:) returns .superseded(priorId, .bargedIn); critical
  AGENT-06 actor-reentrancy guard: priorTask.cancel() THEN
  `_ = await priorTask.value` BEFORE assigning new currentTurn (prevents
  late .messageStop from cancelled stream from clobbering currentTurn)
- ReplayLog wired: turnNonce persisted via startTurn(turnNonce:); full
  blob via .toolResultFull (AGENT-08); SEC-06 invariant (nonce never
  appears in OrchestratorEvent emitted to bus)
- ToolResultPacker integration: 8 KB cap on model-facing string (AGENT-08);
  full bytes preserved for replay via separate channel
- Tool-use loop: append assistant tool_use msg, dispatch via
  ToolDispatcher, append wrapped tool_result msg, re-invoke provider
- Architectural deviation (Rule 3 — blocking dep cycle): Replay already
  depends on AgentCore for TurnID; the plan's literal layout would have
  introduced AgentCore→Replay cycle. Resolved by carving a new target
  `AgentOrchestrator` in the AgentCore package, depending on both
  AgentCore and Replay. Plan files_modified paths shifted from
  `Sources/AgentCore/` to `Sources/AgentOrchestrator/` accordingly
- 11 new tests (OrchestratorSubmitTests + OrchestratorCancelTests) plus
  MockLLMProvider scriptable test double; 105 total AgentCore tests green
2026-04-24 18:16:05 -05:00
james.maes 9c0de9f006 feat(04-04): TurnNonce + UntrustedWrapper + ToolResultPacker (SEC-06, AGENT-08)
- TurnNonce.fresh(): SecRandomCopyBytes(16) → base64url ~22 chars (SEC-06)
- UntrustedWrapper: tag-strip prefilter + nonce-bracketed envelope
- ToolResultPacker: 8 KB model-facing cap + truncation marker (AGENT-08);
  full bytes preserved for ReplayLog routing
- Promoted Base64URL.swift from AnthropicProvider (internal) to AgentCore
  (public) so both targets share the encoder
- 13 new tests across TurnNonce / UntrustedWrapper / ToolResultPacker; full
  AgentCore suite at 94 green
2026-04-24 18:03:25 -05:00
james.maes 77d78bbf3e test(04-02): byte-replay fixture corpus — 6 NDJSON + SSE streams with AGENT-04 regression guard
Six on-disk fixtures the decoders are replayed against:
- happy-text.txt          NDJSON, multi-chunk text + stop
- text-then-tool-call.txt NDJSON, AGENT-04 guard — tool_calls on done:false chunk
- parallel-tool-calls.txt NDJSON, two tool_calls in one chunk
- mid-stream-eof.txt      NDJSON, no terminator (streamTruncated)
- openai-compat-happy.txt SSE, [DONE] sentinel
- openai-compat-tool-call.txt SSE, atomic tool_calls with stringified arguments

testFixture_textThenToolCall_AGENT04 is the fixture-level regression guard:
hits the production code with bytes loaded from disk, asserting
toolUseRequested precedes stopReason. Defence in depth alongside the
inline NDJSONDecoderTests.testToolCallsOnNonTerminatorChunk_AGENT04.

Removes the empty-directory .gitkeep added in Task 1.

Total: 6 new fixture-replay tests; AgentCore suite now 81 tests passing.
2026-04-24 17:45:04 -05:00
james.maes cbc5efcc02 feat(04-02): OllamaProvider actor + OpenAI-compat SSE decoder behind feature flag
- OllamaProvider actor conforms to LLMProvider; branches between
  /api/chat NDJSON (default) and /v1/chat/completions SSE on
  useOpenAICompat flag.
- OpenAICompatDecoder handles atomic tool_calls and accepts arguments
  as either a JSON-encoded string (OpenAI convention) or object.
  Distinct state machine from NDJSONDecoder per the plan's
  "zero shared decoder state" directive.
- [DONE] sentinel emits stopReason+usage+messageStop trio; mid-stream
  EOF emits .stopReason(.streamTruncated).
- 8 tests for OpenAICompatDecoder + 1 conformance compile check.
2026-04-24 17:41:03 -05:00
james.maes 2323a6b929 feat(04-02): NDJSONDecoder with tool_calls-on-sight invariant + OllamaRequestBody drop-tools-array on .none (AGENT-04, AGENT-07)
- NDJSONDecoder emits .toolUseRequested whenever tool_calls is present on
  any chunk; never gates on done:true (AGENT-04 transport gotcha).
- Each tool_use gets a fresh UUID id (Ollama emits no ids on /api/chat).
- done_reason mapping: stop→endTurn (or toolUse if calls seen), length→
  maxTokens, tool_calls→toolUse, missing→toolUse-if-calls-else-endTurn.
- Mid-stream EOF emits .stopReason(.streamTruncated) — no
  partialToolUseAtDisconnect (NDJSON is atomic per line).
- OllamaRequestBody.encodeNative drops the tools array entirely when
  ToolChoice == .none (AGENT-07 cap-recovery invariant).
- ToolChoice.tool(name:) appends a best-effort system hint message
  (Ollama has no first-class tool-choice.force).
- OpenAI-compat path: tool_choice serializes as "auto"/"none"/"required"
  string or {type:function,function:{name}} object.
- Replaces Plan 04-01 placeholder files; tests: 19 passing.
2026-04-24 17:38:35 -05:00
james.maes bb4be1202a test(04-01): byte-replay fixture corpus — 9 SSE streams covering every AGENT-03 edge case
- 9 hand-crafted SSE fixtures under Tests/AnthropicProviderTests/Fixtures/:
  - happy-text.txt: 3 text deltas + close trio
  - text-then-tool-use.txt: text → tool_use with 4-chunk input_json_delta assembly
  - thinking-then-text.txt: 2 thinking_delta events before any text_delta
  - refusal.txt: stop_reason: refusal (NOT end_turn)
  - mid-delta-disconnect.txt: ends mid-input_json_delta — no message_stop
  - ping-spam.txt: 20 pings interleaved with 3 text deltas (pings produce zero LLMEvents)
  - unknown-event.txt: future_event/another_future_event names — decoder logs and continues
  - cache-hit.txt: cache_read_input_tokens=4251 surfaces in usagePrefix
  - empty-input-json-delta.txt: middle chunk partial_json="" must be skipped
- FixtureReplayTests.swift: 9 tests using ExpectedEvent enum + per-case match helper (LLMEvent isn't Equatable due to associated LLMProviderError; structural matching is the test contract)
- SSELineReader: dropped Bytes.AsyncIterator: Sendable constraint — iterator stays in launched Task, never crosses isolation; AsyncStream<UInt8>.Iterator otherwise can't satisfy Swift 6 strict-concurrency
- Final tally: 49 tests pass across AgentCore (21) + AnthropicProvider (28) test targets
2026-04-24 17:27:21 -05:00
james.maes a731b5db6e feat(04-01): AnthropicProvider with URLSession SSE decoder covering all six AGENT-03 edges + 1h cache TTL header wiring (AGENT-02, AGENT-03, AGENT-07)
- AnthropicProvider actor: hand-rolled URLSession + per-request apiKeyProvider closure (S-5 fetch-per-request) + nonisolated stream(...) returning AsyncThrowingStream<LLMEvent, Error>
- AGENT-02: every request carries anthropic-version: 2023-06-01 + anthropic-beta: extended-cache-ttl-2025-04-11 (unconditional) + x-api-key + Accept: text/event-stream + Content-Type: application/json
- SSELineReader: generic over AsyncSequence<UInt8> — same code path for URLSession.AsyncBytes + test fixtures; multi-line data: concat with \n; CRLF tolerant; flushes pending frame on EOF
- SSEDecoder: state machine covering all AGENT-03 edges
  - message_stop (NOT message_delta) is canonical close
  - ping events swallowed
  - thinking_delta routes to its own LLMEvent case
  - stop_reason: refusal first-class (not warning)
  - input_json_delta chunks concatenated; one toolUseRequested on content_block_stop
  - empty partial_json chunks skipped
  - mid-stream EOF emits partialToolUseAtDisconnect, then streamTruncated, then messageStop
- RequestBody: Codable encoder shapes Anthropic JSON via typed JSONValue enum (no [String: Any]); cache_control attaches to first system block with ttl: 1h when CacheHints.extended1h; tools.input_schema embedded inline (NOT double-encoded as string); ToolChoice.none → {"type":"none"}
- Base64URL helper for Plan 04-04 turnNonce wrapping (SEC-06)
- 7 RequestBodyTests + 11 SSEDecoderTests passing
2026-04-24 17:21:04 -05:00
james.maes 0020c35fb6 feat(04-01): scaffold AgentCore package with LLMProvider protocol + LLMEvent + BoundedAsyncChannel (AGENT-01, AGENT-07, AGENT-10)
- packages/AgentCore SPM package with three library products: AgentCore, AnthropicProvider, OllamaProvider (latter two ship placeholders this wave)
- LLMProvider protocol — Sendable, AsyncThrowingStream<LLMEvent, Error>, mandatory toolChoice param
- LLMEvent enum: 10 cases (messageStart, textDelta, thinkingDelta, toolUseRequested, toolUseBuffering, partialToolUseAtDisconnect, stopReason, usage, providerError, messageStop)
- StopReason: 5 cases (endTurn, toolUse, maxTokens, refusal, streamTruncated)
- ToolChoice: 4 cases (auto, none, any, tool(name:))
- ModelID literals per RESEARCH-DELTAS D1 — opus47 = "claude-opus-4-7", qwen25coder32b = "qwen2.5-coder:32b"
- CacheHints / TurnID / TurnUsage / ToolSchema / LLMMessage / LLMProviderError supporting types
- BoundedAsyncChannel<T> actor with .suspend / .dropOldest / .dropNewest policies, AsyncSequence conformance, single-consumer iterator
- 21 AgentCoreTests passing (channel policies, exhaustive enum cases, equality, RawValue literals)
2026-04-24 17:13:40 -05:00