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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "character-still.png",
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
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 {
|
||||||
|
if isUser {
|
||||||
|
Text(message.content)
|
||||||
|
.font(.sybil(.body))
|
||||||
|
} else {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||||
.markdownTheme(.sybilReadable)
|
.markdownTheme(.sybilReadable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
}
|
||||||
.padding(.vertical, isUser ? 13 : 2)
|
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||||
|
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
||||||
.textSelection(.enabled)
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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}}'; \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user