diff --git a/CHANGELOG.md b/CHANGELOG.md index 4377e3e9e150..a32f6bee52e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - **Model aliases:** resolve provider-qualified aliases during session and chat-command model switches without collisions when providers share a display alias. (#100209) Thanks @sahilsatralkar. - **TUI new-session hooks:** create `/new` sessions through the shared Gateway lifecycle so command and session hooks receive the completed parent transcript in both Gateway and embedded modes, while preventing rollover during an active turn. (#100241, #49918) Thanks @BingqingLyu. - **TUI abort diagnostics:** show sanitized tool argument-validation summaries for aborted runs in both Gateway and local TUI modes without exposing raw model arguments. (#91002) Thanks @wsyjh8. +- **iOS Watch replies:** persist queued quick replies in the gateway-scoped chat outbox and submit them through idempotent chat delivery, preventing losses, duplicates, and cross-gateway sends after reconnects. (#100031) Thanks @NianJiuZst. ## 2026.7.1 diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 8a8d365398ad..00592da22982 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -10995,7 +10995,7 @@ }, { "kind": "conditional-branch", - "line": 2319, + "line": 2343, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -11003,7 +11003,7 @@ }, { "kind": "conditional-branch", - "line": 2319, + "line": 2343, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -11011,7 +11011,7 @@ }, { "kind": "conditional-branch", - "line": 2652, + "line": 2676, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -11019,7 +11019,7 @@ }, { "kind": "conditional-branch", - "line": 2652, + "line": 2676, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -11027,7 +11027,7 @@ }, { "kind": "conditional-branch", - "line": 2656, + "line": 2680, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -11035,7 +11035,7 @@ }, { "kind": "conditional-branch", - "line": 2656, + "line": 2680, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -11043,7 +11043,7 @@ }, { "kind": "conditional-branch", - "line": 2964, + "line": 2988, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -11051,7 +11051,7 @@ }, { "kind": "conditional-branch", - "line": 2964, + "line": 2988, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 6096d088c326..7ee993c3da75 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -50,6 +50,11 @@ private struct ExecApprovalGatewayEventPayload: Decodable { var id: String } +private struct NodeEventRequestPayload: Encodable { + var event: String + var payloadJSON: String +} + /// Ensures notification requests return promptly even if the system prompt blocks. private final class NotificationInvokeLatch: @unchecked Sendable { private let lock = NSLock() @@ -87,6 +92,7 @@ private enum IOSDeepLinkAgentPolicy { // swiftlint:disable type_body_length file_length final class NodeAppModel { private nonisolated static let watchChatPreviewItemLimit = 5 + private nonisolated static let watchMessageMaxImmediateRetryAttempts = 3 struct AgentDeepLinkPrompt: Identifiable, Equatable { let id: String @@ -122,12 +128,19 @@ final class NodeAppModel { case failed(message: String) } + private enum WatchMessageSendOutcome { + case sent + case retry + case discard + } + private struct PersistedWatchExecApprovalBridgeState: Codable { var approvals: [ExecApprovalPrompt] var pendingApprovalIDs: [String]? } private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink") + private nonisolated static let agentRequestNodeEventTimeoutSeconds = 8 private nonisolated static let execApprovalNotificationGuidanceSuppressedKey = "notifications.execApprovalGuidance.suppressed" private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake") @@ -225,6 +238,9 @@ final class NodeAppModel { private let remindersService: any RemindersServicing private let motionService: any MotionServicing private let watchMessagingService: any WatchMessagingServicing + #if DEBUG + @ObservationIgnored private var testAgentRequestHandler: ((AgentDeepLink) async throws -> Void)? + #endif private var pttVoiceWakeSuspended = false private var talkVoiceWakeSuspended = false private var backgroundVoiceWakeSuspended = false @@ -238,8 +254,10 @@ final class NodeAppModel { private var backgroundReconnectLeaseUntil: Date? @ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false private var lastSignificantLocationWakeAt: Date? - @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator() - @ObservationIgnored private let watchChatCoordinator = WatchChatCoordinator() + @ObservationIgnored private let watchMessageOutbox = WatchMessageOutbox() + @ObservationIgnored private var watchMessageFlushInFlight = false + @ObservationIgnored private var watchMessageRetryAttempts: [String: Int] = [:] + @ObservationIgnored private var watchMessageRetryTask: Task? @ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport() private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:] private var pendingWatchExecApprovalRecoveryIDs: [String] = [] @@ -1904,15 +1922,21 @@ extension NodeAppModel { message: "INVALID_REQUEST: empty watch notification")) } do { + let gatewayStableID = self.currentWatchChatGatewayStableID() + self.watchMessageOutbox.recordPromptRoute( + promptID: normalizedParams.promptId, + gatewayStableID: gatewayStableID) let result = try await self.watchMessagingService.sendNotification( id: req.id, - params: normalizedParams) + params: normalizedParams, + gatewayStableID: gatewayStableID) if result.queuedForDelivery || !result.deliveredImmediately { let invokeID = req.id Task { @MainActor in await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( invokeID: invokeID, params: normalizedParams, + gatewayStableID: gatewayStableID, sendResult: result) } } @@ -2971,7 +2995,7 @@ extension NodeAppModel { return } Task { [weak self] in - await self?.flushQueuedWatchChatsIfAvailable() + await self?.flushQueuedWatchMessagesIfAvailable() guard changed else { return } await self?.syncWatchAppSnapshot(reason: "operator_online") } @@ -3180,7 +3204,6 @@ extension NodeAppModel { /// Back-compat hook retained for older gateway-connect flows. func onNodeGatewayConnected() async { await self.registerAPNsTokenIfNeeded() - await self.flushQueuedWatchRepliesIfConnected() await self.syncWatchAppSnapshot(reason: "node_connected", includeChat: true) await self.syncWatchExecApprovalSnapshot(reason: "node_connected") await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected") @@ -3263,53 +3286,61 @@ extension NodeAppModel { } private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { - switch await self.watchReplyCoordinator.ingest(event, isGatewayConnected: self.isGatewayConnected()) { - case .dropMissingFields: + let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + let actionID = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !replyID.isEmpty, !actionID.isEmpty else { self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") - case let .deduped(replyId): - self.watchReplyLogger.debug( - "watch reply deduped replyId=\(replyId, privacy: .public)") - case let .queue(replyId, actionId): - self.watchReplyLogger.info( - "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)") - case .forward: - await self.forwardWatchReplyToAgent(event) + return } - } + let payloadGatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let currentGatewayID = self.currentWatchChatGatewayStableID() + let routedGatewayID = self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId) ?? "" + let sourceGatewayID: String + if !payloadGatewayID.isEmpty { + sourceGatewayID = payloadGatewayID + } else if !routedGatewayID.isEmpty { + sourceGatewayID = routedGatewayID + } else if let currentGatewayID { + // Shipped prompts predate gateway routing metadata and cannot be migrated after delivery. + // Bind that prompt once; explicit or persisted owners never use this fallback. + sourceGatewayID = currentGatewayID + self.watchMessageOutbox.recordPromptRoute( + promptID: event.promptId, + gatewayStableID: currentGatewayID) + } else { + sourceGatewayID = "" + } + if !sourceGatewayID.isEmpty, let currentGatewayID, currentGatewayID != sourceGatewayID { + self.watchReplyLogger.info("watch reply dropped: stale gateway target") + return + } + guard !sourceGatewayID.isEmpty else { + self.watchReplyLogger.info("watch reply dropped: unresolved gateway target") + return + } + let gatewayStableID = sourceGatewayID - private func flushQueuedWatchRepliesIfConnected() async { - for event in await self.watchReplyCoordinator.drainIfConnected(self.isGatewayConnected()) { - await self.forwardWatchReplyToAgent(event) - } - } + let message = WatchAppCommandEvent( + commandId: replyID, + command: .sendChat, + sessionKey: event.sessionKey, + gatewayStableID: gatewayStableID, + text: Self.makeWatchReplyAgentMessage(event), + sentAtMs: event.sentAtMs, + transport: event.transport, + messageKind: .quickReply) + let needsReconnect = !self.isWatchMessageSendAvailable() + await self.handleWatchMessage(message) + guard needsReconnect else { return } - private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async { - let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey - let message = Self.makeWatchReplyAgentMessage(event) - let link = AgentDeepLink( - message: message, - sessionKey: effectiveSessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: nil, - timeoutSeconds: nil, - key: event.replyId) - do { - try await self.sendAgentRequest(link: link) - let forwardedMessage = - "watch reply forwarded replyId=\(event.replyId) " - + "action=\(event.actionId)" - self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)") - self.openChatRequestID &+= 1 - } catch { - let failedMessage = - "watch reply forwarding failed replyId=\(event.replyId) " - + "error=\(error.localizedDescription)" - self.watchReplyLogger.error("\(failedMessage, privacy: .public)") - self.watchReplyCoordinator.requeueFront(event) + let connected = await self.ensureOperatorApprovalConnectionForWatchReview( + timeoutMs: 12000, + reason: "watch_reply") + guard connected, self.currentWatchChatGatewayStableID() == gatewayStableID else { + self.watchReplyLogger.info("watch reply remains queued: gateway target unavailable") + return } + await self.flushQueuedWatchMessagesIfAvailable() } private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String { @@ -3783,53 +3814,92 @@ extension NodeAppModel { } private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async { - guard self.watchChatCommandTargetsCurrentGateway(event) else { + guard self.watchMessageTargetsCurrentGateway(event) else { GatewayDiagnostics.log("watch chat send skipped: stale gateway target") await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true) return } - let eventGatewayID = self.normalizedWatchChatGatewayStableID(event) - switch self.watchChatCoordinator.ingest( + await self.handleWatchMessage(event) + } + + private func handleWatchMessage(_ event: WatchAppCommandEvent) async { + let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event) + let isAvailable = self.isWatchMessageSendAvailable() + if isAvailable, !self.watchMessageTargetsCurrentGateway(event) { + GatewayDiagnostics.log("watch message send skipped: stale gateway target") + return + } + switch self.watchMessageOutbox.ingest( event, - isChatAvailable: self.isWatchChatAvailableForSend(), + isAvailable: isAvailable, gatewayStableID: eventGatewayID) { case .dropMissingFields: - GatewayDiagnostics.log("watch chat send skipped: missing commandId/text") + GatewayDiagnostics.log("watch message send skipped: missing id/text") case .dropMissingTarget: - GatewayDiagnostics.log("watch chat send skipped: missing gateway target") - case let .deduped(commandId): - GatewayDiagnostics.log("watch chat send deduped commandId=\(commandId)") - case let .queue(commandId): - GatewayDiagnostics.log("watch chat send queued commandId=\(commandId)") - await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true) - case .forward: - _ = await self.forwardWatchChatMessage(event, requeueOnFailure: true) - } - } - - private func flushQueuedWatchChatsIfAvailable() async { - let gatewayStableID = self.currentWatchChatGatewayStableID() - while let event = self.watchChatCoordinator.nextQueuedCommand( - isChatAvailable: self.isWatchChatAvailableForSend(), - gatewayStableID: gatewayStableID) - { - guard self.watchChatCommandTargetsCurrentGateway(event) else { - GatewayDiagnostics.log("watch chat send skipped: stale queued gateway target") - self.watchChatCoordinator.removeQueuedCommand( - commandId: event.commandId, - gatewayStableID: gatewayStableID) - continue + GatewayDiagnostics.log("watch message send skipped: missing gateway target") + case let .deduped(messageID): + GatewayDiagnostics.log("watch message send deduped id=\(messageID)") + case let .queue(messageID): + GatewayDiagnostics.log("watch message send queued id=\(messageID)") + if self.watchMessageKind(event) == .chat { + await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true) + } + case .forward: + switch await self.forwardWatchMessage(event, requeueOnFailure: true) { + case .sent, .discard: + self.watchMessageOutbox.removeQueuedMessage( + messageID: event.commandId, + gatewayStableID: eventGatewayID) + self.watchMessageRetryAttempts[event.commandId] = nil + case .retry: + self.scheduleWatchMessageRetry(messageID: event.commandId) } - let sent = await self.forwardWatchChatMessage(event, requeueOnFailure: false) - guard sent else { return } - self.watchChatCoordinator.removeQueuedCommand( - commandId: event.commandId, - gatewayStableID: gatewayStableID) } } - private func isWatchChatAvailableForSend() -> Bool { + private func flushQueuedWatchMessagesIfAvailable() async { + guard !self.watchMessageFlushInFlight else { return } + self.watchMessageFlushInFlight = true + defer { self.watchMessageFlushInFlight = false } + guard let gatewayStableID = self.currentWatchChatGatewayStableID() else { return } + while self.currentWatchChatGatewayStableID() == gatewayStableID { + guard let event = self.watchMessageOutbox.nextQueuedMessage( + isAvailable: self.isWatchMessageSendAvailable(), + gatewayStableID: gatewayStableID) + else { return } + guard self.watchMessageTargetsCurrentGateway(event) else { return } + switch await self.forwardWatchMessage(event, requeueOnFailure: false) { + case .sent, .discard: + self.watchMessageOutbox.removeQueuedMessage( + messageID: event.commandId, + gatewayStableID: gatewayStableID) + self.watchMessageRetryAttempts[event.commandId] = nil + case .retry: + self.scheduleWatchMessageRetry(messageID: event.commandId) + return + } + } + } + + private func scheduleWatchMessageRetry(messageID: String) { + guard self.isWatchMessageSendAvailable(), self.watchMessageRetryTask == nil else { return } + let attempt = (self.watchMessageRetryAttempts[messageID] ?? 0) + 1 + guard attempt <= Self.watchMessageMaxImmediateRetryAttempts else { + GatewayDiagnostics.log("watch message retry deferred until reconnect id=\(messageID)") + return + } + self.watchMessageRetryAttempts[messageID] = attempt + let delayNanoseconds = UInt64(500 * (1 << (attempt - 1))) * 1_000_000 + self.watchMessageRetryTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: delayNanoseconds) + guard let self else { return } + self.watchMessageRetryTask = nil + await self.flushQueuedWatchMessagesIfAvailable() + } + } + + private func isWatchMessageSendAvailable() -> Bool { self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected } @@ -3837,32 +3907,45 @@ extension NodeAppModel { self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) } - private func normalizedWatchChatGatewayStableID(_ event: WatchAppCommandEvent) -> String? { + private func normalizedWatchMessageGatewayStableID(_ event: WatchAppCommandEvent) -> String? { let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return gatewayStableID.isEmpty ? nil : gatewayStableID } - private func watchChatCommandTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool { - let eventGatewayID = self.normalizedWatchChatGatewayStableID(event) ?? "" + private func watchMessageTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool { + let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event) ?? "" let currentGatewayID = self.currentWatchChatGatewayStableID() ?? "" guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false } return eventGatewayID == currentGatewayID } - private func forwardWatchChatMessage( + private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind { + event.messageKind ?? .chat + } + + private func forwardWatchMessage( _ event: WatchAppCommandEvent, - requeueOnFailure: Bool) async -> Bool + requeueOnFailure: Bool) async -> WatchMessageSendOutcome { + guard self.watchMessageTargetsCurrentGateway(event) else { + GatewayDiagnostics.log("watch message send skipped: stale gateway target") + return .retry + } let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !text.isEmpty else { GatewayDiagnostics.log("watch chat send skipped: empty text") - return true + return .discard } + let messageKind = self.watchMessageKind(event) + let fallbackSessionKey = messageKind == .quickReply ? self.mainSessionKey : self.chatSessionKey let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? event.sessionKey! - : self.chatSessionKey - self.focusChatSession(sessionKey) + : fallbackSessionKey + if messageKind == .chat { + self.focusChatSession(sessionKey) + } + let thinking = messageKind == .quickReply ? "low" : "auto" do { let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000) @@ -3870,9 +3953,13 @@ extension NodeAppModel { let response = try await self.appleReviewDemoChatTransport.sendMessage( sessionKey: sessionKey, message: text, - thinking: "auto", + thinking: thinking, idempotencyKey: event.commandId, attachments: []) + if messageKind == .quickReply { + await self.finishForwardedWatchMessage(event) + return .sent + } let history = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey) if let replyText = Self.watchChatReplyText( from: history.messages ?? [], @@ -3883,17 +3970,17 @@ extension NodeAppModel { await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText) } await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true) - return true + return .sent } guard self.isOperatorGatewayConnected else { GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected") if requeueOnFailure { - self.watchChatCoordinator.requeueFront( + self.watchMessageOutbox.requeueFront( event, - gatewayStableID: self.normalizedWatchChatGatewayStableID(event)) + gatewayStableID: self.normalizedWatchMessageGatewayStableID(event)) } - return false + return .retry } let transport = IOSGatewayChatTransport(gateway: self.operatorSession) @@ -3902,9 +3989,13 @@ extension NodeAppModel { let response = try await transport.sendMessage( sessionKey: sessionKey, message: text, - thinking: "auto", + thinking: thinking, idempotencyKey: event.commandId, attachments: []) + if messageKind == .quickReply { + await self.finishForwardedWatchMessage(event) + return .sent + } await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true) _ = await transport.waitForRunCompletion( runId: response.runId, @@ -3920,15 +4011,19 @@ extension NodeAppModel { await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText) } await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true) - return true + return .sent } catch { GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)") - if requeueOnFailure { - self.watchChatCoordinator.requeueFront( - event, - gatewayStableID: self.normalizedWatchChatGatewayStableID(event)) + if Self.shouldDiscardFailedWatchMessage(error) { + GatewayDiagnostics.log("watch message discarded after permanent send failure id=\(event.commandId)") + return .discard } - return false + if requeueOnFailure { + self.watchMessageOutbox.requeueFront( + event, + gatewayStableID: self.normalizedWatchMessageGatewayStableID(event)) + } + return .retry } } @@ -3969,6 +4064,22 @@ extension NodeAppModel { } } + private nonisolated static func shouldDiscardFailedWatchMessage(_ error: Error) -> Bool { + guard let gatewayError = error as? GatewayResponseError else { return false } + guard gatewayError.code == "INVALID_REQUEST" else { return false } + return !gatewayError.message.lowercased().hasSuffix("retry.") + } + + private func finishForwardedWatchMessage(_ event: WatchAppCommandEvent) async { + if self.watchMessageKind(event) == .chat { + await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true) + return + } + self.watchReplyLogger.info( + "watch reply forwarded replyId=\(event.commandId, privacy: .public)") + self.openChatRequestID &+= 1 + } + private func syncWatchAppSnapshot(reason: String, includeChat: Bool = false) async { let chatPreview = includeChat ? await self.makeWatchChatPreview() : nil let message = self.makeWatchAppSnapshot(chatPreview: chatPreview) @@ -5129,13 +5240,29 @@ extension NodeAppModel { ]) } + #if DEBUG + if let testAgentRequestHandler { + try await testAgentRequestHandler(link) + return + } + #endif + let data = try JSONEncoder().encode(link) guard let json = String(bytes: data, encoding: .utf8) else { throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", ]) } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + let requestData = try JSONEncoder().encode(NodeEventRequestPayload(event: "agent.request", payloadJSON: json)) + guard let requestJSON = String(bytes: requestData, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request node event as UTF-8", + ]) + } + _ = try await self.nodeGateway.request( + method: "node.event", + paramsJSON: requestJSON, + timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds) } private func isGatewayConnected() async -> Bool { @@ -5305,23 +5432,37 @@ extension NodeAppModel { } func _test_queuedWatchReplyCount() -> Int { - self.watchReplyCoordinator.queuedCount + self.watchMessageOutbox.queuedCount(kind: .quickReply) } func _test_queuedWatchChatCommandCount() -> Int { - self.watchChatCoordinator.queuedCount + self.watchMessageOutbox.queuedCount(kind: .chat) } func _test_queuedWatchChatCommandIds() -> [String] { - self.watchChatCoordinator.queuedCommandIds + self.watchMessageOutbox.queuedMessageIDs(kind: .chat) + } + + func _test_recordWatchPromptRoute(promptID: String, gatewayStableID: String) { + self.watchMessageOutbox.recordPromptRoute( + promptID: promptID, + gatewayStableID: gatewayStableID) } func _test_setConnectedGatewayID(_ gatewayID: String?) { self.connectedGatewayID = gatewayID } + func _test_setAgentRequestHandler(_ handler: @escaping (AgentDeepLink) async throws -> Void) { + self.testAgentRequestHandler = handler + } + static func _test_resetPersistedWatchChatQueueState() { - WatchChatCoordinator.resetPersistedQueue() + WatchMessageOutbox.resetPersistedQueue() + } + + static func _test_resetPersistedWatchReplyQueueState() { + WatchMessageOutbox.resetPersistedQueue() } func _test_setGatewayConnected(_ connected: Bool) { @@ -5504,6 +5645,14 @@ extension NodeAppModel { self.expectedDeepLinkKey() } + nonisolated static func _test_shouldDiscardFailedWatchMessage( + code: String, + message: String = "test") -> Bool + { + self.shouldDiscardFailedWatchMessage( + GatewayResponseError(method: "chat.send", code: code, message: message, details: nil)) + } + static func _test_resetPersistedWatchExecApprovalBridgeState() { UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey) } diff --git a/apps/ios/Sources/Model/WatchReplyCoordinator.swift b/apps/ios/Sources/Model/WatchReplyCoordinator.swift index ee8739440742..65ebb6da1c95 100644 --- a/apps/ios/Sources/Model/WatchReplyCoordinator.swift +++ b/apps/ios/Sources/Model/WatchReplyCoordinator.swift @@ -1,203 +1,230 @@ import Foundation @MainActor -final class WatchReplyCoordinator { - enum Decision { - case dropMissingFields - case deduped(replyId: String) - case queue(replyId: String, actionId: String) - case forward - } - - private var queuedReplies: [WatchQuickReplyEvent] = [] - private var seenReplyIds = Set() - - func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision { - let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) - let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) - if replyId.isEmpty || actionId.isEmpty { - return .dropMissingFields - } - if self.seenReplyIds.contains(replyId) { - return .deduped(replyId: replyId) - } - self.seenReplyIds.insert(replyId) - if !isGatewayConnected { - self.queuedReplies.append(event) - return .queue(replyId: replyId, actionId: actionId) - } - return .forward - } - - func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] { - guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] } - let pending = self.queuedReplies - self.queuedReplies.removeAll() - return pending - } - - func requeueFront(_ event: WatchQuickReplyEvent) { - self.queuedReplies.insert(event, at: 0) - } - - var queuedCount: Int { - self.queuedReplies.count - } -} - -@MainActor -final class WatchChatCoordinator { +final class WatchMessageOutbox { enum Decision { case dropMissingFields case dropMissingTarget - case deduped(commandId: String) - case queue(commandId: String) + case deduped(messageID: String) + case queue(messageID: String) case forward } + // Keep the shipped chat key so upgrades retain messages already queued by the Watch. private static let persistedQueueKey = "watch.chat.command.queue.v1" - private static let maxRecentCommandIds = 128 + private static let persistedMetadataKey = "watch.message.outbox.metadata.v1" + private static let maxRecentMessageIDs = 128 + private static let maxPromptRoutes = 128 - private struct QueuedCommand: Codable, Equatable { + private struct QueuedMessage: Codable, Equatable { var gatewayStableID: String var event: WatchAppCommandEvent } + private struct PromptRoute: Codable, Equatable { + var promptID: String + var gatewayStableID: String + } + + private struct PersistedMetadata: Codable, Equatable { + var recentMessageIDs: [String] + var promptRoutes: [PromptRoute] + } + private let defaults: UserDefaults - private var queuedCommands: [QueuedCommand] = [] - private var recentCommandIds: [String] = [] - private var seenCommandIds = Set() + private var queuedMessages: [QueuedMessage] = [] + private var recentMessageIDs: [String] = [] + private var seenMessageIDs = Set() + private var promptRoutes: [PromptRoute] = [] init(defaults: UserDefaults = .standard) { self.defaults = defaults + self.restoreMetadata() self.restoreQueue() } func ingest( _ event: WatchAppCommandEvent, - isChatAvailable: Bool, + isAvailable: Bool, gatewayStableID: String?) -> Decision { - let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines) + let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines) let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if commandId.isEmpty || text.isEmpty { + if messageID.isEmpty || text.isEmpty { return .dropMissingFields } - if self.seenCommandIds.contains(commandId) { - return .deduped(commandId: commandId) + let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !owner.isEmpty else { return .dropMissingTarget } + if self.seenMessageIDs.contains(messageID) { + return .deduped(messageID: messageID) } - self.rememberRecentCommandId(commandId) - if !isChatAvailable { - let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !owner.isEmpty else { return .dropMissingTarget } - self.queuedCommands.append( - QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner))) - self.rebuildSeenCommandIds() - self.persistQueue() - return .queue(commandId: commandId) - } - return .forward + // Persist before network delivery; iOS may suspend a background callback at any await. + self.queuedMessages.append( + QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner))) + self.rebuildSeenMessageIDs() + self.persistQueue() + return isAvailable ? .forward : .queue(messageID: messageID) } - func nextQueuedCommand(isChatAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? { - let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard isChatAvailable, !owner.isEmpty else { return nil } - return self.queuedCommands.first { $0.gatewayStableID == owner }?.event + func recordPromptRoute(promptID: String?, gatewayStableID: String?) { + let promptID = promptID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { return } + self.promptRoutes.removeAll { $0.promptID == promptID } + self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID)) + if self.promptRoutes.count > Self.maxPromptRoutes { + self.promptRoutes.removeFirst(self.promptRoutes.count - Self.maxPromptRoutes) + } + self.persistMetadata() } - func removeQueuedCommand(commandId: String, gatewayStableID: String?) { - let commandId = commandId.trimmingCharacters(in: .whitespacesAndNewlines) + func gatewayStableID(forPromptID promptID: String) -> String? { + let promptID = promptID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !promptID.isEmpty, promptID != "unknown" else { return nil } + return self.promptRoutes.last { $0.promptID == promptID }?.gatewayStableID + } + + func nextQueuedMessage(isAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? { let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !commandId.isEmpty, !owner.isEmpty else { return } - guard let index = self.queuedCommands.firstIndex(where: { - $0.gatewayStableID == owner && $0.event.commandId == commandId + guard isAvailable, !owner.isEmpty else { return nil } + // Replies are time-sensitive; a retrying chat must not strand them behind it. + if let reply = self.queuedMessages.first(where: { + $0.gatewayStableID == owner && self.kind(of: $0.event) == .quickReply + }) { + return reply.event + } + return self.queuedMessages.first { $0.gatewayStableID == owner }?.event + } + + func removeQueuedMessage(messageID: String, gatewayStableID: String?) { + let messageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines) + let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !messageID.isEmpty, !owner.isEmpty else { return } + guard let index = self.queuedMessages.firstIndex(where: { + $0.gatewayStableID == owner && $0.event.commandId == messageID }) else { return } - self.queuedCommands.remove(at: index) - self.rememberRecentCommandId(commandId) + self.queuedMessages.remove(at: index) + self.rememberRecentMessageID(messageID) self.persistQueue() } func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) { - let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines) + let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines) let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !owner.isEmpty else { return } - if !commandId.isEmpty { - self.rememberRecentCommandId(commandId) - self.queuedCommands.removeAll { $0.event.commandId == commandId } - } - self.queuedCommands.insert( - QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)), + guard !messageID.isEmpty, !owner.isEmpty else { return } + self.queuedMessages.removeAll { $0.event.commandId == messageID } + self.queuedMessages.insert( + QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)), at: 0) - self.rebuildSeenCommandIds() + self.rebuildSeenMessageIDs() self.persistQueue() } - var queuedCount: Int { - self.queuedCommands.count + func queuedCount(kind: WatchMessageKind? = nil) -> Int { + guard let kind else { return self.queuedMessages.count } + return self.queuedMessages.count(where: { self.kind(of: $0.event) == kind }) } - var queuedCommandIds: [String] { - self.queuedCommands.map(\.event.commandId) + func queuedMessageIDs(kind: WatchMessageKind? = nil) -> [String] { + self.queuedMessages.compactMap { queued in + guard kind == nil || self.kind(of: queued.event) == kind else { return nil } + return queued.event.commandId + } } private func restoreQueue() { guard let data = defaults.data(forKey: Self.persistedQueueKey), - let persisted = try? JSONDecoder().decode([QueuedCommand].self, from: data) + let persisted = try? JSONDecoder().decode([QueuedMessage].self, from: data) else { return } - var seen: [String] = [] var seenSet = Set() - self.queuedCommands = persisted.compactMap { queued in + self.queuedMessages = persisted.compactMap { queued in let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) - let commandId = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines) + let messageID = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines) let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !owner.isEmpty, !commandId.isEmpty, !text.isEmpty, seenSet.insert(commandId).inserted else { + guard !owner.isEmpty, !messageID.isEmpty, !text.isEmpty, seenSet.insert(messageID).inserted else { return nil } - seen.append(commandId) - return QueuedCommand(gatewayStableID: owner, event: self.command(queued.event, taggedFor: owner)) + return QueuedMessage(gatewayStableID: owner, event: self.message(queued.event, taggedFor: owner)) } - self.recentCommandIds = Array(seen.suffix(Self.maxRecentCommandIds)) - self.rebuildSeenCommandIds() - if self.queuedCommands.count != persisted.count { + self.rebuildSeenMessageIDs() + if self.queuedMessages.count != persisted.count { self.persistQueue() } } - private func rememberRecentCommandId(_ commandId: String) { - guard !commandId.isEmpty else { return } - self.recentCommandIds.removeAll { $0 == commandId } - self.recentCommandIds.append(commandId) - if self.recentCommandIds.count > Self.maxRecentCommandIds { - self.recentCommandIds.removeFirst(self.recentCommandIds.count - Self.maxRecentCommandIds) + private func rememberRecentMessageID(_ messageID: String) { + guard !messageID.isEmpty else { return } + self.recentMessageIDs.removeAll { $0 == messageID } + self.recentMessageIDs.append(messageID) + if self.recentMessageIDs.count > Self.maxRecentMessageIDs { + self.recentMessageIDs.removeFirst(self.recentMessageIDs.count - Self.maxRecentMessageIDs) } - self.rebuildSeenCommandIds() + self.rebuildSeenMessageIDs() + self.persistMetadata() } - private func rebuildSeenCommandIds() { - var ids = Set(self.recentCommandIds) - ids.formUnion(self.queuedCommands.map(\.event.commandId)) - self.seenCommandIds = ids + private func restoreMetadata() { + guard let data = self.defaults.data(forKey: Self.persistedMetadataKey), + let metadata = try? JSONDecoder().decode(PersistedMetadata.self, from: data) + else { return } + + for rawMessageID in metadata.recentMessageIDs { + let messageID = rawMessageID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !messageID.isEmpty else { continue } + self.recentMessageIDs.removeAll { $0 == messageID } + self.recentMessageIDs.append(messageID) + } + self.recentMessageIDs = Array(self.recentMessageIDs.suffix(Self.maxRecentMessageIDs)) + + for rawRoute in metadata.promptRoutes { + let promptID = rawRoute.promptID.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayStableID = rawRoute.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { continue } + self.promptRoutes.removeAll { $0.promptID == promptID } + self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID)) + } + self.promptRoutes = Array(self.promptRoutes.suffix(Self.maxPromptRoutes)) + self.rebuildSeenMessageIDs() + } + + private func rebuildSeenMessageIDs() { + var ids = Set(self.recentMessageIDs) + ids.formUnion(self.queuedMessages.map(\.event.commandId)) + self.seenMessageIDs = ids } private func persistQueue() { - if self.queuedCommands.isEmpty { + if self.queuedMessages.isEmpty { self.defaults.removeObject(forKey: Self.persistedQueueKey) return } - guard let data = try? JSONEncoder().encode(queuedCommands) else { return } + guard let data = try? JSONEncoder().encode(self.queuedMessages) else { return } self.defaults.set(data, forKey: Self.persistedQueueKey) } - private func command(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent { + private func persistMetadata() { + let metadata = PersistedMetadata( + recentMessageIDs: self.recentMessageIDs, + promptRoutes: self.promptRoutes) + guard let data = try? JSONEncoder().encode(metadata) else { return } + self.defaults.set(data, forKey: Self.persistedMetadataKey) + } + + private func message(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent { var tagged = event tagged.gatewayStableID = gatewayStableID return tagged } + private func kind(of event: WatchAppCommandEvent) -> WatchMessageKind { + event.messageKind ?? .chat + } + static func resetPersistedQueue(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: self.persistedQueueKey) + defaults.removeObject(forKey: self.persistedMetadataKey) } } diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 964deb2f5297..41c7974323d3 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -11,6 +11,7 @@ private struct PendingWatchPromptAction { var actionId: String var actionLabel: String? var sessionKey: String? + var gatewayStableID: String? } private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt @@ -63,7 +64,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc promptId: action.promptId, actionId: action.actionId, actionLabel: action.actionLabel, - sessionKey: action.sessionKey) + sessionKey: action.sessionKey, + gatewayStableID: action.gatewayStableID) } } } @@ -276,6 +278,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String + let gatewayStableID = userInfo[WatchPromptNotificationBridge.gatewayStableIDKey] as? String switch response.actionIdentifier { case WatchPromptNotificationBridge.actionPrimaryIdentifier: @@ -287,7 +290,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc promptId: promptId, actionId: actionId, actionLabel: actionLabel, - sessionKey: sessionKey) + sessionKey: sessionKey, + gatewayStableID: gatewayStableID) case WatchPromptNotificationBridge.actionSecondaryIdentifier: let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -297,7 +301,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc promptId: promptId, actionId: actionId, actionLabel: actionLabel, - sessionKey: sessionKey) + sessionKey: sessionKey, + gatewayStableID: gatewayStableID) default: break } @@ -322,7 +327,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc promptId: promptId, actionId: actionId, actionLabel: actionLabel, - sessionKey: sessionKey) + sessionKey: sessionKey, + gatewayStableID: gatewayStableID) } private static func parseExecApprovalPrompt( @@ -342,7 +348,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc promptId: action.promptId, actionId: action.actionId, actionLabel: action.actionLabel, - sessionKey: action.sessionKey) + sessionKey: action.sessionKey, + gatewayStableID: action.gatewayStableID) _ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action") } @@ -407,6 +414,7 @@ enum WatchPromptNotificationBridge { static let typeValue = "watch.prompt" static let promptIDKey = "openclaw.watch.promptId" static let sessionKeyKey = "openclaw.watch.sessionKey" + static let gatewayStableIDKey = "openclaw.watch.gatewayStableID" static let actionPrimaryIDKey = "openclaw.watch.action.primary.id" static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label" static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id" @@ -422,6 +430,7 @@ enum WatchPromptNotificationBridge { static func scheduleMirroredWatchPromptNotificationIfNeeded( invokeID: String, params: OpenClawWatchNotifyParams, + gatewayStableID: String?, sendResult: WatchNotificationSendResult) async { guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return } @@ -461,6 +470,11 @@ enum WatchPromptNotificationBridge { if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { userInfo[self.sessionKeyKey] = sessionKey } + if let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines), + !gatewayStableID.isEmpty + { + userInfo[self.gatewayStableIDKey] = gatewayStableID + } for (index, action) in displayedActions.enumerated() { userInfo[self.actionIDKey(index: index)] = action.id userInfo[self.actionLabelKey(index: index)] = action.label @@ -594,13 +608,15 @@ extension NodeAppModel { promptId: String?, actionId: String, actionLabel: String?, - sessionKey: String?) async + sessionKey: String?, + gatewayStableID: String?) async { let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedActionID.isEmpty else { return } let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedGatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) let event = WatchQuickReplyEvent( @@ -609,6 +625,7 @@ extension NodeAppModel { actionId: normalizedActionID, actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil, sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil, + gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil, note: "source=ios.notification", sentAtMs: Int(Date().timeIntervalSince1970 * 1000), transport: "ios.notification") diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index abb7673a4b3d..98aad9c4e5dc 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -76,17 +76,23 @@ struct WatchMessagingStatus: Equatable { var activationState: String } -struct WatchQuickReplyEvent: Equatable { +struct WatchQuickReplyEvent: Codable, Equatable { var replyId: String var promptId: String var actionId: String var actionLabel: String? var sessionKey: String? + var gatewayStableID: String? var note: String? var sentAtMs: Int? var transport: String } +enum WatchMessageKind: String, Codable, Equatable { + case chat + case quickReply +} + struct WatchExecApprovalResolveEvent: Equatable { var replyId: String var approvalId: String @@ -115,6 +121,7 @@ struct WatchAppCommandEvent: Codable, Equatable { var text: String? var sentAtMs: Int? var transport: String + var messageKind: WatchMessageKind? = nil } struct WatchNotificationSendResult: Equatable { @@ -134,7 +141,8 @@ protocol WatchMessagingServicing: AnyObject, Sendable { func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) func sendNotification( id: String, - params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult + params: OpenClawWatchNotifyParams, + gatewayStableID: String?) async throws -> WatchNotificationSendResult func sendExecApprovalPrompt( _ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult func sendExecApprovalResolved( diff --git a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift index 3e176d6dd4a9..a31299eb9af6 100644 --- a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift +++ b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift @@ -15,7 +15,8 @@ enum WatchMessagingPayloadCodec { static func encodeNotificationPayload( id: String, - params: OpenClawWatchNotifyParams) -> [String: Any] + params: OpenClawWatchNotifyParams, + gatewayStableID: String?) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.notify.rawValue, @@ -31,6 +32,9 @@ enum WatchMessagingPayloadCodec { if let sessionKey = nonEmpty(params.sessionKey) { payload["sessionKey"] = sessionKey } + if let gatewayStableID = nonEmpty(gatewayStableID) { + payload["gatewayStableID"] = gatewayStableID + } if let kind = nonEmpty(params.kind) { payload["kind"] = kind } @@ -232,6 +236,7 @@ enum WatchMessagingPayloadCodec { let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString let actionLabel = self.nonEmpty(payload["actionLabel"] as? String) let sessionKey = self.nonEmpty(payload["sessionKey"] as? String) + let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String) let note = self.nonEmpty(payload["note"] as? String) let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue @@ -241,6 +246,7 @@ enum WatchMessagingPayloadCodec { actionId: actionId, actionLabel: actionLabel, sessionKey: sessionKey, + gatewayStableID: gatewayStableID, note: note, sentAtMs: sentAtMs, transport: transport) diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift index f41a4fac43ba..f8beafb6b557 100644 --- a/apps/ios/Sources/Services/WatchMessagingService.swift +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -117,9 +117,13 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing { func sendNotification( id: String, - params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult + params: OpenClawWatchNotifyParams, + gatewayStableID: String?) async throws -> WatchNotificationSendResult { - let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params) + let payload = WatchMessagingPayloadCodec.encodeNotificationPayload( + id: id, + params: params, + gatewayStableID: gatewayStableID) return try await self.transport.sendPayload(payload) } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 03107c035ca3..b7806f6ba176 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -100,7 +100,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer queuedForDelivery: false, transport: "sendMessage") var sendError: Error? - var lastSent: (id: String, params: OpenClawWatchNotifyParams)? + var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)? var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage? var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage? var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage? @@ -144,8 +144,12 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer self.appCommandHandler = handler } - func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { - self.lastSent = (id: id, params: params) + func sendNotification( + id: String, + params: OpenClawWatchNotifyParams, + gatewayStableID: String?) async throws -> WatchNotificationSendResult + { + self.lastSent = (id: id, params: params, gatewayStableID: gatewayStableID) if let sendError { throw sendError } @@ -1072,7 +1076,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc defaults.removePersistentDomain(forName: suiteName) } - let coordinator = WatchChatCoordinator(defaults: defaults) + let coordinator = WatchMessageOutbox(defaults: defaults) let first = WatchAppCommandEvent( commandId: "watch-send-chat-gateway-a-1", command: .sendChat, @@ -1090,32 +1094,32 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc sentAtMs: 132, transport: "sendMessage") - if case .queue = coordinator.ingest(first, isChatAvailable: false, gatewayStableID: "gateway-a") { + if case .queue = coordinator.ingest(first, isAvailable: false, gatewayStableID: "gateway-a") { } else { Issue.record("expected first gateway A command to queue") } - if case .queue = coordinator.ingest(second, isChatAvailable: false, gatewayStableID: "gateway-a") { + if case .queue = coordinator.ingest(second, isAvailable: false, gatewayStableID: "gateway-a") { } else { Issue.record("expected second gateway A command to queue") } - #expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil) - coordinator.removeQueuedCommand( - commandId: "watch-send-chat-gateway-a-1", + #expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil) + coordinator.removeQueuedMessage( + messageID: "watch-send-chat-gateway-a-1", gatewayStableID: "gateway-b") #expect( - coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId == + coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-gateway-a-1") #expect( - coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId == + coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-gateway-a-1") - coordinator.removeQueuedCommand( - commandId: "watch-send-chat-gateway-a-1", + coordinator.removeQueuedMessage( + messageID: "watch-send-chat-gateway-a-1", gatewayStableID: "gateway-a") #expect( - coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId == + coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-gateway-a-2") } @@ -1127,7 +1131,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc defaults.removePersistentDomain(forName: suiteName) } - let coordinator = WatchChatCoordinator(defaults: defaults) + let coordinator = WatchMessageOutbox(defaults: defaults) let event = WatchAppCommandEvent( commandId: "watch-send-chat-retry-gateway-a", command: .sendChat, @@ -1139,12 +1143,50 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc coordinator.requeueFront(event, gatewayStableID: event.gatewayStableID) - #expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil) + #expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil) #expect( - coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId == + coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-retry-gateway-a") } + @Test @MainActor func watchMessageOutboxPrioritizesRepliesOverQueuedChat() throws { + let suiteName = "watch-message-priority-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let outbox = WatchMessageOutbox(defaults: defaults) + let chat = WatchAppCommandEvent( + commandId: "queued-chat", + command: .sendChat, + sessionKey: "main", + gatewayStableID: "gateway-a", + text: "Chat first", + sentAtMs: 1, + transport: "transferUserInfo") + let reply = WatchAppCommandEvent( + commandId: "queued-reply", + command: .sendChat, + sessionKey: nil, + gatewayStableID: "gateway-a", + text: "Reply second", + sentAtMs: 2, + transport: "transferUserInfo", + messageKind: .quickReply) + + _ = outbox.ingest(chat, isAvailable: false, gatewayStableID: "gateway-a") + _ = outbox.ingest(reply, isAvailable: false, gatewayStableID: "gateway-a") + + #expect(outbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") == reply) + } + + @Test func watchMessageOutboxDiscardsPermanentGatewayFailures() { + #expect(NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "INVALID_REQUEST")) + #expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage( + code: "INVALID_REQUEST", + message: "Session changed while starting work. Retry.")) + #expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "UNAVAILABLE")) + } + @Test @MainActor func watchChatRestoreBackfillsGatewayOwnerIntoLegacyQueuedEvent() throws { let suiteName = "watch-chat-restore-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) @@ -1153,26 +1195,26 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc defaults.removePersistentDomain(forName: suiteName) } let legacyQueueJSON = """ - [ - { - "gatewayStableID": "gateway-a", - "event": { - "commandId": "watch-send-chat-legacy", - "command": "send-chat", - "sessionKey": "main", - "text": "Legacy queued text", - "sentAtMs": 134, - "transport": "transferUserInfo" - } - } - ] - """ + [ + { + "gatewayStableID": "gateway-a", + "event": { + "commandId": "watch-send-chat-legacy", + "command": "send-chat", + "sessionKey": "main", + "text": "Legacy queued text", + "sentAtMs": 134, + "transport": "transferUserInfo" + } + } + ] + """ defaults.set( Data(legacyQueueJSON.utf8), forKey: "watch.chat.command.queue.v1") - let coordinator = WatchChatCoordinator(defaults: defaults) - let restored = coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a") + let coordinator = WatchMessageOutbox(defaults: defaults) + let restored = coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") #expect(restored?.commandId == "watch-send-chat-legacy") #expect(restored?.gatewayStableID == "gateway-a") @@ -1186,7 +1228,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc defaults.removePersistentDomain(forName: suiteName) } - let coordinator = WatchChatCoordinator(defaults: defaults) + let coordinator = WatchMessageOutbox(defaults: defaults) for index in 0..<140 { let event = WatchAppCommandEvent( commandId: "watch-forward-\(index)", @@ -1198,9 +1240,12 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc transport: "sendMessage") if case .forward = coordinator.ingest( event, - isChatAvailable: true, + isAvailable: true, gatewayStableID: "gateway-a") { + coordinator.removeQueuedMessage( + messageID: event.commandId, + gatewayStableID: "gateway-a") } else { Issue.record("expected forwarded command \(index)") } @@ -1216,7 +1261,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc transport: "sendMessage") if case .forward = coordinator.ingest( oldestEvent, - isChatAvailable: true, + isAvailable: true, gatewayStableID: "gateway-a") { } else { @@ -1233,7 +1278,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc transport: "sendMessage") if case .deduped = coordinator.ingest( recentEvent, - isChatAvailable: true, + isAvailable: true, gatewayStableID: "gateway-a") { } else { @@ -1249,7 +1294,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc defaults.removePersistentDomain(forName: suiteName) } - let coordinator = WatchChatCoordinator(defaults: defaults) + let coordinator = WatchMessageOutbox(defaults: defaults) for index in 0..<140 { let event = WatchAppCommandEvent( commandId: "watch-queued-\(index)", @@ -1261,7 +1306,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc transport: "transferUserInfo") if case .queue = coordinator.ingest( event, - isChatAvailable: false, + isAvailable: false, gatewayStableID: "gateway-a") { } else { @@ -1269,8 +1314,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } } - coordinator.removeQueuedCommand( - commandId: "watch-queued-0", + coordinator.removeQueuedMessage( + messageID: "watch-queued-0", gatewayStableID: "gateway-a") let duplicateDeliveredEvent = WatchAppCommandEvent( @@ -1283,7 +1328,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc transport: "transferUserInfo") if case .deduped = coordinator.ingest( duplicateDeliveredEvent, - isChatAvailable: true, + isAvailable: true, gatewayStableID: "gateway-a") { } else { @@ -1792,6 +1837,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc queuedForDelivery: true, transport: "transferUserInfo") let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("gateway-watch-notify") let params = OpenClawWatchNotifyParams( title: "OpenClaw", body: "Meeting with Peter is at 4pm", @@ -1808,6 +1854,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSent?.params.title == "OpenClaw") #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") #expect(watchService.lastSent?.params.priority == .timeSensitive) + #expect(watchService.lastSent?.gatewayStableID == "gateway-watch-notify") let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) @@ -1928,8 +1975,11 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } @Test @MainActor func watchReplyQueuesWhenGatewayOffline() async { + NodeAppModel._test_resetPersistedWatchReplyQueueState() + defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("gateway-watch-reply") watchService.emitReply( WatchQuickReplyEvent( replyId: "reply-offline-1", @@ -1937,6 +1987,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc actionId: "approve", actionLabel: "Approve", sessionKey: "ios", + gatewayStableID: "gateway-watch-reply", note: nil, sentAtMs: 1234, transport: "transferUserInfo")) @@ -1944,6 +1995,162 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_queuedWatchReplyCount() == 1) } + @Test @MainActor func watchMessageOutboxRestoresQueuedReplyAfterRestart() throws { + let suiteName = "watch-reply-queue-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let event = WatchAppCommandEvent( + commandId: "reply-restore-1", + command: .sendChat, + sessionKey: "ios", + gatewayStableID: "gateway-a", + text: "Watch reply: Approve", + sentAtMs: 1235, + transport: "transferUserInfo", + messageKind: .quickReply) + let firstOutbox = WatchMessageOutbox(defaults: defaults) + if case .queue = firstOutbox.ingest(event, isAvailable: false, gatewayStableID: "gateway-a") { + } else { + Issue.record("expected watch reply to queue") + } + + let secondOutbox = WatchMessageOutbox(defaults: defaults) + #expect(secondOutbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil) + let restored = secondOutbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") + + #expect(restored == event) + #expect(secondOutbox.queuedCount(kind: .quickReply) == 1) + secondOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a") + #expect(secondOutbox.queuedCount() == 0) + } + + @Test @MainActor func watchMessageOutboxRestoresDeliveryTombstonesAndPromptRoutes() throws { + let suiteName = "watch-message-metadata-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let event = WatchAppCommandEvent( + commandId: "delivered-reply", + command: .sendChat, + sessionKey: "main", + gatewayStableID: "gateway-a", + text: "Delivered reply", + sentAtMs: 1, + transport: "sendMessage", + messageKind: .quickReply) + let firstOutbox = WatchMessageOutbox(defaults: defaults) + firstOutbox.recordPromptRoute(promptID: "prompt-a", gatewayStableID: "gateway-a") + _ = firstOutbox.ingest(event, isAvailable: true, gatewayStableID: "gateway-a") + firstOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a") + for index in 0..<140 { + let pending = WatchAppCommandEvent( + commandId: "pending-\(index)", + command: .sendChat, + sessionKey: "main", + gatewayStableID: "gateway-a", + text: "Pending \(index)", + sentAtMs: index + 2, + transport: "transferUserInfo") + _ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a") + } + + let restoredOutbox = WatchMessageOutbox(defaults: defaults) + #expect(restoredOutbox.gatewayStableID(forPromptID: "prompt-a") == "gateway-a") + if case .deduped = restoredOutbox.ingest( + event, + isAvailable: true, + gatewayStableID: "gateway-a") + { + } else { + Issue.record("expected delivered reply to remain deduped after restart") + } + } + + @Test @MainActor func watchReplyDropsStaleGatewayTarget() async { + NodeAppModel._test_resetPersistedWatchReplyQueueState() + defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("gateway-current") + + watchService.emitReply( + WatchQuickReplyEvent( + replyId: "reply-stale-gateway", + promptId: "prompt-stale", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "ios", + gatewayStableID: "gateway-old", + note: nil, + sentAtMs: 1236, + transport: "transferUserInfo")) + await Task.yield() + + #expect(appModel._test_queuedWatchReplyCount() == 0) + #expect(appModel.openChatRequestID == 0) + } + + @Test @MainActor func watchReplyUsesIdempotentChatOutbox() async { + NodeAppModel._test_resetPersistedWatchReplyQueueState() + defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel.enterAppleReviewDemoMode() + appModel._test_recordWatchPromptRoute( + promptID: "prompt-idempotent", + gatewayStableID: AppleReviewDemoMode.gatewayID) + let initialOpenChatRequestID = appModel.openChatRequestID + let event = WatchQuickReplyEvent( + replyId: "reply-idempotent", + promptId: "prompt-idempotent", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "main", + gatewayStableID: nil, + note: nil, + sentAtMs: 1237, + transport: "sendMessage") + + watchService.emitReply(event) + await Task.yield() + watchService.emitReply(event) + await Task.yield() + + #expect(appModel.openChatRequestID == initialOpenChatRequestID + 1) + #expect(appModel._test_queuedWatchReplyCount() == 0) + } + + @Test @MainActor func watchReplyBindsLegacyPromptToCurrentGateway() async { + NodeAppModel._test_resetPersistedWatchReplyQueueState() + defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel.enterAppleReviewDemoMode() + let initialOpenChatRequestID = appModel.openChatRequestID + let event = WatchQuickReplyEvent( + replyId: "reply-legacy-prompt", + promptId: "prompt-from-previous-release", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "main", + gatewayStableID: nil, + note: nil, + sentAtMs: 1238, + transport: "sendMessage") + + watchService.emitReply(event) + await Task.yield() + watchService.emitReply(event) + await Task.yield() + + #expect(appModel.openChatRequestID == initialOpenChatRequestID + 1) + #expect(appModel._test_queuedWatchReplyCount() == 0) + } + @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async throws { let appModel = NodeAppModel() let url = try #require(URL(string: "openclaw://agent?message=hello")) @@ -1962,6 +2169,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) + appModel._test_setAgentRequestHandler { _ in } let url = makeAgentDeepLinkURL(message: "hello from deep link") await appModel.handleDeepLink(url: url) @@ -2017,6 +2225,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) + appModel._test_setAgentRequestHandler { _ in } let key = NodeAppModel._test_currentDeepLinkKey() let url = makeAgentDeepLinkURL(message: "trusted request", key: key) diff --git a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift index 7baa6e46b7e3..2b6c11aa1f45 100644 --- a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift @@ -7,6 +7,7 @@ struct WatchReplyDraft { var actionId: String var actionLabel: String? var sessionKey: String? + var gatewayStableID: String? var note: String? var sentAtMs: Int } @@ -115,6 +116,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { { payload["sessionKey"] = sessionKey } + if let gatewayStableID = draft.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines), + !gatewayStableID.isEmpty + { + payload["gatewayStableID"] = gatewayStableID + } if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { payload["note"] = note } @@ -250,6 +256,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { .trimmingCharacters(in: .whitespacesAndNewlines) let sessionKey = (payload["sessionKey"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayStableID = (payload["gatewayStableID"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) let kind = (payload["kind"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) let details = (payload["details"] as? String)? @@ -266,6 +274,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { sentAtMs: sentAtMs, promptId: promptId, sessionKey: sessionKey, + gatewayStableID: gatewayStableID, kind: kind, details: details, expiresAtMs: expiresAtMs, diff --git a/apps/ios/WatchApp/Sources/WatchInboxStore.swift b/apps/ios/WatchApp/Sources/WatchInboxStore.swift index 27e1697ca3af..d40432d66f8a 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxStore.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxStore.swift @@ -154,6 +154,7 @@ struct WatchNotifyMessage { var sentAtMs: Int? var promptId: String? var sessionKey: String? + var gatewayStableID: String? var kind: String? var details: String? var expiresAtMs: Int? @@ -184,6 +185,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var lastDeliveryKey: String? var promptId: String? var sessionKey: String? + var gatewayStableID: String? var kind: String? var details: String? var expiresAtMs: Int? @@ -213,6 +215,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var updatedAt: Date? var promptId: String? var sessionKey: String? + var gatewayStableID: String? var kind: String? var details: String? var expiresAtMs: Int? @@ -361,6 +364,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.updatedAt = Date() self.promptId = message.promptId self.sessionKey = message.sessionKey + self.gatewayStableID = message.gatewayStableID self.kind = message.kind self.details = message.details self.expiresAtMs = message.expiresAtMs @@ -676,6 +680,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.lastDeliveryKey = state.lastDeliveryKey self.promptId = state.promptId self.sessionKey = state.sessionKey + self.gatewayStableID = state.gatewayStableID self.kind = state.kind self.details = state.details self.expiresAtMs = state.expiresAtMs @@ -704,6 +709,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { lastDeliveryKey: lastDeliveryKey, promptId: promptId, sessionKey: sessionKey, + gatewayStableID: gatewayStableID, kind: kind, details: details, expiresAtMs: expiresAtMs, @@ -761,6 +767,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { actionId: action.id, actionLabel: action.label, sessionKey: self.sessionKey, + gatewayStableID: self.gatewayStableID, note: nil, sentAtMs: Self.nowMs()) }