ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s

This commit is contained in:
2026-10-14 19:51:27 -07:00
parent 2e62bfa736
commit 80748f764f
17 changed files with 2114 additions and 60 deletions
@@ -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 {
}
}
@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 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 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,10 +201,12 @@ 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 {
VStack(alignment: .leading, spacing: 8) {
AgentAttachmentComposer(room: room)
HStack(alignment: .bottom, spacing: 10) {
SybilPromptField(
text: $room.composer,
@@ -205,14 +218,14 @@ struct SybilAgentView: View {
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
}
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)
}
}
+74 -3
View File
@@ -30,10 +30,69 @@ even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
in the Agent transcript, including loaded history and live updates. History windows,
date separators and scroll anchors use visible messages; raw merged history and
cursors remain intact. Send failures, room errors and activity indicators remain
visible. Attachments, model selection and tool cards belong to ordinary chats.
visible. Model selection and tool cards continue to belong to ordinary chats.
Agent contracts remain in `../docs/api/rest.md` and
`../docs/api/streaming-chat.md`; this client requires no backend deployment.
`../docs/api/streaming-chat.md`. ROOT attachments consume the coordinated v1
contract (2026-10-14), now documented in the integrated API sources above.
Attachment readiness requires the coordinated backend/runtime and existing public
API bearer authentication. This iOS change does not deploy or configure them.
### ROOT attachments
The Agent composer exposes Files and Photo Library only when the authenticated
descriptor advertises supported `capabilities.attachments.version == 1`. Missing,
malformed, or unsupported capability keeps the client text-only. Ordinary chat
attachments retain their existing inline format and behavior.
Open-proxy HTTP 503 / WebSocket 4503 failures disable attachment selection while
retaining draft descriptors and outbox retry identities; reconnect checks support.
Files preserve their original bytes, including PDF, archives, text and HEIC files.
Photo Library HEIC/HEIF selections are intentionally converted to bounded JPEG
visual input with a matching `.jpg` filename. PNG/JPEG/GIF/WebP are validated as
raster images; model input is static, with no claim of document or animation
understanding. Safe original names supplied by the picker are retained; PhotosPicker
may provide an export filename rather than the original Photo Library asset name.
The production app does not request broad Photo Library access.
Selection order is stable. One preparation/upload runs at a time, with per-item
loading, upload, error, retry and removal. Sending requires every item to be ready;
an empty caption can send ready attachments. Advertised limits are checked against
v1 hard ceilings (8 files, 32 MiB total, 6 MiB/image, 8 MiB/file, 50 million pixels,
16,384 pixels/axis). Quota snapshots are decoded but do not reserve space. Server
messages/codes, quota details and Retry-After survive failures, including 409, 413,
429 and 507. Removal cancels local work and removes the reference; it does not
delete an uploaded server original.
The caption, ordered upload UUIDs and ready descriptors are saved in a small atomic
manifest under Application Support, scoped to the API base URL. Prepared bytes are
staged in app-owned files (at most 8 MiB each and 32 MiB per draft); no inline bytes
or tokens enter the outbox. Relaunch restores ready drafts and offers explicit retry
for interrupted staged uploads with the same upload UUID. A selection interrupted
before staging must be removed and selected again. Uploads suspend when the room
stops; failed work does not automatically retry. Uploaded originals have no client
expiration. After submission, the outbox owns the caption, ordered descriptors and
stable request ID. A persisted draft identity recovers a crash during this handoff
without creating a second send. Storage failure blocks submission rather than
discarding a draft. Attachment receipts must acknowledge exactly the submitted IDs
in order; matching canonical echoes still win ambiguous HTTP failures.
History and queued/live messages decode only `metadata.agentAttachments` for ROOT.
Cards retain filename, MIME, size and the optional server-provided `workspacePath`,
which can be copied. The client never constructs a runtime path or sends one as a
reference; leading-dot display filenames remain unchanged. Captions remain public
text, including the server's fallback for attachment-only messages.
Original downloads use the configured authenticated URLSession and API base
(including `/api`), reject redirects, and verify exact size and SHA-256. At most two
downloads run concurrently, with a 16 MiB in-memory cache discarded on configuration
change. Queued/active cancellation and generation checks prevent stale previews
from crossing configurations. Raster previews use bounded thumbnails; text previews
are plain text, including HTML/SVG. Save/share exports exact original bytes to a
temporary file removed when its card disappears or configuration changes. An app
crash may leave a temporary export for normal OS temporary-directory cleanup.
Image preview frames have fixed height to preserve scroll anchors during loading.
Attachment modals track their own visibility without changing Quick Question state.
### Agent regression and Simulator checks
@@ -47,7 +106,10 @@ just screenshot build/agent-evidence/simulator.png
```
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
regressions along with the existing app tests through Xcode on iPhone 16e.
regressions along with attachment capability/metadata, multipart/auth/bytes/errors,
HEIC conversion versus original-file preservation, ordered receipts, draft/outbox
relaunch, cancellation, two-transfer concurrency and configuration isolation tests
through Xcode on iPhone 16e.
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
```sh
@@ -71,6 +133,15 @@ admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
anchors and multiline keyboard layout. The launch arguments select the fixture
through ordinary API settings; no fixture mode is compiled into the application.
Attachment cases exercise authenticated originals, safe text/raster display,
history reload, capability absence and picker cancellation. The real Photo Library
selection-to-send test is explicitly skipped: this iOS 26.5 XCTest environment has
unreliable Photos authorization, callback isolation and synthetic-asset cleanup.
Selection-to-send remains a manual/device pre-release check, not a validated UI
path. The gate prevents photo mutations during the general UI suite. Synthetic
HEIC conversion and original HEIC file preservation are covered by package tests.
The fixture accepts multipart uploads, stable upload/message retries, structured
errors and attachment receipts; it is not a runtime image-decoder or quota test.
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
Simulator Reduce Motion enabled before release.
+124 -8
View File
@@ -5,6 +5,10 @@ Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
import asyncio
import datetime
import json
import hashlib
import unicodedata
import uuid
from urllib.parse import quote
from aiohttp import web
sockets = set()
@@ -16,6 +20,21 @@ busy = False
attempts = {}
log = []
reply_tasks = set()
attachments = {}
uploads = {}
attachment_capability = True
next_upload_error = None
blocked_proxy = False
transcript_comparison = False
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
def attachment_error(status, code, message, **fields):
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
headers={"Retry-After": "2"} if status == 429 else None)
def stamp():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
@@ -69,7 +88,7 @@ async def reply(content):
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
async def route(request):
global read_cursor, cursor, busy
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
path = request.path
log.append(dict(method=request.method, path=path, query=dict(request.query)))
if path == '/control':
@@ -80,8 +99,21 @@ async def route(request):
task.cancel()
reply_tasks.clear()
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
elif action == 'transcript-comparison':
transcript_comparison = True
elif action == 'capability':
attachment_capability = body.get('enabled', True)
elif action == 'upload-error':
next_upload_error = body
elif action == 'blocked-proxy':
blocked_proxy = body.get('enabled', True)
elif action == 'attachment-history':
selected = [attachments[key]['descriptor'] for key in body['ids']]
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
await broadcast(dict(type='messages', **page([row])))
elif action == 'reply':
await emit(body.get('content', 'Late asynchronous fixture reply'))
elif action == 'long-tool':
@@ -102,10 +134,13 @@ async def route(request):
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
if path == '/api/v1/auth/session':
return web.json_response(dict(authenticated=True, mode='token'))
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
if path == '/api/v1/workspace-items':
return web.json_response(dict(items=[], hasMore=False))
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
if path == '/api/v1/chats':
return web.json_response(dict(chats=[]))
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
return web.json_response(dict(chat=dict(comparison, messages=messages)))
if path == '/api/v1/searches':
return web.json_response(dict(searches=[]))
if path == '/api/v1/models':
@@ -115,8 +150,70 @@ async def route(request):
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
if request.headers.get('Authorization') != 'Bearer fixture-token':
return web.json_response(dict(error='Fixture token missing'), status=401)
if blocked_proxy:
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
if path == '/api/v1/agent':
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
if attachment_capability:
descriptor['capabilities'] = {'attachments': CAPABILITY}
return web.json_response(descriptor)
if path == '/api/v1/agent/attachments' and request.method == 'POST':
if not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if next_upload_error:
failure = next_upload_error; next_upload_error = None
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
reader = await request.multipart()
parts = {}
while part := await reader.next():
if part.name not in ('file', 'uploadId') or part.name in parts:
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
if set(parts) != {'file', 'uploadId'}:
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
try:
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
filename = unicodedata.normalize('NFC', parts['file'][0])
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
except (ValueError, AssertionError, TypeError):
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
_, mime, data = parts['file']
mime = (mime or 'application/octet-stream').lower()
if len(data) > CAPABILITY['maxFileBytes']:
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
digest = hashlib.sha256(data).hexdigest()
identity = (filename, mime, digest)
if upload_id in uploads:
old_identity, attachment_id = uploads[upload_id]
if old_identity != identity:
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
kind = 'file'
if mime in CAPABILITY['imageMimeTypes']:
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
elif mime.startswith('text/'):
try:
if '\0' not in data.decode('utf-8-sig'):
kind = 'text'
except UnicodeDecodeError:
pass
attachment_id = 'fixture-' + str(uuid.uuid4())
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
uploads[upload_id] = (identity, attachment_id)
return web.json_response({'attachment': descriptor}, status=201)
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
stored = attachments.get(attachment_id)
if stored is None:
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
descriptor = stored['descriptor']
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
if path == '/api/v1/agent/read-state':
if request.method == 'PUT':
read_cursor = max(read_cursor, int((await request.json())['cursor']))
@@ -125,13 +222,29 @@ async def route(request):
if request.method == 'POST':
body = await request.json()
key, content = body['requestId'], body['content']
refs = body.get('attachments', [])
ids = [ref.get('id') for ref in refs]
if refs and not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
if any(key not in attachments for key in ids):
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
if not content.strip() and not ids:
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
attempts[key] = attempts.get(key, 0) + 1
if content == 'retry fixture' and attempts[key] == 1:
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
if ids:
receipt['attachmentIds'] = ids
if key not in requests:
requests[key] = receipt
row = message(content, 'user', id=receipt['messageId'])
requests[key] = dict(receipt, _payload=[content, ids])
descriptors = [attachments[key]['descriptor'] for key in ids]
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
busy = True
await broadcast(dict(type='messages', **page([row])))
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
@@ -141,7 +254,7 @@ async def route(request):
if content == 'echo wins fixture':
await asyncio.sleep(0.3)
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
return web.json_response(requests[key], status=202)
return web.json_response({k: v for k, v in requests[key].items() if k != '_payload'}, status=202)
after = int(request.query.get('after', 0))
rows = [m for m in messages if m['metadata']['entryId'] > after]
limit = min(int(request.query.get('limit', 200)), 200)
@@ -151,6 +264,9 @@ async def route(request):
if request.query:
return web.Response(status=400)
socket = web.WebSocketResponse(); await socket.prepare(request)
if blocked_proxy:
await socket.close(code=4503, message=b'attachments_unavailable')
return socket
auth = await socket.receive_json(timeout=5)
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
await socket.close(code=4401); return socket
@@ -172,6 +288,6 @@ async def route(request):
for i in range(3):
message(f'History message {i + 1}: local simulator fixture.')
app = web.Application()
app = web.Application(client_max_size=8 * 1024 * 1024 + 64 * 1024)
app.router.add_route('*', '/{path:.*}', route)
web.run_app(app, host='127.0.0.1', port=8799)
+178
View File
@@ -1,5 +1,6 @@
import XCTest
import UIKit
import Photos
@MainActor
final class AgentUITests: XCTestCase {
@@ -10,7 +11,9 @@ final class AgentUITests: XCTestCase {
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
openAgent()
try clearComposer()
}
override func tearDown() async throws { app.terminate() }
private func openAgent() {
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
@@ -23,6 +26,16 @@ final class AgentUITests: XCTestCase {
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private func clearComposer() throws {
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
composer.tap(); composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
let value = composer.value as? String ?? ""
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
}
private func send(_ text: String) {
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
}
@@ -37,6 +50,171 @@ final class AgentUITests: XCTestCase {
let (data, _) = try await URLSession.shared.data(for: request)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
let boundary = "Fixture-" + UUID().uuidString
let uploadID = UUID().uuidString
var body = 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=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
request.httpMethod = "POST"; request.httpBody = body
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
return try XCTUnwrap(json["attachment"] as? [String: Any])
}
func testAttachmentHistoryDownloadAndReload() async throws {
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
}
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
let path = try XCTUnwrap(file["workspacePath"] as? String)
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
let download = app.buttons["agent-download"].firstMatch
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
capture("agent-attachment-original-and-preview")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
capture("agent-attachment-reloaded")
// The real app's authenticated content request is recorded by the loopback fixture.
let state = try await control(["action": "state"])
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
}
func testAttachmentCapabilityAndPickerCancellation() async throws {
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
composer.tap(); composer.typeText("Preserved attachment draft caption")
app.buttons["agent-attach"].tap()
app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
capture("agent-attachment-pickers-cancelled")
try await control(["action": "capability", "enabled": false])
app.terminate(); app.launch(); openAgent()
XCTAssertFalse(app.buttons["agent-attach"].exists)
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
try clearComposer()
capture("agent-text-only-capability")
}
func testPhotoSelectionToAttachmentOnlySend() async throws {
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
// Only synthetic assets are touched; no unrelated photo bytes are read.
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
// XCTest's synchronous UI waits must not block the authorization request.
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
try await Task.sleep(for: .milliseconds(300))
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
}
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
// Recover only fixtures from an interrupted test, identified by BOTH our unique
// date and original-filename prefix, before creating this run's asset.
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
let previous = PHAsset.fetchAssets(with: .image, options: options)
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
}.map(\.localIdentifier)
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
}
try XCTUnwrap(photo.pngData()).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
nonisolated(unsafe) var assetID: String?
try await PHPhotoLibrary.shared().performChanges { @Sendable in
let creation = PHAssetCreationRequest.forAsset()
// A unique synthetic date lets us find this asset without enumerating other photos.
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
creation.addResource(with: .photo, fileURL: url, options: options)
assetID = creation.placeholderForCreatedAsset?.localIdentifier
}
let ownedID = try XCTUnwrap(assetID)
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
do { try await exercisePhotoSelection() }
catch {
try await removeSyntheticPhotos([ownedID])
throw error
}
try await removeSyntheticPhotos([ownedID])
}
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
try await Self.eraseSyntheticPhotos(identifiers)
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
}
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
try await Task.detached {
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
XCTAssertEqual(remaining, 0)
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
}.value
}
private func exercisePhotoSelection() async throws {
app.activate()
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 8))
cell.tap()
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
await fulfillment(of: [ready], timeout: 15)
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
capture("agent-photo-upload-ready")
app.buttons["agent-send"].tap()
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
let state = try await control(["action": "state"])
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
XCTAssertEqual(requests.count, 1)
let submission = try XCTUnwrap(requests.values.first)
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
capture("agent-photo-attachment-only-canonical")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
XCTAssertFalse(app.buttons["agent-retry"].exists)
capture("agent-photo-attachment-only-reloaded")
}
func testSendQueueToolsAndRetry() async throws {
capture("agent-history")
send("first fixture")
@@ -0,0 +1,138 @@
import XCTest
import UIKit
/// Loopback-only regressions. No picker selection or external backend writes.
@MainActor
final class TranscriptFadeUITests: XCTestCase {
private let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
try await control(["action": "reset", "count": 160])
try await control(["action": "transcript-comparison"])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
}
override func tearDown() async throws { app.terminate() }
private func showSidebar() {
if !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
}
private func openAgent() {
showSidebar()
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists)
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8))
}
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private var transcript: XCUIElement {
// The tablet sidebar is also a scroll view; select the detail by its content.
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
func testSharedHeaderFadeGeometry() async throws {
showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
// Normal chat is lazy and can open at either end on phone/tablet.
let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch
XCTAssertTrue(normal.waitForExistence(timeout: 8))
let normalTop = normal.frame.minY
let first = app.staticTexts["History message 1: local simulator fixture."]
if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) }
else { normal.swipeDown(velocity: .slow) }
capture("normal-chat-content-crossing-shared-fade")
XCTAssertTrue(app.buttons["Attach file"].isHittable)
openAgent()
let scroll = transcript
XCTAssertTrue(scroll.exists)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
capture("agent-content-crossing-shared-fade")
XCTAssertTrue(composer.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
XCTAssertTrue(app.buttons["agent-send"].exists)
// A viewport padded below the header clips bubbles before they reach its fade.
XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1,
"Agent must scroll beneath the same workspace header as normal chat")
}
func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws {
openAgent()
let scroll = transcript
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown(velocity: .slow)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
// Stay below the header's content clearance, where text is readable.
let visibleTop = scroll.frame.minY + 100
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
$0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY
})
let before = anchor.frame.minY
let stateBefore = try await control(["action": "state"])
try await control(["action": "reply", "content": "Fade regression reply while reading"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
await fulfillment(of: [unread], timeout: 8)
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
let stateAfter = try await control(["action": "state"])
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int))
capture("agent-fade-reading-position-and-unread")
for _ in 0..<30 {
if app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
let load = app.buttons["Load earlier messages"]
XCTAssertTrue(load.isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
load.tap()
capture("agent-fade-prepend-reading-position")
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
"Prepending must preserve the readable row's position beneath the header")
app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"]
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5)
composer.tap()
XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard")
// No send: this only changes a local draft, then clears it.
composer.typeText("Fade draft\nsecond line\nthird line")
XCTAssertTrue(latest.isHittable)
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count))
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
}
+1
View File
@@ -23,6 +23,7 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: Synthetic attachment UI tests create and remove their own photo.
TEST_TARGET_NAME: SybilApp
SWIFT_VERSION: 6.0
schemes: