diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 97fe5cf977acc..d33f14d12afe2 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -199,7 +199,7 @@ const widgetsPlugin: NonNullable[number] = [ supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], // Embeds the layout so the gallery preview and a new widget render // before the app has run once; the app replaces it on publish. - initialLayout: "./src/widgets/AgentActivityWidget.tsx", + initialLayout: "./src/widgets/AgentActivity.tsx", }, }, ], @@ -450,7 +450,9 @@ const config: ExpoConfig = { // Compile out the unused pedometer to avoid an iOS motion purpose string. ["expo-sensors", { motionPermission: false }], // Mods run in reverse order; expo-widgets must create and wipe the target before assets are added. - ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), + ...(!isIosPersonalTeamBuild + ? ["./plugins/withWidgetLogoAsset.cjs", "./plugins/withAgentWidgetRefresh.cjs", widgetsPlugin] + : []), "./plugins/withAndroidNetworkSecurityConfig.cjs", "./plugins/withAndroidGradleHeap.cjs", "./plugins/withAndroidInputBackground.cjs", diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js index 7e684229320a2..06a8da00868d8 100644 --- a/apps/mobile/fingerprint.config.js +++ b/apps/mobile/fingerprint.config.js @@ -14,5 +14,13 @@ if (!majorVersion) { module.exports = { nativeModuleSourceType: "files", - extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], + extraSources: [ + { type: "contents", id: "appMajorVersion", contents: majorVersion }, + ...[ + "plugins/withAgentWidgetRefresh.cjs", + "plugins/widget/AgentWidgetTimelineProvider.swift", + "plugins/widget/AgentWidgetState.swift", + "modules/t3-native-controls/ios/AgentWidgetCredential.swift", + ].map((filePath) => ({ type: "file", filePath, reasons: ["agentWidgetRefresh"] })), + ], }; diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt index 7f4d8c0e691ae..f2a1f9eacce5a 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -94,9 +94,11 @@ internal class ActivityPresentation(data: Map, private val activ ?: rows.count { ActivityPhase.forStatus(it.status)?.finished != true } private val attentionCount = data["activity_attention_count"]?.toIntOrNull()?.coerceAtLeast(0) ?: rows.count { ActivityPhase.forStatus(it.status)?.needsUser == true } + private val workingCount = data["activity_working_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: (activeCount - attentionCount).coerceAtLeast(0) private val failedCount = rows.count { it.status == ActivityPhase.FAILED.status } - val threadCount = - activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true } + val threadCount = data["activity_total_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: (activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true }) private val singleProject = rows.map { it.project }.distinct().size == 1 private val legacyBody = (0..4).mapNotNull { data["activity_line_$it"]?.take(300) } .takeIf { it.isNotEmpty() }?.joinToString("\n") @@ -109,7 +111,8 @@ internal class ActivityPresentation(data: Map, private val activ attentionCount == 1 -> "1 needs you" attentionCount > 1 -> "$attentionCount need you" activeCount > 0 && failedCount > 0 -> "$failedCount failed" - activeCount > 0 -> "$activeCount working" + workingCount > 0 -> "$workingCount working" + activeCount > 0 -> "$activeCount active" failedCount > 0 -> "Finished, $failedCount failed" else -> "All finished" } @@ -136,7 +139,11 @@ internal class ActivityPresentation(data: Map, private val activ builder.setSubText( listOfNotNull( hero!!.project.takeIf { singleProject && it.isNotBlank() }, - if (activeCount > 0) "$activeCount active" else "$threadCount threads" + when { + workingCount > 0 -> "$workingCount working" + activeCount > 0 -> "$activeCount active" + else -> "$threadCount threads" + } ).joinToString(" · ") ) } diff --git a/apps/mobile/modules/t3-native-controls/ios/AgentWidgetCredential.swift b/apps/mobile/modules/t3-native-controls/ios/AgentWidgetCredential.swift new file mode 100644 index 0000000000000..5139e6a8ceb37 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/AgentWidgetCredential.swift @@ -0,0 +1,105 @@ +import Foundation +import Security +import CryptoKit + +// Both targets already belong to this app group, which is also a Keychain +// access group. Keep the bearer credential out of preferences and backups. +enum AgentWidgetCredential { + private static func query(account: String) -> [String: Any]? { + guard let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String else { return nil } + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "t3-agent-widget", + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: group, + ] + } + + enum Lookup: Equatable { + case available(String) + case missing + case unavailable + } + + static func read(account: String = "read-capability") -> String? { + guard case let .available(token) = lookup(account: account) else { return nil } + return token + } + + static func lookup(account: String = "read-capability") -> Lookup { + guard var query = query(account: account) else { return .unavailable } + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return .missing } + guard status == errSecSuccess, let data = result as? Data, + let token = String(data: data, encoding: .utf8) else { return .unavailable } + return .available(token) + } + + static func store(_ token: String, account: String = "read-capability") -> Bool { + guard var query = query(account: account) else { return false } + query[kSecValueData as String] = Data(token.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + var status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem, let base = self.query(account: account) { + status = SecItemUpdate(base as CFDictionary, [ + kSecValueData as String: Data(token.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] as CFDictionary) + } + return status == errSecSuccess + } + + static func remove(account: String = "read-capability") { + guard let query = query(account: account) else { return } + SecItemDelete(query as CFDictionary) + } + + static func preserveRevocation(_ request: URLRequest) -> Bool { + guard let revocation = Revocation(request: request), + let data = try? JSONEncoder().encode(revocation), + let value = String(data: data, encoding: .utf8) else { return false } + return store(value, account: revocation.account) + } + + static func pendingRevocations() -> [Revocation] { + guard var query = query(account: "") else { return [] } + query.removeValue(forKey: kSecAttrAccount as String) + query[kSecReturnAttributes as String] = true + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitAll + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let items = result as? [[String: Any]] else { return [] } + return items.compactMap { item in + guard (item[kSecAttrAccount as String] as? String)?.hasPrefix("revoke-") == true, + let data = item[kSecValueData as String] as? Data else { return nil } + return try? JSONDecoder().decode(Revocation.self, from: data) + } + } + + struct Revocation: Codable { + let url: URL + let token: String + + init?(request: URLRequest) { + guard let url = request.url, let header = request.value(forHTTPHeaderField: "Authorization"), + header.hasPrefix("Bearer ") else { return nil } + self.url = url + self.token = String(header.dropFirst(7)) + } + + var account: String { + "revoke-" + SHA256.hash(data: Data((url.absoluteString + "\n" + token).utf8)).map { String(format: "%02x", $0) }.joined() + } + + var request: URLRequest { + var request = URLRequest(url: url, timeoutInterval: 10) + request.httpMethod = "DELETE" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/AgentWidgetStorage.swift b/apps/mobile/modules/t3-native-controls/ios/AgentWidgetStorage.swift new file mode 100644 index 0000000000000..77f08723d14e4 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/AgentWidgetStorage.swift @@ -0,0 +1,168 @@ +import Foundation +import Darwin + +enum AgentWidgetStorage { + static let timelineKey = "__expo_widgets_AgentActivity_timeline" + static let generationKey = "t3_agent_widget_generation" + + static func withLock(_ body: (UserDefaults) -> T) -> T? { + guard let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String, + let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group), + let defaults = UserDefaults(suiteName: group) else { return nil } + return withLock(at: directory.appendingPathComponent("agent-widget.lock"), defaults: defaults, body) + } + + // App and extension must synchronize preferences inside the same OS lock. + static func withLock(at url: URL, defaults: UserDefaults, _ body: (UserDefaults) -> T) -> T? { + let fd = open(url.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return nil } + defer { close(fd) } + guard flock(fd, LOCK_EX) == 0 else { return nil } + defer { flock(fd, LOCK_UN) } + defaults.synchronize() + defer { defaults.synchronize() } + return body(defaults) + } + + static func generation(in defaults: UserDefaults) -> String { + if let generation = defaults.string(forKey: generationKey) { return generation } + let generation = UUID().uuidString + defaults.set(generation, forKey: generationKey) + return generation + } + + static func owns(_ props: [String: Any]?, in defaults: UserDefaults) -> Bool { + guard let owner = props?[generationKey] as? String else { return false } + return owner == defaults.string(forKey: generationKey) + } + + @discardableResult + static func commit(_ entries: [[String: Any]], generation: String, requestId: String? = nil, in defaults: UserDefaults) -> Bool { + guard defaults.string(forKey: generationKey) == generation else { return false } + if let requestId, defaults.string(forKey: "t3_agent_widget_request") != requestId { return false } + if requestId == nil { _ = beginRefresh(in: defaults) } + defaults.set(entries.map { entry in + var entry = entry + var props = (entry["props"] as? [String: Any] ?? [:]).filter { !($0.value is NSNull) } + props[generationKey] = generation + entry["props"] = props + return entry + }, forKey: timelineKey) + return true + } + + static func retryRevocations() { + let pending: [AgentWidgetCredential.Revocation] = withLock { defaults in + prepareRevocations(in: defaults) + return claimRevocations(AgentWidgetCredential.pendingRevocations(), in: defaults, now: Date()) + } ?? [] + for revocation in pending { + URLSession.shared.dataTask(with: revocation.request) { _, response, error in + _ = withLock { defaults in + finishRevocation(account: revocation.account, in: defaults) + if error == nil, (response as? HTTPURLResponse)?.statusCode == 200 { + AgentWidgetCredential.remove(account: revocation.account) + } + } + }.resume() + } + } + + static func prepareRelay(url: String, in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup = { AgentWidgetCredential.lookup() }, storeRevocation: (URLRequest) -> Bool = AgentWidgetCredential.preserveRevocation) -> Bool { + guard let previous = defaults.string(forKey: "t3_agent_widget_url"), previous != url else { return true } + clearStoredState(in: defaults, readCredential: readCredential, storeRevocation: storeRevocation) + return canCreateCredential(in: defaults, credential: readCredential()) + } + + static func prepareRevocations(in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup = { AgentWidgetCredential.lookup() }, storeRevocation: (URLRequest) -> Bool = AgentWidgetCredential.preserveRevocation) { + if defaults.string(forKey: "t3_agent_widget_identity") == nil, defaults.string(forKey: "t3_agent_widget_url") != nil { + clearCredential(in: defaults, readCredential: readCredential, storeRevocation: storeRevocation) + } + } + + static func claimRevocations(_ pending: [AgentWidgetCredential.Revocation], in defaults: UserDefaults, now: Date) -> [AgentWidgetCredential.Revocation] { + var claims = defaults.dictionary(forKey: "t3_agent_widget_revocation_claims") as? [String: Double] ?? [:] + claims = claims.filter { now.timeIntervalSince1970 - $0.value < 30 } + let claimed = pending.filter { claims[$0.account] == nil } + for revocation in claimed { claims[revocation.account] = now.timeIntervalSince1970 } + defaults.set(claims, forKey: "t3_agent_widget_revocation_claims") + return claimed + } + + static func finishRevocation(account: String, in defaults: UserDefaults) { + var claims = defaults.dictionary(forKey: "t3_agent_widget_revocation_claims") as? [String: Double] ?? [:] + claims.removeValue(forKey: account) + defaults.set(claims, forKey: "t3_agent_widget_revocation_claims") + } + + static func canCreateCredential(in defaults: UserDefaults, credential: AgentWidgetCredential.Lookup) -> Bool { + credential == .missing && defaults.string(forKey: "t3_agent_widget_url") == nil && defaults.string(forKey: "t3_agent_widget_token") == nil + } + + static func observe(_ props: String, entries: [[String: Any]], in defaults: UserDefaults) -> Bool { + let previous = defaults.string(forKey: "t3_agent_widget_local_observation") + defaults.set(props, forKey: "t3_agent_widget_local_observation") + let storedEntries = defaults.array(forKey: timelineKey) as? [[String: Any]] + let stored = storedEntries?.first?["props"] as? [String: Any] + if let previous, let old = presentation(previous), let current = presentation(props), old.isEqual(current), + let stored, let storedEntries, let next = entries.first?["props"] as? [String: Any], presentation(stored).isEqual(presentation(next)), + storedEntries.dropFirst().map({ $0["timestamp"] as? Int }) == entries.dropFirst().map({ $0["timestamp"] as? Int }) { + return false + } + return commit(entries, generation: generation(in: defaults), in: defaults) + } + + private static func presentation(_ props: String) -> NSDictionary? { + guard let data = props.data(using: .utf8), + let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return presentation(value) + } + + private static func presentation(_ props: [String: Any]) -> NSDictionary { + var value = props.filter { !($0.value is NSNull) } + for key in ["observedActivities", "updatedAt", generationKey] { value.removeValue(forKey: key) } + if let deadline = value["expiresAt"] as? NSNumber { value["expiresAt"] = floor(deadline.doubleValue / 60_000) } + if let rows = value["activities"] as? [[String: Any]] { + value["activities"] = rows.map { $0.filter { $0.key != "updatedAt" } } + } + return value as NSDictionary + } + + static func beginRefresh(in defaults: UserDefaults) -> String { + let requestId = UUID().uuidString + defaults.set(requestId, forKey: "t3_agent_widget_request") + return requestId + } + + @discardableResult + static func clearStoredState(in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup = { AgentWidgetCredential.lookup() }, storeRevocation: (URLRequest) -> Bool = AgentWidgetCredential.preserveRevocation) -> URLRequest? { + defaults.set(UUID().uuidString, forKey: AgentWidgetStorage.generationKey) + for key in ["t3_agent_widget_identity", "t3_agent_widget_local_observation", AgentWidgetStorage.timelineKey] { + defaults.removeObject(forKey: key) + } + return clearCredential(in: defaults, readCredential: readCredential, storeRevocation: storeRevocation) + } + + @discardableResult + private static func clearCredential(in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup, storeRevocation: (URLRequest) -> Bool) -> URLRequest? { + let url = defaults.string(forKey: "t3_agent_widget_url").flatMap(URL.init(string:)) + // Revoke the pre-Keychain development credential when upgrading this branch. + let credential = readCredential() + var tokens = Set() + if case let .available(token) = credential { tokens.insert(token) } + if let legacy = defaults.string(forKey: "t3_agent_widget_token") { tokens.insert(legacy) } + guard credential != .unavailable else { return nil } + var request: URLRequest? + for token in tokens { + guard let url else { continue } + var revoke = URLRequest(url: url, timeoutInterval: 10) + revoke.httpMethod = "DELETE" + revoke.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + guard storeRevocation(revoke) else { return nil } + request = revoke + } + AgentWidgetCredential.remove() + for key in ["t3_agent_widget_token", "t3_agent_widget_url"] { defaults.removeObject(forKey: key) } + return request + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3AgentWidgetConfiguration.swift b/apps/mobile/modules/t3-native-controls/ios/T3AgentWidgetConfiguration.swift new file mode 100644 index 0000000000000..ba470fefb0105 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3AgentWidgetConfiguration.swift @@ -0,0 +1,58 @@ +import Foundation +import Security +import WidgetKit + +// Shared with the WidgetKit extension, which runs independently of React Native. +enum T3AgentWidgetConfiguration { + static func token(identity: String, url: String? = nil) -> String? { + let token = AgentWidgetStorage.withLock { defaults -> String? in + if let url, !AgentWidgetStorage.prepareRelay(url: url, in: defaults) { return nil } + if defaults.string(forKey: "t3_agent_widget_identity") == identity, + let token = AgentWidgetCredential.read(), + !AgentWidgetCredential.pendingRevocations().contains(where: { $0.token == token }) { + if let url { defaults.set(url, forKey: "t3_agent_widget_url") } + return token + } + _ = AgentWidgetStorage.clearStoredState(in: defaults) + guard AgentWidgetStorage.canCreateCredential(in: defaults, credential: AgentWidgetCredential.lookup()) else { return nil } + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return nil } + let token = bytes.map { String(format: "%02x", $0) }.joined() + guard AgentWidgetCredential.store(token) else { return nil } + defaults.set(identity, forKey: "t3_agent_widget_identity") + if let url { defaults.set(url, forKey: "t3_agent_widget_url") } + return token + }.flatMap { $0 } + AgentWidgetStorage.retryRevocations() + return token + } + + static func configure(url: String, token: String) { + _ = AgentWidgetStorage.withLock { defaults in + guard defaults.string(forKey: "t3_agent_widget_identity") != nil, + AgentWidgetCredential.read() == token, + defaults.string(forKey: "t3_agent_widget_url") == nil || defaults.string(forKey: "t3_agent_widget_url") == url else { return } + defaults.set(url, forKey: "t3_agent_widget_url") + } + AgentWidgetStorage.retryRevocations() + WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") + } + + static func observe(props: String, timeline: String) -> Bool { + guard let data = timeline.data(using: .utf8), + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return false } + let stored = AgentWidgetStorage.withLock { defaults in + AgentWidgetStorage.observe(props, entries: entries, in: defaults) + } + AgentWidgetStorage.retryRevocations() + if stored == true { WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") } + return stored != nil + } + + static func clear() { + _ = AgentWidgetStorage.withLock { defaults in AgentWidgetStorage.clearStoredState(in: defaults) } + WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") + AgentWidgetStorage.retryRevocations() + } + +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 5b3d84aedcee3..f8d74bfa72312 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -51,6 +51,10 @@ public final class T3NativeControlsModule: Module { ViewName("LayoutMetrics") Events("onMetricsChange") } + Function("observeAgentWidget") { (props: String, timeline: String) in T3AgentWidgetConfiguration.observe(props: props, timeline: timeline) } + Function("agentWidgetToken") { (identity: String, url: String?) in T3AgentWidgetConfiguration.token(identity: identity, url: url) } + Function("configureAgentWidgetRefresh") { (url: String, token: String) in T3AgentWidgetConfiguration.configure(url: url, token: token) } + Function("clearAgentWidgetRefresh") { T3AgentWidgetConfiguration.clear() } View(T3ScrollEdgeBleedView.self) { ViewName("ScrollEdgeBleed") diff --git a/apps/mobile/plugins/widget/AgentWidgetState.swift b/apps/mobile/plugins/widget/AgentWidgetState.swift new file mode 100644 index 0000000000000..9aeab08914853 --- /dev/null +++ b/apps/mobile/plugins/widget/AgentWidgetState.swift @@ -0,0 +1,119 @@ +import Foundation + +// A confirmed relay read renews relay observations. Direct-only rows keep the +// app's original deadline; an empty relay never claims that they finished. +enum AgentWidgetState { + static func timeline(aggregate: [String: Any]?, environmentIds: [String], localObservation: [String: Any]?, now: Date) -> [[String: Any]] { + let freshnessDeadline = now.addingTimeInterval(60 * 60) + var base = aggregate ?? [ + "title": "T3 Code", "subtitle": "No active agents", "activeCount": 0, + "updatedAt": ISO8601DateFormatter().string(from: now), "activities": [[String: Any]]() + ] + base = base.filter { !($0.value is NSNull) } + base["isExpired"] = false + base["isStale"] = false + base["expiresAt"] = freshnessDeadline.timeIntervalSince1970 * 1000 + let relayRows = base["activities"] as? [[String: Any]] ?? [] + let allLocalRows = localObservation?["activities"] as? [[String: Any]] ?? [] + let observedActivities = localObservation?["observedActivities"] as? [String: [[String: Any]]] + let sourceRows = observedActivities.map { observations in + observations.flatMap { environmentId, rows in + rows.map { row in + var row = row + row["environmentId"] = environmentId + return row + } + } + } ?? allLocalRows + let localTotal = localObservation?["totalCount"] as? Int + let localCount = localObservation?["activeCount"] as? Int + let localComplete = observedActivities != nil || (localTotal == allLocalRows.count && localCount == allLocalRows.filter { isUnfinished($0) }.count) + let relayComplete = aggregate == nil || (base["totalCount"] as? Int == relayRows.count) + let observedScope = observedActivities.map { Set($0.keys) } ?? Set(localObservation?["observedEnvironmentIds"] as? [String] ?? []) + .union(allLocalRows.compactMap { $0["environmentId"] as? String }) + let retainedScope = observedScope.filter { environmentId in + let rows = sourceRows.filter { $0["environmentId"] as? String == environmentId } + guard localComplete else { return true } + if rows.isEmpty { return !relayComplete } + return !rows.allSatisfy { row in + relayRows.contains { remote in + guard remote["environmentId"] as? String == environmentId, + let threadId = row["threadId"] as? String, remote["threadId"] as? String == threadId, + let updatedAt = row["updatedAt"] as? String, let remoteTime = remote["updatedAt"] as? String else { return false } + return remoteTime > updatedAt || (remoteTime == updatedAt && remote["phase"] as? String == row["phase"] as? String) + } || (relayComplete && !isUnfinished(row) && !relayRows.contains { + $0["environmentId"] as? String == environmentId && $0["threadId"] as? String == row["threadId"] as? String + }) + } + } + let localRows = allLocalRows.filter { retainedScope.contains($0["environmentId"] as? String ?? "") } + let localSourceRows = sourceRows.filter { retainedScope.contains($0["environmentId"] as? String ?? "") } + let remoteRows = relayRows.filter { !retainedScope.contains($0["environmentId"] as? String ?? "") } + let countUnavailable = !retainedScope.isEmpty && !localComplete + let overlappingScope = !retainedScope.isDisjoint(with: Set(environmentIds).union(relayRows.compactMap { $0["environmentId"] as? String })) + let remoteCountComplete = !overlappingScope || aggregate == nil || base["activeCount"] as? Int == relayRows.filter { isUnfinished($0) }.count + let localDeadline = (localObservation?["expiresAt"] as? NSNumber).map { + Date(timeIntervalSince1970: $0.doubleValue / 1000) + } + func merged(at date: Date) -> [String: Any] { + var props = base + props["isStale"] = !retainedScope.isEmpty && localObservation?["isStale"] as? Bool == true + let directRows = localRows.map { row in + localDeadline.map { $0 > date } == true ? row : stale(row) + } + props["activities"] = remoteRows + directRows + let directSourceRows = localSourceRows.map { row in + localDeadline.map { $0 > date } == true ? row : stale(row) + } + let unavailable = directSourceRows.contains { $0["phase"] as? String == "stale" } + let directCount = directSourceRows.filter { isUnfinished($0) }.count + props["activeCount"] = unavailable || countUnavailable || !remoteCountComplete ? -1 : (!overlappingScope ? (base["activeCount"] as? Int ?? 0) : remoteRows.filter { isUnfinished($0) }.count) + directCount + for key in ["workingCount", "attentionCount", "totalCount"] { + let total = base[key] as? Int + let matches: ([String: Any]) -> Bool = { row in + let phase = row["phase"] as? String + if key == "workingCount" { return phase == "running" || phase == "starting" } + if key == "attentionCount" { return phase == "waiting_for_input" || phase == "waiting_for_approval" } + return true + } + if !unavailable, !countUnavailable, let total, total >= 0, !overlappingScope || relayComplete { + props[key] = (!overlappingScope ? total : remoteRows.filter(matches).count) + directSourceRows.filter(matches).count + } else { + props.removeValue(forKey: key) + } + } + return props + } + var entries = [entry(at: now, props: merged(at: now))] + if let localDeadline, localDeadline > now, localDeadline < freshnessDeadline, localSourceRows.contains(where: isUnfinished) { + entries.append(entry(at: localDeadline, props: merged(at: localDeadline))) + } + var expired = merged(at: freshnessDeadline) + expired["activities"] = (expired["activities"] as? [[String: Any]] ?? []).map(stale) + expired["activeCount"] = -1 + expired.removeValue(forKey: "workingCount") + expired.removeValue(forKey: "attentionCount") + expired["isExpired"] = true + expired["subtitle"] = "Open T3 to refresh" + entries.append(entry(at: freshnessDeadline, props: expired)) + return entries + } + + private static func isUnfinished(_ row: [String: Any]) -> Bool { + let phase = row["phase"] as? String + return phase != "completed" && phase != "failed" && phase != "stale" + } + + private static func stale(_ row: [String: Any]) -> [String: Any] { + let phase = row["phase"] as? String + guard phase != "completed" && phase != "failed" else { return row } + var result = row + result["phase"] = "stale" + result["status"] = "Out of date" + return result + } + + private static func entry(at date: Date, props: [String: Any]) -> [String: Any] { + ["timestamp": Int(date.timeIntervalSince1970 * 1000), "props": props] + } +} diff --git a/apps/mobile/plugins/widget/AgentWidgetTimelineProvider.swift b/apps/mobile/plugins/widget/AgentWidgetTimelineProvider.swift new file mode 100644 index 0000000000000..96b79eec6e2fd --- /dev/null +++ b/apps/mobile/plugins/widget/AgentWidgetTimelineProvider.swift @@ -0,0 +1,130 @@ +import Foundation +import WidgetKit +import SwiftUI +internal import ExpoWidgets + +// Fetch from the widget extension, not the suspended app's JS runtime. The +// capability can only read this install's owner's linked agent activity. +struct AgentWidgetTimelineProvider: TimelineProvider { + struct Entry: TimelineEntry { + let cached: WidgetsTimelineEntry + // Placeholders carry only the embedded layout's props, never account data. + var isPlaceholder = false + var date: Date { cached.date } + } + private let cached = WidgetsTimelineProvider(name: "AgentActivity") + + func placeholder(in context: Context) -> Entry { Entry(cached: cached.placeholder(in: context), isPlaceholder: true) } + func getSnapshot(in context: Context, completion: @escaping @Sendable (Entry) -> Void) { + cached.getTimeline(in: context) { timeline in + let entry = timeline.entries.last(where: { entry in + entry.date <= Date() && AgentWidgetStorage.withLock { AgentWidgetStorage.owns(entry.props, in: $0) } == true + }) + completion(entry.map { Entry(cached: $0) } ?? placeholder(in: context)) + } + } + + func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline) -> Void) { + AgentWidgetNetwork.refresh { _ in + cached.getTimeline(in: context) { timeline in + let entries = timeline.entries.filter { entry in + AgentWidgetStorage.withLock { AgentWidgetStorage.owns(entry.props, in: $0) } == true + }.map { Entry(cached: $0) } + // Ask for another read even if no agent state changes. WidgetKit controls + // the actual schedule; pushes complement this, they don't replace it. + completion(Timeline( + entries: entries.isEmpty ? [placeholder(in: context)] : entries, + policy: .after(Date().addingTimeInterval(5 * 60)) + )) + } + } + } +} + +struct AgentWidgetEntryView: View { + let entry: AgentWidgetTimelineProvider.Entry + + var body: some View { + // WidgetKit may retain an entry already handed out before account cleanup. + if entry.isPlaceholder || AgentWidgetStorage.withLock({ AgentWidgetStorage.owns(entry.cached.props, in: $0) }) == true { + WidgetsEntryView(entry: entry.cached) + } else { + ZStack { + EmptyWidgetView() + VStack(alignment: .leading, spacing: 4) { + Text("T3 Code").font(.headline) + Text("Open T3 to refresh").font(.caption).foregroundStyle(.secondary) + } + .padding() + } + } + } +} + +enum AgentWidgetNetwork { + struct Configuration { + let url: String + let token: String + let generation: String + let pushToken: String? + let requestId: String + let startedAt: Date + } + + static func refresh(completion: @escaping @Sendable (Bool) -> Void) { + AgentWidgetStorage.retryRevocations() + let configuration = AgentWidgetStorage.withLock { defaults -> Configuration? in + guard defaults.string(forKey: "t3_agent_widget_identity") != nil, + let rawURL = defaults.string(forKey: "t3_agent_widget_url"), + let token = AgentWidgetCredential.read() else { return nil } + return Configuration(url: rawURL, token: token, generation: AgentWidgetStorage.generation(in: defaults), pushToken: defaults.string(forKey: "t3_agent_widget_push_token"), requestId: AgentWidgetStorage.beginRefresh(in: defaults), startedAt: Date()) + }.flatMap { $0 } + guard let configuration, + var components = URLComponents(string: configuration.url) else { completion(false); return } + let rawURL = configuration.url + let token = configuration.token + let generation = configuration.generation + if let pushToken = configuration.pushToken { + components.queryItems = [URLQueryItem(name: "pushToken", value: pushToken)] + } + guard let url = components.url else { completion(false); return } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: request) { data, response, _ in + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let data, let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + body.keys.contains("aggregate") else { completion(false); return } + let aggregate = body["aggregate"] as? [String: Any] + guard aggregate != nil || body["aggregate"] is NSNull else { completion(false); return } + let stored = AgentWidgetStorage.withLock { defaults in + guard AgentWidgetCredential.read() == token, + defaults.string(forKey: "t3_agent_widget_url") == rawURL, + defaults.string(forKey: AgentWidgetStorage.generationKey) == generation else { return false } + let localObservation = defaults.string(forKey: "t3_agent_widget_local_observation").flatMap { + $0.data(using: .utf8).flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + } + let environmentIds = body["environmentIds"] as? [String] ?? (aggregate?["activities"] as? [[String: Any]] ?? []).compactMap { $0["environmentId"] as? String } + return AgentWidgetStorage.commit(AgentWidgetState.timeline( + aggregate: aggregate, environmentIds: environmentIds, + localObservation: localObservation, now: configuration.startedAt + ), generation: generation, requestId: configuration.requestId, in: defaults) + } ?? false + completion(stored) + }.resume() + } +} + +@available(iOS 26.0, *) +struct AgentWidgetPushHandler: WidgetPushHandler { + func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) { + let token = widgets.isEmpty ? "" : pushInfo.token.map { String(format: "%02x", $0) }.joined() + _ = AgentWidgetStorage.withLock { $0.set(token, forKey: "t3_agent_widget_push_token") } + AgentWidgetNetwork.refresh { _ in WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") } + } +} + +extension WidgetConfiguration { + func agentWidgetPushHandler() -> some WidgetConfiguration { + if #available(iOS 26.0, *) { return self.pushHandler(AgentWidgetPushHandler.self) } else { return self } + } +} diff --git a/apps/mobile/plugins/widget/tests/main.swift b/apps/mobile/plugins/widget/tests/main.swift new file mode 100644 index 0000000000000..f9f52ec10352f --- /dev/null +++ b/apps/mobile/plugins/widget/tests/main.swift @@ -0,0 +1,358 @@ +import Foundation + +if CommandLine.arguments.count == 5, CommandLine.arguments[1] == "--stale-refresh" { + let defaults = UserDefaults(suiteName: CommandLine.arguments[2])! + let lock = URL(fileURLWithPath: CommandLine.arguments[3]) + FileHandle.standardOutput.write(Data("ready\n".utf8)) + let committed = AgentWidgetStorage.withLock(at: lock, defaults: defaults) { + AgentWidgetStorage.commit([["timestamp": 0, "props": ["activities": ["old account"]]]], + generation: CommandLine.arguments[4], in: $0) + } + exit(committed == false ? 0 : 1) +} + +let now = Date(timeIntervalSince1970: 1000) +func row(_ environmentId: String, _ phase: String) -> [String: Any] { + ["environmentId": environmentId, "threadId": environmentId + "-" + phase, "updatedAt": "2026-01-01T00:00:00Z", "phase": phase, "status": phase, "threadTitle": "Test agent"] +} +func props(_ entry: [String: Any]) -> [String: Any] { entry["props"] as! [String: Any] } +let running: [String: Any] = ["activeCount": 1, "totalCount": 1, "updatedAt": "old unchanged observation", "activities": [row("relay", "running")]] +let local: [String: Any] = ["activeCount": 2, "totalCount": 3, "expiresAt": now.addingTimeInterval(600).timeIntervalSince1970 * 1000, + "activities": [row("relay", "running"), row("direct", "waiting_for_input"), row("direct", "failed")]] +let entries = AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: local, now: now) +assert(entries.count == 3) +assert(props(entries[0])["activeCount"] as? Int == 2) +let directExpired = props(entries[1])["activities"] as! [[String: Any]] +assert(directExpired.map { $0["phase"] as! String } == ["running", "stale", "failed"]) +assert(props(entries[1])["isExpired"] as? Bool == false) +assert(props(entries[1])["activeCount"] as? Int == -1) +let fullyExpired = props(entries[2])["activities"] as! [[String: Any]] +assert(fullyExpired.map { $0["phase"] as! String } == ["stale", "stale", "failed"]) +assert(props(entries[2])["isExpired"] as? Bool == true) +assert(entries[2]["timestamp"] as? Int == 4_600_000) + +// A successful unchanged read renews freshness without inventing a state update. +let renewed = AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: nil, now: now.addingTimeInterval(300)) +assert(renewed.last?["timestamp"] as? Int == 4_900_000) +assert(props(renewed[0])["updatedAt"] as? String == "old unchanged observation") + +// An empty relay cannot confirm that observed unfinished work ended. +let empty = AgentWidgetState.timeline(aggregate: nil, environmentIds: ["relay"], localObservation: local, now: now) +assert((props(empty[0])["activities"] as! [[String: Any]]).count == 3) +assert(props(empty[0])["activeCount"] as? Int == 2) +let terminals: [String: Any] = ["activeCount": 0, "optional": NSNull(), "activities": [row("relay", "completed"), row("relay", "failed")]] +let final = AgentWidgetState.timeline(aggregate: terminals, environmentIds: ["relay"], localObservation: nil, now: now) +assert((props(final.last!)["activities"] as! [[String: Any]]).map { $0["phase"] as! String } == ["completed", "failed"]) +assert(PropertyListSerialization.propertyList(final, isValidFor: .binary)) +print("AgentWidgetState: background renewal, source reconciliation, expiration, and native storage passed") + +// Native background reads can replace JS's last idle publication. Sign-out must +// remove that timeline even when JS would deduplicate its next idle publication. +let suite = "t3-widget-signout-test-\(UUID().uuidString)" +let defaults = UserDefaults(suiteName: suite)! +defer { defaults.removePersistentDomain(forName: suite) } +defaults.set("previous-account", forKey: "t3_agent_widget_identity") +defaults.set("test-capability", forKey: "t3_agent_widget_token") +defaults.set("https://relay.test/v1/widget/agent-activity", forKey: "t3_agent_widget_url") +defaults.set("previous rows", forKey: "t3_agent_widget_local_observation") +defaults.set(entries, forKey: "__expo_widgets_AgentActivity_timeline") +defaults.set("install-push-token", forKey: "t3_agent_widget_push_token") +var pendingRevocations = [URLRequest]() +defaults.set("previous-generation", forKey: "t3_agent_widget_generation") +let revocation = AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .missing }) { request in + // Persist before active authority is removed, so interruption cannot lose it. + assert(defaults.string(forKey: "t3_agent_widget_url") != nil) + pendingRevocations.append(request) + return true +} +let durableRevocation = pendingRevocations.first?.value(forHTTPHeaderField: "Authorization") == "Bearer test-capability" +let invalidatedGeneration = defaults.string(forKey: "t3_agent_widget_generation") != "previous-generation" +print("Revocation survives interrupted logout: \(durableRevocation ? "PASS" : "FAIL")") +print("Cached old-account entry loses ownership after logout: \(invalidatedGeneration ? "PASS" : "FAIL")") +assert(revocation?.httpMethod == "DELETE") +assert(revocation?.url?.absoluteString == "https://relay.test/v1/widget/agent-activity") +assert(revocation?.value(forHTTPHeaderField: "Authorization") == "Bearer test-capability") +for key in ["t3_agent_widget_identity", "t3_agent_widget_token", "t3_agent_widget_url", "t3_agent_widget_local_observation", "__expo_widgets_AgentActivity_timeline"] { + assert(defaults.object(forKey: key) == nil) +} +assert(defaults.string(forKey: "t3_agent_widget_push_token") == "install-push-token") +assert(AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .missing }) == nil) +print("Agent widget sign-out: cached rows removed and previous capability revocation prepared") + +let saved = AgentWidgetCredential.Revocation(request: pendingRevocations[0])! +let restored = try JSONDecoder().decode(AgentWidgetCredential.Revocation.self, from: JSONEncoder().encode(saved)) +assert(restored.request == revocation) +assert(restored.account == saved.account) +assert(!restored.account.contains("test-capability")) + +// Failed secure storage keeps disabled authority recoverable for a later cleanup. +defaults.set("next-account", forKey: "t3_agent_widget_identity") +defaults.set("test-capability", forKey: "t3_agent_widget_token") +defaults.set("https://relay.test/v1/widget/agent-activity", forKey: "t3_agent_widget_url") +assert(AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .missing }, storeRevocation: { _ in false }) == nil) +assert(defaults.string(forKey: "t3_agent_widget_identity") == nil) +assert(defaults.string(forKey: "t3_agent_widget_url") != nil) +assert(defaults.string(forKey: "t3_agent_widget_token") == "test-capability") +assert(!AgentWidgetStorage.canCreateCredential(in: defaults, credential: .missing)) +var retriedPreparation = false +AgentWidgetStorage.prepareRevocations(in: defaults, readCredential: { .missing }) { _ in retriedPreparation = true; return true } +assert(retriedPreparation) +assert(defaults.string(forKey: "t3_agent_widget_url") == nil) +print("Agent widget revocation: secure-write failure retained for retry; durable record round-trip passed") + +// The extension has captured its old owner before the app takes the shared lock. +let lock = FileManager.default.temporaryDirectory.appendingPathComponent("\(suite).lock") +defer { try? FileManager.default.removeItem(at: lock) } +let child = Process() +let ready = Pipe() +child.executableURL = URL(fileURLWithPath: CommandLine.arguments[0]) +child.standardOutput = ready +var previousProps: [String: Any]? +let transitioned = AgentWidgetStorage.withLock(at: lock, defaults: defaults) { defaults in + let generation = AgentWidgetStorage.generation(in: defaults) + assert(AgentWidgetStorage.commit(entries, generation: generation, in: defaults)) + previousProps = props((defaults.array(forKey: AgentWidgetStorage.timelineKey) as! [[String: Any]])[0]) + assert(AgentWidgetStorage.owns(previousProps, in: defaults)) + child.arguments = ["--stale-refresh", suite, lock.path, generation] + try! child.run() + assert(try! ready.fileHandleForReading.read(upToCount: 6) == Data("ready\n".utf8)) + AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .missing }) + defaults.set("new-account", forKey: "t3_agent_widget_identity") + return true +} +assert(transitioned == true) +child.waitUntilExit() +assert(child.terminationStatus == 0) +assert(defaults.array(forKey: AgentWidgetStorage.timelineKey) == nil) +assert(!AgentWidgetStorage.owns(previousProps, in: defaults)) +assert(!AgentWidgetStorage.owns(props(entries[0]), in: defaults)) +assert(AgentWidgetStorage.commit(entries, generation: AgentWidgetStorage.generation(in: defaults), in: defaults)) +print("Agent widget cross-process transition: stale commit rejected, retained and legacy entries rejected, new owner accepted") + +// Truncated local rows cannot establish the total outside relay coverage. +var truncated = local +truncated["activeCount"] = 20 +let truncatedEntries = AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: truncated, now: now) +assert(props(truncatedEntries[0])["activeCount"] as? Int == -1) +var unknown = local +unknown.removeValue(forKey: "activeCount") +assert(props(AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: unknown, now: now)[0])["activeCount"] as? Int == -1) + +// Capped relay rows can hide direct work whose environment is still unconfirmed. +let cappedObservation: [String: Any] = [ + "activeCount": 6, "workingCount": 1, "attentionCount": 5, "totalCount": 6, + "activities": (0..<5).map { _ in row("relay", "waiting_for_approval") }, + "observedEnvironmentIds": ["relay", "direct"] +] +let completedRelay: [String: Any] = [ + "activeCount": 0, "workingCount": 0, "attentionCount": 0, "totalCount": 5, + "activities": (0..<5).map { _ in row("relay", "completed") } +] +let cappedTimeline = AgentWidgetState.timeline(aggregate: completedRelay, environmentIds: ["relay"], localObservation: cappedObservation, now: now) +assert(props(cappedTimeline[0])["activeCount"] as? Int == -1) +assert(props(cappedTimeline[0])["workingCount"] == nil) +assert(props(cappedTimeline[0])["attentionCount"] == nil) +var cappedTerminalObservation = cappedObservation +cappedTerminalObservation["activeCount"] = 5 +cappedTerminalObservation["workingCount"] = 0 +let cappedTerminalTimeline = AgentWidgetState.timeline(aggregate: completedRelay, environmentIds: ["relay"], localObservation: cappedTerminalObservation, now: now) +assert(props(cappedTerminalTimeline[0])["activeCount"] as? Int == -1) +assert(props(cappedTerminalTimeline[0])["totalCount"] == nil) +let coveredCappedTimeline = AgentWidgetState.timeline(aggregate: completedRelay, environmentIds: ["relay", "direct"], localObservation: cappedObservation, now: now) +assert(props(coveredCappedTimeline[0])["activeCount"] as? Int == -1) + +// Fully relay-covered rows must not invalidate an authoritative relay count. +let coveredUnknown: [String: Any] = ["activeCount": 1, "totalCount": 1, "activities": [row("relay", "running")]] +let coveredTimeline = AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: coveredUnknown, now: now) +assert(props(coveredTimeline[0])["activeCount"] as? Int == 1) + +// Exact relay totals survive native background merges, including capped rows. +let counted: [String: Any] = ["activeCount": 12, "workingCount": 8, "attentionCount": 4, "totalCount": 20, "activities": [row("relay", "running")]] +var countedLocal = local +countedLocal["totalCount"] = 3 +let countedEntries = AgentWidgetState.timeline(aggregate: counted, environmentIds: ["relay"], localObservation: countedLocal, now: now) +assert(props(countedEntries[0])["workingCount"] as? Int == 8) +assert(props(countedEntries[0])["attentionCount"] as? Int == 5) +assert(props(countedEntries[0])["totalCount"] as? Int == 22) +assert(props(countedEntries[1])["workingCount"] == nil) +assert(props(countedEntries.last!)["attentionCount"] == nil) +print("Agent widget counts: working, attention, overflow, and stale merges passed") +assert(durableRevocation && invalidatedGeneration) + +// Direct-only JS observations carry unknown counts as JSON null. +let storageGeneration = AgentWidgetStorage.generation(in: defaults) +let unknownCounts: [[String: Any]] = [["timestamp": 0, "props": [ + "activeCount": -1, "workingCount": NSNull(), "attentionCount": NSNull(), + "totalCount": NSNull(), "activities": [row("direct", "running")] +]]] +assert(AgentWidgetStorage.commit(unknownCounts, generation: storageGeneration, in: defaults)) +let storedEntries = defaults.array(forKey: AgentWidgetStorage.timelineKey) as! [[String: Any]] +assert(PropertyListSerialization.propertyList(storedEntries, isValidFor: .binary)) +let storedProps = props(storedEntries[0]) +for key in ["workingCount", "attentionCount", "totalCount"] { assert(storedProps[key] == nil) } +assert(storedProps["activeCount"] as? Int == -1) +assert((storedProps["activities"] as? [[String: Any]])?.count == 1) +assert(AgentWidgetStorage.owns(storedProps, in: defaults)) +print("Unknown direct-only counts persist without invalid property-list nulls") + +// Links cannot acknowledge a newer direct approval or running observation. +for phase in ["running", "waiting_for_approval"] { + var direct = row("relay", phase) + direct["threadId"] = "same-thread" + direct["updatedAt"] = "2026-01-02T00:00:00Z" + var old = row("relay", "completed") + old["threadId"] = "same-thread" + let observed: [String: Any] = ["activeCount": 1, "totalCount": 1, "observedEnvironmentIds": ["relay"], "expiresAt": 1_600_000, "activities": [direct]] + let remote: [String: Any] = ["activeCount": 0, "totalCount": 1, "activities": [old]] + let result = AgentWidgetState.timeline(aggregate: remote, environmentIds: ["relay"], localObservation: observed, now: now) + assert((props(result[0])["activities"] as! [[String: Any]]).map { $0["phase"] as! String } == [phase]) + assert(props(result[0])["activeCount"] as? Int == 1) + assert(result[1]["timestamp"] as? Int == 1_600_000) +} +print("Unconfirmed linked observations survive older terminal and capped reads") + +let revocations = [saved] +assert(AgentWidgetStorage.claimRevocations(revocations, in: defaults, now: now).count == 1) +assert(AgentWidgetStorage.claimRevocations(revocations, in: defaults, now: now).isEmpty) +AgentWidgetStorage.finishRevocation(account: saved.account, in: defaults) +assert(AgentWidgetStorage.claimRevocations(revocations, in: defaults, now: now).count == 1) +assert(AgentWidgetStorage.claimRevocations(revocations, in: defaults, now: now.addingTimeInterval(31)).count == 1) +let firstRequest = AgentWidgetStorage.beginRefresh(in: defaults) +let newerRequest = AgentWidgetStorage.beginRefresh(in: defaults) +assert(AgentWidgetStorage.commit(entries, generation: storageGeneration, requestId: newerRequest, in: defaults)) +assert(!AgentWidgetStorage.commit(entries, generation: storageGeneration, requestId: firstRequest, in: defaults)) +print("Revocation retries deduplicate across shared storage; superseded reads cannot commit") + +// Relay rotation preserves the old destination before clearing its capability. +defaults.set("same-owner", forKey: "t3_agent_widget_identity") +defaults.set("old-capability", forKey: "t3_agent_widget_token") +defaults.set("https://old-relay.test/v1/widget/agent-activity", forKey: "t3_agent_widget_url") +var rotationRevocations = [URLRequest]() +assert(!AgentWidgetStorage.prepareRelay(url: "https://new-relay.test/v1/widget/agent-activity", in: defaults, readCredential: { .missing }, storeRevocation: { _ in false })) +assert(defaults.string(forKey: "t3_agent_widget_url") == "https://old-relay.test/v1/widget/agent-activity") +assert(defaults.string(forKey: "t3_agent_widget_token") == "old-capability") +assert(AgentWidgetStorage.prepareRelay(url: "https://new-relay.test/v1/widget/agent-activity", in: defaults, readCredential: { .missing }, storeRevocation: { request in + rotationRevocations.append(request) + return true +})) +assert(rotationRevocations.count == 1) +assert(rotationRevocations[0].url?.host == "old-relay.test") +assert(rotationRevocations[0].value(forHTTPHeaderField: "Authorization") == "Bearer old-capability") +assert(defaults.string(forKey: "t3_agent_widget_url") == nil) +assert(defaults.string(forKey: "t3_agent_widget_token") == nil) +assert(defaults.string(forKey: "t3_agent_widget_identity") == nil) +print("Relay URL rotation: failed preservation stays recoverable; old URL/token persist before new authority") +// Uncapped source rows let native refresh reconcile each environment independently. +var observedRow = row("relay", "running") +observedRow["threadId"] = "observed" +var hiddenRow = row("relay", "running") +hiddenRow["threadId"] = "hidden" +var completedRow = observedRow +completedRow["phase"] = "completed" +completedRow["updatedAt"] = "2026-01-02T00:00:00Z" +var completedHidden = hiddenRow +completedHidden["phase"] = "completed" +completedHidden["updatedAt"] = "2026-01-02T00:00:00Z" +let cappedLocal: [String: Any] = ["activeCount": 2, "totalCount": 2, "expiresAt": 1_600_000, + "observedEnvironmentIds": ["relay"], "activities": [observedRow], + "observedActivities": ["relay": [observedRow, hiddenRow]]] +let reconciledRelay: [String: Any] = ["activeCount": 0, "totalCount": 2, "activities": [completedRow, completedHidden]] +let reconciled = props(AgentWidgetState.timeline(aggregate: reconciledRelay, environmentIds: ["relay"], localObservation: cappedLocal, now: now)[0]) +assert(reconciled["activeCount"] as? Int == 0) +assert((reconciled["activities"] as! [[String: Any]]).allSatisfy { $0["phase"] as? String == "completed" }) +let partialRelay: [String: Any] = ["activeCount": 0, "totalCount": 1, "activities": [completedRow]] +let partial = props(AgentWidgetState.timeline(aggregate: partialRelay, environmentIds: ["relay"], localObservation: cappedLocal, now: now)[0]) +assert(partial["activeCount"] as? Int == 2) +assert((partial["activities"] as! [[String: Any]]).count == 1) +print("Capped observations: newer relay rows reconcile all source rows; hidden unfinished rows remain protected") + + +var mixedObservation = cappedLocal +mixedObservation["observedActivities"] = ["relay": [observedRow, hiddenRow], "direct": [row("direct", "running")]] +mixedObservation["activities"] = [observedRow, row("direct", "running")] +let mixedEntries = AgentWidgetState.timeline(aggregate: reconciledRelay, environmentIds: ["relay"], localObservation: mixedObservation, now: now) +let mixed = props(mixedEntries[0]) +assert(mixed["activeCount"] as? Int == 1) +assert(mixed["totalCount"] as? Int == 3) +assert((mixed["activities"] as! [[String: Any]]).map { $0["phase"] as! String } == ["completed", "completed", "running"]) +assert(props(mixedEntries[1])["activeCount"] as? Int == -1) +var hiddenOnly = cappedLocal +hiddenOnly["activities"] = [[String: Any]]() +let hiddenEntries = AgentWidgetState.timeline(aggregate: nil, environmentIds: [], localObservation: hiddenOnly, now: now) +assert(props(hiddenEntries[0])["activeCount"] as? Int == 2) +assert(hiddenEntries[1]["timestamp"] as? Int == 1_600_000) +assert(props(hiddenEntries[1])["activeCount"] as? Int == -1) +print("Per-environment reconciliation releases confirmed scopes, preserves exact hidden counts, and expires hidden work") + +defaults.set("https://locked-relay.test/v1/widget/agent-activity", forKey: "t3_agent_widget_url") +_ = AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .unavailable }) +assert(defaults.string(forKey: "t3_agent_widget_url") != nil, "Failed Keychain lookup must retain revocation destination") + +assert(!AgentWidgetStorage.canCreateCredential(in: defaults, credential: .unavailable)) +assert(!AgentWidgetStorage.prepareRelay(url: "https://new-relay.test", in: defaults, readCredential: { .unavailable })) +var recovered = [URLRequest]() +_ = AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .available("recovered-token") }) { request in + recovered.append(request) + return true +} +assert(recovered.first?.url?.host == "locked-relay.test") +assert(recovered.first?.value(forHTTPHeaderField: "Authorization") == "Bearer recovered-token") +assert(AgentWidgetStorage.canCreateCredential(in: defaults, credential: .missing)) +print("Unavailable secure reads retain old revocation routing; legacy cleanup blocks replacement") + +var staleLocal = local +staleLocal["isStale"] = true +assert(props(AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: staleLocal, now: now)[0])["isStale"] as? Bool == true) +staleLocal["activities"] = [row("relay", "running")] +staleLocal["totalCount"] = 1 +staleLocal["activeCount"] = 1 +assert(props(AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: staleLocal, now: now)[0])["isStale"] as? Bool == false) +print("Last observed label survives until the relay confirms retained direct work") + +func json(_ value: [String: Any]) -> String { String(data: try! JSONSerialization.data(withJSONObject: value, options: .sortedKeys), encoding: .utf8)! } +var observation: [String: Any] = ["activities": [observedRow], "observedActivities": ["relay": [observedRow]], "activeCount": 1, "expiresAt": 1_600_000] +var observationEntries: [[String: Any]] = [["timestamp": 0, "props": observation]] +assert(AgentWidgetStorage.observe(json(observation), entries: observationEntries, in: defaults)) +let unchangedTimeline = defaults.array(forKey: AgentWidgetStorage.timelineKey)! as NSArray +observedRow["updatedAt"] = "2026-01-03T00:00:00Z" +observation["observedActivities"] = ["relay": [observedRow]] +observationEntries = [["timestamp": 0, "props": observation]] +assert(!AgentWidgetStorage.observe(json(observation), entries: observationEntries, in: defaults)) +assert(defaults.string(forKey: "t3_agent_widget_local_observation") == json(observation)) +assert(unchangedTimeline.isEqual(defaults.array(forKey: AgentWidgetStorage.timelineKey)!)) +let newestObservation = try! JSONSerialization.jsonObject(with: Data(defaults.string(forKey: "t3_agent_widget_local_observation")!.utf8)) as! [String: Any] +let intermediateCompletion: [String: Any] = ["activeCount": 0, "totalCount": 1, "activities": [completedRow]] +let afterNewObservation = props(AgentWidgetState.timeline(aggregate: intermediateCompletion, environmentIds: ["relay"], localObservation: newestObservation, now: now)[0]) +assert(afterNewObservation["activeCount"] as? Int == 1) +assert((afterNewObservation["activities"] as! [[String: Any]])[0]["phase"] as? String == "running") +print("Newer source observations survive intermediate relay completions without repainting unchanged timelines") + +assert(AgentWidgetStorage.commit([["timestamp": 0, "props": ["activeCount": 0, "activities": [completedRow]]]], generation: AgentWidgetStorage.generation(in: defaults), in: defaults)) +assert(AgentWidgetStorage.observe(json(observation), entries: observationEntries, in: defaults)) +print("Foreground observations repair a changed native background timeline") + +observation["expiresAt"] = 1_620_000 +observationEntries = [["timestamp": 0, "props": observation], ["timestamp": 1_620_000, "props": ["isStale": true]]] +assert(AgentWidgetStorage.observe(json(observation), entries: observationEntries, in: defaults)) +observation["expiresAt"] = 1_650_000 +observationEntries[0]["props"] = observation +observationEntries[1]["timestamp"] = 1_650_000 +assert(AgentWidgetStorage.observe(json(observation), entries: observationEntries, in: defaults)) +assert((defaults.array(forKey: AgentWidgetStorage.timelineKey) as! [[String: Any]])[1]["timestamp"] as? Int == 1_650_000) +observationEntries[0]["timestamp"] = 10 +assert(!AgentWidgetStorage.observe(json(observation), entries: observationEntries, in: defaults)) +print("Future expiration shifts persist within a display minute; unchanged deadlines stay deduplicated") + +defaults.set("https://retry-relay.test/v1/widget/agent-activity", forKey: "t3_agent_widget_url") +_ = AgentWidgetStorage.clearStoredState(in: defaults, readCredential: { .unavailable }) +let signedOutGeneration = AgentWidgetStorage.generation(in: defaults) +let signedOut: [[String: Any]] = [["timestamp": 0, "props": ["isSignedOut": true, "activeCount": 0, "activities": [[String: Any]]()]]] +assert(AgentWidgetStorage.commit(signedOut, generation: signedOutGeneration, in: defaults)) +AgentWidgetStorage.prepareRevocations(in: defaults, readCredential: { .unavailable }) +assert(defaults.string(forKey: AgentWidgetStorage.generationKey) == signedOutGeneration) +assert(props((defaults.array(forKey: AgentWidgetStorage.timelineKey) as! [[String: Any]])[0])["isSignedOut"] as? Bool == true) +AgentWidgetStorage.prepareRevocations(in: defaults, readCredential: { .available("recovered-token") }, storeRevocation: { _ in true }) +assert(defaults.string(forKey: "t3_agent_widget_url") == nil) +assert(defaults.string(forKey: AgentWidgetStorage.generationKey) == signedOutGeneration) +assert(props((defaults.array(forKey: AgentWidgetStorage.timelineKey) as! [[String: Any]])[0])["isSignedOut"] as? Bool == true) +print("Revocation retries preserve signed-out timeline ownership during secure failure and recovery") diff --git a/apps/mobile/plugins/withAgentWidgetRefresh.cjs b/apps/mobile/plugins/withAgentWidgetRefresh.cjs new file mode 100644 index 0000000000000..336dc41d19bb1 --- /dev/null +++ b/apps/mobile/plugins/withAgentWidgetRefresh.cjs @@ -0,0 +1,119 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); + +// Register before expo-widgets: our mods run after its target generation. +module.exports = function withAgentWidgetRefresh(config) { + const pushEnvironment = config.extra?.appVariant === "development" ? "development" : "production"; + const eas = config.extra?.eas ?? {}; + const build = eas.build ?? {}; + const experimental = build.experimental ?? {}; + const ios = experimental.ios ?? {}; + const extensions = ios.appExtensions ?? []; + const current = extensions.find((extension) => extension.targetName === "ExpoWidgetsTarget"); + const widgetExtension = { + ...current, + targetName: "ExpoWidgetsTarget", + bundleIdentifier: `${config.ios.bundleIdentifier}.widgets`, + entitlements: { ...current?.entitlements, "aps-environment": pushEnvironment }, + }; + config.extra = { + ...config.extra, + eas: { + ...eas, + build: { + ...build, + experimental: { + ...experimental, + ios: { + ...ios, + appExtensions: [ + ...extensions.filter((extension) => extension.targetName !== "ExpoWidgetsTarget"), + widgetExtension, + ], + }, + }, + }, + }, + }; + + config = withDangerousMod(config, [ + "ios", + (cfg) => { + const target = path.join(cfg.modRequest.platformProjectRoot, "ExpoWidgetsTarget"); + for (const file of [ + "AgentWidgetTimelineProvider.swift", + "AgentWidgetState.swift", + "AgentWidgetCredential.swift", + "AgentWidgetStorage.swift", + ]) { + const source = + file === "AgentWidgetCredential.swift" || file === "AgentWidgetStorage.swift" + ? path.join(__dirname, "..", "modules", "t3-native-controls", "ios", file) + : path.join(__dirname, "widget", file); + fs.copyFileSync(source, path.join(target, file)); + } + const entitlements = path.join(target, "ExpoWidgetsTarget.entitlements"); + const plist = fs.readFileSync(entitlements, "utf8"); + if (!plist.includes("aps-environment")) + fs.writeFileSync( + entitlements, + plist.replace( + "", + `\n aps-environment\n ${pushEnvironment}`, + ), + ); + if (pushEnvironment === "development") { + const infoPath = path.join(target, "Info.plist"); + const info = fs.readFileSync(infoPath, "utf8"); + if (!info.includes("NSAppTransportSecurity")) + fs.writeFileSync( + infoPath, + info.replace( + "", + "\nNSAppTransportSecurityNSAllowsLocalNetworking", + ), + ); + } + const widget = path.join(target, "AgentActivity.swift"); + const source = fs.readFileSync(widget, "utf8"); + const provider = "WidgetsTimelineProvider(name: name)"; + if (!source.includes(provider)) throw new Error("AgentActivity provider generation changed"); + const entryView = "WidgetsEntryView(entry: entry)"; + const displayName = " .configurationDisplayName"; + if (!source.includes(entryView) || !source.includes(displayName)) + throw new Error("AgentActivity widget generation changed"); + fs.writeFileSync( + widget, + source + .replace(provider, "AgentWidgetTimelineProvider()") + .replace(entryView, "AgentWidgetEntryView(entry: entry)") + .replace(displayName, " .agentWidgetPushHandler()\n .configurationDisplayName"), + ); + return cfg; + }, + ]); + return withXcodeProject(config, (cfg) => { + const proj = cfg.modResults; + const target = Object.entries(proj.pbxNativeTargetSection()).find( + ([key, value]) => !key.endsWith("_comment") && value.name === "ExpoWidgetsTarget", + ); + if (!target) throw new Error("ExpoWidgetsTarget missing before widget refresh wiring"); + const group = Object.entries(proj.hash.project.objects.PBXGroup).find( + ([key, value]) => + !key.endsWith("_comment") && + (value.name === "ExpoWidgetsTarget" || value.path === "ExpoWidgetsTarget"), + ); + if (!group) throw new Error("ExpoWidgetsTarget source group missing"); + for (const file of [ + "AgentWidgetTimelineProvider.swift", + "AgentWidgetState.swift", + "AgentWidgetCredential.swift", + "AgentWidgetStorage.swift", + ]) { + proj.addSourceFile(file, { target: target[0] }, group[0]); + } + return cfg; + }); +}; diff --git a/apps/mobile/plugins/withAgentWidgetRefresh.test.mjs b/apps/mobile/plugins/withAgentWidgetRefresh.test.mjs new file mode 100644 index 0000000000000..fe8f7e149274a --- /dev/null +++ b/apps/mobile/plugins/withAgentWidgetRefresh.test.mjs @@ -0,0 +1,67 @@ +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeVM from "node:vm"; +import { expect, it } from "vite-plus/test"; + +const require = NodeModule.createRequire(import.meta.url); +const plugin = new URL("./withAgentWidgetRefresh.cjs", import.meta.url); +const template = `StaticConfiguration(kind: name, provider: WidgetsTimelineProvider(name: name)) { entry in + WidgetsEntryView(entry: entry) + } + .configurationDisplayName("Agent Activity")`; + +function setup(source) { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-widget-generation-")); + const target = NodePath.join(root, "ExpoWidgetsTarget"); + NodeFS.mkdirSync(target); + for (const name of ["ExpoWidgetsTarget.entitlements", "Info.plist"]) + NodeFS.writeFileSync(NodePath.join(target, name), ""); + const widget = NodePath.join(target, "AgentActivity.swift"); + NodeFS.writeFileSync(widget, source); + let transform; + const pluginModule = { exports: {} }; + NodeVM.runInNewContext(NodeFS.readFileSync(plugin, "utf8"), { + module: pluginModule, + __dirname: NodePath.dirname(NodeURL.fileURLToPath(plugin)), + require(name) { + if (name !== "expo/config-plugins") return require(name); + return { + withDangerousMod(config, [, callback]) { + transform = callback; + return config; + }, + withXcodeProject: (config) => config, + }; + }, + }); + const config = pluginModule.exports({ + ios: { bundleIdentifier: "com.example.widget" }, + extra: { appVariant: "development" }, + }); + return { + run: () => transform({ ...config, modRequest: { platformProjectRoot: root } }), + read: () => NodeFS.readFileSync(widget, "utf8"), + [Symbol.dispose]: () => NodeFS.rmSync(root, { recursive: true, force: true }), + }; +} + +it.each(["WidgetsEntryView(entry: entry)", " .configurationDisplayName"])( + "rejects a generated template missing %s", + (marker) => { + const source = template.replace(marker, "changed-template"); + using test = setup(source); + expect(test.run).toThrow("AgentActivity widget generation changed"); + expect(test.read()).toBe(source); + }, +); + +it("installs the provider, ownership guard and push handler in the supported template", () => { + using test = setup(template); + test.run(); + expect(test.read()).toContain("provider: AgentWidgetTimelineProvider()"); + expect(test.read()).toContain("AgentWidgetEntryView(entry: entry)"); + expect(test.read()).toContain(".agentWidgetPushHandler()\n .configurationDisplayName"); +}); diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts index 32141f220186f..f73f5a9bd2758 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts @@ -1,5 +1,15 @@ +import { observeAgentWidget } from "./agentWidgetRefresh.ios"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +export function publishAgentActivityWidget(props: AgentActivityProps): boolean { + try { + return observeAgentWidget(props); + } catch { + // Personal-team builds have no widget extension. + return false; + } +} + export function dismissEndedAgentLiveActivities() { AgentActivity.dismissEnded(); } diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts index 684c57817ecab..2d5517a8c57f2 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts @@ -13,3 +13,8 @@ export function startAgentLiveActivity( ): LiveActivity | null { return null; } + +/** Home-screen widgets only exist on iOS. */ +export function publishAgentActivityWidget(_props: AgentActivityProps): boolean { + return false; +} diff --git a/apps/mobile/src/features/agent-awareness/agentWidgetRefresh.ios.ts b/apps/mobile/src/features/agent-awareness/agentWidgetRefresh.ios.ts new file mode 100644 index 0000000000000..3066d9bf3121d --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/agentWidgetRefresh.ios.ts @@ -0,0 +1,40 @@ +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { requireOptionalNativeModule } from "expo"; +import { agentActivityTimeline } from "../../widgets/agentActivityTimeline"; + +const native = requireOptionalNativeModule<{ + observeAgentWidget(props: string, timeline: string): boolean; + agentWidgetToken(identity: string, url: string | null): string | null; + configureAgentWidgetRefresh(url: string, token: string): void; + clearAgentWidgetRefresh(): void; +}>("T3NativeControls"); + +export function agentWidgetToken(identity: string, relayUrl?: string): string | null { + return native?.agentWidgetToken?.(identity, relayUrl ? widgetRefreshUrl(relayUrl) : null) ?? null; +} + +export function configureAgentWidgetRefresh(relayUrl: string, token: string): void { + native?.configureAgentWidgetRefresh?.(widgetRefreshUrl(relayUrl), token); +} + +export function clearAgentWidgetRefresh(): void { + native?.clearAgentWidgetRefresh?.(); +} + +export function observeAgentWidget(props: AgentActivityProps): boolean { + return ( + native?.observeAgentWidget?.( + JSON.stringify(props), + JSON.stringify( + agentActivityTimeline(props, Date.now()).map(({ date, props }) => ({ + timestamp: date.getTime(), + props, + })), + ), + ) ?? false + ); +} + +function widgetRefreshUrl(relayUrl: string): string { + return new URL("v1/widget/agent-activity", `${relayUrl.replace(/\/$/, "")}/`).href; +} diff --git a/apps/mobile/src/features/agent-awareness/agentWidgetRefresh.ts b/apps/mobile/src/features/agent-awareness/agentWidgetRefresh.ts new file mode 100644 index 0000000000000..8b7216ec7f6c8 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/agentWidgetRefresh.ts @@ -0,0 +1,5 @@ +export function agentWidgetToken(_identity: string, _relayUrl?: string): string | null { + return null; +} +export function configureAgentWidgetRefresh(_relayUrl: string, _token: string): void {} +export function clearAgentWidgetRefresh(): void {} diff --git a/apps/mobile/src/features/agent-awareness/liveWidgetActivity.test.ts b/apps/mobile/src/features/agent-awareness/liveWidgetActivity.test.ts new file mode 100644 index 0000000000000..717f002dd97f1 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/liveWidgetActivity.test.ts @@ -0,0 +1,478 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + RuntimeRequestId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import type { RelayAgentActivitySnapshotResponse } from "@t3tools/contracts/relay"; +import { vi } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { makeRawThreadShell } from "../../test-fixtures"; +import { Atom } from "effect/reactivity"; +import { AtomRegistry } from "effect/reactivity"; + +import type { AgentActivityRowProps } from "../../widgets/AgentActivity"; +import { + createLiveWidgetActivitiesAtom, + reconcileWidgetActivity, + retainUnconfirmedWidgetActivities, +} from "./liveWidgetActivity"; + +const ENVIRONMENT = EnvironmentId.make("local"); +const REMOTE = EnvironmentId.make("remote"); +const NOW = "2026-09-05T12:00:00.000Z"; +const NOW_MS = Date.parse(NOW); +const project: OrchestrationProjectShell = { + id: ProjectId.make("project"), + title: "Project", + workspaceRoot: "/synthetic/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, +}; + +function thread(overrides: Partial = {}): OrchestrationV2ThreadShell { + return makeRawThreadShell({ + id: ThreadId.make("thread"), + projectId: project.id, + title: "Task", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + createdAt: DateTime.makeUnsafe(NOW), + updatedAt: DateTime.makeUnsafe(NOW), + ...overrides, + }); +} + +function shell( + threads: ReadonlyArray, + status: EnvironmentShellState["status"] = "live", +): EnvironmentShellState { + return { + status, + generation: 0, + removedAt: new Map(), + error: Option.none(), + snapshot: Option.some({ + projects: [project], + threads, + archivedThreads: [], + snapshotSequence: 1, + schemaVersion: 1, + }), + }; +} + +function harness(initial: EnvironmentShellState, now = () => NOW_MS) { + const registry = AtomRegistry.make(); + const shellAtoms = Atom.family((_id: EnvironmentId) => Atom.make(initial)); + const catalog = Atom.make({ + isReady: true, + entries: new Map([ + [ + ENVIRONMENT, + { + target: new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT, + label: "Local", + httpBaseUrl: "https://local.example.test", + wsBaseUrl: "wss://local.example.test/ws", + }), + profile: Option.none(), + enabled: true, + }, + ], + ]), + }); + const atom = createLiveWidgetActivitiesAtom({ + catalogValueAtom: catalog, + shellStateValueAtom: shellAtoms, + now, + }); + return { registry, atom, catalog, shellAtoms }; +} + +function row( + environmentId = REMOTE, + threadId = ThreadId.make("remote-thread"), + phase: AgentActivityRowProps["phase"] = "running", +) { + return { + environmentId, + threadId, + projectTitle: "Project", + threadTitle: "Task", + modelTitle: "test-model", + phase, + status: phase === "completed" ? "Done" : "Working", + updatedAt: NOW, + deepLink: `/threads/${environmentId}/${threadId}`, + }; +} + +function snapshot( + activities: NonNullable["activities"], + activeCount = activities.filter( + (value) => value.phase !== "completed" && value.phase !== "failed", + ).length, +): RelayAgentActivitySnapshotResponse { + return { + aggregate: { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount, + updatedAt: NOW, + activities, + }, + }; +} + +describe("live widget activity", () => { + it("retains completed rows until settlement rather than timing them out", async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + const h = harness(shell([thread({ status: "completed" })]), Date.now); + const stop = h.registry.mount(h.atom); + try { + expect( + h.registry + .get(h.atom) + .get(ENVIRONMENT) + ?.map((row) => row.phase), + ).toEqual(["completed"]); + await vi.advanceTimersByTimeAsync(16 * 60_000); + expect( + h.registry + .get(h.atom) + .get(ENVIRONMENT) + ?.map((row) => row.phase), + ).toEqual(["completed"]); + h.registry.set( + h.shellAtoms(ENVIRONMENT), + shell([thread({ status: "completed", settledOverride: "settled" })]), + ); + expect(h.registry.get(h.atom).get(ENVIRONMENT)).toEqual([]); + } finally { + stop(); + h.registry.dispose(); + vi.useRealTimers(); + } + }); + + it("observes local starts, completion, and deletion without a relay refresh", () => { + const h = harness(shell([])); + const seen: string[][] = []; + const stop = h.registry.subscribe( + h.atom, + (live) => { + seen.push((live.get(ENVIRONMENT) ?? []).map((value) => value.phase)); + }, + { immediate: true }, + ); + try { + h.registry.set(h.shellAtoms(ENVIRONMENT), shell([thread({ status: "starting" })])); + h.registry.set(h.shellAtoms(ENVIRONMENT), shell([thread({ status: "running" })])); + h.registry.set(h.shellAtoms(ENVIRONMENT), shell([thread({ status: "completed" })])); + h.registry.set(h.shellAtoms(ENVIRONMENT), shell([])); + expect(seen).toEqual([[], ["starting"], ["running"], ["completed"], []]); + expect(h.registry.get(h.atom).has(ENVIRONMENT)).toBe(true); + } finally { + stop(); + h.registry.dispose(); + } + }); + + it.each(["cached", "synchronizing", "empty"] as const)( + "does not claim authority from %s shells", + (status) => { + const h = harness(shell([thread({ status: "running" })], status)); + try { + expect(h.registry.get(h.atom).has(ENVIRONMENT)).toBe(false); + h.registry.set(h.shellAtoms(ENVIRONMENT), shell([])); + expect(h.registry.get(h.atom).get(ENVIRONMENT)).toEqual([]); + h.registry.set(h.shellAtoms(ENVIRONMENT), shell([thread({ status: "running" })], status)); + expect(h.registry.get(h.atom).has(ENVIRONMENT)).toBe(false); + } finally { + h.registry.dispose(); + } + }, + ); + + it("drops ownership when the environment is removed", () => { + const h = harness(shell([thread({ status: "running" })])); + try { + expect(h.registry.get(h.atom).size).toBe(1); + h.registry.set(h.catalog, { isReady: true, entries: new Map() }); + expect(h.registry.get(h.atom).size).toBe(0); + } finally { + h.registry.dispose(); + } + }); + + it("drops ownership when the environment is switched off", () => { + const h = harness(shell([thread({ status: "running" })])); + try { + const catalog = h.registry.get(h.catalog); + const entry = catalog.entries.get(ENVIRONMENT); + if (!entry) throw new Error("missing catalog entry"); + h.registry.set(h.catalog, { + ...catalog, + entries: new Map([[ENVIRONMENT, { ...entry, enabled: false }]]), + }); + expect(h.registry.get(h.atom).size).toBe(0); + } finally { + h.registry.dispose(); + } + }); + + it("keeps attention, failures, and older unsettled work but excludes orphan threads", () => { + const h = harness( + shell([ + thread({ + id: ThreadId.make("approval"), + pendingRuntimeRequest: { + id: RuntimeRequestId.make("approval"), + kind: "command", + createdAt: DateTime.makeUnsafe(NOW), + }, + }), + thread({ + id: ThreadId.make("input"), + pendingRuntimeRequest: { + id: RuntimeRequestId.make("input"), + kind: "user_input", + createdAt: DateTime.makeUnsafe(NOW), + }, + }), + thread({ id: ThreadId.make("failure"), status: "failed" }), + thread({ + id: ThreadId.make("old"), + status: "completed", + updatedAt: DateTime.makeUnsafe("2026-09-05T11:00:00.000Z"), + }), + thread({ + id: ThreadId.make("parent"), + status: "completed", + settledOverride: "settled", + snoozedAt: DateTime.makeUnsafe(NOW), + snoozedUntil: DateTime.makeUnsafe(NOW_MS + 60_000), + }), + thread({ + id: ThreadId.make("child"), + creationSource: "mcp", + activityRunStatus: "running", + lineage: { + rootThreadId: ThreadId.make("parent"), + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent", + }, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("child-approval"), + kind: "command", + createdAt: DateTime.makeUnsafe(NOW), + }, + }), + thread({ + id: ThreadId.make("orphan"), + projectId: ProjectId.make("missing"), + status: "running", + }), + ]), + ); + try { + expect( + h.registry + .get(h.atom) + .get(ENVIRONMENT) + ?.map((value) => value.status), + ).toEqual(["Approval", "Input", "Failed", "Done", "Approval"]); + const current = h.registry.get(h.shellAtoms(ENVIRONMENT)); + if (Option.isNone(current.snapshot)) throw new Error("missing snapshot"); + h.registry.set( + h.shellAtoms(ENVIRONMENT), + shell( + current.snapshot.value.threads.map((value) => + value.id === ThreadId.make("child") + ? { + ...value, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("child-input"), + kind: "user_input", + userOnly: true, + createdAt: DateTime.makeUnsafe(NOW), + }, + } + : value, + ), + ), + ); + expect(h.registry.get(h.atom).get(ENVIRONMENT)?.at(-1)?.status).toBe("Input"); + h.registry.set( + h.shellAtoms(ENVIRONMENT), + shell( + current.snapshot.value.threads.map((value) => + value.id === ThreadId.make("child") ? { ...value, pendingRuntimeRequest: null } : value, + ), + ), + ); + expect( + h.registry + .get(h.atom) + .get(ENVIRONMENT) + ?.map((value) => value.status), + ).toEqual(["Approval", "Input", "Failed", "Done"]); + } finally { + h.registry.dispose(); + } + }); + + it("replaces an entire live environment, including its idle and deleted threads", () => { + const relay = snapshot([row(ENVIRONMENT), row()]); + const live = new Map>([[ENVIRONMENT, []]]); + expect(reconcileWidgetActivity(live, relay)).toEqual({ + activeCount: 1, + workingCount: 1, + attentionCount: 0, + totalCount: null, + activities: [row()], + }); + live.set(ENVIRONMENT, [row(ENVIRONMENT, ThreadId.make("different-task"))]); + expect(reconcileWidgetActivity(live, relay).activeCount).toBe(2); + expect(reconcileWidgetActivity(live, relay).activities.map((value) => value.threadId)).toEqual([ + "different-task", + "remote-thread", + ]); + }); + + it("keeps working, input, and total counts exact across a capped scoped relay", () => { + const localRows = [ + row(ENVIRONMENT), + row(ENVIRONMENT, ThreadId.make("input"), "waiting_for_input"), + ]; + const remote = snapshot([row()], 12); + const result = reconcileWidgetActivity(new Map([[ENVIRONMENT, localRows]]), { + ...remote, + excludedEnvironmentIds: [ENVIRONMENT], + aggregate: { ...remote.aggregate!, workingCount: 8, attentionCount: 4, totalCount: 20 }, + }); + expect(result).toMatchObject({ + activeCount: 14, + workingCount: 9, + attentionCount: 5, + totalCount: 22, + }); + }); + + it("counts all local work before capping displayed rows", () => { + const localRows = Array.from({ length: 8 }, (_, index) => + row(ENVIRONMENT, ThreadId.make(`local-${index}`)), + ); + const result = reconcileWidgetActivity(new Map([[ENVIRONMENT, localRows]]), snapshot([row()])); + expect(result.activeCount).toBe(9); + expect(result.activities).toHaveLength(5); + }); + + it("does not turn an incomplete legacy count into an exact mixed count", () => { + const relay = snapshot( + Array.from({ length: 5 }, (_, index) => row(REMOTE, ThreadId.make(`remote-${index}`))), + 12, + ); + expect(reconcileWidgetActivity(new Map(), relay).activeCount).toBe(12); + expect(reconcileWidgetActivity(new Map([[ENVIRONMENT, []]]), relay).activeCount).toBeNull(); + expect( + reconcileWidgetActivity(new Map([[ENVIRONMENT, [row(ENVIRONMENT)]]]), relay).activeCount, + ).toBeNull(); + }); + + it("distinguishes an unread relay snapshot from a confirmed empty one", () => { + const live = new Map([[ENVIRONMENT, [row(ENVIRONMENT)]]]); + expect(reconcileWidgetActivity(live, null).activeCount).toBeNull(); + expect(reconcileWidgetActivity(live, { aggregate: null }).activeCount).toBe(1); + }); + + it("uses an acknowledged matching exclusion scope even when remote activity is capped", () => { + const live = new Map([[ENVIRONMENT, [row(ENVIRONMENT)]]]); + const filtered = { ...snapshot([row()], 12), excludedEnvironmentIds: [ENVIRONMENT] }; + expect(reconcileWidgetActivity(live, filtered).activeCount).toBe(13); + expect(reconcileWidgetActivity(new Map(), filtered).activeCount).toBeNull(); + expect(reconcileWidgetActivity(new Map([[REMOTE, []]]), filtered).activeCount).toBeNull(); + }); + + it("retires expired terminal observations only from complete relay snapshots", () => { + const observed = new Map([ + [ENVIRONMENT, [row(ENVIRONMENT, ThreadId.make("task"), "completed")]], + ]); + const stale = snapshot([row(ENVIRONMENT, ThreadId.make("task"))]); + expect(retainUnconfirmedWidgetActivities(observed, new Map(), observed, stale)).toEqual( + observed, + ); + expect( + retainUnconfirmedWidgetActivities(observed, new Map(), observed, { aggregate: null }).size, + ).toBe(0); + const active = new Map([[ENVIRONMENT, [row(ENVIRONMENT)]]]); + expect( + retainUnconfirmedWidgetActivities(active, new Map(), active, { aggregate: null }), + ).toEqual(active); + expect( + retainUnconfirmedWidgetActivities(observed, new Map(), observed, snapshot([], 10)), + ).toEqual(observed); + const legacy = snapshot([]); + expect( + retainUnconfirmedWidgetActivities(observed, new Map(), observed, { + aggregate: { ...legacy.aggregate!, totalCount: undefined }, + }), + ).toEqual(observed); + const matching = snapshot(observed.get(ENVIRONMENT)!); + expect(retainUnconfirmedWidgetActivities(observed, new Map(), observed, matching).size).toBe(0); + expect(retainUnconfirmedWidgetActivities(observed, observed, observed, matching)).toEqual( + observed, + ); + }); + + it("accepts a newer relay observation but cannot retire local changes made during the read", () => { + const observed = new Map([[ENVIRONMENT, [row(ENVIRONMENT)]]]); + const newer = snapshot([ + { + ...row(ENVIRONMENT, ThreadId.make("remote-thread"), "completed"), + updatedAt: "2026-09-05T12:00:01.000Z", + }, + ]); + expect(retainUnconfirmedWidgetActivities(observed, new Map(), observed, newer).size).toBe(0); + const changed = new Map([[ENVIRONMENT, [row(ENVIRONMENT)]]]); + expect(retainUnconfirmedWidgetActivities(changed, new Map(), observed, newer)).toEqual(changed); + }); + + it("only validates empty observations when no active rows can be hidden by the cap", () => { + const observed = new Map([[ENVIRONMENT, []]]); + expect( + retainUnconfirmedWidgetActivities(observed, new Map(), observed, snapshot([row()], 12)), + ).toEqual(observed); + expect( + retainUnconfirmedWidgetActivities(observed, new Map(), observed, snapshot([row()])), + ).toEqual(observed); + const resumed = { + ...snapshot([row(ENVIRONMENT)]), + aggregate: { ...snapshot([row(ENVIRONMENT)]).aggregate!, totalCount: 1 }, + }; + const released = retainUnconfirmedWidgetActivities(observed, new Map(), observed, resumed); + expect(released.size).toBe(0); + expect(reconcileWidgetActivity(released, resumed)).toMatchObject({ + activeCount: 1, + activities: [row(ENVIRONMENT)], + }); + expect( + retainUnconfirmedWidgetActivities(observed, new Map(), observed, { + aggregate: null, + excludedEnvironmentIds: [ENVIRONMENT], + }), + ).toEqual(observed); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/liveWidgetActivity.ts b/apps/mobile/src/features/agent-awareness/liveWidgetActivity.ts new file mode 100644 index 0000000000000..4a6f7c5f7460e --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/liveWidgetActivity.ts @@ -0,0 +1,227 @@ +import { + enabledEnvironmentIds, + type EnvironmentCatalogState, +} from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { isListedThreadLineage, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivitySnapshotResponse } from "@t3tools/contracts/relay"; +import { + projectThreadAwarenessV2, + subagentAwaitsUser, + type SubagentRequestAwareness, +} from "@t3tools/shared/agentAwareness"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; + +import type { AgentActivityRowProps } from "../../widgets/AgentActivity"; + +export type LiveWidgetActivities = ReadonlyMap>; + +const MAX_WIDGET_ROWS = 5; + +const statusByPhase = { + starting: "Connecting", + running: "Working", + waiting_for_approval: "Approval", + waiting_for_input: "Input", + completed: "Done", + failed: "Failed", + stale: "Waiting", +} satisfies Record; + +function isActive(row: AgentActivityRowProps): boolean { + return row.phase !== "completed" && row.phase !== "failed"; +} + +/** A live empty shell still owns its environment and suppresses older relay rows. */ +export function createLiveWidgetActivitiesAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + readonly now: () => number; +}) { + return Atom.make((get): LiveWidgetActivities => { + const environments = new Map>(); + const now = input.now(); + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + const shell = get(input.shellStateValueAtom(environmentId)); + if (shell.status !== "live" || Option.isNone(shell.snapshot)) continue; + const snapshot = shell.snapshot.value; + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + const threads = new Map(snapshot.threads.map((thread) => [thread.id, thread])); + const childRequests = new Map(); + for (const child of snapshot.threads) { + if (child.archivedAt !== null || !subagentAwaitsUser(child)) continue; + const request = child.pendingRuntimeRequest; + if (!request) continue; + const seen = new Set([child.id]); + let parentId = child.lineage.parentThreadId; + while (parentId !== null && !seen.has(parentId)) { + seen.add(parentId); + const parent = threads.get(parentId); + if (!parent) break; + if (isListedThreadLineage(parent.lineage)) { + const previous = childRequests.get(parent.id); + if (!previous || DateTime.isGreaterThan(child.updatedAt, previous.updatedAt)) { + childRequests.set(parent.id, { + requestId: request.id, + updatedAt: child.updatedAt, + waitsForInput: request.kind === "user_input", + }); + } + break; + } + parentId = parent.lineage.parentThreadId; + } + } + const rows: AgentActivityRowProps[] = []; + for (const thread of snapshot.threads) { + const project = projects.get(thread.projectId); + if (!project) continue; + const state = projectThreadAwarenessV2({ + environmentId, + project, + thread, + now, + pendingSubagentRequest: childRequests.get(thread.id), + }); + if (!state) continue; + rows.push({ + environmentId, + threadId: state.threadId, + projectTitle: state.projectTitle, + threadTitle: state.threadTitle, + modelTitle: state.modelTitle, + phase: state.phase, + status: statusByPhase[state.phase], + updatedAt: state.updatedAt, + deepLink: state.deepLink, + }); + } + environments.set(environmentId, rows); + } + return environments; + }).pipe( + Atom.withRefresh("1 minute"), + Atom.withLabel("mobile:agent-awareness:live-widget-activities"), + ); +} + +function rowPriority(row: AgentActivityRowProps): number { + if (row.phase === "waiting_for_approval" || row.phase === "waiting_for_input") return 0; + if (row.phase === "failed") return 1; + return isActive(row) ? 2 : 3; +} + +export function sameWidgetEnvironmentScope( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + const leftSet = new Set(left); + const rightSet = new Set(right); + return leftSet.size === rightSet.size && [...leftSet].every((id) => rightSet.has(id)); +} + +/** A disconnect is not a newer observation. Omitted capped rows cannot confirm completion. */ +export function retainUnconfirmedWidgetActivities( + observed: LiveWidgetActivities, + live: LiveWidgetActivities, + readStartedWith: LiveWidgetActivities, + snapshot: RelayAgentActivitySnapshotResponse, +): LiveWidgetActivities { + if ((snapshot.excludedEnvironmentIds?.length ?? 0) > 0) return observed; + const relayRows = snapshot.aggregate?.activities ?? []; + const complete = + snapshot.aggregate === null || + (snapshot.aggregate.activeCount === relayRows.filter(isActive).length && + snapshot.aggregate.totalCount !== undefined && + snapshot.aggregate.totalCount === relayRows.length); + const retained = new Map(observed); + for (const [environmentId, rows] of observed) { + if (live.has(environmentId) || readStartedWith.get(environmentId) !== rows) continue; + const environmentRows = relayRows.filter((row) => row.environmentId === environmentId); + const confirmed = + rows.length === 0 + ? complete + : rows.every( + (row) => + environmentRows.some( + (remote) => + remote.threadId === row.threadId && + (remote.updatedAt > row.updatedAt || + (remote.updatedAt === row.updatedAt && remote.phase === row.phase)), + ) || + (complete && + !isActive(row) && + !environmentRows.some((remote) => remote.threadId === row.threadId)), + ); + if (confirmed) retained.delete(environmentId); + } + return retained; +} + +/** Null means the capped legacy response cannot establish the combined total. */ +export function reconcileWidgetActivity( + live: LiveWidgetActivities, + snapshot: RelayAgentActivitySnapshotResponse | null, +) { + const aggregate = snapshot?.aggregate; + const relayRows = aggregate?.activities ?? []; + const remoteRows = relayRows.filter((row) => !live.has(row.environmentId)); + const localRows = [...live.values()].flat(); + const localActiveCount = localRows.filter(isActive).length; + const visibleRelayActiveCount = relayRows.filter(isActive).length; + const relayCountIsComplete = + snapshot !== null && + (aggregate === null || + aggregate === undefined || + aggregate.activeCount === visibleRelayActiveCount); + const acknowledgedScope = snapshot?.excludedEnvironmentIds; + const exactScope = + acknowledgedScope !== undefined && + sameWidgetEnvironmentScope(acknowledgedScope, [...live.keys()]); + const globalScope = (acknowledgedScope?.length ?? 0) === 0; + let activeCount: number | null = null; + if (snapshot !== null) { + if (exactScope) { + activeCount = localActiveCount + (aggregate?.activeCount ?? 0); + } else if (globalScope) { + if (live.size === 0) activeCount = aggregate?.activeCount ?? 0; + else if (relayCountIsComplete) + activeCount = localActiveCount + remoteRows.filter(isActive).length; + } + } + const allRows = [...localRows, ...remoteRows]; + const mergedCount = ( + key: "workingCount" | "attentionCount" | "totalCount", + matches: (row: AgentActivityRowProps) => boolean, + ): number | null => { + const localCount = localRows.filter(matches).length; + const remoteCount = aggregate?.[key]; + if (snapshot === null) return null; + if (exactScope && (remoteCount !== undefined || aggregate == null)) + return localCount + (remoteCount ?? 0); + if (globalScope && live.size === 0 && remoteCount !== undefined) return remoteCount; + if (globalScope && (aggregate == null || aggregate.totalCount === relayRows.length)) + return allRows.filter(matches).length; + if (key !== "totalCount" && globalScope && relayCountIsComplete) + return allRows.filter(matches).length; + return null; + }; + const workingCount = mergedCount( + "workingCount", + (row) => row.phase === "running" || row.phase === "starting", + ); + const attentionCount = mergedCount( + "attentionCount", + (row) => row.phase === "waiting_for_input" || row.phase === "waiting_for_approval", + ); + const totalCount = mergedCount("totalCount", () => true); + const activities = allRows + .sort( + (left, right) => + rowPriority(left) - rowPriority(right) || right.updatedAt.localeCompare(left.updatedAt), + ) + .slice(0, MAX_WIDGET_ROWS); + return { activeCount, workingCount, attentionCount, totalCount, activities }; +} diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d4438911eb7bf..7aefb973eb72d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -6,16 +6,31 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; import * as Cause from "effect/Cause"; +import type { LiveActivity } from "expo-widgets"; +import * as DateTime from "effect/DateTime"; +import * as TestClock from "effect/testing/TestClock"; +import { makeRawThreadShell } from "../../test-fixtures"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationProjectShell, +} from "@t3tools/contracts"; +import type { RelayAgentActivitySnapshotResponse } from "@t3tools/contracts/relay"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import * as Dpop from "../cloud/dpop"; import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; @@ -30,6 +45,7 @@ import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, + armAgentAwarenessLiveActivityForLocalWork, agentAwarenessNotificationsReady, getAgentAwarenessRegistrationStatus, refreshActiveLiveActivityRemoteRegistration, @@ -41,23 +57,42 @@ import { updateAgentAwarenessRegistrationPreferences, type AgentAwarenessRegistrationError, } from "./remoteRegistration"; +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { publishAgentActivityWidget } from "./agentLiveActivity"; +import { + agentActivityTimeline, + AGENT_ACTIVITY_FRESHNESS_MS, +} from "../../widgets/agentActivityTimeline"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; import { configureAndroidAgentNotifications } from "./androidNotifications"; +import { + agentWidgetToken, + configureAgentWidgetRefresh, + clearAgentWidgetRefresh, +} from "./agentWidgetRefresh"; +vi.mock("./agentWidgetRefresh", () => ({ + agentWidgetToken: vi.fn(() => null), + configureAgentWidgetRefresh: vi.fn(), + clearAgentWidgetRefresh: vi.fn(), +})); + vi.mock("./androidNotifications", () => ({ supportsAndroidAgentNotifications: vi.fn(() => true), configureAndroidAgentNotifications: vi.fn(), clearAndroidAgentNotifications: vi.fn(), })); +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentCatalog } from "../../connection/catalog"; +import { environmentShell } from "../../state/shell"; const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ dismissEnded: vi.fn(), - getInstances: vi.fn(() => []), + getInstances: vi.fn<() => ReadonlyArray>>>(() => []), start: vi.fn(() => ({})), })); -const publishWidgetMock = vi.hoisted(() => vi.fn((_source: unknown) => Promise.resolve())); const environmentConfigsMock = vi.hoisted(() => ({ configs: new Map< string, @@ -71,6 +106,7 @@ const backgroundRuntime = vi.hoisted(() => ({ }>, })); const appStateMock = vi.hoisted(() => ({ + currentState: "active", listeners: [] as Array<(state: string) => void>, })); const registrationRecordStore = vi.hoisted(() => ({ @@ -94,26 +130,51 @@ vi.mock("expo-widgets", () => ({ addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), })); -vi.mock("../../widgets/publishAgentActivityWidget", () => ({ - publishAgentActivityWidget: publishWidgetMock, -})); - vi.mock("./agentLiveActivity", () => ({ dismissEndedAgentLiveActivities: widgetMocks.dismissEnded, getAgentLiveActivities: widgetMocks.getInstances, startAgentLiveActivity: widgetMocks.start, + publishAgentActivityWidget: vi.fn(() => true), })); -// Avoid importing the native connection stack; this gate only needs configs. -vi.mock("../../state/atom-registry", () => ({ - appAtomRegistry: { - get: () => environmentConfigsMock.configs, - }, -})); +// Keep the native connection boundary synthetic while exercising the real atom +// registry and widget observer used by the registered app callbacks. +vi.mock("../../state/atom-registry", async () => { + const { AtomRegistry } = await import("effect/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); -vi.mock("../../state/server", () => ({ - environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"), -})); +vi.mock("../../state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { environmentServerConfigsAtom: Atom.make(() => environmentConfigsMock.configs) }; +}); + +vi.mock("../../connection/catalog", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentCatalog: { + catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }), + }, + }; +}); + +vi.mock("../../state/shell", async () => { + const { Atom } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentShell: { + stateValueAtom: Atom.family((_id: EnvironmentId) => + Atom.make({ + status: "empty", + generation: 0, + removedAt: new Map(), + snapshot: Option.none(), + error: Option.none(), + }), + ), + }, + }; +}); vi.mock("expo-notifications", () => ({ AndroidImportance: { NONE: 0, HIGH: 4 }, @@ -167,6 +228,9 @@ vi.mock("react-native", () => ({ }, }, AppState: { + get currentState() { + return appStateMock.currentState; + }, addEventListener: (_event: string, listener: (state: string) => void) => { appStateMock.listeners.push(listener); return { @@ -218,10 +282,137 @@ function proofIat(proof: string): number { return decoded.iat; } +const activeAgentActivityRow = { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running" as const, + status: "Working", + updatedAt: "2026-05-25T13:07:00.000Z", + deepLink: "/threads/env-1/thread-1", +}; + +const activeAgentActivitySnapshot = { + aggregate: { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-05-25T13:07:00.000Z", + activities: [activeAgentActivityRow], + }, +} satisfies RelayAgentActivitySnapshotResponse; + +function snapshotRelayLayer( + getAgentActivitySnapshot: ( + input: Parameters[0], + ) => Effect.Effect< + RelayAgentActivitySnapshotResponse, + ManagedRelay.ManagedRelayClientError + > = () => Effect.succeed(activeAgentActivitySnapshot), +) { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + return Layer.succeed( + ManagedRelay.ManagedRelayClient, + ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + getT3ChatLink: () => Effect.die("unused"), + createT3ChatLinkRequest: () => Effect.die("unused"), + unlinkT3Chat: () => Effect.die("unused"), + listEnvironments: () => Effect.die("unused"), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.succeed({ ok: true }), + getAgentActivitySnapshot, + resetTokenCache: Effect.void, + }), + ); +} + const layerRelayTest = ManagedRelayLayer.layer("https://relay.example.test").pipe( Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)), ); +function setTestAtom(atom: Atom.Atom, value: A): void { + if (!Atom.isWritable(atom)) throw new Error("Expected a writable fixture atom"); + appAtomRegistry.set(atom, value); +} + +const liveEnvironmentId = EnvironmentId.make("env-1"); +const liveShellUpdatedAt = "2026-10-02T12:00:00.000Z"; +const liveProjectId = ProjectId.make("project-1"); + +function setLiveShell( + phase: "starting" | "running" | "ready" | null, + status: EnvironmentShellState["status"] = "live", + now = liveShellUpdatedAt, +): void { + const project: OrchestrationProjectShell = { + id: liveProjectId, + title: "Live project", + workspaceRoot: "/synthetic/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }; + const thread = makeRawThreadShell({ + id: ThreadId.make("local-thread"), + projectId: liveProjectId, + title: "Live task", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + createdAt: DateTime.makeUnsafe(now), + updatedAt: DateTime.makeUnsafe(now), + status: phase === "ready" ? "completed" : (phase ?? "idle"), + }); + setTestAtom(environmentShell.stateValueAtom(liveEnvironmentId), { + status, + generation: 0, + removedAt: new Map(), + error: Option.none(), + snapshot: Option.some({ + projects: [project], + threads: phase === null ? [] : [thread], + snapshotSequence: 1, + archivedThreads: [], + schemaVersion: 1, + }), + }); +} + +function addLiveEnvironment(): void { + setTestAtom(environmentCatalog.catalogValueAtom, { + isReady: true, + entries: new Map([ + [ + liveEnvironmentId, + { + target: new PrimaryConnectionTarget({ + environmentId: liveEnvironmentId, + label: "Live environment", + httpBaseUrl: "https://local.example.test", + wsBaseUrl: "wss://local.example.test/ws", + }), + profile: Option.none(), + enabled: true, + }, + ], + ]), + }); + setLiveShell(null); +} + const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")( function* () { let idlePasses = 0; @@ -254,6 +445,8 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { vi.restoreAllMocks(); + // Freshness windows compare against fixture timestamps, not the runner clock. + vi.spyOn(Date, "now").mockReturnValue(Date.parse(liveShellUpdatedAt)); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ type: "ios", data: "apns-token", @@ -269,19 +462,288 @@ describe("makeRelayDeviceRegistrationRequest", () => { backgroundRuntime.pending.length = 0; Constants.expoConfig!.extra = {}; __resetAgentAwarenessRemoteRegistrationForTest(); + appAtomRegistry.reset(); + appStateMock.currentState = "active"; appStateMock.listeners.length = 0; registrationRecordStore.current = null; vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); - vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false }); + vi.mocked(loadPreferences).mockReset(); + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); - widgetMocks.start.mockClear(); - publishWidgetMock.mockClear(); + widgetMocks.start.mockReset(); + widgetMocks.start.mockReturnValue({ + getId: () => "activity-1", + getPushToken: () => Promise.resolve(null), + addPushTokenListener: vi.fn(), + }); widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); + vi.mocked(publishAgentActivityWidget).mockReset().mockReturnValue(true); + vi.mocked(agentWidgetToken).mockReset().mockReturnValue(null); + vi.mocked(configureAgentWidgetRefresh).mockClear(); + vi.mocked(clearAgentWidgetRefresh).mockClear(); + }); + + it.effect.each(["unchanged", "sign-out", "account switch"] as const)( + "keeps existing Live Activity tokens with their owner across %s during widget refresh", + (transition) => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("old-account-activity-token")), + addPushTokenListener: vi.fn(), + end: vi.fn(() => Promise.resolve()), + }; + widgetMocks.getInstances.mockReturnValue([activity]); + const register = vi.fn( + () => Effect.succeed({ ok: true }), + ); + const client = yield* ManagedRelay.ManagedRelayClient; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("user-a-token"), "user-a"); + backgroundRuntime.pending.length = 0; + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provideService(ManagedRelay.ManagedRelayClient, { + ...client, + registerLiveActivity: register, + getAgentActivitySnapshot: () => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as(activeAgentActivitySnapshot), + ), + }), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + if (transition !== "unchanged") { + setAgentAwarenessRelayTokenProvider(null); + expect(activity.end).toHaveBeenCalledExactlyOnceWith("immediate"); + widgetMocks.getInstances.mockReturnValue([]); + if (transition === "account switch") { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("user-b-token"), "user-b"); + } + } + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + if (transition === "unchanged") { + expect(register).toHaveBeenCalledExactlyOnceWith({ + clerkToken: "user-a-token", + payload: { deviceId: "device-1", activityPushToken: "old-account-activity-token" }, + }); + } else { + expect(register).not.toHaveBeenCalled(); + expect(activity.getPushToken).not.toHaveBeenCalled(); + } + }).pipe(Effect.provide(snapshotRelayLayer()), Effect.scoped), + ); + + it.effect.each( + (["global", "scoped"] as const).flatMap((blockedRead) => + (["scope change", "sign-out", "account switch", "background"] as const).map((transition) => ({ + blockedRead, + transition, + })), + ), + )( + "keeps final relay priming independent of $transition during $blockedRead without crossing account ownership", + ({ blockedRead, transition }) => + Effect.gen(function* () { + if (blockedRead === "scoped") addLiveEnvironment(); + vi.mocked(loadPreferences).mockResolvedValue({ + liveActivitiesEnabled: true, + } as Preferences); + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.start.mockReturnValue(activity); + const remoteSnapshot: RelayAgentActivitySnapshotResponse = { + aggregate: { + ...activeAgentActivitySnapshot.aggregate, + activities: activeAgentActivitySnapshot.aggregate.activities.map((row) => ({ + ...row, + environmentId: EnvironmentId.make("remote-environment"), + })), + }, + }; + let globalReads = 0; + let scopedReads = 0; + const layer = snapshotRelayLayer(({ excludedEnvironmentIds }) => { + const kind = excludedEnvironmentIds ? "scoped" : "global"; + const ordinal = excludedEnvironmentIds ? ++scopedReads : ++globalReads; + const snapshot = excludedEnvironmentIds + ? { ...remoteSnapshot, excludedEnvironmentIds } + : remoteSnapshot; + return ordinal === 1 && kind === blockedRead + ? Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as(snapshot), + ) + : Effect.succeed(snapshot); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const prime = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + if (transition === "scope change") { + if (blockedRead === "global") addLiveEnvironment(); + else + setTestAtom(environmentCatalog.catalogValueAtom, { + isReady: true, + entries: new Map(), + }); + // The in-flight read rechecks the scope before publishing; no second + // foreground reconciliation is needed before that first response. + expect(backgroundRuntime.pending).toHaveLength(0); + } else if (transition === "background") { + appStateMock.currentState = "background"; + for (const listener of appStateMock.listeners) listener("background"); + } else { + setAgentAwarenessRelayTokenProvider(null); + if (transition === "account switch") + setAgentAwarenessRelayTokenProvider( + () => Promise.resolve("clerk-token-user-b"), + "user-b", + ); + } + const widgetBeforeRelease = vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]; + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(prime); + if (transition === "scope change") { + expect({ globalReads, scopedReads }).toEqual({ + globalReads: 1, + scopedReads: 1, + }); + expect(backgroundRuntime.pending).toHaveLength(0); + expect(widgetMocks.start).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + activeCount: 1, + activities: remoteSnapshot.aggregate!.activities, + }), + expect.any(Date), + ); + } else if (transition === "background") { + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(activity.getPushToken).not.toHaveBeenCalled(); + appStateMock.currentState = "active"; + for (const listener of appStateMock.listeners) listener("active"); + expect(backgroundRuntime.pending).toHaveLength(1); + const foreground = backgroundRuntime.pending.shift(); + if (!foreground) throw new Error("Expected foreground Live Activity reconciliation"); + const exit = yield* Effect.exit( + foreground.operation as Effect.Effect, + ).pipe(Effect.provide(layer)); + foreground.resolve(exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(widgetMocks.start).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + activeCount: 1, + activities: remoteSnapshot.aggregate!.activities, + }), + expect.any(Date), + ); + expect(activity.getPushToken).toHaveBeenCalledTimes(1); + expect(backgroundRuntime.pending).toHaveLength(0); + } else { + expect(globalReads).toBe(1); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(activity.getPushToken).not.toHaveBeenCalled(); + expect(widgetBeforeRelease?.activities).toEqual([]); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith(widgetBeforeRelease); + } + }).pipe(Effect.scoped), + ); + + it.each(["sign-out", "account switch"])( + "does not restore local-work widget data after %s during preference loading", + async (transition) => { + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Previous account thread", + projectTitle: "Previous account project", + }); + expect(loadPreferences).toHaveBeenCalledTimes(1); + // Register the same catch/then depth after the production continuation. + // Its receipt settles after the already-registered preference callback, + // without a timer, polling, or executing queued relay operations. + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + setAgentAwarenessRelayTokenProvider(null); + if (transition === "account switch") { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); + } + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: transition === "account switch" ? null : 0, + activities: [], + }), + ); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + await preferenceCallbackDrained; + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: transition === "account switch" ? null : 0, + activities: [], + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }, + ); + + it("still arms local work after a same-account token refresh during preference loading", async () => { + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Current account thread", + projectTitle: "Current account project", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("refreshed-token-user-a"), "user-a"); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + await preferenceCallbackDrained; + + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + expect(widgetMocks.start).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ threadTitle: "Current account thread" })], + }), + expect.any(Date), + ); + expect(publishAgentActivityWidget).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ activeCount: null, activities: [] }), + ); }); it("disables push features in Personal Team relay registrations", () => { @@ -500,7 +962,79 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); - it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { + it.effect("refreshes the widget after a delayed Live Activity push token registers", () => { + let onPushToken: ((event: { pushToken: string }) => void) | undefined; + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve(null)), + addPushTokenListener: vi.fn((listener: (event: { pushToken: string }) => void) => { + onPushToken = listener; + return { remove: vi.fn() }; + }), + }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + expect(onPushToken).toBeDefined(); + + onPushToken?.({ pushToken: "delayed-activity-token" }); + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("publishes the home-screen widget when a Live Activity is already armed", () => { + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + start: vi.fn(), + update: vi.fn(), + end: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(activity.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("publishes the home-screen widget when Live Activities are disabled", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it("ends local Live Activities and clears the home-screen widget on cloud sign-out", () => { + widgetMocks.dismissEnded.mockReset(); const end = vi.fn(() => Promise.resolve()); const activity = { getId: () => "activity-1", @@ -515,13 +1049,44 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(null); expect(end).toHaveBeenCalledWith("immediate"); - expect(widgetMocks.dismissEnded).toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + title: "T3 Code", + subtitle: "Sign in to see agent activity", + isSignedOut: true, + activeCount: 0, + activities: [], + }), + ); + widgetMocks.dismissEnded.mockReset(); expect(appStateMock.listeners).toHaveLength(0); + vi.mocked(publishAgentActivityWidget).mockClear(); + vi.mocked(clearAgentWidgetRefresh).mockClear(); + setAgentAwarenessRelayTokenProvider(null); + expect(clearAgentWidgetRefresh).toHaveBeenCalledOnce(); + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ isSignedOut: true, subtitle: "Sign in to see agent activity" }), + ); }); it.effect("registers the APNs device when cloud auth becomes available", () => { + addLiveEnvironment(); + setLiveShell("running"); + let nativeObservation: Parameters[0] | undefined; + vi.mocked(publishAgentActivityWidget).mockImplementation((props) => { + nativeObservation = props; + return true; + }); + vi.mocked(agentWidgetToken).mockImplementation((_identity, url) => { + if (url) nativeObservation = undefined; + return "a".repeat(64); + }); + let recoveryAtRegistration: Parameters | undefined; const fetchMock = vi.fn((request: RequestInfo | URL) => { const url = request instanceof Request ? request.url : String(request); + if (url.endsWith("/v1/mobile/devices")) { + recoveryAtRegistration = vi.mocked(configureAgentWidgetRefresh).mock.lastCall; + } return Promise.resolve( Response.json( url.endsWith("/v1/client/dpop-token") @@ -543,19 +1108,24 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); return Effect.gen(function* () { yield* runBackgroundOperations(); - const requestUrl = (request: unknown) => - request instanceof Request ? request.url : String(request); - const deviceCalls = fetchMock.mock.calls.filter(([request]) => - requestUrl(request).endsWith("/v1/mobile/devices"), - ); - expect(deviceCalls).toHaveLength(1); - const [request, init] = deviceCalls[0] as unknown as [unknown, RequestInit | undefined]; - const url = requestUrl(request); + expect(nativeObservation?.activities).toEqual([ + expect.objectContaining({ threadId: "local-thread", phase: "running" }), + ]); + expect(agentWidgetToken).toHaveBeenLastCalledWith("user-a", "https://relay.example.test"); + expect(recoveryAtRegistration).toEqual(["https://relay.example.test", "a".repeat(64)]); + const deviceCall = fetchMock.mock.calls.find((call) => { + const request = call[0]; + const url = request instanceof Request ? request.url : String(request); + return url === "https://relay.example.test/v1/mobile/devices"; + }); + expect(deviceCall).toBeDefined(); + const [request, init] = deviceCall as unknown as [unknown, RequestInit | undefined]; + const url = request instanceof Request ? request.url : String(request); const method = request instanceof Request ? request.method : init?.method; const headers = request instanceof Request ? request.headers : new Headers(init?.headers); const dpop = headers.get("dpop"); @@ -575,7 +1145,38 @@ describe("makeRelayDeviceRegistrationRequest", () => { nowEpochSeconds: proofIat(dpop), }), ).toMatchObject({ ok: true }); + const payload = + request instanceof Request + ? yield* Effect.promise(() => request.json()) + : JSON.parse(String(init?.body)); + expect(payload.widgetAccessToken).toBe("a".repeat(64)); + expect(registrationRecordStore.current?.signature).not.toContain(payload.widgetAccessToken); + expect(registrationRecordStore.current?.signature).toContain( + NodeCrypto.createHash("sha256").update(payload.widgetAccessToken).digest("base64url"), + ); + expect(configureAgentWidgetRefresh).toHaveBeenLastCalledWith( + "https://relay.example.test", + "a".repeat(64), + ); expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + const originalSignature = registrationRecordStore.current?.signature; + const registrationRequests = () => + fetchMock.mock.calls.filter(([request]) => + (request instanceof Request ? request.url : String(request)).endsWith( + "/v1/mobile/devices", + ), + ).length; + yield* refreshAgentAwarenessRegistration(); + expect(registrationRequests()).toBe(1); + vi.mocked(agentWidgetToken).mockReturnValue("b".repeat(64)); + yield* refreshAgentAwarenessRegistration(); + expect(registrationRequests()).toBe(2); + expect(registrationRecordStore.current?.signature).not.toBe(originalSignature); + expect(registrationRecordStore.current?.signature).not.toContain("b".repeat(64)); + releaseAgentAwarenessRelayTokenProvider(); + expect(clearAgentWidgetRefresh).not.toHaveBeenCalled(); + setAgentAwarenessRelayTokenProvider(null); + expect(clearAgentWidgetRefresh).toHaveBeenCalledOnce(); }).pipe(Effect.provide(layerRelayTest)); }); @@ -627,12 +1228,9 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, }; - // Sign-in first reads the widget snapshot, then registers the device; fail - // both lookups so the queued registration is the one that must recover. const tokenProvider = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("auth unavailable")) - .mockRejectedValueOnce(new Error("auth unavailable")) .mockResolvedValue("clerk-token-user-a"); setAgentAwarenessRelayTokenProvider(tokenProvider); const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; @@ -643,6 +1241,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); + // Device registration retries after the first auth miss, and the + // home-screen widget refresh independently reads the relay token. expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(layerRelayTest)); }); @@ -948,255 +1548,1454 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }, ); + it.effect("refreshes the home-screen widget after arming local agent work", () => { + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.start.mockReturnValueOnce(activity); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: true, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferenceCallbackDrained); + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); - describe("home-screen widget", () => { - // These cover the widget path only, so keep the Live Activity from priming. - beforeEach(() => { - vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences); + it.effect("refreshes the home-screen widget when local Live Activities are disabled", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: false, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); - const aggregate: RelayAgentActivityAggregateState = { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - updatedAt: "2026-10-18T10:00:00.000Z", - activities: [ - { - environmentId: "env-1" as EnvironmentId, - threadId: "thread-1" as ThreadId, - projectTitle: "t3code", - threadTitle: "Fix the widget", - modelTitle: "GPT-5.5", - phase: "running", - status: "Working", - updatedAt: "2026-10-18T10:00:00.000Z", - deepLink: "/threads/env-1/thread-1", + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferenceCallbackDrained); + yield* runBackgroundOperations(); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(2); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("refreshes the home-screen widget when a local Live Activity is already armed", () => { + widgetMocks.getInstances.mockReturnValueOnce([{}] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: true, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferenceCallbackDrained); + yield* runBackgroundOperations(); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(2); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("refreshes the home-screen widget when local Live Activity arming fails", () => { + widgetMocks.start.mockImplementationOnce(() => { + throw new Error("start failed"); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("discards an older widget snapshot when a newer refresh finishes first", () => + Effect.gen(function* () { + const firstReadStarted = yield* Deferred.make(); + const finishFirstRead = yield* Deferred.make(); + const olderSnapshot = { + aggregate: { + ...activeAgentActivitySnapshot.aggregate, + updatedAt: "2026-05-25T13:06:00.000Z", + activities: [ + { + ...activeAgentActivityRow, + status: "Older status", + updatedAt: "2026-05-25T13:06:00.000Z", + }, + ], }, - ], - }; + } satisfies RelayAgentActivitySnapshotResponse; + let readCount = 0; + const layer = snapshotRelayLayer(() => { + readCount++; + if (readCount === 1) { + return Deferred.succeed(firstReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFirstRead)), + Effect.as(olderSnapshot), + ); + } + return Effect.succeed(activeAgentActivitySnapshot); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; - // The shared fetch-backed relay layer keeps the first test's fetch stub, - // so these tests answer the snapshot read through a fake client instead. - function fakeRelay( - getAgentActivitySnapshot: () => Effect.Effect< - { readonly aggregate: typeof aggregate }, - ManagedRelay.ManagedRelayRequestFailedError - >, - ) { - Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; - const unexpected = () => Effect.die("Unexpected relay request"); - return Layer.succeed( - ManagedRelay.ManagedRelayClient, - ManagedRelay.ManagedRelayClient.of({ - relayUrl: "https://relay.example.test", - listEnvironments: unexpected, - listDevices: unexpected, - getT3ChatLink: unexpected, - createT3ChatLinkRequest: unexpected, - unlinkT3Chat: unexpected, - createEnvironmentLinkChallenge: unexpected, - linkEnvironment: unexpected, - unlinkEnvironment: unexpected, - getEnvironmentStatus: unexpected, - connectEnvironment: unexpected, - registerDevice: unexpected, - unregisterDevice: unexpected, - registerLiveActivity: unexpected, - getAgentActivitySnapshot, - resetTokenCache: Effect.void, + const olderRefresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(firstReadStarted); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + yield* Deferred.succeed(finishFirstRead, undefined); + yield* Fiber.join(olderRefresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(2); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activities: [expect.objectContaining({ status: "Working" })], }), ); - } + }).pipe(Effect.scoped), + ); - it.effect("registers an existing Live Activity before waiting on the widget read", () => { - const order: Array = []; + it.effect("does not prime a Live Activity from a read a newer one replaced", () => + Effect.gen(function* () { + const firstReadStarted = yield* Deferred.make(); + const finishFirstRead = yield* Deferred.make(); + let readCount = 0; + const layer = snapshotRelayLayer(() => { + readCount++; + if (readCount === 1) { + return Deferred.succeed(firstReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFirstRead)), + Effect.as(activeAgentActivitySnapshot), + ); + } + return Effect.succeed({ aggregate: null }); + }); + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true } as Preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + + const olderRefresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(firstReadStarted); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + yield* Deferred.succeed(finishFirstRead, undefined); + yield* Fiber.join(olderRefresh); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the native local-work seed separate from an in-flight widget snapshot", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as({ aggregate: null }), + ), + ); const activity = { getId: () => "activity-1", - getPushToken: vi.fn(() => { - order.push("push-token"); - return Promise.resolve(null); - }), + getPushToken: vi.fn(() => Promise.resolve("activity-token")), addPushTokenListener: vi.fn(), }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - const relay = fakeRelay(() => - Effect.sync(() => { - order.push("widget-snapshot"); - return { aggregate }; - }), + widgetMocks.getInstances + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValue([activity] as never); + widgetMocks.start.mockReturnValueOnce(activity); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: true, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValue(preferences); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + yield* Deferred.await(readStarted); - return Effect.gen(function* () { - yield* refreshActiveLiveActivityRemoteRegistration(); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + yield* Effect.promise(() => preferenceCallbackDrained); - expect(order).toEqual(["push-token", "widget-snapshot"]); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ - kind: "snapshot", - aggregate, - }); - }).pipe(Effect.provide(relay)); - }); + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); - it.effect("keeps the newest rows when an older overlapping read finishes last", () => + expect(widgetMocks.start).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Connecting" })], + }), + expect.any(Date), + ); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(2); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect.each(["global", "scoped"] as const)( + "adopts a local arm made during the final %s snapshot read instead of priming twice", + (blockedRead) => Effect.gen(function* () { - const newer = { ...aggregate, activeCount: 2 }; - const olderRequested = yield* Deferred.make(); - const olderResponse = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); - let reads = 0; - const relay = fakeRelay(() => { - reads++; - return reads === 1 - ? Deferred.succeed(olderRequested, undefined).pipe( - Effect.andThen(Deferred.await(olderResponse)), + addLiveEnvironment(); + const preferences = Promise.resolve({ liveActivitiesEnabled: true } as Preferences); + vi.mocked(loadPreferences).mockReturnValue(preferences); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + let finishToken!: (token: string) => void; + let tokenReadStarted!: () => void; + const pendingToken = new Promise((resolve) => { + finishToken = resolve; + }); + const tokenRead = new Promise((resolve) => { + tokenReadStarted = resolve; + }); + const makeActivity = () => ({ + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }); + const activities: Array> = []; + widgetMocks.getInstances.mockImplementation(() => activities as never); + widgetMocks.start.mockImplementation(() => { + const activity = makeActivity(); + if (activities.length === 0) + activity.getPushToken.mockImplementationOnce(() => { + tokenReadStarted(); + return pendingToken; + }); + activities.push(activity); + return activity; + }); + let globalReads = 0; + let scopedReads = 0; + const layer = snapshotRelayLayer(({ excludedEnvironmentIds }) => { + const kind = excludedEnvironmentIds ? "scoped" : "global"; + const ordinal = excludedEnvironmentIds ? ++scopedReads : ++globalReads; + const snapshot = excludedEnvironmentIds + ? { aggregate: null, excludedEnvironmentIds } + : activeAgentActivitySnapshot; + return ordinal === 1 && kind === blockedRead + ? Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as(snapshot), ) - : Effect.succeed({ aggregate: newer }); + : Effect.succeed(snapshot); }); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); - - const older = yield* Effect.forkChild( - refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, ); - yield* Deferred.await(olderRequested); - yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); - yield* Deferred.succeed(olderResponse, { aggregate }); - yield* Fiber.join(older); - - expect(publishWidgetMock).toHaveBeenLastCalledWith({ - kind: "snapshot", - aggregate: newer, + yield* Deferred.await(readStarted); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: liveEnvironmentId, + projectTitle: "Live project", + threadTitle: "Local task", }); - }), - ); + yield* Effect.promise(() => preferences.catch(() => null).then(() => undefined)); + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + const pending = backgroundRuntime.pending.shift(); + if (!pending) throw new Error("Expected local token registration"); + const localRegistration = yield* Effect.exit( + pending.operation as Effect.Effect, + ).pipe(Effect.provide(layer), Effect.forkChild); + yield* Effect.promise(() => tokenRead); + yield* Deferred.succeed(finishRead, undefined); + const startsBeforeTokenResolved = widgetMocks.start.mock.calls.length; + finishToken("activity-token"); + yield* Fiber.join(refresh); + pending.resolve(yield* Fiber.join(localRegistration)); + expect({ global: globalReads, scoped: scopedReads }).toEqual({ global: 2, scoped: 2 }); + expect(startsBeforeTokenResolved).toBe(1); + expect(activities).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect("does not prime a Live Activity after cloud sign-out", () => + Effect.gen(function* () { + let preferencesStarted!: () => void; + let finishPreferences!: (preferences: Preferences) => void; + const started = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockImplementationOnce(() => { + preferencesStarted(); + return preferences; + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - it.effect("an obsolete refresh does not discard the next account's read", () => + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer()), + Effect.forkChild, + ); + yield* Effect.promise(() => started); + + setAgentAwarenessRelayTokenProvider(null); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "Sign in to see agent activity", + isSignedOut: true, + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not prime from a snapshot that became idle while preferences loaded", () => + Effect.gen(function* () { + let preferencesStarted!: () => void; + let finishPreferences!: (preferences: Preferences) => void; + const started = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockImplementationOnce(() => { + preferencesStarted(); + return preferences; + }); + let snapshot: RelayAgentActivitySnapshotResponse = activeAgentActivitySnapshot; + const readSnapshot = vi.fn(() => Effect.succeed(snapshot)); + const layer = snapshotRelayLayer(readSnapshot); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Effect.promise(() => started); + + expect(readSnapshot).not.toHaveBeenCalled(); + snapshot = { aggregate: null }; + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(readSnapshot).toHaveBeenCalledTimes(1); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect.each([false, true])( + "publishes later local shell work after the registered local-start refresh was idle (Live Activities %s)", + (liveActivitiesEnabled) => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.start.mockReturnValueOnce(activity); + const preferences = Promise.resolve({ liveActivitiesEnabled } as Preferences); + vi.mocked(loadPreferences).mockReturnValue(preferences); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: liveEnvironmentId, + threadTitle: "Live task", + projectTitle: "Live project", + }); + const preferencesDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferencesDrained); + expect(widgetMocks.start).toHaveBeenCalledTimes(liveActivitiesEnabled ? 1 : 0); + yield* runBackgroundOperations(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + setLiveShell("starting"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [ + expect.objectContaining({ threadTitle: "Live task", status: "Connecting" }), + ], + }), + ); + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ threadTitle: "Live task", status: "Working" })], + }), + ); + setLiveShell("ready"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 0, + activities: [expect.objectContaining({ threadTitle: "Live task", status: "Done" })], + }), + ); + setLiveShell(null); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + expect(backgroundRuntime.pending).toHaveLength(0); + }).pipe(Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null })))); + }, + ); + it.effect.each(["completed", "multiple running"] as const)( + "keeps %s widget rows when delayed local arming is followed by a relay failure", + (state) => Effect.gen(function* () { - let markTokenRequested = () => {}; - const tokenRequested = new Promise((resolve) => { - markTokenRequested = resolve; + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null }))), + ); + setLiveShell("running"); + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.start.mockReturnValueOnce(activity); + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; }); - let releaseToken = () => {}; - widgetMocks.getInstances.mockReturnValueOnce([ - { - getId: () => "activity-a", - getPushToken: () => { - markTokenRequested(); - return new Promise((resolve) => { - releaseToken = () => resolve(null); - }); - }, - addPushTokenListener: vi.fn(), - }, - ] as never); - const requested = yield* Deferred.make(); - const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); - const relay = fakeRelay(() => - Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: liveEnvironmentId, + threadTitle: "Live task", + projectTitle: "Live project", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + expect(widgetMocks.start).not.toHaveBeenCalled(); + + if (state === "completed") { + setLiveShell("ready"); + } else { + const shell = appAtomRegistry.get(environmentShell.stateValueAtom(liveEnvironmentId)); + const snapshot = Option.getOrThrow(shell.snapshot); + const thread = snapshot.threads[0]; + if (!thread) throw new Error("Expected the running fixture thread"); + setTestAtom(environmentShell.stateValueAtom(liveEnvironmentId), { + ...shell, + snapshot: Option.some({ + ...snapshot, + threads: [ + thread, + { ...thread, id: ThreadId.make("second-thread"), title: "Second live task" }, + ], + }), + }); + } + const observed = vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]; + expect(observed?.activeCount).toBe(state === "completed" ? 0 : 2); + expect(observed?.activities).toHaveLength(state === "completed" ? 1 : 2); + + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Effect.promise(() => preferenceCallbackDrained); + expect(widgetMocks.start).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Connecting" })], + }), + expect.any(Date), ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(backgroundRuntime.pending).toHaveLength(1); + const pending = backgroundRuntime.pending.shift(); + if (!pending) throw new Error("Expected the local activity registration operation"); + let failedReads = 0; + const failedRelay = snapshotRelayLayer(() => { + failedReads++; + return Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "read relay agent activity snapshot", + transportFailed: true, + cause: new Error("Synthetic snapshot transport failure"), + }), + ); + }); + const exit = yield* Effect.exit( + pending.operation as Effect.Effect, + ).pipe(Effect.provide(failedRelay)); + pending.resolve(exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(failedReads).toBe(1); + expect(activity.getPushToken).toHaveBeenCalledTimes(1); + expect(backgroundRuntime.pending).toHaveLength(0); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith(observed); + }), + ); - const refreshA = yield* Effect.forkChild( - refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + it.effect("does not let a delayed idle relay response hide current live work", () => + Effect.gen(function* () { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as({ aggregate: null }), + ), + ); + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + setLiveShell("running"); + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ threadTitle: "Live task", status: "Working" })], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect.each(["cached", "synchronizing"] as const)( + "preserves last-observed work instead of false idle on %s", + (status) => + Effect.gen(function* () { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null }))), ); - yield* Effect.promise(() => tokenRequested); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); - const refreshB = yield* Effect.forkChild( - refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + setLiveShell("running"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]?.activeCount).toBe(1); + setLiveShell("running", status); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + isStale: true, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + setLiveShell(null); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 0, + isStale: false, + activities: [], + }), ); - yield* Deferred.await(requested); - releaseToken(); - yield* Fiber.join(refreshA); - yield* Deferred.succeed(response, { aggregate }); - yield* Fiber.join(refreshB); - - expect(publishWidgetMock).toHaveBeenLastCalledWith({ - kind: "snapshot", - aggregate, - }); }), - ); + ); - it.effect("does not prime a Live Activity from a read a newer one replaced", () => + it.effect("does not resurrect older relay Working after observed Done on disconnect", () => + Effect.gen(function* () { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const earlier = DateTime.formatIso( + DateTime.makeUnsafe(Date.parse(liveShellUpdatedAt) - 1_000), + ); + const relaySnapshot: RelayAgentActivitySnapshotResponse = { + aggregate: { + ...activeAgentActivitySnapshot.aggregate, + updatedAt: earlier, + activities: [ + { + ...activeAgentActivityRow, + threadId: ThreadId.make("local-thread"), + updatedAt: earlier, + }, + ], + }, + }; + const layer = snapshotRelayLayer(() => Effect.succeed(relaySnapshot)); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + setLiveShell("running"); + setLiveShell("ready"); + const observed = vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]; + expect(observed?.activeCount).toBe(0); + setLiveShell("ready", "cached"); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 0, + isStale: true, + activities: observed?.activities, + }), + ); + }), + ); + + it.effect.each([false, true])( + "keeps widget counts separate from Live Activity opt-out %s", + (disabled) => Effect.gen(function* () { - let markRequested = () => {}; - const requested = new Promise((resolve) => { - markRequested = resolve; + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + widgetMocks.getInstances.mockReturnValue([]); + vi.mocked(loadPreferences).mockResolvedValue({ + liveActivitiesEnabled: true, + } as Preferences); + const queries: Array | undefined> = []; + const layer = snapshotRelayLayer(({ excludedEnvironmentIds }) => { + queries.push(excludedEnvironmentIds); + return Effect.succeed( + excludedEnvironmentIds + ? { + aggregate: null, + excludedEnvironmentIds, + } + : { + ...activeAgentActivitySnapshot, + liveActivityAggregate: disabled + ? null + : { + ...activeAgentActivitySnapshot.aggregate, + activeCount: 6, + workingCount: 4, + attentionCount: 2, + totalCount: 7, + }, + }, + ); }); - let resolvePreferences = (_preferences: Preferences) => {}; - vi.mocked(loadPreferences).mockImplementationOnce(() => { - markRequested(); - return new Promise((resolve) => { - resolvePreferences = resolve; - }); - }); - const idle = { ...aggregate, activeCount: 0, activities: [] }; - let reads = 0; - const relay = fakeRelay(() => - Effect.succeed({ aggregate: ++reads === 1 ? aggregate : idle }), + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + expect(queries).toEqual([undefined, [liveEnvironmentId]]); + if (disabled) expect(widgetMocks.start).not.toHaveBeenCalled(); + else + expect(widgetMocks.start).toHaveBeenCalledWith( + expect.objectContaining({ + activities: activeAgentActivitySnapshot.aggregate.activities, + workingCount: 4, + attentionCount: 2, + totalCount: 7, + }), + expect.any(Date), + ); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + isStale: false, + activities: [expect.objectContaining({ threadId: "local-thread" })], + }), ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + }), + ); - const older = yield* Effect.forkChild( - refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + it.effect( + "publishes honest unknown totals for capped older relays without hiding current rows", + () => + Effect.gen(function* () { + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide( + snapshotRelayLayer(() => + Effect.succeed({ + aggregate: { ...activeAgentActivitySnapshot.aggregate, activeCount: 12 }, + }), + ), + ), ); - yield* Effect.promise(() => requested); - yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); - resolvePreferences({ liveActivitiesEnabled: true } as Preferences); - yield* Fiber.join(older); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: null, + isStale: false, + subtitle: "Activity count unavailable", + activities: [expect.objectContaining({ threadId: "local-thread", status: "Working" })], + }), + ); + }), + ); - expect(widgetMocks.start).not.toHaveBeenCalled(); + it.effect( + "publishes complete local rows when the first relay read fails without inferring a total", + () => + Effect.gen(function* () { + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide( + snapshotRelayLayer(() => + Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "read relay agent activity snapshot", + transportFailed: true, + cause: new Error("Synthetic first read failure"), + }), + ), + ), + ), + ); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: null, + isStale: false, + observedActivities: { + [liveEnvironmentId]: [expect.objectContaining({ threadId: "local-thread" })], + }, + activities: [expect.objectContaining({ threadId: "local-thread", status: "Working" })], + }), + ); }), - ); + ); + + it.effect("re-reads a changed exclusion scope instead of publishing the old scoped total", () => + Effect.gen(function* () { + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const queries: Array | undefined> = []; + const layer = snapshotRelayLayer(({ excludedEnvironmentIds }) => { + queries.push(excludedEnvironmentIds); + if (excludedEnvironmentIds?.length === 1) + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.as({ + aggregate: { ...activeAgentActivitySnapshot.aggregate, activeCount: 12 }, + excludedEnvironmentIds, + }), + ); + return Effect.succeed({ + aggregate: null, + excludedEnvironmentIds: excludedEnvironmentIds ?? [], + }); + }); + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(started); + const other = EnvironmentId.make("other"); + const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); + setTestAtom(environmentShell.stateValueAtom(other), { + status: "live", + generation: 0, + removedAt: new Map(), + error: Option.none(), + snapshot: Option.some({ + projects: [], + threads: [], + snapshotSequence: 1, + archivedThreads: [], + schemaVersion: 1, + }), + }); + setTestAtom(environmentCatalog.catalogValueAtom, { + ...catalog, + entries: new Map([ + ...catalog.entries, + [ + other, + { + target: new PrimaryConnectionTarget({ + environmentId: other, + label: "Other", + httpBaseUrl: "https://other.example.test", + wsBaseUrl: "wss://other.example.test/ws", + }), + profile: Option.none(), + enabled: true, + }, + ], + ]), + }); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(refresh); + expect(queries).toEqual([undefined, [liveEnvironmentId], [liveEnvironmentId, other]]); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 1, isStale: false }), + ); + expect( + vi + .mocked(publishAgentActivityWidget) + .mock.calls.some(([props]) => props.activeCount === 13), + ).toBe(false); + }).pipe(Effect.scoped), + ); - it.effect("drops a snapshot that arrives after a sign-out, even on re-sign-in", () => + it.effect("evicts retained observations on catalog removal and account switch", () => + Effect.gen(function* () { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const layer = snapshotRelayLayer(({ excludedEnvironmentIds }) => + Effect.succeed({ aggregate: null, excludedEnvironmentIds: excludedEnvironmentIds ?? [] }), + ); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + setLiveShell("running"); + setLiveShell("running", "cached"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]?.isStale).toBe(true); + setTestAtom(environmentCatalog.catalogValueAtom, { isReady: true, entries: new Map() }); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, isStale: false, activities: [] }), + ); + addLiveEnvironment(); + setLiveShell("running"); + setLiveShell("running", "cached"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, isStale: false, activities: [] }), + ); + }), + ); + + it.effect.each(["account switch", "provider remount"] as const)( + "clears the prior account's native widget on %s even when the relay is unavailable", + (transition) => Effect.gen(function* () { - const requested = yield* Deferred.make(); - const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); - const relay = fakeRelay(() => - Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer()), + ); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: activeAgentActivitySnapshot.aggregate.activities, + }), + ); + if (transition === "provider remount") releaseAgentAwarenessRelayTokenProvider(); + vi.mocked(clearAgentWidgetRefresh).mockClear(); + vi.mocked(agentWidgetToken).mockClear(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-b"), "user-b"); + expect(agentWidgetToken).toHaveBeenCalledWith("user-b"); + if (transition === "account switch") expect(clearAgentWidgetRefresh).toHaveBeenCalledOnce(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: null, activities: [] }), ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide( + snapshotRelayLayer(() => + Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "read relay agent activity snapshot", + transportFailed: true, + cause: new Error("offline"), + }), + ), + ), + ), + ); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: null, activities: [] }), + ); + }), + ); - const refresh = yield* Effect.forkChild( - refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + it.effect("publishes freshness changes even when rows and count are unchanged", () => + Effect.gen(function* () { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null }))), + ); + setLiveShell("running"); + setLiveShell("running", "live", "2026-10-02T12:00:00.500Z"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + observedActivities: { + [liveEnvironmentId]: [ + expect.objectContaining({ updatedAt: "2026-10-02T12:00:00.500Z" }), + ], + }, + }), + ); + const count = vi.mocked(publishAgentActivityWidget).mock.calls.length; + setLiveShell("running", "cached"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count + 1); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]?.isStale).toBe(true); + setLiveShell("running", "synchronizing"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count + 1); + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count + 2); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]?.isStale).toBe(false); + }), + ); + + it.effect( + "uses live idle and deletion over relay work, retaining local evidence when disconnected", + () => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), ); - yield* Deferred.await(requested); - setAgentAwarenessRelayTokenProvider(null); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); - publishWidgetMock.mockClear(); - yield* Deferred.succeed(response, { aggregate }); - yield* Fiber.join(refresh); + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activities: [expect.objectContaining({ threadTitle: "Live task" })], + }), + ); + setLiveShell("ready", "cached"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + isStale: true, + activities: [expect.objectContaining({ threadTitle: "Live task" })], + }), + ); + setLiveShell(null); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }, + ); - expect(publishWidgetMock).not.toHaveBeenCalled(); - }), - ); + it.effect("detaches on background and resumes from the latest shell on foreground", () => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + setLiveShell("running"); + appStateMock.currentState = "background"; + for (const listener of appStateMock.listeners) listener("background"); + const publishedBeforeBackground = vi.mocked(publishAgentActivityWidget).mock.calls.length; + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall?.[0]?.isStale).toBe(false); + setLiveShell("ready"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(publishedBeforeBackground); + appStateMock.currentState = "active"; + for (const listener of appStateMock.listeners) listener("active"); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 0, + activities: [expect.objectContaining({ status: "Done" })], + }), + ); + expect(backgroundRuntime.pending).toHaveLength(1); + }).pipe(Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null })))); + }); - it.effect("does not prime a Live Activity after sign-out during the preference read", () => + it.effect( + "keeps the original deadline when a background relay read cannot validate retained observations", + () => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + setLiveShell("running"); + appStateMock.currentState = "background"; + for (const listener of appStateMock.listeners) listener("background"); + setLiveShell(null); + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + isStale: false, + activities: [expect.objectContaining({ threadTitle: "Live task" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }, + ); + + it.effect("deduplicates timestamp-only shell updates and same-account observer refreshes", () => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + setLiveShell("running"); + const count = vi.mocked(publishAgentActivityWidget).mock.calls.length; + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider( + () => Promise.resolve("refreshed-token-user-a"), + "user-a", + ); + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count); + setLiveShell("ready"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count + 1); + }).pipe(Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null })))); + }); + + it.effect("retries identical widget content after a failed native publish", () => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + vi.mocked(publishAgentActivityWidget).mockReturnValueOnce(false); + setLiveShell("running"); + const count = vi.mocked(publishAgentActivityWidget).mock.calls.length; + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count + 1); + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count + 1); + }).pipe(Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null })))); + }); + + it.effect( + "does not renew disconnected work from a fresh read of an older relay observation", + () => Effect.gen(function* () { - let markRequested = () => {}; - const requested = new Promise((resolve) => { - markRequested = resolve; - }); - let resolvePreferences = (_preferences: Preferences) => {}; - vi.mocked(loadPreferences).mockImplementationOnce(() => { - markRequested(); - return new Promise((resolve) => { - resolvePreferences = resolve; - }); + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const layer = snapshotRelayLayer(() => Effect.succeed({ aggregate: null })); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + const deadline = vi.mocked(publishAgentActivityWidget).mock.lastCall![0].expiresAt; + + clock.mockReturnValue(now + 60_000); + setLiveShell("running", "cached"); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + const retained = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + expect(retained).toMatchObject({ isStale: true, expiresAt: deadline }); + expect(retained.activities[0]?.threadTitle).toBe("Live task"); + + appStateMock.currentState = "background"; + for (const listener of appStateMock.listeners) listener("background"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0]).toMatchObject({ + isStale: true, + expiresAt: deadline, }); - const relay = fakeRelay(() => Effect.succeed({ aggregate })); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + }), + ); - const refresh = yield* Effect.forkChild( - refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), - ); - yield* Effect.promise(() => requested); - setAgentAwarenessRelayTokenProvider(null); - resolvePreferences({ liveActivitiesEnabled: true } as Preferences); - yield* Fiber.join(refresh); + it("renews a confirmed live shell but not an unrelated atom recomputation", () => { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + const original = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + expect(original.expiresAt).toBe(now + AGENT_ACTIVITY_FRESHNESS_MS); + const publications = vi.mocked(publishAgentActivityWidget).mock.calls.length; + + clock.mockReturnValue(now + 30_000); + const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); + setTestAtom(environmentCatalog.catalogValueAtom, { ...catalog }); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(publications); + + setLiveShell("running"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0].expiresAt).toBe( + now + 30_000 + AGENT_ACTIVITY_FRESHNESS_MS, + ); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(publications + 1); + }); - expect(widgetMocks.start).not.toHaveBeenCalled(); - }), + it("does not let an old idle environment expire freshly observed work", () => { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + addLiveEnvironment(); + const other = EnvironmentId.make("idle-environment"); + setTestAtom( + environmentShell.stateValueAtom(other), + appAtomRegistry.get(environmentShell.stateValueAtom(liveEnvironmentId)), + ); + const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); + setTestAtom(environmentCatalog.catalogValueAtom, { + ...catalog, + entries: new Map([ + ...catalog.entries, + [ + other, + { + target: new PrimaryConnectionTarget({ + environmentId: other, + label: "Idle", + httpBaseUrl: "https://idle.example.test", + wsBaseUrl: "wss://idle.example.test/ws", + }), + profile: Option.none(), + enabled: true, + }, + ], + ]), + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + clock.mockReturnValue(now + AGENT_ACTIVITY_FRESHNESS_MS + 60_000); + setLiveShell("running"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0]).toMatchObject({ + expiresAt: now + AGENT_ACTIVITY_FRESHNESS_MS * 2 + 60_000, + }); + }); + + it("keeps the full freshness interval for a shell observed late in a minute", () => { + const now = Date.parse("2026-10-02T12:00:59.000Z"); + vi.spyOn(Date, "now").mockReturnValue(now); + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0].expiresAt).toBe( + now + AGENT_ACTIVITY_FRESHNESS_MS, ); }); + + it.effect.each([ + null, + { + ...activeAgentActivitySnapshot.aggregate, + activeCount: 0, + activities: [{ ...activeAgentActivityRow, phase: "completed" as const }], + }, + ])( + "does not expire fresh direct work because an empty or terminal relay read is old: %s", + (aggregate) => + Effect.gen(function* () { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate }))), + ); + clock.mockReturnValue(now + AGENT_ACTIVITY_FRESHNESS_MS + 59_000); + setLiveShell("running"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0].expiresAt).toBe( + now + AGENT_ACTIVITY_FRESHNESS_MS * 2 + 59_000, + ); + }), + ); + + it.effect("renews unchanged confirmed relay content, but not a failed read", () => + Effect.gen(function* () { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + yield* TestClock.setTime(now); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const layer = snapshotRelayLayer(); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + const original = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + expect(original.expiresAt).toBe(now + 60 * 60_000); + + clock.mockReturnValue(now + 30_000); + yield* TestClock.setTime(now + 30_000); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + const renewed = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + expect(renewed.activities).toEqual(original.activities); + expect(renewed.expiresAt).toBe(now + 30_000 + 60 * 60_000); + const publications = vi.mocked(publishAgentActivityWidget).mock.calls.length; + + clock.mockReturnValue(now + 120_000); + yield* TestClock.setTime(now + 120_000); + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide( + snapshotRelayLayer(() => + Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "read relay agent activity snapshot", + transportFailed: true, + cause: new Error("offline"), + }), + ), + ), + ), + ); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(publications); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0].expiresAt).toBe( + renewed.expiresAt, + ); + }), + ); + + it.effect("does not renew expiration when an older unchanged relay read finishes last", () => + Effect.gen(function* () { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + yield* TestClock.setTime(now); + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const older = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide( + snapshotRelayLayer(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.as(activeAgentActivitySnapshot), + ), + ), + ), + Effect.forkChild, + ); + yield* Deferred.await(started); + clock.mockReturnValue(now + 60_000); + yield* TestClock.setTime(now + 60_000); + yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer()), + ); + const publications = vi.mocked(publishAgentActivityWidget).mock.calls.length; + clock.mockReturnValue(now + 120_000); + yield* TestClock.setTime(now + 120_000); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(older); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(publications); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0].expiresAt).toBe( + now + 60_000 + 60 * 60_000, + ); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a slow read's original deadline instead of freshening it on arrival", () => + Effect.gen(function* () { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + yield* TestClock.setTime(now); + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide( + snapshotRelayLayer(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.as(activeAgentActivitySnapshot), + ), + ), + ), + Effect.forkChild, + ); + yield* Deferred.await(started); + const arrivedAt = now + 60 * 60_000 + 1; + clock.mockReturnValue(arrivedAt); + yield* TestClock.setTime(arrivedAt); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(refresh); + const publication = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + expect(publication.expiresAt).toBe(now + 60 * 60_000); + expect(agentActivityTimeline(publication, arrivedAt)).toEqual([ + { + date: DateTime.toDateUtc(DateTime.makeUnsafe(arrivedAt)), + props: expect.objectContaining({ isExpired: true, activeCount: -1 }), + }, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("renews unchanged live observations and preserves their deadline on background", () => + Effect.gen(function* () { + const now = Date.parse("2026-10-02T12:00:00.000Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + addLiveEnvironment(); + setLiveShell("running"); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a"); + backgroundRuntime.pending.length = 0; + const layer = snapshotRelayLayer(({ excludedEnvironmentIds }) => + Effect.succeed({ aggregate: null, excludedEnvironmentIds: excludedEnvironmentIds ?? [] }), + ); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + const original = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + + clock.mockReturnValue(now + 60_000); + setLiveShell("running"); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + const renewed = vi.mocked(publishAgentActivityWidget).mock.lastCall![0]; + expect(renewed.activities.map(({ updatedAt: _updatedAt, ...row }) => row)).toEqual( + original.activities.map(({ updatedAt: _updatedAt, ...row }) => row), + ); + expect(renewed.expiresAt).toBe(now + 60_000 + AGENT_ACTIVITY_FRESHNESS_MS); + + const backgroundedAt = now + 120_000; + clock.mockReturnValue(backgroundedAt); + appStateMock.currentState = "background"; + for (const listener of appStateMock.listeners) listener("background"); + expect(vi.mocked(publishAgentActivityWidget).mock.lastCall![0]).toMatchObject({ + isStale: false, + expiresAt: renewed.expiresAt, + }); + const timeline = agentActivityTimeline(renewed, backgroundedAt); + expect(timeline[0]?.props.isExpired).toBeUndefined(); + expect(timeline[1]?.date.getTime()).toBe(renewed.expiresAt); + expect(timeline[1]?.props.activities[0]).toMatchObject({ + phase: "stale", + status: "Out of date", + }); + }), + ); + + it.effect("detaches the shell publisher on sign-out and provider release", () => { + addLiveEnvironment(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + backgroundRuntime.pending.length = 0; + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + setLiveShell("running"); + releaseAgentAwarenessRelayTokenProvider(); + const count = vi.mocked(publishAgentActivityWidget).mock.calls.length; + setLiveShell("ready"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(count); + expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + yield* refreshActiveLiveActivityRemoteRegistration(); + setAgentAwarenessRelayTokenProvider(null); + const countAfterSignout = vi.mocked(publishAgentActivityWidget).mock.calls.length; + setLiveShell("running"); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(countAfterSignout); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + }).pipe(Effect.provide(snapshotRelayLayer(() => Effect.succeed({ aggregate: null })))); + }); + + it.effect("does not restore an in-flight widget snapshot after cloud sign-out", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as(activeAgentActivitySnapshot), + ), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + setAgentAwarenessRelayTokenProvider(null); + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(2); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "Sign in to see agent activity", + isSignedOut: true, + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2b972b0ccc2d..cbf4f356bd65d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -11,12 +11,14 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { AppState, Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { type RelayDeviceRegistrationRequest, type RelayAgentActivitySnapshotResponse, type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { computeDpopAccessTokenHash } from "@t3tools/shared/dpop"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { isAtomCommandInterrupted, @@ -26,6 +28,8 @@ import { import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentCatalog } from "../../connection/catalog"; +import { environmentShell } from "../../state/shell"; import { environmentServerConfigsAtom } from "../../state/server"; import type { Preferences } from "../../persistence/mobile-preferences"; import { @@ -37,16 +41,28 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; -import type { AgentActivityWidgetSource } from "../../widgets/agentActivityWidgetTimeline"; -import { publishAgentActivityWidget } from "../../widgets/publishAgentActivityWidget"; +import { AGENT_ACTIVITY_FRESHNESS_MS } from "../../widgets/agentActivityTimeline"; import { dismissEndedAgentLiveActivities, getAgentLiveActivities, + publishAgentActivityWidget, startAgentLiveActivity, } from "./agentLiveActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; +import { + agentWidgetToken, + configureAgentWidgetRefresh, + clearAgentWidgetRefresh, +} from "./agentWidgetRefresh"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; +import { + createLiveWidgetActivitiesAtom, + reconcileWidgetActivity, + retainUnconfirmedWidgetActivities, + sameWidgetEnvironmentScope, + type LiveWidgetActivities, +} from "./liveWidgetActivity"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; @@ -91,9 +107,6 @@ const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); const registeredActivityPushTokens = new Map(); -// Refreshes can overlap (sign-in, foreground, retry); only the newest read may -// repaint, so a slow older response never overwrites newer rows. -let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; let lastNativePushToken: string | null = null; let registeredWithPushToken = false; @@ -139,6 +152,25 @@ let activeLiveActivityRegistrationRetry: ReturnType | null = let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; let deviceRegistrationGeneration = 0; +let widgetRefreshGeneration = 0; +const liveWidgetActivitiesAtom = createLiveWidgetActivitiesAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, + now: () => Date.now(), +}); +let liveWidgetSubscription: (() => void) | null = null; +let liveWidgetActivities: LiveWidgetActivities = new Map(); +let observedWidgetActivities: LiveWidgetActivities = new Map(); +let relayWidgetSnapshot: RelayAgentActivitySnapshotResponse | null = null; +// The newest accepted global read; a superseded refresh primes from it instead of its own. +let acceptedPrimingSnapshot: { + readonly snapshot: RelayAgentActivitySnapshotResponse; + readonly startedAt: number; +} | null = null; +let relayWidgetConfirmedAt: number | null = null; +const widgetShellObservations = new Map(); +const widgetShellConfirmedAt = new Map(); +let publishedWidgetContent: string | null = null; let activeDeviceRegistration: { readonly input: DeviceRegistrationInput; operation: Promise; @@ -198,11 +230,13 @@ export function setAgentAwarenessRelayTokenProvider( // An unset JS identity may mean remount; native configure checks the persisted account on cold start. if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); - } - if (provider) { - publishWidget({ kind: "loading" }); + if (provider) clearAgentWidgetRefresh(); } androidDeviceReplayedAt = null; + stopLiveWidgetObserver(); + relayWidgetSnapshot = null; + acceptedPrimingSnapshot = null; + relayWidgetConfirmedAt = null; deviceRegistrationGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; @@ -212,6 +246,8 @@ export function setAgentAwarenessRelayTokenProvider( relayTokenProviderIdentity = provider ? (identity ?? null) : null; if (!provider) { clearAndroidAgentNotifications(); + clearAgentWidgetRefresh(); + publishedWidgetContent = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -222,15 +258,33 @@ export function setAgentAwarenessRelayTokenProvider( } // Signed-out activities would be orphaned because the relay can no longer update or end them. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); - publishWidget({ kind: "signed-out" }); + publishRegularWidget({ + ...idleWidgetProps(), + isSignedOut: true, + subtitle: "Sign in to see agent activity", + }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { logRegistrationError("clear registration record on sign-out failed", error); }); return; } + // Native timelines survive JS restarts. Clear unowned content before a new + // session publishes, even if its relay read fails. Token refreshes keep it. + if (!isExistingIdentity) { + // Compare the persisted native identity before any asynchronous registration + // work; a cold JS runtime cannot otherwise identify the previous account. + if (identity) agentWidgetToken(identity); + publishedWidgetContent = null; + publishRegularWidget({ + ...idleWidgetProps(), + activeCount: null, + subtitle: "Activity count unavailable", + }); + } ensurePushTokenListener(); ensureAppStateListener(); + startLiveWidgetObserver(); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after cloud sign-in failed", @@ -246,6 +300,10 @@ export function setAgentAwarenessRelayTokenProvider( // Provider remounts retain a valid relay registration; teardown must preserve it and lock-screen activities. export function releaseAgentAwarenessRelayTokenProvider(): void { + stopLiveWidgetObserver(); + relayWidgetSnapshot = null; + acceptedPrimingSnapshot = null; + relayWidgetConfirmedAt = null; relayTokenProvider = null; relayTokenProviderIdentity = null; deviceRegistrationGeneration++; @@ -363,6 +421,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { return [ body.deviceId, body.pushToken ?? "", + body.widgetAccessToken ? computeDpopAccessTokenHash(body.widgetAccessToken) : "", body.bundleId ?? "", body.apsEnvironment ?? "", body.appVersion ?? "", @@ -411,7 +470,18 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith // record or report the previous account's registration as current. return; } - const payload = body; + const widgetRefreshEnabled = Platform.OS === "ios" && supportsAgentAwarenessPush() && identity; + const widgetToken = widgetRefreshEnabled ? agentWidgetToken(identity, relayConfig.url) : null; + // Persist the revocation URL before exposing this credential to the relay. + if (widgetToken) configureAgentWidgetRefresh(relayConfig.url, widgetToken); + if (widgetRefreshEnabled) { + // Native credential rotation invalidates the stored observation's generation. + publishedWidgetContent = null; + publishReconciledWidget(); + } + const payload = widgetToken ? { ...body, widgetAccessToken: widgetToken } : body; + // The relay URL participates so pointing the app at a different relay + // invalidates the record and re-registers there. const signature = `${relayConfig.url}|${registrationSignature(payload)}`; // Android registration also silently replays the current card. Collapse // foreground bursts, but repair missed pushes on cold start or a return @@ -487,6 +557,149 @@ function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolea ); } +function widgetPropsFromAggregate( + aggregate: NonNullable, +): AgentActivityProps { + return { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + workingCount: aggregate.workingCount, + attentionCount: aggregate.attentionCount, + totalCount: aggregate.totalCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }; +} + +function idleWidgetProps(): AgentActivityProps { + return { + title: "T3 Code", + subtitle: "No active agents", + activeCount: 0, + updatedAt: new Date().toISOString(), + activities: [], + }; +} + +function publishRegularWidget(props: AgentActivityProps): void { + // Streaming text changes shell timestamps without changing widget content. + const content = JSON.stringify({ + activeCount: props.activeCount, + workingCount: props.workingCount, + attentionCount: props.attentionCount, + totalCount: props.totalCount, + isSignedOut: props.isSignedOut, + observedActivities: props.observedActivities, + observedEnvironmentIds: props.observedEnvironmentIds + ? [...props.observedEnvironmentIds].sort() + : undefined, + isStale: props.isStale, + expiresAt: props.expiresAt, + activities: props.activities.map(({ updatedAt: _updatedAt, ...row }) => row), + }); + if (content === publishedWidgetContent) return; + if (publishAgentActivityWidget(props)) publishedWidgetContent = content; +} + +function publishReconciledWidget(): void { + if (!relayTokenProvider) return; + if (relayWidgetSnapshot === null && observedWidgetActivities.size === 0) return; + const result = reconcileWidgetActivity(observedWidgetActivities, relayWidgetSnapshot); + const confirmations = [...observedWidgetActivities.entries()] + .filter(([, rows]) => rows.some((row) => row.phase !== "completed" && row.phase !== "failed")) + .map(([id]) => widgetShellConfirmedAt.get(id) ?? 0); + const hasDirectWork = confirmations.length > 0; + if (relayWidgetConfirmedAt !== null && (relayWidgetSnapshot?.aggregate?.activeCount ?? 0) > 0) + confirmations.push(relayWidgetConfirmedAt); + const confirmedAt = + confirmations.length > 0 ? Math.min(...confirmations) : (relayWidgetConfirmedAt ?? Date.now()); + publishRegularWidget({ + title: "T3 Code", + subtitle: + result.activeCount === null + ? "Activity count unavailable" + : result.activeCount > 0 + ? "Agent work in progress" + : "No active agents", + activeCount: result.activeCount, + workingCount: result.workingCount, + attentionCount: result.attentionCount, + totalCount: result.totalCount, + observedEnvironmentIds: [...observedWidgetActivities.keys()], + observedActivities: Object.fromEntries( + [...observedWidgetActivities].map(([id, rows]) => [ + id, + rows.map(({ threadId, phase, updatedAt }) => ({ threadId, phase, updatedAt })), + ]), + ), + isStale: [...observedWidgetActivities.keys()].some((id) => !liveWidgetActivities.has(id)), + expiresAt: confirmedAt + (hasDirectWork ? AGENT_ACTIVITY_FRESHNESS_MS : 60 * 60_000), + updatedAt: new Date().toISOString(), + activities: result.activities, + }); +} + +function stopLiveWidgetObserver(clearObservations = true): void { + liveWidgetSubscription?.(); + liveWidgetSubscription = null; + // Suspending observation does not disconnect a healthy environment. Keep + // its last freshness state and let the native timeline expire it on time. + if (clearObservations) { + liveWidgetActivities = new Map(); + observedWidgetActivities = new Map(); + widgetShellObservations.clear(); + widgetShellConfirmedAt.clear(); + } + widgetRefreshGeneration++; +} + +function startLiveWidgetObserver(): void { + if ( + liveWidgetSubscription || + !canRegisterRemoteLiveActivities() || + !relayTokenProvider || + AppState.currentState !== "active" + ) + return; + liveWidgetSubscription = appAtomRegistry.subscribe( + liveWidgetActivitiesAtom, + (activities) => { + const previousScope = [...observedWidgetActivities.keys()]; + liveWidgetActivities = activities; + const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); + const observed = new Map(observedWidgetActivities); + for (const environmentId of observed.keys()) { + if (catalog.entries.get(environmentId)?.enabled !== true) { + observed.delete(environmentId); + widgetShellObservations.delete(environmentId); + widgetShellConfirmedAt.delete(environmentId); + } + } + for (const [environmentId, rows] of activities) { + const shell = appAtomRegistry.get(environmentShell.stateValueAtom(environmentId)); + if (widgetShellObservations.get(environmentId) !== shell) { + widgetShellObservations.set(environmentId, shell); + // Coalesce streaming shell updates within a minute. Timer-driven + // atom recomputations alone cannot confirm an unchanged shell. + widgetShellConfirmedAt.set(environmentId, Date.now()); + } + observed.set(environmentId, rows); + } + observedWidgetActivities = observed; + publishReconciledWidget(); + if ( + relayWidgetSnapshot !== null && + !sameWidgetEnvironmentScope(previousScope, [...observed.keys()]) + ) { + runRegistrationInBackground(refreshAgentActivityWidget(), "widget scope refresh failed"); + } + }, + { immediate: true }, + ); +} + +// Arm while foregrounded so the token can register; skip nonpublishing environments to avoid a permanently stale seed. export function armAgentAwarenessLiveActivityForLocalWork(input: { readonly environmentId: EnvironmentId; readonly threadTitle: string; @@ -498,72 +711,100 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { if (!environmentPublishesAgentActivity(input.environmentId)) { return; } + const expectedDeviceGeneration = deviceRegistrationGeneration; void loadPreferences() .catch(() => null) .then((preferences) => { + if (deviceRegistrationGeneration !== expectedDeviceGeneration || !relayTokenProvider) { + return; + } if (preferences?.liveActivitiesEnabled === false) { + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after local task start failed", + ); return; } armAgentAwarenessLiveActivityForLocalWorkNow(input); }); } +function localWorkStartingWidgetProps(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): AgentActivityProps { + const nowIso = new Date(Date.now()).toISOString(); + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: nowIso, + activities: [ + { + environmentId: "", + threadId: "", + projectTitle: input.projectTitle, + threadTitle: input.threadTitle, + modelTitle: "", + phase: "starting", + status: "Connecting", + updatedAt: nowIso, + deepLink: "/", + }, + ], + }; +} + function armAgentAwarenessLiveActivityForLocalWorkNow(input: { readonly threadTitle: string; readonly projectTitle: string; }): void { try { if (getAgentLiveActivities().length > 0) { + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after local task start failed", + ); return; } - const nowIso = new Date(Date.now()).toISOString(); - const activity = startAgentLiveActivity( - { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - updatedAt: nowIso, - activities: [ - { - environmentId: "", - threadId: "", - projectTitle: input.projectTitle, - threadTitle: input.threadTitle, - modelTitle: "", - phase: "starting", - status: "Connecting", - updatedAt: nowIso, - deepLink: "/", - }, - ], - }, - liveActivityStaleDate(), - ); + const props = localWorkStartingWidgetProps(input); + // Only the foreground-only Live Activity needs this optimistic seed. + // Regular widgets keep observed state until real work arrives. + const activity = startAgentLiveActivity(props, liveActivityStaleDate()); if (!activity) { return; } runRegistrationInBackground( - registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), + registerLiveActivityPushToken({ activity }).pipe( + Effect.ensuring(refreshAgentActivityWidget()), + Effect.asVoid, + ), "live activity arming after local task start failed", ); } catch (error) { logRegistrationError("live activity arming failed", error); + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after live activity arming failed", + ); } } -const readAgentActivitySnapshot: () => Effect.Effect< +const readAgentActivitySnapshot: ( + excludedEnvironmentIds?: ReadonlyArray, +) => Effect.Effect< RelayAgentActivitySnapshotResponse | null, never, ManagedRelay.ManagedRelayClient > = Effect.fn("remoteRegistration.readAgentActivitySnapshot")( - function* () { + function* (excludedEnvironmentIds?: ReadonlyArray) { if (!readRelayConfig()) return null; const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { return null; } const client = yield* ManagedRelay.ManagedRelayClient; - return yield* client.getAgentActivitySnapshot({ clerkToken: token }); + return yield* client.getAgentActivitySnapshot({ clerkToken: token, excludedEnvironmentIds }); }, Effect.catch((error) => Effect.sync(() => { @@ -573,6 +814,58 @@ const readAgentActivitySnapshot: () => Effect.Effect< ), ); +const refreshAgentActivityWidget = Effect.fn("remoteRegistration.refreshAgentActivityWidget")( + function* (): Effect.fn.Return< + RelayAgentActivitySnapshotResponse | null, + never, + ManagedRelay.ManagedRelayClient + > { + const expectedDeviceGeneration = deviceRegistrationGeneration; + const expectedRefreshGeneration = ++widgetRefreshGeneration; + const readStartedWith = observedWidgetActivities; + const readStartedAt = yield* Clock.currentTimeMillis; + const snapshot = yield* readAgentActivitySnapshot(); + if (expectedDeviceGeneration !== deviceRegistrationGeneration || !relayTokenProvider) { + return null; + } + // A superseded read still primes, unless a newer accepted read replaced it. + const primingSnapshot = () => + acceptedPrimingSnapshot !== null && acceptedPrimingSnapshot.startedAt >= readStartedAt + ? acceptedPrimingSnapshot.snapshot + : snapshot; + if (expectedRefreshGeneration !== widgetRefreshGeneration) return primingSnapshot(); + if (snapshot) { + observedWidgetActivities = retainUnconfirmedWidgetActivities( + observedWidgetActivities, + AppState.currentState === "active" ? liveWidgetActivities : new Map(), + readStartedWith, + snapshot, + ); + let widgetSnapshot = snapshot; + while (observedWidgetActivities.size > 0) { + const excludedEnvironmentIds = [...observedWidgetActivities.keys()]; + const scoped = yield* readAgentActivitySnapshot(excludedEnvironmentIds); + if (expectedDeviceGeneration !== deviceRegistrationGeneration || !relayTokenProvider) + return null; + if (expectedRefreshGeneration !== widgetRefreshGeneration) return primingSnapshot(); + if ( + !sameWidgetEnvironmentScope(excludedEnvironmentIds, [...observedWidgetActivities.keys()]) + ) + continue; + if (scoped) widgetSnapshot = scoped; + break; + } + relayWidgetSnapshot = widgetSnapshot; + acceptedPrimingSnapshot = { snapshot, startedAt: readStartedAt }; + // A response confirms its request's observation time, not the later + // arrival time. Failed and superseded reads never renew the timeline. + relayWidgetConfirmedAt = readStartedAt; + publishReconciledWidget(); + } + return snapshot; + }, +); + const registerLiveActivityWithRelay = Effect.fn("remoteRegistration.registerLiveActivityWithRelay")( function* ( body: RelayLiveActivityRegistrationRequest, @@ -786,9 +1079,12 @@ function ensureAppStateListener(): void { appStateSubscription = AppState.addEventListener("change", (state) => { if (state !== "active") { + stopLiveWidgetObserver(false); + publishReconciledWidget(); return; } enqueueDeviceRegistration({}, "device registration after app foreground failed"); + startLiveWidgetObserver(); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity reconciliation after app foreground failed", @@ -796,15 +1092,6 @@ function ensureAppStateListener(): void { }); } -function publishWidget(source: AgentActivityWidgetSource): void { - if (!canRegisterRemoteLiveActivities()) { - return; - } - publishAgentActivityWidget(source).catch((error: unknown) => { - logRegistrationError("agent activity widget publish failed", error); - }); -} - // Binaries built before the native dismissEnded method throw here. function dismissEndedLocalLiveActivities(): void { try { @@ -863,6 +1150,11 @@ export function updateAgentAwarenessRegistrationPreferences( } export function __resetAgentAwarenessRemoteRegistrationForTest(): void { + stopLiveWidgetObserver(); + relayWidgetSnapshot = null; + acceptedPrimingSnapshot = null; + relayWidgetConfirmedAt = null; + publishedWidgetContent = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -874,6 +1166,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { relayTokenProvider = null; relayTokenProviderIdentity = null; deviceRegistrationGeneration++; + widgetRefreshGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; @@ -884,12 +1177,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); - widgetSnapshotReads = 0; } -export const unregisterAgentAwarenessDeviceForCurrentUser: ( - tokenProvider: () => Promise, -) => Effect.Effect = Effect.fn( +export const unregisterAgentAwarenessDeviceForCurrentUser = Effect.fn( "remoteRegistration.unregisterAgentAwarenessDeviceForCurrentUser", )( function* (tokenProvider: () => Promise) { @@ -1011,33 +1301,7 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } - - // The home-screen widget has no push path of its own, so every foreground - // refresh repaints it from one snapshot read. Yields undefined once the - // account changed or a newer read started, so a stale result is never - // shown or primed. - const generation = deviceRegistrationGeneration; - const isCurrentAccount = () => - relayTokenProvider !== null && deviceRegistrationGeneration === generation; - let read = 0; - const isNewestRead = () => isCurrentAccount() && read === widgetSnapshotReads; - const refreshWidget = Effect.gen(function* () { - // An obsolete refresh must not supersede the current account's read. - if (!isCurrentAccount()) { - return undefined; - } - read = ++widgetSnapshotReads; - const snapshot = yield* readAgentActivitySnapshot(); - if (!isNewestRead()) { - return undefined; - } - if (snapshot) { - publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); - } else { - publishWidget({ kind: "unavailable" }); - } - return { snapshot }; - }); + const expectedDeviceGeneration = deviceRegistrationGeneration; dismissEndedLocalLiveActivities(); let activities = yield* Effect.try({ @@ -1058,7 +1322,6 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // The relay tracks exactly one card per device; if concurrent arming ever // produced extras, end them so only one keeps receiving updates. - const hadActivities = activities.length > 0; if (activities.length > 1) { for (const extra of activities.slice(1)) { extra.end("immediate").catch((error: unknown) => { @@ -1068,68 +1331,56 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( activities = activities.slice(0, 1); } - // Activities are only ever created here, in the foreground, where the - // update token can be observed and registered immediately — the relay never - // remote-starts one (background push-to-start wakes proved too unreliable - // to hand the token over). - if (!hadActivities) { - const widgetRead = yield* refreshWidget; - if (!widgetRead) { - return; - } - const { snapshot } = widgetRead; - const preferences = yield* Effect.tryPromise({ - try: () => loadPreferences(), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "load-live-activity-prime-preferences", - cause, - }), - }).pipe(Effect.orElseSucceed(() => null)); - // A sign-out, account switch, or newer read during the preference read - // makes this snapshot stale; priming now could bring old rows back. - if (!isNewestRead()) { - return; - } - if (preferences?.liveActivitiesEnabled !== false) { - // The snapshot and preference reads yield; an arm-on-send may have - // created the card in the meantime. Re-check so two cards are never started. - const armedMeanwhile = yield* Effect.try({ - try: () => getAgentLiveActivities(), - catch: () => [] as ReadonlyArray>, - }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); - if (armedMeanwhile.length > 0) { - activities = [...armedMeanwhile]; - } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { - const aggregate = snapshot.aggregate; - const primed = yield* Effect.try({ - try: () => - startAgentLiveActivity( - { - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }, - liveActivityStaleDate(), - ), + // Read preferences before the snapshot so the widget and a newly primed + // Live Activity can share one fresh response, including any scoped read. + const preferences = + activities.length === 0 + ? yield* Effect.tryPromise({ + try: () => loadPreferences(), catch: (cause) => new AgentAwarenessOperationError({ - operation: "prime-live-activity", + operation: "load-live-activity-prime-preferences", cause, }), - }).pipe( - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("live activity priming failed", error); - return null; - }), - ), - ); - if (primed) { - activities = [primed]; - } + }).pipe(Effect.orElseSucceed(() => null)) + : null; + if (expectedDeviceGeneration !== deviceRegistrationGeneration || !relayTokenProvider) return; + + // Home-screen widgets update independently of the Live Activity toggle. + const snapshot = yield* refreshAgentActivityWidget(); + if (expectedDeviceGeneration !== deviceRegistrationGeneration || !relayTokenProvider) return; + + if ( + activities.length === 0 && + preferences?.liveActivitiesEnabled !== false && + AppState.currentState === "active" + ) { + // Local arming may have created a card while preferences or either + // snapshot request was pending. Register that card instead of a duplicate. + activities = yield* Effect.try({ + try: () => [...getAgentLiveActivities()], + catch: () => [] as Array>, + }).pipe(Effect.orElseSucceed(() => [] as Array>)); + const aggregate = + snapshot && "liveActivityAggregate" in snapshot + ? snapshot.liveActivityAggregate + : snapshot?.aggregate; + if (activities.length === 0 && aggregate && aggregate.activeCount > 0) { + const primed = yield* Effect.try({ + try: () => + startAgentLiveActivity(widgetPropsFromAggregate(aggregate), liveActivityStaleDate()), + catch: (cause) => + new AgentAwarenessOperationError({ operation: "prime-live-activity", cause }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("live activity priming failed", error); + return null; + }), + ), + ); + if (primed) { + activities = [primed]; } } } @@ -1149,10 +1400,4 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( if (registrationResults.some(Boolean)) { scheduleActiveLiveActivityRegistrationRetry(); } - - // An existing card keeps its push token registered without waiting on the - // relay; the widget still repaints afterwards. - if (hadActivities) { - yield* refreshWidget; - } }); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts index 784875fd6253b..8abb7762956fb 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -97,14 +97,15 @@ export function showcaseAndroidActivityData( ).length; const hero = activity.activities[0]; const alert = showcaseAgentAlert(activity); + const activeCount = activity.activeCount ?? 0; return { t3_kind: "agent_activity", updated_at: String(now), - active: String(activity.activeCount > 0), + active: String(activeCount > 0), activity_chip: attentionCount > 0 ? "Review" : "Active", - activity_title: `${activity.activeCount} active agents · ${attentionCount} need attention`, + activity_title: `${activeCount} active agents · ${attentionCount} need attention`, activity_phase: hero?.phase ?? "", - activity_active_count: String(activity.activeCount), + activity_active_count: String(activeCount), activity_attention_count: String(attentionCount), activity_body: hero ? `${hero.status}: ${hero.threadTitle} · ${hero.projectTitle}` : "", ...Object.fromEntries( diff --git a/apps/mobile/src/widgets/AgentActivity.test.tsx b/apps/mobile/src/widgets/AgentActivity.test.tsx new file mode 100644 index 0000000000000..47e33300a8dff --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivity.test.tsx @@ -0,0 +1,67 @@ +import { isValidElement } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { AgentActivity, type AgentActivityProps } from "./AgentActivity"; + +vi.mock("expo-widgets", () => ({ + createLiveActivity: () => ({}), + createWidget: () => ({}), +})); +vi.mock("@expo/ui/swift-ui", () => ({ + Divider: "Divider", + HStack: "HStack", + Image: "Image", + Text: "Text", + Spacer: "Spacer", + VStack: "VStack", + ZStack: "ZStack", +})); +vi.mock("@expo/ui/swift-ui/modifiers", () => + Object.fromEntries( + [ + "accessibilityElement", + "accessibilityLabel", + "activityBackgroundTint", + "containerBackground", + "aspectRatio", + "font", + "foregroundStyle", + "frame", + "layoutPriority", + "lineLimit", + "minimumScaleFactor", + "multilineTextAlignment", + "padding", + "resizable", + "widgetURL", + ].map((name) => [name, (value: unknown) => ({ name, value })]), + ), +); + +it.each([-1, 0, 1])( + "does not infer completion from five capped terminal rows: active=%s", + (activeCount) => { + const activity: AgentActivityProps = { + title: "T3 Code", + subtitle: "", + activeCount, + isExpired: true, + updatedAt: "2026-10-28T10:00:00Z", + activities: Array.from({ length: 5 }, (_, index) => ({ + environmentId: "environment", + threadId: String(index), + projectTitle: "Project", + threadTitle: "Finished task", + modelTitle: "Codex", + phase: "completed", + status: "Done", + updatedAt: "2026-10-28T10:00:00Z", + deepLink: "/", + })), + }; + const compact = AgentActivity(activity, { colorScheme: "dark" }).compactTrailing; + expect(isValidElement(compact)).toBe(true); + if (!isValidElement<{ children: string }>(compact)) return; + expect(compact.props.children).toBe(activeCount === 0 ? "Done" : "Out of date"); + }, +); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 609beceac0cdf..ac6f437c26b48 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,9 +1,11 @@ -import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; -import type { ComponentProps } from "react"; +import { Divider, HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; +import type { ComponentProps, JSX } from "react"; import { accessibilityElement, accessibilityLabel, activityBackgroundTint, + aspectRatio, + containerBackground, font, foregroundStyle, frame, @@ -16,12 +18,12 @@ import { } from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, - type LiveActivityComponent, + createWidget, + type LiveActivityEnvironment, type LiveActivityLayout, + type WidgetEnvironment, } from "expo-widgets"; -type LiveActivityEnvironment = Parameters>[1]; - export type AgentActivityPhase = | "starting" | "running" @@ -46,18 +48,78 @@ export interface AgentActivityRowProps { export interface AgentActivityProps { readonly title: string; readonly subtitle: string; - readonly activeCount: number; + readonly activeCount: number | null; + readonly workingCount?: number | null; + readonly attentionCount?: number | null; + readonly totalCount?: number | null; + // Native reads need source coverage even when the visible rows are capped. + readonly observedEnvironmentIds?: ReadonlyArray; + readonly observedActivities?: Readonly< + Record>> + >; + readonly isSignedOut?: boolean; + readonly isStale?: boolean; + readonly isExpired?: boolean; + readonly expiresAt?: number; readonly updatedAt: string; readonly activities: ReadonlyArray; } // Widget serialization excludes module helpers; use only imported view/modifier factories. -function AgentActivity( - props: AgentActivityProps, +export function AgentActivity( + props: AgentActivityProps | undefined, + environment: WidgetEnvironment, +): JSX.Element; +export function AgentActivity( + props: AgentActivityProps | undefined, environment: LiveActivityEnvironment, -): LiveActivityLayout { +): LiveActivityLayout; +export function AgentActivity( + props: AgentActivityProps = { + title: "T3 Code", + subtitle: "No active agents", + activeCount: 0, + updatedAt: "", + activities: [], + }, + environment: WidgetEnvironment | LiveActivityEnvironment, +): JSX.Element | LiveActivityLayout { "widget"; + // Placeholder / first-paint entries arrive with empty props. Treat missing + // fields as idle rather than throwing inside the widget JS runtime. + const receivedActivities = Array.isArray(props.activities) ? props.activities : []; + const activeCount = + props.activeCount === null || (typeof props.activeCount === "number" && props.activeCount < 0) + ? null + : typeof props.activeCount === "number" + ? props.activeCount + : 0; + const visibleActiveCount = receivedActivities.filter( + (row) => row.phase !== "completed" && row.phase !== "failed", + ).length; + const countsComplete = activeCount !== null && activeCount === visibleActiveCount; + const workingCount = + typeof props.workingCount === "number" && props.workingCount >= 0 + ? props.workingCount + : countsComplete + ? receivedActivities.filter((row) => row.phase === "running" || row.phase === "starting") + .length + : null; + const attentionCount = + typeof props.attentionCount === "number" && props.attentionCount >= 0 + ? props.attentionCount + : countsComplete + ? receivedActivities.filter( + (row) => row.phase === "waiting_for_input" || row.phase === "waiting_for_approval", + ).length + : null; + const totalCount = + typeof props.totalCount === "number" && props.totalCount >= receivedActivities.length + ? props.totalCount + : null; + const widgetFamily = "widgetFamily" in environment ? environment.widgetFamily : undefined; + // Hierarchical foregrounds inherit system tint and vibrancy. type Foreground = Parameters[0]; const primaryForeground = { type: "hierarchical", style: "primary" } as const; @@ -76,36 +138,41 @@ function AgentActivity( } switch (phase) { case "waiting_for_approval": - return isLightScheme ? "#d97706" : "#fcd34d"; + return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300 case "waiting_for_input": - return isLightScheme ? "#4f46e5" : "#a5b4fc"; + return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300 case "failed": - return isLightScheme ? "#dc2626" : "#fca5a5"; + return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 case "completed": - return isLightScheme ? "#059669" : "#6ee7b7"; + return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 case "stale": return secondaryForeground; case "starting": case "running": default: - return isLightScheme ? "#0284c7" : "#7dd3fc"; + return isLightScheme ? "#0284c7" : "#7dd3fc"; // sky-600 / sky-300 } }; + // Order attention-first so whatever needs the user floats to the top of every + // presentation, then failures, then in-flight work, then finished/stale. const phasePriority = (phase: AgentActivityPhase): number => { if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; if (phase === "failed") return 1; if (phase === "running" || phase === "starting") return 2; return 3; }; - // Mark in-flight rows stale after expiry so they cannot claim agents still work. - const activities: ReadonlyArray = environment.isStale - ? props.activities.map((row) => + // Past a Live Activity's stale date the system stops vouching for the + // content, so every in-flight row degrades to "stale" rather than claiming + // an agent is still working. Terminal phases keep their own state. + const isSystemStale = "isStale" in environment && environment.isStale === true; + const activities: ReadonlyArray = isSystemStale + ? receivedActivities.map((row) => row.phase === "completed" || row.phase === "failed" ? row : { ...row, phase: "stale", status: "Out of date" }, ) - : props.activities; + : receivedActivities; const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase)); const row0 = ordered[0]; const row1 = ordered[1]; @@ -120,32 +187,57 @@ function AgentActivity( const failedRow = activities.find((row) => row.phase === "failed"); const heroRow = attentionRow ?? failedRow ?? row0; const tint = phaseTint(heroRow?.phase); + // Headline count leans on the accent when a human is actually blocked. const headerTint = attentionRow ? phaseTint(attentionRow.phase) : failedRow ? phaseTint(failedRow.phase) : tint; - const allDone = props.activeCount === 0; - const doneLabel = failedRow ? "Failed" : "Done"; - const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed"; + // Derive terminal outcomes from all rows so a failure dominates a newer success in every presentation. + const allDone = activeCount === 0; + const hasRows = activities.length > 0; + const doneLabel = props.isSignedOut + ? "Signed out" + : failedRow + ? "Failed" + : hasRows + ? "Done" + : "Idle"; + const outcomeLabel = props.isSignedOut + ? "Sign in to see agent activity" + : failedRow + ? "Agent work failed" + : hasRows + ? "Agent work completed" + : "No active agents"; - const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = allDone + const countLabel = allDone ? outcomeLabel - : environment.isStale + : props.isExpired || isSystemStale ? "Agent status out of date" - : `${props.activeCount} active ${agentWord}`; + : workingCount === null + ? "Working count unavailable" + : `${workingCount} working`; + const agentsLabel = + props.isStale && !props.isExpired ? `Last observed: ${countLabel}` : countLabel; const attentionSuffix = - attentionRows.length > 0 - ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` + !props.isExpired && !isSystemStale && attentionCount !== null && attentionCount > 0 + ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""; - const activeLabel = allDone + const currentLabel = allDone ? doneLabel - : environment.isStale + : props.isExpired || isSystemStale ? "Out of date" - : `${props.activeCount} active`; - const showCompactGlyph = !attentionRow && !failedRow && !allDone && !environment.isStale; + : workingCount === null + ? "Count unavailable" + : `${workingCount} working`; + const activeLabel = + props.isStale && !props.isExpired ? `Last observed: ${currentLabel}` : currentLabel; + // A retained observation keeps its "Last observed" label instead of a glyph + // that would claim agents are still working. + const showCompactGlyph = + !attentionRow && !failedRow && !allDone && !isSystemStale && !props.isExpired && !props.isStale; const summary = attentionSuffix || activeLabel; // Widget taps target the containing app, so its production scheme is safe in every build. @@ -154,8 +246,11 @@ function AgentActivity( deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") ? `t3code://${deepLinkRow.deepLink.slice(1)}` : null; + const linkModifiers = deepLink ? [widgetURL(deepLink)] : []; + // A scannable status glyph per phase — reads faster than colored words and + // ties the compact / expanded / banner / watch presentations together. type SFName = NonNullable["systemName"]>; const phaseSymbol = (phase: AgentActivityPhase): SFName => { switch (phase) { @@ -179,7 +274,8 @@ function AgentActivity( }; // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to - // the image; size + tint them through a container the resizable symbol fills. + // the image; size + tint them through a container. Preserve the symbol's + // intrinsic aspect ratio when the resizable image fills that frame. const renderGlyph = ( systemName: SFName, size: number, @@ -189,10 +285,14 @@ function AgentActivity( - + ); + // Single-line rows fit the expanded island height budget; protect project and status from truncation. const renderCompactRow = (row: AgentActivityRowProps) => ( ); - // Image views only honor `resizable` directly (frame/foregroundStyle are - // dropped), so we size it via a container frame the resizable image fills and - // tint it through the container's foreground style, which the template image - // inherits. + // Widget Image ignores frame/foregroundStyle; size and tint its resizable template through a 3:2 container. const renderLogo = (height: number, color: Foreground, modifiers: typeof linkModifiers = []) => ( ); - return { - banner: ( - - - - {renderLogo(13, primaryForeground)} - + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family) + // and lock-screen accessory widgets. + const renderCompactLayout = () => ( + + + {renderLogo(14, primaryForeground)} + + {props.isStale || activeCount === null ? activeLabel : summary} + + + + {row0 ? ( + + + {props.isExpired ? "Open T3 to refresh" : row0.threadTitle} + + + + {row0.status} + + + ) : null} + {props.isExpired && !row0 ? ( + + Open T3 to refresh + + ) : null} + + ); + + if ( + widgetFamily === "accessoryCircular" || + widgetFamily === "accessoryInline" || + widgetFamily === "accessoryRectangular" + ) { + return renderCompactLayout(); + } + + if (widgetFamily) { + // iOS supplies content margins for home-screen widgets, so adding explicit + // padding here would double-inset the layout. + const widgetModifiers = [ + ...(deepLink ? [widgetURL(deepLink)] : []), + containerBackground("clear", "widget"), + ]; + const homeSummaryTint = allDone && !hasRows ? secondaryForeground : headerTint; + const homeSummary = + props.isStale || activeCount === null ? agentsLabel : attentionSuffix || agentsLabel; + const renderHomeStatusIcon = (row: AgentActivityRowProps, size: number) => + renderGlyph(phaseSymbol(row.phase), size, phaseTint(row.phase)); + + if (widgetFamily === "systemSmall") { + return ( + + + {renderLogo(12, primaryForeground)} + + Code + + + {renderGlyph("arrow.up.right", 10, secondaryForeground)} - - + + {homeSummary} + + {heroRow ? ( - {agentsLabel} + {heroRow.threadTitle} - {attentionSuffix ? ( - · - ) : null} - {attentionSuffix ? ( + ) : null} + {heroRow ? ( + + {renderHomeStatusIcon(heroRow, 11)} + + {heroRow.projectTitle} + + - {attentionSuffix} + {heroRow.status} - ) : null} - - - - {row0 ? renderCompactRow(row0) : null} - {row1 ? renderCompactRow(row1) : null} - {row2 ? renderCompactRow(row2) : null} - {row3 ? renderCompactRow(row3) : null} - {row4 ? renderCompactRow(row4) : null} - - ), - bannerSmall: ( - - - {renderLogo(14, primaryForeground)} + + ) : null} + {props.isExpired ? ( + + Open T3 to refresh + + ) : null} + + + ); + } + + const renderHomeRow = (row: AgentActivityRowProps) => ( + + {renderHomeStatusIcon(row, 17)} + - {attentionRows.length > 0 ? summary : activeLabel} + {row.threadTitle} - + + {row.projectTitle} + + + + + {row.status} + + + ); + + return ( + + + {renderLogo(13, primaryForeground)} + + Code + + + + {homeSummary} + + {renderGlyph("arrow.up.right", 10, secondaryForeground)} + + {row0 ? renderHomeRow(row0) : null} + {row1 ? : null} + {row1 ? renderHomeRow(row1) : null} + {props.isExpired ? ( + + Open T3 to refresh + + ) : null} + + + ); + } + + const banner = ( + + {/* Keep the status centered independently of the logo. */} + + + {renderLogo(13, primaryForeground)} + - {row0 ? ( - + + + + {agentsLabel} + + {attentionSuffix ? ( + · + ) : null} + {attentionSuffix ? ( - {row0.threadTitle} + {attentionSuffix} - - - {row0.status} - - - ) : null} - - ), + ) : null} + + + + {row0 ? renderCompactRow(row0) : null} + {row1 ? renderCompactRow(row1) : null} + {row2 ? renderCompactRow(row2) : null} + {row3 ? renderCompactRow(row3) : null} + {totalCount !== null && totalCount > 5 ? ( + + {`${totalCount - 4} more threads · Open T3`} + + ) : row4 ? ( + renderCompactRow(row4) + ) : null} + + ); + + return { + banner, + bannerSmall: renderCompactLayout(), compactLeading: renderLogo(14, tint, linkModifiers), // While agents are only working, stay quiet: the thread list's Working // glyph marks the activity without a count. Labels appear only for @@ -360,13 +652,27 @@ function AgentActivity( {renderLogo(15, tint)} - {allDone ? doneLabel : `${props.activeCount}`} + {allDone + ? doneLabel + : workingCount === null || isSystemStale || props.isExpired + ? "?" + : `${workingCount}`} ), + // No center content: the phase glyphs + statuses in expandedBottom already + // carry the attention signal, and the expanded island's height budget is + // tight enough that a summary line there pushed the third row off. expandedCenter: null, + // No trailing content: a timestamp is glanceable-lock-screen info, not + // useful in a view the user is actively holding open — and the trailing + // region hugs the island's corner radius, which clipped it anyway. expandedTrailing: null, expandedBottom: ( + // Vertical padding only: the expanded region provides its own horizontal + // content margins, so `all` padding double-indented the rows. + // Horizontal padding keeps both edges clear of the island's corner + // curvature (right edge clipped status labels; titles hugged the left). ("AgentActivity", AgentActivity); + export default createLiveActivity("AgentActivity", AgentActivity); diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx deleted file mode 100644 index 2d97c15b8e2d7..0000000000000 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; -import { - containerBackground, - font, - foregroundStyle, - frame, - layoutPriority, - lineLimit, - resizable, - widgetURL, -} from "@expo/ui/swift-ui/modifiers"; -import { createWidget, type WidgetEnvironment } from "expo-widgets"; - -import type { - AgentActivityPhase, - AgentActivityProps, - AgentActivityRowProps, -} from "./AgentActivity"; - -// Home-screen and Lock Screen counterpart of the AgentActivity Live Activity. -// It shares the Live Activity's name, but expo-widgets stores widget and Live -// Activity layouts under different keys, so each needs its own registration. -export function AgentActivityWidget( - publishedProps: Partial | undefined, - environment: WidgetEnvironment, -) { - "widget"; - // The extension evaluates this function without the app's module scope. - // Before the app's first publish, the embedded layout renders with no props. - const props = publishedProps ?? {}; - const family = environment.widgetFamily; - const accessory = family === "accessoryRectangular"; - const small = family === "systemSmall"; - const monochrome = - environment.widgetRenderingMode === "accented" || - environment.widgetRenderingMode === "vibrant" || - environment.isLuminanceReduced === true; - const isLightScheme = environment.colorScheme === "light"; - - type Foreground = Parameters[0]; - const primaryForeground = { type: "hierarchical", style: "primary" } as const; - const secondaryForeground = { type: "hierarchical", style: "secondary" } as const; - const phaseTint = (phase: AgentActivityPhase): Foreground => { - if (monochrome) return primaryForeground; - switch (phase) { - case "waiting_for_approval": - return isLightScheme ? "#d97706" : "#fcd34d"; - case "waiting_for_input": - return isLightScheme ? "#4f46e5" : "#a5b4fc"; - case "failed": - return isLightScheme ? "#dc2626" : "#fca5a5"; - case "completed": - return isLightScheme ? "#059669" : "#6ee7b7"; - case "stale": - return secondaryForeground; - case "starting": - case "running": - default: - return isLightScheme ? "#0284c7" : "#7dd3fc"; - } - }; - const phasePriority = (phase: AgentActivityPhase): number => { - if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; - if (phase === "failed") return 1; - if (phase === "running" || phase === "starting") return 2; - return 3; - }; - - const loaded = props.activities !== undefined; - const rows = [...(props.activities ?? [])].sort( - (a, b) => phasePriority(a.phase) - phasePriority(b.phase), - ); - const shown = rows.slice(0, accessory || small ? 2 : 4); - const attentionCount = rows.filter( - (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", - ).length; - const workingCount = rows.filter( - (row) => row.phase === "running" || row.phase === "starting", - ).length; - const summary = - attentionCount > 0 - ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} ${small ? "you" : "attention"}` - : workingCount > 0 - ? `${props.activeCount ?? workingCount} active` - : rows.some((row) => row.phase === "stale") - ? "Out of date" - : rows.some((row) => row.phase === "failed") - ? "Failed" - : rows.length > 0 - ? "Done" - : ""; - const summaryTint = rows[0] ? phaseTint(rows[0].phase) : secondaryForeground; - const emptyMessage = loaded - ? (props.subtitle ?? "No active agents") - : "Open T3 Code to load agent activity"; - - // Widget taps open the containing app, and every build accepts the prod - // scheme as a link prefix, so dev and preview builds route this too. - const linkRow = rows[0]; - const deepLink = - linkRow && linkRow.deepLink.startsWith("/") && !linkRow.deepLink.startsWith("//") - ? `t3code://${linkRow.deepLink.slice(1)}` - : null; - - const background = accessory ? "clear" : isLightScheme ? "#ffffff" : "#1c1c1e"; - const rootModifiers = [ - containerBackground(background, "widget"), - ...(deepLink ? [widgetURL(deepLink)] : []), - ]; - - // The template image ignores frame/foregroundStyle applied to itself; size - // and tint it through a container the resizable image fills. - const renderLogo = (height: number) => ( - - - - ); - - const renderRow = (row: AgentActivityRowProps, key: string) => - small || accessory ? ( - - - {row.threadTitle} - - - {row.status} - - - ) : ( - - - {row.threadTitle} - - - {row.projectTitle} - - - - {row.status} - - - ); - - return ( - - - {accessory ? null : renderLogo(13)} - - {summary || (accessory ? "T3 Code" : "")} - - - - {shown.length > 0 ? ( - - {shown.map((row, index) => renderRow(row, `${row.threadId}:${index}`))} - - ) : ( - - {emptyMessage} - - )} - {accessory ? null : } - - ); -} - -export default createWidget>("AgentActivity", AgentActivityWidget); diff --git a/apps/mobile/src/widgets/agentActivityTimeline.test.ts b/apps/mobile/src/widgets/agentActivityTimeline.test.ts new file mode 100644 index 0000000000000..23e0449eeac7a --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityTimeline.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "./AgentActivity"; +import { agentActivityTimeline, AGENT_ACTIVITY_FRESHNESS_MS } from "./agentActivityTimeline"; + +const now = Date.parse("2026-10-02T12:00:00.000Z"); +const row = { + environmentId: "environment-1", + threadId: "thread-1", + projectTitle: "Project", + threadTitle: "Task", + modelTitle: "Codex", + phase: "running", + status: "Working", + updatedAt: new Date(now).toISOString(), + deepLink: "/threads/environment-1/thread-1", +} satisfies AgentActivityRowProps; +const props = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: new Date(now).toISOString(), + activities: [row], + expiresAt: now + AGENT_ACTIVITY_FRESHNESS_MS, +} satisfies AgentActivityProps; + +describe("agentActivityTimeline", () => { + it("schedules expiration without requiring the app to run again", () => { + const timeline = agentActivityTimeline(props, now); + expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, props.expiresAt]); + expect(timeline[0]?.props).toEqual(props); + expect(timeline[1]?.props).toEqual({ + ...props, + activeCount: -1, + isExpired: true, + subtitle: "Open T3 to refresh", + activities: [{ ...row, phase: "stale", status: "Out of date" }], + }); + }); + + it.each([ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", + "stale", + ] satisfies AgentActivityPhase[])("expires unfinished %s work", (phase) => { + const timeline = agentActivityTimeline({ ...props, activities: [{ ...row, phase }] }, now); + expect(timeline[1]?.props.activities[0]).toMatchObject({ + phase: "stale", + status: "Out of date", + }); + }); + + it("keeps terminal outcomes and row links when other work expires", () => { + const completed = { + ...row, + threadId: "completed", + phase: "completed", + status: "Done", + } as const; + const failed = { ...row, threadId: "failed", phase: "failed", status: "Failed" } as const; + const timeline = agentActivityTimeline({ ...props, activities: [row, completed, failed] }, now); + expect(timeline[1]?.props.activities.slice(1)).toEqual([completed, failed]); + expect(timeline[1]?.props.activities[0]?.deepLink).toBe(row.deepLink); + }); + + it("publishes an already old observation as expired immediately", () => { + const timeline = agentActivityTimeline(props, props.expiresAt); + expect(timeline).toHaveLength(1); + expect(timeline[0]?.props).toMatchObject({ activeCount: -1, isExpired: true }); + }); + + it("preserves the disconnected distinction until its original deadline", () => { + const timeline = agentActivityTimeline({ ...props, isStale: true }, now); + expect(timeline[0]?.props).toMatchObject({ isStale: true, activeCount: 1 }); + expect(timeline[1]?.props).toMatchObject({ isStale: true, isExpired: true, activeCount: -1 }); + }); + + it("encodes unavailable counts without nulls rejected by native widget storage", () => { + const timeline = agentActivityTimeline({ ...props, activeCount: null }, now); + expect(timeline.map((entry) => entry.props.activeCount)).toEqual([-1, -1]); + expect(JSON.stringify(timeline)).not.toContain(":null"); + }); + + it("does not schedule signed-out idle content to expire", () => { + const idle = { ...props, expiresAt: undefined, activeCount: 0, activities: [] }; + expect(agentActivityTimeline(idle, now)).toEqual([{ date: new Date(now), props: idle }]); + }); +}); diff --git a/apps/mobile/src/widgets/agentActivityTimeline.ts b/apps/mobile/src/widgets/agentActivityTimeline.ts new file mode 100644 index 0000000000000..383c181920e16 --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityTimeline.ts @@ -0,0 +1,28 @@ +import type { AgentActivityProps } from "./AgentActivity"; + +export const AGENT_ACTIVITY_FRESHNESS_MS = 10 * 60_000; + +/** Expiration is precomputed so WidgetKit can age observations while JS is suspended. */ +export function agentActivityTimeline(props: AgentActivityProps, now: number) { + const expiresAt = props.expiresAt; + // Expo stores timelines in UserDefaults, whose property lists cannot contain + // null. The layout treats a negative count as unavailable. + const current = { ...props, activeCount: props.activeCount ?? -1 }; + const expired: AgentActivityProps = { + ...props, + activeCount: -1, + isExpired: true, + subtitle: "Open T3 to refresh", + activities: props.activities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale", status: "Out of date" }, + ), + }; + if (expiresAt === undefined) return [{ date: new Date(now), props: current }]; + if (expiresAt <= now) return [{ date: new Date(now), props: expired }]; + return [ + { date: new Date(now), props: current }, + { date: new Date(expiresAt), props: expired }, + ]; +} diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts deleted file mode 100644 index 9ffd91aeeeb20..0000000000000 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; - -import { - AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, - agentActivityWidgetTimeline, -} from "./agentActivityWidgetTimeline"; - -const now = Date.parse("2026-10-18T10:00:00.000Z"); - -type Row = RelayAgentActivityAggregateState["activities"][number]; - -function row(phase: Row["phase"], status: string): Row { - return { - environmentId: "env-1" as EnvironmentId, - threadId: `thread-${phase}` as ThreadId, - projectTitle: "t3code", - threadTitle: `Thread ${phase}`, - modelTitle: "GPT-5.5", - phase, - status, - updatedAt: "2026-10-18T09:59:00.000Z", - deepLink: `/threads/env-1/thread-${phase}`, - }; -} - -function aggregate(activities: ReadonlyArray): RelayAgentActivityAggregateState { - return { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: activities.length, - updatedAt: "2026-10-18T09:59:00.000Z", - activities, - }; -} - -describe("agentActivityWidgetTimeline", () => { - it("marks unfinished rows out of date once the widget can no longer vouch for them", () => { - const timeline = agentActivityWidgetTimeline( - { - kind: "snapshot", - aggregate: aggregate([row("running", "Working"), row("completed", "Done")]), - }, - now, - ); - - expect(timeline.map((entry) => entry.date.getTime())).toEqual([ - now, - now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, - ]); - expect(timeline[0]?.props.activities.map((entry) => entry.phase)).toEqual([ - "running", - "completed", - ]); - expect(timeline[1]?.props.activities.map((entry) => [entry.phase, entry.status])).toEqual([ - ["stale", "Out of date"], - ["completed", "Done"], - ]); - }); - - it("keeps finished outcomes on a single entry", () => { - const timeline = agentActivityWidgetTimeline( - { kind: "snapshot", aggregate: aggregate([row("failed", "Failed")]) }, - now, - ); - - expect(timeline).toHaveLength(1); - expect(timeline[0]?.props.activities[0]?.phase).toBe("failed"); - }); -}); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts deleted file mode 100644 index 7a00789cc9b5a..0000000000000 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; - -import type { AgentActivityProps } from "./AgentActivity"; - -// The widget only refreshes while the app is open, so after this long it stops -// claiming an agent is still working. Matches the Live Activity's stale date. -export const AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS = 10 * 60_000; - -export type AgentActivityWidgetSource = - | { readonly kind: "signed-out" } - | { readonly kind: "unavailable" } - | { readonly kind: "loading" } - | { readonly kind: "snapshot"; readonly aggregate: RelayAgentActivityAggregateState | null }; - -export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, now: number) { - const updatedAt = new Date(now).toISOString(); - if (source.kind !== "snapshot" || !source.aggregate) { - const props: AgentActivityProps = { - title: "T3 Code", - subtitle: - source.kind === "signed-out" - ? "Sign in to T3 Connect to see agents" - : source.kind === "unavailable" - ? "Couldn't reach T3 Connect" - : source.kind === "loading" - ? "Loading agents…" - : "No active agents", - activeCount: 0, - updatedAt, - activities: [], - }; - return [{ date: new Date(now), props }]; - } - - const { aggregate } = source; - const props: AgentActivityProps = { - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }; - const hasInFlightRows = props.activities.some( - (row) => row.phase !== "completed" && row.phase !== "failed", - ); - if (!hasInFlightRows) { - return [{ date: new Date(now), props }]; - } - const staleProps: AgentActivityProps = { - ...props, - activities: props.activities.map((row) => - row.phase === "completed" || row.phase === "failed" - ? row - : { ...row, phase: "stale", status: "Out of date" }, - ), - }; - return [ - { date: new Date(now), props }, - { date: new Date(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS), props: staleProps }, - ]; -} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts deleted file mode 100644 index feaf058172a22..0000000000000 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { requireOptionalNativeModule } from "expo"; - -import { - agentActivityWidgetTimeline, - type AgentActivityWidgetSource, -} from "./agentActivityWidgetTimeline"; - -export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { - if (!requireOptionalNativeModule("ExpoWidgets")) return; - const { default: widget } = await import("./AgentActivityWidget"); - widget.updateTimeline(agentActivityWidgetTimeline(source, Date.now())); -} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ts deleted file mode 100644 index a432fc4c92471..0000000000000 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { AgentActivityWidgetSource } from "./agentActivityWidgetTimeline"; - -export async function publishAgentActivityWidget(_source: AgentActivityWidgetSource) {} diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 156223fee2072..6ed9d0c153fff 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -125,11 +125,16 @@ const assertShellIndexCoversShells = Effect.fnUntraced(function* () { activity.threads.map(awareness), shells.filter((thread) => isListedThreadLineage(thread.lineage)).map(awareness), ); + const fields = (thread: (typeof activity.threads)[number]) => [ + thread.id, + thread.projectId, + thread.latestRunCompletedAt, + thread.snoozedUntil, + thread.snoozedAt, + ]; assert.deepEqual( - activity.threads.map((thread) => [thread.id, thread.projectId, thread.latestRunCompletedAt]), - shells - .filter((thread) => isListedThreadLineage(thread.lineage)) - .map((thread) => [thread.id, thread.projectId, thread.latestRunCompletedAt]), + activity.threads.map(fields), + shells.filter((thread) => isListedThreadLineage(thread.lineage)).map(fields), ); assert.isFalse(statements.some((statement) => /COUNT\(\*\)|GROUP BY/i.test(statement))); assert.isTrue(queryTransactions.every((transaction) => transaction !== null)); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 10388e99fe7c5..cd2c2c96a262c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -119,12 +119,43 @@ function waitingChild( }); } -const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); +const PublishPayload = Schema.Struct({ + state: Schema.NullOr(RelayAgentActivityState), + replay: Schema.optional(Schema.Boolean), +}); const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); const unused = () => Effect.die("Unexpected test dependency call"); describe("startup agent activity", () => { - it("publishes active work and only terminal runs completed after startup", () => { + it.effect("backfills pre-start completions silently", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + backfillThread: shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("1969-12-31T00:00:00.000Z"), + }), + }); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + assert.equal(publications[0]?.replay, true); + yield* TestClock.adjust("1 second"); + yield* Ref.update( + currentShell, + (thread) => + thread && { + ...thread, + latestRunCompletedAt: DateTime.makeUnsafe(1_000), + updatedAt: DateTime.makeUnsafe(1_000), + }, + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 2); + assert.equal(publications.at(-1)?.replay, false); + }), + ); + it("publishes active work and older unsettled completions after startup", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); const newCompleted = ThreadId.make("new-completed"); @@ -132,7 +163,7 @@ describe("startup agent activity", () => { const newFailed = ThreadId.make("new-failed"); const ids = AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ environmentId: EnvironmentId.make("relay-env"), - startedAt, + now: startedAt + 60_000, projects: [{ id: PROJECT_ID, title: "Project" }], threads: [ shell(), @@ -156,11 +187,27 @@ describe("startup agent activity", () => { status: "failed", latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:01.000Z"), }), + shell({ + id: ThreadId.make("expired-snooze"), + snoozedUntil: DateTime.makeUnsafe("2026-09-04T12:00:30.000Z"), + }), + shell({ + id: ThreadId.make("active-snooze"), + snoozedUntil: DateTime.makeUnsafe("2026-09-04T12:02:00.000Z"), + }), shell({ id: ThreadId.make("idle"), status: "idle" }), shell({ id: ThreadId.make("missing-project"), projectId: ProjectId.make("missing") }), ], }); - assert.deepStrictEqual(ids, [THREAD_ID, newCompleted, newFailed]); + assert.deepStrictEqual(ids, [ + THREAD_ID, + oldCompleted, + newCompleted, + oldFailed, + newFailed, + ThreadId.make("expired-snooze"), + ThreadId.make("active-snooze"), + ]); }); }); @@ -172,6 +219,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( readonly readShell?: (threadId: ThreadId) => Effect.Effect; readonly domainEvents?: Stream.Stream; readonly catchUpThreads?: ReadonlyArray; + readonly backfillThread?: OrchestrationV2ThreadShell; } = {}, ) { const values = new Map( @@ -204,7 +252,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( remove: (name) => Effect.sync(() => void values.delete(name)), getOrCreateRandom: unused, }); - const currentShell = yield* Ref.make(shell()); + const currentShell = yield* Ref.make( + options.backfillThread ?? shell(), + ); const shellReads: ThreadId[] = []; const catchUp = { activitySnapshotReads: 0 }; const threads = ThreadManagementService.ThreadManagementService.of({ @@ -214,7 +264,11 @@ const makeTestRelay = Effect.fnUntraced(function* ( threadId === undefined ? Effect.sync(() => { catchUp.activitySnapshotReads += 1; - return { snapshotSequence: 1, threads: options.catchUpThreads ?? [] }; + return { + snapshotSequence: 1, + threads: + options.catchUpThreads ?? (options.backfillThread ? [options.backfillThread] : []), + }; }) : Effect.sync(() => shellReads.push(threadId)).pipe( Effect.andThen(options.readShell?.(threadId) ?? Ref.get(currentShell)), @@ -271,6 +325,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( const publications: Array<{ readonly url: string; readonly authorization: string | null; + readonly replay?: boolean; readonly state: RelayAgentActivityState | null; }> = []; const fetch: typeof globalThis.fetch = Object.assign( @@ -289,6 +344,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( url: String(input), authorization: new Headers(init?.headers).get("authorization"), state: payload.state, + replay: payload.replay ?? false, }); return Promise.resolve( options.respond?.(publications.length) ?? Response.json({ ok: true, deliveries: [] }), @@ -763,6 +819,7 @@ describe("AgentAwarenessRelay", () => { snoozedAt: DateTime.makeUnsafe(NOW), snoozedUntil: DateTime.makeUnsafe("2026-09-04T13:00:00.000Z"), }; + yield* relay.publishThread(THREAD_ID); yield* Ref.set(currentShell, shell(snooze)); yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("2 hours"); @@ -788,7 +845,11 @@ describe("AgentAwarenessRelay", () => { Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); - const completed = shell({ status: "completed" }); + yield* TestClock.adjust("1 second"); + const completed = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe(1_000), + }); yield* Ref.set(currentShell, completed); yield* relay.publishThread(THREAD_ID); yield* Ref.set(currentShell, { @@ -811,6 +872,11 @@ describe("AgentAwarenessRelay", () => { publications.map((publication) => publication.state?.phase ?? null), ["running", "completed", null], ); + + yield* Ref.set(currentShell, { ...completed, title: "Reopened completion" }); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, true); }), ); @@ -995,6 +1061,131 @@ describe("AgentAwarenessRelay", () => { assert.equal(publications[1]?.state, null); }), ); + it.effect("restores a snoozed result after startup backfill without an unsnooze event", () => + Effect.gen(function* () { + const { relay, publications } = yield* makeTestRelay({ + backfillThread: shell({ status: "completed", snoozedUntil: DateTime.makeUnsafe(60_000) }), + }); + yield* relay.start(); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.equal(publications.length, 0); + yield* TestClock.adjust("59 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, true); + }).pipe(Effect.scoped), + ); + + it.effect("reschedules a changed snooze deadline and ignores the old deadline", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + const completed = shell({ status: "completed", snoozedUntil: DateTime.makeUnsafe(60_000) }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { ...completed, snoozedUntil: DateTime.makeUnsafe(120_000) }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + const before = publications.length; + yield* TestClock.adjust("50 seconds"); + yield* relay.drain; + assert.equal(publications.length, before); + yield* TestClock.adjust("60 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + }), + ); + + it.effect("alerts for a new completion while the snooze wakeup is awaiting its read", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + let holdRead = false; + const current = yield* Ref.make( + shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe(0), + snoozedUntil: DateTime.makeUnsafe(60_000), + }), + ); + const { relay, publications } = yield* makeTestRelay({ + readShell: () => + Effect.gen(function* () { + if (holdRead) { + yield* Deferred.succeed(readStarted, undefined); + yield* Deferred.await(finishRead); + } + return yield* Ref.get(current); + }), + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + holdRead = true; + yield* TestClock.adjust("55 seconds"); + yield* Deferred.await(readStarted); + yield* TestClock.adjust("1 second"); + yield* Ref.update(current, (thread) => ({ + ...thread, + latestRunCompletedAt: DateTime.makeUnsafe(61_000), + updatedAt: DateTime.makeUnsafe(61_000), + })); + yield* Deferred.succeed(finishRead, undefined); + yield* relay.drain; + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, false); + }), + ); + + it.effect("restores a snoozed result at expiry without another domain event or alert", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* TestClock.adjust("1 second"); + const completed = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe(1_000), + updatedAt: DateTime.makeUnsafe(1_000), + }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + yield* Ref.set(currentShell, { + ...completed, + snoozedAt: yield* DateTime.now, + snoozedUntil: DateTime.makeUnsafe(60_000), + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state, null); + yield* TestClock.adjust("55 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, true); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("10 seconds"); + yield* Ref.set(currentShell, { + ...completed, + snoozedUntil: null, + latestRunCompletedAt: DateTime.makeUnsafe(70_000), + updatedAt: DateTime.makeUnsafe(70_000), + }); + yield* relay.publishThread(THREAD_ID); + yield* relay.drain; + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.replay, false); + }), + ); + it.effect("publishes a held completion when its background item ends, not on tool output", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index ef2e34799742c..ee30c1e299078 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -97,6 +97,8 @@ function shouldPublishAgentAwarenessEvent( case "thread.metadata-updated": case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.snoozed": + case "thread.unsnoozed": case "thread.settled": case "thread.model-selection-updated": case "thread.provider-switched": @@ -109,8 +111,6 @@ function shouldPublishAgentAwarenessEvent( // Unsettling must not re-alert a dismissed result. case "thread.unsettled": case "thread.guest-share-updated": - case "thread.snoozed": - case "thread.unsnoozed": case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": @@ -168,6 +168,7 @@ export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* ( function agentAwarenessPublishIdentity( state: RelayAgentActivityState | null, subagentRequest: SubagentRequestAwareness | null = null, + completedAt?: number, ): string { if (state === null) { return "null"; @@ -176,7 +177,11 @@ function agentAwarenessPublishIdentity( // A child's next request reads the same as its last one, but it is a new // wait and must reach the phone. return JSON.stringify( - subagentRequest === null ? meaningfulState : [meaningfulState, subagentRequest.requestId], + subagentRequest !== null + ? [meaningfulState, subagentRequest.requestId] + : state.phase === "completed" || state.phase === "failed" + ? [meaningfulState, completedAt] + : meaningfulState, ); } @@ -268,6 +273,7 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { readonly threadId: ThreadId; readonly state: RelayAgentActivityState | null; readonly jti: string; + readonly replay?: boolean; }) { const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 5 }); @@ -281,6 +287,7 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { environmentId: input.environmentId as RelayAgentActivityPublishProofPayload["environmentId"], threadId: input.threadId, state: input.state, + ...(input.replay ? { replay: true } : {}), } satisfies RelayAgentActivityPublishProofPayload; const signingKey = yield* EnvironmentSigningKey; return yield* signingKey.sign({ typ: RELAY_ACTIVITY_PUBLISH_TYP, payload }); @@ -323,6 +330,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly environmentId: EnvironmentId; readonly thread: Option.Option; readonly project: Option.Option; + readonly now: number; readonly pendingSubagentRequest: SubagentRequestAwareness | null; }): { readonly projectId: string | null; @@ -351,6 +359,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { environmentId: input.environmentId, project: input.project.value, thread: input.thread.value, + now: input.now, }; const state = projectThreadAwarenessV2({ ...awarenessInput, @@ -391,7 +400,7 @@ function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number) export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; - readonly startedAt: number; + readonly now: number; readonly projects: ReadonlyArray>; readonly threads: ReadonlyArray; }): ReadonlyArray { @@ -408,13 +417,9 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { environmentId: input.environmentId, project, thread, + now: input.now, }); - return ( - state !== null && - (state.phase !== "completed" && state.phase !== "failed" - ? true - : terminalWorkSinceStart(thread, input.startedAt)) - ); + return state !== null || (thread.snoozedUntil?.epochMilliseconds ?? 0) > input.now; }) .map((thread) => thread.id); } @@ -430,6 +435,10 @@ export const make = Effect.gen(function* () { const scope = yield* Effect.scope; const startedAt = (yield* DateTime.now).epochMilliseconds; const catchUpRequests = yield* Queue.dropping(1); + const publishedTerminalWork = new Map< + ThreadId, + { phase: RelayAgentActivityState["phase"]; completedAt: number } + >(); const publishedStateByThreadRef = yield* Ref.make(new Map()); // Android keys alerts by phase and time, so a child's unchanged request keeps // its first alert time through parent renames, relinks and the parent's own requests. @@ -499,6 +508,33 @@ export const make = Effect.gen(function* () { yield* Ref.set(publishedStateByThreadRef, new Map()); }); + const snoozeWakeups = new Map }>(); + let enqueueSnoozeWakeup: (threadId: ThreadId) => Effect.Effect = () => Effect.void; + const scheduleSnoozeWakeup = Effect.fn("AgentAwarenessRelay.scheduleSnoozeWakeup")(function* ( + thread: AgentAwarenessThread, + nowMs: number, + ) { + const deadline = thread.snoozedUntil?.epochMilliseconds; + const previous = snoozeWakeups.get(thread.id); + const pending = deadline !== undefined && deadline > nowMs && thread.archivedAt === null; + if (pending && previous?.deadline === deadline) return; + snoozeWakeups.delete(thread.id); + if (previous) yield* Fiber.interrupt(previous.timer); + if (!pending) return; + // Expiry changes the projection without a domain event; startup must restore these timers too. + const timer = yield* Effect.sleep(deadline - nowMs).pipe( + Effect.andThen( + Effect.suspend(() => { + if (snoozeWakeups.get(thread.id)?.deadline !== deadline) return Effect.void; + snoozeWakeups.delete(thread.id); + return enqueueSnoozeWakeup(thread.id); + }), + ), + Effect.forkIn(scope), + ); + snoozeWakeups.set(thread.id, { deadline, timer }); + }); + const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { const publishAgentActivity = yield* readPublishAgentActivityEnabled; if (!publishAgentActivity) { @@ -532,6 +568,7 @@ export const make = Effect.gen(function* () { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; readonly reason: string; + readonly replay?: boolean; }) { if (cachedRelayClient === undefined) { cachedRelayClient = yield* makeRelayClient(relayConfig); @@ -542,6 +579,7 @@ export const make = Effect.gen(function* () { environmentId, threadId, state: input.state, + replay: input.replay ?? false, jti: yield* random.uuidV4(), }).pipe(Effect.provideService(EnvironmentSigningKey, signingKey)); @@ -562,6 +600,7 @@ export const make = Effect.gen(function* () { }, payload: { state: input.state, + ...(input.replay ? { replay: true } : {}), proof, }, }) @@ -577,6 +616,9 @@ export const make = Effect.gen(function* () { // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* readAwarenessThread(threadId); + if (threadShell !== null) { + yield* scheduleSnoozeWakeup(threadShell, (yield* DateTime.now).epochMilliseconds); + } yield* trackSubagentRequest(threadId, threadShell); if ( threadShell !== null && @@ -597,19 +639,42 @@ export const make = Effect.gen(function* () { environmentId, thread, project, + now: (yield* DateTime.now).epochMilliseconds, pendingSubagentRequest: yield* currentSubagentRequestFor(threadId), }); - const publishIdentity = agentAwarenessPublishIdentity(snapshot.state, snapshot.subagentWait); + const completedAt = threadShell?.latestRunCompletedAt?.epochMilliseconds; + const publishIdentity = agentAwarenessPublishIdentity( + snapshot.state, + snapshot.subagentWait, + completedAt, + ); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); + const previousTerminal = publishedTerminalWork.get(threadId); + const snoozedUntil = threadShell?.snoozedUntil?.epochMilliseconds; + const snoozeExpired = + snoozedUntil !== undefined && snoozedUntil <= (yield* DateTime.now).epochMilliseconds; + const replay = + (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && + ((snoozeExpired && + (completedAt === undefined || + (snoozedUntil !== undefined && completedAt <= snoozedUntil)) && + !hasLivePublishedState(publishedStateByThread, threadId)) || + (completedAt !== undefined && + previousTerminal?.completedAt === completedAt && + previousTerminal.phase === snapshot.state.phase) || + (!publishedStateByThread.has(threadId) && + Option.isSome(thread) && + !terminalWorkSinceStart(thread.value, startedAt))); if ( (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && - !hasLivePublishedState(publishedStateByThread, threadId) - ) { - if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; - } + !replay && + publishedStateByThread.has(threadId) && + !hasLivePublishedState(publishedStateByThread, threadId) && + (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) + ) + return; if ( publishedStateByThread.get(threadId) === publishIdentity && - // A held completion settled after an earlier clear still has to alert. !(snapshot.state === null && heldCompletions.has(threadId)) ) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. @@ -626,7 +691,11 @@ export const make = Effect.gen(function* () { // Confirm tombstones over live state to avoid removing active cards. // Confirm completion: a run can end moments before its continuation starts, and the relay // alerts on every move into completed. - const requiresConfirmation = snapshot.state === null || snapshot.state?.phase === "completed"; + const requiresConfirmation = + (snapshot.state === null && + (publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null) || + heldCompletions.has(threadId))) || + (snapshot.state?.phase === "completed" && !replay); if (requiresConfirmation) { if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; @@ -664,6 +733,7 @@ export const make = Effect.gen(function* () { environmentId, thread: Option.some({ ...thread.value, settledOverride: null }), project, + now: nowMs, pendingSubagentRequest: null, }) : null; @@ -674,10 +744,16 @@ export const make = Effect.gen(function* () { state: completion, reason: unsettled.reason, }).pipe(withRelayClientTracing); + if (completedAt !== undefined) { + publishedTerminalWork.set(threadId, { phase: completion.phase, completedAt }); + } heldCompletions.delete(threadId); publishConfirmDeadlines.set(threadId, nowMs + 5_000); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => - new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), + new Map(publishedStates).set( + threadId, + agentAwarenessPublishIdentity(completion, null, completedAt), + ), ); yield* schedulePublishConfirm(threadId); return; @@ -712,7 +788,14 @@ export const make = Effect.gen(function* () { projectId: snapshot.projectId, state, reason: snapshot.reason, + replay, }).pipe(withRelayClientTracing); + if ( + completedAt !== undefined && + (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") + ) { + publishedTerminalWork.set(threadId, { phase: snapshot.state.phase, completedAt }); + } if (state !== null && childRequestId !== undefined) { childAlertByThread.set(threadId, { requestId: childRequestId, updatedAt: state.updatedAt }); } @@ -813,6 +896,7 @@ export const make = Effect.gen(function* () { const worker = yield* makeAgentAwarenessPublishWorker(processThreadPublish); const enqueueThreadPublish = (threadId: ThreadId) => cancelPublishRetry(threadId).pipe(Effect.andThen(worker.enqueue(threadId))); + enqueueSnoozeWakeup = enqueueThreadPublish; enqueueTopLevelPublish = enqueueThreadPublish; const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => enqueueThreadPublish(threadId).pipe(Effect.andThen(worker.drain)); @@ -870,6 +954,14 @@ export const make = Effect.gen(function* () { const environmentId = yield* serverEnvironment.getEnvironmentId; const projectSnapshot = yield* projects.snapshot; const activitySnapshot = yield* threads.getAgentAwarenessSnapshot(); + const nowMs = (yield* DateTime.now).epochMilliseconds; + yield* Effect.forEach( + activitySnapshot.threads, + (thread) => scheduleSnoozeWakeup(thread, nowMs), + { + discard: true, + }, + ); // Live children aren't listed, but a waiting one makes its top-level thread wait. const liveChildren = yield* Effect.forEach( (yield* threads.listActiveShellIndex()).filter( @@ -877,11 +969,19 @@ export const make = Effect.gen(function* () { ), (thread) => readAwarenessThread(thread.id), ); + const candidates = [ + ...activitySnapshot.threads, + ...liveChildren.filter((thread) => thread !== null), + ]; + // Waiting children go first so a backfilled parent publishes their wait, not its completion. const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, - startedAt, + now: nowMs, projects: projectSnapshot.projects, - threads: [...activitySnapshot.threads, ...liveChildren.filter((thread) => thread !== null)], + threads: [ + ...candidates.filter(subagentAwaitsUser), + ...candidates.filter((thread) => !subagentAwaitsUser(thread)), + ], }); if (activeThreadIds.length === 0) { yield* Effect.logDebug("agent activity snapshot has no publishable threads"); diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 992efd5b7b29a..dfaa150678ce4 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -6,7 +6,7 @@ Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 an For activity without ordinary alerts on Android 8 or newer, leave **Ongoing agent activity** enabled and turn off **Agent alerts** in Android's notification settings for T3 Code. Activity updates continue, and the same card shows the finished result without a separate alert. On Android 16 and newer, the finished result remains a Live Update until dismissed or expired. Keep app-wide notification permission and **Live updates** enabled so Android can display it. -On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. +The iOS Agent Activity home-screen widget refreshes in the background through T3 Connect, even while the app is closed. On iOS 26 and newer, agent changes request an update through push notifications; scheduled refreshes also check for current activity. iOS controls refresh timing. If background reads stop succeeding for an hour, unfinished work becomes out of date. Direct connections refresh while T3 Code is open and become out of date after ten minutes without a confirmed observation. Completed and failed outcomes keep their labels. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. diff --git a/infra/relay/migrations/postgres/20261110041035_agent_widget_refresh/migration.sql b/infra/relay/migrations/postgres/20261110041035_agent_widget_refresh/migration.sql new file mode 100644 index 0000000000000..7bb715fd815de --- /dev/null +++ b/infra/relay/migrations/postgres/20261110041035_agent_widget_refresh/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "relay_mobile_devices" ADD COLUMN "widget_access_token_hash" text;--> statement-breakpoint +ALTER TABLE "relay_mobile_devices" ADD COLUMN "widget_push_token" text;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_mobile_devices_widget_access_token" ON "relay_mobile_devices" ("widget_access_token_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_mobile_devices_widget_push_token" ON "relay_mobile_devices" ("widget_push_token"); \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20261110041035_agent_widget_refresh/snapshot.json b/infra/relay/migrations/postgres/20261110041035_agent_widget_refresh/snapshot.json new file mode 100644 index 0000000000000..5e4f060955fbf --- /dev/null +++ b/infra/relay/migrations/postgres/20261110041035_agent_widget_refresh/snapshot.json @@ -0,0 +1,151 @@ +{ + "type": "diff", + "version": "9", + "dialect": "postgres", + "id": "c7a49add-50ec-43e9-b15b-63e241877094", + "prevIds": ["4413755a-63f1-4f93-bdc9-d1357283c23e"], + "footprints": [ + { + "statementType": "add_column", + "statementTarget": ["column", "public", "relay_mobile_devices", "widget_access_token_hash"], + "writes": [ + "s|col|public.relay_mobile_devices.widget_access_token_hash", + "n|col|public.relay_mobile_devices.widget_access_token_hash", + "e|col|public.relay_mobile_devices.widget_access_token_hash" + ], + "reads": ["n|tbl|public.relay_mobile_devices", "n|sch|public"] + }, + { + "statementType": "add_column", + "statementTarget": ["column", "public", "relay_mobile_devices", "widget_push_token"], + "writes": [ + "s|col|public.relay_mobile_devices.widget_push_token", + "n|col|public.relay_mobile_devices.widget_push_token", + "e|col|public.relay_mobile_devices.widget_push_token" + ], + "reads": ["n|tbl|public.relay_mobile_devices", "n|sch|public"] + }, + { + "statementType": "create_index", + "statementTarget": [ + "index", + "public", + "relay_mobile_devices", + "idx_relay_mobile_devices_widget_access_token" + ], + "writes": [ + "s|idx|public.idx_relay_mobile_devices_widget_access_token", + "n|idx|public.idx_relay_mobile_devices_widget_access_token", + "e|idx|public.idx_relay_mobile_devices_widget_access_token", + "n|rel|public.idx_relay_mobile_devices_widget_access_token" + ], + "reads": [ + "n|tbl|public.relay_mobile_devices", + "n|sch|public", + "n|col|public.relay_mobile_devices.widget_access_token_hash" + ] + }, + { + "statementType": "create_index", + "statementTarget": [ + "index", + "public", + "relay_mobile_devices", + "idx_relay_mobile_devices_widget_push_token" + ], + "writes": [ + "s|idx|public.idx_relay_mobile_devices_widget_push_token", + "n|idx|public.idx_relay_mobile_devices_widget_push_token", + "e|idx|public.idx_relay_mobile_devices_widget_push_token", + "n|rel|public.idx_relay_mobile_devices_widget_push_token" + ], + "reads": [ + "n|tbl|public.relay_mobile_devices", + "n|sch|public", + "n|col|public.relay_mobile_devices.widget_push_token" + ] + } + ], + "deltas": [ + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_mobile_devices", + "name": "widget_access_token_hash", + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "entityType": "columns" + } + }, + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_mobile_devices", + "name": "widget_push_token", + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "entityType": "columns" + } + }, + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_mobile_devices", + "name": "idx_relay_mobile_devices_widget_access_token", + "nameExplicit": true, + "columns": [ + { + "value": "widget_access_token_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "entityType": "indexes" + } + }, + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_mobile_devices", + "name": "idx_relay_mobile_devices_widget_push_token", + "nameExplicit": true, + "columns": [ + { + "value": "widget_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "entityType": "indexes" + } + } + ] +} diff --git a/infra/relay/migrations/postgres/20261112142833_widget_revocation_tombstones/migration.sql b/infra/relay/migrations/postgres/20261112142833_widget_revocation_tombstones/migration.sql new file mode 100644 index 0000000000000..b4ef09aac4f01 --- /dev/null +++ b/infra/relay/migrations/postgres/20261112142833_widget_revocation_tombstones/migration.sql @@ -0,0 +1,3 @@ +CREATE TABLE "relay_widget_revocations" ( + "token_hash" text PRIMARY KEY +); diff --git a/infra/relay/migrations/postgres/20261112142833_widget_revocation_tombstones/snapshot.json b/infra/relay/migrations/postgres/20261112142833_widget_revocation_tombstones/snapshot.json new file mode 100644 index 0000000000000..f6d550e3e25e8 --- /dev/null +++ b/infra/relay/migrations/postgres/20261112142833_widget_revocation_tombstones/snapshot.json @@ -0,0 +1,60 @@ +{ + "type": "diff", + "version": "9", + "dialect": "postgres", + "id": "2610035a-6d8b-403c-8e02-7a174c4dd0f2", + "prevIds": ["c7a49add-50ec-43e9-b15b-63e241877094"], + "footprints": [ + { + "statementType": "create_table", + "statementTarget": ["table", "public", "relay_widget_revocations"], + "writes": [ + "s|tbl|public.relay_widget_revocations", + "n|tbl|public.relay_widget_revocations", + "e|tbl|public.relay_widget_revocations", + "n|rel|public.relay_widget_revocations", + "n|type|public.relay_widget_revocations", + "n|rel|public.relay_widget_revocations_pkey" + ], + "reads": ["n|sch|public"] + } + ], + "deltas": [ + { + "op": "create", + "row": { + "schema": "public", + "name": "relay_widget_revocations", + "isRlsEnabled": false, + "entityType": "tables" + } + }, + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_widget_revocations", + "name": "token_hash", + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "entityType": "columns" + } + }, + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_widget_revocations", + "name": "relay_widget_revocations_pkey", + "columns": ["token_hash"], + "nameExplicit": false, + "entityType": "pks" + } + } + ] +} diff --git a/infra/relay/migrations/postgres/20261112153935_agent_activity_delivery_pending/migration.sql b/infra/relay/migrations/postgres/20261112153935_agent_activity_delivery_pending/migration.sql new file mode 100644 index 0000000000000..63be7a1167970 --- /dev/null +++ b/infra/relay/migrations/postgres/20261112153935_agent_activity_delivery_pending/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "relay_agent_activity_rows" ADD COLUMN "delivery_pending" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20261112153935_agent_activity_delivery_pending/snapshot.json b/infra/relay/migrations/postgres/20261112153935_agent_activity_delivery_pending/snapshot.json new file mode 100644 index 0000000000000..326874c866936 --- /dev/null +++ b/infra/relay/migrations/postgres/20261112153935_agent_activity_delivery_pending/snapshot.json @@ -0,0 +1,37 @@ +{ + "type": "diff", + "version": "9", + "dialect": "postgres", + "id": "349f2f8d-d3fa-4ced-b816-182ab68e79b3", + "prevIds": ["2610035a-6d8b-403c-8e02-7a174c4dd0f2"], + "footprints": [ + { + "statementType": "add_column", + "statementTarget": ["column", "public", "relay_agent_activity_rows", "delivery_pending"], + "writes": [ + "s|col|public.relay_agent_activity_rows.delivery_pending", + "n|col|public.relay_agent_activity_rows.delivery_pending", + "e|col|public.relay_agent_activity_rows.delivery_pending" + ], + "reads": ["n|tbl|public.relay_agent_activity_rows", "n|sch|public"] + } + ], + "deltas": [ + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_agent_activity_rows", + "name": "delivery_pending", + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "entityType": "columns" + } + } + ] +} diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 679c410470397..a63aaf7fb6687 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -1,4 +1,5 @@ import { makeAggregateState } from "./agentActivityAggregate.ts"; +import * as AgentWidgetRefresh from "./AgentWidgetRefresh.ts"; import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -21,7 +22,23 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; import * as FcmDeliveries from "./FcmDeliveries.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +const widgetLayer = Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + notify: () => Effect.succeed([]), + process: (job) => + Effect.succeed({ + deviceId: job.target.deviceId, + kind: "widget_refresh", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }), +}); + const layerPublisher = AgentActivityPublisher.layer.pipe( + Layer.provide(widgetLayer), Layer.provide( Layer.succeed(FcmDeliveries.FcmDeliveries, { enqueue: () => Effect.succeed(null), @@ -81,7 +98,8 @@ function makeAgentActivityRows( overrides: Partial = {}, ): AgentActivityRows.AgentActivityRows["Service"] { return { - upsert: () => Effect.void, + upsert: () => Effect.succeed(true), + acknowledgeDelivery: () => Effect.void, remove: () => Effect.void, pruneTerminal: () => Effect.void, listForUser: () => Effect.succeed([state]), @@ -149,6 +167,206 @@ function makeApnsDeliveries( } describe("AgentActivityPublisher", () => { + it.effect("repaints backfilled inbox rows without a notification", () => { + const replays: boolean[] = []; + const widgetUsers: string[] = []; + let changed = true; + let failWidget = true; + let failFcm = true; + let configuredFcm = false; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + const publish = publisher.publish({ + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state: { ...state, phase: "completed", retained: true }, + replay: true, + }); + expect((yield* Effect.flip(publish))._tag).toBe("WidgetRefreshPersistenceError"); + failWidget = false; + expect((yield* Effect.flip(publish))._tag).toBe("FcmDeliveryError"); + expect(changed).toBe(true); + failFcm = false; + yield* publish; + expect(changed).toBe(true); + configuredFcm = true; + yield* publish; + expect(widgetUsers).toEqual(["dev:julius", "dev:julius", "dev:julius"]); + changed = false; + yield* publish; + expect(widgetUsers).toEqual(["dev:julius", "dev:julius", "dev:julius"]); + changed = true; + yield* publish; + expect(widgetUsers).toEqual(["dev:julius", "dev:julius", "dev:julius", "dev:julius"]); + expect(replays).toEqual([true, true, true, true]); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + process: () => Effect.die("unused widget job"), + notify: ({ userId }) => + Effect.suspend(() => + failWidget + ? Effect.fail( + new AgentWidgetRefresh.WidgetRefreshPersistenceError({ + stage: "test", + cause: "queue unavailable", + }), + ) + : Effect.sync(() => { + widgetUsers.push(userId); + return []; + }), + ), + }), + ), + Layer.provide( + Layer.succeed(FcmDeliveries.FcmDeliveries, { + enqueue: (input) => + Effect.suspend(() => { + replays.push(input.replay === true); + return failFcm + ? Effect.fail( + new FcmDeliveries.FcmDeliveryError({ + operation: "enqueue", + cause: "queue unavailable", + }), + ) + : Effect.succeed( + configuredFcm + ? null + : { + deviceId: "android", + kind: "push_notification" as const, + ok: false, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }, + ); + }), + process: () => Effect.void, + }), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + upsert: () => Effect.sync(() => changed), + acknowledgeDelivery: () => + Effect.sync(() => { + changed = false; + }), + }), + ), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listTargets: () => + Effect.succeed([{ ...target("android"), platform: "android" }]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + replays.push(input.replay === true); + return null; + }), + }), + ), + ), + ), + ), + ), + ); + }); + it.effect.each([true, false])( + "enqueues the widget refresh independently of notification delivery: enabled=%s", + (enabled) => { + const widgetUsers: string[] = []; + const android = { + ...target("android"), + platform: "android" as const, + ios_major_version: null, + }; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + const publish = publisher.publish({ + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state, + }); + if (enabled) { + const error = yield* Effect.flip(publish); + expect(error._tag).toBe("FcmDeliveryError"); + } else { + expect(yield* publish).toMatchObject({ ok: true }); + } + expect(widgetUsers).toEqual([android.user_id]); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed( + EnvironmentLinks.EnvironmentLinks, + makeEnvironmentLinks({ + listDeliveryUsersForEnvironment: ({ includeDisabled }) => + Effect.succeed( + includeDisabled + ? [ + { + userId: android.user_id, + notificationsEnabled: enabled, + liveActivitiesEnabled: enabled, + }, + ] + : [], + ), + }), + ), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed([android]) }), + ), + Layer.succeed(ApnsDeliveries.ApnsDeliveries, makeApnsDeliveries()), + Layer.succeed(FcmDeliveries.FcmDeliveries, { + enqueue: () => + Effect.fail( + new FcmDeliveries.FcmDeliveryError({ + operation: "enqueue", + cause: "test failure", + }), + ), + process: () => Effect.void, + }), + Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + notify: ({ userId }) => + Effect.sync(() => { + widgetUsers.push(userId); + return []; + }), + process: () => Effect.die("unused"), + }), + ), + ), + ), + ), + ); + }, + ); it.effect("routes Android publication and registration replay to FCM alongside iOS", () => { const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; const ios = target("ios"); @@ -167,13 +385,29 @@ describe("AgentActivityPublisher", () => { deviceId: android.device_id, }); expect(fcmCalls).toEqual([ - { target: android, state }, + { target: android, state, replay: false }, { target: android, state: null, replay: true }, ]); expect(appleDevices).toEqual(["ios"]); }).pipe( Effect.provide( AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + notify: () => Effect.succeed([]), + process: (job) => + Effect.succeed({ + deviceId: job.target.deviceId, + kind: "widget_refresh", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }), + }), + ), Layer.provide( Layer.mergeAll( Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), @@ -235,7 +469,7 @@ describe("AgentActivityPublisher", () => { expect(hasAggregate).toEqual([listCalls > 0]); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + layerPublisher.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -426,6 +660,7 @@ describe("AgentActivityPublisher", () => { }), ); const deliveriesLayer = ApnsDeliveries.layer.pipe( + Layer.provide(widgetLayer), Layer.provide(NodeCryptoLayer.layerMac), Layer.provide( ApnsClient.layer.pipe( @@ -571,6 +806,43 @@ describe("makeAggregateState", () => { ).toBeNull(); }); + it("keeps older inbox completions and exact counts beyond the row cap", () => { + const oldRows = Array.from({ length: 8 }, (_, index): RelayAgentActivityState => ({ + ...state, + threadId: `old-${index}` as RelayAgentActivityState["threadId"], + phase: "completed", + retained: true, + })); + const aggregate = makeAggregateState({ + activeStates: [ + { ...state, updatedAt: "1970-01-02T00:00:00.000Z" }, + { + ...state, + threadId: "input" as RelayAgentActivityState["threadId"], + phase: "waiting_for_input", + updatedAt: "1970-01-02T00:00:00.000Z", + }, + ...oldRows, + ], + terminalState: null, + nowMs: 24 * hourMs, + }); + expect(aggregate).toMatchObject({ + activeCount: 2, + workingCount: 1, + attentionCount: 1, + totalCount: 10, + }); + expect(aggregate?.activities).toHaveLength(5); + expect(aggregate?.activities.map((row) => row.phase)).toEqual([ + "waiting_for_input", + "running", + "completed", + "completed", + "completed", + ]); + }); + it("gives active agents the display slots before finished ones", () => { const mkActive = (id: string): RelayAgentActivityState => ({ ...state, diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 6533d021f0f29..2c4aa817522a9 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -21,8 +21,12 @@ import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as FcmDeliveries from "./FcmDeliveries.ts"; +import type * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as AgentWidgetRefresh from "./AgentWidgetRefresh.ts"; export type AgentActivityPublishError = + | AgentWidgetRefresh.WidgetRefreshPersistenceError + | ApnsDeliveryQueue.ApnsDeliveryQueueError | FcmDeliveries.FcmDeliveryError | AgentActivityRows.AgentActivityRowUpsertPersistenceError | AgentActivityRows.AgentActivityRowDeletePersistenceError @@ -39,6 +43,7 @@ export class AgentActivityPublisher extends Context.Service< readonly environmentPublicKey: string; readonly threadId: string; readonly state: RelayAgentActivityState | null; + readonly replay?: boolean; }) => Effect.Effect; readonly replayForLiveActivityRegistration: (input: { readonly userId: string; @@ -57,12 +62,16 @@ const make = Effect.gen(function* () { const liveActivities = yield* LiveActivities.LiveActivities; const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries; const fcmDeliveries = yield* FcmDeliveries.FcmDeliveries; + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; const publishForDeliveryUser = Effect.fnUntraced(function* (input: { readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; readonly state: RelayAgentActivityState | null; readonly nowMs: number; + readonly replay?: boolean; }) { + if (!input.deliveryUser.notificationsEnabled && !input.deliveryUser.liveActivitiesEnabled) + return []; const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); // Only iOS targets with a running Live Activity read the aggregate, and few users have one, // so skip the per-user activity query otherwise. @@ -79,13 +88,18 @@ const make = Effect.gen(function* () { targets, Effect.fnUntraced(function* (target) { if (target.platform === "android") { - return yield* fcmDeliveries.enqueue({ target, state: input.state }); + return yield* fcmDeliveries.enqueue({ + target, + state: input.state, + replay: input.replay ?? false, + }); } return yield* apnsDeliveries.sendForTarget({ target, aggregate: liveActivityAggregate, state: input.deliveryUser.notificationsEnabled ? input.state : null, nowMs: input.nowMs, + replay: input.replay ?? false, }); }), { concurrency: 4 }, @@ -186,9 +200,10 @@ const make = Effect.gen(function* () { "relay.thread_id": input.threadId, "relay.agent_activity.phase": input.state?.phase ?? "deleted", }); + let changed = true; if (input.state) { - // Retain terminal rows until cron pruning so completed threads remain visible alongside active ones. - yield* rows.upsert({ + // Inbox rows are removed by settlement or archival, not by elapsed time. + changed = yield* rows.upsert({ environmentPublicKey: input.environmentPublicKey, state: input.state, }); @@ -200,10 +215,18 @@ const make = Effect.gen(function* () { }); } + if (input.replay && !changed) return { ok: true, deliveries: [] }; + const deliveryUsers = yield* links.listDeliveryUsersForEnvironment({ environmentId: input.environmentId, environmentPublicKey: input.environmentPublicKey, + includeDisabled: true, }); + const widgetDeliveries = yield* Effect.forEach( + deliveryUsers, + (user) => widgets.notify({ userId: user.userId }), + { concurrency: 4 }, + ); const now = yield* DateTime.now; const deliveriesByUser = yield* Effect.forEach( deliveryUsers, @@ -211,11 +234,17 @@ const make = Effect.gen(function* () { publishForDeliveryUser({ deliveryUser, state: input.state, + replay: input.replay ?? false, nowMs: now.epochMilliseconds, }), { concurrency: 4 }, ); - const deliveries = deliveriesByUser.flat(); + const deliveries = [...deliveriesByUser.flat(), ...widgetDeliveries.flat()]; + if (input.state && deliveries.every((delivery) => delivery === null || delivery.ok)) + yield* rows.acknowledgeDelivery({ + environmentPublicKey: input.environmentPublicKey, + state: input.state, + }); return { ok: true, deliveries: deliveries.filter( diff --git a/infra/relay/src/agentActivity/AgentActivityRows.test.ts b/infra/relay/src/agentActivity/AgentActivityRows.test.ts new file mode 100644 index 0000000000000..5f758e7303687 --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityRows.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PgClient } from "@effect/sql-pg/PgClient"; +import { PGlite } from "@electric-sql/pglite"; +import { makeWithDefaults } from "drizzle-orm/effect-postgres"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; +import { eq } from "drizzle-orm"; +import * as RelayDb from "../db.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; + +describe("AgentActivityRows", () => { + it.effect( + "retries pending widget changes and prunes retained rows only after the last link is revoked", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pg = yield* Effect.acquireRelease( + Effect.promise(() => PGlite.create()), + (client) => Effect.promise(() => client.close()), + ); + const migrations = new URL("../../migrations/postgres/", import.meta.url).pathname; + for (const folder of (yield* fs.readDirectory(migrations)).toSorted()) { + const migration = yield* fs.readFileString(`${migrations}/${folder}/migration.sql`); + yield* Effect.promise(() => pg.exec(migration)); + } + const client = { + unsafe: (query: string, params: ReadonlyArray = []) => { + const result = Effect.promise(() => + pg.query>(query, [...params]), + ); + return Object.assign(result.pipe(Effect.map((result) => result.rows)), { + raw: result, + withoutTransform: result.pipe(Effect.map((result) => result.rows)), + values: result.pipe( + Effect.map((result) => + result.rows.map((row) => result.fields.map((field) => row[field.name])), + ), + ), + }); + }, + } as unknown as PgClient; + const db = yield* makeWithDefaults().pipe(Effect.provideService(PgClient, client)); + const state: RelayAgentActivityState = { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "Model", + phase: "running", + headline: "Working", + updatedAt: "1970-01-01T00:00:00.000Z", + deepLink: "/threads/env/thread", + }; + yield* Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; + const input = { environmentPublicKey: "key", state }; + expect(yield* rows.upsert(input)).toBe(true); + expect(yield* rows.upsert(input)).toBe(true); + yield* rows.acknowledgeDelivery(input); + expect(yield* rows.upsert(input)).toBe(false); + expect( + yield* rows.upsert({ + ...input, + state: { ...state, phase: "completed", headline: "Done", retained: true }, + }), + ).toBe(true); + yield* rows.acknowledgeDelivery(input); + const terminal = { + ...input, + state: { ...state, headline: "Done", phase: "completed" as const, retained: true }, + }; + expect(yield* rows.upsert(terminal)).toBe(true); + yield* rows.acknowledgeDelivery(terminal); + expect(yield* rows.upsert(terminal)).toBe(false); + yield* db.insert(relayEnvironmentLinks).values({ + userId: "user", + environmentId: "env", + environmentPublicKey: "key", + environmentLabel: "Laptop", + endpointHttpBaseUrl: "https://test", + endpointWsBaseUrl: "wss://test", + endpointProviderKind: "custom", + createdAt: state.updatedAt, + updatedAt: state.updatedAt, + }); + yield* db.insert(relayEnvironmentLinks).values({ + userId: "other-user", + environmentId: "env", + environmentPublicKey: "key", + endpointHttpBaseUrl: "https://test", + endpointWsBaseUrl: "wss://test", + endpointProviderKind: "direct", + createdAt: state.updatedAt, + updatedAt: state.updatedAt, + }); + yield* rows.pruneTerminal({ updatedBefore: "2026-01-01T00:00:00Z" }); + expect(yield* db.select().from(relayAgentActivityRows)).toHaveLength(1); + yield* db + .update(relayEnvironmentLinks) + .set({ revokedAt: "2026-01-01T00:00:00Z" }) + .where(eq(relayEnvironmentLinks.userId, "user")); + yield* rows.pruneTerminal({ updatedBefore: "2026-01-01T00:00:00Z" }); + expect(yield* db.select().from(relayAgentActivityRows)).toHaveLength(1); + yield* db + .update(relayEnvironmentLinks) + .set({ revokedAt: "2026-01-01T00:00:00Z" }) + .where(eq(relayEnvironmentLinks.userId, "other-user")); + yield* rows.pruneTerminal({ updatedBefore: "2026-01-01T00:00:00Z" }); + expect(yield* db.select().from(relayAgentActivityRows)).toHaveLength(0); + }).pipe( + Effect.provide( + AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 0faf5b9da358e..7ce324570f301 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, lt, or, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -68,6 +68,10 @@ export class AgentActivityRows extends Context.Service< readonly upsert: (input: { readonly environmentPublicKey: string; readonly state: RelayAgentActivityState; + }) => Effect.Effect; + readonly acknowledgeDelivery: (input: { + readonly environmentPublicKey: string; + readonly state: RelayAgentActivityState; }) => Effect.Effect; readonly pruneTerminal: (input: { readonly updatedBefore: string; @@ -79,6 +83,7 @@ export class AgentActivityRows extends Context.Service< }) => Effect.Effect; readonly listForUser: (input: { readonly userId: string; + readonly includeLiveActivitiesDisabled?: boolean; }) => Effect.Effect< ReadonlyArray, AgentActivityRowListPersistenceError @@ -121,7 +126,7 @@ const make = Effect.gen(function* () { }), ), ); - yield* db + const changed = yield* db .insert(relayAgentActivityRows) .values({ environmentId: input.state.environmentId, @@ -140,8 +145,11 @@ const make = Effect.gen(function* () { set: { stateJson, updatedAt: input.state.updatedAt, + deliveryPending: true, }, + setWhere: sql`${relayAgentActivityRows.stateJson} is distinct from excluded.state_json OR ${relayAgentActivityRows.deliveryPending} = true`, }) + .returning({ threadId: relayAgentActivityRows.threadId }) .pipe( Effect.mapError( (cause) => @@ -152,8 +160,35 @@ const make = Effect.gen(function* () { }), ), ); + return changed.length > 0; }), + acknowledgeDelivery: Effect.fn("relay.agent_activity_rows.acknowledge_delivery")( + function* (input) { + yield* db + .update(relayAgentActivityRows) + .set({ deliveryPending: false }) + .where( + and( + eq(relayAgentActivityRows.environmentId, input.state.environmentId), + eq(relayAgentActivityRows.environmentPublicKey, input.environmentPublicKey), + eq(relayAgentActivityRows.threadId, input.state.threadId), + eq(relayAgentActivityRows.stateJson, input.state), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + }, + ), + remove: Effect.fn("relay.agent_activity_rows.remove")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId, @@ -189,6 +224,10 @@ const make = Effect.gen(function* () { .where( and( sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, + or( + sql`COALESCE(${relayAgentActivityRows.stateJson} ->> 'retained', 'false') != 'true'`, + sql`NOT EXISTS (SELECT 1 FROM ${relayEnvironmentLinks} WHERE ${relayEnvironmentLinks.environmentId} = ${relayAgentActivityRows.environmentId} AND ${relayEnvironmentLinks.environmentPublicKey} = ${relayAgentActivityRows.environmentPublicKey} AND ${relayEnvironmentLinks.revokedAt} IS NULL)`, + ), lt(relayAgentActivityRows.updatedAt, input.updatedBefore), ), ) @@ -221,7 +260,9 @@ const make = Effect.gen(function* () { and( eq(relayEnvironmentLinks.userId, input.userId), isNull(relayEnvironmentLinks.revokedAt), - eq(relayEnvironmentLinks.liveActivitiesEnabled, true), + input.includeLiveActivitiesDisabled + ? undefined + : eq(relayEnvironmentLinks.liveActivitiesEnabled, true), ), ) .orderBy(desc(relayAgentActivityRows.updatedAt)) diff --git a/infra/relay/src/agentActivity/AgentWidgetRefresh.test.ts b/infra/relay/src/agentActivity/AgentWidgetRefresh.test.ts new file mode 100644 index 0000000000000..f47a9a9d8bc45 --- /dev/null +++ b/infra/relay/src/agentActivity/AgentWidgetRefresh.test.ts @@ -0,0 +1,494 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- a synchronous sha256 fixture mirrors the stored token hash. +import * as NodeCrypto from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PgClient } from "@effect/sql-pg/PgClient"; +import { PGlite } from "@electric-sql/pglite"; +import { makeWithDefaults } from "drizzle-orm/effect-postgres"; +import * as FileSystem from "effect/FileSystem"; +import * as Devices from "./Devices.ts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { PgDialect } from "drizzle-orm/pg-core"; +import type { SQL } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as SqlError from "effect/sql/SqlError"; +import { relayEnvironmentLinks, relayWidgetRevocations } from "../persistence/schema.ts"; +import * as RelayDb from "../db.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as ApnsClient from "./ApnsClient.ts"; +import * as AgentWidgetRefresh from "./AgentWidgetRefresh.ts"; +import type { ApnsDeliveryJobPayload } from "./apnsDeliveryJobs.ts"; + +const token = "a".repeat(64); +const tokenHash = NodeCrypto.createHash("sha256").update(token).digest("hex"); +const device = { + userId: "user-a", + deviceId: "phone-a", + platform: "ios", + widgetAccessTokenHash: tokenHash, + widgetPushToken: "abc123", + bundleId: "com.t3tools.t3code.dev", + apsEnvironment: "sandbox" as const, +}; +const job: ApnsDeliveryJobPayload = { + version: 1, + jobId: "job", + kind: "widget_refresh", + target: { userId: device.userId, deviceId: device.deviceId, token: device.widgetPushToken }, + aggregate: null, + notification: null, + createdAt: "1970-01-01T00:00:00Z", + expiresAt: "1970-01-01T00:10:00Z", +}; +const state: RelayAgentActivityState = { + environmentId: EnvironmentId.make("env-a"), + threadId: ThreadId.make("thread-a"), + projectTitle: "Widget test", + threadTitle: "Finish background refresh", + modelTitle: "Test agent", + phase: "running", + headline: "Working", + updatedAt: "1970-01-01T00:00:00Z", + deepLink: "/threads/env-a/thread-a", +}; + +function fixture( + response: ApnsClient.ApnsDeliveryResult = { ok: true, status: 200, apnsId: "apns-id" }, + database?: RelayDb.RelayDb["Service"], +) { + let present = true; + let revokedBeforeClaim = false; + const currentDevice = { ...device }; + let revocationsAllowed = true; + let failCleanup = false; + const revocations = new Set(); + let activity = state; + const queries: Array> = []; + const updates: Array> = []; + const reads: Array = []; + const pushes: Array< + Parameters[0] + > = []; + const queued: Array< + Parameters[0] + > = []; + const dialect = new PgDialect(); + const select = (condition: SQL) => { + const params = dialect.sqlToQuery(condition).params; + queries.push(params); + const authorized = + present && + (params.includes(tokenHash) || + (params.includes(device.userId) && + (!params.includes(device.deviceId) || + params.includes(currentDevice.widgetPushToken ?? "")))); + const result = Effect.succeed(authorized ? [{ ...currentDevice }] : []); + return Object.assign(result, { + limit: () => + Object.assign(result, { for: () => (revokedBeforeClaim ? Effect.succeed([]) : result) }), + }); + }; + const db = { + $client: { withTransaction: (effect: Effect.Effect) => effect }, + select: () => ({ + from: (table: unknown) => ({ + where: + table === relayWidgetRevocations + ? (condition: SQL) => { + const params = dialect.sqlToQuery(condition).params; + const result = Effect.succeed(revocations.has(String(params[0])) ? [{}] : []); + return Object.assign(result, { limit: () => result }); + } + : table === relayEnvironmentLinks + ? (condition: SQL) => { + const params = dialect.sqlToQuery(condition).params; + queries.push(params); + return Effect.succeed(params.includes(true) ? [] : [{ environmentId: "env-a" }]); + } + : select, + }), + }), + insert: () => ({ + values: ({ tokenHash }: { tokenHash: string }) => ({ + onConflictDoNothing: () => + Effect.sync(() => { + revocations.add(tokenHash); + }), + }), + }), + update: () => ({ + set: (values: Record) => ({ + where: (condition: SQL) => { + const result = Effect.suspend(() => { + if (failCleanup && values.widgetAccessTokenHash === null) { + failCleanup = false; + return Effect.fail( + new SqlError.SqlError({ + reason: new SqlError.ConnectionError({ + cause: new Error("cleanup write failed"), + }), + }), + ); + } + return Effect.sync(() => { + updates.push(values); + const params = dialect.sqlToQuery(condition).params; + queries.push(params); + if (params.includes(tokenHash) && values.widgetAccessTokenHash === null) + present = false; + if ( + params.includes(tokenHash) || + params.includes(currentDevice.widgetPushToken ?? "") + ) { + Object.assign(currentDevice, values); + } + return { rowCount: present ? 1 : 0 }; + }); + }); + return Object.assign(result, { + returning: () => + result.pipe(Effect.map(() => (present ? [{ deviceId: device.deviceId }] : []))), + }); + }, + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + const layer = AgentWidgetRefresh.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayDb.RelayDb, database ?? db), + Layer.succeed(AgentWidgetRefresh.WidgetRevocationRateLimiter, { + allow: Effect.sync(() => revocationsAllowed), + }), + Layer.succeed(AgentActivityRows.AgentActivityRows, { + upsert: () => Effect.succeed(true), + remove: () => Effect.void, + pruneTerminal: () => Effect.void, + acknowledgeDelivery: () => Effect.void, + getForUserThread: () => Effect.succeed(null), + listForUser: ({ userId }) => + Effect.sync(() => { + reads.push(userId); + return userId === device.userId ? [activity] : []; + }), + }), + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueue, { + enqueueLiveActivity: (input) => + Effect.sync(() => { + queued.push(input); + return { + deviceId: input.deviceId, + kind: input.kind, + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }), + enqueuePushNotification: () => Effect.die("unexpected alert"), + }), + Layer.succeed(ApnsClient.ApnsClient, { + makeLiveActivityRequest: () => { + throw new Error("unexpected activity"); + }, + makePushNotificationRequest: () => { + throw new Error("unexpected alert"); + }, + sendLiveActivityRequest: () => Effect.die("unexpected activity"), + sendPushNotificationRequest: (input) => + Effect.sync(() => { + pushes.push(input); + return response; + }), + }), + Layer.succeed(RelayConfiguration.RelayConfiguration, { + relayIssuer: "https://relay.test", + clerkSecretKey: Redacted.make("test"), + clerkPublishableKey: "test", + clerkJwtAudience: "test", + apnsDeliveryJobSigningSecret: Redacted.make("test"), + cloudMintPrivateKey: Redacted.make("test"), + cloudMintPublicKey: "test", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + apns: { + teamId: "team", + keyId: "key", + bundleId: "com.t3tools.t3code", + environment: "production", + privateKey: Redacted.make("test"), + }, + }), + ), + ), + ); + return { + layer, + queries, + updates, + reads, + pushes, + queued, + revoke: () => { + present = false; + }, + expireBeforeClaim: () => { + revokedBeforeClaim = true; + }, + failNextCleanup: () => { + failCleanup = true; + }, + allowRevocations: (allowed: boolean) => { + revocationsAllowed = allowed; + }, + complete: () => { + activity = { ...activity, phase: "completed", headline: "Done" }; + }, + }; +} + +describe("AgentWidgetRefresh", () => { + it.effect( + "keeps revocation effective before a late registration and across registration retries", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pg = yield* Effect.acquireRelease( + Effect.promise(() => PGlite.create()), + (client) => Effect.promise(() => client.close()), + ); + const migrations = new URL("../../migrations/postgres/", import.meta.url).pathname; + for (const folder of (yield* fs.readDirectory(migrations)).toSorted()) { + const migration = yield* fs.readFileString(`${migrations}/${folder}/migration.sql`); + yield* Effect.promise(() => pg.exec(migration)); + } + const client = { + unsafe: (query: string, params: ReadonlyArray = []) => { + const result = Effect.promise(() => + pg.query>(query, [...params]), + ); + return Object.assign(result.pipe(Effect.map((result) => result.rows)), { + raw: result, + withoutTransform: result.pipe(Effect.map((result) => result.rows)), + values: result.pipe( + Effect.map((result) => + result.rows.map((row) => result.fields.map((field) => row[field.name])), + ), + ), + }); + }, + } as unknown as PgClient; + const db = yield* makeWithDefaults().pipe(Effect.provideService(PgClient, client)); + const registration = { + deviceId: device.deviceId, + label: "Phone", + platform: "ios" as const, + widgetAccessToken: token, + preferences: { + notificationsEnabled: false, + liveActivitiesEnabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }; + yield* Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + const devices = yield* Devices.Devices; + yield* widgets.revoke({ token }); + for (let attempt = 0; attempt < 2; attempt++) { + yield* devices.register({ userId: device.userId, registration }); + expect((yield* Effect.flip(widgets.refresh({ token })))._tag).toBe( + "WidgetRefreshUnauthorized", + ); + } + const nextToken = "b".repeat(64); + yield* devices.register({ + userId: device.userId, + registration: { ...registration, widgetAccessToken: nextToken }, + }); + expect((yield* widgets.refresh({ token: nextToken })).aggregate?.activeCount).toBe(1); + yield* widgets.revoke({ token: nextToken }); + yield* devices.register({ + userId: device.userId, + registration: { ...registration, widgetAccessToken: nextToken }, + }); + expect((yield* Effect.flip(widgets.refresh({ token: nextToken })))._tag).toBe( + "WidgetRefreshUnauthorized", + ); + }).pipe( + Effect.provide( + Layer.merge( + fixture(undefined, db).layer, + Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "revokes only the presented capability and rejects subsequent reads and queued pushes", + () => { + const test = fixture(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + test.allowRevocations(false); + expect((yield* Effect.flip(widgets.revoke({ token: "b".repeat(64) })))._tag).toBe( + "WidgetRefreshRateLimited", + ); + expect(test.updates).toEqual([]); + expect((yield* widgets.refresh({ token })).aggregate?.activeCount).toBe(1); + test.allowRevocations(true); + yield* widgets.revoke({ token: "b".repeat(64) }); + expect((yield* widgets.refresh({ token })).aggregate?.activeCount).toBe(1); + test.allowRevocations(false); + yield* widgets.revoke({ token }); + const cleanupWrites = test.updates.length; + yield* widgets.revoke({ token }); + expect(test.updates).toHaveLength(cleanupWrites); + expect((yield* Effect.flip(widgets.refresh({ token })))._tag).toBe( + "WidgetRefreshUnauthorized", + ); + yield* widgets.process(job); + expect(test.pushes).toEqual([]); + expect(test.updates.at(-1)).toEqual({ widgetAccessTokenHash: null, widgetPushToken: null }); + expect(test.queries).toContainEqual([tokenHash]); + }).pipe(Effect.provide(test.layer)); + }, + ); + it.effect("retries device cleanup after persisting a revocation tombstone", () => { + const test = fixture(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + test.failNextCleanup(); + expect((yield* Effect.flip(widgets.revoke({ token })))._tag).toBe( + "WidgetRefreshPersistenceError", + ); + yield* widgets.notify({ userId: device.userId }); + yield* widgets.process(job); + expect(test.queued).toEqual([]); + expect(test.pushes).toEqual([]); + test.allowRevocations(false); + yield* widgets.revoke({ token }); + yield* widgets.notify({ userId: device.userId }); + yield* widgets.process(job); + expect(test.queued).toEqual([]); + expect(test.pushes).toEqual([]); + expect(test.updates.at(-1)).toEqual({ widgetAccessTokenHash: null, widgetPushToken: null }); + }).pipe(Effect.provide(test.layer)); + }); + it.effect( + "reads the latest owner state without app credentials and sees completion on the next read", + () => { + const test = fixture(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + const snapshot = yield* widgets.refresh({ token }); + expect(snapshot.environmentIds).toEqual([state.environmentId]); + expect(snapshot.aggregate).toMatchObject({ + activeCount: 1, + activities: [{ phase: "running" }], + }); + test.complete(); + expect((yield* widgets.refresh({ token })).aggregate).toMatchObject({ + activeCount: 0, + activities: [{ phase: "completed" }], + }); + expect(test.reads).toEqual(["user-a", "user-a"]); + expect(test.queries[0]).toEqual([tokenHash, "ios"]); + }).pipe(Effect.provide(test.layer)); + }, + ); + + it.effect("rejects wrong and revoked capabilities before reading agent state", () => { + const test = fixture(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + expect((yield* Effect.flip(widgets.refresh({ token: "b".repeat(64) })))._tag).toBe( + "WidgetRefreshUnauthorized", + ); + test.revoke(); + expect((yield* Effect.flip(widgets.refresh({ token })))._tag).toBe( + "WidgetRefreshUnauthorized", + ); + expect(test.reads).toEqual([]); + }).pipe(Effect.provide(test.layer)); + }); + + it.effect("rejects a stale claim before clearing another owner's token", () => { + const test = fixture(); + test.expireBeforeClaim(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + expect((yield* Effect.flip(widgets.refresh({ token, pushToken: "abc456" })))._tag).toBe( + "WidgetRefreshUnauthorized", + ); + expect(test.updates).toEqual([]); + }).pipe(Effect.provide(test.layer)); + }); + + it.effect("claims rotated WidgetKit tokens and queues change requests without stale data", () => { + const test = fixture(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + yield* widgets.refresh({ token, pushToken: "abc456" }); + expect(test.updates).toEqual([{ widgetPushToken: null }, { widgetPushToken: "abc456" }]); + yield* widgets.notify({ userId: device.userId }); + expect(test.queued).toEqual([ + { + kind: "widget_refresh", + userId: device.userId, + deviceId: device.deviceId, + token: "abc456", + bundleId: device.bundleId, + apsEnvironment: "sandbox", + aggregate: null, + }, + ]); + }).pipe(Effect.provide(test.layer)); + }); + + it.effect( + "sends a quiet WidgetKit reload with install routing and skips revoked queued targets", + () => { + const test = fixture(); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + yield* widgets.process(job); + expect(test.pushes).toHaveLength(1); + expect(test.pushes[0]).toMatchObject({ + credentials: { bundleId: device.bundleId, environment: "sandbox" }, + request: { + pushType: "widgets", + priority: "5", + payload: { aps: { "content-changed": true } }, + }, + }); + test.revoke(); + yield* widgets.process(job); + expect(test.pushes).toHaveLength(1); + }).pipe(Effect.provide(test.layer)); + }, + ); + it.effect("invalidates a widget token permanently rejected for its topic", () => { + const test = fixture({ + ok: false, + status: 400, + apnsId: "apns-id", + reason: "DeviceTokenNotForTopic", + }); + return Effect.gen(function* () { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + yield* widgets.process(job); + expect(test.updates).toContainEqual({ widgetPushToken: null }); + }).pipe(Effect.provide(test.layer)); + }); +}); diff --git a/infra/relay/src/agentActivity/AgentWidgetRefresh.ts b/infra/relay/src/agentActivity/AgentWidgetRefresh.ts new file mode 100644 index 0000000000000..0cce54fabc2b9 --- /dev/null +++ b/infra/relay/src/agentActivity/AgentWidgetRefresh.ts @@ -0,0 +1,351 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect.Crypto has no synchronous sha256 for token lookups. +import * as NodeCrypto from "node:crypto"; +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type { + RelayAgentActivitySnapshotResponse, + RelayDeliveryResult, +} from "@t3tools/contracts/relay"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as SqlError from "effect/sql/SqlError"; +import * as RelayDb from "../db.ts"; +import * as RelayConfiguration from "../Config.ts"; +import { + relayMobileDevices, + relayEnvironmentLinks, + relayWidgetRevocations, +} from "../persistence/schema.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as ApnsClient from "./ApnsClient.ts"; +import { makeAggregateState } from "./agentActivityAggregate.ts"; +import type { ApnsDeliveryJobPayload } from "./apnsDeliveryJobs.ts"; + +export class WidgetRefreshUnauthorized extends Schema.TaggedError()( + "WidgetRefreshUnauthorized", + {}, +) {} + +export const WIDGET_REVOCATION_RATE_LIMIT = { limit: 60, periodSeconds: 60 } as const; + +export class WidgetRevocationRateLimiter extends Context.Service< + WidgetRevocationRateLimiter, + { readonly allow: Effect.Effect } +>()("t3code-relay/agentActivity/AgentWidgetRefresh/WidgetRevocationRateLimiter") {} + +export class WidgetRefreshRateLimited extends Schema.TaggedError()( + "WidgetRefreshRateLimited", + {}, +) {} + +export class WidgetRefreshPersistenceError extends Schema.TaggedError()( + "WidgetRefreshPersistenceError", + { stage: Schema.String, cause: Schema.Defect() }, +) { + override get message() { + return `Failed to persist widget refresh during ${this.stage}.`; + } +} + +type WidgetRefreshError = + | WidgetRefreshPersistenceError + | WidgetRefreshUnauthorized + | AgentActivityRows.AgentActivityRowListPersistenceError; + +export class AgentWidgetRefresh extends Context.Service< + AgentWidgetRefresh, + { + readonly refresh: (input: { + readonly token: string; + readonly pushToken?: string; + }) => Effect.Effect; + readonly revoke: (input: { + readonly token: string; + }) => Effect.Effect< + void, + WidgetRefreshPersistenceError | WidgetRefreshUnauthorized | WidgetRefreshRateLimited + >; + readonly notify: (input: { + readonly userId: string; + }) => Effect.Effect< + ReadonlyArray, + WidgetRefreshPersistenceError | ApnsDeliveryQueue.ApnsDeliveryQueueError + >; + readonly process: ( + job: ApnsDeliveryJobPayload, + ) => Effect.Effect; + } +>()("t3code-relay/agentActivity/AgentWidgetRefresh") {} + +const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; + const revocationLimiter = yield* WidgetRevocationRateLimiter; + const rows = yield* AgentActivityRows.AgentActivityRows; + const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; + const apns = yield* ApnsClient.ApnsClient; + const config = yield* RelayConfiguration.RelayConfiguration; + const persistenceError = (stage: string) => + Effect.mapError((cause) => new WidgetRefreshPersistenceError({ stage, cause })); + + const isRevoked = Effect.fn("relay.agent_widget.is_revoked")(function* (tokenHash: string) { + const [revoked] = yield* db + .select() + .from(relayWidgetRevocations) + .where(eq(relayWidgetRevocations.tokenHash, tokenHash)) + .limit(1) + .pipe(persistenceError("authorize-push-target")); + return revoked !== undefined; + }); + + return AgentWidgetRefresh.of({ + revoke: Effect.fn("relay.agent_widget.revoke")(function* ({ token }) { + if (!/^[a-f0-9]{64}$/.test(token)) return yield* new WidgetRefreshUnauthorized(); + const tokenHash = NodeCrypto.createHash("sha256").update(token).digest("hex"); + const [revoked] = yield* db + .select() + .from(relayWidgetRevocations) + .where(eq(relayWidgetRevocations.tokenHash, tokenHash)) + .limit(1) + .pipe(persistenceError("revoke")); + + const [device] = yield* db + .select() + .from(relayMobileDevices) + .where(eq(relayMobileDevices.widgetAccessTokenHash, tokenHash)) + .limit(1) + .pipe(persistenceError("revoke")); + if (revoked && !device) return; + // Unknown tokens need tombstones for late registration, but cannot block a registered owner's sign-out. + if (!revoked && !device && !(yield* revocationLimiter.allow)) + return yield* new WidgetRefreshRateLimited(); + yield* db + .insert(relayWidgetRevocations) + .values({ tokenHash }) + .onConflictDoNothing() + .pipe(persistenceError("revoke")); + yield* db + .update(relayMobileDevices) + .set({ widgetAccessTokenHash: null, widgetPushToken: null }) + .where(eq(relayMobileDevices.widgetAccessTokenHash, tokenHash)) + .pipe(persistenceError("revoke")); + }), + refresh: Effect.fn("relay.agent_widget.refresh")(function* ({ token, pushToken }) { + if (!/^[a-f0-9]{64}$/.test(token)) return yield* new WidgetRefreshUnauthorized(); + const tokenHash = NodeCrypto.createHash("sha256").update(token).digest("hex"); + const [device] = yield* db + .select() + .from(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.widgetAccessTokenHash, tokenHash), + eq(relayMobileDevices.platform, "ios"), + ), + ) + .limit(1) + .pipe(persistenceError("authorize")); + if (!device) return yield* new WidgetRefreshUnauthorized(); + const [revoked] = yield* db + .select() + .from(relayWidgetRevocations) + .where(eq(relayWidgetRevocations.tokenHash, tokenHash)) + .limit(1) + .pipe(persistenceError("authorize")); + if (revoked) return yield* new WidgetRefreshUnauthorized(); + if ( + pushToken !== undefined && + pushToken !== device.widgetPushToken && + (pushToken === "" || /^[a-f0-9]{1,512}$/.test(pushToken)) + ) { + yield* db.$client + .withTransaction( + Effect.gen(function* () { + const [owner] = yield* db + .select() + .from(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, device.userId), + eq(relayMobileDevices.deviceId, device.deviceId), + eq(relayMobileDevices.widgetAccessTokenHash, tokenHash), + ), + ) + .limit(1) + .for("update") + .pipe(persistenceError("authorize-push-token")); + if (!owner) return yield* new WidgetRefreshUnauthorized(); + const [revoked] = yield* db + .select() + .from(relayWidgetRevocations) + .where(eq(relayWidgetRevocations.tokenHash, tokenHash)) + .limit(1) + .pipe(persistenceError("authorize-push-token")); + if (revoked) return yield* new WidgetRefreshUnauthorized(); + yield* db + .update(relayMobileDevices) + .set({ widgetPushToken: null }) + .where(eq(relayMobileDevices.widgetPushToken, pushToken)) + .pipe(persistenceError("claim-push-token")); + const updated = yield* db + .update(relayMobileDevices) + .set({ widgetPushToken: pushToken || null }) + .where( + and( + eq(relayMobileDevices.userId, device.userId), + eq(relayMobileDevices.deviceId, device.deviceId), + eq(relayMobileDevices.widgetAccessTokenHash, tokenHash), + ), + ) + .returning({ deviceId: relayMobileDevices.deviceId }) + .pipe(persistenceError("register-push-token")); + if (updated.length === 0) return yield* new WidgetRefreshUnauthorized(); + }), + ) + .pipe( + Effect.mapError((cause) => + SqlError.isSqlError(cause) + ? new WidgetRefreshPersistenceError({ + stage: "claim-push-token-transaction", + cause, + }) + : cause, + ), + ); + } + const coveredEnvironments = yield* db + .select({ environmentId: relayEnvironmentLinks.environmentId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.userId, device.userId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .pipe(persistenceError("list-environments")); + const activeStates = yield* rows.listForUser({ + userId: device.userId, + includeLiveActivitiesDisabled: true, + }); + const now = yield* DateTime.now; + return { + environmentIds: coveredEnvironments.map((row) => EnvironmentId.make(row.environmentId)), + aggregate: makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }), + }; + }), + notify: Effect.fn("relay.agent_widget.notify")(function* ({ userId }) { + const targets = yield* db + .select() + .from(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, userId), + isNotNull(relayMobileDevices.widgetAccessTokenHash), + isNotNull(relayMobileDevices.widgetPushToken), + ), + ) + .pipe(persistenceError("list-push-targets")); + const authorized = yield* Effect.filter(targets, (target) => + isRevoked(target.widgetAccessTokenHash ?? "").pipe(Effect.map((revoked) => !revoked)), + ); + return yield* Effect.forEach( + authorized, + (target) => + queue.enqueueLiveActivity({ + kind: "widget_refresh", + userId, + deviceId: target.deviceId, + token: target.widgetPushToken ?? "", + bundleId: target.bundleId, + apsEnvironment: target.apsEnvironment, + aggregate: null, + }), + { concurrency: 4 }, + ); + }), + process: Effect.fn("relay.agent_widget.process")(function* (job) { + const [device] = yield* db + .select() + .from(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, job.target.userId), + eq(relayMobileDevices.deviceId, job.target.deviceId), + eq(relayMobileDevices.widgetPushToken, job.target.token), + ), + ) + .limit(1) + .pipe(persistenceError("read-push-target")); + // Signed-out, revoked, or rotated targets never receive an old queued push. + const skipped = { + deviceId: job.target.deviceId, + kind: "widget_refresh" as const, + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + if (!device || !device.widgetAccessTokenHash || !config.apns) return skipped; + if (yield* isRevoked(device.widgetAccessTokenHash)) return skipped; + const now = yield* DateTime.now; + const result = yield* apns.sendPushNotificationRequest({ + credentials: { + ...config.apns, + bundleId: device.bundleId ?? config.apns.bundleId, + environment: device.apsEnvironment ?? config.apns.environment, + }, + request: { + token: job.target.token, + priority: "5", + pushType: "widgets", + payload: { aps: { "content-changed": true } }, + }, + issuedAtUnixSeconds: Math.floor(now.epochMilliseconds / 1000), + }); + if ( + result.status === 410 || + result.reason === "BadDeviceToken" || + result.reason === "Unregistered" || + result.reason === "DeviceTokenNotForTopic" + ) { + yield* db + .update(relayMobileDevices) + .set({ widgetPushToken: null }) + .where( + and( + eq(relayMobileDevices.userId, device.userId), + eq(relayMobileDevices.deviceId, device.deviceId), + eq(relayMobileDevices.widgetPushToken, job.target.token), + ), + ) + .pipe(persistenceError("invalidate-push-token")); + } else if (!result.ok) { + return yield* new ApnsClient.ApnsHttpRequestError({ + requestKind: "push-notification", + event: null, + environment: device.apsEnvironment ?? config.apns.environment, + bundleId: device.bundleId ?? config.apns.bundleId, + tokenSuffix: job.target.token.slice(-8), + stage: "send", + status: result.status, + cause: result.reason, + }); + } + return { + ...skipped, + ok: result.ok, + apnsStatus: result.status, + apnsReason: result.reason ?? null, + apnsId: result.apnsId, + }; + }), + }); +}); + +export const layer = Layer.effect(AgentWidgetRefresh, make); diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 81e76b0391aae..cddd7b2218762 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -246,6 +246,55 @@ describe("ApnsClient", () => { } }).pipe(Effect.provide(layerTest)), ); + it.effect("uses the WidgetKit push type and app topic without a user alert", () => { + const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const client = HttpClient.make((request) => { + expect(request.url).toBe("https://api.sandbox.push.apple.com/3/device/widget-token"); + expect(request.headers).toMatchObject({ + "apns-push-type": "widgets", + "apns-topic": "com.t3tools.t3code.dev.push-type.widgets", + "apns-priority": "5", + "apns-collapse-id": "agent-widget", + }); + expect(request.body._tag).toBe("Uint8Array"); + if (request.body._tag === "Uint8Array") + expect(JSON.parse(new TextDecoder().decode(request.body.body))).toEqual({ + aps: { "content-changed": true }, + }); + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))); + }); + return Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const result = yield* apns.sendPushNotificationRequest({ + credentials: { + teamId: "widget-team", + keyId: "widget-key", + privateKey: Redacted.make(privateKey), + bundleId: "com.t3tools.t3code.dev", + environment: "sandbox", + }, + request: { + token: "widget-token", + pushType: "widgets", + priority: "5", + payload: { aps: { "content-changed": true } }, + }, + issuedAtUnixSeconds: 0, + }); + expect(result.ok).toBe(true); + }).pipe( + Effect.provide( + ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index b21953c59eecc..bc6e1b4b248f7 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -40,7 +40,8 @@ interface ApnsLiveActivityRequest { interface ApnsPushNotificationRequest { readonly token: string; - readonly priority: "10"; + readonly priority: "5" | "10"; + readonly pushType?: "widgets"; readonly payload: unknown; } @@ -346,8 +347,12 @@ const make = Effect.gen(function* () { HttpClientRequest.setHeaders({ authorization: `bearer ${jwt}`, "apns-priority": input.request.priority, - "apns-push-type": "alert", - "apns-topic": input.credentials.bundleId, + "apns-push-type": input.request.pushType ?? "alert", + "apns-topic": + input.request.pushType === "widgets" + ? `${input.credentials.bundleId}.push-type.widgets` + : input.credentials.bundleId, + ...(input.request.pushType === "widgets" ? { "apns-collapse-id": "agent-widget" } : {}), }), HttpClientRequest.bodyJson(input.request.payload), Effect.flatMap(httpClient.execute), diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index e9d2ded1b2862..958f3606a0571 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1,4 +1,5 @@ import * as AgentActivityAlerts from "./agentActivityAlerts.ts"; +import * as AgentWidgetRefresh from "./AgentWidgetRefresh.ts"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { RelayAgentActivityAggregateState, @@ -158,15 +159,32 @@ function layerFor(input: { }) { return ApnsDeliveries.layer.pipe( Layer.provideMerge(NodeCryptoLayer.layerMac), + Layer.provide( + Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + notify: () => Effect.succeed([]), + process: (job) => + Effect.succeed({ + deviceId: job.target.deviceId, + kind: "widget_refresh", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }), + }), + ), Layer.provide(ApnsClient.layer), Layer.provide(ApnsProviderTokens.layer), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( Layer.succeed(AgentActivityRows.AgentActivityRows, { - upsert: () => Effect.void, + upsert: () => Effect.succeed(true), remove: () => Effect.void, pruneTerminal: () => Effect.void, + acknowledgeDelivery: () => Effect.void, listForUser: () => input.activityStates !== undefined ? Effect.succeed([...input.activityStates]) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 4f19fa3ecee05..54cbca6f91bb1 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -42,6 +42,7 @@ import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as AgentWidgetRefresh from "./AgentWidgetRefresh.ts"; import { withSpanAttributes } from "../observability.ts"; import { @@ -87,6 +88,7 @@ type ChosenPushNotificationDelivery = { type ChosenDelivery = ChosenLiveActivityDelivery | ChosenPushNotificationDelivery; export type ApnsDeliveryError = + | WidgetRefreshDeliveryError | ApnsDeliveryQueue.ApnsDeliveryQueueError | ApnsDeliveryJobVerificationError | ApnsDeliveryJobClaimInFlight @@ -94,6 +96,15 @@ export type ApnsDeliveryError = | LiveActivities.LiveActivityTargetListPersistenceError | LiveActivities.LiveActivityDeliveryMarkPersistenceError; +export class WidgetRefreshDeliveryError extends Schema.TaggedError()( + "WidgetRefreshDeliveryError", + { deviceId: Schema.String, sourceJobId: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Failed to deliver widget refresh for device ${this.deviceId}.`; + } +} + export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedError()( "ApnsDeliveryJobClaimInFlight", { @@ -414,6 +425,8 @@ function expectedCurrentToken(input: { case "live_activity_update": case "live_activity_end": return input.target.activity_push_token; + case "widget_refresh": + return null; case "push_notification": return input.target.push_token; } @@ -507,6 +520,7 @@ const make = Effect.gen(function* () { const mac = yield* Mac.Mac; const apns = yield* Apns.ApnsClient; const activityRows = yield* AgentActivityRows.AgentActivityRows; + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; const userHasContentToShow = Effect.fnUntraced(function* (userId: string) { const now = yield* DateTime.now; @@ -940,6 +954,17 @@ const make = Effect.gen(function* () { }); return yield* Effect.suspend(() => { switch (payload.kind) { + case "widget_refresh": + return widgets.process(payload).pipe( + Effect.mapError( + (cause) => + new WidgetRefreshDeliveryError({ + deviceId: payload.target.deviceId, + sourceJobId: payload.jobId, + cause, + }), + ), + ); case "live_activity_update": if (payload.aggregate === null) { return Effect.fail( diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index d6b7dfe89500d..57bb2772d7fa3 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; - +import { PgDialect } from "drizzle-orm/pg-core"; +import type { SQL } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import * as Devices from "./Devices.ts"; @@ -25,6 +26,60 @@ const deviceRow = (pushToken: string | null) => ({ }); describe("Devices", () => { + it.effect("moves a widget credential without clearing the current device's push token", () => { + const changes: Array<{ values: Record; condition: SQL }> = []; + let storedHash: unknown; + const db = { + update: () => ({ + set: (values: Record) => ({ + where: (condition: SQL) => + Effect.sync(() => { + changes.push({ values, condition }); + }), + }), + }), + insert: () => ({ + values: (values: Record) => ({ + onConflictDoUpdate: () => + Effect.sync(() => { + storedHash = values.widgetAccessTokenHash; + }), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + yield* devices.register({ + userId: "user", + registration: { + deviceId: "device", + label: "Phone", + platform: "ios", + iosMajorVersion: 18, + widgetAccessToken: "a".repeat(32), + preferences: { + notificationsEnabled: false, + liveActivitiesEnabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }, + }); + expect(changes).toHaveLength(1); + expect(changes[0]?.values).toMatchObject({ + widgetAccessTokenHash: null, + widgetPushToken: null, + }); + const query = new PgDialect().sqlToQuery(changes[0]!.condition); + expect(query.params).toEqual([storedHash, "user", "device"]); + expect(query.sql).toContain('"widget_access_token_hash" = $1'); + expect(query.sql).toContain('"user_id" <> $2'); + expect(query.sql).toContain('"device_id" <> $3'); + expect(query.sql).toContain(" or "); + }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))))); + }); it.effect.each([ [null, false], ["fcm-token", true], diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 21f05b2c61fd2..1af9db1afe23f 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -7,9 +7,11 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne, or } from "drizzle-orm"; import { sql } from "drizzle-orm"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- the widget token hash feeds synchronous insert values. +import * as NodeCrypto from "node:crypto"; import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; @@ -18,7 +20,12 @@ export class DeviceRegistrationPersistenceError extends Schema.TaggedError + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-widget-token", + cause, + }), + ), + ); + } + yield* db .insert(relayMobileDevices) .values({ @@ -131,6 +167,7 @@ const make = Effect.gen(function* () { apsEnvironment: registration.apsEnvironment ?? null, pushToken: registration.pushToken ?? null, pushToStartToken: registration.pushToStartToken ?? null, + widgetAccessTokenHash, preferencesJson: registration.preferences, createdAt: updatedAt, updatedAt, @@ -154,6 +191,8 @@ const make = Effect.gen(function* () { excluded.push_to_start_token, ${relayMobileDevices.pushToStartToken} )`, + widgetAccessTokenHash: + widgetAccessTokenHash ?? sql`${relayMobileDevices.widgetAccessTokenHash}`, preferencesJson: registration.preferences, updatedAt, }, diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index f1ce50d5832b6..c402e0dfa19b5 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -130,9 +130,10 @@ function harness() { invalidateDeliveryToken: () => Effect.void, }), Layer.succeed(AgentActivityRows.AgentActivityRows, { - upsert: () => Effect.void, + upsert: () => Effect.succeed(true), remove: () => Effect.void, pruneTerminal: () => Effect.void, + acknowledgeDelivery: () => Effect.void, listForUser: () => Effect.sync(() => current.linked @@ -298,17 +299,17 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); - it.effect.each(["completed", "waiting_for_approval", "waiting_for_input", "failed"] as const)( - "deleting one thread preserves another thread's %s alert", - (phase) => { + it.effect.each([false, true])( + "deletion or replay preserves another thread's completion alert: replay=%s", + (replay) => { const h = harness(); h.current.otherStates = [secondState]; return Effect.gen(function* () { const delivery = yield* FcmDeliveries.FcmDeliveries; yield* delivery.process(h.job); h.current.otherStates = []; - h.current.state = { ...state, phase }; - yield* delivery.enqueue({ target, state: null }); + h.current.state = { ...state, phase: "completed" }; + yield* delivery.enqueue({ target, state: replay ? secondState : null, replay }); yield* delivery.process(h.queued[0]); expect(h.sent.at(-1)?.alert).toBe(false); yield* delivery.process({ ...h.job, state: h.current.state }); @@ -319,19 +320,23 @@ describe("Android delivery routing", () => { }, ); - it.effect("registration replay establishes a baseline without alerting", () => { - const h = harness(); - h.current.state = { ...state, phase: "waiting_for_approval" }; - h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.enqueue({ target, state: null, replay: true }); - yield* delivery.process(h.queued[0]); - yield* delivery.process({ ...h.job, state: h.current.state }); - expect(h.sent.every((message) => !message.alert)).toBe(true); - expect(h.marked[0]?.aggregate?.activities).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }); + it.effect.each([false, true])( + "registration replay only acknowledges work present when queued: completion=%s", + (completeWhileQueued) => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.enqueue({ target, state: null, replay: true }); + if (completeWhileQueued) h.current.state = { ...state, phase: "completed" }; + yield* delivery.process(h.queued[0]); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(completeWhileQueued ? 1 : 0); + expect(h.marked[0]?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }, + ); it.effect.each(["mutedEnvironments", "revokedEnvironments"] as const)( "excludes %s when forming cross-environment groups", diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index d2795a90a88e5..caee8066a786f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -39,6 +39,7 @@ export const FcmDeliveryJob = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState), queuedAt: Schema.Number, replay: Schema.optional(Schema.Boolean), + replayBaseline: Schema.optional(Schema.NullOr(RelayAgentActivityAggregateState)), }); export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); @@ -120,7 +121,10 @@ export class FcmDeliveries extends Context.Service< readonly target: LiveActivities.TargetRow; readonly state: RelayAgentActivityState | null; readonly replay?: boolean; - }) => Effect.Effect; + }) => Effect.Effect< + RelayDeliveryResult | null, + FcmDeliveryError | AgentActivityRows.AgentActivityRowListPersistenceError + >; readonly process: ( body: unknown, ) => Effect.Effect< @@ -162,13 +166,27 @@ const make = Effect.gen(function* () { }; } const now = yield* DateTime.now; + const preferences = decodePreferences(input.target.preferences_json); + const replayBaseline = + input.replay && + input.state === null && + Option.isSome(preferences) && + preferences.value.liveActivitiesEnabled + ? makeAggregateState({ + activeStates: yield* rows.listForUser({ userId: input.target.user_id }), + terminalState: null, + nowMs: now.epochMilliseconds, + }) + : null; yield* sender .send({ userId: input.target.user_id, deviceId: input.target.device_id, token: input.target.push_token, state: input.state, - ...(input.replay ? { replay: true } : {}), + ...(input.replay + ? { replay: true, ...(input.state === null ? { replayBaseline } : {}) } + : {}), queuedAt: now.epochMilliseconds, }) .pipe(Effect.mapError((cause) => new FcmDeliveryError({ operation: "enqueue", cause }))); @@ -213,8 +231,45 @@ const make = Effect.gen(function* () { : null; let alert: ReturnType = null; // Deletions update the card but must not consume another thread's pending completion alert. - let acknowledgeAggregate = job.state !== null || job.replay === true || aggregate === null; - if (job.state && preferences.value.notificationsEnabled) { + let acknowledgeAggregate = job.state !== null || aggregate === null; + let acknowledgedAggregate = aggregate; + if (job.replay) { + acknowledgeAggregate = aggregate === null; + acknowledgedAggregate = aggregate === null ? null : previousAggregate; + if ( + aggregate && + !previousAggregate && + job.state === null && + job.replayBaseline !== undefined + ) { + acknowledgedAggregate = job.replayBaseline; + acknowledgeAggregate = true; + } else if (job.state && aggregate) { + const replayState = job.state; + const owned = aggregate.activities.filter( + (row) => + row.environmentId === replayState.environmentId && + row.threadId === replayState.threadId && + row.phase === replayState.phase && + row.updatedAt === replayState.updatedAt, + ); + if (owned.length > 0) { + acknowledgedAggregate = { + ...(previousAggregate ?? aggregate), + activities: [ + ...(previousAggregate?.activities ?? []).filter( + (row) => + row.environmentId !== replayState.environmentId || + row.threadId !== replayState.threadId, + ), + ...owned, + ], + }; + acknowledgeAggregate = true; + } + } + } + if (job.state && !job.replay && preferences.value.notificationsEnabled) { const state = yield* rows.getForUserThread({ userId: job.userId, environmentId: job.state.environmentId, @@ -328,7 +383,7 @@ const make = Effect.gen(function* () { deviceId: job.deviceId, kind: active ? "live_activity_update" : "live_activity_end", // Retain terminal rows as the baseline so finished-card replays cannot alert again. - aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, + aggregate: preferences.value.liveActivitiesEnabled ? acknowledgedAggregate : null, deliveredAt: DateTime.formatIso(now), }); } diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 1865a633d5715..67263d932d97d 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -1,8 +1,10 @@ +import * as AgentWidgetRefresh from "./AgentWidgetRefresh.ts"; import type { RelayAgentActivityState, RelayDeviceRegistrationRequest, } from "@t3tools/contracts/relay"; import type { SignedApnsDeliveryJob } from "./apnsDeliveryJobs.ts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -21,6 +23,22 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; import * as FcmDeliveries from "./FcmDeliveries.ts"; const layerPublisher = AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + notify: () => Effect.succeed([]), + process: (job) => + Effect.succeed({ + deviceId: job.target.deviceId, + kind: "widget_refresh", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }), + }), + ), Layer.provide( Layer.succeed(FcmDeliveries.FcmDeliveries, { enqueue: () => Effect.succeed(null), @@ -79,9 +97,10 @@ function makeAgentActivityRows( overrides: Partial = {}, ): AgentActivityRows.AgentActivityRows["Service"] { return { - upsert: () => Effect.void, + upsert: () => Effect.succeed(true), remove: () => Effect.void, pruneTerminal: () => Effect.void, + acknowledgeDelivery: () => Effect.void, listForUser: () => { const activeState: RelayAgentActivityState = { environmentId: "env-1" as RelayAgentActivityState["environmentId"], @@ -162,6 +181,22 @@ function layerRegistrationReplay(input: { Layer.provide(layerPublisher), Layer.provide( ApnsDeliveries.layer.pipe( + Layer.provide( + Layer.succeed(AgentWidgetRefresh.AgentWidgetRefresh, { + revoke: () => Effect.void, + refresh: () => Effect.succeed({ aggregate: null }), + notify: () => Effect.succeed([]), + process: (job) => + Effect.succeed({ + deviceId: job.target.deviceId, + kind: "widget_refresh", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }), + }), + ), Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))), ), ), @@ -243,6 +278,79 @@ describe("MobileRegistrations", () => { }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); + it.effect("excludes local environments before counting and selecting the five relay rows", () => + Effect.gen(function* () { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + const states: RelayAgentActivityState[] = Array.from({ length: 14 }, (_, index) => ({ + environmentId: index < 6 ? local : remote, + threadId: ThreadId.make(`thread-${index}`), + projectTitle: "Project", + threadTitle: `Task ${index}`, + modelTitle: "Test model", + phase: index < 6 ? "waiting_for_approval" : "running", + headline: "Working", + updatedAt: "1970-01-01T00:00:10.000Z", + deepLink: `/threads/${index < 6 ? local : remote}/thread-${index}`, + })); + const registrations = yield* MobileRegistrations.MobileRegistrations.pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + listForUser: (input) => + Effect.succeed( + input.includeLiveActivitiesDisabled + ? states + : states.filter((state) => state.environmentId === remote), + ), + }), + ), + Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher(), + ), + ), + ), + ), + ), + ); + const global = yield* registrations.getAgentActivitySnapshot({ userId: "user" }); + expect(global.excludedEnvironmentIds).toEqual([]); + expect(global.aggregate?.activeCount).toBe(14); + expect(global.liveActivityAggregate?.activeCount).toBe(8); + expect( + global.liveActivityAggregate?.activities.every((row) => row.environmentId === remote), + ).toBe(true); + expect(global.aggregate?.activities.every((row) => row.environmentId === local)).toBe(true); + const filtered = yield* registrations.getAgentActivitySnapshot({ + userId: "user", + excludedEnvironmentIds: [local, local], + }); + expect(filtered.excludedEnvironmentIds).toEqual([local]); + expect(filtered.aggregate?.activeCount).toBe(8); + expect(filtered.aggregate?.activities).toHaveLength(5); + expect(filtered.aggregate?.activities.every((row) => row.environmentId === remote)).toBe( + true, + ); + expect( + yield* registrations.getAgentActivitySnapshot({ + userId: "user", + excludedEnvironmentIds: [local, remote], + }), + ).toEqual({ + aggregate: null, + liveActivityAggregate: null, + excludedEnvironmentIds: [local, remote], + }); + }), + ); + it.effect( "does not remotely start a Live Activity when a device registers after work is already active", () => { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index 0818a90e22bb2..55a0c84436727 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -4,6 +4,7 @@ import type { RelayDeviceRegistrationRequest, RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -37,6 +38,7 @@ export class MobileRegistrations extends Context.Service< }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; readonly getAgentActivitySnapshot: (input: { readonly userId: string; + readonly excludedEnvironmentIds?: ReadonlyArray; }) => Effect.Effect; } >()("t3code-relay/agentActivity/MobileRegistrations") {} @@ -93,11 +95,22 @@ const make = Effect.gen(function* () { ), getAgentActivitySnapshot: Effect.fn("relay.mobile_registrations.get_agent_activity_snapshot")( function* (input) { - const activeStates = yield* rows.listForUser({ userId: input.userId }); + const [activeStates, liveActivityStates] = yield* Effect.all([ + rows.listForUser({ userId: input.userId, includeLiveActivitiesDisabled: true }), + rows.listForUser({ userId: input.userId }), + ]); const now = yield* DateTime.now; + const excludedEnvironmentIds = [...new Set(input.excludedEnvironmentIds ?? [])]; + const excluded = new Set(excludedEnvironmentIds); return { + excludedEnvironmentIds, + liveActivityAggregate: makeAggregateState({ + activeStates: liveActivityStates.filter((state) => !excluded.has(state.environmentId)), + terminalState: null, + nowMs: now.epochMilliseconds, + }), aggregate: makeAggregateState({ - activeStates, + activeStates: activeStates.filter((state) => !excluded.has(state.environmentId)), terminalState: null, nowMs: now.epochMilliseconds, }), diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 99fdcf71ef90d..58b433808d604 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -50,6 +50,9 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv title: "T3 Code", subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed", activeCount: 0, + workingCount: 0, + attentionCount: 0, + totalCount: 1, updatedAt: state.updatedAt, activities: [aggregateRowForState(state)], }); @@ -68,7 +71,7 @@ function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): b if (Number.isNaN(updatedAtMs)) { return false; } - return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; + return state.retained === true || nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; } export function makeAggregateState(input: { @@ -80,7 +83,10 @@ export function makeAggregateState(input: { (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), ); if (activeStates.length === 0) { - if (input.terminalState !== null) { + if ( + input.terminalState !== null && + !input.activeStates.some((state) => isRecentTerminalState(state, input.nowMs)) + ) { return terminalAggregateState(input.terminalState); } // Alerts compare with the delivered aggregate; replays must repaint without buzzing. @@ -95,6 +101,9 @@ export function makeAggregateState(input: { title: "T3 Code", subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", activeCount: 0, + workingCount: 0, + attentionCount: 0, + totalCount: recentTerminal.length, updatedAt: newest.updatedAt, activities: recentTerminal.slice(0, MAX_ACTIVITY_ROWS).map(aggregateRowForState), }); @@ -104,7 +113,11 @@ export function makeAggregateState(input: { .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); const displayedStates = [ ...activeStates - .toSorted((a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase)) + .toSorted( + (a, b) => + activityPhasePriority(a.phase) - activityPhasePriority(b.phase) || + b.updatedAt.localeCompare(a.updatedAt), + ) .slice(0, MAX_ACTIVITY_ROWS), ...recentTerminalStates, ].slice(0, MAX_ACTIVITY_ROWS); @@ -115,6 +128,13 @@ export function makeAggregateState(input: { title: "T3 Code", subtitle: "Agent work in progress", activeCount: activeStates.length, + workingCount: activeStates.filter( + (state) => state.phase === "running" || state.phase === "starting", + ).length, + attentionCount: activeStates.filter( + (state) => state.phase === "waiting_for_input" || state.phase === "waiting_for_approval", + ).length, + totalCount: activeStates.length + recentTerminalStates.length, updatedAt, activities: displayedStates.map(aggregateRowForState), }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index b732507a731a5..0c35688c38b8f 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -19,6 +19,7 @@ const ApnsDeliveryKindSchema = Schema.Literals([ "live_activity_update", "live_activity_end", "push_notification", + "widget_refresh", ]); const LiveActivityStartOrUpdateKindSchema = Schema.Literals(["live_activity_update"]); const LiveActivityKindSchema = Schema.Literals(["live_activity_update", "live_activity_end"]); @@ -283,6 +284,8 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI }); } return null; + case "widget_refresh": + return null; case "live_activity_end": if (payload.notification !== null) { return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 6c813235e4f61..3b9dab644d4b8 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -10,7 +10,10 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), ); const activeCount = aggregate?.activeCount ?? 0; - const attentionCount = rows.filter((row) => activityPhasePriority(row.phase) === 0).length; + const attentionCount = + aggregate?.attentionCount ?? + rows.filter((row) => activityPhasePriority(row.phase) === 0).length; + const workingCount = aggregate?.workingCount ?? Math.max(0, activeCount - attentionCount); const failed = rows.some((row) => row.phase === "failed"); const clean = (value: string) => value.replace(/\s+/g, " ").trim(); const lines = rows.map((row) => @@ -19,7 +22,7 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState const hero = rows[0]; const title = activeCount > 0 - ? `${activeCount} active agent${activeCount === 1 ? "" : "s"}${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}` + ? `${workingCount} working${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}` : failed ? "Agent work failed" : "Agent work completed"; @@ -37,6 +40,10 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState activity_title: title, activity_phase: hero?.phase ?? "", activity_active_count: String(activeCount), + activity_working_count: String(workingCount), + ...(aggregate?.totalCount === undefined + ? {} + : { activity_total_count: String(aggregate.totalCount) }), activity_attention_count: String(attentionCount), activity_body: hero ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}` diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 263e3bf11560c..b8fb77a3ad359 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -45,6 +45,51 @@ describe("EnvironmentLinks", () => { ); }); + it.effect("includes widget-only recipients without broadening ordinary delivery", () => { + const queries: ReturnType[] = []; + const db = { + select: () => ({ + from: () => ({ + where: (condition: Parameters[0]) => { + const query = new PgDialect().sqlToQuery(condition); + queries.push(query); + return Effect.succeed( + query.params.includes(true) + ? [] + : [ + { + userId: "widget-only", + notificationsEnabled: false, + liveActivitiesEnabled: false, + }, + ], + ); + }, + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const key = { environmentId: "env", environmentPublicKey: "key" }; + expect(yield* links.listDeliveryUsersForEnvironment(key)).toEqual([]); + expect( + yield* links.listDeliveryUsersForEnvironment({ ...key, includeDisabled: true }), + ).toEqual([ + { userId: "widget-only", notificationsEnabled: false, liveActivitiesEnabled: false }, + ]); + expect(queries[0]?.sql).toBe( + '(("relay_environment_links"."environment_id" = $1) and (("relay_environment_links"."revoked_at" is null)) and ((("relay_environment_links"."notifications_enabled" = $2) or ("relay_environment_links"."live_activities_enabled" = $3))) and ("relay_environment_links"."environment_public_key" = $4))', + ); + expect(queries[0]?.params).toEqual(["env", true, true, "key"]); + expect(queries[1]?.sql).toContain('"revoked_at" is null'); + expect(queries[1]?.params).toEqual(["env", "key"]); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))), + ), + ); + }); + it.effect("revokes only the active link owned by the requesting user", () => { const updateValues: Array> = []; const whereConditions: Array = []; diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index b7b38f202a1a5..62dc739f05d1b 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -112,6 +112,7 @@ export class EnvironmentLinks extends Context.Service< readonly listDeliveryUsersForEnvironment: (input: { readonly environmentId: string; readonly environmentPublicKey: string; + readonly includeDisabled?: boolean; }) => Effect.Effect< ReadonlyArray, EnvironmentLinkUserListPersistenceError @@ -155,14 +156,17 @@ export class EnvironmentLinks extends Context.Service< function agentAwarenessDeliveryUserKeyCondition(input: { readonly environmentId: string; readonly environmentPublicKey: string; + readonly includeDisabled?: boolean; }) { return and( eq(relayEnvironmentLinks.environmentId, input.environmentId), isNull(relayEnvironmentLinks.revokedAt), - or( - eq(relayEnvironmentLinks.notificationsEnabled, true), - eq(relayEnvironmentLinks.liveActivitiesEnabled, true), - ), + input.includeDisabled + ? undefined + : or( + eq(relayEnvironmentLinks.notificationsEnabled, true), + eq(relayEnvironmentLinks.liveActivitiesEnabled, true), + ), eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), ); } diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index e50e61810aad3..f57c2e0ec2436 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -138,6 +138,22 @@ describe("EnvironmentPublishSignatures", () => { ); }); + it.effect("rejects an unsigned backfill flag", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, replay: true }, + }), + ); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(layer())), + ); + it.effect("rejects top-level state tampering", () => Effect.gen(function* () { const request = yield* freshRequest; diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index 625ce46ce4d43..bbda36cca35ca 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -148,6 +148,7 @@ const make = Effect.gen(function* () { proof.threadId !== input.threadId || proof.sub !== input.environmentId || stableStringify(proof.state) !== stableStringify(input.request.state) || + (proof.replay ?? false) !== (input.request.replay ?? false) || (input.request.state !== null && (input.request.state.environmentId !== input.environmentId || input.request.state.threadId !== input.threadId)) diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 3abb8b28f4d6a..7a23fceb7ff25 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1460,6 +1460,7 @@ describe("relay routing fallback", () => { environmentPublicKey, threadId, state: null, + replay: false, })), ); expect(verified.map((input) => input.threadId)).toEqual(threadIds); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index f06d441c12c48..6c1ec2fb90798 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -1,3 +1,4 @@ +import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; import { createClerkClient, verifyToken } from "@clerk/backend"; import { sql as drizzleSql } from "drizzle-orm"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -52,6 +53,7 @@ import { RelayManagedEndpointRecoveryProofPayload, type RelayDpopAccessTokenScope, RelayInternalError, + RelayWidgetRateLimitedError, } from "@t3tools/contracts/relay"; import { normalizeRelayIssuer, @@ -78,7 +80,7 @@ import * as ManagedTunnelLimits from "../environments/ManagedTunnelLimits.ts"; import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; -import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; +import * as AgentWidgetRefresh from "../agentActivity/AgentWidgetRefresh.ts"; import { withSpanAttributes } from "../observability.ts"; import { isRelayHookPath, redactRelayHookUrl } from "../hooks/HookForwarder.ts"; import * as RelayDb from "../db.ts"; @@ -744,6 +746,77 @@ export const recoverEnvironmentTunnelRecord = Effect.fn( }; }); +export const layerWidgetApi = HttpApiBuilder.group( + RelayApi, + "widget", + Effect.fnUntraced(function* (handlers) { + const widgets = yield* AgentWidgetRefresh.AgentWidgetRefresh; + return handlers + .handle( + "refresh", + Effect.fn("relay.api.widget.refresh")( + function* ({ headers, query }) { + const token = headers.authorization.replace(/^Bearer /i, ""); + yield* appendRelayCredentialResponseHeaders; + return yield* widgets.refresh({ + token, + ...(query.pushToken !== undefined ? { pushToken: query.pushToken } : {}), + }); + }, + mapErrorTags({ + WidgetRefreshUnauthorized: (_error, traceId) => + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "not_authorized", + traceId, + }), + WidgetRefreshPersistenceError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }), + AgentActivityRowListPersistenceError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }), + }), + ), + ) + .handle( + "revoke", + Effect.fn("relay.api.widget.revoke")( + function* ({ headers }) { + yield* appendRelayCredentialResponseHeaders; + yield* widgets.revoke({ token: headers.authorization.replace(/^Bearer /i, "") }); + return { ok: true as const }; + }, + mapErrorTags({ + WidgetRefreshRateLimited: (_error, traceId) => + new RelayWidgetRateLimitedError({ + code: "widget_rate_limited", + traceId, + }), + WidgetRefreshUnauthorized: (_error, traceId) => + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "not_authorized", + traceId, + }), + WidgetRefreshPersistenceError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }), + }), + ), + ); + }), +); + export const layerMobileApi = HttpApiBuilder.group( RelayApi, "mobile", @@ -777,13 +850,16 @@ export const layerMobileApi = HttpApiBuilder.group( ) .handle( "getAgentActivitySnapshot", - Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* () { + Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* ({ query }) { const { userId, token } = yield* RelayClientPrincipal; const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); yield* requireDpopThumbprint(proofKeyThumbprint, { expectedAccessToken: token, }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); - return yield* registrations.getAgentActivitySnapshot({ userId }); + return yield* registrations.getAgentActivitySnapshot({ + userId, + excludedEnvironmentIds: query.excludedEnvironmentIds ?? [], + }); }, mapRelayCommonApiErrors("invalid_dpop")), ) .handle( @@ -1227,6 +1303,7 @@ export const layerServerApi = HttpApiBuilder.group( environmentPublicKey: principal.environmentPublicKey, threadId: params.threadId, state: payload.state, + replay: payload.replay ?? false, }); }, mapErrorTags({ @@ -1325,6 +1402,12 @@ export const layerServerApi = HttpApiBuilder.group( reason: "upstream_unavailable", traceId, }), + WidgetRefreshDeliveryError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "upstream_unavailable", + traceId, + }), FcmDeliveryError: (_error, traceId) => new RelayInternalError({ code: "internal_error", @@ -1475,6 +1558,7 @@ const RelayCommonPersistenceError = Schema.Union([ DpopProofs.DpopProofReplayPersistenceError, LiveActivities.LiveActivityTargetListPersistenceError, AgentActivityRows.AgentActivityRowUpsertPersistenceError, + AgentWidgetRefresh.WidgetRefreshPersistenceError, AgentActivityRows.AgentActivityRowDeletePersistenceError, AgentActivityRows.AgentActivityRowListPersistenceError, LiveActivities.LiveActivityDeliveryMarkPersistenceError, diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 0a15473776df9..34e488467d0bf 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -31,17 +31,26 @@ export const relayMobileDevices = pgTable( apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(), pushToken: text("push_token"), pushToStartToken: text("push_to_start_token"), + widgetAccessTokenHash: text("widget_access_token_hash"), + widgetPushToken: text("widget_push_token"), preferencesJson: jsonb("preferences_json").notNull().$type(), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), }, (table) => [ primaryKey({ columns: [table.userId, table.deviceId] }), + uniqueIndex("idx_relay_mobile_devices_widget_access_token").on(table.widgetAccessTokenHash), + uniqueIndex("idx_relay_mobile_devices_widget_push_token").on(table.widgetPushToken), uniqueIndex("idx_relay_mobile_devices_push_token").on(table.pushToken), uniqueIndex("idx_relay_mobile_devices_push_to_start_token").on(table.pushToStartToken), ], ); +// Revocation must survive a registration that arrives after sign-out's DELETE. +export const relayWidgetRevocations = pgTable("relay_widget_revocations", { + tokenHash: text("token_hash").primaryKey(), +}); + export const relayLiveActivities = pgTable( "relay_live_activities", { @@ -154,6 +163,7 @@ export const relayAgentActivityRows = pgTable( environmentPublicKey: text("environment_public_key").notNull(), threadId: varchar("thread_id", { length: 512 }).notNull(), stateJson: jsonb("state_json").notNull().$type(), + deliveryPending: boolean("delivery_pending").notNull().default(true), updatedAt: varchar("updated_at", { length: 64 }).notNull(), createdAt: varchar("created_at", { length: 64 }).notNull(), }, diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index e9da9a216726f..5229ab5cb5ef5 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -1,3 +1,4 @@ +import * as MobileTelemetry from "./mobileTelemetry/MobileTelemetry.ts"; import * as Alchemy from "alchemy"; import * as Axiom from "@alchemy.run/axiom"; import * as Cloudflare from "@alchemy.run/cloudflare"; @@ -74,7 +75,7 @@ import * as HookForwarder from "./hooks/HookForwarder.ts"; import * as HeldHooks from "./hooks/HeldHooks.ts"; import * as HookInbox from "./hooks/HookInbox.ts"; import * as HookInboxObject from "./hooks/HookInboxObject.ts"; -import * as MobileTelemetry from "./mobileTelemetry/MobileTelemetry.ts"; +import * as AgentWidgetRefresh from "./agentActivity/AgentWidgetRefresh.ts"; const layerWebcrypto = Layer.mergeAll( Digest.layerWebCrypto, @@ -100,6 +101,7 @@ const layerRelayApi = Layer.mergeAll( RelayHttpApi.layerHealthApi, RelayHttpApi.layerMetadataApi, RelayHttpApi.layerMobileApi, + RelayHttpApi.layerWidgetApi, RelayHttpApi.layerClientApi, RelayHttpApi.layerTokenApi, RelayHttpApi.layerDpopClientApi, @@ -216,6 +218,13 @@ export const layer = Api.make( period: MobileTelemetry.MOBILE_TELEMETRY_RATE_LIMIT.periodSeconds, }, }); + const widgetRevocationRateLimit = yield* Cloudflare.RateLimit("WIDGET_REVOCATION_RATE_LIMIT", { + namespaceId: 1004, + simple: { + limit: AgentWidgetRefresh.WIDGET_REVOCATION_RATE_LIMIT.limit, + period: AgentWidgetRefresh.WIDGET_REVOCATION_RATE_LIMIT.periodSeconds, + }, + }); const mobileSourceMaps = yield* Cloudflare.R2.ReadBucket(observability.mobileSourceMaps); const mobileTelemetryToken = yield* observability.mobileTelemetryIngestToken.token; const mobileTracesUrl = yield* observability.traces.otelTracesEndpoint; @@ -302,7 +311,26 @@ export const layer = Api.make( ), ), Layer.provideMerge(DpopProofs.layer), - Layer.provideMerge(ApnsDeliveries.layer), + Layer.provideMerge( + ApnsDeliveries.layer.pipe( + Layer.provideMerge( + AgentWidgetRefresh.layer.pipe( + Layer.provide( + Layer.succeed(AgentWidgetRefresh.WidgetRevocationRateLimiter, { + // Bound unauthenticated tombstone writes across every token and source; failures retain native retries. + allow: widgetRevocationRateLimit + .limit({ key: `${stage}:widget-revocations` }) + .pipe( + Effect.map((result) => result.success), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.orElseSucceed(() => false), + ), + }), + ), + ), + ), + ), + ), Layer.provideMerge( FcmDeliveries.layer.pipe( Layer.provide( diff --git a/packages/client-runtime/src/relay/dpopProof.test.ts b/packages/client-runtime/src/relay/dpopProof.test.ts index bced68c504b00..49e659a1d2b74 100644 --- a/packages/client-runtime/src/relay/dpopProof.test.ts +++ b/packages/client-runtime/src/relay/dpopProof.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import { ManagedRelayDpopSigner } from "./managedRelay.ts"; import * as DpopProof from "./dpopProof.ts"; const decodePublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); @@ -40,6 +41,26 @@ const webCryptoProofKey = Effect.gen(function* () { }); describe("DpopProof", () => { + it.effect("omits query strings from proof creation failures", () => + Effect.gen(function* () { + const signer = yield* ManagedRelayDpopSigner; + const error = yield* Effect.flip( + signer.createProof({ + method: "GET", + url: "https://relay.test/v1/mobile/agent-activity?excludedEnvironmentIds=private", + }), + ); + expect(error).toMatchObject({ url: "https://relay.test/v1/mobile/agent-activity" }); + }).pipe( + Effect.provide( + DpopProof.layerSigner({ + keyStore: "indexed-db", + loadOrCreateKey: Effect.fail(new DpopProof.DpopProofError({ message: "unavailable" })), + }).pipe(Layer.provide(layer)), + ), + ), + ); + it.effect("creates proofs the relay verifier accepts, with htu normalized and ath bound", () => Effect.gen(function* () { yield* TestClock.setTime(1_700_000_000_000); diff --git a/packages/client-runtime/src/relay/dpopProof.ts b/packages/client-runtime/src/relay/dpopProof.ts index 295f58f89125a..206c56dc53e00 100644 --- a/packages/client-runtime/src/relay/dpopProof.ts +++ b/packages/client-runtime/src/relay/dpopProof.ts @@ -98,7 +98,11 @@ export const layerSigner = (options: { ), createProof: Effect.fn("managedRelayDpopSigner.createProof")(function* (input) { const creationError = (cause: unknown) => - new ManagedRelayDpopProofCreationError({ method: input.method, url: input.url, cause }); + new ManagedRelayDpopProofCreationError({ + method: input.method, + url: normalizeDpopHtu(input.url) ?? "invalid URL", + cause, + }); const loaded = yield* loadKey.pipe(Effect.mapError(creationError)); return yield* createProof({ ...input, key: loaded.key }).pipe( Effect.provideService(SecureRandom.SecureRandom, random), diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 76c6cb905a7a9..093bf9aa08030 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -2,10 +2,12 @@ import { EnvironmentId } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, + RelayMobileRegistrationScope, } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Tracer from "effect/Tracer"; import * as Layer from "effect/Layer"; import { RELAY_GATEWAY_TIMEOUT_MESSAGE, relayRequestFailureDetail } from "./errorPresentation.ts"; @@ -40,6 +42,70 @@ function clerkToken(subject: string, nonce: string): string { } describe("ManagedRelayClient", () => { + it.effect.each([false, true])( + "keeps global reads compatible and signs the filtered request (legacy=%s)", + (legacy) => { + const requests: URL[] = []; + const spans: Tracer.NativeSpan[] = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const excludedEnvironmentIds = [EnvironmentId.make("local-1"), EnvironmentId.make("local-2")]; + const fetchFn = ((input, init) => { + const url = new URL(String(input)); + if (url.pathname === "/v1/client/dpop-token") { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + token_type: "DPoP", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + expires_in: 1_800, + scope: RelayMobileRegistrationScope, + }), + ); + } + requests.push(url); + expect(new Headers(init?.headers).get("dpop")).toBe(`proof:${url}`); + return Promise.resolve( + Response.json({ + aggregate: null, + ...(legacy + ? {} + : { excludedEnvironmentIds: url.searchParams.getAll("excludedEnvironmentIds") }), + }), + ); + }) satisfies typeof globalThis.fetch; + return Effect.gen(function* () { + const client = yield* ManagedRelay.ManagedRelayClient; + const input = { clerkToken: clerkToken("user", "session") }; + const global = yield* client.getAgentActivitySnapshot(input); + const filtered = yield* client.getAgentActivitySnapshot({ + ...input, + excludedEnvironmentIds, + }); + expect( + spans + .filter((span) => span.name === "clientRuntime.managedRelay.authorize") + .map((span) => span.attributes.get("url.full")), + ).toEqual([ + "https://relay.example.test/v1/mobile/agent-activity", + "https://relay.example.test/v1/mobile/agent-activity", + ]); + expect(requests[0]?.search).toBe(""); + expect(requests[1]?.searchParams.getAll("excludedEnvironmentIds")).toEqual( + excludedEnvironmentIds, + ); + expect(global.excludedEnvironmentIds).toEqual(legacy ? undefined : []); + expect(filtered.excludedEnvironmentIds).toEqual( + legacy ? undefined : excludedEnvironmentIds, + ); + }).pipe(Effect.provide(layerManagedRelayTest(fetchFn)), Effect.withTracer(tracer)); + }, + ); it.effect("rejects unsafe relay URLs before sending credentials", () => { let requestCount = 0; const fetchFn = (() => { diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index c10e2d5a5eca5..0c0d370a2eaba 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -34,6 +34,7 @@ import { import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { normalizeDpopHtu } from "@t3tools/shared/dpopCommon"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -316,6 +317,9 @@ export class ManagedRelayClient extends Context.Service< }) => Effect.Effect; readonly getAgentActivitySnapshot: (input: { readonly clerkToken: string; + readonly excludedEnvironmentIds?: ReadonlyArray< + RelayClientEnvironmentRecord["environmentId"] + >; }) => Effect.Effect; readonly resetTokenCache: Effect.Effect; } @@ -348,7 +352,7 @@ function relayRequestError(action: ManagedRelayRequestAction) { function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) { return { method: error.method, - url: error.url, + url: normalizeDpopHtu(error.url) ?? "invalid URL", cause: error, }; } @@ -500,9 +504,11 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( method: RelayUnregisterDeviceEndpoint.method, url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), }), - getAgentActivitySnapshot: (): DpopProofTarget => ({ + getAgentActivitySnapshot: ( + excludedEnvironmentIds?: ReadonlyArray, + ): DpopProofTarget => ({ method: RelayAgentActivitySnapshotEndpoint.method, - url: urlBuilder.mobile.getAgentActivitySnapshot(), + url: urlBuilder.mobile.getAgentActivitySnapshot({ query: { excludedEnvironmentIds } }), }), registerLiveActivity: (): DpopProofTarget => ({ method: RelayRegisterLiveActivityEndpoint.method, @@ -645,7 +651,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( "relay.client_id": options.clientId, "relay.scopes": input.scopes.join(" "), "http.request.method": input.target.method, - "url.full": input.target.url, + "url.full": normalizeDpopHtu(input.target.url) ?? "invalid URL", }); const thumbprint = yield* signer.thumbprint; const token = yield* obtainAccessToken({ @@ -954,12 +960,13 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( return yield* mobileRegistrationRequest( { clerkToken: input.clerkToken, - target: dpopProofTargets.getAgentActivitySnapshot(), + target: dpopProofTargets.getAgentActivitySnapshot(input.excludedEnvironmentIds), }, (authorization) => client.mobile .getAgentActivitySnapshot({ headers: dpopHeaders(authorization), + query: { excludedEnvironmentIds: input.excludedEnvironmentIds }, }) .pipe( Effect.mapError(relayRequestError("read relay agent activity snapshot")), diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index bf4a28c984b2c..cf25fdae8d20b 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; +import type * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; import * as Schema from "effect/Schema"; -import { RelayDeviceRegistrationRequest } from "./relay.ts"; +import { + RelayAgentActivitySnapshotEndpoint, + RelayAgentActivitySnapshotResponse, + RelayDeviceRegistrationRequest, +} from "./relay.ts"; const decodeDevice = Schema.decodeUnknownExit(RelayDeviceRegistrationRequest); const device = { @@ -32,3 +37,28 @@ describe("mobile device platforms", () => { ).toBe("Failure"); }); }); + +describe("RelayAgentActivitySnapshot", () => { + it("decodes an older global snapshot without inventing filter support", () => { + const decode = Schema.decodeUnknownSync(RelayAgentActivitySnapshotResponse); + expect(decode({ aggregate: null })).toEqual({ aggregate: null }); + expect(decode({ aggregate: null, excludedEnvironmentIds: [] })).toEqual({ + aggregate: null, + excludedEnvironmentIds: [], + }); + }); + it("decodes absent, single and repeated exclusion query values", () => { + const decode = Schema.decodeUnknownSync( + RelayAgentActivitySnapshotEndpoint.query as HttpApiEndpoint.Query< + typeof RelayAgentActivitySnapshotEndpoint + >, + ); + expect(decode({})).toEqual({}); + expect(decode({ excludedEnvironmentIds: "local" })).toEqual({ + excludedEnvironmentIds: ["local"], + }); + expect(decode({ excludedEnvironmentIds: ["local", "other"] })).toEqual({ + excludedEnvironmentIds: ["local", "other"], + }); + }); +}); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 8ebc9df23a0bb..283328bd323ea 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -55,6 +55,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ apsEnvironment: Schema.optional(RelayApnsEnvironment), pushToken: Schema.optional(TrimmedNonEmptyString), pushToStartToken: Schema.optional(TrimmedNonEmptyString), + // Read-only capability for the native widget extension. Never used for app authentication. + widgetAccessToken: Schema.optional(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/))), preferences: RelayAgentAwarenessPreferences, }).check( Schema.makeFilter((device) => @@ -63,7 +65,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ : device.androidApiLevel !== undefined && device.iosMajorVersion === undefined && device.apsEnvironment === undefined && - device.pushToStartToken === undefined, + device.pushToStartToken === undefined && + device.widgetAccessToken === undefined, ), ); export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; @@ -123,6 +126,7 @@ export const RelayAgentActivityState = Schema.Struct({ threadTitle: TrimmedNonEmptyString, phase: RelayAgentAwarenessPhase, headline: TrimmedNonEmptyString, + retained: Schema.optional(Schema.Boolean), detail: Schema.optional(TrimmedNonEmptyString), modelTitle: TrimmedNonEmptyString, updatedAt: TrimmedNonEmptyString, @@ -147,6 +151,9 @@ export const RelayAgentActivityAggregateState = Schema.Struct({ title: TrimmedNonEmptyString, subtitle: TrimmedNonEmptyString, activeCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + workingCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + attentionCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + totalCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), updatedAt: TrimmedNonEmptyString, activities: Schema.Array(RelayAgentActivityAggregateRow), }); @@ -263,11 +270,13 @@ export const RelayAgentActivityPublishProofPayload = Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId, state: Schema.NullOr(RelayAgentActivityState), + replay: Schema.optional(Schema.Boolean), }); export type RelayAgentActivityPublishProofPayload = typeof RelayAgentActivityPublishProofPayload.Type; export const RelayAgentActivityPublishRequest = Schema.Struct({ + replay: Schema.optional(Schema.Boolean), state: Schema.NullOr(RelayAgentActivityState).annotate({ description: "Current agent-awareness state, or null to remove the published state.", }), @@ -944,6 +953,7 @@ export const RelayDeliveryKind = Schema.Literals([ "live_activity_update", "live_activity_end", "push_notification", + "widget_refresh", ]); export type RelayDeliveryKind = typeof RelayDeliveryKind.Type; @@ -1047,6 +1057,10 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( export const RelayAgentActivitySnapshotResponse = Schema.Struct({ aggregate: Schema.NullOr(RelayAgentActivityAggregateState), + liveActivityAggregate: Schema.optional(Schema.NullOr(RelayAgentActivityAggregateState)), + environmentIds: Schema.optional(Schema.Array(EnvironmentId)), + // Absent on older relays, which may ignore an unknown query parameter. + excludedEnvironmentIds: Schema.optional(Schema.Array(EnvironmentId)), }); export type RelayAgentActivitySnapshotResponse = typeof RelayAgentActivitySnapshotResponse.Type; @@ -1054,6 +1068,9 @@ export const RelayAgentActivitySnapshotEndpoint = HttpApiEndpoint.get( "getAgentActivitySnapshot", "/v1/mobile/agent-activity", { + query: { + excludedEnvironmentIds: Schema.optional(Schema.ArrayEnsure(EnvironmentId)), + }, headers: RelayDpopRequestHeaders, success: RelayAgentActivitySnapshotResponse, error: RelayAuthAndInternalErrors, @@ -1081,6 +1098,28 @@ const RelayMobileGroup = HttpApiGroup.make("mobile") .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") .middleware(RelayDpopClientAuth); +export class RelayWidgetRateLimitedError extends Schema.TaggedError()( + "RelayWidgetRateLimitedError", + { code: Schema.Literal("widget_rate_limited"), traceId: TrimmedNonEmptyString }, + { httpApiStatus: 429 }, +) {} + +const RelayWidgetGroup = HttpApiGroup.make("widget").add( + HttpApiEndpoint.get("refresh", "/v1/widget/agent-activity", { + headers: RelayBearerRequestHeaders, + query: { + pushToken: Schema.optional(Schema.String.check(Schema.isPattern(/^(?:[a-f0-9]{1,512})?$/))), + }, + success: RelayAgentActivitySnapshotResponse, + error: RelayAuthAndInternalErrors, + }), + HttpApiEndpoint.delete("revoke", "/v1/widget/agent-activity", { + headers: RelayBearerRequestHeaders, + success: RelayOkResponse, + error: [...RelayAuthAndInternalErrors, RelayWidgetRateLimitedError], + }), +); + const RelayClientGroup = HttpApiGroup.make("client") .add( HttpApiEndpoint.get("listEnvironments", "/v1/environments", { @@ -1313,6 +1352,7 @@ export const RelayApi = HttpApi.make("RelayApi") RelayHealthGroup, RelayMetadataGroup, RelayMobileGroup, + RelayWidgetGroup, RelayClientGroup, RelayTokenGroup, RelayDpopClientGroup, diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 71f4f21bd1e2a..35d36d581dcf2 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -49,6 +49,48 @@ describe("projectThreadAwarenessV2", () => { ...overrides, }); + it("excludes archived and snoozed inbox rows, then restores them on wake", () => { + const base = { + environmentId: "env-1" as EnvironmentId, + project, + now: Date.parse(NOW), + thread: v2Thread({ status: "completed" }), + }; + const snoozedUntil = DateTime.makeUnsafe("2026-05-22T13:00:00.000Z"); + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, archivedAt: updatedAt } }), + ).toBeNull(); + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, snoozedUntil } }), + ).toBeNull(); + expect( + projectThreadAwarenessV2({ + ...base, + now: Date.parse("2026-05-22T13:01:00.000Z"), + thread: { ...base.thread, snoozedUntil }, + }), + ).toMatchObject({ phase: "completed", retained: true }); + const snoozedAt = DateTime.makeUnsafe("2026-05-22T11:30:00.000Z"); + const waiting = (kind: "user_input" | "command", createdAt: string) => + projectThreadAwarenessV2({ + ...base, + thread: { + ...v2Thread({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-1"), + kind, + createdAt: DateTime.makeUnsafe(createdAt), + }, + }), + snoozedUntil, + snoozedAt, + }, + })?.phase ?? null; + expect(waiting("user_input", "2026-05-22T11:00:00.000Z")).toBeNull(); + expect(waiting("user_input", "2026-05-22T11:45:00.000Z")).toBe("waiting_for_input"); + expect(waiting("command", "2026-05-22T11:00:00.000Z")).toBe("waiting_for_approval"); + }); + it("does not announce completion after a manual stop", () => { const input = { environmentId: "env-1" as EnvironmentId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 4e68951308c51..d4da4df300699 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -68,6 +68,7 @@ export interface AgentAwarenessState { readonly threadTitle: string; readonly phase: AgentAwarenessPhase; readonly headline: string; + readonly retained?: boolean; readonly detail?: string; readonly modelTitle: string; readonly updatedAt: string; @@ -84,21 +85,28 @@ function buildAgentAwarenessDeepLink(input: { export interface ProjectThreadAwarenessV2Input { readonly environmentId: EnvironmentId; readonly project: Pick; - readonly thread: Pick< - OrchestrationV2ThreadShell, - | "activityRunStatus" - | "id" - | "lineage" - | "latestRunBackgroundWake" - | "latestRunStopRequested" - | "modelSelection" - | "pendingBackgroundTasks" - | "pendingRuntimeRequest" - | "settledOverride" - | "status" - | "title" - | "updatedAt" - >; + readonly now?: number; + readonly thread: Partial< + Pick< + OrchestrationV2ThreadShell, + "archivedAt" | "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" + > + > & + Pick< + OrchestrationV2ThreadShell, + | "activityRunStatus" + | "id" + | "lineage" + | "latestRunBackgroundWake" + | "latestRunStopRequested" + | "modelSelection" + | "pendingBackgroundTasks" + | "pendingRuntimeRequest" + | "settledOverride" + | "status" + | "title" + | "updatedAt" + >; /** What a live subagent below this thread is waiting on the user for. */ readonly pendingSubagentRequest?: SubagentRequestAwareness | null; } @@ -107,8 +115,28 @@ export function projectThreadAwarenessV2( input: ProjectThreadAwarenessV2Input, ): AgentAwarenessState | null { const { environmentId, project, thread } = input; - if (!isListedThreadLineage(thread.lineage)) return null; + if (!isListedThreadLineage(thread.lineage) || thread.archivedAt != null) return null; const subagentRequest = input.pendingSubagentRequest ?? null; + if ( + input.now !== undefined && + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) > input.now + ) { + const failedAfterSnooze = + (thread.activityRunStatus ?? thread.status) === "failed" && + (thread.snoozedAt == null || + (thread.latestRunCompletedAt != null && + DateTime.toEpochMillis(thread.latestRunCompletedAt) > + DateTime.toEpochMillis(thread.snoozedAt))); + // Matches the app's snooze: approvals wake it, a question only if asked after snoozing. + const request = thread.pendingRuntimeRequest; + const requestWakes = + request != null && + (request.kind !== "user_input" || + thread.snoozedAt == null || + DateTime.toEpochMillis(request.createdAt) > DateTime.toEpochMillis(thread.snoozedAt)); + if (!requestWakes && subagentRequest === null && !failedAfterSnooze) return null; + } const phase = resolveThreadAwarenessPhaseV2(thread, subagentRequest); if (phase === null) { return null; @@ -138,6 +166,7 @@ export function projectThreadAwarenessV2( threadTitle: thread.title, phase, headline: headlineForPhase(phase), + retained: true, ...(detail === undefined ? {} : { detail }), modelTitle: thread.modelSelection.model, // A child's request leaves the parent's own timestamp alone. Alerts are