Files
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

220 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
import Foundation
@testable import AnthropicProvider
@testable import AgentCore
/// Direct dispatch of synthetic SSEFrame values through `SSEDecoder.dispatch`.
/// No URLSession, no fixture files — pure state-machine coverage.
final class SSEDecoderTests: XCTestCase {
// MARK: - Helpers
/// Build an SSEFrame from a JSON string `data` payload.
private func frame(_ event: String, _ json: String) -> SSEFrame {
SSEFrame(event: event, data: Data(json.utf8))
}
/// Run a sequence of frames through SSEDecoder, collecting events.
private func run(_ frames: [SSEFrame], flushOnEOF: Bool = false) -> [LLMEvent] {
var state = SSEDecoder.State()
var emitted: [LLMEvent] = []
for f in frames {
SSEDecoder.dispatch(frame: f, state: &state) { emitted.append($0) }
}
if flushOnEOF, !state.messageStopEmitted {
SSEDecoder.flushOnEOF(state: &state) { emitted.append($0) }
}
return emitted
}
// MARK: - Test S1: message_start parses usage prefix
func testMessageStartEmitsLLMMessageStart() {
let events = run([
frame("message_start", #"""
{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-4-7","usage":{"input_tokens":12,"cache_creation_input_tokens":100,"cache_read_input_tokens":4251,"output_tokens":1}}}
"""#),
])
XCTAssertEqual(events.count, 1)
guard case .messageStart(let start) = events[0] else {
XCTFail("expected .messageStart"); return
}
XCTAssertEqual(start.messageId, "msg_1")
XCTAssertEqual(start.model, "claude-opus-4-7")
XCTAssertEqual(start.usagePrefix?.inputTokens, 12)
XCTAssertEqual(start.usagePrefix?.cacheCreationInputTokens, 100)
XCTAssertEqual(start.usagePrefix?.cacheReadInputTokens, 4251)
}
// MARK: - Test S2: three text deltas + close trio
func testThreeTextDeltasAndCloseTrio() {
let events = run([
frame("message_start", #"{"type":"message_start","message":{"id":"m","model":"x","usage":{"input_tokens":1,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#),
frame("content_block_start", #"{"index":0,"content_block":{"type":"text"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"text_delta","text":"a"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"text_delta","text":"b"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"text_delta","text":"c"}}"#),
frame("content_block_stop", #"{"index":0}"#),
frame("message_delta", #"{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}"#),
frame("message_stop", #"{"type":"message_stop"}"#),
])
// Expected: messageStart, textDelta×3, stopReason(.endTurn), usage, messageStop
XCTAssertEqual(events.count, 7)
if case .textDelta("a") = events[1] {} else { XCTFail("idx 1 expected textDelta(a)") }
if case .textDelta("b") = events[2] {} else { XCTFail("idx 2 expected textDelta(b)") }
if case .textDelta("c") = events[3] {} else { XCTFail("idx 3 expected textDelta(c)") }
if case .stopReason(.endTurn) = events[4] {} else { XCTFail("idx 4 expected stopReason(endTurn)") }
if case .usage(let u) = events[5] {
XCTAssertEqual(u.outputTokens, 3)
} else { XCTFail("idx 5 expected usage") }
if case .messageStop = events[6] {} else { XCTFail("idx 6 expected messageStop") }
}
// MARK: - Test S3: ping events emit ZERO LLMEvents
func testPingEventsAreSwallowed() {
let events = run([
frame("ping", "{}"),
frame("ping", "{}"),
frame("ping", "{}"),
])
XCTAssertEqual(events.count, 0,
"ping events must produce zero LLMEvents")
}
// MARK: - Test S4: tool_use sequence emits ONE assembled toolUseRequested
func testToolUseSequenceEmitsOneAssembledRequest() {
let events = run([
frame("content_block_start", #"{"index":0,"content_block":{"type":"tool_use","id":"toolu_X","name":"get_time","input":{}}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"\":1"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":",\"b\":"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"2}"}}"#),
frame("content_block_stop", #"{"index":0}"#),
])
// Expected: toolUseBuffering, toolUseRequested. No intermediate emits.
XCTAssertEqual(events.count, 2)
if case .toolUseBuffering(let id) = events[0] {
XCTAssertEqual(id, "toolu_X")
} else { XCTFail("idx 0 expected toolUseBuffering") }
guard case .toolUseRequested(let req) = events[1] else {
XCTFail("idx 1 expected toolUseRequested"); return
}
XCTAssertEqual(req.id, "toolu_X")
XCTAssertEqual(req.name, "get_time")
let assembled = String(data: req.argsJSON, encoding: .utf8)
XCTAssertEqual(assembled, "{\"a\":1,\"b\":2}")
// Verify it parses as JSON.
XCTAssertNoThrow(try JSONSerialization.jsonObject(with: req.argsJSON))
}
// MARK: - Test S5: empty input_json_delta is skipped
func testEmptyInputJSONDeltaIsSkipped() {
let events = run([
frame("content_block_start", #"{"index":0,"content_block":{"type":"tool_use","id":"t","name":"n"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"{\"k"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":""}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"\":1"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":""}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"}"}}"#),
frame("content_block_stop", #"{"index":0}"#),
])
guard case .toolUseRequested(let req) = events.last else {
XCTFail("expected trailing toolUseRequested"); return
}
XCTAssertEqual(String(data: req.argsJSON, encoding: .utf8), "{\"k\":1}")
}
// MARK: - Test S6: thinking_delta routes to its own case
func testThinkingDeltaRoutesToThinkingDeltaEvent() {
let events = run([
frame("content_block_start", #"{"index":0,"content_block":{"type":"thinking"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"thinking_delta","thinking":"step 1"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"thinking_delta","thinking":"step 2"}}"#),
frame("content_block_stop", #"{"index":0}"#),
])
XCTAssertEqual(events.count, 2)
if case .thinkingDelta("step 1") = events[0] {} else { XCTFail("idx 0 expected thinkingDelta(step 1)") }
if case .thinkingDelta("step 2") = events[1] {} else { XCTFail("idx 1 expected thinkingDelta(step 2)") }
}
// MARK: - Test S7: stop_reason: refusal maps to .refusal
func testRefusalIsFirstClassStopReason() {
let events = run([
frame("message_delta", #"{"delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}"#),
frame("message_stop", #"{}"#),
])
// Expect: stopReason(.refusal), usage, messageStop
XCTAssertEqual(events.count, 3)
if case .stopReason(.refusal) = events[0] {} else { XCTFail("idx 0 expected .refusal") }
if case .messageStop = events[2] {} else { XCTFail("idx 2 expected messageStop") }
}
// MARK: - Test S8: mid-stream EOF mid-tool-args
func testMidStreamEOFEmitsPartialToolUseAtDisconnect() {
let events = run([
frame("content_block_start", #"{"index":0,"content_block":{"type":"tool_use","id":"toolu_Y","name":"search"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"{\"q"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"input_json_delta","partial_json":"uery"}}"#),
], flushOnEOF: true)
// Expect: toolUseBuffering, partialToolUseAtDisconnect, stopReason(.streamTruncated), usage, messageStop
XCTAssertEqual(events.count, 5)
if case .toolUseBuffering = events[0] {} else { XCTFail("idx 0 expected toolUseBuffering") }
guard case .partialToolUseAtDisconnect(let req) = events[1] else {
XCTFail("idx 1 expected partialToolUseAtDisconnect"); return
}
XCTAssertEqual(req.id, "toolu_Y")
XCTAssertEqual(req.name, "search")
XCTAssertEqual(String(data: req.argsJSON, encoding: .utf8), "{\"query")
if case .stopReason(.streamTruncated) = events[2] {} else { XCTFail("idx 2 expected streamTruncated") }
if case .messageStop = events[4] {} else { XCTFail("idx 4 expected messageStop") }
}
// MARK: - Test S9: unknown event name does not throw
func testUnknownEventNameContinuesGracefully() {
let events = run([
frame("future_event", #"{"data":"whatever"}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"text_delta","text":"ok"}}"#),
])
XCTAssertEqual(events.count, 1)
if case .textDelta("ok") = events[0] {} else { XCTFail("expected textDelta(ok)") }
}
// MARK: - Test S10: message_delta does NOT close — only message_stop does
func testMessageDeltaDoesNotClose() {
let events = run([
frame("message_delta", #"{"delta":{"stop_reason":"end_turn"}}"#),
frame("content_block_delta", #"{"index":0,"delta":{"type":"text_delta","text":"trailing"}}"#),
frame("message_stop", "{}"),
])
// Expect: textDelta("trailing"), stopReason(.endTurn), usage, messageStop.
let textIndex = events.firstIndex { if case .textDelta("trailing") = $0 { return true } else { return false } }
XCTAssertNotNil(textIndex,
"trailing text_delta after message_delta must still be emitted")
let stopIndex = events.lastIndex { if case .messageStop = $0 { return true } else { return false } }
XCTAssertNotNil(stopIndex)
XCTAssertGreaterThan(stopIndex!, textIndex!,
"messageStop must come AFTER the trailing text")
}
// MARK: - Test S11: stop_reason mapping
func testStopReasonMapping() {
XCTAssertEqual(SSEDecoder.mapStopReason("end_turn"), .endTurn)
XCTAssertEqual(SSEDecoder.mapStopReason("tool_use"), .toolUse)
XCTAssertEqual(SSEDecoder.mapStopReason("max_tokens"), .maxTokens)
XCTAssertEqual(SSEDecoder.mapStopReason("refusal"), .refusal)
XCTAssertEqual(SSEDecoder.mapStopReason("garbage"), .endTurn,
"unknown stop_reason maps to .endTurn (logs warning)")
}
}