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>
This commit is contained in:
james.maesandClaude Opus 4.7 committed 2026-05-06 09:25:00 -05:00
1 parent 33881d126b
commit d8983af004
2 files changed
+171 -1

No files matched your search

@@ -87,7 +87,8 @@ enum RequestBody {
: encodeSystem(systemMessages, cacheHints: cacheHints),
messages: encodedConv,
tools: tools.isEmpty ? nil : try tools.map(encodeTool),
toolChoice: encodeToolChoice(toolChoice)
toolChoice: encodeToolChoice(toolChoice),
stream: true
)
let encoder = JSONEncoder()
@@ -235,6 +236,11 @@ struct AnthropicRequestBody: Encodable {
let messages: [EncodedMessage]
let tools: [EncodedTool]?
let toolChoice: EncodedToolChoice
/// Anthropic's `/v1/messages` returns SSE iff this is `true`. Without it,
/// the server returns a regular JSON response (200 OK + ~1 KB body) and
/// the SSELineReader yields zero `data:` frames — the canonical
/// `streamTruncatedFinal` cause caught live on 2026-05-05.
let stream: Bool
enum CodingKeys: String, CodingKey {
case model
@@ -243,6 +249,7 @@ struct AnthropicRequestBody: Encodable {
case messages
case tools
case toolChoice = "tool_choice"
case stream
}
}
@@ -0,0 +1,163 @@
import XCTest
import Foundation
@testable import AnthropicProvider
@testable import AgentCore
/// Live integration test against the real Anthropic API.
///
/// Reproduces the production `streamTruncatedFinal` failure mode that surfaced
/// when running the actual app on 2026-05-05: a small system+user prompt
/// returns HTTP 200 + zero SSE frames + zero `tokenDelta` events.
///
/// Gated on `JARVIS_REAL_ANTHROPIC=1` AND `ANTHROPIC_API_KEY=sk-ant-...`
/// being set in the env. Skips otherwise so CI never tries to hit the API.
///
/// Run with:
///
/// JARVIS_REAL_ANTHROPIC=1 ANTHROPIC_API_KEY=sk-ant-... \
/// swift test --package-path packages/AgentCore \
/// --filter AnthropicProviderTests.LiveAnthropicStreamingTests
final class LiveAnthropicStreamingTests: XCTestCase {
private func skipIfNotConfigured() throws -> String {
let env = ProcessInfo.processInfo.environment
guard env["JARVIS_REAL_ANTHROPIC"] == "1" else {
throw XCTSkip("set JARVIS_REAL_ANTHROPIC=1 to enable live Anthropic integration tests")
}
guard let key = env["ANTHROPIC_API_KEY"], key.hasPrefix("sk-ant-") else {
throw XCTSkip("set ANTHROPIC_API_KEY=sk-ant-... to enable live Anthropic integration tests")
}
return key
}
/// L1 — the failing test. Mirrors what AppDelegate's first turn looks like:
/// small system prompt + small user prompt + Opus 4.7 + streaming. The bug
/// shows up here: stream completes with zero `tokenDelta` events.
func testL1_smallPrompt_smallSystem_streamsAtLeastOneTokenDelta() async throws {
let apiKey = try skipIfNotConfigured()
let provider = AnthropicProvider(apiKeyProvider: { apiKey })
// Emulate a sub-1024-token prompt — the regime where CacheHints
// returns nil and `cache_control` blocks are absent from the body.
let smallSystemPrompt = "You are a helpful assistant."
let messages: [LLMMessage] = [
LLMMessage(role: .system, content: [.text(smallSystemPrompt)]),
LLMMessage(role: .user, content: [.text("Reply with exactly the word: pong")])
]
// Don't ask for cache (small prompt — eligibility helper would say nil).
let cacheHints: CacheHints? = nil
let events = provider.stream(
messages: messages,
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 64,
cacheHints: cacheHints
)
var tokenDeltaCount = 0
var messageStopSeen = false
var providerErrors: [String] = []
var stopReasons: [StopReason] = []
var collectedText = ""
for try await event in events {
switch event {
case .textDelta(let s):
tokenDeltaCount += 1
collectedText += s
case .messageStop:
messageStopSeen = true
case .stopReason(let reason):
stopReasons.append(reason)
case .providerError(let err):
providerErrors.append(String(describing: err))
default:
break
}
}
// Diagnostic on failure
if tokenDeltaCount == 0 {
XCTFail("""
L1 FAIL: zero tokenDelta events.
messageStopSeen=\(messageStopSeen)
stopReasons=\(stopReasons)
providerErrors=\(providerErrors)
collectedText='\(collectedText)'
This is the production streamTruncatedFinal regression.
""")
return
}
XCTAssertGreaterThan(tokenDeltaCount, 0, "L1: expected at least one tokenDelta")
XCTAssertTrue(messageStopSeen, "L1: expected messageStop")
XCTAssertFalse(collectedText.isEmpty, "L1: expected non-empty text")
}
/// L3 — TDD assertion that should be the regression fence. Anthropic's
/// `/v1/messages` returns SSE only when `"stream": true` is in the
/// REQUEST BODY. The `Accept: text/event-stream` header alone is not
/// sufficient; with `Accept: text/event-stream` + missing `stream` field
/// the server returns a regular JSON response (200 OK + ~1 KB body),
/// our SSELineReader reads zero `data:` frames, and the decoder emits
/// `streamTruncatedFinal`. Reproduced live on 2026-05-05 against the
/// production AppDelegate path.
///
/// FAILING NOW. Will pass after RequestBody.encodeMultimodal sets
/// `stream = true` in the encoded body.
func testL3_requestBody_includesStreamTrue() throws {
let messages: [LLMMessage] = [
LLMMessage(role: .user, content: [.text("ping")])
]
let body = try RequestBody.encodeMultimodal(
messages: messages,
images: [],
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 16,
cacheHints: nil
)
let json = try XCTUnwrap(
JSONSerialization.jsonObject(with: body) as? [String: Any],
"request body must be valid JSON object"
)
XCTAssertEqual(
json["stream"] as? Bool, true,
"L3: request body MUST include `\"stream\": true` to opt into SSE; missing field is the canonical streamTruncatedFinal cause"
)
}
/// L2 — diagnostic: dump exactly what request body our code produces for
/// the L1 prompt. No network. Useful for diff'ing against a known-good
/// curl request once we figure out what's wrong.
func testL2_dumpRequestBody_forSmallPrompt() throws {
// Skip env gate for L2 — it's pure encoding.
let messages: [LLMMessage] = [
LLMMessage(role: .system, content: [.text("You are a helpful assistant.")]),
LLMMessage(role: .user, content: [.text("Reply with exactly the word: pong")])
]
let body = try RequestBody.encodeMultimodal(
messages: messages,
images: [],
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 64,
cacheHints: nil
)
let json = String(data: body, encoding: .utf8) ?? "<non-utf8>"
// Print to stdout so we can read it from the test runner output.
print("=== L2 request body ===")
print(json)
print("=== L2 end ===")
XCTAssertTrue(json.contains("\"model\""), "body should include model field")
XCTAssertTrue(json.contains("\"messages\""), "body should include messages field")
}
}