From 4389f805f4e79cfde83610374df767d97a8fe6dc Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 Oct 2026 13:34:37 -0700 Subject: [PATCH] ios: add persistent Sybil Agent room Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details. Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review. --- ios/Packages/Sybil/Package.swift | 3 +- .../Resources/Agent.xcassets/Contents.json | 6 + .../character-still.imageset/Contents.json | 12 + .../character-still.png | Bin 0 -> 41747 bytes .../Sybil/Sources/Sybil/SplitView.swift | 3 + .../Sybil/Sources/Sybil/SybilAPIClient.swift | 47 ++- .../Sources/Sybil/SybilAgentModels.swift | 241 ++++++++++++ .../Sources/Sybil/SybilAgentOutbox.swift | 41 ++ .../Sybil/Sources/Sybil/SybilAgentRoom.swift | 371 ++++++++++++++++++ .../Sources/Sybil/SybilAgentTransport.swift | 56 +++ .../Sybil/Sources/Sybil/SybilAgentView.swift | 360 +++++++++++++++++ .../Sybil/SybilChatTranscriptView.swift | 26 +- .../Sybil/Sources/Sybil/SybilModels.swift | 12 +- .../Sources/Sybil/SybilPhoneShellView.swift | 13 +- .../Sources/Sybil/SybilSidebarView.swift | 26 ++ .../Sybil/Sources/Sybil/SybilViewModel.swift | 35 +- .../Sources/Sybil/SybilWorkspaceView.swift | 6 +- .../Sybil/Tests/SybilTests/AgentTests.swift | 327 +++++++++++++++ ios/README.md | 58 +++ ios/Tests/Fixtures/agent_server.py | 177 +++++++++ ios/Tests/SybilUITests/AgentUITests.swift | 175 +++++++++ ios/justfile | 2 +- ios/project.yml | 27 ++ 23 files changed, 1998 insertions(+), 26 deletions(-) create mode 100644 ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/Contents.json create mode 100644 ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/Contents.json create mode 100644 ios/Packages/Sybil/Sources/Sybil/Resources/Agent.xcassets/character-still.imageset/character-still.png create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentModels.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentOutbox.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift create mode 100644 ios/Packages/Sybil/Tests/SybilTests/AgentTests.swift create mode 100644 ios/Tests/Fixtures/agent_server.py create mode 100644 ios/Tests/SybilUITests/AgentUITests.swift 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 0000000000000000000000000000000000000000..073668ac13b7e2f637167192ee967924b6567b77 GIT binary patch literal 41747 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%0A;U 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 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