Files
Jarvis/packages/AgentCore/Tests/AnthropicProviderTests/AnthropicImageBlockEncodingTests.swift
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

125 lines
5.3 KiB
Swift

import XCTest
import Foundation
@testable import AnthropicProvider
import AgentCore
/// Plan 07-05 / Task 2 — Anthropic image_block JSON encoding (T3 path).
///
/// Behaviors verified:
/// 1. The multimodal request body contains the exact Anthropic Vision API
/// `image_block` shape: `{type:image, source:{type:base64, media_type, data}}`.
/// 2. `media_type` is sourced from `ImageBlock.mediaType` (not hard-coded).
/// 3. The image content block sits AFTER the text content block (deterministic
/// order; Anthropic accepts either, we test the chosen one).
/// 4. With `images: []`, the multimodal encoder produces a body byte-for-byte
/// identical to the single-modal encoder (regression-protection for
/// schema drift).
final class AnthropicImageBlockEncodingTests: XCTestCase {
// Tiny fixture — the bytes don't need to be a valid JPEG for base64
// round-trip testing. JPEG SOI marker + a few payload bytes.
private let fixtureBytes = Data([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46])
private var fixtureBase64: String { fixtureBytes.base64EncodedString() }
// MARK: - Test 1: image_block shape
func testMultimodalBodyContainsImageBlockShape() throws {
let imageBlock = ImageBlock(mediaType: "image/jpeg", data: fixtureBytes)
let messages: [LLMMessage] = [
LLMMessage(role: .user, content: [.text("describe this")])
]
let body = try RequestBody.encodeMultimodal(
messages: messages,
images: [imageBlock],
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 1024,
cacheHints: nil
)
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
let topMessages = json["messages"] as! [[String: Any]]
XCTAssertEqual(topMessages.count, 1)
let userMsg = topMessages[0]
let content = userMsg["content"] as! [[String: Any]]
// Find the image block.
let imageBlocks = content.filter { ($0["type"] as? String) == "image" }
XCTAssertEqual(imageBlocks.count, 1, "exactly one image block")
let imgBlock = imageBlocks[0]
let source = imgBlock["source"] as! [String: Any]
XCTAssertEqual(source["type"] as? String, "base64")
XCTAssertEqual(source["media_type"] as? String, "image/jpeg")
XCTAssertEqual(source["data"] as? String, fixtureBase64)
}
// MARK: - Test 2: media_type is sourced from ImageBlock.mediaType
func testMediaTypeIsSourcedFromImageBlock() throws {
let imageBlock = ImageBlock(mediaType: "image/png", data: fixtureBytes)
let body = try RequestBody.encodeMultimodal(
messages: [LLMMessage(role: .user, content: [.text("desc")])],
images: [imageBlock],
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 1024,
cacheHints: nil
)
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
let topMessages = json["messages"] as! [[String: Any]]
let content = topMessages[0]["content"] as! [[String: Any]]
let imgBlock = content.first { ($0["type"] as? String) == "image" }!
let source = imgBlock["source"] as! [String: Any]
XCTAssertEqual(source["media_type"] as? String, "image/png",
"media_type must come from ImageBlock.mediaType, not be hard-coded")
}
// MARK: - Test 3: deterministic order — text first, then image
func testTextBlockPrecedesImageBlock() throws {
let imageBlock = ImageBlock(mediaType: "image/jpeg", data: fixtureBytes)
let body = try RequestBody.encodeMultimodal(
messages: [LLMMessage(role: .user, content: [.text("question")])],
images: [imageBlock],
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 1024,
cacheHints: nil
)
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
let topMessages = json["messages"] as! [[String: Any]]
let content = topMessages[0]["content"] as! [[String: Any]]
XCTAssertEqual(content.count, 2, "text + image")
XCTAssertEqual(content[0]["type"] as? String, "text", "text block first")
XCTAssertEqual(content[1]["type"] as? String, "image", "image block second")
}
// MARK: - Test 4: empty images produces same body as single-modal path
func testEmptyImagesProducesSameBodyAsSingleModal() throws {
let messages: [LLMMessage] = [
LLMMessage(role: .user, content: [.text("hello")])
]
let single = try RequestBody.encode(
messages: messages,
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 256,
cacheHints: nil
)
let multi = try RequestBody.encodeMultimodal(
messages: messages,
images: [],
tools: [],
toolChoice: .auto,
model: ModelID.opus47,
maxOutputTokens: 256,
cacheHints: nil
)
XCTAssertEqual(single, multi,
"encodeMultimodal with empty images must be byte-identical to encode()")
}
}