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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user