diff --git a/ios/Packages/Sybil/Package.swift b/ios/Packages/Sybil/Package.swift index 50947c7..b6adb2e 100644 --- a/ios/Packages/Sybil/Package.swift +++ b/ios/Packages/Sybil/Package.swift @@ -24,7 +24,8 @@ let package = Package( name: "Sybil", dependencies: [ .product(name: "MarkdownUI", package: "swift-markdown-ui") - ]), + ], + resources: [.process("Resources")]), .testTarget( name: "SybilTests", dependencies: ["Sybil"] diff --git a/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/Contents.json b/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/Contents.json b/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/Contents.json new file mode 100644 index 0000000..da4c9aa --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "character-still.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/character-still.png b/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/character-still.png new file mode 100644 index 0000000..073668a Binary files /dev/null and b/ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/character-still.png differ diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index c066348..3312d8a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -106,6 +106,9 @@ public struct SplitView: View { .onChange(of: viewModel.isAuthenticated) { _, _ in presentPendingQuickQuestionIfPossible() } + .onChange(of: isQuickQuestionPresented) { _, presented in + viewModel.agent.isModalPresented = presented + } .onChange(of: scenePhase) { _, nextPhase in switch nextPhase { case .background: diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 04d89f3..eb28c7d 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -17,7 +17,7 @@ struct AnyEncodable: Encodable { } } -actor SybilAPIClient: SybilAPIClienting { +actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting { private let configuration: APIConfiguration private let session: URLSession @@ -40,6 +40,48 @@ actor SybilAPIClient: SybilAPIClienting { self.session = session } + func agentDescriptor() async throws -> AgentDescriptor { + let 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.") + } + return value + } + + func agentMessages(after: String?, before: String?) async throws -> AgentPage { + guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") } + var query = [URLQueryItem(name: "limit", value: "200")] + if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) } + if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) } + let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self) + try page.validate() + return page + } + + func agentReadState() async throws -> AgentReadState { + try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self) + } + + func agentMarkRead(cursor: String) async throws -> AgentReadState { + try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self) + } + + func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt { + let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self) + try receipt.validate(requestID: requestID) + return receipt + } + + func agentSocket(after: String) async throws -> any AgentSocket { + let task = session.webSocketTask(with: try configuration.agentWebSocketURL()) + task.maximumMessageSize = 8 * 1024 * 1024 + task.resume() + let socket = AgentURLSocket(task: task) + do { try await socket.send(configuration.agentAuthFrame(after: after)) } + catch { await socket.close(); throw error } + return socket + } + func verifySession() async throws -> AuthSession { try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) } @@ -360,7 +402,8 @@ actor SybilAPIClient: SybilAPIClienting { let url = try buildURL(path: path, queryItems: queryItems) var request = URLRequest(url: url) request.httpMethod = method - request.timeoutInterval = 120 + if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData } + request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120 if acceptsSSE { request.setValue("text/event-stream", forHTTPHeaderField: "Accept") diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift new file mode 100644 index 0000000..4d3f559 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift @@ -0,0 +1,241 @@ +import Foundation + +struct AgentFailure: LocalizedError, Sendable { + let message: String + var errorDescription: String? { message } +} + +// Compare decimal strings without losing precision through Double or overflowing Int. +enum AgentCursor { + static func normalize(_ value: String) throws -> String { + guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else { + throw AgentFailure(message: "Invalid Agent cursor.") + } + let trimmed = value.drop(while: { $0 == "0" }) + return trimmed.isEmpty ? "0" : String(trimmed) + } + static func less(_ a: String, _ b: String) -> Bool { + a.count == b.count ? a < b : a.count < b.count + } + static func advance(_ current: String, _ incoming: String) throws -> String { + let value = try normalize(incoming) + return less(current, value) ? value : current + } +} + +struct AgentActivity: Codable, Equatable, Sendable { + struct Tool: Codable, Equatable, Sendable { + var id: String + var name: String + var status: String + var output: String? + } + struct Wait: Codable, Equatable, Sendable { + var due: Double + var reason: String? + } + var state: String = "idle" + var isThinking = false + var queued = 0 + var backgroundWorkers = 0 + var tools: [Tool] = [] + var queuedMessages: [Message] = [] + var wait: Wait? + var error: String? + var isBusy: Bool { isThinking || backgroundWorkers > 0 } +} + +struct AgentDescriptor: Decodable, Sendable { + var id: String + var title: String + var type: String + var model: String + var `protocol`: String + var activity: AgentActivity + var websocket: String +} +struct AgentPage: Codable, Sendable { + var conversationId: String + var messages: [Message] + var cursor: String? + var beforeCursor: String? + var hasMore: Bool + + func validate() throws { + guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else { + throw AgentFailure(message: "Invalid Agent history.") + } + if let cursor { _ = try AgentCursor.normalize(cursor) } + if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) } + } +} +struct AgentReadState: Codable, Sendable { + var cursor: String + var updatedAt: String? +} +struct AgentReceipt: Codable, Sendable { + var id: Int64 + var status: String + var messageId: String + func validate(requestID: 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.") + } + } +} + +enum AgentEvent: Sendable { + case ready(String?) + case messages(AgentPage) + case activity(String, AgentActivity) + case heartbeat + case error(String) + + static func decode(_ data: Data) throws -> AgentEvent? { + struct Envelope: Decodable { + var type: String + var conversationId: String? + var activity: AgentActivity? + var error: String? + } + let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data) + switch envelope.type { + case "ready": return .ready(envelope.conversationId) + case "messages": + let page = try AgentCoding.decoder().decode(AgentPage.self, from: data) + try page.validate() + return .messages(page) + case "activity": + guard let id = envelope.conversationId, let activity = envelope.activity else { + throw AgentFailure(message: "Invalid Agent activity frame.") + } + return .activity(id, activity) + case "pong", "heartbeat": return .heartbeat + case "error": return .error(envelope.error ?? "Agent connection error.") + default: return nil + } + } +} + +enum AgentCoding { + static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let text = try container.decode(String.self) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: text) { return date } + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: text) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date") + } + return date + } + return decoder + } +} + +extension Message { + var agentEntryCursor: String? { + let value = metadata?.objectValue?["entryId"] + if let text = value?.stringValue { return try? AgentCursor.normalize(text) } + if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number { + return String(Int64(number)) + } + return nil + } + var isAgentPublicReply: Bool { + role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message" + } + var agentToolID: String { toolCallMetadata?.toolCallId ?? id } +} + +struct AgentRoomState: Equatable, Sendable { + var conversationID: String? + var messages: [Message] = [] + var cursor = "0" + var readCursor = "0" + var activity = AgentActivity() + var unreadCount: Int { + messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count + } + mutating func checkConversation(_ id: String) throws { + guard conversationID == nil || conversationID == id else { + throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.") + } + conversationID = id + } + mutating func apply(_ page: AgentPage) throws { + try page.validate() + var next = self + try next.checkConversation(page.conversationId) + next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor + next.messages = Self.merge(messages, page.messages) + self = next + } + static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] { + var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + for var message in incoming { + if let old = byID[message.id] { + let prior = old.metadata?.objectValue ?? [:] + let next = message.metadata?.objectValue ?? [:] + if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue } + if old.toolCallMetadata != nil, message.toolCallMetadata != nil { + let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated" + var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b }) + if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] } + if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args } + message.metadata = .object(merged) + message.createdAt = min(old.createdAt, message.createdAt) + if stale { message.content = old.content; message.name = old.name } + } + } + byID[message.id] = message + } + return byID.values.sorted { + if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt } + if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) } + return $0.id < $1.id + } + } +} + +// The transcript latch is independent of the truthful sidebar/worker activity. +struct AgentPresentation { + private(set) var suppressed = false + private(set) var busySince: Date? + private var seenTools: Set = [] + static let quietTools: Set = ["user_message", "notify_parent", "clock_wait", "event_decision"] + + mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) { + switch event { + case let .messages(page): + let existing = Set(before.messages.map(\.id)) + let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true } + .sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") } + if !replay { + for message in fresh { + if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true } + else if message.role == .user { suppressed = false } + else if message.toolCallMetadata?.status == "initiated", + !Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false } + } + } + for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) } + case let .activity(_, activity): + if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false } + seenTools.formUnion(activity.tools.map(\.id)) + default: break + } + reconcile(activity: after.activity, now: now) + } + mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) } + private mutating func reconcile(activity: AgentActivity, now: Date) { + if suppressed || !activity.isBusy { busySince = nil } + else if busySince == nil { busySince = now } + } + func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift new file mode 100644 index 0000000..b37f226 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift @@ -0,0 +1,41 @@ +import CryptoKit +import Foundation + +struct AgentOutboxRow: Codable, Equatable, Sendable { + var requestID: String + var content: String + var createdAt: Date + var status: String = "sending" + var seenOnServer = false + var confirmed = false + var error: String? + 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) } + return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info)) + } +} + +struct AgentOutboxStorage: Sendable { + var read: @Sendable () throws -> [AgentOutboxRow] + var write: @Sendable ([AgentOutboxRow]) throws -> Void + + static func file(baseURL: URL) -> Self { + // Separate installations, but never persist tokens in a filename or payload. + let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined() + let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true) + let url = directory.appendingPathComponent("\(key).json") + return Self(read: { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url)) + }, write: { rows in + let data = try JSONEncoder().encode(rows) + guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else { + throw AgentFailure(message: "Retry pending messages before sending more.") + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + }) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift new file mode 100644 index 0000000..8a03d33 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift @@ -0,0 +1,371 @@ +import Foundation +import Network +import Observation + +@MainActor +@Observable +final class SybilAgentRoom { + struct Snapshot: Equatable { + var room = AgentRoomState() + var isTyping = false + var entranceIDs: Set = [] + var revision = 0 + } + private(set) var snapshot = Snapshot() + private(set) var connection = "Offline" + private(set) var error: String? + private(set) var isLoading = false + private(set) var outbox: [AgentOutboxRow] = [] + var composer = "" + var isObscured = false + var isModalPresented = false + var isSelected = false + private(set) var isActive = true + + @ObservationIgnored private var client: (any AgentAPIClienting)? + @ObservationIgnored private var storage: AgentOutboxStorage? + @ObservationIgnored private var storageReadable = true + @ObservationIgnored private var pending = AgentRoomState() + @ObservationIgnored private var presenter = AgentPresentation() + @ObservationIgnored private var pendingEntrances: Set = [] + @ObservationIgnored private var generation = UUID() + @ObservationIgnored private var connectionTask: Task? + @ObservationIgnored private var maintenanceTask: Task? + @ObservationIgnored private var batchTask: Task? + @ObservationIgnored private var typingTask: Task? + @ObservationIgnored private var readTask: Task? + @ObservationIgnored private var sends: [String: Task] = [:] + @ObservationIgnored private var socket: (any AgentSocket)? + @ObservationIgnored private var monitor: NWPathMonitor? + @ObservationIgnored private var lastPacket = Date() + @ObservationIgnored private var socketStarted = Date() + @ObservationIgnored private var desiredRead = "0" + @ObservationIgnored private var acknowledgedRead = "0" + @ObservationIgnored private var hasLoaded = false + @ObservationIgnored private var syncing = false + @ObservationIgnored private var wasNetworkOffline = false + + var isBusy: Bool { snapshot.room.activity.isBusy } + var unreadCount: Int { snapshot.room.unreadCount } + var messages: [Message] { + let committed = Set(snapshot.room.messages.map(\.id)) + let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) } + var messages = AgentRoomState.merge(snapshot.room.messages, queued) + for row in outbox { + if let index = messages.firstIndex(where: { $0.id == row.id }) { + messages[index].createdAt = row.createdAt + if !row.confirmed { + var info = messages[index].metadata?.objectValue ?? [:] + info["status"] = .string(row.status) + if let error = row.error { info["error"] = .string(error) } + messages[index].metadata = .object(info) + } + } else { messages.append(row.message) } + } + return AgentRoomState.merge([], messages) + } + + func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) { + stop() + self.client = client + self.storage = storage + pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation() + desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = "" + error = nil; storageReadable = true + do { + outbox = try storage.read().map { row in + var restored = row + if !restored.seenOnServer { + restored.status = "failed" + restored.error = "Delivery was interrupted. Retry to confirm." + } + return restored + } + } catch { + outbox = []; storageReadable = false + self.error = "Could not restore pending messages: \(error.localizedDescription)" + } + start() + } + + func disconnect() { + stop() + client = nil + } + + func stop() { + generation = UUID() + connectionTask?.cancel(); connectionTask = nil + maintenanceTask?.cancel(); maintenanceTask = nil + batchTask?.cancel(); batchTask = nil + typingTask?.cancel(); typingTask = nil + readTask?.cancel(); readTask = nil + sends.values.forEach { $0.cancel() }; sends = [:] + for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" { + outbox[index].status = "failed" + outbox[index].error = "Delivery was interrupted. Retry to confirm." + } + if !outbox.isEmpty { try? persist() } + monitor?.cancel(); monitor = nil + let old = socket; socket = nil + Task { await old?.close() } + syncing = false + pendingEntrances = [] + connection = "Offline" + } + + func setActive(_ active: Bool) { + guard isActive != active else { return } + isActive = active + if active { start() } + else { + // Publish successfully merged input before cancelling; replay resumes here. + flush() + stop() + } + } + + func retryConnection() { flush(); stop(); if isActive { start() } } + + private func start() { + guard isActive, let client, connectionTask == nil else { return } + let run = generation + isLoading = !hasLoaded + connection = "Connecting" + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + let satisfied = path.status == .satisfied + Task { @MainActor [weak self] in + guard let self, self.generation == run else { return } + let recovered = self.wasNetworkOffline && satisfied + self.wasNetworkOffline = !satisfied + if recovered { self.retryConnection() } + } + } + monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network")) + self.monitor = monitor + connectionTask = Task { [weak self] in + guard let self else { return } + var delay = 1 + while !Task.isCancelled, self.generation == run { + do { + let descriptor = try await client.agentDescriptor() + guard self.generation == run else { return } + 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) + guard self.generation == run, !Task.isCancelled else { return } + self.hasLoaded = true; self.isLoading = false; self.flush() + let socket = try await client.agentSocket(after: self.pending.cursor) + guard self.generation == run, !Task.isCancelled else { await socket.close(); return } + self.socket = socket + self.socketStarted = Date(); self.lastPacket = Date() + while !Task.isCancelled, self.generation == run { + let data = try await socket.receive() + guard self.generation == run, !Task.isCancelled else { return } + self.lastPacket = Date() + guard let event = try AgentEvent.decode(data) else { continue } + if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 } + try self.receive(event) + } + } catch { + guard self.generation == run, !Task.isCancelled else { return } + let failedSocket = self.socket + let code = await failedSocket?.closeCode() + await failedSocket?.close() + 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 + self.connection = code == 4401 ? "Offline" : "Reconnecting" + if code == 4401 { return } + } + try? await Task.sleep(for: .seconds(delay)) + delay = min(delay * 2, 30) + } + } + maintenanceTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(15)) + guard let self, self.generation == run, !Task.isCancelled else { return } + do { + try await self.refreshRead(client: client, run: run) + guard self.generation == run, !Task.isCancelled else { return } + self.flushRead() + if self.connection != "Connected" { + try await self.catchUp(client: client, run: run, replay: !self.hasLoaded) + } + guard self.generation == run, !Task.isCancelled else { return } + if let socket = self.socket { + if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) { + await socket.close() + } else { try await socket.send("{\"type\":\"ping\"}") } + } + } catch { + if self.generation == run { self.error = error.localizedDescription } + } + } + } + } + + func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws { + let run = run ?? generation + guard generation == run, !Task.isCancelled, !syncing else { return } + syncing = true + defer { if generation == run { syncing = false } } + var cursor = pending.cursor + while !Task.isCancelled, generation == run { + let page = try await client.agentMessages(after: cursor, before: nil) + guard generation == run, !Task.isCancelled else { return } + try page.validate() + let next = try AgentCursor.normalize(page.cursor ?? cursor) + guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else { + throw AgentFailure(message: "Agent history did not advance its cursor.") + } + try receive(.messages(page), replay: replay) + cursor = next + if !page.hasMore { return } + } + } + + func receive(_ event: AgentEvent, replay: Bool = false) throws { + let before = pending + var next = pending + switch event { + case let .messages(page): try next.apply(page) + case let .activity(id, activity): + try next.checkConversation(id) + next.activity = activity + case let .ready(id): if let id { try next.checkConversation(id) } + case let .error(message): throw AgentFailure(message: message) + case .heartbeat: return + } + presenter.receive(event, before: before, after: next, replay: replay, now: Date()) + pending = next + if !replay { + let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id)) + pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id)) + } + reconcileOutbox() + schedulePublish() + } + + private func schedulePublish() { + guard batchTask == nil else { return } + batchTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + self?.flush() + } + } + + func flush() { + batchTask?.cancel(); batchTask = nil + typingTask?.cancel(); typingTask = nil + snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1) + pendingEntrances = [] + if let start = presenter.busySince, !snapshot.isTyping { + let delay = max(0.01, 0.25 - Date().timeIntervalSince(start)) + typingTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + self?.flush() + } + } + } + + private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws { + let read = try await client.agentReadState() + guard generation == run else { return } + try applyRead(read) + } + func applyRead(_ read: AgentReadState) throws { + acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor) + pending.readCursor = acknowledgedRead + schedulePublish() + } + func markRead(renderedCursor: String) { + guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return } + do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() } + catch { self.error = error.localizedDescription } + } + private func flushRead() { + guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return } + let run = generation + readTask = Task { [weak self] in + guard let self else { return } + defer { if self.generation == run { self.readTask = nil } } + do { + while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled { + let captured = self.desiredRead + let read = try await client.agentMarkRead(cursor: captured) + guard self.generation == run, !Task.isCancelled else { return } + guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else { + throw AgentFailure(message: "Agent read state was not acknowledged.") + } + try self.applyRead(read) + } + } catch { if self.generation == run { self.error = error.localizedDescription } } + } + } + + @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 + } + 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()) + outbox.append(row) + pendingEntrances.insert(row.id) + presenter.sent(now: Date(), activity: pending.activity) + flush() + dispatch(row.id) + return true + } + func retry(_ id: String) { dispatch(id) } + private func persist() throws { + guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") } + try storage.write(outbox.filter { !$0.confirmed }) + } + private func dispatch(_ id: String) { + guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { 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 } + let row = outbox[index], run = generation + sends[id] = Task { [weak self] in + 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) + 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" + self.outbox[index].error = nil + try? self.persist() + } catch { + guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return } + self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription + try? self.persist() + } + } + } + private func reconcileOutbox() { + let committed = Set(pending.messages.map(\.id)) + let queued = Set(pending.activity.queuedMessages.map(\.id)) + var changed = false + for index in outbox.indices { + if committed.contains(outbox[index].id) { + 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 { + changed = true + outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil + } + } + if changed { try? persist() } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift new file mode 100644 index 0000000..331ca26 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift @@ -0,0 +1,56 @@ +import Foundation + +protocol AgentSocket: Sendable { + func receive() async throws -> Data + func send(_ text: String) async throws + func close() async + func closeCode() async -> Int +} + +protocol AgentAPIClienting: Sendable { + func agentDescriptor() async throws -> AgentDescriptor + func agentMessages(after: String?, before: String?) async throws -> AgentPage + func agentReadState() async throws -> AgentReadState + func agentMarkRead(cursor: String) async throws -> AgentReadState + func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt + func agentSocket(after: String) async throws -> any AgentSocket +} + +actor AgentURLSocket: AgentSocket { + private let task: URLSessionWebSocketTask + init(task: URLSessionWebSocketTask) { self.task = task } + func receive() async throws -> Data { + switch try await task.receive() { + case let .string(text): return Data(text.utf8) + case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.") + @unknown default: throw AgentFailure(message: "Unsupported Agent frame.") + } + } + func send(_ text: String) async throws { try await task.send(.string(text)) } + func close() { task.cancel(with: .goingAway, reason: nil) } + func closeCode() -> Int { task.closeCode.rawValue } +} + +extension APIConfiguration { + func agentWebSocketURL() throws -> URL { + guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), + ["http", "https"].contains(parts.scheme), parts.host != nil, + parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL } + parts.scheme = parts.scheme == "https" ? "wss" : "ws" + parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty + ? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws" + parts.query = nil + parts.fragment = nil + guard let url = parts.url else { throw APIError.invalidBaseURL } + return url + } + func agentAuthFrame(after: String) throws -> String { + let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines) + let value: JSONValue = .object([ + "type": .string("auth"), + "token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null, + "after": .string(try AgentCursor.normalize(after)) + ]) + return String(decoding: try JSONEncoder().encode(value), as: UTF8.self) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift new file mode 100644 index 0000000..fcf04f9 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -0,0 +1,360 @@ +import SwiftUI + +struct SybilAgentView: View { + @Bindable var room: SybilAgentRoom + var topInset: CGFloat = 0 + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @FocusState private var composerFocused: Bool + @State private var atLatest = true + @State private var isScrolling = false + @State private var historyLimit = 100 + @State private var historyStartID: String? + @State private var scrollPosition = ScrollPosition() + @State private var scrollMetrics = AgentScrollMetrics() + @State private var rowFrames: [String: CGRect] = [:] + @State private var restoreAnchor: AgentReadingAnchor? + @State private var appeared = false + 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)" + } + var body: some View { + let rendered = room.snapshot + let allMessages = room.messages + let startIndex = historyStartID.flatMap { id in allMessages.firstIndex(where: { $0.id == id }) } ?? max(0, allMessages.count - historyLimit) + let displayed = Array(allMessages.dropFirst(startIndex)) + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + 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)) + } + atLatest = false + historyLimit += 100 + historyStartID = allMessages[max(0, startIndex - 100)].id + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) } + ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in + VStack(spacing: 14) { + if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) { + Text(message.createdAt.formatted(date: .abbreviated, time: .omitted)) + .font(.sybil(.caption2)) + .foregroundStyle(SybilTheme.textMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + Group { + if message.isToolCallLog { + AgentToolCard(message: message) + } else { + AgentMessageRow(message: message, retry: { room.retry(message.id) }) + } + } + .background(GeometryReader { geometry in + Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))]) + }) + } + .modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion)) + .id(message.id) + } + if rendered.isTyping { + HStack(spacing: 9) { + AgentAvatar() + ProgressView().controlSize(.small) + Text("Sybil is thinking…").font(.sybil(.caption)) + } + .foregroundStyle(SybilTheme.textMuted) + .accessibilityIdentifier("agent-typing") + } + Color.clear.frame(height: 1).id(bottomID) + } + .scrollTargetLayout() + .padding(.horizontal, 16) + .padding(.vertical, 16) + .frame(maxWidth: SybilLayout.webContentMaxWidth) + .frame(maxWidth: .infinity) + } + .coordinateSpace(name: "agent-scroll") + .scrollPosition($scrollPosition) + .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .scrollDismissesKeyboard(.interactively) + .onScrollPhaseChange { _, phase in + isScrolling = phase.isScrolling + if phase == .interacting || phase == .tracking { restoreAnchor = nil } + } + .onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in + AgentScrollMetrics(offset: geometry.contentOffset.y, + height: geometry.containerSize.height, + contentHeight: geometry.contentSize.height, + bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top) + } action: { previous, next in + scrollMetrics = next + if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom } + if atLatest, !isScrolling, restoreAnchor == nil, + previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .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) + if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 { + // Restore the exact pixel offset after insertion, tool + // expansion 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 appliedShift = target - scrollMetrics.offset + rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) } + if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) } + restoreAnchor = nil + } else { + rowFrames = frames + if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil } + } + } + .overlay(alignment: .bottomTrailing) { + if !atLatest { + Button { + atLatest = true + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) } + } label: { + Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down") + .font(.sybil(.caption, weight: .semibold)) + .padding(12) + .background(SybilTheme.surfaceStrong, in: Capsule()) + .overlay(Capsule().stroke(SybilTheme.border)) + } + .padding(14) + .accessibilityIdentifier("agent-jump-latest") + } + } + .onAppear { + appeared = true + if !room.isLoading { historyStartID = displayed.first?.id } + } + .onChange(of: room.isLoading) { _, loading in + if !loading && historyStartID == nil { historyStartID = displayed.first?.id } + } + .onChange(of: allMessages.count) { _, _ in + if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } + } + .onChange(of: composerFocused) { _, _ in + if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } + } + } + 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 } + try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360)) + guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return } + room.markRead(renderedCursor: cursor) + } + } + + @ViewBuilder private var statusBar: some View { + let activity = room.snapshot.room.activity + if let error = room.error ?? activity.error { + HStack(alignment: .top) { + Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) + Spacer(minLength: 4) + Button("Reconnect") { room.retryConnection() }.font(.sybil(.caption, weight: .semibold)) + }.padding(.horizontal, 16).padding(.vertical, 8) + } else if activity.queued > 0 || activity.backgroundWorkers > 0 || activity.wait != nil { + HStack(spacing: 10) { + if activity.queued > 0 { Label("\(activity.queued) queued", systemImage: "clock") } + if activity.backgroundWorkers > 0 { Label("\(activity.backgroundWorkers) working", systemImage: "gearshape.2") } + if let wait = activity.wait { + Text(wait.reason ?? "Waiting") + Text(Date(timeIntervalSince1970: wait.due / 1000), style: .time) + } + Spacer(minLength: 0) + } + .font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted) + .padding(.horizontal, 16).padding(.vertical, 6) + } + } + + private var composer: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField("Message Sybil…", text: $room.composer, axis: .vertical) + .lineLimit(1...6) + .font(.sybil(.body)) + .focused($composerFocused) + .accessibilityIdentifier("agent-composer") + Button { + let content = room.composer + if room.send(content) { room.composer = ""; atLatest = true } + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 38, height: 38) + .background(SybilTheme.primaryGradient, in: Circle()) + } + .disabled(room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || room.composer.utf16.count > 40_000) + .accessibilityLabel("Send message") + .accessibilityIdentifier("agent-send") + } + .padding(12) + .background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 22)) + .overlay(RoundedRectangle(cornerRadius: 22).stroke(SybilTheme.border)) + .padding(.horizontal, 12).padding(.bottom, 10).padding(.top, 6) + } +} + +struct AgentAvatar: View { + var body: some View { + Image("character-still", bundle: .module).resizable().scaledToFill() + .frame(width: 27, height: 27).clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(SybilTheme.primary.opacity(0.3))) + .accessibilityHidden(true) + } +} + +private struct AgentMessageRow: View { + var message: Message + var retry: () -> Void + private var own: Bool { message.role == .user } + private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" } + var body: some View { + HStack(alignment: .bottom, spacing: 8) { + if !own { AgentAvatar().padding(.bottom, 23) } + VStack(alignment: own ? .trailing : .leading, spacing: 5) { + MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true) + HStack(spacing: 6) { + Text(own ? "You" : "Sybil") + Text(message.createdAt, style: .time) + if own && status == "sending" { Text("Sending…") } + if own && status == "queued" { Label("Queued", systemImage: "clock") } + if own && status == "failed" { + Text("Not confirmed").foregroundStyle(SybilTheme.danger) + Button("Retry", action: retry).accessibilityIdentifier("agent-retry") + } + } + .font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted) + if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue { + Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger) + } + } + } + } +} + +struct AgentToolCard: View { + var message: Message + @State private var expanded = false + var body: some View { + if let metadata = message.toolCallMetadata { + VStack(alignment: .leading, spacing: 0) { + Button { + expanded.toggle() + } label: { + HStack(spacing: 8) { + ToolCallActivityChip(metadata: metadata, fallbackContent: message.content, createdAt: message.createdAt, compactLayout: true) + .allowsHitTesting(false) + Spacer(minLength: 0) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 13, weight: .semibold)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("agent-tool-\(metadata.toolName ?? "tool")") + .accessibilityValue(expanded ? "Expanded" : "Collapsed") + if expanded { + VStack(alignment: .leading, spacing: 12) { + if let args = metadata.args { detail("Input", text: pretty(args)) } + if let error = metadata.error { detail("Error", text: error) } + if let result = metadata.result { detail("Result", text: pretty(result)) } + else if let preview = metadata.resultPreview { detail("Result", text: preview) } + if metadata.args == nil && metadata.result == nil && metadata.resultPreview == nil && metadata.error == nil { + Text(message.content).font(.sybil(.caption)) + } + }.padding(.top, 8).frame(maxWidth: .infinity, alignment: .leading) + } + } + .foregroundStyle(SybilTheme.textMuted) + .padding(8) + .background(SybilTheme.surface.opacity(0.8), in: RoundedRectangle(cornerRadius: 14)) + } + } + private func detail(_ title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-tool-\(title.lowercased())") + Text(text).font(.system(size: 12, design: .monospaced)).textSelection(.enabled) + } + } + private func pretty(_ value: JSONValue) -> String { + if let text = value.stringValue { return text } + let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return (try? encoder.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? "" + } +} + +// Animate both occupied height and content, so incoming rows push older history up. +private struct AgentGrowingLayout: Layout { + var progress: CGFloat + var animatableData: CGFloat { get { progress } set { progress = newValue } } + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero + return CGSize(width: proposal.width ?? size.width, height: size.height * progress) + } + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil)) + } +} +private struct AgentEntrance: ViewModifier { + var animate: Bool + var reduceMotion: Bool + @State private var entered = false + func body(content: Content) -> some View { + let preparing = animate && !entered && !reduceMotion + AgentGrowingLayout(progress: preparing ? 0 : 1) { + content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0) + } + .clipped() + .onAppear { + withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true } + } + } +} + +private struct AgentScrollMetrics: Equatable { + var offset: CGFloat = 0 + var height: CGFloat = 0 + var contentHeight: CGFloat = 0 + var bottomInset: CGFloat = 0 + var topInset: CGFloat = 0 + var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 } +} +private struct AgentReadingAnchor { + var id: String + var offset: CGFloat + var expectedCount: Int? = nil +} +private struct AgentRowFrames: PreferenceKey { + static let defaultValue: [String: CGRect] = [:] + static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 5ecfbce..4c6df7f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -149,12 +149,13 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt return items } -private struct MessageBubble: View { +struct MessageBubble: View { var message: Message var isSending: Bool var streamingAssistantPresentation: StreamingAssistantPresentation? var isForking: Bool var onFork: (() -> Void)? + var agentStyle = false private var toolCallMetadata: ToolCallMetadata? { message.toolCallMetadata @@ -229,14 +230,19 @@ private struct MessageBubble: View { presentation: matchingStreamingAssistantPresentation ) } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Markdown(message.content) - .tint(SybilTheme.primary) - .foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95)) - .markdownTheme(.sybilReadable) + if isUser { + Text(message.content) + .font(.sybil(.body)) + } else { + Markdown(message.content) + .tint(SybilTheme.primary) + .foregroundStyle(SybilTheme.text.opacity(0.95)) + .markdownTheme(.sybilReadable) + } } } - .padding(.horizontal, isUser ? 14 : 2) - .padding(.vertical, isUser ? 13 : 2) + .padding(.horizontal, isUser || agentStyle ? 14 : 2) + .padding(.vertical, isUser || agentStyle ? 13 : 2) .textSelection(.enabled) .background( Group { @@ -247,6 +253,10 @@ private struct MessageBubble: View { RoundedRectangle(cornerRadius: 16) .stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1) ) + } else if agentStyle { + RoundedRectangle(cornerRadius: 16) + .fill(Color(red: 0.12, green: 0.075, blue: 0.21)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1)) } else { RoundedRectangle(cornerRadius: 0) .fill(Color.clear) @@ -601,7 +611,7 @@ private struct ToolCallStackCard: View { } } -private struct ToolCallActivityChip: View { +struct ToolCallActivityChip: View { enum VisualState { case initiated case completed diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index e8a3939..7ca2681 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -359,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable { toolName: object["toolName"]?.stringValue ?? name, status: object["status"]?.stringValue, summary: object["summary"]?.stringValue, - durationMs: object["durationMs"]?.numberValue.map(Int.init), + durationMs: object["durationMs"]?.numberValue.flatMap { value in + value >= 0 && value < Double(Int.max) ? Int(value) : nil + }, error: object["error"]?.stringValue, - resultPreview: object["resultPreview"]?.stringValue + resultPreview: object["resultPreview"]?.stringValue, + args: object["args"], + result: object["result"], + startedAt: object["startedAt"]?.stringValue ) } @@ -378,6 +383,9 @@ public struct ToolCallMetadata: Hashable, Sendable { public var durationMs: Int? public var error: String? public var resultPreview: String? + public var args: JSONValue? = nil + public var result: JSONValue? = nil + public var startedAt: String? = nil } public enum JSONValue: Codable, Hashable, Sendable { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index bfdc211..f6a883c 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -2,6 +2,7 @@ import Observation import SwiftUI enum PhoneRoute: Hashable { + case agent case chat(String) case search(String) case draftChat @@ -14,6 +15,7 @@ enum PhoneRoute: Hashable { return .chat(chatID) case let .search(searchID): return .search(searchID) + case .agent: return .agent case .settings: return .settings } @@ -64,6 +66,7 @@ struct SybilPhoneShellView: View { return .chat(chatID) case let .search(searchID): return .search(searchID) + case .agent: return .agent case .draftChat, .draftSearch, .settings: return nil } @@ -87,6 +90,10 @@ struct SybilPhoneShellView: View { .tint(SybilTheme.primary) .animation(.easeOut(duration: 0.22), value: route) .animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented) + .onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in + viewModel.agent.isObscured = covered + } + .onDisappear { viewModel.agent.isObscured = false } .onChange(of: scenePhase) { _, nextPhase in switch nextPhase { case .background: @@ -525,7 +532,9 @@ private struct SybilPhoneSidebarRoot: View { }, onOpenForkedChat: { chatID in onSelect(.chat(chatID)) - } + }, + onSelectAgent: { onSelect(.agent) }, + isAgentSelected: highlightedSelection == .agent ) } .background(SybilTheme.panelGradient) @@ -629,6 +638,8 @@ private struct SybilPhoneDestinationView: View { return } viewModel.select(.search(searchID)) + case .agent: + viewModel.select(.agent) case .draftChat: viewModel.startNewChat() case .draftSearch: diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 0e11ad0..bf00495 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -113,6 +113,8 @@ struct SybilSidebarItemList: View { var isSelected: (SidebarItem) -> Bool var onSelect: (SidebarItem) -> Void var onOpenForkedChat: ((String) -> Void)? = nil + var onSelectAgent: (() -> Void)? = nil + var isAgentSelected: Bool? = nil @State private var renameTarget: SidebarItem? @State private var renameTitle = "" @FocusState private var isSearchFocused: Bool @@ -130,6 +132,30 @@ struct SybilSidebarItemList: View { var body: some View { VStack(spacing: 0) { + Button { + if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) } + } label: { + HStack(spacing: 10) { + AgentAvatar() + Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold)) + Spacer() + if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") } + if viewModel.agent.unreadCount > 0 { + Text(String(viewModel.agent.unreadCount)) + .font(.sybil(.caption, weight: .semibold)) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(SybilTheme.primary, in: Capsule()) + .accessibilityLabel("\(viewModel.agent.unreadCount) unread replies") + } + } + .foregroundStyle(SybilTheme.text) + .padding(12) + .background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("agent-sidebar") + .padding(.horizontal, 10).padding(.top, 10) HStack(spacing: 10) { Image(systemName: "magnifyingglass") .foregroundStyle(SybilTheme.textMuted) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index f7cbb34..d74f759 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -7,6 +7,7 @@ enum DraftKind { } enum SidebarSelection: Hashable { + case agent case chat(String) case search(String) case settings @@ -17,6 +18,8 @@ enum SidebarSelection: Hashable { return "chat:\(chatID)" case let .search(searchID): return "search:\(searchID)" + case .agent: + return "agent" case .settings: return "settings" } @@ -153,6 +156,7 @@ private actor SearchStreamStatus { @Observable final class SybilViewModel { let settings: SybilSettingsStore + let agent = SybilAgentRoom() var isCheckingSession = true var isAuthenticated = false @@ -339,6 +343,8 @@ final class SybilViewModel { } return "Search" + case .agent: + return "Sybil Agent" case .settings: return "Settings" } @@ -378,6 +384,7 @@ final class SybilViewModel { } var showsComposer: Bool { + if selectedItem == .agent { return false } if case .settings = selectedItem { return false } @@ -617,7 +624,7 @@ final class SybilViewModel { switch selectedItem { case .chat, .search: return true - case .settings: + case .settings, .agent: return false } } @@ -633,6 +640,7 @@ final class SybilViewModel { } func reconnect() async { + agent.disconnect() setSidebarSearchQuery("") isCheckingSession = true authError = nil @@ -671,6 +679,10 @@ 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.setActive(isAppActive) + } startActiveRunPolling() } catch { stopActiveRunPolling() @@ -981,7 +993,7 @@ final class SybilViewModel { selectedChat = nil composerAttachments = [] - case .settings: + case .settings, .agent: selectedChat = nil selectedSearch = nil return nil @@ -1019,6 +1031,7 @@ final class SybilViewModel { } func deleteItem(_ selection: SidebarSelection) async { + guard selection != .agent else { return } guard isAuthenticated else { return } @@ -1031,7 +1044,7 @@ final class SybilViewModel { try await client.deleteChat(chatID: chatID) case let .search(searchID): try await client.deleteSearch(searchID: searchID) - case .settings: + case .settings, .agent: break } await refreshCollections(preferredSelection: nil) @@ -1099,6 +1112,7 @@ final class SybilViewModel { } func setItemStarred(_ selection: SidebarSelection, starred: Bool) async { + guard selection != .agent else { return } guard isAuthenticated else { return } @@ -1116,7 +1130,7 @@ final class SybilViewModel { case let .search(searchID): let updated = try await client.updateSearchStar(searchID: searchID, starred: starred) applySearchSummary(updated, moveToFront: false) - case .settings: + case .settings, .agent: break } } catch { @@ -1139,12 +1153,14 @@ final class SybilViewModel { } isAppActive = false + agent.setActive(false) appLifecycleGeneration += 1 SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)") } func markAppActiveForNetwork() { isAppActive = true + agent.setActive(true) } func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async { @@ -1472,7 +1488,7 @@ final class SybilViewModel { if let nextSelection { switch nextSelection { - case .settings: + case .settings, .agent: selectedChat = nil selectedSearch = nil case .chat, .search: @@ -1783,7 +1799,7 @@ final class SybilViewModel { await self?.attachToActiveSearchStream(searchID: searchID) } - case .settings, nil: + case .settings, .agent, nil: return } } @@ -1992,12 +2008,13 @@ final class SybilViewModel { return selectedChat?.id != chatID case let .search(searchID): return selectedSearch?.id != searchID - case .settings: + case .settings, .agent: return false } } private func refreshSelectionIfNeeded() async { + if selectedItem == .agent { isLoadingSelection = false; return } guard let target = selectedItem else { selectedChat = nil selectedSearch = nil @@ -2039,7 +2056,7 @@ final class SybilViewModel { selectedSearch = search selectedChat = nil - case .settings: + case .settings, .agent: break } errorMessage = nil @@ -2755,7 +2772,7 @@ final class SybilViewModel { return chats.contains(where: { $0.id == chatID }) case let .search(searchID): return searches.contains(where: { $0.id == searchID }) - case .settings: + case .settings, .agent: return true } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 1c6439c..f018f01 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -173,6 +173,8 @@ struct SybilWorkspaceView: View { if isSettingsSelected { SybilSettingsView(viewModel: viewModel) .padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0) + } else if viewModel.selectedItem == .agent { + SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0) } else if viewModel.isSearchMode { SybilSearchResultsView( search: viewModel.displayedSearch, @@ -285,7 +287,7 @@ struct SybilWorkspaceView: View { .lineLimit(1) .minimumScaleFactor(0.78) - Text(selectedProviderModelSubtitle) + Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle) .font(.sybil(size: 10, weight: .medium)) .foregroundStyle(SybilTheme.textMuted) .lineLimit(1) @@ -507,7 +509,7 @@ struct SybilWorkspaceView: View { @ViewBuilder private var workspaceNavigationTrailingControl: some View { - if isSettingsSelected { + if isSettingsSelected || viewModel.selectedItem == .agent { EmptyView() } else if viewModel.isSearchMode { searchModeNavigationLabel diff --git a/ios/Packages/Sybil/Tests/SybilTests/AgentTests.swift b/ios/Packages/Sybil/Tests/SybilTests/AgentTests.swift new file mode 100644 index 0000000..397e4ec --- /dev/null +++ b/ios/Packages/Sybil/Tests/SybilTests/AgentTests.swift @@ -0,0 +1,327 @@ +import Foundation +import Testing +@testable import Sybil + +private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message { + Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b }))) +} +private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage { + AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more) +} + +private final class MemoryOutbox: @unchecked Sendable { + private let lock = NSLock() + private var rows: [AgentOutboxRow] = [] + var storage: AgentOutboxStorage { + AgentOutboxStorage(read: { self.read() }, write: { self.save($0) }) + } + func read() -> [AgentOutboxRow] { lock.withLock { rows } } + func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } } +} +private actor ControlledAgentSocket: AgentSocket { + let stream: AsyncThrowingStream + let continuation: AsyncThrowingStream.Continuation + private(set) var closed = false + init() { (stream, continuation) = AsyncThrowingStream.makeStream() } + func receive() async throws -> Data { + var iterator = stream.makeAsyncIterator() + guard let data = try await iterator.next() else { throw CancellationError() } + return data + } + func send(_ text: String) {} + func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) } + func closeCode() -> Int { 0 } + func push(_ text: String) { continuation.yield(Data(text.utf8)) } +} +private actor TestAgentClient: AgentAPIClienting { + var pages: [String: AgentPage] + var requests: [String] = [] + var submissions: [(String, String)] = [] + var read = "0" + var failRead = false + var failSubmit = false + var malformedReceipt = false + var socketAfters: [String] = [] + var sockets: [ControlledAgentSocket] = [] + var holdRead = false + var heldRead: CheckedContinuation? + var beforeSubmit: (@Sendable () -> Bool)? + init(pages: [String: AgentPage] = [:]) { self.pages = pages } + func agentDescriptor() -> AgentDescriptor { + AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws") + } + func agentMessages(after: String?, before: String?) -> AgentPage { + let cursor = after ?? "0"; requests.append(cursor) + return pages[cursor] ?? agentPage([], cursor: cursor) + } + func agentReadState() async -> AgentReadState { + if holdRead { return await withCheckedContinuation { heldRead = $0 } } + return AgentReadState(cursor: read) + } + func pauseRead() { holdRead = true } + func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil } + func hasHeldRead() -> Bool { heldRead != nil } + func agentMarkRead(cursor: String) throws -> AgentReadState { + if failRead { throw AgentFailure(message: "Offline") } + read = try AgentCursor.advance(read, cursor) + return AgentReadState(cursor: read) + } + func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt { + #expect(beforeSubmit?() ?? true) + submissions.append((content, requestID)) + try await Task.sleep(for: .milliseconds(80)) + if failSubmit { throw AgentFailure(message: "Lost acknowledgement") } + return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)") + } + func agentSocket(after: String) async -> any AgentSocket { + socketAfters.append(after) + let socket = ControlledAgentSocket(); sockets.append(socket) + await socket.push(#"{"type":"ready","conversationId":"root"}"#) + return socket + } + func connectedAfters() -> [String] { socketAfters } + func latestSocket() -> ControlledAgentSocket? { sockets.last } + func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed } + func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check } + func submittedIDs() -> [String] { submissions.map(\.1) } + func requestedCursors() -> [String] { requests } +} + +@MainActor +private func eventually(_ predicate: () -> Bool) async -> Bool { + for _ in 0..<200 { + if predicate() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return false +} + +struct AgentTests { + @Test func cursorAndMalformedFrames() throws { + #expect(try AgentCursor.normalize("00012") == "12") + #expect(try AgentCursor.advance("9", "10") == "10") + #expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993") + #expect(throws: (any Error).self) { try AgentCursor.normalize("-1") } + var state = AgentRoomState() + try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10")) + let original = state + #expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) } + #expect(state == original) + #expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) } + #expect(state.cursor == "10") + let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)]) + #expect(oversizedDuration.toolCallMetadata?.durationMs == nil) + } + + @Test func terminalToolAndRestSocketRace() throws { + let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")]) + var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])]) + end.createdAt.addTimeInterval(5) + var state = AgentRoomState() + try state.apply(agentPage([start], cursor: "5")) + try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9")) + try state.apply(agentPage([start], cursor: "5")) + #expect(state.cursor == "9") + #expect(state.messages.count == 2) + let tool = try #require(state.messages.first?.toolCallMetadata) + #expect(tool.status == "failed") + #expect(tool.error == "fixture failure") + #expect(tool.args == .object(["query": .string("fixture")])) + #expect(tool.startedAt == "start") + #expect(state.messages.first?.createdAt == start.createdAt) + } + + @Test func unreadExcludesToolsAndUsers() throws { + var state = AgentRoomState() + try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12")) + state.readCursor = "9" + #expect(state.unreadCount == 1) + state.readCursor = try AgentCursor.advance(state.readCursor, "12") + state.readCursor = try AgentCursor.advance(state.readCursor, "2") + #expect(state.unreadCount == 0) + } + + @Test func typingReplyAndStaleActivityAreAtomic() throws { + var presentation = AgentPresentation(), state = AgentRoomState() + let now = Date(timeIntervalSince1970: 100) + var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1 + var after = state; after.activity = busy + presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now) + state = after + #expect(!presentation.isTyping(now: now.addingTimeInterval(0.249))) + #expect(presentation.isTyping(now: now.addingTimeInterval(0.251))) + let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1") + try after.apply(reply) + presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1)) + state = after + #expect(!presentation.isTyping(now: now.addingTimeInterval(2))) + #expect(state.activity.isBusy) + presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3)) + #expect(!presentation.isTyping(now: now.addingTimeInterval(4))) + for name in AgentPresentation.quietTools { + busy.tools.append(.init(id: name, name: name, status: "initiated")) + presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5)) + #expect(!presentation.isTyping(now: now.addingTimeInterval(6))) + } + busy.tools.append(.init(id: "real", name: "search", status: "initiated")) + presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7)) + #expect(presentation.isTyping(now: now.addingTimeInterval(7.3))) + } + + @Test func socketURLAndAuthentication() throws { + let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ") + #expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws") + let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8)) + #expect(frame.objectValue?["token"] == .string("token")) + #expect(frame.objectValue?["after"] == .string("9")) + let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil) + let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8)) + #expect(openFrame.objectValue?["token"] == .null) + } + + @Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws { + let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")]) + let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage) + defer { room.stop() } + #expect(await eventually { !room.isLoading }) + #expect(await client.requestedCursors() == ["0", "3", "7"]) + #expect(room.snapshot.room.cursor == "8") + #expect(room.unreadCount == 1) + #expect(room.snapshot.entranceIDs.isEmpty) + try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9"))) + room.flush() + #expect(room.snapshot.entranceIDs == ["late"]) + try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9"))) + room.flush() + #expect(room.snapshot.entranceIDs.isEmpty) + #expect(room.messages.count == 2) + } + + @Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws { + let memory = MemoryOutbox(), client = TestAgentClient() + await client.setFailures(submit: true) + await client.requirePersisted { !memory.read().isEmpty } + let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage) + defer { room.stop() } + #expect(await eventually { !room.isLoading }) + #expect(room.send("identical")); #expect(room.send("identical")) + #expect(room.messages.count == 2) + let first = room.outbox[0], second = room.outbox[1] + #expect(first.requestID != second.requestID) + #expect(memory.read().count == 2) + try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1"))) + room.flush() + #expect(await eventually { room.outbox[1].status == "failed" }) + #expect(room.outbox[0].status == "complete") + #expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt) + room.retry(second.id) + #expect(await eventually { room.outbox[1].status == "failed" }) + let ids = await client.submittedIDs() + #expect(ids.filter { $0 == second.requestID }.count == 2) + let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage) + defer { restored.stop() } + #expect(restored.outbox.count == 1) + #expect(restored.outbox[0].requestID == second.requestID) + #expect(restored.outbox[0].createdAt == second.createdAt) + } + + @Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async { + let client = TestAgentClient(); await client.setFailures(malformed: true) + let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage) + defer { room.stop() } + #expect(await eventually { !room.isLoading }) + room.send("fixture") + #expect(await eventually { room.outbox.first?.status == "failed" }) + #expect(room.outbox.first?.seenOnServer == false) + let failed = SybilAgentRoom() + failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") })) + defer { failed.stop() } + failed.send("must not submit") + #expect(failed.outbox.first?.status == "failed") + #expect(await client.submittedIDs().count == 1) + } + + @Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws { + let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")]) + let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage) + defer { room.stop() } + #expect(await eventually { !room.isLoading }) + room.isSelected = true + await client.setFailures(read: true) + room.markRead(renderedCursor: "1") + #expect(await eventually { room.error != nil }) + #expect(room.snapshot.room.readCursor == "0") + await client.setFailures() + try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2"))) + room.markRead(renderedCursor: "1") + #expect(await eventually { room.snapshot.room.readCursor == "1" }) + #expect(room.unreadCount == 1) + room.isObscured = true; room.markRead(renderedCursor: "2") + try? await Task.sleep(for: .milliseconds(100)) + #expect(room.snapshot.room.readCursor == "1") + try room.applyRead(AgentReadState(cursor: "2")); room.flush() + #expect(room.unreadCount == 0) + room.setActive(false); room.setActive(true) + #expect(await eventually { room.connection == "Connecting" }) + #expect(room.messages.count == 2) + } + @Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws { + let client = TestAgentClient(), memory = MemoryOutbox() + let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage) + defer { room.stop() } + #expect(await eventually { room.connection == "Connected" }) + room.send("interrupted fixture") + let original = try #require(room.outbox.first) + room.setActive(false) + #expect(room.outbox.first?.status == "failed") + #expect(memory.read().first?.requestID == original.requestID) + room.setActive(true) + #expect(await eventually { room.connection == "Connected" }) + room.retry(original.id) + #expect(await eventually { room.outbox.first?.seenOnServer == true }) + #expect(room.outbox.first?.createdAt == original.createdAt) + #expect(await client.submittedIDs().allSatisfy { $0 == original.requestID }) + } + + @Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws { + let old = TestAgentClient(); await old.pauseRead() + let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage) + for _ in 0..<100 { + if await old.hasHeldRead() { break } + try? await Task.sleep(for: .milliseconds(10)) + } + room.disconnect() + room.setActive(false); room.setActive(true) + #expect(await old.connectedAfters().isEmpty) + let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")]) + room.configure(client: next, storage: MemoryOutbox().storage) + defer { room.stop() } + #expect(await eventually { room.connection == "Connected" }) + await old.releaseRead() + try? await Task.sleep(for: .milliseconds(50)) + try await room.catchUp(client: next) + #expect(room.messages.map(\.id) == ["new"]) + #expect(await next.requestedCursors() == ["0", "2"]) + #expect(await old.connectedAfters().isEmpty) + } + + @Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws { + let client = TestAgentClient() + let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage) + defer { room.stop() } + #expect(await eventually { room.connection == "Connected" }) + let socket = try #require(await client.latestSocket()) + let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"# + await socket.push(frame) + #expect(await eventually { room.snapshot.room.cursor == "7" }) + room.setActive(false); room.setActive(true) + #expect(await eventually { room.connection == "Connected" }) + let next = try #require(await client.latestSocket()) + await next.push(frame) + try? await Task.sleep(for: .milliseconds(150)) + #expect(await client.connectedAfters() == ["0", "7"]) + #expect(room.messages.count == 1) + #expect(await socket.closed) + } + +} diff --git a/ios/README.md b/ios/README.md index d4b1003..3d53cac 100644 --- a/ios/README.md +++ b/ios/README.md @@ -13,6 +13,64 @@ cancels superseded requests, and leaves the current conversation open. Clear the field to return to recent items. Searches use word prefixes (for example, `plan` finds `planning`); if more than 100 items match, refine the query. +## Sybil Agent + +The permanent **Sybil Agent** sidebar item opens the persistent room on both +phone and iPad. It uses the configured Sybil API URL (including an explicit +`/api` path) and token. The room stays subscribed while other chats are selected; +foreground and network recovery replay from the last merged cursor. Shared unread +counts include only public assistant replies and are acknowledged after the +visible transcript reaches the bottom and its entrance animation finishes. + +Multiple messages can be queued while the Agent works. The local outbox is saved +atomically under Application Support before submission and is scoped to the API +base URL. An interrupted send stays on its original bubble with **Retry**, retaining +its request ID and timestamp across relaunch. A canonical server echo confirms it +even if the HTTP acknowledgement is lost. Tool cards expand to show input, result, +and error details. Attachments and model selection belong to ordinary chats. + +Agent contracts remain in `../docs/api/rest.md` and +`../docs/api/streaming-chat.md`; this client requires no backend deployment. + +### Agent regression and Simulator checks + +Run these commands from this worktree's `ios/` directory: + +```sh +just build +just test +just run +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. +For interactive UI coverage, start the local fixture (Python with `aiohttp`): + +```sh +python3 Tests/Fixtures/agent_server.py +``` + +In a second terminal, run: + +```sh +just generate +xcodebuild test -scheme SybilAgentUI \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \ + -parallel-testing-enabled NO \ + -resultBundlePath build/agent-evidence/phone-ui.xcresult +``` + +The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts +external services. UI tests exercise the real REST/WebSocket client and retain +screenshots in the result bundle. They cover queued submissions, uncertain +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. +Use a fresh result-bundle path for each run. Check the same UI on iPad and with +Simulator Reduce Motion enabled before release. + ## Quick Question on macOS While the Catalyst app is running, **Option+Space** toggles a floating Quick diff --git a/ios/Tests/Fixtures/agent_server.py b/ios/Tests/Fixtures/agent_server.py new file mode 100644 index 0000000..d0b0fee --- /dev/null +++ b/ios/Tests/Fixtures/agent_server.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Local-only deterministic Agent contract fixture. Never contacts external services. +Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI. +""" +import asyncio +import datetime +import json +from aiohttp import web + +sockets = set() +messages = [] +requests = {} +read_cursor = 0 +cursor = 0 +busy = False +attempts = {} +log = [] +reply_tasks = set() + +def stamp(): + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + +def activity(): + return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0, + backgroundWorkers=0, tools=[], queuedMessages=[]) + +def message(content, role='assistant', id=None, info=None): + global cursor + cursor += 1 + row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(), + metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {}))) + messages.append(row) + return row + +def page(rows, position=None, more=False): + return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more) + +async def broadcast(frame): + for socket in list(sockets): + try: + await socket.send_json(frame) + except ConnectionError: + sockets.discard(socket) + +async def emit(content): + row = message(content) + await broadcast(dict(type='messages', **page([row]))) + return row + +async def reply(content): + global busy, cursor + await asyncio.sleep(1.2) + tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}') + tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp()) + await broadcast(dict(type='messages', **page([tool]))) + await asyncio.sleep(0.8) + cursor += 1 + tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches')) + messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool + await broadcast(dict(type='messages', **page([tool]))) + failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}') + failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure') + await broadcast(dict(type='messages', **page([failed]))) + await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.') + # Deliberately stale busy frame after the public reply. + await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity())) + await asyncio.sleep(0.5) + busy = False + await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity())) + +async def route(request): + global read_cursor, cursor, busy + path = request.path + log.append(dict(method=request.method, path=path, query=dict(request.query))) + if path == '/control': + body = await request.json() + action = body.get('action') + if action == 'reset': + for task in reply_tasks: + task.cancel() + reply_tasks.clear() + messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False + for i in range(body.get('count', 3)): + message(f'History message {i + 1}: local simulator fixture.') + elif action == 'reply': + await emit(body.get('content', 'Late asynchronous fixture reply')) + elif action == 'long-tool': + row = message('Local tool output', 'tool') + row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed', + args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80))) + await broadcast(dict(type='messages', **page([row]))) + elif action == 'busy': + busy = body.get('value', True) + await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity())) + elif action == 'read': + read_cursor = cursor + elif action == 'disconnect': + for socket in list(sockets): + await socket.close(code=1012) + elif action == 'malformed': + await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False)) + 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')) + if path == '/api/v1/workspace-items': + return web.json_response(dict(items=[], hasMore=False)) + if path == '/api/v1/chats': + return web.json_response(dict(chats=[])) + if path == '/api/v1/searches': + return web.json_response(dict(searches=[])) + if path == '/api/v1/models': + return web.json_response(dict(providers={})) + if path == '/api/v1/active-runs': + return web.json_response(dict(chats=[], searches=[])) + 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 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())) + if path == '/api/v1/agent/read-state': + if request.method == 'PUT': + read_cursor = max(read_cursor, int((await request.json())['cursor'])) + return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp())) + if path == '/api/v1/agent/messages': + if request.method == 'POST': + body = await request.json() + key, content = body['requestId'], body['content'] + 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 key not in requests: + requests[key] = receipt + row = message(content, 'user', id=receipt['messageId']) + busy = True + await broadcast(dict(type='messages', **page([row]))) + await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity())) + task = asyncio.create_task(reply(content)) + reply_tasks.add(task) + task.add_done_callback(reply_tasks.discard) + 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) + 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) + selected = rows[:limit] + return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit)) + if path == '/api/v1/agent/ws': + if request.query: + return web.Response(status=400) + socket = web.WebSocketResponse(); await socket.prepare(request) + 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 + log.append(dict(authAfter=auth.get('after'))) + sockets.add(socket) + await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root')) + await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))]))) + await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity())) + try: + async for frame in socket: + if frame.type == web.WSMsgType.TEXT: + data = json.loads(frame.data) + if data.get('type') == 'ping': + await socket.send_json(dict(type='pong')) + finally: + sockets.discard(socket) + return socket + return web.json_response(dict(error='No fixture for this endpoint'), status=404) + +for i in range(3): + message(f'History message {i + 1}: local simulator fixture.') +app = web.Application() +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 new file mode 100644 index 0000000..0b066c0 --- /dev/null +++ b/ios/Tests/SybilUITests/AgentUITests.swift @@ -0,0 +1,175 @@ +import XCTest +import UIKit + +@MainActor +final class AgentUITests: XCTestCase { + let app = XCUIApplication() + override func setUp() async throws { + continueAfterFailure = false + try await control(["action": "reset", "count": 3]) + app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"] + app.launch() + openAgent() + } + private func openAgent() { + if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !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)) + app.buttons["agent-sidebar"].tap() + XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists) + } + private var composer: XCUIElement { + app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"] + } + private func send(_ text: String) { + composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap() + } + 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, _) = try await URLSession.shared.data(for: request) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + func testSendQueueToolsAndRetry() async throws { + capture("agent-history") + send("first fixture") + send("second fixture") + XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12)) + XCTAssertFalse(app.otherElements["agent-typing"].exists) + capture("agent-replies-keyboard") + app.swipeDown() + send("retry fixture") + XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5)) + capture("agent-retry") + app.buttons["agent-retry"].tap() + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12)) + send("echo wins fixture") + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12)) + XCTAssertFalse(app.buttons["agent-retry"].exists) + composer.tap(); app.swipeDown() + let failures = app.buttons.matching(identifier: "agent-tool-fixture_error") + failures.element(boundBy: failures.count - 1).tap() + XCTAssertTrue(app.staticTexts["agent-tool-input"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.staticTexts["agent-tool-error"].exists) + capture("agent-tool-error") + let state = try await control(["action": "state"]) + XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4) + } + func testReduceMotionSend() async throws { + try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled") + send("reduce motion fixture") + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12)) + XCTAssertFalse(app.otherElements["agent-typing"].exists) + capture("agent-reduce-motion") + } + + func testExpandedToolCollapseToShortHistory() async throws { + try await control(["action": "long-tool"]) + let tool = app.buttons["agent-tool-fixture_long"] + XCTAssertTrue(tool.waitForExistence(timeout: 5)) + tool.tap() + XCTAssertTrue(app.staticTexts["agent-tool-result"].waitForExistence(timeout: 3)) + let scroll = app.scrollViews.firstMatch + for _ in 0..<12 { + if tool.isHittable { break } + scroll.swipeDown() + } + XCTAssertTrue(tool.isHittable) + tool.tap() + XCTAssertEqual(tool.value as? String, "Collapsed") + try await control(["action": "reply", "content": "Reply after collapsing a tall tool"]) + XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].isHittable) + XCTAssertFalse(app.buttons["agent-jump-latest"].exists) + capture("agent-collapsed-tool-short-history") + } + + func testTabletSidebarAndInactiveChat() async throws { + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check") + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["agent-sidebar"].isHittable) + app.buttons["New chat"].tap() + try await control(["action": "busy", "value": true]) + try await control(["action": "reply", "content": "iPad inactive chat reply"]) + let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"]) + await fulfillment(of: [unread], timeout: 5) + capture("agent-ipad-sidebar") + app.buttons["agent-sidebar"].tap() + XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5)) + capture("agent-ipad-room-before-send") + send("iPad fixture") + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12)) + capture("agent-ipad-room") + XCUIDevice.shared.orientation = .portrait + } + + func testScrollPreservationAndKeyboard() async throws { + app.terminate() + try await control(["action": "reset", "count": 160]) + app.launch(); openAgent() + let scroll = app.scrollViews.firstMatch + XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable) + scroll.swipeDown() + scroll.swipeDown() + XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3)) + let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable }) + let before = anchor.frame.minY + try await control(["action": "reply", "content": "Reply while reading earlier history"]) + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(anchor.frame.minY, before, accuracy: 2) + capture("agent-reading-anchor") + for _ in 0..<25 { + if app.buttons["Load earlier messages"].isHittable { break } + scroll.swipeDown(velocity: .fast) + } + XCTAssertTrue(app.buttons["Load earlier messages"].isHittable) + let first = app.staticTexts["History message 61: local simulator fixture."] + let beforeLoad = first.frame.minY + app.buttons["Load earlier messages"].tap() + XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3) + capture("agent-older-history-anchor") + app.buttons["agent-jump-latest"].tap() + XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) + composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line") + XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) + capture("agent-multiline-keyboard") + } + + func testBackgroundUnreadReadReconciliationAndReplay() async throws { + app.buttons["Show sidebar"].tap() + try await control(["action": "busy", "value": true]) + try await control(["action": "reply", "content": "Late asynchronous fixture reply"]) + let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"]) + await fulfillment(of: [unread], timeout: 5) + capture("agent-unread-sidebar") + let covered = try await control(["action": "state"]) + XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0) + try await control(["action": "read"]) + let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies") + let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"]) + await fulfillment(of: [cleared], timeout: 20) + app.buttons["agent-sidebar"].tap() + XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5)) + try await control(["action": "disconnect"]) + try await control(["action": "reply", "content": "Reconnect fixture reply"]) + XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10)) + try await control(["action": "malformed"]) + try await control(["action": "reply", "content": "Recovered malformed frame"]) + XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12)) + XCUIDevice.shared.press(.home) + try await control(["action": "reply", "content": "Foreground fixture reply"]) + app.activate() + XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12)) + XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1) + capture("agent-foreground-replay") + } +} diff --git a/ios/justfile b/ios/justfile index ff23138..4e63a6c 100644 --- a/ios/justfile +++ b/ios/justfile @@ -9,7 +9,7 @@ generate: xcodegen --spec project.yml build: generate - if command -v xcbeautify >/dev/null 2>&1; then \ + set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \ xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \ else \ xcodebuild -scheme Sybil -destination '{{simulator}}'; \ diff --git a/ios/project.yml b/ios/project.yml index bb23cd2..9a8e19d 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -8,3 +8,30 @@ packages: include: - Apps/Sybil/project.yml - Apps/SybilMacQuickQuestion/project.yml + +# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp. +targets: + SybilAgentUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "18.0" + sources: + - Tests/SybilUITests + dependencies: + - target: SybilApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: SybilApp + SWIFT_VERSION: 6.0 +schemes: + SybilAgentUI: + build: + targets: + SybilApp: all + SybilAgentUITests: [test] + test: + config: Debug + targets: + - SybilAgentUITests