ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s
TestFlight / Build and upload (push) Successful in 1m48s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
struct APIConfiguration: Sendable {
|
||||
var baseURL: URL
|
||||
@@ -41,10 +42,11 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
}
|
||||
|
||||
func agentDescriptor() async throws -> AgentDescriptor {
|
||||
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
var value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
||||
}
|
||||
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { value.capabilities?.attachments = nil }
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -72,6 +74,72 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
if !submission.attachmentIDs.isEmpty {
|
||||
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
guard submission.attachmentIDs.count <= limits.countLimit else { throw AgentAttachmentIssue.local("Too many attachments for this Agent.", code: "attachment_limit_exceeded") }
|
||||
}
|
||||
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(submission), responseType: AgentReceipt.self)
|
||||
try receipt.validate(requestID: submission.requestId, attachmentIDs: submission.attachmentIDs)
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
||||
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
let prepared = try AgentAttachmentPreparation.prepare(file.data, filename: file.filename, mimeType: file.mimeType, limits: limits)
|
||||
let multipart = try AgentMultipart(file: prepared, uploadID: uploadID)
|
||||
var request = try makeRequest(path: "/v1/agent/attachments", method: "POST", body: nil, acceptsSSE: false)
|
||||
request.timeoutInterval = 120
|
||||
request.setValue("multipart/form-data; boundary=\(multipart.boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = multipart.body
|
||||
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: 64 * 1024)
|
||||
guard status == 200 || status == 201 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
|
||||
struct UploadResponse: Decodable { var attachment: AgentAttachment }
|
||||
guard let response = try? JSONDecoder().decode(UploadResponse.self, from: data) else {
|
||||
throw AgentAttachmentIssue.local("Invalid attachment upload receipt. Retry to confirm.", code: "invalid_upstream_response")
|
||||
}
|
||||
let attachment = response.attachment
|
||||
try attachment.validate()
|
||||
guard attachment.filename == prepared.filename, attachment.mimeType == prepared.mimeType,
|
||||
attachment.sizeBytes == prepared.data.count, attachment.sha256 == prepared.sha256 else {
|
||||
throw AgentAttachmentIssue.local("The upload receipt does not match the selected file.", code: "invalid_upstream_response")
|
||||
}
|
||||
return attachment
|
||||
}
|
||||
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data {
|
||||
try attachment.validate()
|
||||
guard configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw AgentAttachmentIssue.unavailable }
|
||||
var request = try makeRequest(path: "/v1/agent/attachments", method: "GET", body: nil, acceptsSSE: false)
|
||||
request.url = try configuration.agentAttachmentContentURL(id: attachment.id)
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
|
||||
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: attachment.sizeBytes)
|
||||
guard status == 200 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
|
||||
guard data.count == attachment.sizeBytes,
|
||||
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == attachment.sha256 else {
|
||||
throw AgentAttachmentIssue.local("The downloaded original failed its size or checksum check. Retry the download.", code: "invalid_upstream_response")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func attachmentBytes(_ request: URLRequest, maximum: Int) async throws -> (Data, Int, String?) {
|
||||
let (stream, response) = try await session.bytes(for: request, delegate: AgentAttachmentRedirectPolicy())
|
||||
defer { stream.task.cancel() }
|
||||
guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
let success = (200...299).contains(response.statusCode)
|
||||
let limit = success ? maximum : 64 * 1024
|
||||
guard response.expectedContentLength <= Int64(limit) else {
|
||||
throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response")
|
||||
}
|
||||
var data = Data()
|
||||
for try await byte in stream {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < limit else { throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response") }
|
||||
data.append(byte)
|
||||
}
|
||||
return (data, response.statusCode, response.value(forHTTPHeaderField: "Retry-After"))
|
||||
}
|
||||
|
||||
func agentSocket(after: String) async throws -> any AgentSocket {
|
||||
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
|
||||
task.maximumMessageSize = 8 * 1024 * 1024
|
||||
@@ -355,7 +423,7 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
(data, response) = try await session.data(for: request, delegate: path.hasPrefix("/v1/agent") ? AgentAttachmentRedirectPolicy() : nil)
|
||||
} catch {
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||
SybilLog.debug(
|
||||
@@ -369,6 +437,10 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
throw wrapped
|
||||
}
|
||||
|
||||
if path.hasPrefix("/v1/agent"), let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode),
|
||||
(try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue?["code"]?.stringValue != nil {
|
||||
throw AgentAttachmentIssue.response(data, status: http.statusCode, retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
|
||||
}
|
||||
try validate(response: response, data: data)
|
||||
|
||||
do {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
|
||||
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
|
||||
actor AgentAttachmentDownloads {
|
||||
private var active = 0
|
||||
private var invalidated = false
|
||||
private var transfers: [UUID: Task<Data, any Error>] = [:]
|
||||
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
|
||||
private var cache: [AgentAttachment: Data] = [:]
|
||||
private var order: [AgentAttachment] = []
|
||||
private var byteCount = 0
|
||||
|
||||
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
|
||||
try Task.checkCancellation()
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
if let value = cache[attachment] { return value }
|
||||
try await acquire()
|
||||
defer { release() }
|
||||
try Task.checkCancellation()
|
||||
if let value = cache[attachment] { return value }
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
let id = UUID()
|
||||
let transfer = Task { try await client.agentDownload(attachment) }
|
||||
transfers[id] = transfer
|
||||
defer { transfers[id] = nil }
|
||||
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
|
||||
try Task.checkCancellation()
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
|
||||
let oldest = order.removeFirst()
|
||||
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
|
||||
}
|
||||
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
|
||||
return value
|
||||
}
|
||||
func invalidate() {
|
||||
invalidated = true
|
||||
transfers.values.forEach { $0.cancel() }
|
||||
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
|
||||
waiters = []; cache = [:]; order = []; byteCount = 0
|
||||
}
|
||||
private func acquire() async throws {
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
if active < 2 { active += 1; return }
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
|
||||
else { waiters.append((id, continuation)) }
|
||||
}
|
||||
} onCancel: { Task { await self.cancel(id) } }
|
||||
}
|
||||
private func cancel(_ id: UUID) {
|
||||
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
|
||||
waiters.remove(at: index).1.resume(throwing: CancellationError())
|
||||
}
|
||||
private func release() {
|
||||
if waiters.isEmpty { active -= 1 }
|
||||
else { waiters.removeFirst().1.resume() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct AgentDraftItem: Codable, Equatable, Identifiable, Sendable {
|
||||
enum State: String, Codable, Sendable { case loading, uploading, ready, failed }
|
||||
var id: String = UUID().uuidString // Stable multipart uploadId, including across relaunch.
|
||||
var filename: String
|
||||
var mimeType: String?
|
||||
var sizeBytes: Int?
|
||||
var state: State = .loading
|
||||
var attachment: AgentAttachment?
|
||||
var issue: AgentAttachmentIssue?
|
||||
}
|
||||
struct AgentDraftSnapshot: Codable, Sendable {
|
||||
var caption = ""
|
||||
var items: [AgentDraftItem] = []
|
||||
var id: String? = nil // Optional for drafts saved before durable outbox handoff tracking.
|
||||
}
|
||||
struct AgentDraftStorage: Sendable {
|
||||
var read: @Sendable () throws -> AgentDraftSnapshot
|
||||
var write: @Sendable (AgentDraftSnapshot) throws -> Void
|
||||
var stage: @Sendable (String, Data) throws -> Void
|
||||
var bytes: @Sendable (String) throws -> Data
|
||||
var remove: @Sendable (String) -> Void
|
||||
|
||||
static func file(baseURL: URL, root: URL = .applicationSupportDirectory) -> Self {
|
||||
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let directory = root.appendingPathComponent("AgentAttachmentDrafts/\(key)", isDirectory: true)
|
||||
let manifest = directory.appendingPathComponent("draft.json")
|
||||
let file: @Sendable (String) throws -> URL = { id in
|
||||
guard UUID(uuidString: id) != nil else { throw AgentAttachmentIssue.local("Invalid staged upload identity.") }
|
||||
return directory.appendingPathComponent(id + ".bytes")
|
||||
}
|
||||
let write: @Sendable (Data, URL) throws -> Void = { data, destination in
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
}
|
||||
return Self(read: {
|
||||
guard FileManager.default.fileExists(atPath: manifest.path) else { return AgentDraftSnapshot() }
|
||||
let data = try Data(contentsOf: manifest)
|
||||
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
|
||||
let snapshot = try JSONDecoder().decode(AgentDraftSnapshot.self, from: data)
|
||||
guard snapshot.items.count <= 8, Set(snapshot.items.map(\.id)).count == snapshot.items.count,
|
||||
snapshot.items.allSatisfy({ UUID(uuidString: $0.id) != nil }) else { throw AgentAttachmentIssue.local("The saved attachment draft is invalid.") }
|
||||
return snapshot
|
||||
}, write: { snapshot in
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
|
||||
try write(data, manifest)
|
||||
}, stage: { id, data in
|
||||
guard data.count <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
|
||||
try write(data, file(id))
|
||||
}, bytes: { id in
|
||||
let url = try file(id)
|
||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
|
||||
guard size <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
|
||||
return try Data(contentsOf: url)
|
||||
}, remove: { id in if let url = try? file(id) { try? FileManager.default.removeItem(at: url) } })
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Observable
|
||||
final class AgentAttachmentDraft {
|
||||
private(set) var id = UUID().uuidString
|
||||
var caption = "" { didSet { save() } }
|
||||
private(set) var items: [AgentDraftItem] = []
|
||||
private(set) var capability: AgentAttachmentCapabilities?
|
||||
private(set) var error: String?
|
||||
private(set) var persistenceAvailable = true
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@ObservationIgnored private var storage: AgentDraftStorage?
|
||||
@ObservationIgnored private var prepared: [String: AgentPreparedAttachment] = [:]
|
||||
@ObservationIgnored private var loaders: [String: @Sendable () async throws -> AgentPreparedAttachment] = [:]
|
||||
@ObservationIgnored private var worker: Task<Void, Never>?
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
@ObservationIgnored private var restoring = false
|
||||
@ObservationIgnored private var active = true
|
||||
var supportsAttachments: Bool { capability?.supported == true && persistenceAvailable }
|
||||
var ready: [AgentAttachment] { items.compactMap(\.attachment) }
|
||||
var canSend: Bool {
|
||||
caption.utf16.count <= 40_000 && (!caption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !items.isEmpty)
|
||||
&& (items.isEmpty || (supportsAttachments && items.allSatisfy { $0.state == .ready && $0.attachment != nil }))
|
||||
&& persistenceAvailable
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentDraftStorage?) {
|
||||
suspend()
|
||||
self.client = client; self.storage = storage
|
||||
prepared = [:]; loaders = [:]; capability = nil; error = nil; persistenceAvailable = true
|
||||
restoring = true
|
||||
defer { restoring = false; active = true }
|
||||
do {
|
||||
let saved = try storage?.read() ?? AgentDraftSnapshot()
|
||||
id = saved.id ?? UUID().uuidString
|
||||
caption = saved.caption
|
||||
items = saved.items.map { item in
|
||||
var item = item
|
||||
if item.state != .ready {
|
||||
item.state = .failed
|
||||
item.issue = .local("Upload interrupted. Retry with the same upload ID. If selection did not finish, remove and select the file again.", code: "upload_interrupted")
|
||||
}
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
caption = ""; items = []; persistenceAvailable = false
|
||||
self.error = "Could not restore attachment draft: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
func updateCapability(_ value: AgentAttachmentCapabilities?) {
|
||||
capability = value?.supported == true ? value : nil
|
||||
if capability == nil { suspend(); active = true }
|
||||
}
|
||||
func resume() { active = true; pump() }
|
||||
func suspend() {
|
||||
active = false; generation = UUID(); worker?.cancel(); worker = nil
|
||||
for index in items.indices where items[index].state == .loading || items[index].state == .uploading {
|
||||
items[index].state = .failed
|
||||
items[index].issue = .local("Transfer interrupted. Retry to continue.", code: "upload_interrupted")
|
||||
}
|
||||
save()
|
||||
}
|
||||
@discardableResult
|
||||
func add(filename: String, load: @escaping @Sendable () async throws -> AgentPreparedAttachment) -> String? {
|
||||
guard supportsAttachments, let capability else { error = AgentAttachmentIssue.unavailable.localizedDescription; return nil }
|
||||
guard items.count < capability.countLimit else { error = "You can attach up to \(capability.countLimit) files."; return nil }
|
||||
let item = AgentDraftItem(filename: filename)
|
||||
items.append(item); loaders[item.id] = load; error = nil
|
||||
save(); pump()
|
||||
return item.id
|
||||
}
|
||||
func remove(_ id: String) {
|
||||
// Restart the serial worker; completion from the old generation can never reinsert a removed item.
|
||||
let interrupted = items.filter { $0.state == .loading || $0.state == .uploading }.map(\.id)
|
||||
generation = UUID(); worker?.cancel(); worker = nil
|
||||
items.removeAll { $0.id == id }; loaders[id] = nil; prepared[id] = nil
|
||||
save()
|
||||
if persistenceAvailable { storage?.remove(id) }
|
||||
for other in interrupted where other != id {
|
||||
if let index = items.firstIndex(where: { $0.id == other }) { items[index].state = .loading }
|
||||
}
|
||||
pump()
|
||||
}
|
||||
func retry(_ id: String) {
|
||||
guard supportsAttachments else { error = AgentAttachmentIssue.unavailable.localizedDescription; return }
|
||||
guard let index = items.firstIndex(where: { $0.id == id }), items[index].state == .failed else { return }
|
||||
items[index].state = .loading; items[index].issue = nil; error = nil
|
||||
save(); pump()
|
||||
}
|
||||
func validateReady() throws -> [AgentAttachment] {
|
||||
guard items.isEmpty || supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
guard items.allSatisfy({ $0.state == .ready && $0.attachment != nil }) else { throw AgentAttachmentIssue.local("Wait for every attachment to finish, or retry/remove failed files.") }
|
||||
if let capability { try capability.validate(ready) }
|
||||
return ready
|
||||
}
|
||||
@discardableResult func clearAfterSubmission() -> Bool {
|
||||
guard persistenceAvailable else { return false }
|
||||
let nextID = UUID().uuidString
|
||||
do { try storage?.write(AgentDraftSnapshot(id: nextID)) }
|
||||
catch {
|
||||
persistenceAvailable = false
|
||||
self.error = "Message is saved in the outbox, but its draft could not be cleared. Reconnect before retrying: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
let ids = items.map(\.id)
|
||||
generation = UUID(); worker?.cancel(); worker = nil
|
||||
restoring = true; caption = ""; items = []; id = nextID; restoring = false
|
||||
prepared = [:]; loaders = [:]
|
||||
ids.forEach { storage?.remove($0) }
|
||||
return true
|
||||
}
|
||||
func save() {
|
||||
guard !restoring, persistenceAvailable else { return }
|
||||
do { try storage?.write(AgentDraftSnapshot(caption: caption, items: items, id: id)) }
|
||||
catch { persistenceAvailable = false; self.error = "Could not save attachment draft: \(error.localizedDescription)" }
|
||||
}
|
||||
private func pump() {
|
||||
guard active, supportsAttachments, let client, let limits = capability, worker == nil,
|
||||
let item = items.first(where: { $0.state == .loading }) else { return }
|
||||
let run = generation
|
||||
worker = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.worker = nil; self.pump() } }
|
||||
do {
|
||||
let file: AgentPreparedAttachment
|
||||
if let existing = self.prepared[item.id] { file = existing }
|
||||
else if let mime = item.mimeType, let storage = self.storage {
|
||||
file = try AgentAttachmentPreparation.prepare(storage.bytes(item.id), filename: item.filename, mimeType: mime, limits: limits)
|
||||
} else if let loader = self.loaders[item.id] { file = try await loader() }
|
||||
else { throw AgentAttachmentIssue.local("Selection did not finish before the app closed. Remove this item and select it again.", code: "selection_unavailable") }
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
let otherBytes = self.items.filter { $0.id != item.id }.reduce(0) { $0 + ($1.sizeBytes ?? 0) }
|
||||
guard otherBytes + file.data.count <= limits.totalLimit else { throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded") }
|
||||
self.prepared[item.id] = file
|
||||
try self.storage?.stage(item.id, file.data)
|
||||
self.items[index].filename = file.filename; self.items[index].mimeType = file.mimeType
|
||||
self.items[index].sizeBytes = file.data.count; self.items[index].state = .uploading
|
||||
self.save()
|
||||
guard self.persistenceAvailable else { throw AgentAttachmentIssue.local("Save failed. Reconnect before uploading.") }
|
||||
let attachment = try await client.agentUpload(file: file, uploadID: item.id)
|
||||
try attachment.validate()
|
||||
guard attachment.filename == file.filename, attachment.mimeType == file.mimeType,
|
||||
attachment.sizeBytes == file.data.count, attachment.sha256 == file.sha256 else {
|
||||
throw AgentAttachmentIssue.local("Upload receipt does not match the original file. Retry to confirm.", code: "invalid_upstream_response")
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
self.items[index].attachment = attachment; self.items[index].state = .ready; self.items[index].issue = nil
|
||||
self.save()
|
||||
self.loaders[item.id] = nil; self.prepared[item.id] = nil
|
||||
// Keep staged bytes until the message is durably handed to the outbox.
|
||||
} catch {
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
self.items[index].state = .failed; self.items[index].issue = .wrapping(error); self.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
|
||||
// PhotosPicker does not expose the original PHAsset filename without broader library access.
|
||||
struct AgentPickedPhoto: Transferable, Sendable {
|
||||
var data: Data
|
||||
var filename: String?
|
||||
var mimeType: String?
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(importedContentType: .image) { received in
|
||||
let url = received.file
|
||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
|
||||
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var data = Data()
|
||||
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
data.append(chunk)
|
||||
}
|
||||
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
|
||||
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentComposer: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
@State private var modalID = UUID()
|
||||
@State private var pickerSession: UUID?
|
||||
@State private var showChoices = false
|
||||
@State private var showFiles = false
|
||||
@State private var showPhotos = false
|
||||
@State private var photos: [PhotosPickerItem] = []
|
||||
private var modal: Bool { showChoices || showFiles || showPhotos }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !room.attachmentDraft.items.isEmpty {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ForEach(room.attachmentDraft.items) { item in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.filename).lineLimit(1)
|
||||
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
|
||||
.accessibilityLabel("Remove \(item.filename)")
|
||||
}
|
||||
if let attachment = item.attachment {
|
||||
AgentAttachmentDetails(attachment: attachment)
|
||||
} else {
|
||||
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
|
||||
}
|
||||
if let issue = item.issue {
|
||||
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
|
||||
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
|
||||
.accessibilityIdentifier("agent-upload-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}.frame(maxHeight: 190)
|
||||
}
|
||||
if let error = room.attachmentDraft.error {
|
||||
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
||||
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
if room.attachmentDraft.supportsAttachments {
|
||||
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
|
||||
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
|
||||
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Add attachment", isPresented: $showChoices) {
|
||||
Button("Photo Library") { showPhotos = true }
|
||||
Button("Files") { showFiles = true }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.photosPicker(isPresented: $showPhotos, selection: $photos,
|
||||
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
|
||||
selectionBehavior: .ordered, matching: .images)
|
||||
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
|
||||
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
for url in urls {
|
||||
room.attachmentDraft.add(filename: url.lastPathComponent) {
|
||||
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
if (error as NSError).code != NSUserCancelledError {
|
||||
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: photos) { _, selected in
|
||||
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
|
||||
for item in selected {
|
||||
room.attachmentDraft.add(filename: "Photo") {
|
||||
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
|
||||
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
|
||||
}
|
||||
return try await AgentAttachmentPreparation.offMain {
|
||||
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
|
||||
}
|
||||
}
|
||||
}
|
||||
photos = []
|
||||
}
|
||||
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
|
||||
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
|
||||
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentDetails: View {
|
||||
var attachment: AgentAttachment
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if let path = attachment.workspacePath {
|
||||
Button {
|
||||
UIPasteboard.general.string = path
|
||||
} label: {
|
||||
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
|
||||
}
|
||||
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
|
||||
}
|
||||
}.font(.sybil(.caption2))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentCard: View {
|
||||
var attachment: AgentAttachment
|
||||
var room: SybilAgentRoom
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var image: UIImage?
|
||||
@State private var text: String?
|
||||
@State private var issue: String?
|
||||
@State private var loading = false
|
||||
@State private var wantsDownload = false
|
||||
@State private var revision = 0
|
||||
@State private var exportURL: URL?
|
||||
@State private var showShare = false
|
||||
@State private var modalID = UUID()
|
||||
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
|
||||
AgentAttachmentDetails(attachment: attachment)
|
||||
if attachment.kind == .image {
|
||||
ZStack {
|
||||
SybilTheme.surface
|
||||
if let image { Image(uiImage: image).resizable().scaledToFit() }
|
||||
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
|
||||
}.frame(height: 180).clipped()
|
||||
}
|
||||
if let text {
|
||||
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
|
||||
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
|
||||
}
|
||||
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
|
||||
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
|
||||
HStack {
|
||||
if exportURL != nil {
|
||||
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
|
||||
} else if !loading {
|
||||
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
|
||||
.accessibilityIdentifier("agent-download")
|
||||
}
|
||||
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
|
||||
}.font(.sybil(.caption))
|
||||
}
|
||||
.padding(12).frame(maxWidth: 340, alignment: .leading)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
|
||||
.task(id: loadKey) {
|
||||
// Each view owns its transfer; configuration and foreground changes cancel it.
|
||||
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
|
||||
loading = true; issue = nil
|
||||
let session = room.attachmentSessionID
|
||||
do {
|
||||
let data = try await room.downloadAttachment(attachment)
|
||||
try Task.checkCancellation()
|
||||
guard session == room.attachmentSessionID else { return }
|
||||
if attachment.kind == .image {
|
||||
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
|
||||
try Task.checkCancellation()
|
||||
image = UIImage(cgImage: bitmap)
|
||||
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
|
||||
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
|
||||
}
|
||||
if wantsDownload {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
|
||||
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
exportURL = destination
|
||||
}
|
||||
loading = false
|
||||
} catch {
|
||||
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShare) {
|
||||
if let exportURL { AgentOriginalShare(url: exportURL) }
|
||||
}
|
||||
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
|
||||
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
|
||||
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
|
||||
}
|
||||
private func reset() {
|
||||
image = nil; text = nil
|
||||
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
|
||||
exportURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentOriginalShare: UIViewControllerRepresentable {
|
||||
var url: URL
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
|
||||
var version: Int
|
||||
var maxCount: Int
|
||||
var maxTotalBytes: Int
|
||||
var maxImageBytes: Int
|
||||
var maxFileBytes: Int
|
||||
var imageMimeTypes: [String]
|
||||
var maxImagePixels: Int
|
||||
var maxImageDimension: Int
|
||||
var quotaBytes: Int64? = nil
|
||||
var usedBytes: Int64? = nil
|
||||
var remainingBytes: Int64? = nil
|
||||
var maxConcurrentUploads: Int? = nil
|
||||
var maxConcurrentDecodes: Int? = nil
|
||||
|
||||
var supported: Bool {
|
||||
version == 1 && maxCount > 0 && maxTotalBytes > 0 && maxImageBytes > 0 && maxFileBytes > 0
|
||||
&& maxImagePixels > 0 && maxImageDimension > 0
|
||||
}
|
||||
var countLimit: Int { min(maxCount, 8) }
|
||||
var totalLimit: Int { min(maxTotalBytes, 32 * 1024 * 1024) }
|
||||
var imageLimit: Int { min(maxImageBytes, 6 * 1024 * 1024) }
|
||||
var fileLimit: Int { min(maxFileBytes, 8 * 1024 * 1024) }
|
||||
var pixelLimit: Int { min(maxImagePixels, 50_000_000) }
|
||||
var dimensionLimit: Int { min(maxImageDimension, 16_384) }
|
||||
func validate(_ attachments: [AgentAttachment]) throws {
|
||||
guard supported else { throw AgentAttachmentIssue.unavailable }
|
||||
for attachment in attachments { try attachment.validate() }
|
||||
guard attachments.count <= countLimit, attachments.reduce(0, { $0 + $1.sizeBytes }) <= totalLimit else {
|
||||
throw AgentAttachmentIssue.local("Attachments exceed the advertised message limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
for attachment in attachments {
|
||||
guard attachment.sizeBytes <= (attachment.kind == .image ? imageLimit : fileLimit),
|
||||
attachment.kind != .image || imageMimeTypes.contains(attachment.mimeType) else {
|
||||
throw AgentAttachmentIssue.local("An attachment exceeds the current Agent limits or uses an unavailable image format.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentCapabilities: Decodable, Sendable {
|
||||
var attachments: AgentAttachmentCapabilities?
|
||||
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
|
||||
private enum CodingKeys: String, CodingKey { case attachments }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Unknown versions or malformed future capabilities must not break text chat.
|
||||
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
enum Kind: String, Codable, Sendable { case image, text, file }
|
||||
var id: String
|
||||
var kind: Kind
|
||||
var filename: String
|
||||
var mimeType: String
|
||||
var sizeBytes: Int
|
||||
var sha256: String
|
||||
var workspacePath: String? = nil
|
||||
|
||||
func validate() throws {
|
||||
guard !id.isEmpty, id.utf8.count <= 1024, !id.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
|
||||
try AgentAttachmentPreparation.filename(filename) == filename,
|
||||
sizeBytes >= 0, sizeBytes <= 8 * 1024 * 1024,
|
||||
sha256.count == 64, sha256.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }),
|
||||
try AgentAttachmentPreparation.mime(mimeType) == mimeType else {
|
||||
throw AgentAttachmentIssue.local("The Agent returned an invalid attachment descriptor.", code: "invalid_upstream_response")
|
||||
}
|
||||
guard (kind == .image) == AgentAttachmentPreparation.nativeMIMEs.contains(mimeType),
|
||||
kind != .image || sizeBytes <= 6 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("Invalid image attachment descriptor.", code: "invalid_upstream_response")
|
||||
}
|
||||
if let workspacePath {
|
||||
guard workspacePath.hasPrefix("/"), !workspacePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
|
||||
throw AgentAttachmentIssue.local("The Agent returned an invalid workspace path.", code: "invalid_upstream_response")
|
||||
}
|
||||
}
|
||||
}
|
||||
var metadataValue: JSONValue {
|
||||
// All stored fields are JSON-compatible; this also keeps optional future fields separate from ChatAttachment.
|
||||
(try? JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(self))) ?? .null
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var agentAttachments: [AgentAttachment] {
|
||||
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
|
||||
return entries.compactMap {
|
||||
guard let data = try? JSONEncoder().encode($0),
|
||||
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
|
||||
(try? attachment.validate()) != nil else { return nil }
|
||||
return attachment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentSubmission: Encodable, Sendable {
|
||||
struct Reference: Encodable, Sendable { let id: String }
|
||||
let requestId: String
|
||||
let content: String
|
||||
let attachments: [Reference]?
|
||||
init(content: String, requestID: String, attachments: [AgentAttachment] = []) throws {
|
||||
guard !requestID.isEmpty, requestID.utf16.count <= 256, content.utf16.count <= 40_000,
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty,
|
||||
attachments.count <= 8, Set(attachments.map(\.id)).count == attachments.count else {
|
||||
throw AgentAttachmentIssue.local("Enter a message of at most 40,000 characters or attach files.", code: "invalid_attachment")
|
||||
}
|
||||
for attachment in attachments { try attachment.validate() }
|
||||
guard attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
self.content = content; requestId = requestID
|
||||
self.attachments = attachments.isEmpty ? nil : attachments.map { Reference(id: $0.id) }
|
||||
}
|
||||
var attachmentIDs: [String] { attachments?.map(\.id) ?? [] }
|
||||
}
|
||||
|
||||
struct AgentAttachmentIssue: Error, LocalizedError, Codable, Equatable, Sendable {
|
||||
var message: String
|
||||
var code: String
|
||||
var statusCode: Int?
|
||||
// Preserve additive quota information without inventing field names.
|
||||
var details: [String: JSONValue] = [:]
|
||||
var retryAfter: String? = nil
|
||||
var errorDescription: String? {
|
||||
var result = "\(message) [\(code)]"
|
||||
if code == "attachment_quota_exceeded" {
|
||||
let numbers = ["requiredBytes", "remainingBytes", "quotaBytes"].compactMap { key -> String? in
|
||||
guard let number = details[key]?.numberValue, number.isFinite, number >= 0, number < Double(Int64.max) else { return nil }
|
||||
return "\(key): \(ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .file))"
|
||||
}
|
||||
if !numbers.isEmpty { result += " " + numbers.joined(separator: ", ") }
|
||||
}
|
||||
if let retryAfter { result += " Retry-After: \(retryAfter)." }
|
||||
return result
|
||||
}
|
||||
static func local(_ message: String, code: String = "invalid_attachment") -> Self { Self(message: message, code: code) }
|
||||
static let unavailable = Self.local("Attachments are unavailable on this Agent. Reconnect to check support, or remove the files to send text.", code: "attachments_unavailable")
|
||||
static func wrapping(_ error: any Error) -> Self {
|
||||
error as? Self ?? .local(error.localizedDescription, code: "attachment_transfer_failed")
|
||||
}
|
||||
static func response(_ data: Data, status: Int, retryAfter: String? = nil) -> Self {
|
||||
let fields = (try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue ?? [:]
|
||||
return Self(message: fields["message"]?.stringValue ?? fields["error"]?.stringValue ?? "Attachment request failed (HTTP \(status)).",
|
||||
code: fields["code"]?.stringValue ?? "attachment_request_failed", statusCode: status, details: fields, retryAfter: retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPreparedAttachment: Sendable {
|
||||
var filename: String
|
||||
var mimeType: String
|
||||
var data: Data
|
||||
var sha256: String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
|
||||
}
|
||||
|
||||
enum AgentAttachmentPreparation {
|
||||
static func offMain<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
|
||||
let task = Task.detached(operation: operation)
|
||||
return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() }
|
||||
}
|
||||
static let nativeMIMEs: Set<String> = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
static func filename(_ value: String) throws -> String {
|
||||
let value = value.precomposedStringWithCanonicalMapping
|
||||
guard !value.isEmpty, value.utf8.count <= 255, value != ".", value != "..",
|
||||
!value.contains("/"), !value.contains("\\"),
|
||||
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
|
||||
throw AgentAttachmentIssue.local("The filename must be a safe basename of 1–255 UTF-8 bytes.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
static func mime(_ value: String) throws -> String {
|
||||
let value = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = value.split(separator: "/", omittingEmptySubsequences: false)
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~")
|
||||
guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }), value.utf8.count <= 255 else {
|
||||
throw AgentAttachmentIssue.local("The selected file has an invalid media type.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
static func file(_ url: URL, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
let accessed = url.startAccessingSecurityScopedResource()
|
||||
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
|
||||
let name = try filename(url.lastPathComponent)
|
||||
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType ?? UTType(filenameExtension: url.pathExtension)
|
||||
let media = try mime(type?.preferredMIMEType ?? "application/octet-stream")
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var data = Data()
|
||||
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard data.count + chunk.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else {
|
||||
throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
data.append(chunk)
|
||||
}
|
||||
return try prepare(data, filename: name, mimeType: media, limits: limits)
|
||||
}
|
||||
static func prepare(_ data: Data, filename name: String, mimeType: String, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
guard limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
let name = try filename(name), media = try mime(mimeType)
|
||||
guard data.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else { throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded") }
|
||||
if nativeMIMEs.contains(media) {
|
||||
guard limits.imageMimeTypes.contains(media), data.count <= limits.imageLimit else {
|
||||
throw AgentAttachmentIssue.local("\(name) exceeds the image limit or uses an unavailable image format.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
_ = try raster(data, mime: media, pixels: limits.pixelLimit, dimension: limits.dimensionLimit)
|
||||
}
|
||||
return AgentPreparedAttachment(filename: name, mimeType: media, data: data)
|
||||
}
|
||||
static func raster(_ data: Data, mime: String, pixels: Int = 50_000_000, dimension: Int = 16_384) throws -> CGImage {
|
||||
guard nativeMIMEs.contains(mime), let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let type = CGImageSourceGetType(source), UTType(type as String)?.preferredMIMEType == mime,
|
||||
CGImageSourceGetStatus(source) == .statusComplete,
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
||||
width > 0, height > 0, width <= dimension, height <= dimension, width <= pixels / height,
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 600, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else {
|
||||
throw AgentAttachmentIssue.local("The image is invalid, has a mismatched media type, or exceeds the dimension/pixel limit.", code: "invalid_image")
|
||||
}
|
||||
return image
|
||||
}
|
||||
static func photo(_ data: Data, filename originalName: String?, mimeType: String?, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
let media = mimeType ?? "application/octet-stream"
|
||||
if nativeMIMEs.contains(media) {
|
||||
return try prepare(data, filename: originalName ?? "photo.\(UTType(mimeType: media)?.preferredFilenameExtension ?? "jpg")", mimeType: media, limits: limits)
|
||||
}
|
||||
// Photo Library is an explicit visual-input selection. Convert HEIC/HEIF with bounded decoding.
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let sourceType = CGImageSourceGetType(source),
|
||||
["public.heic", "public.heif"].contains(sourceType as String), limits.imageMimeTypes.contains("image/jpeg"),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
||||
width > 0, height > 0, width <= limits.dimensionLimit, height <= limits.dimensionLimit, width <= limits.pixelLimit / height,
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: min(2000, limits.dimensionLimit), kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary),
|
||||
let jpeg = UIImage(cgImage: image).jpegData(compressionQuality: 0.88) else {
|
||||
throw AgentAttachmentIssue.local("This photo could not be prepared as a supported image. Select its original in Files to upload it as a file.", code: "invalid_image")
|
||||
}
|
||||
let safe = try filename(originalName ?? "photo.heic")
|
||||
let stem = (safe as NSString).deletingPathExtension
|
||||
return try prepare(jpeg, filename: stem + ".jpg", mimeType: "image/jpeg", limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentMultipart: Sendable {
|
||||
let boundary: String
|
||||
let body: Data
|
||||
init(file: AgentPreparedAttachment, uploadID: String) throws {
|
||||
guard UUID(uuidString: uploadID) != nil else { throw AgentAttachmentIssue.local("Invalid upload identity.") }
|
||||
let name = try AgentAttachmentPreparation.filename(file.filename)
|
||||
let media = try AgentAttachmentPreparation.mime(file.mimeType)
|
||||
var boundary: String
|
||||
repeat { boundary = "Sybil-" + UUID().uuidString } while file.data.range(of: Data(boundary.utf8)) != nil
|
||||
self.boundary = boundary
|
||||
let quoted = name.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
var bytes = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(quoted)\"\r\nContent-Type: \(media)\r\n\r\n".utf8)
|
||||
bytes.append(file.data)
|
||||
bytes.append(Data("\r\n--\(boundary)--\r\n".utf8))
|
||||
guard bytes.count <= 8 * 1024 * 1024 + 64 * 1024 else { throw AgentAttachmentIssue.local("The upload exceeds the request size limit.", code: "attachment_limit_exceeded") }
|
||||
body = bytes
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ struct AgentDescriptor: Decodable, Sendable {
|
||||
var `protocol`: String
|
||||
var activity: AgentActivity
|
||||
var websocket: String
|
||||
var capabilities: AgentCapabilities? = nil
|
||||
}
|
||||
struct AgentPage: Codable, Sendable {
|
||||
var conversationId: String
|
||||
@@ -77,12 +78,16 @@ struct AgentReceipt: Codable, Sendable {
|
||||
var id: Int64
|
||||
var status: String
|
||||
var messageId: String
|
||||
func validate(requestID: String) throws {
|
||||
var attachmentIds: [String]? = nil
|
||||
func validate(requestID: String, attachmentIDs: [String] = []) throws {
|
||||
guard id > 0, id <= 9_007_199_254_740_991,
|
||||
["queued", "placed", "done", "unanswered"].contains(status),
|
||||
messageId == "inbound:sybil:\(requestID)" else {
|
||||
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
|
||||
}
|
||||
if !attachmentIDs.isEmpty, attachmentIds != attachmentIDs {
|
||||
throw AgentAttachmentIssue.local("Agent receipt did not confirm the exact ordered attachments. Retry this message to confirm delivery.", code: "invalid_upstream_response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ struct AgentOutboxRow: Codable, Equatable, Sendable {
|
||||
var seenOnServer = false
|
||||
var confirmed = false
|
||||
var error: String?
|
||||
var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes.
|
||||
var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer.
|
||||
var id: String { "inbound:sybil:\(requestID)" }
|
||||
var message: Message {
|
||||
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
|
||||
if let error { info["error"] = .string(error) }
|
||||
if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) }
|
||||
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,23 @@ final class SybilAgentRoom {
|
||||
private(set) var error: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var outbox: [AgentOutboxRow] = []
|
||||
var composer = ""
|
||||
let attachmentDraft = AgentAttachmentDraft()
|
||||
private(set) var attachmentSessionID = UUID()
|
||||
private var attachmentModals: Set<UUID> = []
|
||||
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
|
||||
func setAttachmentModal(_ id: UUID, presented: Bool) {
|
||||
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
|
||||
}
|
||||
var composer: String {
|
||||
get { attachmentDraft.caption }
|
||||
set { attachmentDraft.caption = newValue }
|
||||
}
|
||||
var isObscured = false
|
||||
var isModalPresented = false
|
||||
var isSelected = false
|
||||
private(set) var isActive = true
|
||||
|
||||
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@ObservationIgnored private var storage: AgentOutboxStorage?
|
||||
@ObservationIgnored private var storageReadable = true
|
||||
@@ -56,6 +67,10 @@ final class SybilAgentRoom {
|
||||
messages[index].createdAt = row.createdAt
|
||||
if !row.confirmed {
|
||||
var info = messages[index].metadata?.objectValue ?? [:]
|
||||
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
|
||||
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
|
||||
messages[index].content = row.content
|
||||
}
|
||||
info["status"] = .string(row.status)
|
||||
if let error = row.error { info["error"] = .string(error) }
|
||||
messages[index].metadata = .object(info)
|
||||
@@ -65,12 +80,17 @@ final class SybilAgentRoom {
|
||||
return AgentRoomState.merge([], messages)
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) {
|
||||
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) {
|
||||
stop()
|
||||
self.client = client
|
||||
self.storage = storage
|
||||
attachmentSessionID = UUID()
|
||||
let oldDownloads = attachmentDownloads
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
attachmentDraft.configure(client: client, storage: draftStorage)
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = ""
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
||||
error = nil; storageReadable = true
|
||||
do {
|
||||
outbox = try storage.read().map { row in
|
||||
@@ -81,6 +101,9 @@ final class SybilAgentRoom {
|
||||
}
|
||||
return restored
|
||||
}
|
||||
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
|
||||
attachmentDraft.clearAfterSubmission()
|
||||
}
|
||||
} catch {
|
||||
outbox = []; storageReadable = false
|
||||
self.error = "Could not restore pending messages: \(error.localizedDescription)"
|
||||
@@ -91,9 +114,15 @@ final class SybilAgentRoom {
|
||||
func disconnect() {
|
||||
stop()
|
||||
client = nil
|
||||
attachmentDraft.updateCapability(nil)
|
||||
attachmentSessionID = UUID()
|
||||
let oldDownloads = attachmentDownloads
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
attachmentDraft.suspend()
|
||||
generation = UUID()
|
||||
connectionTask?.cancel(); connectionTask = nil
|
||||
maintenanceTask?.cancel(); maintenanceTask = nil
|
||||
@@ -117,7 +146,7 @@ final class SybilAgentRoom {
|
||||
func setActive(_ active: Bool) {
|
||||
guard isActive != active else { return }
|
||||
isActive = active
|
||||
if active { start() }
|
||||
if active { attachmentDraft.resume(); start() }
|
||||
else {
|
||||
// Publish successfully merged input before cancelling; replay resumes here.
|
||||
flush()
|
||||
@@ -151,6 +180,8 @@ final class SybilAgentRoom {
|
||||
do {
|
||||
let descriptor = try await client.agentDescriptor()
|
||||
guard self.generation == run else { return }
|
||||
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
|
||||
self.attachmentDraft.resume()
|
||||
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
|
||||
try await self.refreshRead(client: client, run: run)
|
||||
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
||||
@@ -176,7 +207,11 @@ final class SybilAgentRoom {
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.socket = nil
|
||||
self.isLoading = false
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription
|
||||
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
|
||||
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
|
||||
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
|
||||
: error.localizedDescription
|
||||
self.connection = code == 4401 ? "Offline" : "Reconnecting"
|
||||
if code == 4401 { return }
|
||||
}
|
||||
@@ -285,7 +320,7 @@ final class SybilAgentRoom {
|
||||
schedulePublish()
|
||||
}
|
||||
func markRead(renderedCursor: String) {
|
||||
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return }
|
||||
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
|
||||
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
@@ -309,17 +344,51 @@ final class SybilAgentRoom {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
|
||||
guard let client else { throw AgentAttachmentIssue.unavailable }
|
||||
let session = attachmentSessionID
|
||||
let data = try await attachmentDownloads.data(for: attachment, client: client)
|
||||
try Task.checkCancellation()
|
||||
guard attachmentSessionID == session else { throw CancellationError() }
|
||||
return data
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String) -> Bool {
|
||||
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else {
|
||||
error = "Enter a message of at most 40,000 characters."; return false
|
||||
}
|
||||
func sendComposer() -> Bool {
|
||||
do {
|
||||
let attachments = try attachmentDraft.validateReady()
|
||||
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
|
||||
return true
|
||||
} catch { self.error = error.localizedDescription; return false }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
|
||||
do {
|
||||
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
|
||||
if !attachments.isEmpty {
|
||||
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
try limits.validate(attachments)
|
||||
}
|
||||
} catch { self.error = error.localizedDescription; return false }
|
||||
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
|
||||
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date())
|
||||
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
|
||||
outbox.append(row)
|
||||
do { if !attachments.isEmpty || draftID != nil { try persist() } }
|
||||
catch {
|
||||
outbox.removeAll { $0.id == row.id }
|
||||
self.error = "Could not save message: \(error.localizedDescription)"
|
||||
return false // Keep the entire draft when durable handoff fails.
|
||||
}
|
||||
pendingEntrances.insert(row.id)
|
||||
presenter.sent(now: Date(), activity: pending.activity)
|
||||
flush()
|
||||
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
|
||||
outbox[outbox.count - 1].status = "failed"
|
||||
outbox[outbox.count - 1].error = attachmentDraft.error
|
||||
try? persist()
|
||||
return false
|
||||
}
|
||||
dispatch(row.id)
|
||||
return true
|
||||
}
|
||||
@@ -330,6 +399,7 @@ final class SybilAgentRoom {
|
||||
}
|
||||
private func dispatch(_ id: String) {
|
||||
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
|
||||
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
|
||||
outbox[index].status = "sending"; outbox[index].error = nil
|
||||
do { try persist() }
|
||||
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
|
||||
@@ -338,8 +408,14 @@ final class SybilAgentRoom {
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.sends[id] = nil } }
|
||||
do {
|
||||
let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID)
|
||||
try receipt.validate(requestID: row.requestID)
|
||||
let attachments = row.attachments ?? []
|
||||
if !attachments.isEmpty {
|
||||
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
try limits.validate(attachments)
|
||||
}
|
||||
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
|
||||
let receipt = try await client.agentSubmit(submission)
|
||||
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
|
||||
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
|
||||
self.outbox[index].seenOnServer = true
|
||||
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
|
||||
@@ -353,15 +429,19 @@ final class SybilAgentRoom {
|
||||
}
|
||||
}
|
||||
private func reconcileOutbox() {
|
||||
let committed = Set(pending.messages.map(\.id))
|
||||
let queued = Set(pending.activity.queuedMessages.map(\.id))
|
||||
let committed = pending.messages
|
||||
let queued = pending.activity.queuedMessages
|
||||
var changed = false
|
||||
for index in outbox.indices {
|
||||
if committed.contains(outbox[index].id) {
|
||||
let row = outbox[index]
|
||||
let matches: (Message) -> Bool = { message in
|
||||
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
|
||||
}
|
||||
if committed.contains(where: matches) {
|
||||
if !outbox[index].confirmed { changed = true }
|
||||
outbox[index].confirmed = true; outbox[index].seenOnServer = true
|
||||
outbox[index].status = "complete"; outbox[index].error = nil
|
||||
} else if queued.contains(outbox[index].id), !outbox[index].confirmed {
|
||||
} else if queued.contains(where: matches), !outbox[index].confirmed {
|
||||
changed = true
|
||||
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ protocol AgentAPIClienting: Sendable {
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
|
||||
func agentSocket(after: String) async throws -> any AgentSocket
|
||||
}
|
||||
|
||||
@@ -54,3 +57,34 @@ extension APIConfiguration {
|
||||
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
// Text-only test/legacy clients fail closed for the new wire path.
|
||||
extension AgentAPIClienting {
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
|
||||
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
|
||||
}
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
|
||||
}
|
||||
|
||||
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
// Reject every redirect; attachment credentials are valid only for the configured API.
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIConfiguration {
|
||||
func agentAttachmentContentURL(id: String) throws -> URL {
|
||||
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
||||
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
|
||||
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
|
||||
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
|
||||
parts.query = nil; parts.fragment = nil
|
||||
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,14 @@ struct SybilAgentView: View {
|
||||
private let bottomID = "agent-bottom"
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let rendered = room.snapshot
|
||||
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
|
||||
@@ -32,8 +38,9 @@ struct SybilAgentView: View {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
if let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) {
|
||||
restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100))
|
||||
if var anchor = visibleReadingAnchor {
|
||||
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
||||
restoreAnchor = anchor
|
||||
}
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
@@ -52,7 +59,7 @@ struct SybilAgentView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
AgentMessageRow(message: message, retry: { room.retry(message.id) })
|
||||
AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) })
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||
})
|
||||
@@ -73,7 +80,10 @@ struct SybilAgentView: View {
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.padding(.bottom, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.top, 16 + topInset)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -90,7 +100,8 @@ struct SybilAgentView: View {
|
||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||
height: geometry.containerSize.height,
|
||||
contentHeight: geometry.contentSize.height,
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top)
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
||||
visibleBottom: geometry.visibleRect.maxY)
|
||||
} action: { previous, next in
|
||||
scrollMetrics = next
|
||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
||||
@@ -100,19 +111,20 @@ struct SybilAgentView: View {
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil)
|
||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Restore the exact pixel offset after insertion or text
|
||||
// reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollMetrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
|
||||
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
|
||||
let appliedShift = target - scrollMetrics.offset
|
||||
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) }
|
||||
if abs(appliedShift) > 1 {
|
||||
// Geometry reports the raw offset; ScrollPosition uses
|
||||
// zero at the top inset, including the safe area.
|
||||
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
|
||||
}
|
||||
restoreAnchor = nil
|
||||
} else {
|
||||
rowFrames = frames
|
||||
@@ -152,16 +164,15 @@ struct SybilAgentView: View {
|
||||
statusBar
|
||||
composer
|
||||
}
|
||||
.padding(.top, topInset)
|
||||
.onAppear { room.isSelected = true }
|
||||
.onDisappear { room.isSelected = false }
|
||||
.task(id: readKey) {
|
||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
||||
// the task, and cannot be acknowledged while still entering the layout.
|
||||
let cursor = rendered.room.cursor
|
||||
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return }
|
||||
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||
room.markRead(renderedCursor: cursor)
|
||||
}
|
||||
}
|
||||
@@ -190,20 +201,23 @@ struct SybilAgentView: View {
|
||||
}
|
||||
|
||||
private var canSendComposer: Bool {
|
||||
!room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000
|
||||
room.attachmentDraft.canSend
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityIdentifier("agent-composer")
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
AgentAttachmentComposer(room: room)
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityIdentifier("agent-composer")
|
||||
|
||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||
.accessibilityIdentifier("agent-send")
|
||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||
.accessibilityIdentifier("agent-send")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
@@ -211,8 +225,7 @@ struct SybilAgentView: View {
|
||||
}
|
||||
|
||||
private func submitComposer() {
|
||||
guard canSendComposer, room.send(room.composer) else { return }
|
||||
room.composer = ""
|
||||
guard canSendComposer, room.sendComposer() else { return }
|
||||
atLatest = true
|
||||
}
|
||||
}
|
||||
@@ -228,6 +241,7 @@ struct AgentAvatar: View {
|
||||
|
||||
private struct AgentMessageRow: View {
|
||||
var message: Message
|
||||
var room: SybilAgentRoom
|
||||
var retry: () -> Void
|
||||
private var own: Bool { message.role == .user }
|
||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||
@@ -235,6 +249,9 @@ private struct AgentMessageRow: View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if !own { AgentAvatar().padding(.bottom, 23) }
|
||||
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
|
||||
ForEach(message.agentAttachments) { attachment in
|
||||
AgentAttachmentCard(attachment: attachment, room: room)
|
||||
}
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
@@ -289,7 +306,12 @@ private struct AgentScrollMetrics: Equatable {
|
||||
var contentHeight: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
var topInset: CGFloat = 0
|
||||
var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 }
|
||||
var visibleBottom: CGFloat = 0
|
||||
// visibleRect includes the viewport extended into the safe area, as well as
|
||||
// the alignment inset used when a short transcript is pinned to the bottom.
|
||||
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
|
||||
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
|
||||
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
|
||||
}
|
||||
private struct AgentReadingAnchor {
|
||||
var id: String
|
||||
|
||||
@@ -680,7 +680,7 @@ final class SybilViewModel {
|
||||
|
||||
await loadInitialData(using: client)
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL))
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
|
||||
agent.setActive(isAppActive)
|
||||
}
|
||||
startActiveRunPolling()
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user