546 lines
40 KiB
Swift
546 lines
40 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) 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] }
|
|
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 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)
|
|
}
|
|
}
|