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] = [:] 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("".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) } }