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:
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user