From 80748f764fd41d7a6a410c217a45165909331a05 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 Oct 2026 19:51:27 -0700 Subject: [PATCH] ios: add Agent attachments and shared transcript fade --- .../Sybil/Sources/Sybil/SybilAPIClient.swift | 76 ++- .../Sybil/SybilAgentAttachmentDownloads.swift | 62 ++ .../Sybil/SybilAgentAttachmentDraft.swift | 219 +++++++ .../Sybil/SybilAgentAttachmentViews.swift | 239 ++++++++ .../Sources/Sybil/SybilAgentAttachments.swift | 269 +++++++++ .../Sources/Sybil/SybilAgentModels.swift | 7 +- .../Sources/Sybil/SybilAgentOutbox.swift | 3 + .../Sybil/Sources/Sybil/SybilAgentRoom.swift | 114 +++- .../Sources/Sybil/SybilAgentTransport.swift | 34 ++ .../Sybil/Sources/Sybil/SybilAgentView.swift | 78 ++- .../Sybil/Sources/Sybil/SybilViewModel.swift | 2 +- .../SybilTests/AgentAttachmentTests.swift | 545 ++++++++++++++++++ ios/README.md | 77 ++- ios/Tests/Fixtures/agent_server.py | 132 ++++- ios/Tests/SybilUITests/AgentUITests.swift | 178 ++++++ .../SybilUITests/TranscriptFadeUITests.swift | 138 +++++ ios/project.yml | 1 + 17 files changed, 2114 insertions(+), 60 deletions(-) create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDownloads.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDraft.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachments.swift create mode 100644 ios/Packages/Sybil/Tests/SybilTests/AgentAttachmentTests.swift create mode 100644 ios/Tests/SybilUITests/TranscriptFadeUITests.swift diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index eb28c7d..38775d0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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 { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDownloads.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDownloads.swift new file mode 100644 index 0000000..cb7dec8 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDownloads.swift @@ -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] = [:] + private var waiters: [(UUID, CheckedContinuation)] = [] + 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) 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() } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDraft.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDraft.swift new file mode 100644 index 0000000..bcc1759 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDraft.swift @@ -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? + @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() + } + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift new file mode 100644 index 0000000..ebdc39b --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift @@ -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) {} +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachments.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachments.swift new file mode 100644 index 0000000..60b8c58 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachments.swift @@ -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(_ 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 = ["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 + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift index 9b50c2e..350c1e4 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift @@ -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") + } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift index b37f226..770a99d 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift @@ -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)) } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift index 8a03d33..3102c88 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift @@ -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 = [] + var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty } + func setAttachmentModal(_ id: UUID, presented: Bool) { + if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) } + } + var composer: String { + get { attachmentDraft.caption } + set { attachmentDraft.caption = newValue } + } var isObscured = false var isModalPresented = false var isSelected = false private(set) var isActive = true + @ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads() @ObservationIgnored private var client: (any AgentAPIClienting)? @ObservationIgnored private var storage: AgentOutboxStorage? @ObservationIgnored private var storageReadable = true @@ -56,6 +67,10 @@ final class SybilAgentRoom { messages[index].createdAt = row.createdAt if !row.confirmed { var info = messages[index].metadata?.objectValue ?? [:] + if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) { + info["agentAttachments"] = .array(attachments.map(\.metadataValue)) + messages[index].content = row.content + } info["status"] = .string(row.status) if let error = row.error { info["error"] = .string(error) } messages[index].metadata = .object(info) @@ -65,12 +80,17 @@ final class SybilAgentRoom { return AgentRoomState.merge([], messages) } - func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) { + func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) { stop() self.client = client self.storage = storage + attachmentSessionID = UUID() + let oldDownloads = attachmentDownloads + Task { await oldDownloads.invalidate() } + attachmentDownloads = AgentAttachmentDownloads() + attachmentDraft.configure(client: client, storage: draftStorage) pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation() - desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = "" + desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false error = nil; storageReadable = true do { outbox = try storage.read().map { row in @@ -81,6 +101,9 @@ final class SybilAgentRoom { } return restored } + if outbox.contains(where: { $0.draftID == attachmentDraft.id }) { + attachmentDraft.clearAfterSubmission() + } } catch { outbox = []; storageReadable = false self.error = "Could not restore pending messages: \(error.localizedDescription)" @@ -91,9 +114,15 @@ final class SybilAgentRoom { func disconnect() { stop() client = nil + attachmentDraft.updateCapability(nil) + attachmentSessionID = UUID() + let oldDownloads = attachmentDownloads + Task { await oldDownloads.invalidate() } + attachmentDownloads = AgentAttachmentDownloads() } func stop() { + attachmentDraft.suspend() generation = UUID() connectionTask?.cancel(); connectionTask = nil maintenanceTask?.cancel(); maintenanceTask = nil @@ -117,7 +146,7 @@ final class SybilAgentRoom { func setActive(_ active: Bool) { guard isActive != active else { return } isActive = active - if active { start() } + if active { attachmentDraft.resume(); start() } else { // Publish successfully merged input before cancelling; replay resumes here. flush() @@ -151,6 +180,8 @@ final class SybilAgentRoom { do { let descriptor = try await client.agentDescriptor() guard self.generation == run else { return } + self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments) + self.attachmentDraft.resume() try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded) try await self.refreshRead(client: client, run: run) try await self.catchUp(client: client, run: run, replay: !self.hasLoaded) @@ -176,7 +207,11 @@ final class SybilAgentRoom { guard self.generation == run, !Task.isCancelled else { return } self.socket = nil self.isLoading = false - self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription + let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable" + if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) } + self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." + : code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]" + : error.localizedDescription self.connection = code == 4401 ? "Offline" : "Reconnecting" if code == 4401 { return } } @@ -285,7 +320,7 @@ final class SybilAgentRoom { schedulePublish() } func markRead(renderedCursor: String) { - guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return } + guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return } do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() } catch { self.error = error.localizedDescription } } @@ -309,17 +344,51 @@ final class SybilAgentRoom { } } + func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data { + guard let client else { throw AgentAttachmentIssue.unavailable } + let session = attachmentSessionID + let data = try await attachmentDownloads.data(for: attachment, client: client) + try Task.checkCancellation() + guard attachmentSessionID == session else { throw CancellationError() } + return data + } + @discardableResult - func send(_ content: String) -> Bool { - guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else { - error = "Enter a message of at most 40,000 characters."; return false - } + func sendComposer() -> Bool { + do { + let attachments = try attachmentDraft.validateReady() + guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false } + return true + } catch { self.error = error.localizedDescription; return false } + } + + @discardableResult + func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool { + do { + _ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments) + if !attachments.isEmpty { + guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable } + try limits.validate(attachments) + } + } catch { self.error = error.localizedDescription; return false } guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false } - let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date()) + let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID) outbox.append(row) + do { if !attachments.isEmpty || draftID != nil { try persist() } } + catch { + outbox.removeAll { $0.id == row.id } + self.error = "Could not save message: \(error.localizedDescription)" + return false // Keep the entire draft when durable handoff fails. + } pendingEntrances.insert(row.id) presenter.sent(now: Date(), activity: pending.activity) flush() + if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { + outbox[outbox.count - 1].status = "failed" + outbox[outbox.count - 1].error = attachmentDraft.error + try? persist() + return false + } dispatch(row.id) return true } @@ -330,6 +399,7 @@ final class SybilAgentRoom { } private func dispatch(_ id: String) { guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return } + if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return } outbox[index].status = "sending"; outbox[index].error = nil do { try persist() } catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return } @@ -338,8 +408,14 @@ final class SybilAgentRoom { guard let self else { return } defer { if self.generation == run { self.sends[id] = nil } } do { - let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID) - try receipt.validate(requestID: row.requestID) + let attachments = row.attachments ?? [] + if !attachments.isEmpty { + guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable } + try limits.validate(attachments) + } + let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments) + let receipt = try await client.agentSubmit(submission) + try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs) guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return } self.outbox[index].seenOnServer = true self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete" @@ -353,15 +429,19 @@ final class SybilAgentRoom { } } private func reconcileOutbox() { - let committed = Set(pending.messages.map(\.id)) - let queued = Set(pending.activity.queuedMessages.map(\.id)) + let committed = pending.messages + let queued = pending.activity.queuedMessages var changed = false for index in outbox.indices { - if committed.contains(outbox[index].id) { + let row = outbox[index] + let matches: (Message) -> Bool = { message in + message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id)) + } + if committed.contains(where: matches) { if !outbox[index].confirmed { changed = true } outbox[index].confirmed = true; outbox[index].seenOnServer = true outbox[index].status = "complete"; outbox[index].error = nil - } else if queued.contains(outbox[index].id), !outbox[index].confirmed { + } else if queued.contains(where: matches), !outbox[index].confirmed { changed = true outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift index 331ca26..5c6c6b0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift @@ -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 + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 8b3d3ea..096a50a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -18,8 +18,14 @@ struct SybilAgentView: View { private let bottomID = "agent-bottom" private var readKey: String { - "\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)" + "\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)" } + private var visibleReadingAnchor: AgentReadingAnchor? { + rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height } + .min { $0.value.minY < $1.value.minY } + .map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) } + } + var body: some View { let rendered = room.snapshot let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit) @@ -32,8 +38,9 @@ struct SybilAgentView: View { VStack(alignment: .leading, spacing: 15) { if allMessages.count > displayed.count { Button("Load earlier messages") { - if let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) { - restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100)) + if var anchor = visibleReadingAnchor { + anchor.expectedCount = min(allMessages.count, displayed.count + 100) + restoreAnchor = anchor } atLatest = false historyLimit += 100 @@ -52,7 +59,7 @@ struct SybilAgentView: View { .frame(maxWidth: .infinity) .padding(.vertical, 6) } - AgentMessageRow(message: message, retry: { room.retry(message.id) }) + AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) }) .background(GeometryReader { geometry in Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))]) }) @@ -73,7 +80,10 @@ struct SybilAgentView: View { } .scrollTargetLayout() .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(.bottom, 16) + // Match normal chat: content clears the header, while the + // viewport extends beneath the shared navigation fade. + .padding(.top, 16 + topInset) .frame(maxWidth: SybilLayout.webContentMaxWidth) .frame(maxWidth: .infinity) } @@ -90,7 +100,8 @@ struct SybilAgentView: View { AgentScrollMetrics(offset: geometry.contentOffset.y, height: geometry.containerSize.height, contentHeight: geometry.contentSize.height, - bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top) + bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top, + visibleBottom: geometry.visibleRect.maxY) } action: { previous, next in scrollMetrics = next if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom } @@ -100,19 +111,20 @@ struct SybilAgentView: View { } } .onPreferenceChange(AgentRowFrames.self) { frames in - let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height } - .min { $0.value.minY < $1.value.minY } - .map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) } - let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil) + let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil) if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 { // Restore the exact pixel offset after insertion or text // reflow. Normalize the saved frames to // the requested offset so the next layout cannot undo it. let requested = scrollMetrics.offset + frame.minY - anchor.offset - let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset)) + let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset) let appliedShift = target - scrollMetrics.offset rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) } - if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) } + if abs(appliedShift) > 1 { + // Geometry reports the raw offset; ScrollPosition uses + // zero at the top inset, including the safe area. + scrollPosition.scrollTo(y: target + scrollMetrics.topInset) + } restoreAnchor = nil } else { rowFrames = frames @@ -152,16 +164,15 @@ struct SybilAgentView: View { statusBar composer } - .padding(.top, topInset) .onAppear { room.isSelected = true } .onDisappear { room.isSelected = false } .task(id: readKey) { // This cursor belongs to the rendered snapshot. New network input cancels // the task, and cannot be acknowledged while still entering the layout. let cursor = rendered.room.cursor - guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active, !room.isLoading else { return } + guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return } try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360)) - guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return } + guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return } room.markRead(renderedCursor: cursor) } } @@ -190,20 +201,23 @@ struct SybilAgentView: View { } private var canSendComposer: Bool { - !room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000 + room.attachmentDraft.canSend } private var composer: some View { - HStack(alignment: .bottom, spacing: 10) { - SybilPromptField( - text: $room.composer, - focused: $composerFocused, - submit: submitComposer - ) - .accessibilityIdentifier("agent-composer") + VStack(alignment: .leading, spacing: 8) { + AgentAttachmentComposer(room: room) + HStack(alignment: .bottom, spacing: 10) { + SybilPromptField( + text: $room.composer, + focused: $composerFocused, + submit: submitComposer + ) + .accessibilityIdentifier("agent-composer") - SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer) - .accessibilityIdentifier("agent-send") + SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer) + .accessibilityIdentifier("agent-send") + } } .padding(.horizontal, 14) .padding(.bottom, 12) @@ -211,8 +225,7 @@ struct SybilAgentView: View { } private func submitComposer() { - guard canSendComposer, room.send(room.composer) else { return } - room.composer = "" + guard canSendComposer, room.sendComposer() else { return } atLatest = true } } @@ -228,6 +241,7 @@ struct AgentAvatar: View { private struct AgentMessageRow: View { var message: Message + var room: SybilAgentRoom var retry: () -> Void private var own: Bool { message.role == .user } private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" } @@ -235,6 +249,9 @@ private struct AgentMessageRow: View { HStack(alignment: .bottom, spacing: 8) { if !own { AgentAvatar().padding(.bottom, 23) } VStack(alignment: own ? .trailing : .leading, spacing: 5) { + ForEach(message.agentAttachments) { attachment in + AgentAttachmentCard(attachment: attachment, room: room) + } MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true) HStack(spacing: 6) { Text(own ? "You" : "Sybil") @@ -289,7 +306,12 @@ private struct AgentScrollMetrics: Equatable { var contentHeight: CGFloat = 0 var bottomInset: CGFloat = 0 var topInset: CGFloat = 0 - var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 } + var visibleBottom: CGFloat = 0 + // visibleRect includes the viewport extended into the safe area, as well as + // the alignment inset used when a short transcript is pinned to the bottom. + var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom } + var atBottom: Bool { height > 0 && distanceFromBottom <= 24 } + var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) } } private struct AgentReadingAnchor { var id: String diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index d74f759..e551859 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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() diff --git a/ios/Packages/Sybil/Tests/SybilTests/AgentAttachmentTests.swift b/ios/Packages/Sybil/Tests/SybilTests/AgentAttachmentTests.swift new file mode 100644 index 0000000..05118d5 --- /dev/null +++ b/ios/Packages/Sybil/Tests/SybilTests/AgentAttachmentTests.swift @@ -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] = [:] + 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("".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) + } +} diff --git a/ios/README.md b/ios/README.md index 48307f1..cd3362d 100644 --- a/ios/README.md +++ b/ios/README.md @@ -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. diff --git a/ios/Tests/Fixtures/agent_server.py b/ios/Tests/Fixtures/agent_server.py index d0b0fee..b74e7d2 100644 --- a/ios/Tests/Fixtures/agent_server.py +++ b/ios/Tests/Fixtures/agent_server.py @@ -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) diff --git a/ios/Tests/SybilUITests/AgentUITests.swift b/ios/Tests/SybilUITests/AgentUITests.swift index 159eb20..bc7b033 100644 --- a/ios/Tests/SybilUITests/AgentUITests.swift +++ b/ios/Tests/SybilUITests/AgentUITests.swift @@ -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. ".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 %@", "")).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.. 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") diff --git a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift new file mode 100644 index 0000000..141c75e --- /dev/null +++ b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift @@ -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) + } +} diff --git a/ios/project.yml b/ios/project.yml index 9a8e19d..4f3b31a 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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: