ios: add persistent Sybil Agent room
TestFlight / Build and upload (push) Successful in 5m29s

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.
This commit is contained in:
2026-10-14 13:34:37 -07:00
parent e83281c400
commit 4389f805f4
23 changed files with 1998 additions and 26 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ let package = Package(
name: "Sybil", name: "Sybil",
dependencies: [ dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui") .product(name: "MarkdownUI", package: "swift-markdown-ui")
]), ],
resources: [.process("Resources")]),
.testTarget( .testTarget(
name: "SybilTests", name: "SybilTests",
dependencies: ["Sybil"] dependencies: ["Sybil"]
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "character-still.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -106,6 +106,9 @@ public struct SplitView: View {
.onChange(of: viewModel.isAuthenticated) { _, _ in .onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible() presentPendingQuickQuestionIfPossible()
} }
.onChange(of: isQuickQuestionPresented) { _, presented in
viewModel.agent.isModalPresented = presented
}
.onChange(of: scenePhase) { _, nextPhase in .onChange(of: scenePhase) { _, nextPhase in
switch nextPhase { switch nextPhase {
case .background: case .background:
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
} }
} }
actor SybilAPIClient: SybilAPIClienting { actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
private let configuration: APIConfiguration private let configuration: APIConfiguration
private let session: URLSession private let session: URLSession
@@ -40,6 +40,48 @@ actor SybilAPIClient: SybilAPIClienting {
self.session = session self.session = session
} }
func agentDescriptor() async throws -> AgentDescriptor {
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
throw AgentFailure(message: "Unsupported Agent room protocol.")
}
return value
}
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
var query = [URLQueryItem(name: "limit", value: "200")]
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
try page.validate()
return page
}
func agentReadState() async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
}
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
}
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
try receipt.validate(requestID: requestID)
return receipt
}
func agentSocket(after: String) async throws -> any AgentSocket {
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
task.maximumMessageSize = 8 * 1024 * 1024
task.resume()
let socket = AgentURLSocket(task: task)
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
catch { await socket.close(); throw error }
return socket
}
func verifySession() async throws -> AuthSession { func verifySession() async throws -> AuthSession {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) 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) let url = try buildURL(path: path, queryItems: queryItems)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = method request.httpMethod = method
request.timeoutInterval = 120 if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
if acceptsSSE { if acceptsSSE {
request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
@@ -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<String> = []
static let quietTools: Set<String> = ["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 }
}
@@ -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])
})
}
}
@@ -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<String> = []
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<String> = []
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var connectionTask: Task<Void, Never>?
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
@ObservationIgnored private var batchTask: Task<Void, Never>?
@ObservationIgnored private var typingTask: Task<Void, Never>?
@ObservationIgnored private var readTask: Task<Void, Never>?
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
@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() }
}
}
@@ -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)
}
}
@@ -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 })
}
}
@@ -149,12 +149,13 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
return items return items
} }
private struct MessageBubble: View { struct MessageBubble: View {
var message: Message var message: Message
var isSending: Bool var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation? var streamingAssistantPresentation: StreamingAssistantPresentation?
var isForking: Bool var isForking: Bool
var onFork: (() -> Void)? var onFork: (() -> Void)?
var agentStyle = false
private var toolCallMetadata: ToolCallMetadata? { private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata message.toolCallMetadata
@@ -229,14 +230,19 @@ private struct MessageBubble: View {
presentation: matchingStreamingAssistantPresentation presentation: matchingStreamingAssistantPresentation
) )
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content) if isUser {
.tint(SybilTheme.primary) Text(message.content)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95)) .font(.sybil(.body))
.markdownTheme(.sybilReadable) } else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
} }
} }
.padding(.horizontal, isUser ? 14 : 2) .padding(.horizontal, isUser || agentStyle ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2) .padding(.vertical, isUser || agentStyle ? 13 : 2)
.textSelection(.enabled) .textSelection(.enabled)
.background( .background(
Group { Group {
@@ -247,6 +253,10 @@ private struct MessageBubble: View {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1) .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 { } else {
RoundedRectangle(cornerRadius: 0) RoundedRectangle(cornerRadius: 0)
.fill(Color.clear) .fill(Color.clear)
@@ -601,7 +611,7 @@ private struct ToolCallStackCard: View {
} }
} }
private struct ToolCallActivityChip: View { struct ToolCallActivityChip: View {
enum VisualState { enum VisualState {
case initiated case initiated
case completed case completed
@@ -359,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
toolName: object["toolName"]?.stringValue ?? name, toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue, status: object["status"]?.stringValue,
summary: object["summary"]?.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, 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 durationMs: Int?
public var error: String? public var error: String?
public var resultPreview: 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 { public enum JSONValue: Codable, Hashable, Sendable {
@@ -2,6 +2,7 @@ import Observation
import SwiftUI import SwiftUI
enum PhoneRoute: Hashable { enum PhoneRoute: Hashable {
case agent
case chat(String) case chat(String)
case search(String) case search(String)
case draftChat case draftChat
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
return .chat(chatID) return .chat(chatID)
case let .search(searchID): case let .search(searchID):
return .search(searchID) return .search(searchID)
case .agent: return .agent
case .settings: case .settings:
return .settings return .settings
} }
@@ -64,6 +66,7 @@ struct SybilPhoneShellView: View {
return .chat(chatID) return .chat(chatID)
case let .search(searchID): case let .search(searchID):
return .search(searchID) return .search(searchID)
case .agent: return .agent
case .draftChat, .draftSearch, .settings: case .draftChat, .draftSearch, .settings:
return nil return nil
} }
@@ -87,6 +90,10 @@ struct SybilPhoneShellView: View {
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: route) .animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented) .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 .onChange(of: scenePhase) { _, nextPhase in
switch nextPhase { switch nextPhase {
case .background: case .background:
@@ -525,7 +532,9 @@ private struct SybilPhoneSidebarRoot: View {
}, },
onOpenForkedChat: { chatID in onOpenForkedChat: { chatID in
onSelect(.chat(chatID)) onSelect(.chat(chatID))
} },
onSelectAgent: { onSelect(.agent) },
isAgentSelected: highlightedSelection == .agent
) )
} }
.background(SybilTheme.panelGradient) .background(SybilTheme.panelGradient)
@@ -629,6 +638,8 @@ private struct SybilPhoneDestinationView: View {
return return
} }
viewModel.select(.search(searchID)) viewModel.select(.search(searchID))
case .agent:
viewModel.select(.agent)
case .draftChat: case .draftChat:
viewModel.startNewChat() viewModel.startNewChat()
case .draftSearch: case .draftSearch:
@@ -113,6 +113,8 @@ struct SybilSidebarItemList: View {
var isSelected: (SidebarItem) -> Bool var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void var onSelect: (SidebarItem) -> Void
var onOpenForkedChat: ((String) -> Void)? = nil var onOpenForkedChat: ((String) -> Void)? = nil
var onSelectAgent: (() -> Void)? = nil
var isAgentSelected: Bool? = nil
@State private var renameTarget: SidebarItem? @State private var renameTarget: SidebarItem?
@State private var renameTitle = "" @State private var renameTitle = ""
@FocusState private var isSearchFocused: Bool @FocusState private var isSearchFocused: Bool
@@ -130,6 +132,30 @@ struct SybilSidebarItemList: View {
var body: some View { var body: some View {
VStack(spacing: 0) { 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) { HStack(spacing: 10) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
@@ -7,6 +7,7 @@ enum DraftKind {
} }
enum SidebarSelection: Hashable { enum SidebarSelection: Hashable {
case agent
case chat(String) case chat(String)
case search(String) case search(String)
case settings case settings
@@ -17,6 +18,8 @@ enum SidebarSelection: Hashable {
return "chat:\(chatID)" return "chat:\(chatID)"
case let .search(searchID): case let .search(searchID):
return "search:\(searchID)" return "search:\(searchID)"
case .agent:
return "agent"
case .settings: case .settings:
return "settings" return "settings"
} }
@@ -153,6 +156,7 @@ private actor SearchStreamStatus {
@Observable @Observable
final class SybilViewModel { final class SybilViewModel {
let settings: SybilSettingsStore let settings: SybilSettingsStore
let agent = SybilAgentRoom()
var isCheckingSession = true var isCheckingSession = true
var isAuthenticated = false var isAuthenticated = false
@@ -339,6 +343,8 @@ final class SybilViewModel {
} }
return "Search" return "Search"
case .agent:
return "Sybil Agent"
case .settings: case .settings:
return "Settings" return "Settings"
} }
@@ -378,6 +384,7 @@ final class SybilViewModel {
} }
var showsComposer: Bool { var showsComposer: Bool {
if selectedItem == .agent { return false }
if case .settings = selectedItem { if case .settings = selectedItem {
return false return false
} }
@@ -617,7 +624,7 @@ final class SybilViewModel {
switch selectedItem { switch selectedItem {
case .chat, .search: case .chat, .search:
return true return true
case .settings: case .settings, .agent:
return false return false
} }
} }
@@ -633,6 +640,7 @@ final class SybilViewModel {
} }
func reconnect() async { func reconnect() async {
agent.disconnect()
setSidebarSearchQuery("") setSidebarSearchQuery("")
isCheckingSession = true isCheckingSession = true
authError = nil authError = nil
@@ -671,6 +679,10 @@ final class SybilViewModel {
) )
await loadInitialData(using: client) 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() startActiveRunPolling()
} catch { } catch {
stopActiveRunPolling() stopActiveRunPolling()
@@ -981,7 +993,7 @@ final class SybilViewModel {
selectedChat = nil selectedChat = nil
composerAttachments = [] composerAttachments = []
case .settings: case .settings, .agent:
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
return nil return nil
@@ -1019,6 +1031,7 @@ final class SybilViewModel {
} }
func deleteItem(_ selection: SidebarSelection) async { func deleteItem(_ selection: SidebarSelection) async {
guard selection != .agent else { return }
guard isAuthenticated else { guard isAuthenticated else {
return return
} }
@@ -1031,7 +1044,7 @@ final class SybilViewModel {
try await client.deleteChat(chatID: chatID) try await client.deleteChat(chatID: chatID)
case let .search(searchID): case let .search(searchID):
try await client.deleteSearch(searchID: searchID) try await client.deleteSearch(searchID: searchID)
case .settings: case .settings, .agent:
break break
} }
await refreshCollections(preferredSelection: nil) await refreshCollections(preferredSelection: nil)
@@ -1099,6 +1112,7 @@ final class SybilViewModel {
} }
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async { func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard selection != .agent else { return }
guard isAuthenticated else { guard isAuthenticated else {
return return
} }
@@ -1116,7 +1130,7 @@ final class SybilViewModel {
case let .search(searchID): case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred) let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false) applySearchSummary(updated, moveToFront: false)
case .settings: case .settings, .agent:
break break
} }
} catch { } catch {
@@ -1139,12 +1153,14 @@ final class SybilViewModel {
} }
isAppActive = false isAppActive = false
agent.setActive(false)
appLifecycleGeneration += 1 appLifecycleGeneration += 1
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)") SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
} }
func markAppActiveForNetwork() { func markAppActiveForNetwork() {
isAppActive = true isAppActive = true
agent.setActive(true)
} }
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async { func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
@@ -1472,7 +1488,7 @@ final class SybilViewModel {
if let nextSelection { if let nextSelection {
switch nextSelection { switch nextSelection {
case .settings: case .settings, .agent:
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
case .chat, .search: case .chat, .search:
@@ -1783,7 +1799,7 @@ final class SybilViewModel {
await self?.attachToActiveSearchStream(searchID: searchID) await self?.attachToActiveSearchStream(searchID: searchID)
} }
case .settings, nil: case .settings, .agent, nil:
return return
} }
} }
@@ -1992,12 +2008,13 @@ final class SybilViewModel {
return selectedChat?.id != chatID return selectedChat?.id != chatID
case let .search(searchID): case let .search(searchID):
return selectedSearch?.id != searchID return selectedSearch?.id != searchID
case .settings: case .settings, .agent:
return false return false
} }
} }
private func refreshSelectionIfNeeded() async { private func refreshSelectionIfNeeded() async {
if selectedItem == .agent { isLoadingSelection = false; return }
guard let target = selectedItem else { guard let target = selectedItem else {
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
@@ -2039,7 +2056,7 @@ final class SybilViewModel {
selectedSearch = search selectedSearch = search
selectedChat = nil selectedChat = nil
case .settings: case .settings, .agent:
break break
} }
errorMessage = nil errorMessage = nil
@@ -2755,7 +2772,7 @@ final class SybilViewModel {
return chats.contains(where: { $0.id == chatID }) return chats.contains(where: { $0.id == chatID })
case let .search(searchID): case let .search(searchID):
return searches.contains(where: { $0.id == searchID }) return searches.contains(where: { $0.id == searchID })
case .settings: case .settings, .agent:
return true return true
} }
} }
@@ -173,6 +173,8 @@ struct SybilWorkspaceView: View {
if isSettingsSelected { if isSettingsSelected {
SybilSettingsView(viewModel: viewModel) SybilSettingsView(viewModel: viewModel)
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0) .padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
} else if viewModel.selectedItem == .agent {
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
} else if viewModel.isSearchMode { } else if viewModel.isSearchMode {
SybilSearchResultsView( SybilSearchResultsView(
search: viewModel.displayedSearch, search: viewModel.displayedSearch,
@@ -285,7 +287,7 @@ struct SybilWorkspaceView: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.78) .minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle) Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium)) .font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.lineLimit(1) .lineLimit(1)
@@ -507,7 +509,7 @@ struct SybilWorkspaceView: View {
@ViewBuilder @ViewBuilder
private var workspaceNavigationTrailingControl: some View { private var workspaceNavigationTrailingControl: some View {
if isSettingsSelected { if isSettingsSelected || viewModel.selectedItem == .agent {
EmptyView() EmptyView()
} else if viewModel.isSearchMode { } else if viewModel.isSearchMode {
searchModeNavigationLabel searchModeNavigationLabel
@@ -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<Data, any Error>
let continuation: AsyncThrowingStream<Data, any Error>.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<AgentReadState, Never>?
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)
}
}
+58
View File
@@ -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` field to return to recent items. Searches use word prefixes (for example, `plan`
finds `planning`); if more than 100 items match, refine the query. 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 ## Quick Question on macOS
While the Catalyst app is running, **Option+Space** toggles a floating Quick While the Catalyst app is running, **Option+Space** toggles a floating Quick
+177
View File
@@ -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)
+175
View File
@@ -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")
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ generate:
xcodegen --spec project.yml xcodegen --spec project.yml
build: generate 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; \ xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
else \ else \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \ xcodebuild -scheme Sybil -destination '{{simulator}}'; \
+27
View File
@@ -8,3 +8,30 @@ packages:
include: include:
- Apps/Sybil/project.yml - Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/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