From 079ac9390d842bd49e0b59cd75fa9707b4e1a893 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 03:23:31 -0400 Subject: [PATCH] refactor(ios): simplify node and Watch lifecycle ownership (#114366) * refactor(ios): centralize node lifecycle owners * fix(ios): sync native localization source inventory * test(ios): prove gateway and notification lifecycles in simulator * fix(ios): preserve voice upgrade and correlate watch snapshots --- .github/workflows/ci.yml | 44 ++ apps/.i18n/native-source.json | 28 +- apps/ios/Sources/Model/NodeAppModel.swift | 594 +++++------------- .../Sources/Model/WatchReplyCoordinator.swift | 156 +++++ .../Services/NotificationService.swift | 65 ++ apps/ios/Tests/NodeAppModelInvokeTests.swift | 108 ++++ .../NotificationServingPreferenceTests.swift | 48 ++ 7 files changed, 586 insertions(+), 457 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3645d61abc3b..65efee3a2dde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3233,6 +3233,50 @@ jobs: - name: Build iOS app run: pnpm ios:build + # App compilation and screenshots do not execute approval or notification + # lifecycles. Exercise both owners on the already provisioned simulator. + - name: Run focused iOS lifecycle simulator tests + id: ios_lifecycle_tests + if: env.HISTORICAL_TARGET != 'true' + run: | + set -euo pipefail + simulator_id="$( + xcrun simctl list devices available --json | node --input-type=module -e ' + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const runtimes = JSON.parse(Buffer.concat(chunks).toString("utf8")).devices; + const simulator = Object.values(runtimes) + .flat() + .find((device) => device.isAvailable && device.name.startsWith("iPhone")); + if (!simulator) { + console.error("No available iPhone simulator for iOS lifecycle tests"); + process.exit(1); + } + process.stdout.write(simulator.udid); + ' + )" + result_bundle="apps/ios/build/LifecycleTestResults/OpenClawLifecycleTests.xcresult" + mkdir -p "apps/ios/build/LifecycleTestResults" + xcodebuild \ + -project apps/ios/OpenClaw.xcodeproj \ + -scheme OpenClaw \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=${simulator_id}" \ + -resultBundlePath "$result_bundle" \ + -parallel-testing-enabled NO \ + -only-testing:OpenClawTests/NodeAppModelInvokeTests \ + -only-testing:OpenClawTests/NotificationServingPreferenceTests \ + test + + - name: Upload iOS lifecycle simulator evidence + if: ${{ always() && steps.ios_lifecycle_tests.outcome != '' && steps.ios_lifecycle_tests.outcome != 'skipped' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ios-lifecycle-tests-${{ needs.preflight.outputs.checkout_revision }} + path: apps/ios/build/LifecycleTestResults/*.xcresult + if-no-files-found: warn + retention-days: 14 + - name: Capture iOS release screenshots if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }} run: pnpm ios:screenshots diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 5ed176478295..0aad843b83be 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -25795,7 +25795,7 @@ }, { "kind": "ui-localized-call", - "line": 4307, + "line": 4233, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -25803,7 +25803,7 @@ }, { "kind": "ui-localized-call", - "line": 4308, + "line": 4234, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -25811,7 +25811,7 @@ }, { "kind": "ui-localized-call", - "line": 5023, + "line": 4949, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -25819,7 +25819,7 @@ }, { "kind": "ui-localized-call", - "line": 5024, + "line": 4950, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -25827,7 +25827,7 @@ }, { "kind": "conditional-branch", - "line": 5379, + "line": 5305, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -25835,7 +25835,7 @@ }, { "kind": "conditional-branch", - "line": 5379, + "line": 5305, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -25843,7 +25843,7 @@ }, { "kind": "conditional-branch", - "line": 6549, + "line": 6458, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "No chat messages yet", "surface": "apple", @@ -25851,7 +25851,7 @@ }, { "kind": "conditional-branch", - "line": 6748, + "line": 6520, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -25859,7 +25859,7 @@ }, { "kind": "conditional-branch", - "line": 6748, + "line": 6520, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -25867,7 +25867,7 @@ }, { "kind": "conditional-branch", - "line": 9157, + "line": 8867, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval", "surface": "apple", @@ -25875,7 +25875,7 @@ }, { "kind": "conditional-branch", - "line": 9157, + "line": 8867, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "This approval was already", "surface": "apple", @@ -25883,7 +25883,7 @@ }, { "kind": "conditional-branch", - "line": 9163, + "line": 8873, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "This approval was already set to Always Allow.", "surface": "apple", @@ -25891,7 +25891,7 @@ }, { "kind": "conditional-branch", - "line": 9164, + "line": 8874, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval set to Always Allow.", "surface": "apple", @@ -25899,7 +25899,7 @@ }, { "kind": "conditional-branch", - "line": 10501, + "line": 10209, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "\\(urlText.prefix(500))…", "surface": "apple", diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 37ff44120243..529ebc9274b6 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1,5 +1,4 @@ import CoreLocation -import CryptoKit import Observation import OpenClawChatUI import OpenClawKit @@ -14,11 +13,6 @@ private let clientDatabaseLogger = Logger( subsystem: "ai.openclawfoundation.app", category: "ClientDatabases") -/// Wrap errors without pulling non-Sendable types into async notification paths. -private struct NotificationCallError: Error { - let message: String -} - private struct GatewayRelayIdentityResponse: Decodable { let deviceId: String let publicKey: String @@ -30,27 +24,6 @@ private struct WatchChatPreview { var statusText: String? } -private struct WatchChatMetadataEnvelope: Decodable { - struct Metadata: Decodable { - var id: String? - } - - var metadata: Metadata? - var messageToolMirror: [String: String]? - - enum CodingKeys: String, CodingKey { - case metadata = "__openclaw" - case messageToolMirror = "openclawMessageToolMirror" - } -} - -private struct WatchChatMessageEntry { - var message: OpenClawChatMessage - var text: String - var serverId: String? - var isMessageToolMirror: Bool -} - private struct ExecApprovalGatewayEventPayload: Decodable { var id: String } @@ -60,33 +33,6 @@ private struct NodeEventRequestPayload: Encodable { var payloadJSON: String } -/// Ensures notification requests return promptly even if the system prompt blocks. -private final class NotificationInvokeLatch: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation, Never>? - private var resumed = false - - func setContinuation(_ continuation: CheckedContinuation, Never>) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func resume(_ response: Result) { - let cont: CheckedContinuation, Never>? - self.lock.lock() - if self.resumed { - self.lock.unlock() - return - } - self.resumed = true - cont = self.continuation - self.continuation = nil - self.lock.unlock() - cont?.resume(returning: response) - } -} - private enum IOSDeepLinkAgentPolicy { static let maxMessageChars = 20000 static let maxUnkeyedConfirmChars = 240 @@ -97,11 +43,15 @@ private enum TalkCapturePreparationOwner { case chatDictation(epoch: UInt64) } +private enum GatewayConnectionWaitOwner { + case node + case `operator` +} + @MainActor @Observable // 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 { @@ -1436,9 +1386,16 @@ final class NodeAppModel { } self.talkMode.setEnabled(enabled) if enabled { + // Preserve a known missing scope before an offline config reload can + // overwrite it; the operator reconnect owns the approval handshake. + self.requestTalkPermissionUpgradeIfNeeded() Task { [weak self] in guard let self else { return } - await self.talkMode.reloadConfig() + guard !self.forceOperatorTalkPermissionUpgradeRequest else { return } + await self.talkMode.reloadConfig(shouldApply: { [weak self] in + guard let self else { return false } + return self.talkMode.isEnabled && !self.forceOperatorTalkPermissionUpgradeRequest + }) self.requestTalkPermissionUpgradeIfNeeded() } } @@ -2692,7 +2649,7 @@ final class NodeAppModel { error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications")) } - let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let addResult = await NotificationOperationRunner.run(timeoutSeconds: 2.0) { [notificationCenter] in let content = UNMutableNotificationContent() content.title = title content.body = body @@ -2749,7 +2706,7 @@ final class NodeAppModel { let messageId = UUID().uuidString if notificationsAllowed { - let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let addResult = await NotificationOperationRunner.run(timeoutSeconds: 2.0) { [notificationCenter] in let content = UNMutableNotificationContent() content.title = "OpenClaw" content.body = text @@ -2782,7 +2739,7 @@ final class NodeAppModel { } private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { - let result = await runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in + let result = await NotificationOperationRunner.run(timeoutSeconds: 1.5) { [notificationCenter] in await notificationCenter.authorizationStatus() } switch result { @@ -2836,37 +2793,6 @@ final class NodeAppModel { UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey) } - private func runNotificationCall( - timeoutSeconds: Double, - operation: @escaping @Sendable () async throws -> T) async -> Result - { - let latch = NotificationInvokeLatch() - var opTask: Task? - var timeoutTask: Task? - defer { - opTask?.cancel() - timeoutTask?.cancel() - } - let clamped = max(0.0, timeoutSeconds) - return await withCheckedContinuation { (cont: CheckedContinuation, Never>) in - latch.setContinuation(cont) - opTask = Task { @MainActor in - do { - let value = try await operation() - latch.resume(.success(value)) - } catch { - latch.resume(.failure(NotificationCallError(message: error.localizedDescription))) - } - } - timeoutTask = Task.detached { - if clamped > 0 { - try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) - } - latch.resume(.failure(NotificationCallError(message: "notification request timed out"))) - } - } - } - private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { switch req.command { case OpenClawDeviceCommand.status.rawValue: @@ -5426,17 +5352,21 @@ extension NodeAppModel { } extension NodeAppModel { - func enterAppleReviewDemoMode() { + /// Both local transports must retire the same real gateway owners before a + /// fixture publishes connected state; stale loops must never outlive the switch. + private func prepareLocalGatewayFixture(screenshot: Bool) { self.invalidateGatewayConnectAttempts() - self.isAppleReviewDemoModeEnabled = true - self.isScreenshotFixtureModeEnabled = false + self.isAppleReviewDemoModeEnabled = !screenshot + self.isScreenshotFixtureModeEnabled = screenshot self.gatewayAutoReconnectEnabled = false self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil self.nodeGatewayProblem = nil self.operatorGatewayProblem = nil - self.credentialHandoffFailureGeneration = nil + if !screenshot { + self.credentialHandoffFailureGeneration = nil + } self.nodeGatewayTask?.cancel() self.nodeGatewayTask = nil self.operatorGatewayTask?.cancel() @@ -5444,7 +5374,8 @@ extension NodeAppModel { self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil self.gatewayHealthMonitor.stop() - LiveActivityManager.shared.endActivity(reason: "apple_review_demo") + LiveActivityManager.shared.endActivity( + reason: screenshot ? "screenshot_fixture" : "apple_review_demo") Task { await self.operatorGateway.disconnect() @@ -5453,6 +5384,20 @@ extension NodeAppModel { self.gatewayStatusText = "Connected" self.nodeStatusText = "Connected" + } + + private func configureLocalGatewayFixtureSession(agents: [AgentSummary]) { + self.mainSessionBaseKey = "main" + self.gatewaySessionScope = "per-sender" + self.selectedAgentId = nil + self.gatewayDefaultAgentId = "main" + self.gatewayAgents = agents + self.focusedChatSessionKey = nil + self.synchronizeTalkSessionKey() + } + + func enterAppleReviewDemoMode() { + self.prepareLocalGatewayFixture(screenshot: false) self.gatewayServerName = AppleReviewDemoMode.gatewayName self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress self.connectedGatewayID = AppleReviewDemoMode.gatewayID @@ -5464,42 +5409,12 @@ extension NodeAppModel { self.talkMode.updateGatewayConnected(false) self.talkMode.setEnabled(false) self.talkMode.statusText = "Demo mode only" - self.mainSessionBaseKey = "main" - self.gatewaySessionScope = "per-sender" - self.selectedAgentId = nil - self.gatewayDefaultAgentId = "main" - self.gatewayAgents = AppleReviewDemoMode.agents - self.focusedChatSessionKey = nil - self.synchronizeTalkSessionKey() + self.configureLocalGatewayFixtureSession(agents: AppleReviewDemoMode.agents) self.homeCanvasRevision &+= 1 } func enterScreenshotFixtureMode() { - self.invalidateGatewayConnectAttempts() - self.isAppleReviewDemoModeEnabled = false - self.isScreenshotFixtureModeEnabled = true - self.gatewayAutoReconnectEnabled = false - self.gatewayPairingPaused = false - self.gatewayPairingRequestId = nil - self.lastGatewayProblem = nil - self.nodeGatewayProblem = nil - self.operatorGatewayProblem = nil - self.nodeGatewayTask?.cancel() - self.nodeGatewayTask = nil - self.operatorGatewayTask?.cancel() - self.operatorGatewayTask = nil - self.voiceWakeSyncTask?.cancel() - self.voiceWakeSyncTask = nil - self.gatewayHealthMonitor.stop() - LiveActivityManager.shared.endActivity(reason: "screenshot_fixture") - - Task { - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - } - - self.gatewayStatusText = "Connected" - self.nodeStatusText = "Connected" + self.prepareLocalGatewayFixture(screenshot: true) self.gatewayServerName = ScreenshotFixtureMode.gatewayName self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress self.connectedGatewayID = ScreenshotFixtureMode.gatewayID @@ -5507,13 +5422,7 @@ extension NodeAppModel { self.gatewayConnected = true self.setOperatorConnected(true) self.hasOperatorAdminScope = true - self.mainSessionBaseKey = "main" - self.gatewaySessionScope = "per-sender" - self.selectedAgentId = nil - self.gatewayDefaultAgentId = "main" - self.gatewayAgents = ScreenshotFixtureMode.agents - self.focusedChatSessionKey = nil - self.synchronizeTalkSessionKey() + self.configureLocalGatewayFixtureSession(agents: ScreenshotFixtureMode.agents) self.talkMode.enterScreenshotFixtureMode() self.homeCanvasRevision &+= 1 } @@ -6540,7 +6449,7 @@ extension NodeAppModel { .requestHistory(sessionKey: self.chatSessionKey) } - let items = Self.makeWatchChatItems(from: payload.messages ?? []) + let items = WatchChatPresentation.makeItems(from: payload.messages ?? []) return WatchChatPreview( items: items, status: items.isEmpty @@ -6556,143 +6465,6 @@ extension NodeAppModel { } } - private nonisolated static func watchChatReplyText( - from raw: [OpenClawKit.AnyCodable], - runId: String, - submittedText: String, - submittedAtMs: Int64) -> String? - { - let entries = raw.compactMap(self.decodeWatchChatMessage) - if let directReply = entries.last(where: { - self.isTerminalWatchAssistant($0) && $0.message.idempotencyKey == runId - }) { - return directReply.text - } - - let userIdempotencyKey = "\(runId):user" - let exactUserIndex = entries.lastIndex(where: { - $0.message.role.lowercased() == "user" && - $0.message.idempotencyKey == userIdempotencyKey - }) - let queuedUserIndex = entries.lastIndex(where: { entry in - guard entry.message.role.lowercased() == "user", - let timestampMs = self.watchTimestampMs(entry.message.timestamp), - timestampMs >= submittedAtMs - else { - return false - } - return entry.text.contains(submittedText) - }) - guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil } - return entries[(userIndex + 1)...].first(where: { - self.isTerminalWatchAssistant($0) - })?.text - } - - private nonisolated static func isTerminalWatchAssistant(_ entry: WatchChatMessageEntry) -> Bool { - guard entry.message.role.lowercased() == "assistant" else { return false } - if entry.isMessageToolMirror { - return true - } - guard let stopReason = entry.message.stopReason?.lowercased() else { return false } - // Tool-use rows can contain visible progress text, but a later assistant row owns the final reply. - return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls" - } - - private nonisolated static func decodeWatchChatMessage( - _ raw: OpenClawKit.AnyCodable) -> WatchChatMessageEntry? - { - guard let data = try? JSONEncoder().encode(raw), - let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data), - let text = nonEmptyWatchChatText(watchChatText(from: message)) - else { - return nil - } - let metadata = try? JSONDecoder().decode(WatchChatMetadataEnvelope.self, from: data) - return WatchChatMessageEntry( - message: message, - text: text, - serverId: metadata?.metadata?.id, - isMessageToolMirror: metadata?.messageToolMirror != nil) - } - - private nonisolated static func makeWatchChatItems( - from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] - { - let readableMessages = raw.compactMap(self.decodeWatchChatMessage) - var idOccurrences: [String: Int] = [:] - let identified = readableMessages.map { entry -> (WatchChatMessageEntry, String) in - let baseId = entry.serverId.map { "\(entry.message.role)-\($0)" } - ?? self.watchChatFallbackKey(entry) - idOccurrences[baseId, default: 0] += 1 - let stableId = "\(baseId)-\(idOccurrences[baseId]!)" - return (entry, stableId) - } - return identified.suffix(self.watchChatPreviewItemLimit).map { entry, stableId in - let timestampMs = self.watchTimestampMs(entry.message.timestamp) - return OpenClawWatchChatItem( - id: stableId, - role: entry.message.role, - text: self.truncatedWatchChatText(entry.text), - timestampMs: timestampMs) - } - } - - private nonisolated static func watchChatFallbackKey(_ entry: WatchChatMessageEntry) -> String { - let timestamp = self.watchTimestampMs(entry.message.timestamp).map(String.init) ?? "missing" - let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)" - let digest = SHA256.hash(data: Data(source.utf8)).map { String(format: "%02x", $0) }.joined() - return "\(entry.message.role)-\(digest)" - } - - private nonisolated static func watchChatText(from message: OpenClawChatMessage) -> String { - let parts = message.content.compactMap { content -> String? in - let kind = (content.type ?? "text").lowercased() - guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil } - if let text = self.nonEmptyWatchChatText(content.text) { - return text - } - if let text = self.nonEmptyWatchChatText(content.content?.value as? String) { - return text - } - if let dict = content.content?.value as? [String: OpenClawKit.AnyCodable], - let text = self.nonEmptyWatchChatText(dict["text"]?.value as? String) - { - return text - } - return nil - } - let contentText = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - if !contentText.isEmpty { - return contentText - } - return message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - - private nonisolated static func nonEmptyWatchChatText(_ text: String?) -> String? { - let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - private nonisolated static func truncatedWatchChatText(_ text: String) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count > 240 else { return trimmed } - return "\(trimmed.prefix(237))..." - } - - private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int64? { - guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil } - let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000 - let maxReasonableEpochMs: Double = 32_503_680_000_000 - guard milliseconds.isFinite, - milliseconds >= 0, - milliseconds <= maxReasonableEpochMs - else { - return nil - } - return Int64(milliseconds) - } - private func makeWatchAppSnapshot( chatPreview: WatchChatPreview? = nil) -> OpenClawWatchAppSnapshotMessage { @@ -7044,9 +6816,9 @@ extension NodeAppModel { return .sent } let history = try await appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey) - if let replyText = Self.watchChatReplyText( + if let replyText = WatchChatPresentation.replyText( from: history.messages ?? [], - runId: response.runId, + runID: response.runId, submittedText: text, submittedAtMs: submittedAtMs) { @@ -7156,9 +6928,9 @@ extension NodeAppModel { if let payload = try? await transport.requestHistory( sessionKey: sessionKey, ifCurrentRoute: expectedRoute), - let replyText = Self.watchChatReplyText( + let replyText = WatchChatPresentation.replyText( from: payload.messages ?? [], - runId: runId, + runID: runId, submittedText: submittedText, submittedAtMs: submittedAtMs) { @@ -7291,33 +7063,29 @@ extension NodeAppModel { let visiblePromptWasResolving = self.pendingExecApprovalPromptResolving let surfaceGenerationAtStart = self.pendingExecApprovalPromptSurfaceGeneration - let cachedPass = await self.readBackCachedWatchExecApprovalPrompts( - prompts, - gatewayStableID: gatewayStableID, - reason: reason, - syncSnapshots: syncSnapshots) - let persistedPass = await self.readBackPersistedWatchExecApprovalReadbacks( - persistedReadbacks, - gatewayStableID: gatewayStableID, - syncSnapshots: syncSnapshots) var classifiedApprovalIDs = cachedApprovalIDs classifiedApprovalIDs.formUnion(persistedReadbacks.compactMap { Self.execApprovalIDKey($0.approvalId) }) - let heldPass = await self.readBackHeldWatchExecApprovals( - heldApprovalsByID, - alreadyClassifiedApprovalIDs: classifiedApprovalIDs, + var candidates: [WatchApprovalReadbackCandidate] = + prompts.map { .cached($0) } + persistedReadbacks.map { .persisted($0) } + for heldApproval in heldApprovalsByID.values.sorted(by: { + Self.approvalIDSortsBefore($0.approvalId, $1.approvalId) + }) { + guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId), + classifiedApprovalIDs.insert(approvalID).inserted + else { continue } + candidates.append(.held(heldApproval)) + } + // Cached cards, durable migration rows, then Watch-held actions must retain + // their original order so presentation and write fences stay deterministic. + let readback = await self.readBackWatchExecApprovals( + candidates, gatewayStableID: gatewayStableID, reason: reason, syncSnapshots: syncSnapshots) - // Concatenation order mirrors the original readback order: cached prompts, - // persisted readbacks, then held Watch approvals. - var loadedPrompts = cachedPass.loadedPrompts + persistedPass.loadedPrompts + heldPass.loadedPrompts - let allReadbacksWereAuthoritative = cachedPass.allReadbacksWereAuthoritative && - persistedPass.allReadbacksWereAuthoritative && - heldPass.allReadbacksWereAuthoritative - - guard allReadbacksWereAuthoritative else { return false } + guard readback.isAuthoritative else { return false } + var loadedPrompts = readback.loadedPrompts // Readbacks can interleave with terminal events while awaiting other owners. // Re-check the live owner table instead of replaying the stale local array. @@ -7378,7 +7146,7 @@ extension NodeAppModel { syncSnapshots: syncSnapshots) } - guard let selectedPhonePrompt else { return allReadbacksWereAuthoritative } + guard let selectedPhonePrompt else { return true } let selectedPromptWasResolving = visiblePromptWasResolving && visiblePromptAtStart.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true && visiblePromptNow.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true && @@ -7391,143 +7159,85 @@ extension NodeAppModel { self.pendingExecApprovalPromptErrorText = "The previous decision was not recorded. Review and try again." } - return allReadbacksWereAuthoritative + return true } - private struct WatchExecApprovalReadbackPass { + private func readBackWatchExecApprovals( + _ candidates: [WatchApprovalReadbackCandidate], + gatewayStableID: String, + reason: String, + syncSnapshots: Bool) async -> (loadedPrompts: [ExecApprovalPrompt], isAuthoritative: Bool) + { var loadedPrompts: [ExecApprovalPrompt] = [] - var allReadbacksWereAuthoritative = true - } - - private func readBackCachedWatchExecApprovalPrompts( - _ prompts: [ExecApprovalPrompt], - gatewayStableID: String, - reason: String, - syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass - { - var pass = WatchExecApprovalReadbackPass() - for cachedPrompt in prompts { - let persistedReadback = PersistedExecApprovalReadback( - approvalId: cachedPrompt.id, - gatewayStableID: cachedPrompt.gatewayStableID) - let readback = await self.fetchExecApprovalPrompt( - approvalId: cachedPrompt.id, - sourceReason: reason) - switch readback { - case let .loaded(prompt): - self.upsertWatchExecApprovalPrompt(prompt) - self.removePendingPersistedExecApprovalReadback(persistedReadback) - pass.loadedPrompts.append(prompt) - case let .terminal(terminal): - self.removePendingPersistedExecApprovalReadback(persistedReadback) - let outcome = await self.applyCanonicalExecApprovalTerminal( - terminal, - appliedHere: false, - gatewayStableID: gatewayStableID, - syncSnapshots: syncSnapshots) - if case .failed = outcome { - pass.allReadbacksWereAuthoritative = false - } - case .stale: - self.removePendingPersistedExecApprovalReadback(persistedReadback) - self.markPendingExecApprovalTerminal( - approvalId: cachedPrompt.id, - outcome: ExecApprovalOutcome( - text: "This approval is no longer available.", - tone: .warning)) - await self.publishWatchExecApprovalExpired( - approvalId: cachedPrompt.id, - gatewayStableID: gatewayStableID, - reason: .notFound, - syncSnapshots: syncSnapshots) - case .failed: - pass.allReadbacksWereAuthoritative = false + var isAuthoritative = true + for candidate in candidates { + let approvalID: String + let persistedReadback: PersistedExecApprovalReadback? + let fetchReason: String + let markVisiblePromptStale: Bool + switch candidate { + case let .cached(prompt): + approvalID = prompt.id + persistedReadback = PersistedExecApprovalReadback( + approvalId: prompt.id, + gatewayStableID: prompt.gatewayStableID) + fetchReason = reason + markVisiblePromptStale = true + case let .persisted(readback): + approvalID = readback.approvalId + persistedReadback = readback + fetchReason = "persisted_upgrade" + markVisiblePromptStale = false + case let .held(approval): + approvalID = approval.approvalId + persistedReadback = nil + fetchReason = reason + markVisiblePromptStale = false } - } - return pass - } - private func readBackPersistedWatchExecApprovalReadbacks( - _ persistedReadbacks: [PersistedExecApprovalReadback], - gatewayStableID: String, - syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass - { - var pass = WatchExecApprovalReadbackPass() - for persistedReadback in persistedReadbacks { - let readback = await self.fetchExecApprovalPrompt( - approvalId: persistedReadback.approvalId, - sourceReason: "persisted_upgrade") - switch readback { - case let .loaded(prompt): - self.upsertWatchExecApprovalPrompt(prompt) - self.removePendingPersistedExecApprovalReadback(persistedReadback) - pass.loadedPrompts.append(prompt) - case let .terminal(terminal): - self.removePendingPersistedExecApprovalReadback(persistedReadback) - let outcome = await self.applyCanonicalExecApprovalTerminal( - terminal, - appliedHere: false, - gatewayStableID: gatewayStableID, - syncSnapshots: syncSnapshots) - if case .failed = outcome { - pass.allReadbacksWereAuthoritative = false - } - case .stale: - self.removePendingPersistedExecApprovalReadback(persistedReadback) - await self.publishWatchExecApprovalExpired( - approvalId: persistedReadback.approvalId, - gatewayStableID: gatewayStableID, - reason: .notFound, - syncSnapshots: syncSnapshots) - case .failed: - pass.allReadbacksWereAuthoritative = false - } - } - return pass - } - - private func readBackHeldWatchExecApprovals( - _ heldApprovalsByID: [ExecApprovalIdentifier.Key: WatchExecApprovalSnapshotRequestItem], - alreadyClassifiedApprovalIDs: Set, - gatewayStableID: String, - reason: String, - syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass - { - var pass = WatchExecApprovalReadbackPass() - var classifiedApprovalIDs = alreadyClassifiedApprovalIDs - for heldApproval in heldApprovalsByID.values.sorted(by: { - Self.approvalIDSortsBefore($0.approvalId, $1.approvalId) - }) { - guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId), - classifiedApprovalIDs.insert(approvalID).inserted - else { continue } switch await self.fetchExecApprovalPrompt( - approvalId: heldApproval.approvalId, - sourceReason: reason) + approvalId: approvalID, + sourceReason: fetchReason) { case let .loaded(prompt): self.upsertWatchExecApprovalPrompt(prompt) - pass.loadedPrompts.append(prompt) + if let persistedReadback { + self.removePendingPersistedExecApprovalReadback(persistedReadback) + } + loadedPrompts.append(prompt) case let .terminal(terminal): + if let persistedReadback { + self.removePendingPersistedExecApprovalReadback(persistedReadback) + } let outcome = await self.applyCanonicalExecApprovalTerminal( terminal, appliedHere: false, gatewayStableID: gatewayStableID, syncSnapshots: syncSnapshots) if case .failed = outcome { - pass.allReadbacksWereAuthoritative = false + isAuthoritative = false } case .stale: + if let persistedReadback { + self.removePendingPersistedExecApprovalReadback(persistedReadback) + } + if markVisiblePromptStale { + self.markPendingExecApprovalTerminal( + approvalId: approvalID, + outcome: ExecApprovalOutcome( + text: "This approval is no longer available.", + tone: .warning)) + } await self.publishWatchExecApprovalExpired( - approvalId: heldApproval.approvalId, + approvalId: approvalID, gatewayStableID: gatewayStableID, reason: .notFound, syncSnapshots: syncSnapshots) case .failed: - pass.allReadbacksWereAuthoritative = false + isAuthoritative = false } } - return pass + return (loadedPrompts, isAuthoritative) } private nonisolated static func watchExecApprovalIDsNeedingFetch( @@ -10093,44 +9803,32 @@ extension NodeAppModel { var durationMs: Int } - private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool { + private func waitForGatewayConnection( + timeoutMs: Int, + pollMs: Int, + owner: GatewayConnectionWaitOwner = .node) async -> Bool + { let clampedTimeoutMs = max(0, timeoutMs) let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000 let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0) + let isConnected: @MainActor () -> Bool = { + switch owner { + case .node: + self.gatewayConnected + case .operator: + self.operatorConnected + } + } while Date() < deadline { - if Task.isCancelled { - return false - } - if await isGatewayConnected() { - return true - } + if Task.isCancelled { return false } + if isConnected() { return true } do { try await Task.sleep(nanoseconds: pollIntervalNs) } catch { return false } } - return await isGatewayConnected() - } - - private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool { - let clampedTimeoutMs = max(0, timeoutMs) - let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000 - let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0) - while Date() < deadline { - if Task.isCancelled { - return false - } - if await self.isOperatorConnected() { - return true - } - do { - try await Task.sleep(nanoseconds: pollIntervalNs) - } catch { - return false - } - } - return await self.isOperatorConnected() + return isConnected() } private func ensureOperatorReconnectLoopIfNeeded() { @@ -10217,7 +9915,11 @@ extension NodeAppModel { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_wait " + "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)") - if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) { + if await self.waitForGatewayConnection( + timeoutMs: initialWaitMs, + pollMs: 200, + owner: .operator) + { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_connected " + "reason=\(reconnectReason) phase=initial") @@ -10247,7 +9949,10 @@ extension NodeAppModel { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_wait " + "reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)") - let connected = await waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200) + let connected = await self.waitForGatewayConnection( + timeoutMs: remainingWaitMs, + pollMs: 200, + owner: .operator) GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") " + "reason=\(reconnectReason) phase=restart") @@ -10259,7 +9964,10 @@ extension NodeAppModel { return true } self.ensureOperatorReconnectLoopIfNeeded() - return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250) + return await self.waitForGatewayConnection( + timeoutMs: timeoutMs, + pollMs: 250, + owner: .operator) } private func performBackgroundAliveBeaconIfNeeded( @@ -10864,7 +10572,7 @@ extension NodeAppModel { } nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] { - self.makeWatchChatItems(from: raw) + WatchChatPresentation.makeItems(from: raw) } nonisolated static func _test_watchChatReplyText( @@ -10873,9 +10581,9 @@ extension NodeAppModel { submittedText: String, submittedAtMs: Int64) -> String? { - self.watchChatReplyText( + WatchChatPresentation.replyText( from: raw, - runId: runId, + runID: runId, submittedText: submittedText, submittedAtMs: submittedAtMs) } diff --git a/apps/ios/Sources/Model/WatchReplyCoordinator.swift b/apps/ios/Sources/Model/WatchReplyCoordinator.swift index 65ebb6da1c95..55801929b930 100644 --- a/apps/ios/Sources/Model/WatchReplyCoordinator.swift +++ b/apps/ios/Sources/Model/WatchReplyCoordinator.swift @@ -1,4 +1,160 @@ +import CryptoKit import Foundation +import OpenClawChatUI +import OpenClawKit + +/// Three recovery sources represent the same gateway-owned approval readback. +/// Preserve their source so cached cards, migration rows, and held Watch actions +/// share one classifier without losing source-specific cleanup. +enum WatchApprovalReadbackCandidate { + case cached(Prompt) + case persisted(PersistedReadback) + case held(WatchExecApprovalSnapshotRequestItem) +} + +/// Owns the one canonical interpretation of projected chat rows shared by Watch +/// previews and run-correlated voice replies. +enum WatchChatPresentation { + private static let previewItemLimit = 5 + + private struct MetadataEnvelope: Decodable { + struct Metadata: Decodable { + var id: String? + } + + var metadata: Metadata? + var messageToolMirror: [String: String]? + + enum CodingKeys: String, CodingKey { + case metadata = "__openclaw" + case messageToolMirror = "openclawMessageToolMirror" + } + } + + private struct MessageEntry { + var message: OpenClawChatMessage + var text: String + var serverID: String? + var isMessageToolMirror: Bool + } + + nonisolated static func replyText( + from rawMessages: [OpenClawKit.AnyCodable], + runID: String, + submittedText: String, + submittedAtMs: Int64) -> String? + { + let entries = rawMessages.compactMap(Self.decodeMessage) + if let directReply = entries.last(where: { + Self.isTerminalAssistant($0) && $0.message.idempotencyKey == runID + }) { + return directReply.text + } + + let userIdempotencyKey = "\(runID):user" + let exactUserIndex = entries.lastIndex(where: { + $0.message.role.lowercased() == "user" && + $0.message.idempotencyKey == userIdempotencyKey + }) + let queuedUserIndex = entries.lastIndex(where: { entry in + guard entry.message.role.lowercased() == "user", + let timestampMs = Self.timestampMs(entry.message.timestamp), + timestampMs >= submittedAtMs + else { return false } + return entry.text.contains(submittedText) + }) + guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil } + return entries[(userIndex + 1)...].first(where: Self.isTerminalAssistant)?.text + } + + nonisolated static func makeItems( + from rawMessages: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] + { + var occurrences: [String: Int] = [:] + let identified = rawMessages.compactMap(Self.decodeMessage).map { entry in + let baseID = entry.serverID.map { "\(entry.message.role)-\($0)" } + ?? Self.fallbackKey(entry) + occurrences[baseID, default: 0] += 1 + return (entry, "\(baseID)-\(occurrences[baseID]!)") + } + return identified.suffix(Self.previewItemLimit).map { entry, stableID in + OpenClawWatchChatItem( + id: stableID, + role: entry.message.role, + text: Self.truncatedText(entry.text), + timestampMs: Self.timestampMs(entry.message.timestamp)) + } + } + + private nonisolated static func isTerminalAssistant(_ entry: MessageEntry) -> Bool { + guard entry.message.role.lowercased() == "assistant" else { return false } + if entry.isMessageToolMirror { return true } + guard let stopReason = entry.message.stopReason?.lowercased() else { return false } + // Progress rows are not final replies; the later assistant row owns completion. + return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls" + } + + private nonisolated static func decodeMessage(_ raw: OpenClawKit.AnyCodable) -> MessageEntry? { + guard let data = try? JSONEncoder().encode(raw), + let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data), + let text = nonEmptyText(messageText(message)) + else { return nil } + let metadata = try? JSONDecoder().decode(MetadataEnvelope.self, from: data) + return MessageEntry( + message: message, + text: text, + serverID: metadata?.metadata?.id, + isMessageToolMirror: metadata?.messageToolMirror != nil) + } + + private nonisolated static func fallbackKey(_ entry: MessageEntry) -> String { + let timestamp = Self.timestampMs(entry.message.timestamp).map(String.init) ?? "missing" + let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)" + let digest = SHA256.hash(data: Data(source.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return "\(entry.message.role)-\(digest)" + } + + private nonisolated static func messageText(_ message: OpenClawChatMessage) -> String { + let parts = message.content.compactMap { content -> String? in + let kind = (content.type ?? "text").lowercased() + guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil } + if let text = Self.nonEmptyText(content.text) { return text } + if let text = Self.nonEmptyText(content.content?.value as? String) { return text } + if let values = content.content?.value as? [String: OpenClawKit.AnyCodable] { + return Self.nonEmptyText(values["text"]?.value as? String) + } + return nil + } + let contentText = parts.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return contentText.isEmpty + ? message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + : contentText + } + + private nonisolated static func nonEmptyText(_ text: String?) -> String? { + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private nonisolated static func truncatedText(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 240 else { return trimmed } + return "\(trimmed.prefix(237))..." + } + + private nonisolated static func timestampMs(_ timestamp: Double?) -> Int64? { + guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil } + let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000 + guard milliseconds.isFinite, + milliseconds >= 0, + milliseconds <= 32_503_680_000_000 + else { return nil } + return Int64(milliseconds) + } +} @MainActor final class WatchMessageOutbox { diff --git a/apps/ios/Sources/Services/NotificationService.swift b/apps/ios/Sources/Services/NotificationService.swift index 50413896385d..61144ce214ba 100644 --- a/apps/ios/Sources/Services/NotificationService.swift +++ b/apps/ios/Sources/Services/NotificationService.swift @@ -1,6 +1,71 @@ import Foundation import UserNotifications +/// Keeps notification failures Sendable across the system prompt and timeout tasks. +struct NotificationCallError: Error { + let message: String +} + +private final class NotificationInvokeLatch: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation, Never>? + private var resumed = false + + func setContinuation(_ continuation: CheckedContinuation, Never>) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func resume(_ result: Result) { + self.lock.lock() + guard !self.resumed else { + self.lock.unlock() + return + } + self.resumed = true + let continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: result) + } +} + +@MainActor +enum NotificationOperationRunner { + /// System permission prompts can stall indefinitely; the latch makes the timeout + /// and operation race resume exactly once without blocking the app's main actor. + static func run( + timeoutSeconds: Double, + operation: @escaping @Sendable () async throws -> Value) async -> Result + { + let latch = NotificationInvokeLatch() + var operationTask: Task? + var timeoutTask: Task? + defer { + operationTask?.cancel() + timeoutTask?.cancel() + } + let timeout = max(0, timeoutSeconds) + return await withCheckedContinuation { continuation in + latch.setContinuation(continuation) + operationTask = Task { @MainActor in + do { + try await latch.resume(.success(operation())) + } catch { + latch.resume(.failure(NotificationCallError(message: error.localizedDescription))) + } + } + timeoutTask = Task.detached { + if timeout > 0 { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + } + latch.resume(.failure(NotificationCallError(message: "notification request timed out"))) + } + } + } +} + struct NotificationSnapshot: @unchecked Sendable { let identifier: String let userInfo: [AnyHashable: Any] diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index d543e82396ef..0abb030a7354 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -88,6 +88,18 @@ private actor ApprovalResolutionCapture { } } +private actor WatchApprovalReadbackProbe { + private var approvalIDs: [String] = [] + + func record(_ approvalID: String) { + self.approvalIDs.append(approvalID) + } + + func snapshot() -> [String] { + self.approvalIDs + } +} + private actor MockHealthSummaryService: HealthSummaryServicing { private(set) var periods: [OpenClawHealthSummaryPeriod] = [] @@ -4474,6 +4486,65 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(snapshot.approvals.map(\.id) == approvalIDs.sorted()) } + @Test @MainActor func `watch readback classifies cached persisted and held approvals once in owner order`() + async throws + { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + NodeAppModel._test_setPersistedWatchExecApprovalBridgeStateJSON(#""" + { + "approvals": [], + "pendingApprovalReadbacks": [{ + "approvalId": "approval-persisted-owner", + "gatewayStableID": "test-gateway" + }] + } + """#) + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-cached-owner", + commandText: "echo cached", + expiresAtMs: 4_000_000_000_000))) + + let expectedApprovalIDs = [ + "approval-cached-owner", + "approval-persisted-owner", + "approval-held-owner", + ] + let probe = WatchApprovalReadbackProbe() + appModel._test_setUnifiedExecApprovalGetResponses( + expectedApprovalIDs.map { + (approvalID: $0, json: makePendingExecApprovalJSON($0)) + }, + beforeResponse: { approvalID in + await probe.record(approvalID) + }) + + await appModel._test_refreshWatchExecApprovalSnapshotOnDemand( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-canonical-owner-order", + gatewayStableID: "test-gateway", + heldApprovals: [WatchExecApprovalSnapshotRequestItem( + approvalId: "approval-held-owner", + activeResolutionAttemptId: nil)], + sentAtMs: 226, + transport: "sendMessage")) + + #expect(await probe.snapshot() == expectedApprovalIDs) + let snapshot = try #require(watchService.sentExecApprovalSnapshots.first { + $0.requestId == "snapshot-canonical-owner-order" + }) + #expect(snapshot.requestId == "snapshot-canonical-owner-order") + #expect(snapshot.requestGatewayStableID == "test-gateway") + #expect(snapshot.approvals.map(\.id) == expectedApprovalIDs.sorted()) + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().isEmpty) + } + @Test @MainActor func `canonical watch refresh does not acknowledge byte distinct owner`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } @@ -5583,6 +5654,43 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(items.map(\.text) == ["Still worth reading"]) } + @Test func `canonical watch chat owner keeps the last five readable projected rows`() throws { + let rawMessages = try (0..<7).map { index in + try makeWatchChatRawMessage( + role: "assistant", + text: "Readable message \(index)", + timestamp: Double(index + 1)) + } + + let items = WatchChatPresentation.makeItems(from: rawMessages) + + #expect(items.map(\.text) == (2..<7).map { "Readable message \($0)" }) + } + + @Test func `canonical watch chat owner anchors terminal tool mirrors to the submitted turn`() throws { + let rawMessages = try [ + makeWatchChatRawMessage( + role: "user", + text: "Send the update", + timestamp: 3000, + idempotencyKey: "watch-run:user"), + makeProjectedWatchChatRawMessage( + role: "assistant", + text: "Update sent", + timestamp: 4000, + serverId: "tool-result-1", + isMessageToolMirror: true), + ] + + let reply = WatchChatPresentation.replyText( + from: rawMessages, + runID: "watch-run", + submittedText: "Send the update", + submittedAtMs: 2500) + + #expect(reply == "Update sent") + } + @Test func `watch chat preview reads responses output text`() throws { let rawMessages = try [ makeWatchChatRawMessage( diff --git a/apps/ios/Tests/NotificationServingPreferenceTests.swift b/apps/ios/Tests/NotificationServingPreferenceTests.swift index abbc3323594f..1f14fc449396 100644 --- a/apps/ios/Tests/NotificationServingPreferenceTests.swift +++ b/apps/ios/Tests/NotificationServingPreferenceTests.swift @@ -2,6 +2,14 @@ import Foundation import Testing @testable import OpenClaw +private enum NotificationOperationProbeError: LocalizedError { + case expected + + var errorDescription: String? { + "expected notification failure" + } +} + struct NotificationServingPreferenceTests { @Test func `defaults to enabled`() throws { let (suiteName, defaults) = try self.makeDefaults() @@ -21,6 +29,46 @@ struct NotificationServingPreferenceTests { #expect(NotificationServingPreference.isEnabled(defaults: defaults)) } + @Test @MainActor func `notification operation returns its completed value`() async { + let result = await NotificationOperationRunner.run(timeoutSeconds: 1) { 42 } + + switch result { + case let .success(value): + #expect(value == 42) + case let .failure(error): + Issue.record("Unexpected notification failure: \(error.message)") + } + } + + @Test @MainActor func `notification operation preserves its failure`() async { + let result: Result = await NotificationOperationRunner.run( + timeoutSeconds: 1) + { + throw NotificationOperationProbeError.expected + } + + switch result { + case .success: + Issue.record("The failed notification operation unexpectedly succeeded") + case let .failure(error): + #expect(error.message == "expected notification failure") + } + } + + @Test @MainActor func `notification operation times out suspended work`() async { + let result = await NotificationOperationRunner.run(timeoutSeconds: 0.01) { + try await Task.sleep(nanoseconds: 200_000_000) + return 42 + } + + switch result { + case .success: + Issue.record("The suspended notification operation unexpectedly succeeded") + case let .failure(error): + #expect(error.message == "notification request timed out") + } + } + private func makeDefaults() throws -> (String, UserDefaults) { let suiteName = "NotificationServingPreferenceTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName))