Files
Sybil-2/ios/Packages/Sybil/Tests/SybilTests/AgentAttachmentTests.swift
T

671 lines
49 KiB
Swift

import CryptoKit
import Foundation
import ImageIO
import Testing
import UIKit
import UniformTypeIdentifiers
@testable import Sybil
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = originalBytes, name: String = "original.bin") -> AgentAttachment {
AgentAttachment(id: id, kind: .file, filename: name, mimeType: "application/octet-stream", sizeBytes: data.count,
sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), workspacePath: "/runtime/provided/\(name)")
}
private func descriptorData(capability: Bool = true, assistantImages: Bool = false) throws -> Data {
var object: [String: Any] = ["id": "root", "title": "Sybil", "type": "persistent-agent", "model": "fixture", "protocol": "pi-room.v1", "websocket": "/v1/agent/ws",
"activity": ["state": "idle", "isThinking": false, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": []]]
if capability { object["capabilities"] = ["attachments": try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)), "future": true] }
if assistantImages {
var capabilities = object["capabilities"] as? [String: Any] ?? [:]
capabilities["assistantImages"] = ["version": 1]
object["capabilities"] = capabilities
}
object["unknown"] = ["additive": true]
return try JSONSerialization.data(withJSONObject: object)
}
private final class AttachmentTestStore: @unchecked Sendable {
private let lock = NSLock()
private var rows: [AgentOutboxRow] = []
var storage: AgentOutboxStorage { .init(read: { self.read() }, write: { value in self.lock.withLock { self.rows = value } }) }
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
}
private actor AttachmentTestClient: AgentAPIClienting {
var uploads: [String] = []
var submissions: [AgentSubmission] = []
var failUploads = 0
var badReceipt = false
var uploadDelay = Duration.milliseconds(20)
var holdDownloads = false
var downloadWaiters: [String: CheckedContinuation<Data, Never>] = [:]
var downloadStarts: [String] = []
var downloadActive = 0
var downloadPeak = 0
var beforeSubmit: (@Sendable () -> Bool)?
var unavailableDescriptor = false
var unavailableSocket = false
func agentDescriptor() throws -> AgentDescriptor {
if unavailableDescriptor { throw AgentAttachmentIssue(message: "Synthetic open proxy requires authentication", code: "attachments_unavailable", statusCode: 503) }
return try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
}
func agentMessages(after: String?, before: String?) -> AgentPage { .init(conversationId: "root", messages: [], cursor: after ?? "0", hasMore: false) }
func agentReadState() -> AgentReadState { .init(cursor: "0") }
func agentMarkRead(cursor: String) -> AgentReadState { .init(cursor: cursor) }
func agentSubmit(content: String, requestID: String) -> AgentReceipt { .init(id: 1, status: "queued", messageId: "inbound:sybil:\(requestID)") }
func agentSubmit(_ submission: AgentSubmission) throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append(submission)
return .init(id: 1, status: "queued", messageId: "inbound:sybil:\(submission.requestId)", attachmentIds: badReceipt ? nil : submission.attachmentIDs)
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
uploads.append(uploadID)
try await Task.sleep(for: uploadDelay)
if failUploads > 0 { failUploads -= 1; throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507, details: ["futureQuotaField": .number(1)]) }
return AgentAttachment(id: "server-\(uploadID)", kind: .file, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
}
func agentDownload(_ attachment: AgentAttachment) async -> Data {
downloadStarts.append(attachment.id); downloadActive += 1; downloadPeak = max(downloadPeak, downloadActive)
defer { downloadActive -= 1 }
if holdDownloads { return await withCheckedContinuation { downloadWaiters[attachment.id] = $0 } }
return originalBytes
}
func pauseDownloads() { holdDownloads = true }
func releaseDownload(_ id: String) { downloadWaiters.removeValue(forKey: id)?.resume(returning: originalBytes) }
func downloadCounts() -> (Int, Int) { (downloadStarts.count, downloadPeak) }
func downloadedIDs() -> [String] { downloadStarts }
func agentSocket(after: String) throws -> any AgentSocket {
if unavailableSocket { return AttachmentUnavailableSocket() }
throw AgentFailure(message: "No socket in unit fixture")
}
func blockDescriptor() { unavailableDescriptor = true }
func blockSocket() { unavailableSocket = true }
func failNextUpload(delay: Duration = .milliseconds(20)) { failUploads = 1; uploadDelay = delay }
func delayUploads(_ delay: Duration) { uploadDelay = delay }
func malformedReceipts(_ value: Bool) { badReceipt = value }
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
func uploadIDs() -> [String] { uploads }
func sent() -> [AgentSubmission] { submissions }
}
private struct AttachmentUnavailableSocket: AgentSocket {
func receive() throws -> Data { throw AgentFailure(message: "Synthetic closed socket") }
func send(_ text: String) {}
func close() {}
func closeCode() -> Int { 4503 }
}
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
private final class AttachmentURLProtocol: URLProtocol, @unchecked Sendable {
private static let lock = NSLock()
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, [String: String], Data))?
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, [String: String], Data)) { lock.withLock { handler = value } }
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let handler = Self.lock.withLock { Self.handler! }
let (status, headers, data) = try handler(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
private func attachmentSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [AttachmentURLProtocol.self]
return URLSession(configuration: config)
}
private func requestBytes(_ request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open(); defer { stream.close() }
var data = Data(), buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(contentsOf: buffer.prefix(count)) }
return data
}
@Suite(.serialized) struct AgentAttachmentTests {
@Test @MainActor func assistantCapabilityDoesNotAdmitInputAndRequiresToken() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/api/v1/agent") // Never upload or submit references.
return (200, [:], try descriptorData(capability: false, assistantImages: true))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
for token in [nil, " ", "synthetic-test"] {
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: token), session: session)
let descriptor = try await client.agentDescriptor()
#expect(descriptor.capabilities?.attachments == nil)
#expect((descriptor.capabilities?.assistantImages?.supported == true) == (token == "synthetic-test"))
let draft = AgentAttachmentDraft()
draft.configure(client: client, storage: nil); draft.updateCapability(descriptor.capabilities?.attachments)
#expect(!draft.supportsAttachments)
#expect(draft.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) } == nil)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "output-only", attachments: [sampleAttachment()])) }
}
for json in [#"{"assistantImages":{"version":2}}"#, #"{"assistantImages":{"version":"future"}}"#] {
#expect(try JSONDecoder().decode(AgentCapabilities.self, from: Data(json.utf8)).assistantImages?.supported != true)
}
}
@Test func assistantDescriptorsRequireCompletedAssistantImageAndSafeFields() throws {
let image = AgentAttachment(id: "output-image", kind: .image, filename: "landscape.png", mimeType: "image/png", sizeBytes: 12, sha256: String(repeating: "a", count: 64))
var row = Message(id: "outbound:1", createdAt: Date(), role: .assistant, content: "", metadata: .object([
"kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([image.metadataValue])]))
#expect(row.agentAttachments == [image])
for role in [MessageRole.system, .tool] {
var wrong = row; wrong.role = role
#expect(wrong.agentAttachments.isEmpty)
}
for (key, value) in [("kind", "tool_call"), ("kind", "other"), ("status", "queued"), ("status", "streaming")] {
var wrong = row, fields = row.metadata!.objectValue!
fields[key] = .string(value); wrong.metadata = .object(fields)
#expect(wrong.agentAttachments.isEmpty)
}
var descriptors: [JSONValue] = [sampleAttachment().metadataValue]
for key in ["workspacePath", "sourcePath", "privatePath", "url", "sourceURL", "downloadUrl", "external", "future"] {
var fields = image.metadataValue.objectValue!; fields[key] = .string("/private/synthetic.png")
descriptors.append(.object(fields))
}
for (key, value) in [("filename", "../private.png"), ("filename", "https://example.invalid/a.png"), ("mimeType", "image/svg+xml"), ("sha256", "bad")] {
var fields = image.metadataValue.objectValue!; fields[key] = .string(value)
descriptors.append(.object(fields))
}
for descriptor in descriptors {
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([descriptor])])
#expect(row.agentAttachments.isEmpty)
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([image.metadataValue, descriptor])])
#expect(row.agentAttachments.isEmpty) // No valid prefix from an invalid output set.
}
let maximum = (0..<8).map { index in
var item = image; item.id = "image-\(index)"; item.sizeBytes = 4 * 1024 * 1024
return item
}
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array(maximum.map(\.metadataValue))])
#expect(row.agentAttachments == maximum) // Exactly eight IDs and 32 MiB.
let tooMany = (0..<9).map { index in
var item = image; item.id = "image-\(index)"
return item
}
var tooLarge = maximum; tooLarge[0].sizeBytes += 1
for invalid in [tooMany, [image, image], tooLarge] {
row.role = .assistant
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array(invalid.map(\.metadataValue))])
#expect(row.agentAttachments.isEmpty)
row.role = .user
#expect(row.agentAttachments == invalid) // These new output limits do not change legacy input parsing.
}
row.role = .user
row.metadata = .object(["agentAttachments": .array([sampleAttachment().metadataValue])])
#expect(row.agentAttachments == [sampleAttachment()])
}
@Test @MainActor func imageOnlyAssistantLiveAndHistoryUseSameMergeAndAuthenticatedLoader() async throws {
let png = try #require(UIGraphicsImageRenderer(size: CGSize(width: 24, height: 16)).image { context in
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 24, height: 16))
}.pngData())
let attachment = AgentAttachment(id: "output/opaque", kind: .image, filename: "landscape.png", mimeType: "image/png", sizeBytes: png.count,
sha256: SHA256.hash(data: png).map { String(format: "%02x", $0) }.joined())
let row = Message(id: "outbound:1", createdAt: Date(timeIntervalSince1970: 1), role: .assistant, content: "", metadata: .object([
"kind": .string("message"), "status": .string("complete"), "entryId": .number(1), "agentAttachments": .array([attachment.metadataValue])]))
let page = AgentPage(conversationId: "fixture-root", messages: [row], cursor: "1", hasMore: false)
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
let bytes = try encoder.encode(page)
var envelope = try JSONSerialization.jsonObject(with: bytes) as! [String: Any]; envelope["type"] = "messages"
let decodedEvent = try AgentEvent.decode(JSONSerialization.data(withJSONObject: envelope))
let event = try #require(decodedEvent)
let room = SybilAgentRoom(); defer { room.stop() }
try room.receive(event); room.flush()
var history = AgentRoomState()
try history.apply(AgentCoding.decoder().decode(AgentPage.self, from: bytes))
// Replay must not duplicate a completed output; no current capability is needed.
try room.receive(event); room.flush()
#expect(AgentTranscript(messages: room.messages).displayed == AgentTranscript(messages: history.messages).displayed)
#expect(room.messages.count == 1); #expect(room.messages[0].content.isEmpty)
#expect(room.messages[0].agentAttachments == [attachment])
var presentation = AgentPresentation()
presentation.receive(event, before: AgentRoomState(), after: history, replay: false, now: Date())
#expect(presentation.suppressed)
AttachmentURLProtocol.install { request in
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/output%2Fopaque/content")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
return (200, ["Content-Length": String(png.count)], png)
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
let downloads = AgentAttachmentDownloads()
#expect(try await downloads.data(for: attachment, client: client) == png)
AttachmentURLProtocol.install { _ in Issue.record("Verified output should be cached"); return (500, [:], Data()) }
#expect(try await downloads.data(for: attachment, client: client) == png)
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 0, count: png.count)) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
let unauthenticated = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
await #expect(throws: AgentAttachmentIssue.self) { try await unauthenticated.agentDownload(attachment) }
}
@Test func additiveCapabilityAndDistinctMetadata() throws {
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
#expect(old.capabilities == nil)
let current = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
#expect(current.capabilities?.attachments?.supported == true)
var future = try JSONSerialization.jsonObject(with: descriptorData()) as! [String: Any]
future["capabilities"] = ["attachments": ["version": 9, "newWire": true]]
let unsupported = try AgentCoding.decoder().decode(AgentDescriptor.self, from: JSONSerialization.data(withJSONObject: future))
#expect(unsupported.capabilities?.attachments == nil)
var other = attachmentLimits; other.version = 2
#expect(!other.supported)
let attachment = sampleAttachment()
let message = Message(id: "user", createdAt: Date(), role: .user, content: "caption", metadata: .object(["agentAttachments": .array([attachment.metadataValue]), "future": .bool(true)]))
#expect(message.attachments.isEmpty)
#expect(message.agentAttachments == [attachment])
#expect(message.content == "caption")
let legacy = Data(#"{"id":"x","kind":"file","filename":"x.bin","mimeType":"application/octet-stream","sizeBytes":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","unknown":true}"#.utf8)
#expect(try JSONDecoder().decode(AgentAttachment.self, from: legacy).workspacePath == nil)
}
@Test func safeNamesAndExactOriginalBytes() throws {
#expect(try AgentAttachmentPreparation.filename("cafe\u{301}.txt") == "café.txt")
for invalid in ["", ".", "..", "../secret", "x/y", "x\\y", "a\u{0000}b", "x\ny", String(repeating: "é", count: 128)] {
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.filename(invalid) }
}
let file = try AgentAttachmentPreparation.prepare(originalBytes, filename: "archive.zip", mimeType: "application/zip", limits: attachmentLimits)
#expect(file.data == originalBytes)
let text = try AgentAttachmentPreparation.prepare(originalBytes, filename: "invalid-utf8.txt", mimeType: "text/plain", limits: attachmentLimits)
#expect(text.data == originalBytes) // Server classifies as file; client never decodes/truncates upload bytes.
#expect(try AgentAttachmentPreparation.prepare(Data(), filename: "empty.txt", mimeType: "text/plain", limits: attachmentLimits).data.isEmpty)
var small = attachmentLimits; small.maxFileBytes = 2
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.prepare(originalBytes, filename: "x.bin", mimeType: "application/octet-stream", limits: small) }
}
@Test @MainActor func rasterValidationAndFileSelection() throws {
let image = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 8)).image { context in UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 12, height: 8)) }
let png = try #require(image.pngData())
#expect(try AgentAttachmentPreparation.prepare(png, filename: "red.png", mimeType: "image/png", limits: attachmentLimits).data == png)
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/jpeg") }
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/png", pixels: 20) }
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(Data("<svg/>".utf8), mime: "image/svg+xml") }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("sample.pdf"); try originalBytes.write(to: url)
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
#expect(file.data == originalBytes); #expect(file.mimeType == "application/pdf")
}
@Test @MainActor func syntheticHEICPhotoConvertsButFileKeepsOriginal() async throws {
let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1800)).image { context in
UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1800))
}
let encoded = NSMutableData()
let destination = try #require(CGImageDestinationCreateWithData(encoded, UTType.heic.identifier as CFString, 1, nil), "Simulator must provide an ImageIO HEIC encoder for this regression")
CGImageDestinationAddImage(destination, try #require(image.cgImage), nil)
#expect(CGImageDestinationFinalize(destination), "Synthetic HEIC encoding must succeed")
let data = encoded as Data
let prepared = try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(data, filename: "cafe\u{301}.HEIC", mimeType: "image/heic", limits: attachmentLimits)
}
#expect(prepared.filename == "café.jpg"); #expect(prepared.mimeType == "image/jpeg")
#expect(prepared.data.count <= attachmentLimits.imageLimit)
let source = try #require(CGImageSourceCreateWithData(prepared.data as CFData, nil))
#expect(CGImageSourceGetType(source) as String? == UTType.jpeg.identifier)
let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
#expect((properties[kCGImagePropertyPixelWidth] as? Int ?? Int.max) <= 2000)
#expect((properties[kCGImagePropertyPixelHeight] as? Int ?? Int.max) <= 2000)
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appendingPathComponent("original.heic"); try data.write(to: url)
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
#expect(file.data == data); #expect(file.filename == "original.heic"); #expect(file.mimeType == "image/heic")
var tooSmall = attachmentLimits; tooSmall.maxImagePixels = 100
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.photo(data, filename: "large.heic", mimeType: "image/heic", limits: tooSmall) }
}
@Test @MainActor func downloadCoordinatorBoundsAndCancelsQueuedTransfers() async throws {
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
await client.pauseDownloads()
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
let three = Task { try await downloads.data(for: sampleAttachment("three"), client: client) }
let cancelled = Task { try await downloads.data(for: sampleAttachment("cancelled"), client: client) }
try await Task.sleep(for: .milliseconds(30)); cancelled.cancel()
await #expect(throws: CancellationError.self) { try await cancelled.value }
#expect(await client.downloadCounts().0 == 2)
await client.releaseDownload("one"); _ = try await one.value
for _ in 0..<100 { if await client.downloadCounts().0 == 3 { break }; try await Task.sleep(for: .milliseconds(10)) }
#expect(await client.downloadCounts().1 == 2)
#expect(await client.downloadedIDs().contains("cancelled") == false)
await client.releaseDownload("two"); await client.releaseDownload("three")
_ = try await two.value; _ = try await three.value
#expect(try await downloads.data(for: sampleAttachment("one"), client: client) == originalBytes)
#expect(await client.downloadCounts().0 == 3) // Verified cache hit.
}
@Test @MainActor func configurationResetRejectsOldDownloadsAndCache() async throws {
let old = AttachmentTestClient(), next = AttachmentTestClient(), room = SybilAgentRoom()
room.configure(client: old, storage: AttachmentTestStore().storage)
defer { room.stop() }
await old.pauseDownloads()
let stale = Task { try await room.downloadAttachment(sampleAttachment()) }
for _ in 0..<100 { if await old.downloadCounts().0 == 1 { break }; try await Task.sleep(for: .milliseconds(10)) }
room.configure(client: next, storage: AttachmentTestStore().storage)
await old.releaseDownload(sampleAttachment().id)
await #expect(throws: CancellationError.self) { try await stale.value }
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
#expect(await next.downloadCounts().0 == 1)
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
#expect(await next.downloadCounts().0 == 1)
}
@Test @MainActor func invalidatedDownloadQueueNeverStartsOrCaches() async throws {
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
await client.pauseDownloads()
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
let queued = Task { try await downloads.data(for: sampleAttachment("queued"), client: client) }
try await Task.sleep(for: .milliseconds(20))
await downloads.invalidate()
await #expect(throws: CancellationError.self) { try await queued.value }
await client.releaseDownload("one"); await client.releaseDownload("two")
await #expect(throws: CancellationError.self) { try await one.value }
await #expect(throws: CancellationError.self) { try await two.value }
await #expect(throws: CancellationError.self) { try await downloads.data(for: sampleAttachment("one"), client: client) }
#expect(await client.downloadCounts().0 == 2)
}
@Test func orderedReferenceAndReceiptValidation() throws {
let a = sampleAttachment("one"), b = sampleAttachment("two")
let request = try AgentSubmission(content: " ", requestID: "stable", attachments: [a, b])
let fields = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any])
#expect((fields["attachments"] as? [[String: String]]) == [["id": "one"], ["id": "two"]])
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable") }
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable", attachments: [a, a]) }
for ids: [String]? in [nil, [], ["one"], ["two", "one"], ["one", "two", "extra"]] {
let receipt = AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ids)
#expect(throws: AgentAttachmentIssue.self) { try receipt.validate(requestID: "stable", attachmentIDs: ["one", "two"]) }
}
try AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ["one", "two"]).validate(requestID: "stable", attachmentIDs: request.attachmentIDs)
}
@Test func multipartPreservesBinaryAndBoundary() throws {
let id = UUID().uuidString
let file = AgentPreparedAttachment(filename: "café \"original\".bin", mimeType: "application/octet-stream", data: originalBytes)
let multipart = try AgentMultipart(file: file, uploadID: id)
let prefix = Data("\r\n\r\n".utf8)
#expect(multipart.body.range(of: originalBytes) != nil)
let printable = String(decoding: multipart.body, as: UTF8.self)
#expect(printable.components(separatedBy: "Content-Disposition:").count == 3)
#expect(printable.contains("name=\"uploadId\"\r\n\r\n\(id)"))
#expect(printable.contains("filename=\"café \\\"original\\\".bin\""))
#expect(multipart.body.suffix(Data("\r\n--\(multipart.boundary)--\r\n".utf8).count) == Data("\r\n--\(multipart.boundary)--\r\n".utf8))
#expect(multipart.body.range(of: prefix) != nil)
}
@Test func uploadTransportAuthBytesAndReceipt() async throws {
let file = AgentPreparedAttachment(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), id = UUID().uuidString
AttachmentURLProtocol.install { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
#expect(request.url?.query == nil)
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
#expect(request.url!.path == "/api/v1/agent/attachments"); #expect(request.httpMethod == "POST")
let contentType = try #require(request.value(forHTTPHeaderField: "Content-Type"))
let boundary = try #require(contentType.components(separatedBy: "boundary=").last)
let bytes = requestBytes(request)
#expect(bytes.starts(with: Data("--\(boundary)\r\n".utf8)))
#expect(bytes.range(of: originalBytes) != nil); #expect(bytes.range(of: Data(id.utf8)) != nil)
return (201, [:], try JSONEncoder().encode(["attachment": sampleAttachment()]))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
#expect(try await client.agentUpload(file: file, uploadID: id) == sampleAttachment())
}
@Test func missingCapabilityNeverUploadsOrSubmitsReferences() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/api/v1/agent")
return (200, [:], try descriptorData(capability: false))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "stable", attachments: [sampleAttachment()])) }
}
@Test func openPublicAPIDisablesAttachmentCapabilityAndDownloads() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/v1/agent")
return (200, [:], try descriptorData())
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!), session: session)
#expect(try await client.agentDescriptor().capabilities?.attachments == nil)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(sampleAttachment()) }
}
@Test func quotaCapabilitySnapshotAndDotFilenameAreAdditive() throws {
var fields = try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)) as! [String: Any]
fields.merge(["quotaBytes": 1073741824, "usedBytes": 20, "remainingBytes": 1073741804, "maxConcurrentUploads": 2, "maxConcurrentDecodes": 1, "future": true]) { _, next in next }
let limits = try JSONDecoder().decode(AgentAttachmentCapabilities.self, from: JSONSerialization.data(withJSONObject: fields))
#expect(limits.supported); #expect(limits.remainingBytes == 1073741804)
#expect(limits.maxConcurrentUploads == 2); #expect(limits.maxConcurrentDecodes == 1)
var attachment = sampleAttachment(name: ".notes.txt")
attachment.workspacePath = "/data/workspace/uploads/server-id/_notes.txt"
try attachment.validate()
#expect(attachment.filename == ".notes.txt")
#expect(attachment.workspacePath?.hasSuffix("/_notes.txt") == true)
}
@Test func downloadAuthOpaqueIDExactBytesAndLimits() async throws {
let attachment = sampleAttachment("opaque/part?value#x")
AttachmentURLProtocol.install { request in
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/opaque%2Fpart%3Fvalue%23x/content")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
return (200, ["Content-Length": String(originalBytes.count)], originalBytes)
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api/")!, authToken: "synthetic-test"), session: session)
#expect(try await client.agentDownload(attachment) == originalBytes)
AttachmentURLProtocol.install { _ in (200, [:], originalBytes + Data([9])) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 1, count: originalBytes.count)) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
}
@Test func errorCodesAndAdditiveQuotaFieldsSurvive() async throws {
for status in [409, 413, 422, 429, 507, 503] {
AttachmentURLProtocol.install { request in
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
return (status, status == 429 ? ["Retry-After": "2"] : [:], try JSONSerialization.data(withJSONObject: ["error": true, "message": "Actionable storage message", "statusCode": status, "code": status == 507 ? "attachment_quota_exceeded" : status == 422 ? "image_model_unsupported" : "attachments_busy", "futureByteField": 123]))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!, authToken: "synthetic-test"), session: session)
do { _ = try await client.agentDownload(sampleAttachment()); Issue.record("Expected failure") }
catch let issue as AgentAttachmentIssue {
#expect(issue.statusCode == status); #expect(issue.message == "Actionable storage message")
#expect(issue.details["futureByteField"] == .number(123))
if status == 507 { #expect(issue.code == "attachment_quota_exceeded") }
if status == 429 { #expect(issue.retryAfter == "2") }
if status == 422 { #expect(issue.code == "image_model_unsupported") }
}
}
}
@Test func redirectPolicyRejectsUntrustedOrigin() async throws {
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let task = session.dataTask(with: URL(string: "https://fixture.invalid/api")!)
let policy = AgentAttachmentRedirectPolicy()
let response = HTTPURLResponse(url: URL(string: "https://fixture.invalid/api")!, statusCode: 302, httpVersion: nil, headerFields: [:])!
await withCheckedContinuation { continuation in
policy.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: URLRequest(url: URL(string: "https://untrusted.invalid")!)) { request in
#expect(request == nil); continuation.resume()
}
}
task.cancel()
}
@Test @MainActor func orderedSelectionFailureRetryRemoveAndCapability() async throws {
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
await client.failNextUpload()
let first = try #require(draft.add(filename: "one.bin") { .init(filename: "one.bin", mimeType: "application/octet-stream", data: originalBytes) })
let second = try #require(draft.add(filename: "two.bin") { .init(filename: "two.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(!draft.canSend)
#expect(await attachmentEventually { draft.items[0].state == .failed && draft.items[1].state == .ready })
#expect(draft.items[0].issue?.code == "attachment_quota_exceeded")
#expect(!draft.canSend); draft.retry(first)
#expect(await attachmentEventually { draft.items.allSatisfy { $0.state == .ready } })
#expect(draft.ready.map(\.filename) == ["one.bin", "two.bin"])
#expect(await client.uploadIDs() == [first, second, first])
#expect(draft.canSend)
draft.updateCapability(nil); #expect(!draft.canSend)
#expect(draft.ready.count == 2)
draft.remove(first); draft.remove(second); draft.caption = "Text still works"
#expect(draft.canSend)
}
@Test @MainActor func removedInflightUploadCannotReappear() async throws {
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
await client.delayUploads(.milliseconds(250))
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
let id = try #require(draft.add(filename: "remove.bin") { .init(filename: "remove.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(await attachmentEventually { draft.items.first?.state == .uploading })
draft.remove(id)
try await Task.sleep(for: .milliseconds(300))
#expect(draft.items.isEmpty); #expect(!draft.canSend)
}
@Test @MainActor func stagedUploadAndReadyDraftSurviveRelaunch() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let storage = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
let client = AttachmentTestClient(), first = AgentAttachmentDraft()
await client.failNextUpload()
first.configure(client: client, storage: storage); first.updateCapability(attachmentLimits); first.caption = "Keep my caption"
let id = try #require(first.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(await attachmentEventually { first.items.first?.state == .failed })
first.suspend()
let next = AgentAttachmentDraft(); next.configure(client: client, storage: storage); next.updateCapability(attachmentLimits)
#expect(next.caption == "Keep my caption"); #expect(next.items.first?.id == id)
next.retry(id); #expect(await attachmentEventually { next.items.first?.state == .ready })
#expect(await client.uploadIDs() == [id, id]); #expect(try storage.bytes(id) == originalBytes)
let reopened = AgentAttachmentDraft(); reopened.configure(client: client, storage: storage)
#expect(reopened.ready == next.ready); #expect(!reopened.canSend)
reopened.updateCapability(attachmentLimits); #expect(reopened.canSend)
reopened.clearAfterSubmission(); #expect(try storage.read().items.isEmpty)
#expect(throws: (any Error).self) { try storage.bytes(id) }
}
@Test @MainActor func outboxReferencesPersistBeforePostRetryAndEchoWins() async throws {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
room.configure(client: client, storage: store.storage); defer { room.stop() }
#expect(await attachmentEventually { !room.isLoading })
await client.malformedReceipts(true)
await client.requirePersisted { store.read().first?.attachments == [sampleAttachment()] }
#expect(room.send("", attachments: [sampleAttachment()]))
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let original = try #require(room.outbox.first)
room.retry(original.id)
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let submitted = await client.sent()
#expect(submitted.count == 2); #expect(submitted.allSatisfy { $0.requestId == original.requestID && $0.attachmentIDs == [sampleAttachment().id] })
var echo = original.message
echo.content = "Attached: original.bin."
echo.metadata?.self = .object(["kind": .string("message"), "entryId": .number(1), "status": .string("complete"), "agentAttachments": .array([sampleAttachment().metadataValue])])
try room.receive(.messages(.init(conversationId: "root", messages: [echo], cursor: "1", hasMore: false)))
room.flush()
#expect(room.outbox.first?.confirmed == true); #expect(store.read().isEmpty)
#expect(room.messages.count == 1); #expect(room.messages.first?.agentAttachments == [sampleAttachment()])
#expect(room.messages.first?.createdAt == original.createdAt)
}
@Test @MainActor func failedDraftHandoffCannotPostOrCreateAnotherIdentityAfterRelaunch() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let disk = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
let draftID = UUID().uuidString
let item = AgentDraftItem(filename: "original.bin", state: .ready, attachment: sampleAttachment())
try disk.write(.init(caption: "Keep caption and references", items: [item], id: draftID))
var unavailable = disk
unavailable.write = { snapshot in
if snapshot.items.isEmpty { throw AgentAttachmentIssue.local("Synthetic disk failure") }
try disk.write(snapshot)
}
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
room.configure(client: client, storage: store.storage, draftStorage: unavailable)
#expect(await attachmentEventually { !room.isLoading })
#expect(!room.sendComposer())
#expect(room.composer == "Keep caption and references")
#expect(room.attachmentDraft.ready == [sampleAttachment()])
#expect(store.read().count == 1)
let saved = try #require(store.read().first)
#expect(saved.draftID == draftID)
room.retry(saved.id)
#expect(await client.sent().isEmpty)
room.stop()
let reopened = SybilAgentRoom()
reopened.configure(client: client, storage: store.storage, draftStorage: disk)
defer { reopened.stop() }
#expect(await attachmentEventually { !reopened.isLoading })
#expect(reopened.composer.isEmpty); #expect(reopened.attachmentDraft.items.isEmpty)
#expect(!reopened.sendComposer())
reopened.retry(saved.id)
#expect(await attachmentEventually { reopened.outbox.first?.seenOnServer == true })
#expect(await client.sent().map(\.requestId) == [saved.requestID])
#expect(await client.sent().first?.attachmentIDs == [sampleAttachment().id])
}
@Test @MainActor func openProxyHTTP503AndSocket4503KeepReadyDraftAndOutbox() async throws {
for socketFailure in [false, true] {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
if socketFailure { await client.blockSocket() } else { await client.blockDescriptor() }
let row = AgentOutboxRow(requestID: "unchanged-retry", content: "Retained caption", createdAt: Date(), attachments: [sampleAttachment()])
try store.storage.write([row])
let snapshot = AgentDraftSnapshot(caption: "Retained draft", items: [.init(filename: "original.bin", state: .ready, attachment: sampleAttachment())])
let draft = AgentDraftStorage(read: { snapshot }, write: { _ in }, stage: { _, _ in }, bytes: { _ in originalBytes }, remove: { _ in })
room.configure(client: client, storage: store.storage, draftStorage: draft)
#expect(await attachmentEventually { room.error?.contains(socketFailure ? "4503" : "Synthetic open proxy") == true })
#expect(!room.attachmentDraft.supportsAttachments)
#expect(room.composer == "Retained draft"); #expect(room.attachmentDraft.ready == [sampleAttachment()])
#expect(!room.sendComposer())
#expect(room.outbox.first?.requestID == "unchanged-retry")
#expect(store.read().first?.attachments == [sampleAttachment()])
#expect(await client.sent().isEmpty)
room.stop()
}
}
@Test @MainActor func mismatchedCanonicalAndQueuedEchoesNeverHideLocalReferencesOrRetry() async throws {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
await client.malformedReceipts(true)
room.configure(client: client, storage: store.storage); defer { room.stop() }
#expect(await attachmentEventually { !room.isLoading })
let attachments = [sampleAttachment("first"), sampleAttachment("second")]
#expect(room.send("Original caption", attachments: attachments))
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let row = try #require(room.outbox.first)
for (index, wrong) in [[], Array(attachments.reversed()), [attachments[0]]].enumerated() {
var message = row.message
message.content = "Incorrect server echo"
message.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(Double(index + 1)), "agentAttachments": .array(wrong.map(\.metadataValue))])
try room.receive(.messages(.init(conversationId: "root", messages: [message], cursor: String(index + 1), hasMore: false)))
var activity = room.snapshot.room.activity
activity.queuedMessages = [message]
try room.receive(.activity("root", activity)); room.flush()
#expect(room.outbox.first?.confirmed == false); #expect(room.outbox.first?.seenOnServer == false)
#expect(room.outbox.first?.status == "failed"); #expect(room.outbox.first?.error != nil)
#expect(room.messages.first?.agentAttachments == attachments)
#expect(room.messages.first?.content == "Original caption")
#expect(store.read().first?.requestID == row.requestID)
}
}
@Test func legacyOutboxAndQueuedHistoryKeepDistinctAttachmentsAndHiddenTools() throws {
let row = AgentOutboxRow(requestID: "stable", content: "caption", createdAt: Date(timeIntervalSince1970: 1))
#expect(try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(row)).attachments == nil)
var attached = row; attached.attachments = [sampleAttachment()]
let restored = try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(attached))
#expect(restored.attachments == attached.attachments); #expect(restored.requestID == row.requestID)
var queued = restored.message; queued.metadata = .object(["kind": .string("message"), "status": .string("queued"), "agentAttachments": .array([sampleAttachment().metadataValue])])
var committed = queued; committed.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(2), "agentAttachments": .array([sampleAttachment().metadataValue])])
let tool = Message(id: "hidden", createdAt: Date(), role: .tool, content: "raw tool", metadata: .object(["kind": .string("tool_call"), "entryId": .number(3)]))
var state = AgentRoomState()
try state.apply(.init(conversationId: "root", messages: [queued, tool], cursor: "3", hasMore: false))
try state.apply(.init(conversationId: "root", messages: [committed], cursor: "3", hasMore: false))
try state.apply(.init(conversationId: "root", messages: [queued], cursor: "2", hasMore: false))
#expect(state.messages.count == 2); #expect(state.cursor == "3"); #expect(state.unreadCount == 0)
let transcript = AgentTranscript(messages: state.messages)
#expect(transcript.messages.count == 1); #expect(transcript.messages[0].agentAttachments == [sampleAttachment()])
#expect(transcript.messages[0].attachments.isEmpty)
}
}