7 Commits
Author SHA1 Message Date
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 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.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 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 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