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
This commit is contained in:
Peter Steinberger
2026-07-27 03:23:31 -04:00
committed by GitHub
parent 0cc0a100ee
commit 079ac9390d
7 changed files with 586 additions and 457 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
private var resumed = false
func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
self.lock.lock()
defer { self.lock.unlock() }
self.continuation = continuation
}
func resume(_ response: Result<T, NotificationCallError>) {
let cont: CheckedContinuation<Result<T, NotificationCallError>, 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<T: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
{
let latch = NotificationInvokeLatch<T>()
var opTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>?
defer {
opTask?.cancel()
timeoutTask?.cancel()
}
let clamped = max(0.0, timeoutSeconds)
return await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, 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<ExecApprovalPrompt, PersistedExecApprovalReadback>] =
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<ExecApprovalPrompt, PersistedExecApprovalReadback>],
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<ExecApprovalIdentifier.Key>,
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)
}

View File

@@ -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<Prompt, PersistedReadback> {
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 {

View File

@@ -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<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Result<Value, NotificationCallError>, Never>?
private var resumed = false
func setContinuation(_ continuation: CheckedContinuation<Result<Value, NotificationCallError>, Never>) {
self.lock.lock()
defer { self.lock.unlock() }
self.continuation = continuation
}
func resume(_ result: Result<Value, NotificationCallError>) {
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<Value: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> Value) async -> Result<Value, NotificationCallError>
{
let latch = NotificationInvokeLatch<Value>()
var operationTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>?
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]

View File

@@ -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(

View File

@@ -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<Int, NotificationCallError> = 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))