feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
@@ -38,6 +38,12 @@ private actor TestAgentClient: AgentAPIClienting {
var requests: [String] = []
var submissions: [(String, String)] = []
var read = "0"
var readSource: String?
var readWrites: [(cursor: String, source: String?)] = []
var writeReply: AgentReadState?
var writeConflict: Bool?
var holdWrite = false
var heldWrite: CheckedContinuation<Void, Never>?
var failRead = false
var failSubmit = false
var malformedReceipt = false
@@ -56,16 +62,34 @@ private actor TestAgentClient: AgentAPIClienting {
}
func agentReadState() async -> AgentReadState {
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
return AgentReadState(cursor: read)
return AgentReadState(cursor: read, sourceId: readSource)
}
func pauseRead() { holdRead = true }
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); 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 agentMarkRead(cursor: String) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor, sourceID: nil)
}
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
readWrites.append((cursor, sourceID))
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
if let coded = writeConflict {
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
}
if failRead { throw AgentFailure(message: "Offline") }
if let writeReply { return writeReply }
read = try AgentCursor.advance(read, cursor)
return AgentReadState(cursor: read, sourceId: readSource)
}
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
func setWriteConflict(coded: Bool) { writeConflict = coded }
func pauseWrite() { holdWrite = true }
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
func hasHeldWrite() -> Bool { heldWrite != nil }
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append((content, requestID))
@@ -420,6 +444,89 @@ struct AgentTests {
#expect(await eventually { room.connection == "Connecting" })
#expect(room.messages.count == 2)
}
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
let sourceB = "22222222-2222-4222-8222-222222222222"
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90"))
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
for _ in 0..<100 {
if await client.hasHeldWrite() { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(await client.hasHeldWrite())
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
#expect(room.requiresReload && room.connection == "Reload required")
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
try await Task.sleep(for: .milliseconds(50))
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
#expect(await client.writtenReads().first?.source == nil)
#expect(room.messages.map(\.id) == ["old"])
// Explicit reconnect resets cursor space before discovering B or loading
// its history. The old queued100 can never be sent under sourceB.
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
room.retryConnection()
#expect(await eventually { room.connection == "Connected" })
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
#expect(room.messages.map(\.id) == ["new"])
room.markRead(renderedCursor: "1")
#expect(await eventually { room.snapshot.room.readCursor == "1" })
let writes = await client.writtenReads()
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
}
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
let room = SybilAgentRoom(); defer { room.stop() }
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
#expect(throws: AgentFailure.self) {
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
}
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
}
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
let sourceA = "11111111-1111-4111-8111-111111111111"
let sourceB = "22222222-2222-4222-8222-222222222222"
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; room.markRead(renderedCursor: "100")
#expect(await eventually { room.requiresReload })
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
try await Task.sleep(for: .milliseconds(30))
#expect(room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
#expect(await client.writtenReads().first?.source == sourceA)
room.stop()
}
}
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
for coded in [false, true] {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
await client.setWriteConflict(coded: coded)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; room.markRead(renderedCursor: "100")
#expect(await eventually { room.requiresReload })
room.markRead(renderedCursor: "100")
#expect(room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
room.stop()
}
}
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
let client = TestAgentClient(), memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)