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",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
]),
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
.testTarget(
|
||||
name: "SybilTests",
|
||||
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
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: isQuickQuestionPresented) { _, presented in
|
||||
viewModel.agent.isModalPresented = presented
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
|
||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -40,6 +40,48 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func agentDescriptor() async throws -> AgentDescriptor {
|
||||
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
|
||||
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
|
||||
var query = [URLQueryItem(name: "limit", value: "200")]
|
||||
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
|
||||
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
|
||||
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
|
||||
try page.validate()
|
||||
return page
|
||||
}
|
||||
|
||||
func agentReadState() async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
|
||||
try receipt.validate(requestID: requestID)
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentSocket(after: String) async throws -> any AgentSocket {
|
||||
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
|
||||
task.maximumMessageSize = 8 * 1024 * 1024
|
||||
task.resume()
|
||||
let socket = AgentURLSocket(task: task)
|
||||
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
|
||||
catch { await socket.close(); throw error }
|
||||
return socket
|
||||
}
|
||||
|
||||
func verifySession() async throws -> AuthSession {
|
||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||
}
|
||||
@@ -360,7 +402,8 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
let url = try buildURL(path: path, queryItems: queryItems)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = 120
|
||||
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
|
||||
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
|
||||
|
||||
if acceptsSSE {
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
private struct MessageBubble: View {
|
||||
struct MessageBubble: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var isForking: Bool
|
||||
var onFork: (() -> Void)?
|
||||
var agentStyle = false
|
||||
|
||||
private var toolCallMetadata: ToolCallMetadata? {
|
||||
message.toolCallMetadata
|
||||
@@ -229,14 +230,19 @@ private struct MessageBubble: View {
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.sybil(.body))
|
||||
} else {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser ? 14 : 2)
|
||||
.padding(.vertical, isUser ? 13 : 2)
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
||||
.textSelection(.enabled)
|
||||
.background(
|
||||
Group {
|
||||
@@ -247,6 +253,10 @@ private struct MessageBubble: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
} else if agentStyle {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.fill(Color.clear)
|
||||
@@ -601,7 +611,7 @@ private struct ToolCallStackCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
|
||||
@@ -359,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
toolName: object["toolName"]?.stringValue ?? name,
|
||||
status: object["status"]?.stringValue,
|
||||
summary: object["summary"]?.stringValue,
|
||||
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
||||
durationMs: object["durationMs"]?.numberValue.flatMap { value in
|
||||
value >= 0 && value < Double(Int.max) ? Int(value) : nil
|
||||
},
|
||||
error: object["error"]?.stringValue,
|
||||
resultPreview: object["resultPreview"]?.stringValue
|
||||
resultPreview: object["resultPreview"]?.stringValue,
|
||||
args: object["args"],
|
||||
result: object["result"],
|
||||
startedAt: object["startedAt"]?.stringValue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,6 +383,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
public var args: JSONValue? = nil
|
||||
public var result: JSONValue? = nil
|
||||
public var startedAt: String? = nil
|
||||
}
|
||||
|
||||
public enum JSONValue: Codable, Hashable, Sendable {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
enum PhoneRoute: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case draftChat
|
||||
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .settings:
|
||||
return .settings
|
||||
}
|
||||
@@ -64,6 +66,7 @@ struct SybilPhoneShellView: View {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
}
|
||||
@@ -87,6 +90,10 @@ struct SybilPhoneShellView: View {
|
||||
.tint(SybilTheme.primary)
|
||||
.animation(.easeOut(duration: 0.22), value: route)
|
||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
|
||||
viewModel.agent.isObscured = covered
|
||||
}
|
||||
.onDisappear { viewModel.agent.isObscured = false }
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
@@ -525,7 +532,9 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
},
|
||||
onOpenForkedChat: { chatID in
|
||||
onSelect(.chat(chatID))
|
||||
}
|
||||
},
|
||||
onSelectAgent: { onSelect(.agent) },
|
||||
isAgentSelected: highlightedSelection == .agent
|
||||
)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
@@ -629,6 +638,8 @@ private struct SybilPhoneDestinationView: View {
|
||||
return
|
||||
}
|
||||
viewModel.select(.search(searchID))
|
||||
case .agent:
|
||||
viewModel.select(.agent)
|
||||
case .draftChat:
|
||||
viewModel.startNewChat()
|
||||
case .draftSearch:
|
||||
|
||||
@@ -113,6 +113,8 @@ struct SybilSidebarItemList: View {
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
var onSelectAgent: (() -> Void)? = nil
|
||||
var isAgentSelected: Bool? = nil
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
@@ -130,6 +132,30 @@ struct SybilSidebarItemList: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
AgentAvatar()
|
||||
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
|
||||
if viewModel.agent.unreadCount > 0 {
|
||||
Text(String(viewModel.agent.unreadCount))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(SybilTheme.primary, in: Capsule())
|
||||
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(12)
|
||||
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("agent-sidebar")
|
||||
.padding(.horizontal, 10).padding(.top, 10)
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
@@ -7,6 +7,7 @@ enum DraftKind {
|
||||
}
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case settings
|
||||
@@ -17,6 +18,8 @@ enum SidebarSelection: Hashable {
|
||||
return "chat:\(chatID)"
|
||||
case let .search(searchID):
|
||||
return "search:\(searchID)"
|
||||
case .agent:
|
||||
return "agent"
|
||||
case .settings:
|
||||
return "settings"
|
||||
}
|
||||
@@ -153,6 +156,7 @@ private actor SearchStreamStatus {
|
||||
@Observable
|
||||
final class SybilViewModel {
|
||||
let settings: SybilSettingsStore
|
||||
let agent = SybilAgentRoom()
|
||||
|
||||
var isCheckingSession = true
|
||||
var isAuthenticated = false
|
||||
@@ -339,6 +343,8 @@ final class SybilViewModel {
|
||||
}
|
||||
return "Search"
|
||||
|
||||
case .agent:
|
||||
return "Sybil Agent"
|
||||
case .settings:
|
||||
return "Settings"
|
||||
}
|
||||
@@ -378,6 +384,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var showsComposer: Bool {
|
||||
if selectedItem == .agent { return false }
|
||||
if case .settings = selectedItem {
|
||||
return false
|
||||
}
|
||||
@@ -617,7 +624,7 @@ final class SybilViewModel {
|
||||
switch selectedItem {
|
||||
case .chat, .search:
|
||||
return true
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -633,6 +640,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func reconnect() async {
|
||||
agent.disconnect()
|
||||
setSidebarSearchQuery("")
|
||||
isCheckingSession = true
|
||||
authError = nil
|
||||
@@ -671,6 +679,10 @@ final class SybilViewModel {
|
||||
)
|
||||
|
||||
await loadInitialData(using: client)
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL))
|
||||
agent.setActive(isAppActive)
|
||||
}
|
||||
startActiveRunPolling()
|
||||
} catch {
|
||||
stopActiveRunPolling()
|
||||
@@ -981,7 +993,7 @@ final class SybilViewModel {
|
||||
selectedChat = nil
|
||||
composerAttachments = []
|
||||
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
return nil
|
||||
@@ -1019,6 +1031,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func deleteItem(_ selection: SidebarSelection) async {
|
||||
guard selection != .agent else { return }
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
@@ -1031,7 +1044,7 @@ final class SybilViewModel {
|
||||
try await client.deleteChat(chatID: chatID)
|
||||
case let .search(searchID):
|
||||
try await client.deleteSearch(searchID: searchID)
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
break
|
||||
}
|
||||
await refreshCollections(preferredSelection: nil)
|
||||
@@ -1099,6 +1112,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
||||
guard selection != .agent else { return }
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
@@ -1116,7 +1130,7 @@ final class SybilViewModel {
|
||||
case let .search(searchID):
|
||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||
applySearchSummary(updated, moveToFront: false)
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
@@ -1139,12 +1153,14 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
isAppActive = false
|
||||
agent.setActive(false)
|
||||
appLifecycleGeneration += 1
|
||||
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
||||
}
|
||||
|
||||
func markAppActiveForNetwork() {
|
||||
isAppActive = true
|
||||
agent.setActive(true)
|
||||
}
|
||||
|
||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
@@ -1472,7 +1488,7 @@ final class SybilViewModel {
|
||||
|
||||
if let nextSelection {
|
||||
switch nextSelection {
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
case .chat, .search:
|
||||
@@ -1783,7 +1799,7 @@ final class SybilViewModel {
|
||||
await self?.attachToActiveSearchStream(searchID: searchID)
|
||||
}
|
||||
|
||||
case .settings, nil:
|
||||
case .settings, .agent, nil:
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1992,12 +2008,13 @@ final class SybilViewModel {
|
||||
return selectedChat?.id != chatID
|
||||
case let .search(searchID):
|
||||
return selectedSearch?.id != searchID
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSelectionIfNeeded() async {
|
||||
if selectedItem == .agent { isLoadingSelection = false; return }
|
||||
guard let target = selectedItem else {
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
@@ -2039,7 +2056,7 @@ final class SybilViewModel {
|
||||
selectedSearch = search
|
||||
selectedChat = nil
|
||||
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
break
|
||||
}
|
||||
errorMessage = nil
|
||||
@@ -2755,7 +2772,7 @@ final class SybilViewModel {
|
||||
return chats.contains(where: { $0.id == chatID })
|
||||
case let .search(searchID):
|
||||
return searches.contains(where: { $0.id == searchID })
|
||||
case .settings:
|
||||
case .settings, .agent:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ struct SybilWorkspaceView: View {
|
||||
if isSettingsSelected {
|
||||
SybilSettingsView(viewModel: viewModel)
|
||||
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.selectedItem == .agent {
|
||||
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.isSearchMode {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.displayedSearch,
|
||||
@@ -285,7 +287,7 @@ struct SybilWorkspaceView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
|
||||
Text(selectedProviderModelSubtitle)
|
||||
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
|
||||
.font(.sybil(size: 10, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
@@ -507,7 +509,7 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationTrailingControl: some View {
|
||||
if isSettingsSelected {
|
||||
if isSettingsSelected || viewModel.selectedItem == .agent {
|
||||
EmptyView()
|
||||
} else if viewModel.isSearchMode {
|
||||
searchModeNavigationLabel
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user