import Foundation import OpenClawKit import OpenClawProtocol import Testing import UIKit import UserNotifications @testable import OpenClaw @testable import OpenClawChatUI @MainActor private final class MockVoiceNoteAudioCapture: VoiceNoteAudioCapture { private(set) var cancelCallCount = 0 private(set) var permissionRequestCount = 0 func requestPermission() async -> Bool { self.permissionRequestCount += 1 return true } func start(url _: URL) throws {} func stop() -> TimeInterval { 1 } func cancel() { self.cancelCallCount += 1 } func setFailureHandler(_: @escaping @MainActor () -> Void) {} } private actor CancellingCameraService: CameraServicing { func listDevices() async -> [CameraController.CameraDeviceInfo] { [] } func snap(params: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult { throw CancellationError() } func clip(params: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult { throw CancellationError() } } private actor RecordingCameraService: CameraServicing { private var clipCalls = 0 func listDevices() async -> [CameraController.CameraDeviceInfo] { [] } func snap(params _: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult { (format: "jpg", base64: "", width: 1, height: 1) } func clip(params _: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult { self.clipCalls += 1 return (format: "mp4", base64: "", durationMs: 1, hasAudio: true) } func clipCallCount() -> Int { self.clipCalls } } private actor BlockingAudioCameraService: CameraServicing { private let barrier: TalkPreparationBarrier init(barrier: TalkPreparationBarrier) { self.barrier = barrier } func listDevices() async -> [CameraController.CameraDeviceInfo] { [] } func snap(params _: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult { (format: "jpg", base64: "", width: 1, height: 1) } func clip(params _: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult { await self.barrier.suspendFirstPreparation() try Task.checkCancellation() return (format: "mp4", base64: "", durationMs: 1, hasAudio: true) } } private actor BlockingAudioScreenRecorder: ScreenRecordingServicing { private let barrier: TalkPreparationBarrier private var recordCalls = 0 init(barrier: TalkPreparationBarrier) { self.barrier = barrier } func record( screenIndex _: Int?, durationMs _: Int?, fps _: Double?, includeAudio _: Bool?, outPath _: String?) async throws -> String { self.recordCalls += 1 await self.barrier.suspendFirstPreparation() try Task.checkCancellation() let url = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-screen-test-\(UUID().uuidString).mp4") try Data().write(to: url) return url.path } func recordCallCount() -> Int { self.recordCalls } } private actor CancellationIgnoringScreenRecorder: ScreenRecordingServicing { private let barrier: TalkPreparationBarrier private let outputURL: URL init(barrier: TalkPreparationBarrier, outputURL: URL) { self.barrier = barrier self.outputURL = outputURL } func record( screenIndex _: Int?, durationMs _: Int?, fps _: Double?, includeAudio _: Bool?, outPath _: String?) async throws -> String { await self.barrier.suspendFirstPreparation() try Data([0x01]).write(to: self.outputURL) return self.outputURL.path } } private actor OverlappingCameraService: CameraServicing { private let firstStarted: AsyncStream.Continuation private let secondStarted: AsyncStream.Continuation private var firstGate: CheckedContinuation? private var secondGate: CheckedContinuation? private var snapCount = 0 init( firstStarted: AsyncStream.Continuation, secondStarted: AsyncStream.Continuation) { self.firstStarted = firstStarted self.secondStarted = secondStarted } func listDevices() async -> [CameraController.CameraDeviceInfo] { [] } func snap(params: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult { self.snapCount += 1 if self.snapCount == 1 { self.firstStarted.yield() self.firstStarted.finish() await withCheckedContinuation { self.firstGate = $0 } throw CancellationError() } self.secondStarted.yield() self.secondStarted.finish() await withCheckedContinuation { self.secondGate = $0 } return (format: "jpg", base64: "", width: 1, height: 1) } func clip(params: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult { throw CancellationError() } func releaseFirst() { self.firstGate?.resume() self.firstGate = nil } func releaseSecond() { self.secondGate?.resume() self.secondGate = nil } } @MainActor private final class TalkPreparationBarrier { private var didEnter = false private var enteredContinuation: CheckedContinuation? private var releaseContinuation: CheckedContinuation? func suspendFirstPreparation() async { guard !self.didEnter else { return } self.didEnter = true self.enteredContinuation?.resume() self.enteredContinuation = nil await withCheckedContinuation { continuation in self.releaseContinuation = continuation } } func waitUntilEntered() async { if self.didEnter { return } await withCheckedContinuation { continuation in self.enteredContinuation = continuation } } func release() { self.releaseContinuation?.resume() self.releaseContinuation = nil } } @MainActor private func waitForTalkCondition(_ condition: @MainActor () -> Bool) async { for _ in 0..<2000 { if condition() { return } try? await Task.sleep(nanoseconds: 1_000_000) } Issue.record("Timed out waiting for Talk state") } private func talkRequest(id: String, command: OpenClawTalkCommand) -> BridgeInvokeRequest { BridgeInvokeRequest(id: id, command: command.rawValue) } private func decodeTalkPayload(_ type: T.Type, from response: BridgeInvokeResponse) throws -> T { let data = try #require(response.payloadJSON?.data(using: .utf8)) return try JSONDecoder().decode(type, from: data) } private func makeAgentDeepLinkURL( message: String, deliver: Bool = false, to: String? = nil, channel: String? = nil, key: String? = nil) -> URL { var components = URLComponents() components.scheme = "openclaw" components.host = "agent" var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] if deliver { queryItems.append(URLQueryItem(name: "deliver", value: "1")) } if let to { queryItems.append(URLQueryItem(name: "to", value: to)) } if let channel { queryItems.append(URLQueryItem(name: "channel", value: channel)) } if let key { queryItems.append(URLQueryItem(name: "key", value: key)) } components.queryItems = queryItems return components.url! } private func makeWatchChatRawMessage( role: String, text: String?, type: String = "text", timestamp: Double, idempotencyKey: String? = nil, stopReason: String? = nil) throws -> AnyCodable { let message = OpenClawChatMessage( role: role, content: [ OpenClawChatMessageContent( type: type, text: text, mimeType: nil, fileName: nil, content: nil), ], timestamp: timestamp, idempotencyKey: idempotencyKey, stopReason: stopReason ?? (role == "assistant" ? "stop" : nil)) let data = try JSONEncoder().encode(message) return try JSONDecoder().decode(AnyCodable.self, from: data) } private func makeProjectedWatchChatRawMessage( role: String, text: String, timestamp: Double, serverId: String, isMessageToolMirror: Bool = false) throws -> AnyCodable { var object: [String: Any] = [ "role": role, "content": [["type": "text", "text": text]], "timestamp": timestamp, "__openclaw": ["id": serverId], ] if isMessageToolMirror { object["openclawMessageToolMirror"] = ["toolName": "message"] } let data = try JSONSerialization.data(withJSONObject: object) return try JSONDecoder().decode(AnyCodable.self, from: data) } @MainActor private func waitForMainActorWork(_ condition: () -> Bool) async { for _ in 0..<100 { if condition() { return } await Task.yield() } } @MainActor private func mountScreen(_ screen: ScreenController) throws -> ScreenWebViewCoordinator { let coordinator = ScreenWebViewCoordinator(controller: screen) _ = coordinator.makeContainerView() _ = try #require(coordinator.managedWebView) return coordinator } @MainActor private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { var currentStatus = WatchMessagingStatus( supported: true, paired: true, appInstalled: true, reachable: true, activationState: "activated") var nextSendResult = WatchNotificationSendResult( deliveredImmediately: true, queuedForDelivery: false, transport: "sendMessage") var sendError: Error? var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)? var lastDirectNodeSetupCode: String? var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage? var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage? var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage? var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage? var sentExecApprovalSnapshots: [OpenClawWatchExecApprovalSnapshotMessage] = [] var lastSentAppSnapshot: OpenClawWatchAppSnapshotMessage? var syncExecApprovalSnapshotHandler: ((OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult)? var lastSentChatCompletion: OpenClawWatchChatCompletionMessage? private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)? private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)? private var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)? private var appSnapshotRequestHandler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)? private var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)? func status() async -> WatchMessagingStatus { self.currentStatus } func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) { self.statusHandler = handler } func emitStatus(_ status: WatchMessagingStatus) { self.currentStatus = status self.statusHandler?(status) } func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { self.replyHandler = handler } func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) { self.execApprovalResolveHandler = handler } func setExecApprovalSnapshotRequestHandler( _ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?) { self.execApprovalSnapshotRequestHandler = handler } func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) { self.appSnapshotRequestHandler = handler } func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) { self.appCommandHandler = handler } func sendNotification( id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?) async throws -> WatchNotificationSendResult { self.lastSent = (id: id, params: params, gatewayStableID: gatewayStableID) if let sendError { throw sendError } return self.nextSendResult } func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult { self.lastDirectNodeSetupCode = setupCode if let sendError { throw sendError } return self.nextSendResult } func sendExecApprovalPrompt( _ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult { self.lastSentExecApprovalPrompt = message if let sendError { throw sendError } return self.nextSendResult } func sendExecApprovalResolved( _ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult { self.lastSentExecApprovalResolved = message if let sendError { throw sendError } return self.nextSendResult } func sendExecApprovalExpired( _ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult { self.lastSentExecApprovalExpired = message if let sendError { throw sendError } return self.nextSendResult } func syncExecApprovalSnapshot( _ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult { self.lastSentExecApprovalSnapshot = message self.sentExecApprovalSnapshots.append(message) if let syncExecApprovalSnapshotHandler { return try await syncExecApprovalSnapshotHandler(message) } if let sendError { throw sendError } return self.nextSendResult } func syncAppSnapshot( _ message: OpenClawWatchAppSnapshotMessage) async throws -> WatchNotificationSendResult { self.lastSentAppSnapshot = message if let sendError { throw sendError } return self.nextSendResult } func sendChatCompletion( _ message: OpenClawWatchChatCompletionMessage) async throws -> WatchNotificationSendResult { self.lastSentChatCompletion = message if let sendError { throw sendError } return self.nextSendResult } func emitReply(_ event: WatchQuickReplyEvent) { self.replyHandler?(event) } func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) { self.execApprovalResolveHandler?(event) } func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) { self.execApprovalSnapshotRequestHandler?(event) } func emitAppSnapshotRequest(_ event: WatchAppSnapshotRequestEvent) { self.appSnapshotRequestHandler?(event) } func emitAppCommand(_ event: WatchAppCommandEvent) { self.appCommandHandler?(event) } } private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable { var status: NotificationAuthorizationStatus = .notDetermined var authorizationStatusHandler: (@Sendable () async -> NotificationAuthorizationStatus)? var addCalls = 0 var pendingRemovedIdentifiers: [[String]] = [] var deliveredRemovedIdentifiers: [[String]] = [] var delivered: [NotificationSnapshot] = [] func authorizationStatus() async -> NotificationAuthorizationStatus { if let authorizationStatusHandler { return await authorizationStatusHandler() } return self.status } func add(_: UNNotificationRequest) async throws { self.addCalls += 1 } func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async { self.pendingRemovedIdentifiers.append(identifiers) } func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async { self.deliveredRemovedIdentifiers.append(identifiers) } func deliveredNotifications() async -> [NotificationSnapshot] { self.delivered } } private actor NotificationAuthorizationGate { private var didStart = false private var continuation: CheckedContinuation? func wait() async -> NotificationAuthorizationStatus { self.didStart = true return await withCheckedContinuation { continuation in self.continuation = continuation } } func hasStarted() -> Bool { self.didStart } func resume(returning status: NotificationAuthorizationStatus) { self.continuation?.resume(returning: status) self.continuation = nil } } private actor WatchSnapshotSendGate { private var didStart = false private var continuation: CheckedContinuation? func wait() async { self.didStart = true await withCheckedContinuation { continuation in self.continuation = continuation } } func hasStarted() -> Bool { self.didStart } func resume() { self.continuation?.resume() self.continuation = nil } } private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Void { let defaults = UserDefaults.standard let previous = defaults.object(forKey: NotificationServingPreference.storageKey) defaults.set(enabled, forKey: NotificationServingPreference.storageKey) return { if let previous { defaults.set(previous, forKey: NotificationServingPreference.storageKey) } else { defaults.removeObject(forKey: NotificationServingPreference.storageKey) } } } @Suite(.serialized) struct NodeAppModelInvokeTests { @Test @MainActor func `decode params fails without JSON`() { #expect(throws: Error.self) { _ = try NodeAppModel._test_decodeParams(OpenClawCanvasNavigateParams.self, from: nil) } } @Test @MainActor func `encode payload emits JSON`() throws { struct Payload: Codable, Equatable { var value: String } let json = try NodeAppModel._test_encodePayload(Payload(value: "ok")) #expect(json.contains("\"value\"")) } @Test @MainActor func `chat session key defaults to main base`() { let appModel = NodeAppModel() #expect(appModel.chatSessionKey == "main") #expect(appModel.chatDeliveryAgentId == nil) } @Test @MainActor func `chat delivery owner requires persisted or gateway ownership`() { let appModel = NodeAppModel() #expect(appModel.chatDeliveryAgentId == nil) appModel.gatewayDefaultAgentId = " Agent-A " #expect(appModel.chatDeliveryAgentId == "agent-a") appModel.setSelectedAgentId(" Agent-B ") #expect(appModel.chatDeliveryAgentId == "agent-b") appModel.openChat(sessionKey: "agent:Agent-C:incident") #expect(appModel.chatDeliveryAgentId == "agent-c") } @Test @MainActor func `init preserves saved talk mode preference`() { withUserDefaults(["talk.enabled": true]) { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) #expect(UserDefaults.standard.bool(forKey: "talk.enabled")) #expect(appModel.talkMode.isEnabled) } } @Test @MainActor func `chat session key uses agent scoped key for non default agent`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("agent-123") #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main")) #expect(appModel.mainSessionKey == "agent:agent-123:main") } @Test @MainActor func `session key extracts canonical agent ID`() { #expect(SessionKey.agentId(from: "agent:rust-claw:mattermost:channel:w6g") == "rust-claw") #expect(SessionKey.agentId(from: " agent:main:main ") == "main") #expect(SessionKey.agentId(from: "main") == nil) #expect(SessionKey.agentId(from: "agent::main") == nil) #expect(SessionKey.agentId(from: nil) == nil) } @Test @MainActor func `chat agent name uses focused canonical session agent`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.gatewayAgents = [ AgentSummary( id: "main", name: "Joshtimus Prime", identity: nil, workspace: nil, workspacegit: nil, model: nil, agentruntime: nil), AgentSummary( id: "rust-claw", name: "Rust Claw", identity: nil, workspace: nil, workspacegit: nil, model: nil, agentruntime: nil), ] appModel.setSelectedAgentId("main") appModel.openChat(sessionKey: "agent:rust-claw:mattermost:channel:w6gjp6iz3fyp3fo15q4fwfpnno") #expect(appModel.selectedAgentId == "main") #expect(appModel.activeAgentName == "Joshtimus Prime") #expect(appModel.chatAgentId == "rust-claw") #expect(appModel.chatAgentName == "Rust Claw") } @Test @MainActor func `chat agent name falls back to selected agent for unscoped session`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.gatewayAgents = [ AgentSummary( id: "rust-claw", name: "Rust Claw", identity: nil, workspace: nil, workspacegit: nil, model: nil, agentruntime: nil), ] appModel.setSelectedAgentId("rust-claw") appModel.openChat(sessionKey: "incident-42") #expect(appModel.chatAgentId == "rust-claw") #expect(appModel.chatAgentName == "Rust Claw") } @Test @MainActor func `selecting agent clears explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" let rustSessionKey = SessionKey.makeAgentSessionKey(agentId: "rust-claw", baseKey: "main") appModel.setSelectedAgentId("rust-claw") #expect(appModel.chatSessionKey == rustSessionKey) appModel.focusChatSession(rustSessionKey) appModel.setSelectedAgentId("main") #expect(appModel.defaultChatSessionKey == "main") #expect(appModel.mainSessionKey == "main") #expect(appModel.chatSessionKey == "main") } @Test @MainActor func `same selected agent keeps explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("main") appModel.openChat(sessionKey: "incident-42") appModel.setSelectedAgentId("main") #expect(appModel.defaultChatSessionKey == "main") #expect(appModel.chatSessionKey == "incident-42") } @Test @MainActor func `default chat session key ignores explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("rust-claw") appModel.openChat(sessionKey: "incident-42") #expect(appModel.defaultChatSessionKey == SessionKey.makeAgentSessionKey( agentId: "rust-claw", baseKey: "main")) #expect(appModel.chatSessionKey == "incident-42") } @Test @MainActor func `opening nil chat session clears explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("rust-claw") appModel.openChat(sessionKey: "incident-42") appModel.openChat(sessionKey: nil) #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey( agentId: "rust-claw", baseKey: "main")) appModel.setSelectedAgentId("main") #expect(appModel.chatSessionKey == "main") } @Test @MainActor func `exec approval prompt presentation tracks latest notification tap`() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-1", commandText: "echo first", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: "main", expiresAtMs: 1))) let firstPrompt = try #require(appModel._test_pendingExecApprovalPrompt()) #expect(firstPrompt.id == "approval-1") #expect(firstPrompt.commandText == "echo first") #expect(firstPrompt.allowsAllowAlways == false) try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-2", commandText: "echo second", allowedDecisions: ["allow-once", "allow-always", "deny"], host: "gateway", nodeId: "node-2", agentId: nil, expiresAtMs: 2))) let secondPrompt = try #require(appModel._test_pendingExecApprovalPrompt()) #expect(secondPrompt.id == "approval-2") #expect(secondPrompt.commandText == "echo second") #expect(secondPrompt.allowsAllowAlways) appModel._test_dismissPendingExecApprovalPrompt() #expect(appModel._test_pendingExecApprovalPrompt() == nil) } @Test @MainActor func `gateway switch invalidates privileged approval surfaces`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let notificationCenter = MockBootstrapNotificationCenter() notificationCenter.delivered = [ NotificationSnapshot( identifier: "old-requested-approval", userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "recovery-a", "gatewayDeviceId": "device-a", ], ]), NotificationSnapshot( identifier: "new-requested-approval", userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "recovery-b", "gatewayDeviceId": "device-b", ], ]), ] let appModel = NodeAppModel( notificationCenter: notificationCenter, watchMessagingService: watchService) defer { appModel.disconnectGateway() } let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "ios", clientMode: "node", clientDisplayName: "Phone") let gatewayA = try GatewayConnectConfig( url: #require(URL(string: "wss://127.0.0.1:1")), stableID: "gateway-a", tls: nil, token: "token-a", bootstrapToken: nil, password: nil, nodeOptions: options) let gatewayB = try GatewayConnectConfig( url: #require(URL(string: "wss://127.0.0.1:2")), stableID: "gateway-b", tls: nil, token: "token-b", bootstrapToken: nil, password: nil, nodeOptions: options) appModel.applyGatewayConnectConfig(gatewayA) try appModel._test_presentExecApprovalPrompt(#require( NodeAppModel._test_makeExecApprovalPrompt( id: "shared-approval-id", gatewayStableID: gatewayA.effectiveStableID, commandText: "deploy gateway A", allowedDecisions: ["allow-once", "deny"], host: "gateway-a", nodeId: nil, agentId: "main", expiresAtMs: nil))) appModel._test_recordPendingWatchExecApprovalRecoveryID( "recovery-a", gatewayDeviceId: "device-a") appModel.applyGatewayConnectConfig(gatewayB) for _ in 0..<1000 where notificationCenter.deliveredRemovedIdentifiers.isEmpty || watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty != true { await Task.yield() } #expect(appModel._test_pendingExecApprovalPrompt() == nil) #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty) #expect(watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty == true) #expect(notificationCenter.pendingRemovedIdentifiers.contains([ "exec.approval.device-a.recovery-a", ])) #expect(notificationCenter.deliveredRemovedIdentifiers.contains([ "old-requested-approval", ])) #expect(!notificationCenter.deliveredRemovedIdentifiers .flatMap(\.self) .contains("new-requested-approval")) try appModel._test_presentExecApprovalPrompt(#require( NodeAppModel._test_makeExecApprovalPrompt( id: "shared-approval-id", gatewayStableID: gatewayB.effectiveStableID, commandText: "deploy gateway B", allowedDecisions: ["allow-once", "deny"], host: "gateway-b", nodeId: nil, agentId: "main", expiresAtMs: nil))) watchService.emitExecApprovalResolve(WatchExecApprovalResolveEvent( replyId: "stale-watch-reply", approvalId: "shared-approval-id", gatewayStableID: gatewayA.effectiveStableID, decision: .allowOnce, sentAtMs: nil, transport: "test")) await Task.yield() await Task.yield() #expect(watchService.lastSentExecApprovalResolved == nil) #expect(watchService.lastSentExecApprovalExpired == nil) #expect(watchService.lastSentExecApprovalSnapshot?.approvals.first?.gatewayStableID == gatewayB .effectiveStableID) } @Test @MainActor func `offline resolution push remains durable until its gateway reconnects`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let push = ExecApprovalNotificationPrompt( approvalId: "approval-resolved-offline", gatewayDeviceId: "gateway-device-a") let notificationCenter = MockBootstrapNotificationCenter() notificationCenter.delivered = [NotificationSnapshot( identifier: "offline-request-alert", userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": push.approvalId, "gatewayDeviceId": "gateway-device-a", ], ])] let firstModel = NodeAppModel(notificationCenter: notificationCenter) #expect(await firstModel.handleExecApprovalResolvedRemotePush(push)) #expect(firstModel._test_pendingExecApprovalResolvedPushes() == [push]) #expect(notificationCenter.pendingRemovedIdentifiers == [[ "exec.approval.gateway-device-a.approval-resolved-offline", ]]) #expect(notificationCenter.deliveredRemovedIdentifiers == [["offline-request-alert"]]) let restoredModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) #expect(restoredModel._test_pendingExecApprovalResolvedPushes() == [push]) } @Test @MainActor func `offline approval request remains durable until its gateway reconnects`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let push = ExecApprovalNotificationPrompt( approvalId: "approval-requested-offline", gatewayDeviceId: "gateway-device-a") let firstModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) #expect(await firstModel.handleExecApprovalRequestedRemotePush(push)) #expect(firstModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) let restoredModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) #expect(restoredModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) } @Test @MainActor func `offline approval notification tap retains watch recovery`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let push = ExecApprovalNotificationPrompt( approvalId: "approval-notification-offline", gatewayDeviceId: "gateway-device-a") let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) await appModel.presentExecApprovalNotificationPrompt(push) #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) } @Test @MainActor func `failed PTT start restores voice wake suspension`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) appModel.voiceWake.isEnabled = true appModel.voiceWake.isListening = true appModel.voiceWake.statusText = "Listening" let request = BridgeInvokeRequest( id: "ptt-start", command: OpenClawTalkCommand.pttStart.rawValue) let response = await appModel._test_handleInvoke(request) #expect(response.ok == false) #expect(response.error?.message.contains("Gateway not connected") == true) #expect(!appModel.voiceWake._test_isSuppressedByPushToTalk()) appModel.voiceWake.stop() } @Test @MainActor func `PTT start preserves an active voice note`() async { let capture = MockVoiceNoteAudioCapture() let recorder = OpenClawVoiceNoteRecorder(capture: capture) #expect(await recorder.start()) let appModel = NodeAppModel( talkMode: TalkModeManager(allowSimulatorCapture: true), voiceNoteRecorder: recorder) let request = BridgeInvokeRequest( id: "ptt-start-with-voice-note", command: OpenClawTalkCommand.pttStart.rawValue) let response = await appModel._test_handleInvoke(request) #expect(response.ok == false) #expect(response.error?.message.contains("active voice note") == true) #expect(recorder.isRecording) #expect(capture.cancelCallCount == 0) recorder.cancel() } @Test @MainActor func `cancelled queued PTT start never acquires preparation`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCapturePreparationHandler { await barrier.suspendFirstPreparation() } defer { appModel._test_setTalkCapturePreparationHandler(nil) appModel.voiceWake.stop() } let active = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "ptt-active", command: .pttStart)) } await barrier.waitUntilEntered() let queued = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "ptt-queued", command: .pttStart)) } await waitForTalkCondition { appModel._test_talkPreparationWaiterCount() == 1 } queued.cancel() await waitForTalkCondition { appModel._test_talkPreparationWaiterCount() == 0 } barrier.release() let activeResponse = await active.value let queuedResponse = await queued.value let activePayload = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: activeResponse) #expect(activeResponse.ok) #expect(!queuedResponse.ok) #expect(talkMode._test_activePushToTalkCaptureId() == activePayload.captureId) #expect(await appModel._test_handleInvoke(talkRequest(id: "cleanup", command: .pttCancel)).ok) } @Test @MainActor func `PTT cancel invalidates suspended preparation without waiting`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCapturePreparationHandler { await barrier.suspendFirstPreparation() } defer { appModel._test_setTalkCapturePreparationHandler(nil) appModel.voiceWake.stop() } let start = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "stale-start", command: .pttStart)) } await barrier.waitUntilEntered() let epoch = appModel._test_talkPttCommandEpoch() let cancel = await appModel._test_handleInvoke(talkRequest(id: "cancel", command: .pttCancel)) #expect(cancel.ok) #expect(appModel._test_talkPttCommandEpoch() == epoch + 1) barrier.release() #expect(await start.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `PTT start after cancel uses the new command epoch`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCapturePreparationHandler { await barrier.suspendFirstPreparation() } defer { appModel._test_setTalkCapturePreparationHandler(nil) appModel.voiceWake.stop() } let stale = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "old-epoch", command: .pttStart)) } await barrier.waitUntilEntered() #expect(await appModel._test_handleInvoke(talkRequest(id: "cancel", command: .pttCancel)).ok) let fresh = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "new-epoch", command: .pttStart)) } await waitForTalkCondition { appModel._test_talkPreparationWaiterCount() == 1 } barrier.release() #expect(await stale.value.ok == false) let freshResponse = await fresh.value let freshPayload = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: freshResponse) #expect(freshResponse.ok) #expect(talkMode._test_activePushToTalkCaptureId() == freshPayload.captureId) #expect(await appModel._test_handleInvoke(talkRequest(id: "cleanup", command: .pttCancel)).ok) } @Test @MainActor func `chat focus switch invalidates reserved and queued PTT starts`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTReservedHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTReservedHandler(nil) appModel.voiceWake.stop() } let reserved = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "focus-reserved", command: .pttStart)) } await barrier.waitUntilEntered() let queued = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "focus-queued", command: .pttStart)) } await waitForTalkCondition { appModel._test_talkPreparationWaiterCount() == 1 } let epoch = appModel._test_talkPttCommandEpoch() appModel.focusChatSession("agent:main:focused-replacement") #expect(appModel._test_talkPttCommandEpoch() == epoch + 1) #expect(talkMode.isUsingMainSessionKey("agent:main:focused-replacement")) barrier.release() #expect(await reserved.value.ok == false) #expect(await queued.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `same-session route replacement invalidates reserved and queued PTT starts`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTReservedHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTReservedHandler(nil) appModel.voiceWake.stop() } let reserved = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "route-reserved", command: .pttStart)) } await barrier.waitUntilEntered() let queued = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "route-queued", command: .pttStart)) } await waitForTalkCondition { appModel._test_talkPreparationWaiterCount() == 1 } let epoch = appModel._test_talkPttCommandEpoch() appModel._test_invalidateOperatorTalkRoute() talkMode.updateGatewayConnected(true) #expect(appModel._test_talkPttCommandEpoch() == epoch + 1) barrier.release() #expect(await reserved.value.ok == false) #expect(await queued.value.ok == false) #expect(appModel._test_talkPreparationWaiterCount() == 0) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `same-route reconnect preserves routing restore before Talk admission`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() let stableID = "talk-routing-restore-\(UUID().uuidString)" let databaseURL = try #require(NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID)) let identity = try #require(OpenClawChatSessionRoutingIdentity( scope: "per-sender", mainSessionKey: "restored-main", defaultAgentID: "main")) let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: stableID) await store.storeSessionRoutingIdentity(identity) await store.retire() appModel._test_setChatSessionRoutingRestoreHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() appModel._test_setChatSessionRoutingRestoreHandler(nil) OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL) appModel.voiceWake.stop() } appModel._test_prepareForGatewayConnect(stableID: stableID) await barrier.waitUntilEntered() appModel._test_invalidateOperatorTalkRoute() #expect(appModel._test_hasChatSessionRoutingRestoreTask()) #expect(!talkMode.isGatewayConnected) #expect(appModel.chatSessionRoutingContract == nil) barrier.release() await appModel._test_admitTalkAfterSessionHydration() #expect(talkMode.isGatewayConnected) #expect(appModel.chatSessionRoutingContract == identity.contract) #expect(talkMode.isUsingMainSessionKey(appModel.chatSessionKey)) } @Test @MainActor func `cancelled routing restore cannot apply after SQLite load`() async throws { let appModel = NodeAppModel() let barrier = TalkPreparationBarrier() let stableID = "cancelled-routing-restore-\(UUID().uuidString)" let databaseURL = try #require(NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID)) let identity = try #require(OpenClawChatSessionRoutingIdentity( scope: "per-sender", mainSessionKey: "stale-main", defaultAgentID: "main")) let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: stableID) await store.storeSessionRoutingIdentity(identity) await store.retire() appModel._test_setConnectedGatewayID(stableID) appModel._test_setChatSessionRoutingRestoreHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() appModel._test_setChatSessionRoutingRestoreHandler(nil) OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL) appModel.voiceWake.stop() } let restore = Task { @MainActor in await appModel.restoreChatSessionRoutingIdentityIfNeeded() } await barrier.waitUntilEntered() restore.cancel() barrier.release() await restore.value #expect(appModel.chatSessionRoutingContract == nil) await appModel.purgeChatTranscriptCache(gatewayID: stableID) } @Test @MainActor func `gateway main key refresh preserves focused Talk session`() { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) appModel.focusChatSession("agent:focused:thread") let epoch = appModel._test_talkPttCommandEpoch() appModel._test_applyMainSessionKey("gateway-main") #expect(appModel.chatSessionKey == "agent:focused:thread") #expect(talkMode.isUsingMainSessionKey("agent:focused:thread")) #expect(appModel._test_talkPttCommandEpoch() == epoch) } @Test @MainActor func `gateway replacement waits for final Talk session before admission`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) appModel.focusChatSession("agent:old:thread") let epoch = appModel._test_talkPttCommandEpoch() let stableID = "talk-session-replacement-\(UUID().uuidString)" defer { GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: nil) } appModel._test_prepareForGatewayConnect(stableID: stableID) #expect(appModel.chatSessionKey != "agent:old:thread") #expect(talkMode.isUsingMainSessionKey(appModel.chatSessionKey)) #expect(appModel._test_talkPttCommandEpoch() > epoch) #expect(!talkMode.isGatewayConnected) let blocked = await appModel._test_handleInvoke( talkRequest(id: "pre-hydration", command: .pttStart)) #expect(!blocked.ok) appModel._test_applyMainSessionKey("custom-main") appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("worker") await appModel._test_admitTalkAfterSessionHydration() #expect(talkMode.isGatewayConnected) #expect(appModel.chatSessionKey == "agent:worker:custom-main") #expect(talkMode.isUsingMainSessionKey(appModel.chatSessionKey)) let admitted = await appModel._test_handleInvoke( talkRequest(id: "post-hydration", command: .pttStart)) #expect(admitted.ok) _ = await appModel._test_handleInvoke( talkRequest(id: "post-hydration-cleanup", command: .pttCancel)) } @Test @MainActor func `cancelled PTT start after capture activation cleans up the capture`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCaptureStartedHandler { await barrier.suspendFirstPreparation() } defer { appModel._test_setTalkCaptureStartedHandler(nil) appModel.voiceWake.stop() } let start = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "cancel-after-start", command: .pttStart)) } await barrier.waitUntilEntered() #expect(talkMode._test_activePushToTalkCaptureId() != nil) start.cancel() barrier.release() #expect(await start.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `session switch cannot return a cancelled PTT capture id`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCaptureStartedHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() appModel._test_setTalkCaptureStartedHandler(nil) appModel.voiceWake.stop() } let start = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "session-switch-after-start", command: .pttStart)) } await barrier.waitUntilEntered() #expect(talkMode._test_activePushToTalkCaptureId() != nil) talkMode.updateMainSessionKey("agent:main:replacement-after-start") barrier.release() #expect(await start.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `session switch cannot strand a one shot PTT waiter`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTOnceStartedHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTOnceStartedHandler(nil) appModel.voiceWake.stop() } let once = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "session-switch-once", command: .pttOnce)) } await barrier.waitUntilEntered() #expect(talkMode._test_activePushToTalkCaptureId() != nil) talkMode.updateMainSessionKey("agent:main:replacement-once") barrier.release() #expect(await once.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `node route invalidation cancels active and preparing PTT`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) defer { barrier.release() appModel._test_setTalkCapturePreparationHandler(nil) appModel.voiceWake.stop() } let activeResponse = await appModel._test_handleInvoke( talkRequest(id: "node-route-active", command: .pttStart)) let active = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: activeResponse) #expect(talkMode._test_activePushToTalkCaptureId() == active.captureId) appModel._test_invalidateNodePushToTalkRoute() #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) appModel._test_setTalkCapturePreparationHandler { await barrier.suspendFirstPreparation() } let preparing = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "node-route-preparing", command: .pttStart)) } await barrier.waitUntilEntered() appModel._test_invalidateNodePushToTalkRoute() barrier.release() #expect(await preparing.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `cancelled stale interrupt cannot stop a newer PTT capture`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) defer { barrier.release() _ = talkMode.cancelPushToTalk() } let startResponse = await appModel._test_handleInvoke( talkRequest(id: "fresh-before-stale-cancel", command: .pttStart)) let active = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: startResponse) let staleCancel = Task { @MainActor in await barrier.suspendFirstPreparation() return await appModel._test_handleInvoke( talkRequest(id: "stale-route-cancel", command: .pttCancel)) } await barrier.waitUntilEntered() staleCancel.cancel() barrier.release() #expect(await staleCancel.value.ok == false) #expect(talkMode._test_activePushToTalkCaptureId() == active.captureId) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds() == [active.captureId]) } @Test @MainActor func `PTT stop during reserved preparation restores idle capture state`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTReservedHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTReservedHandler(nil) } let start = Task { @MainActor in try await talkMode.beginPushToTalk() } await barrier.waitUntilEntered() let captureId = try #require(talkMode._test_activePushToTalkCaptureId()) let stopped = talkMode.endPushToTalk(captureId: captureId) #expect(stopped.status == "idle") #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(talkMode._test_pushToTalkCaptureIsIdle()) barrier.release() var startFailed = false do { _ = try await start.value } catch { startFailed = true } #expect(startFailed) } @Test @MainActor func `session switch cancels reserved and active PTT`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTReservedHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTReservedHandler(nil) talkMode.stop() } let preparing = Task { @MainActor in try await talkMode.beginPushToTalk() } await barrier.waitUntilEntered() talkMode.updateMainSessionKey("agent:main:reserved-replacement") #expect(talkMode._test_activePushToTalkCaptureId() == nil) barrier.release() await #expect(throws: Error.self) { _ = try await preparing.value } talkMode._test_setPTTReservedHandler(nil) let active = try await talkMode.beginPushToTalk() talkMode.updateMainSessionKey("agent:main:active-replacement") #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(!talkMode.isPushToTalkActive) #expect(talkMode.cancelPushToTalk(captureId: active.captureId).status == "idle") } @Test @MainActor func `PTT stop and cancel interrupt active one-shot capture`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) defer { appModel.voiceWake.stop() } let cancelledOnce = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "once-cancel", command: .pttOnce)) } await waitForTalkCondition { talkMode._test_activePushToTalkCaptureId() != nil } let cancelledCaptureId = try #require(talkMode._test_activePushToTalkCaptureId()) let cancelResponse = await appModel._test_handleInvoke(talkRequest(id: "cancel", command: .pttCancel)) let cancelPayload = try decodeTalkPayload(OpenClawTalkPTTStopPayload.self, from: cancelResponse) let cancelledOncePayload = try await decodeTalkPayload( OpenClawTalkPTTStopPayload.self, from: cancelledOnce.value) #expect(cancelPayload.captureId == cancelledCaptureId) #expect(cancelPayload.status == "cancelled") #expect(cancelledOncePayload == cancelPayload) let stoppedOnce = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "once-stop", command: .pttOnce)) } await waitForTalkCondition { talkMode._test_activePushToTalkCaptureId() != nil } let stoppedCaptureId = try #require(talkMode._test_activePushToTalkCaptureId()) let stopResponse = await appModel._test_handleInvoke(talkRequest(id: "stop", command: .pttStop)) let stopPayload = try decodeTalkPayload(OpenClawTalkPTTStopPayload.self, from: stopResponse) let stoppedOncePayload = try await decodeTalkPayload(OpenClawTalkPTTStopPayload.self, from: stoppedOnce.value) #expect(stopPayload.captureId == stoppedCaptureId) #expect(stopPayload.status == "empty") #expect(stoppedOncePayload == stopPayload) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `stale PTT cleanup cannot stop a newer capture`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) talkMode.updateGatewayConnected(true) let first = try await talkMode.beginPushToTalk() #expect(talkMode.cancelPushToTalk(captureId: first.captureId).status == "cancelled") let second = try await talkMode.beginPushToTalk() let stale = talkMode.cancelPushToTalk(captureId: first.captureId) #expect(stale.status == "idle") #expect(talkMode._test_activePushToTalkCaptureId() == second.captureId) #expect(talkMode.isPushToTalkActive) #expect(talkMode.cancelPushToTalk(captureId: second.captureId).status == "cancelled") } @Test @MainActor func `standalone PTT deactivates audio before releasing ownership`() async throws { var events: [String] = [] let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { events.append("deactivate") }) talkMode.setPushToTalkAudioOwnershipEndHandler { _ in events.append("release") } talkMode.updateGatewayConnected(true) let capture = try await talkMode.beginPushToTalk() #expect(talkMode.cancelPushToTalk(captureId: capture.captureId).status == "cancelled") #expect(events == ["deactivate", "release"]) } @Test @MainActor func `failed audio deactivation remains retryable`() async throws { var deactivationAttempts = 0 let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { deactivationAttempts += 1 if deactivationAttempts == 1 { throw NSError(domain: "TalkModeTests", code: 1) } }) talkMode.updateGatewayConnected(true) let capture = try await talkMode.beginPushToTalk() _ = talkMode.cancelPushToTalk(captureId: capture.captureId) #expect(deactivationAttempts == 1) talkMode.stop() #expect(deactivationAttempts == 2) } @Test @MainActor func `blocked continuous resume releases the PTT audio session`() async throws { var deactivationCount = 0 let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { deactivationCount += 1 }) talkMode.updateGatewayConnected(true) talkMode.gatewayTalkConfigLoaded = true talkMode.gatewayTalkPermissionState = .missingScope("operator.talk.secrets") defer { talkMode.stop() } let capture = try await talkMode.beginPushToTalk() talkMode.setEnabled(true) #expect(talkMode.cancelPushToTalk(captureId: capture.captureId).status == "cancelled") await waitForTalkCondition { talkMode.statusText == "Gateway permission required" } #expect(deactivationCount == 1) #expect(!talkMode._test_audioSessionIsActive()) #expect(!talkMode.isListening) } @Test @MainActor func `stale PTT recognition callback cannot mutate a newer capture`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) talkMode.updateGatewayConnected(true) let first = try await talkMode.beginPushToTalkOnce(maxDurationSeconds: 0) let firstCaptureId = try #require(talkMode._test_activePushToTalkCaptureId()) _ = talkMode.cancelPushToTalk(captureId: firstCaptureId) _ = await talkMode.awaitPushToTalkOnce(first) let second = try await talkMode.beginPushToTalkOnce(maxDurationSeconds: 0) let secondCaptureId = try #require(talkMode._test_activePushToTalkCaptureId()) await talkMode._test_handlePushToTalkTranscript( "stale transcript", isFinal: true, captureId: firstCaptureId) #expect(talkMode._test_activePushToTalkCaptureId() == secondCaptureId) #expect(talkMode.isPushToTalkActive) _ = talkMode.cancelPushToTalk(captureId: secondCaptureId) _ = await talkMode.awaitPushToTalkOnce(second) } @Test @MainActor func `stale continuous recognition callback cannot stop newer PTT`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) talkMode.updateGatewayConnected(true) let staleGeneration = talkMode._test_recognitionGeneration() let once = try await talkMode.beginPushToTalkOnce(maxDurationSeconds: 0) let captureId = try #require(talkMode._test_activePushToTalkCaptureId()) await talkMode._test_handleTranscript( "stale continuous transcript", isFinal: true, pttCaptureId: nil, recognitionGeneration: staleGeneration) #expect(talkMode._test_activePushToTalkCaptureId() == captureId) #expect(talkMode.isPushToTalkActive) _ = talkMode.cancelPushToTalk(captureId: captureId) _ = await talkMode.awaitPushToTalkOnce(once) } @Test @MainActor func `finishing PTT turn blocks replacement until finalizer exits`() async throws { var ownershipEvents: [String] = [] let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { ownershipEvents.append("deactivate") }) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTFinalizerHandler { await barrier.suspendFirstPreparation() } talkMode.setPushToTalkAudioOwnershipEndHandler { _ in ownershipEvents.append("release") } defer { barrier.release() talkMode._test_setPTTFinalizerHandler(nil) talkMode.setPushToTalkAudioOwnershipEndHandler(nil) } let first = try await talkMode.beginPushToTalk() await talkMode._test_handlePushToTalkTranscript( "first turn", isFinal: false, captureId: first.captureId) let queued = talkMode.endPushToTalk(captureId: first.captureId) #expect(queued.status == "queued") await barrier.waitUntilEntered() #expect(talkMode._test_finishingPushToTalkCaptureId() == first.captureId) var busyError: Error? do { _ = try await talkMode.beginPushToTalk() } catch { busyError = error } #expect(busyError?.localizedDescription == "PTT_BUSY: previous push-to-talk turn is still finishing") let once = try await talkMode.beginPushToTalkOnce(maxDurationSeconds: 0) switch once { case let .busy(payload): #expect(payload.captureId == first.captureId) #expect(payload.status == "busy") case .started: Issue.record("one-shot PTT replaced a finishing turn") } barrier.release() await waitForTalkCondition { talkMode._test_finishingPushToTalkCaptureId() == nil } #expect(ownershipEvents == ["deactivate", "release"]) let replacement = try await talkMode.beginPushToTalk() #expect(talkMode._test_activePushToTalkCaptureId() == replacement.captureId) _ = talkMode.cancelPushToTalk(captureId: replacement.captureId) } @Test @MainActor func `cancelled PTT finalizer keeps ownership until task exits`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let barrier = TalkPreparationBarrier() var audioOwnershipEndCount = 0 talkMode.updateGatewayConnected(true) talkMode._test_setPTTFinalizerHandler { await barrier.suspendFirstPreparation() } talkMode.setPushToTalkAudioOwnershipEndHandler { _ in audioOwnershipEndCount += 1 } defer { barrier.release() talkMode._test_setPTTFinalizerHandler(nil) talkMode.setPushToTalkAudioOwnershipEndHandler(nil) } let first = try await talkMode.beginPushToTalk() await talkMode._test_handlePushToTalkTranscript( "cancelled finalizer", isFinal: false, captureId: first.captureId) #expect(talkMode.endPushToTalk(captureId: first.captureId).status == "queued") await barrier.waitUntilEntered() #expect(audioOwnershipEndCount == 0) talkMode.stop() #expect(talkMode._test_finishingPushToTalkCaptureId() == first.captureId) #expect(audioOwnershipEndCount == 0) var busyError: Error? do { _ = try await talkMode.beginPushToTalk() } catch { busyError = error } #expect(busyError?.localizedDescription == "PTT_BUSY: previous push-to-talk turn is still finishing") barrier.release() await waitForTalkCondition { talkMode._test_finishingPushToTalkCaptureId() == nil } #expect(audioOwnershipEndCount == 1) #expect(talkMode.statusText == "Off") let replacement = try await talkMode.beginPushToTalk() _ = talkMode.cancelPushToTalk(captureId: replacement.captureId) } @Test @MainActor func `enabling Talk during PTT finalization resumes after ownership clears`() async throws { var audioDeactivationCount = 0 let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { audioDeactivationCount += 1 }) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTFinalizerHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTFinalizerHandler(nil) talkMode.stop() } let start = try await talkMode.beginPushToTalk() await talkMode._test_handlePushToTalkTranscript( "resume continuous Talk", isFinal: false, captureId: start.captureId) #expect(talkMode.endPushToTalk(captureId: start.captureId).status == "queued") await barrier.waitUntilEntered() talkMode.setEnabled(true) await talkMode.start() #expect(!talkMode.isListening) barrier.release() await waitForTalkCondition { talkMode._test_finishingPushToTalkCaptureId() == nil && talkMode.isListening } #expect(talkMode.isEnabled) #expect(talkMode.isListening) #expect(audioDeactivationCount == 1) } @Test @MainActor func `native Talk disconnect releases audio and reconnects once`() async { var audioDeactivationCount = 0 let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { audioDeactivationCount += 1 }) defer { talkMode.stop() } talkMode.updateGatewayConnected(true) talkMode.setEnabled(true) await waitForTalkCondition { talkMode.isListening } await talkMode._test_handleTranscript( "partial old route", isFinal: false, pttCaptureId: nil, recognitionGeneration: talkMode._test_recognitionGeneration()) #expect(talkMode._test_lastTranscript() == "partial old route") talkMode.updateGatewayConnected(false) #expect(talkMode.isEnabled) #expect(!talkMode.isListening) #expect(talkMode._test_lastTranscript().isEmpty) #expect(audioDeactivationCount == 1) talkMode.updateGatewayConnected(true) await waitForTalkCondition { talkMode.isListening } #expect(talkMode.isListening) #expect(audioDeactivationCount == 1) } @Test @MainActor func `native Talk session switch discards old recognition and restarts`() async { var audioDeactivationCount = 0 let talkMode = TalkModeManager( allowSimulatorCapture: true, audioSessionDeactivationAction: { audioDeactivationCount += 1 }) talkMode.updateGatewayConnected(true) talkMode.setEnabled(true) defer { talkMode.stop() } await waitForTalkCondition { talkMode.isListening } let staleRecognitionGeneration = talkMode._test_recognitionGeneration() await talkMode._test_handleTranscript( "old session partial", isFinal: false, pttCaptureId: nil, recognitionGeneration: staleRecognitionGeneration) #expect(talkMode._test_lastTranscript() == "old session partial") talkMode.updateMainSessionKey("agent:main:replacement") #expect(talkMode._test_recognitionGeneration() > staleRecognitionGeneration) #expect(talkMode._test_lastTranscript().isEmpty) await waitForTalkCondition { talkMode.isListening } #expect(talkMode.isUsingMainSessionKey("agent:main:replacement")) #expect(audioDeactivationCount == 1) } @Test @MainActor func `session switch cancellation clears finalizer status`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTFinalizerHandler { talkMode.statusText = "Aborted" await barrier.suspendFirstPreparation() } defer { barrier.release() talkMode._test_setPTTFinalizerHandler(nil) talkMode.stop() } let start = try await talkMode.beginPushToTalk() await talkMode._test_handlePushToTalkTranscript( "cancel old session", isFinal: false, captureId: start.captureId) #expect(talkMode.endPushToTalk(captureId: start.captureId).status == "queued") await barrier.waitUntilEntered() #expect(talkMode.statusText == "Aborted") talkMode.updateMainSessionKey("agent:main:replacement") barrier.release() await waitForTalkCondition { talkMode._test_finishingPushToTalkCaptureId() == nil } #expect(talkMode.statusText == "Ready") } @Test @MainActor func `backgrounding completes PTT once and releases its voice wake lease`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) defer { appModel.voiceWake.stop() } let once = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "once-background", command: .pttOnce)) } await waitForTalkCondition { talkMode._test_activePushToTalkCaptureId() != nil } let captureId = try #require(talkMode._test_activePushToTalkCaptureId()) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds() == [captureId]) talkMode.suspendForBackground() let payload = try await decodeTalkPayload(OpenClawTalkPTTStopPayload.self, from: once.value) #expect(payload.captureId == captureId) #expect(payload.status == "cancelled") #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `background PTT teardown cannot restart Voice Wake`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) appModel.voiceWake.isEnabled = true appModel.voiceWake.isListening = true appModel.voiceWake.statusText = "Listening" defer { appModel.voiceWake.stop() } let startResponse = await appModel._test_handleInvoke(talkRequest(id: "background-start", command: .pttStart)) let start = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: startResponse) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds() == [start.captureId]) appModel.setScenePhase(.background) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) #expect(appModel.voiceWake._test_isSuppressedForBackground()) #expect(!appModel.voiceWake._test_isSuppressedByPushToTalk()) #expect(!appModel.voiceWake.isListening) appModel.setScenePhase(.active) #expect(!appModel.voiceWake._test_isSuppressedForBackground()) } @Test @MainActor func `background listening preference never preserves active PTT`() async throws { let defaults = UserDefaults.standard let previous = defaults.object(forKey: "talk.background.enabled") defaults.set(true, forKey: "talk.background.enabled") defer { if let previous { defaults.set(previous, forKey: "talk.background.enabled") } else { defaults.removeObject(forKey: "talk.background.enabled") } } let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) defer { appModel.setScenePhase(.active) talkMode.stop() } let response = await appModel._test_handleInvoke(talkRequest(id: "background-pref-start", command: .pttStart)) let start = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: response) #expect(talkMode._test_activePushToTalkCaptureId() == start.captureId) appModel.setScenePhase(.background) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(talkMode._test_pushToTalkCaptureIsIdle()) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `background listening preference does not strand an initially gated Talk start`() async { let defaults = UserDefaults.standard let previous = defaults.object(forKey: "talk.background.enabled") defaults.set(true, forKey: "talk.background.enabled") defer { if let previous { defaults.set(previous, forKey: "talk.background.enabled") } else { defaults.removeObject(forKey: "talk.background.enabled") } } let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel( talkMode: talkMode, audioAdmissionInitiallyAllowed: false) talkMode.updateGatewayConnected(true) talkMode.setEnabled(true) defer { appModel.setScenePhase(.active) talkMode.stop() } await waitForTalkCondition { talkMode.statusText == "Paused" } #expect(!talkMode.canKeepContinuousTalkActiveInBackground) appModel.setScenePhase(.background) appModel.setScenePhase(.active) await waitForTalkCondition { talkMode.isListening } #expect(talkMode.isEnabled) #expect(talkMode.isListening) } @Test @MainActor func `background listening keeps an active continuous transcript turn`() { let talkMode = TalkModeManager(allowSimulatorCapture: true) talkMode.isEnabled = true talkMode._test_setContinuousTranscriptProcessingActive(true) defer { talkMode._test_setContinuousTranscriptProcessingActive(false) talkMode.stop() } #expect(talkMode.canKeepContinuousTalkActiveInBackground) talkMode.suspendForBackground(keepActive: true) #expect(talkMode.canKeepContinuousTalkActiveInBackground) talkMode.resumeAfterBackground(wasKeptActive: true) #expect(talkMode.canKeepContinuousTalkActiveInBackground) } @Test @MainActor func `late finalizer release cannot restart Voice Wake in background`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) talkMode._test_setPTTFinalizerHandler { await barrier.suspendFirstPreparation() } appModel.voiceWake.isEnabled = true appModel.voiceWake.isListening = true appModel.voiceWake.statusText = "Listening" defer { barrier.release() talkMode._test_setPTTFinalizerHandler(nil) appModel.setScenePhase(.active) appModel.voiceWake.stop() } let startResponse = await appModel._test_handleInvoke( talkRequest(id: "background-finalizer-start", command: .pttStart)) let start = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: startResponse) await talkMode._test_handlePushToTalkTranscript( "finish in background", isFinal: false, captureId: start.captureId) let stopResponse = await appModel._test_handleInvoke( talkRequest(id: "background-finalizer-stop", command: .pttStop)) #expect(try decodeTalkPayload(OpenClawTalkPTTStopPayload.self, from: stopResponse).status == "queued") await barrier.waitUntilEntered() appModel.setScenePhase(.background) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds() == [start.captureId]) barrier.release() await waitForTalkCondition { talkMode._test_finishingPushToTalkCaptureId() == nil } #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) #expect(!appModel.voiceWake.isListening) #expect(appModel.voiceWake.statusText == "Paused") #expect(talkMode.statusText == "Paused") } @Test @MainActor func `idle background blocks a later Talk enable`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) defer { appModel.setScenePhase(.active) talkMode.stop() } appModel.setScenePhase(.background) talkMode.setEnabled(true) await waitForTalkCondition { talkMode.statusText == "Paused" } #expect(!talkMode.isListening) #expect(talkMode.statusText == "Paused") appModel.setScenePhase(.active) await waitForTalkCondition { talkMode.isListening } #expect(talkMode.isEnabled) #expect(talkMode.isListening) } @Test @MainActor func `background to inactive keeps PTT admission closed`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) defer { appModel.setScenePhase(.active) talkMode.stop() } appModel.setScenePhase(.background) appModel.setScenePhase(.inactive) let response = await appModel._test_handleInvoke( talkRequest(id: "inactive-ptt-start", command: .pttStart)) #expect(!response.ok) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `stale foreground resume cannot reopen Talk after rebackgrounding`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() var startResumed = false talkMode.updateGatewayConnected(true) talkMode._test_setStartEntryHandler { await barrier.suspendFirstPreparation() startResumed = true } defer { barrier.release() talkMode._test_setStartEntryHandler(nil) appModel.setScenePhase(.active) talkMode.stop() } appModel.setScenePhase(.background) talkMode.setEnabled(true) appModel.setScenePhase(.active) await barrier.waitUntilEntered() appModel.setScenePhase(.background) barrier.release() await waitForTalkCondition { startResumed } #expect(!talkMode.isListening) #expect(talkMode.statusText == "Paused") } @Test @MainActor func `gateway disconnect invalidates a suspended Talk start`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let barrier = TalkPreparationBarrier() var startResumed = false talkMode.updateGatewayConnected(true) talkMode._test_setStartEntryHandler { await barrier.suspendFirstPreparation() startResumed = true } defer { barrier.release() talkMode._test_setStartEntryHandler(nil) talkMode.stop() } talkMode.setEnabled(true) await barrier.waitUntilEntered() talkMode.updateGatewayConnected(false) barrier.release() await waitForTalkCondition { startResumed } #expect(!talkMode.isListening) #expect(talkMode.statusText == "Offline") } @Test @MainActor func `gateway disconnect cancels manual PTT and releases its lease`() async throws { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) talkMode.updateGatewayConnected(true) let response = await appModel._test_handleInvoke( talkRequest(id: "disconnect-ptt-start", command: .pttStart)) let start = try decodeTalkPayload(OpenClawTalkPTTStartPayload.self, from: response) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds() == [start.captureId]) talkMode.updateGatewayConnected(false) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(!talkMode.isPushToTalkActive) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `background invalidates active and queued PTT preparation`() async { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCapturePreparationHandler { await barrier.suspendFirstPreparation() } defer { appModel._test_setTalkCapturePreparationHandler(nil) appModel.setScenePhase(.active) appModel.voiceWake.stop() } let active = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "background-active", command: .pttStart)) } await barrier.waitUntilEntered() let queued = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "background-queued", command: .pttStart)) } await waitForTalkCondition { appModel._test_talkPreparationWaiterCount() == 1 } appModel.setScenePhase(.background) barrier.release() #expect(await active.value.ok == false) #expect(await queued.value.ok == false) #expect(appModel._test_talkPreparationWaiterCount() == 0) #expect(talkMode._test_activePushToTalkCaptureId() == nil) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds().isEmpty) } @Test @MainActor func `stale PTT release cannot clear the active voice wake lease`() { let appModel = NodeAppModel(talkMode: TalkModeManager(allowSimulatorCapture: true)) appModel.voiceWake.isEnabled = true appModel.voiceWake.isListening = true appModel.voiceWake.statusText = "Listening" appModel._test_acquirePttVoiceWakeLease(captureId: "capture-a") #expect(appModel.isTalkCaptureActive == true) appModel._test_acquirePttVoiceWakeLease(captureId: "capture-a") #expect(appModel.voiceWake._test_isSuppressedByPushToTalk()) #expect(appModel._test_pttVoiceWakeLeaseCaptureIds() == ["capture-a"]) appModel._test_releasePttVoiceWakeLease(captureId: "stale-capture") #expect(appModel.voiceWake._test_isSuppressedByPushToTalk()) appModel._test_releasePttVoiceWakeLease(captureId: "capture-a") #expect(!appModel.voiceWake._test_isSuppressedByPushToTalk()) #expect(appModel.isTalkCaptureActive == false) appModel.voiceWake.stop() } @Test @MainActor func `enabling Voice Wake during standalone PTT remains suppressed`() async { let appModel = NodeAppModel(talkMode: TalkModeManager(allowSimulatorCapture: true)) appModel._test_acquirePttVoiceWakeLease(captureId: "standalone-ptt") appModel.setVoiceWakeEnabled(true) await appModel.voiceWake._test_waitForScheduledStart() #expect(appModel.voiceWake.statusText == "Paused") #expect(!appModel.voiceWake.isListening) appModel._test_releasePttVoiceWakeLease(captureId: "standalone-ptt") await appModel.voiceWake._test_waitForScheduledStart() #expect(appModel.voiceWake.statusText == "Voice Wake isn’t supported on Simulator") appModel.voiceWake.stop() } @Test @MainActor func `voice note start cannot race an acquired PTT lease`() async { let capture = MockVoiceNoteAudioCapture() let recorder = OpenClawVoiceNoteRecorder(capture: capture) let appModel = NodeAppModel( talkMode: TalkModeManager(allowSimulatorCapture: true), voiceNoteRecorder: recorder) appModel._test_acquirePttVoiceWakeLease(captureId: "voice-note-race") #expect(await recorder.start() == false) #expect(recorder.isRecording == false) #expect(capture.permissionRequestCount == 0) appModel._test_releasePttVoiceWakeLease(captureId: "voice-note-race") } @Test @MainActor func `voice note cannot start after the app backgrounds`() async { let capture = MockVoiceNoteAudioCapture() let recorder = OpenClawVoiceNoteRecorder(capture: capture) let appModel = NodeAppModel(voiceNoteRecorder: recorder) defer { appModel.setScenePhase(.active) } appModel.setScenePhase(.background) #expect(await recorder.start() == false) #expect(!recorder.isRecording) #expect(capture.permissionRequestCount == 0) #expect(recorder.errorMessage == "Another feature is using the microphone.") } @Test @MainActor func `voice note cannot start during PTT preparation`() async { let capture = MockVoiceNoteAudioCapture() let recorder = OpenClawVoiceNoteRecorder(capture: capture) let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode, voiceNoteRecorder: recorder) let barrier = TalkPreparationBarrier() talkMode.updateGatewayConnected(true) appModel._test_setTalkCapturePreparationHandler { await barrier.suspendFirstPreparation() } defer { barrier.release() appModel._test_setTalkCapturePreparationHandler(nil) _ = talkMode.cancelPushToTalk() } let start = Task { @MainActor in await appModel._test_handleInvoke(talkRequest(id: "voice-note-preparation", command: .pttStart)) } await barrier.waitUntilEntered() #expect(await recorder.start() == false) #expect(!recorder.isRecording) #expect(capture.permissionRequestCount == 0) barrier.release() _ = await start.value } @Test @MainActor func `audio camera clip cannot overlap PTT ownership`() async throws { let camera = RecordingCameraService() let appModel = NodeAppModel( camera: camera, talkMode: TalkModeManager(allowSimulatorCapture: true)) appModel._test_acquirePttVoiceWakeLease(captureId: "camera-audio-ptt") defer { appModel._test_releasePttVoiceWakeLease(captureId: "camera-audio-ptt") } let params = try JSONEncoder().encode(OpenClawCameraClipParams(includeAudio: true)) let request = try BridgeInvokeRequest( id: "camera-audio-during-ptt", command: OpenClawCameraCommand.clip.rawValue, paramsJSON: #require(String(data: params, encoding: .utf8))) let response = await appModel._test_handleInvoke(request) #expect(!response.ok) #expect(response.error?.message.contains("Finish the active audio capture") == true) #expect(await camera.clipCallCount() == 0) } @Test @MainActor func `camera audio ownership blocks PTT and continuous Talk`() async throws { let barrier = TalkPreparationBarrier() let talkMode = TalkModeManager(allowSimulatorCapture: true) let voiceNoteCapture = MockVoiceNoteAudioCapture() let voiceNoteRecorder = OpenClawVoiceNoteRecorder(capture: voiceNoteCapture) let appModel = NodeAppModel( camera: BlockingAudioCameraService(barrier: barrier), talkMode: talkMode, voiceNoteRecorder: voiceNoteRecorder) talkMode.updateGatewayConnected(true) defer { barrier.release() talkMode.stop() } let params = try JSONEncoder().encode(OpenClawCameraClipParams(includeAudio: true)) let clipRequest = try BridgeInvokeRequest( id: "blocking-camera-audio", command: OpenClawCameraCommand.clip.rawValue, paramsJSON: #require(String(data: params, encoding: .utf8))) let clip = Task { @MainActor in await appModel._test_handleInvoke(clipRequest) } await barrier.waitUntilEntered() let ptt = await appModel._test_handleInvoke( talkRequest(id: "ptt-during-camera-audio", command: .pttStart)) appModel.setTalkEnabled(true) let voiceNoteStarted = await voiceNoteRecorder.start() #expect(!ptt.ok) #expect(ptt.error?.message.contains("active audio capture") == true) #expect(!talkMode.isEnabled) #expect(talkMode.statusText == "Finish the active audio capture first") #expect(!voiceNoteStarted) #expect(voiceNoteCapture.permissionRequestCount == 0) #expect(voiceNoteRecorder.errorMessage == "Another feature is using the microphone.") barrier.release() #expect(await clip.value.ok) } @Test @MainActor func `screen audio ownership blocks PTT`() async throws { let barrier = TalkPreparationBarrier() let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel( screenRecorder: BlockingAudioScreenRecorder(barrier: barrier), talkMode: talkMode) talkMode.updateGatewayConnected(true) defer { barrier.release() talkMode.stop() } let params = try JSONEncoder().encode(OpenClawScreenRecordParams(includeAudio: true)) let recordRequest = try BridgeInvokeRequest( id: "blocking-screen-audio", command: OpenClawScreenCommand.record.rawValue, paramsJSON: #require(String(data: params, encoding: .utf8))) let recording = Task { @MainActor in await appModel._test_handleInvoke(recordRequest) } await barrier.waitUntilEntered() let ptt = await appModel._test_handleInvoke( talkRequest(id: "ptt-during-screen-audio", command: .pttStart)) #expect(!ptt.ok) #expect(ptt.error?.message.contains("active audio capture") == true) barrier.release() #expect(await recording.value.ok) } @Test @MainActor func `screen recording stays single flight across audio modes`() async throws { for (firstIncludesAudio, secondIncludesAudio) in [(false, true), (true, false)] { let barrier = TalkPreparationBarrier() let recorder = BlockingAudioScreenRecorder(barrier: barrier) let appModel = NodeAppModel(screenRecorder: recorder) let firstParams = try JSONEncoder().encode( OpenClawScreenRecordParams(includeAudio: firstIncludesAudio)) let secondParams = try JSONEncoder().encode( OpenClawScreenRecordParams(includeAudio: secondIncludesAudio)) let firstRequest = try BridgeInvokeRequest( id: "screen-first-\(firstIncludesAudio)", command: OpenClawScreenCommand.record.rawValue, paramsJSON: #require(String(data: firstParams, encoding: .utf8))) let secondRequest = try BridgeInvokeRequest( id: "screen-second-\(secondIncludesAudio)", command: OpenClawScreenCommand.record.rawValue, paramsJSON: #require(String(data: secondParams, encoding: .utf8))) let first = Task { @MainActor in await appModel._test_handleInvoke(firstRequest) } await barrier.waitUntilEntered() #expect(appModel.screenRecordActive) let second = await appModel._test_handleInvoke(secondRequest) #expect(!second.ok) #expect(second.error?.message.contains("screen recording already active") == true) #expect(await recorder.recordCallCount() == 1) #expect(appModel.screenRecordActive) barrier.release() #expect(await first.value.ok) #expect(!appModel.screenRecordActive) } } @Test @MainActor func `background cancels camera audio capture and retains suppression until exit`() async throws { let barrier = TalkPreparationBarrier() let appModel = NodeAppModel(camera: BlockingAudioCameraService(barrier: barrier)) defer { barrier.release() appModel.setScenePhase(.active) } appModel.voiceWake.isEnabled = true appModel.voiceWake.statusText = "Listening" let params = try JSONEncoder().encode(OpenClawCameraClipParams(includeAudio: true)) let request = try BridgeInvokeRequest( id: "background-camera-audio", command: OpenClawCameraCommand.clip.rawValue, paramsJSON: #require(String(data: params, encoding: .utf8))) let capture = Task { @MainActor in await appModel._test_handleInvoke(request) } await barrier.waitUntilEntered() appModel.setScenePhase(.background) #expect(appModel.voiceWake._test_isSuppressedForAuxiliaryAudio()) barrier.release() #expect(await capture.value.ok == false) #expect(!appModel.voiceWake._test_isSuppressedForAuxiliaryAudio()) #expect(appModel.voiceWake._test_isSuppressedForBackground()) } @Test @MainActor func `background cancels audio free screen capture`() async throws { let barrier = TalkPreparationBarrier() let appModel = NodeAppModel(screenRecorder: BlockingAudioScreenRecorder(barrier: barrier)) defer { barrier.release() appModel.setScenePhase(.active) } let params = try JSONEncoder().encode(OpenClawScreenRecordParams(includeAudio: false)) let request = try BridgeInvokeRequest( id: "background-screen-no-audio", command: OpenClawScreenCommand.record.rawValue, paramsJSON: #require(String(data: params, encoding: .utf8))) let capture = Task { @MainActor in await appModel._test_handleInvoke(request) } await barrier.waitUntilEntered() appModel.setScenePhase(.background) barrier.release() #expect(await capture.value.ok == false) #expect(!appModel.screenRecordActive) } @Test @MainActor func `cancelled screen capture deletes a late output file`() async throws { let barrier = TalkPreparationBarrier() let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("late-cancelled-screen-\(UUID().uuidString).mp4") defer { try? FileManager.default.removeItem(at: outputURL) } let appModel = NodeAppModel( screenRecorder: CancellationIgnoringScreenRecorder( barrier: barrier, outputURL: outputURL)) defer { barrier.release() appModel.setScenePhase(.active) } let params = try JSONEncoder().encode(OpenClawScreenRecordParams(includeAudio: false)) let request = try BridgeInvokeRequest( id: "late-cancelled-screen", command: OpenClawScreenCommand.record.rawValue, paramsJSON: #require(String(data: params, encoding: .utf8))) let capture = Task { @MainActor in await appModel._test_handleInvoke(request) } await barrier.waitUntilEntered() appModel.setScenePhase(.background) barrier.release() #expect(await capture.value.ok == false) #expect(!FileManager.default.fileExists(atPath: outputURL.path)) #expect(!appModel.screenRecordActive) } @Test @MainActor func `late watch snapshot is repaired after gateway switch`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let gate = WatchSnapshotSendGate() var shouldBlockNextSnapshot = true watchService.syncExecApprovalSnapshotHandler = { _ in if shouldBlockNextSnapshot { shouldBlockNextSnapshot = false await gate.wait() } return watchService.nextSendResult } let appModel = NodeAppModel(watchMessagingService: watchService) defer { appModel.disconnectGateway() } let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "ios", clientMode: "node", clientDisplayName: "Phone") let gatewayA = try GatewayConnectConfig( url: #require(URL(string: "wss://127.0.0.1:1")), stableID: "watch-route-a", tls: nil, token: "token-a", bootstrapToken: nil, password: nil, nodeOptions: options) let gatewayB = try GatewayConnectConfig( url: #require(URL(string: "wss://127.0.0.1:2")), stableID: "watch-route-b", tls: nil, token: "token-b", bootstrapToken: nil, password: nil, nodeOptions: options) appModel.applyGatewayConnectConfig(gatewayA) try appModel._test_presentExecApprovalPrompt(#require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-route-a", gatewayStableID: gatewayA.effectiveStableID, commandText: "route A", allowedDecisions: ["deny"], host: nil, nodeId: nil, agentId: nil, expiresAtMs: nil))) while await !(gate.hasStarted()) { await Task.yield() } appModel.applyGatewayConnectConfig(gatewayB) try appModel._test_presentExecApprovalPrompt(#require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-route-b", gatewayStableID: gatewayB.effectiveStableID, commandText: "route B", allowedDecisions: ["deny"], host: nil, nodeId: nil, agentId: nil, expiresAtMs: nil))) await gate.resume() for _ in 0..<1000 where watchService.sentExecApprovalSnapshots.count < 3 || watchService.lastSentExecApprovalSnapshot?.approvals.first?.gatewayStableID != gatewayB.effectiveStableID { await Task.yield() } #expect(watchService.sentExecApprovalSnapshots.count >= 3) #expect(watchService.lastSentExecApprovalSnapshot?.approvals.map(\.gatewayStableID) == [ gatewayB.effectiveStableID, ]) } @Test @MainActor func `dismiss pending exec approval prompt by id leaves different prompt visible`() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-active", commandText: "echo keep", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: nil, expiresAtMs: 1))) appModel.dismissPendingExecApprovalPrompt(approvalId: "approval-stale") let prompt = try #require(appModel._test_pendingExecApprovalPrompt()) #expect(prompt.id == "approval-active") } @Test @MainActor func `presenting exec approval prompt syncs watch prompt`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let prompt = try #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-watch-sync", commandText: "npm publish", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: "node-1", agentId: "main", expiresAtMs: 1234)) appModel._test_presentExecApprovalPrompt(prompt) await Task.yield() let sent = try #require(watchService.lastSentExecApprovalPrompt) #expect(sent.approval.id == "approval-watch-sync") #expect(sent.approval.allowedDecisions == [.allowOnce, .deny]) #expect(sent.approval.host == "gateway") #expect(sent.approval.risk == nil) #expect(sent.resetResolvingState != true) } @Test @MainActor func `watch exec approval snapshot request publishes cached approvals in background`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000 try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-watch-snapshot", commandText: "echo from watch", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: nil, expiresAtMs: futureExpiryMs))) await Task.yield() appModel.setScenePhase(.background) watchService.emitExecApprovalSnapshotRequest( WatchExecApprovalSnapshotRequestEvent( requestId: "snapshot-1", sentAtMs: 111, transport: "sendMessage")) await Task.yield() let snapshot = try #require(watchService.lastSentExecApprovalSnapshot) #expect(snapshot.approvals.map(\.id) == ["approval-watch-snapshot"]) } @Test @MainActor func `watch exec approval snapshot request skips foreground recovery`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000 try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-watch-foreground-skip", commandText: "echo foreground", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: nil, expiresAtMs: futureExpiryMs))) await Task.yield() watchService.lastSentExecApprovalSnapshot = nil watchService.emitExecApprovalSnapshotRequest( WatchExecApprovalSnapshotRequestEvent( requestId: "snapshot-foreground", sentAtMs: 222, transport: "sendMessage")) await Task.yield() #expect(watchService.lastSentExecApprovalSnapshot == nil) } @Test @MainActor func `watch app snapshot request publishes current dashboard state`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setGatewayConnected(true) appModel._test_setOperatorConnected(true) appModel._test_setConnectedGatewayID("gateway-watch-snapshot") appModel.gatewayStatusText = "Connected" appModel.talkMode.setEnabled(true) appModel.talkMode.statusText = "Listening" watchService.emitAppSnapshotRequest( WatchAppSnapshotRequestEvent( requestId: "app-snapshot-1", sentAtMs: 123, transport: "sendMessage")) for _ in 0..<20 { if watchService.lastSentAppSnapshot != nil { break } try? await Task.sleep(nanoseconds: 50_000_000) } let snapshot = try #require(watchService.lastSentAppSnapshot) #expect(snapshot.gatewayConnected == true) #expect(snapshot.gatewayStatusText == "Connected") #expect(snapshot.agentName == "Main") #expect(snapshot.sessionKey == "main") #expect(snapshot.gatewayStableID == "gateway-watch-snapshot") #expect(!snapshot.talkStatusText.isEmpty) #expect(snapshot.talkEnabled == true) #expect(snapshot.pendingApprovalCount == 0) } @Test @MainActor func `watch app snapshot publishes offline when operator disconnects`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setGatewayConnected(true) appModel._test_setOperatorConnected(true) appModel.gatewayStatusText = "Connected" watchService.emitAppSnapshotRequest( WatchAppSnapshotRequestEvent( requestId: "app-snapshot-before-disconnect", sentAtMs: 123, transport: "sendMessage")) for _ in 0..<20 { if watchService.lastSentAppSnapshot?.gatewayConnected == true { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(watchService.lastSentAppSnapshot?.gatewayConnected == true) appModel.disconnectGateway() for _ in 0..<20 { if watchService.lastSentAppSnapshot?.gatewayConnected == false { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(watchService.lastSentAppSnapshot?.gatewayConnected == false) #expect(watchService.lastSentAppSnapshot?.gatewayStatusText == "Offline") } @Test @MainActor func `watch app snapshot publishes online when operator reconnects`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setGatewayConnected(true) appModel.gatewayStatusText = "Connected" watchService.emitAppSnapshotRequest( WatchAppSnapshotRequestEvent( requestId: "app-snapshot-before-reconnect", sentAtMs: 124, transport: "sendMessage")) for _ in 0..<20 { if watchService.lastSentAppSnapshot?.gatewayConnected == false { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(watchService.lastSentAppSnapshot?.gatewayConnected == false) appModel._test_setOperatorConnected(true) for _ in 0..<20 { if watchService.lastSentAppSnapshot?.gatewayConnected == true { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(watchService.lastSentAppSnapshot?.gatewayConnected == true) #expect(watchService.lastSentAppSnapshot?.gatewayStatusText == "Connected") } @Test @MainActor func `watch app snapshot uses configured agent avatar`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel.gatewayDefaultAgentId = "main" appModel.gatewayAgents = [ AgentSummary( id: "main", name: "Main", identity: [ "avatarUrl": AnyCodable("https://example.com/openclaw.png"), "emoji": AnyCodable("OC"), ], workspace: nil, workspacegit: nil, model: nil, agentruntime: nil), ] watchService.emitAppSnapshotRequest( WatchAppSnapshotRequestEvent( requestId: "app-snapshot-avatar", sentAtMs: 124, transport: "sendMessage")) await Task.yield() let snapshot = try #require(watchService.lastSentAppSnapshot) #expect(snapshot.agentAvatarURL == "https://example.com/openclaw.png") #expect(snapshot.agentAvatarText == "OC") } @Test @MainActor func `watch app snapshot includes pending approval count`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-watch-app-count", commandText: "rm -rf build", allowedDecisions: ["allow-once", "deny"], host: "Mac", nodeId: "node-1", agentId: "agent-1", expiresAtMs: nil))) await Task.yield() let snapshot = try #require(watchService.lastSentAppSnapshot) #expect(snapshot.pendingApprovalCount == 1) } @Test @MainActor func `watch app command controls talk through phone model`() async { let watchService = MockWatchMessagingService() let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(watchMessagingService: watchService, talkMode: talkMode) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-start-talk", command: .startTalk, sessionKey: "main", gatewayStableID: nil, text: nil, sentAtMs: 123, transport: "sendMessage")) await Task.yield() #expect(appModel.talkMode.isEnabled == true) #expect(watchService.lastSentAppSnapshot?.talkEnabled == true) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-stop-talk", command: .stopTalk, sessionKey: "main", gatewayStableID: nil, text: nil, sentAtMs: 124, transport: "sendMessage")) await Task.yield() #expect(appModel.talkMode.isEnabled == false) #expect(watchService.lastSentAppSnapshot?.talkEnabled == false) } @Test @MainActor func `watch app command opens chat session on phone model`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-open-chat", command: .openChat, sessionKey: "incident-42", gatewayStableID: nil, text: nil, sentAtMs: 125, transport: "sendMessage")) await Task.yield() #expect(appModel.chatSessionKey == "incident-42") #expect(watchService.lastSentAppSnapshot?.sessionKey == "incident-42") } @Test @MainActor func `watch app commands reject stale gateway targets`() async { let watchService = MockWatchMessagingService() let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(watchMessagingService: watchService, talkMode: talkMode) appModel._test_setConnectedGatewayID("gateway-current") appModel.setTalkEnabled(false) for command in [OpenClawWatchAppCommand.openChat, .startTalk] { watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-stale-\(command.rawValue)", command: command, sessionKey: "stale-session", gatewayStableID: "gateway-stale", text: nil, sentAtMs: 125, transport: "transferUserInfo")) await Task.yield() } #expect(appModel.chatSessionKey != "stale-session") #expect(appModel.talkMode.isEnabled == false) appModel.setTalkEnabled(true) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-stale-stop-talk", command: .stopTalk, sessionKey: "stale-session", gatewayStableID: "gateway-stale", text: nil, sentAtMs: 126, transport: "transferUserInfo")) await Task.yield() #expect(appModel.talkMode.isEnabled == true) } @Test @MainActor func `watch app command sends chat message through phone model`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel.enterAppleReviewDemoMode() watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat", command: .sendChat, sessionKey: "main", gatewayStableID: AppleReviewDemoMode.gatewayID, text: "Watch says hello", sentAtMs: 126, transport: "sendMessage")) for _ in 0..<20 { if watchService.lastSentChatCompletion?.commandId == "watch-send-chat", watchService.lastSentAppSnapshot?.chatItems?.contains(where: { item in item.role == "user" && item.text.contains("Watch says hello") }) == true { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(watchService.lastSentAppSnapshot?.chatItems?.contains { item in item.role == "user" && item.text.contains("Watch says hello") } == true) #expect(watchService.lastSentChatCompletion?.commandId == "watch-send-chat") #expect(watchService.lastSentChatCompletion?.replyText.contains("Watch says hello") == true) } @Test func `watch chat preview keeps older readable messages after internal events`() throws { var rawMessages = try [ makeWatchChatRawMessage( role: "assistant", text: "Still worth reading", timestamp: 1000), ] for index in 0..<30 { try rawMessages.append( makeWatchChatRawMessage( role: "assistant", text: nil, type: "toolCall", timestamp: 2000 + Double(index))) } let items = NodeAppModel._test_makeWatchChatItems(from: rawMessages) #expect(items.map(\.text) == ["Still worth reading"]) } @Test func `watch chat preview reads responses output text`() throws { let rawMessages = try [ makeWatchChatRawMessage( role: "assistant", text: "Responses reply", type: "output_text", timestamp: 1000), ] let items = NodeAppModel._test_makeWatchChatItems(from: rawMessages) #expect(items.map(\.text) == ["Responses reply"]) } @Test func `watch voice reply matches direct run instead of newest assistant`() throws { let rawMessages = try [ makeWatchChatRawMessage( role: "assistant", text: "Matching reply", timestamp: 2000, idempotencyKey: "watch-run"), makeWatchChatRawMessage( role: "assistant", text: "Unrelated newer reply", timestamp: 3000, idempotencyKey: "other-run"), ] let reply = NodeAppModel._test_watchChatReplyText( from: rawMessages, runId: "watch-run", submittedText: "Question", submittedAtMs: 1000) #expect(reply == "Matching reply") } @Test func `watch voice reply anchors queued run after persisted user turn`() throws { let rawMessages = try [ makeWatchChatRawMessage(role: "assistant", text: "Active reply", timestamp: 2000), makeWatchChatRawMessage( role: "user", text: "Watch question", timestamp: 3000, idempotencyKey: "watch-run:user"), makeWatchChatRawMessage( role: "assistant", text: "Still working", timestamp: 3500, stopReason: "toolUse"), makeWatchChatRawMessage(role: "assistant", text: "Queued reply", timestamp: 4000), ] let reply = NodeAppModel._test_watchChatReplyText( from: rawMessages, runId: "watch-run", submittedText: "Watch question", submittedAtMs: 2500) #expect(reply == "Queued reply") } @Test func `watch voice reply finds collected queued turn`() throws { let rawMessages = try [ makeWatchChatRawMessage(role: "assistant", text: "Active reply", timestamp: 2000), makeWatchChatRawMessage( role: "user", text: "[Queued messages]\nWatch question\nAnother request", timestamp: 3100, idempotencyKey: "followup-collect:session:hash"), makeWatchChatRawMessage(role: "assistant", text: "Collected reply", timestamp: 4000), ] let reply = NodeAppModel._test_watchChatReplyText( from: rawMessages, runId: "watch-run", submittedText: "Watch question", submittedAtMs: 2500) #expect(reply == "Collected reply") } @Test func `watch voice reply accepts terminal message tool mirror`() 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 = NodeAppModel._test_watchChatReplyText( from: rawMessages, runId: "watch-run", submittedText: "Send the update", submittedAtMs: 2500) #expect(reply == "Update sent") } @Test func `watch chat completion bounds reply text`() { let message = OpenClawWatchChatCompletionMessage( commandId: "watch-voice", replyText: String(repeating: "x", count: 5000)) let payload = WatchMessagingPayloadCodec.encodeChatCompletionPayload(message) let reply = payload["replyText"] as? String #expect(reply?.count == WatchMessagingPayloadCodec.completedChatReplyTextLimit) #expect(reply?.hasSuffix("...") == true) } @Test func `watch chat preview disambiguates identical fallback messages`() throws { let rawMessages = try [ makeWatchChatRawMessage(role: "assistant", text: "Same", timestamp: 1000), makeWatchChatRawMessage(role: "assistant", text: "Same", timestamp: 1000), ] let items = NodeAppModel._test_makeWatchChatItems(from: rawMessages) #expect(items.count == 2) #expect(items[0].id != items[1].id) } @Test func `watch chat preview disambiguates projected rows sharing server ID`() throws { let rawMessages = try [ makeProjectedWatchChatRawMessage( role: "toolResult", text: "Update sent", timestamp: 1000, serverId: "shared-result"), makeProjectedWatchChatRawMessage( role: "assistant", text: "Update sent", timestamp: 1000, serverId: "shared-result", isMessageToolMirror: true), ] let items = NodeAppModel._test_makeWatchChatItems(from: rawMessages) #expect(items.count == 2) #expect(items[0].id != items[1].id) } @Test func `watch chat preview keeps message I ds stable when window rolls`() throws { var rawMessages: [AnyCodable] = [] for index in 0..<5 { try rawMessages.append( makeWatchChatRawMessage( role: "assistant", text: "Reply \(index)", timestamp: Double(1000 + index))) } let before = NodeAppModel._test_makeWatchChatItems(from: rawMessages) try rawMessages.append( makeWatchChatRawMessage( role: "user", text: "Next question", timestamp: 2000)) let after = NodeAppModel._test_makeWatchChatItems(from: rawMessages) #expect(before.last?.id == after.dropLast().last?.id) #expect(after.last?.role == "user") } @Test @MainActor func `watch app command queues chat message when operator offline`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let gatewayID = "gateway-watch-chat-offline" appModel._test_setConnectedGatewayID(gatewayID) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat-offline", command: .sendChat, sessionKey: "main", gatewayStableID: gatewayID, text: "Queue this from watch", sentAtMs: 127, transport: "sendMessage")) await Task.yield() #expect(appModel._test_queuedWatchChatCommandCount() == 1) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat-offline", command: .sendChat, sessionKey: "main", gatewayStableID: gatewayID, text: "Queue this from watch", sentAtMs: 128, transport: "sendMessage")) await Task.yield() #expect(appModel._test_queuedWatchChatCommandCount() == 1) } @Test @MainActor func `watch app command queues until cold launch restores its gateway`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat-before-route", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-cold-launch", text: "Keep this until startup restores the route", sentAtMs: 127, transport: "transferUserInfo")) await waitForMainActorWork { appModel._test_queuedWatchChatCommandCount() == 1 } #expect(appModel._test_queuedWatchChatCommandCount() == 1) #expect(watchService.lastSentAppSnapshot == nil) } @Test @MainActor func `watch app command drops chat message for stale gateway snapshot`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setConnectedGatewayID("gateway-current") watchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat-stale-gateway", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-from-old-snapshot", text: "Do not send to the new gateway", sentAtMs: 128, transport: "transferUserInfo")) await Task.yield() #expect(appModel._test_queuedWatchChatCommandCount() == 0) } @Test @MainActor func `watch app command restores queued chat message after model restart`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } let gatewayID = "gateway-watch-chat-restore" let firstWatchService = MockWatchMessagingService() let firstAppModel = NodeAppModel(watchMessagingService: firstWatchService) firstAppModel._test_setConnectedGatewayID(gatewayID) firstWatchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat-restore", command: .sendChat, sessionKey: "main", gatewayStableID: gatewayID, text: "Keep this through restart", sentAtMs: 129, transport: "sendMessage")) await Task.yield() #expect(firstAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"]) let secondWatchService = MockWatchMessagingService() let secondAppModel = NodeAppModel(watchMessagingService: secondWatchService) secondAppModel._test_setConnectedGatewayID(gatewayID) #expect(secondAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"]) secondWatchService.emitAppCommand( WatchAppCommandEvent( commandId: "watch-send-chat-restore", command: .sendChat, sessionKey: "main", gatewayStableID: gatewayID, text: "Keep this through restart", sentAtMs: 130, transport: "transferUserInfo")) await Task.yield() #expect(secondAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"]) } @Test @MainActor func `watch chat queue scopes and orders commands by gateway`() throws { let suiteName = "watch-chat-queue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let coordinator = WatchMessageOutbox(defaults: defaults) let first = WatchAppCommandEvent( commandId: "watch-send-chat-gateway-a-1", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-a", text: "First for gateway A", sentAtMs: 131, transport: "sendMessage") let second = WatchAppCommandEvent( commandId: "watch-send-chat-gateway-a-2", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-a", text: "Second for gateway A", sentAtMs: 132, transport: "sendMessage") if case .queue = coordinator.ingest(first, isAvailable: false, gatewayStableID: "gateway-a") { } else { Issue.record("expected first gateway A command to queue") } if case .queue = coordinator.ingest(second, isAvailable: false, gatewayStableID: "gateway-a") { } else { Issue.record("expected second gateway A command to queue") } #expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil) coordinator.removeQueuedMessage( messageID: "watch-send-chat-gateway-a-1", gatewayStableID: "gateway-b") #expect( coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-gateway-a-1") #expect( coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-gateway-a-1") coordinator.removeQueuedMessage( messageID: "watch-send-chat-gateway-a-1", gatewayStableID: "gateway-a") #expect( coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-gateway-a-2") } @Test @MainActor func `watch chat requeue keeps original gateway owner`() throws { let suiteName = "watch-chat-requeue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let coordinator = WatchMessageOutbox(defaults: defaults) let event = WatchAppCommandEvent( commandId: "watch-send-chat-retry-gateway-a", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-a", text: "Retry for gateway A", sentAtMs: 133, transport: "sendMessage") coordinator.requeueFront(event, gatewayStableID: event.gatewayStableID) #expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil) #expect( coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId == "watch-send-chat-retry-gateway-a") } @Test @MainActor func `watch message retry budget resets only on reconnect`() { let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) let messageID = "watch-message-exhausted" appModel._test_setWatchMessageRetryAttempts(3, messageID: messageID) appModel._test_setOperatorConnected(true) #expect(appModel._test_watchMessageRetryAttempts(messageID: messageID) == nil) appModel._test_setWatchMessageRetryAttempts(2, messageID: messageID) appModel._test_setOperatorConnected(true) #expect(appModel._test_watchMessageRetryAttempts(messageID: messageID) == 2) appModel._test_setOperatorConnected(false) appModel._test_setOperatorConnected(true) #expect(appModel._test_watchMessageRetryAttempts(messageID: messageID) == nil) } @Test @MainActor func `watch message outbox prioritizes replies over queued chat`() throws { let suiteName = "watch-message-priority-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let outbox = WatchMessageOutbox(defaults: defaults) let chat = WatchAppCommandEvent( commandId: "queued-chat", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-a", text: "Chat first", sentAtMs: 1, transport: "transferUserInfo") let reply = WatchAppCommandEvent( commandId: "queued-reply", command: .sendChat, sessionKey: nil, gatewayStableID: "gateway-a", text: "Reply second", sentAtMs: 2, transport: "transferUserInfo", messageKind: .quickReply) _ = outbox.ingest(chat, isAvailable: false, gatewayStableID: "gateway-a") _ = outbox.ingest(reply, isAvailable: false, gatewayStableID: "gateway-a") #expect(outbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") == reply) } @Test func `watch message outbox discards permanent gateway failures`() { #expect(NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "INVALID_REQUEST")) #expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage( code: "INVALID_REQUEST", message: "Session changed while starting work. Retry.")) #expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "UNAVAILABLE")) } @Test @MainActor func `watch chat restore backfills gateway owner into legacy queued event`() throws { let suiteName = "watch-chat-restore-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let legacyQueueJSON = """ [ { "gatewayStableID": "gateway-a", "event": { "commandId": "watch-send-chat-legacy", "command": "send-chat", "sessionKey": "main", "text": "Legacy queued text", "sentAtMs": 134, "transport": "transferUserInfo" } } ] """ defaults.set( Data(legacyQueueJSON.utf8), forKey: "watch.chat.command.queue.v1") let coordinator = WatchMessageOutbox(defaults: defaults) let restored = coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") #expect(restored?.commandId == "watch-send-chat-legacy") #expect(restored?.gatewayStableID == "gateway-a") } @Test @MainActor func `watch chat command deduping keeps only recent forwarded commands`() throws { let suiteName = "watch-chat-recent-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let coordinator = WatchMessageOutbox(defaults: defaults) for index in 0..<140 { let event = WatchAppCommandEvent( commandId: "watch-forward-\(index)", command: .sendChat, sessionKey: "main", gatewayStableID: nil, text: "Message \(index)", sentAtMs: Int64(index), transport: "sendMessage") if case .forward = coordinator.ingest( event, isAvailable: true, gatewayStableID: "gateway-a") { coordinator.removeQueuedMessage( messageID: event.commandId, gatewayStableID: "gateway-a") } else { Issue.record("expected forwarded command \(index)") } } let oldestEvent = WatchAppCommandEvent( commandId: "watch-forward-0", command: .sendChat, sessionKey: "main", gatewayStableID: nil, text: "Message 0 again", sentAtMs: 999, transport: "sendMessage") if case .forward = coordinator.ingest( oldestEvent, isAvailable: true, gatewayStableID: "gateway-a") { } else { Issue.record("expected oldest forwarded command to age out of dedupe") } let recentEvent = WatchAppCommandEvent( commandId: "watch-forward-139", command: .sendChat, sessionKey: "main", gatewayStableID: nil, text: "Message 139 again", sentAtMs: 1000, transport: "sendMessage") if case .deduped = coordinator.ingest( recentEvent, isAvailable: true, gatewayStableID: "gateway-a") { } else { Issue.record("expected recent forwarded command to stay deduped") } } @Test @MainActor func `watch chat command deduping keeps delivered queued commands recent`() throws { let suiteName = "watch-chat-delivered-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let coordinator = WatchMessageOutbox(defaults: defaults) for index in 0..<140 { let event = WatchAppCommandEvent( commandId: "watch-queued-\(index)", command: .sendChat, sessionKey: "main", gatewayStableID: nil, text: "Queued \(index)", sentAtMs: Int64(index), transport: "transferUserInfo") if case .queue = coordinator.ingest( event, isAvailable: false, gatewayStableID: "gateway-a") { } else { Issue.record("expected queued command \(index)") } } coordinator.removeQueuedMessage( messageID: "watch-queued-0", gatewayStableID: "gateway-a") let duplicateDeliveredEvent = WatchAppCommandEvent( commandId: "watch-queued-0", command: .sendChat, sessionKey: "main", gatewayStableID: nil, text: "Duplicate after delivery", sentAtMs: 999, transport: "transferUserInfo") if case .deduped = coordinator.ingest( duplicateDeliveredEvent, isAvailable: true, gatewayStableID: "gateway-a") { } else { Issue.record("expected delivered queued command to stay deduped") } } @Test @MainActor func `pending watch recovery I ds are included without delivered notifications`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-recovery") let ids = await appModel._test_pendingExecApprovalIDsForWatchRecovery() #expect(ids == ["approval-watch-recovery"]) } @Test @MainActor func `delivered approval becomes durable watch recovery`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let notificationCenter = MockBootstrapNotificationCenter() notificationCenter.delivered = [NotificationSnapshot( identifier: "delivered-approval", userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "approval-delivered-recovery", "gatewayDeviceId": "gateway-device-a", ], ])] let firstModel = NodeAppModel(notificationCenter: notificationCenter) #expect(await firstModel._test_pendingExecApprovalIDsForWatchRecovery() == [ "approval-delivered-recovery", ]) #expect(firstModel._test_pendingWatchExecApprovalRecoveryIDs() == [ "approval-delivered-recovery", ]) let restoredModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) #expect(restoredModel._test_pendingWatchExecApprovalRecoveryIDs() == [ "approval-delivered-recovery", ]) } @Test @MainActor func `route prompt cannot clear ownerful push recovery`() throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-clear") #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"]) try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-watch-clear", commandText: "echo clear", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: nil, expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000))) #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"]) } @Test func `approval notification error classification prefers structured details`() { let staleError = GatewayResponseError( method: "exec.approval.get", code: "INVALID_REQUEST", message: "gateway error", details: ["reason": AnyCodable("APPROVAL_NOT_FOUND")]) let unavailableError = GatewayResponseError( method: "exec.approval.resolve", code: "INVALID_REQUEST", message: "gateway error", details: ["reason": AnyCodable("APPROVAL_ALLOW_ALWAYS_UNAVAILABLE")]) #expect(NodeAppModel._test_isApprovalNotificationStaleError(staleError)) #expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError)) } @Test func `background aware exec approval reconnect covers watch and push paths`() { #expect( NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: "watch_request", isBackgrounded: true)) #expect( NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: "push_request", isBackgrounded: true)) #expect( NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: "watch_resolve", isBackgrounded: true)) #expect( !NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: "direct", isBackgrounded: true)) #expect( !NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: "watch_request", isBackgrounded: false)) } @Test func `exec approval event ID decodes gateway payload`() { #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1") #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil) #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil) } @Test @MainActor func `operator gateway resolved event leaves unvalidated push recovery`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let notificationCenter = MockBootstrapNotificationCenter() notificationCenter.delivered = [NotificationSnapshot( identifier: "approval-event-notification", userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "approval-event-resolved", "gatewayDeviceId": "gateway-device-a", ], ])] let appModel = NodeAppModel(notificationCenter: notificationCenter) appModel._test_recordPendingWatchExecApprovalRecoveryID( "approval-event-resolved", gatewayDeviceId: "gateway-device-a") try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( id: "approval-event-resolved", commandText: "echo clear", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: nil, expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000))) await appModel._test_handleOperatorGatewayServerEvent(EventFrame( type: "event", event: ExecApprovalNotificationBridge.resolvedKind, payload: AnyCodable(["id": "approval-event-resolved"]), seq: nil, stateversion: nil)) #expect(appModel._test_pendingExecApprovalPrompt() == nil) #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-event-resolved"]) #expect(!notificationCenter.deliveredRemovedIdentifiers.contains([ "approval-event-notification", ])) } @Test @MainActor func `validated resolved push clears only its gateway recovery`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) appModel._test_setConnectedGatewayID("gateway-a") let gatewayA = ExecApprovalNotificationPrompt( approvalId: "shared-approval-id", gatewayDeviceId: "gateway-device-a") let gatewayB = ExecApprovalNotificationPrompt( approvalId: "shared-approval-id", gatewayDeviceId: "gateway-device-b") appModel._test_recordPendingWatchExecApprovalRecoveryID( gatewayA.approvalId, gatewayDeviceId: "gateway-device-a") appModel._test_recordPendingWatchExecApprovalRecoveryID( gatewayB.approvalId, gatewayDeviceId: "gateway-device-b") await appModel._test_handleExecApprovalResolvedForCurrentGateway( approvalId: gatewayA.approvalId, recoveryPushGatewayDeviceID: gatewayA.gatewayDeviceId) #expect(appModel._test_pendingWatchExecApprovalRecoveryPushes() == [gatewayB]) } @Test func `watch exec approval hydrate fetches only missing I ds`() { let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch( candidateIDs: ["cached", "pending", "cached", "other", "", " pending "], cachedApprovalIDs: ["cached", "also-cached"]) #expect(idsToFetch == ["pending", "other"]) } @Test func `watch exec approval retry prompt resets resolving state only for retry reason`() { #expect(NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "resolve_retry")) #expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "push_request")) #expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "present_prompt")) } @Test func `operator loop waits for bootstrap handoff before using stored token`() { #expect( !NodeAppModel._test_shouldStartOperatorGatewayLoop( token: nil, bootstrapToken: "fresh-bootstrap-token", password: nil, hasStoredOperatorToken: true)) #expect( !NodeAppModel._test_shouldStartOperatorGatewayLoop( token: nil, bootstrapToken: nil, password: nil, hasStoredOperatorToken: false)) #expect( NodeAppModel._test_shouldStartOperatorGatewayLoop( token: nil, bootstrapToken: nil, password: nil, hasStoredOperatorToken: true)) #expect( NodeAppModel._test_shouldStartOperatorGatewayLoop( token: "shared-token", bootstrapToken: "fresh-bootstrap-token", password: nil, hasStoredOperatorToken: false)) } @Test func `credential handoff is required only for bootstrap authentication`() { #expect(NodeAppModel._test_usesBootstrapCredential( token: nil, bootstrapToken: "fresh-bootstrap-token", password: nil)) #expect(!NodeAppModel._test_usesBootstrapCredential( token: "shared-token", bootstrapToken: "fresh-bootstrap-token", password: nil)) #expect(!NodeAppModel._test_usesBootstrapCredential( token: nil, bootstrapToken: "fresh-bootstrap-token", password: "shared-password")) #expect(!NodeAppModel._test_usesBootstrapCredential( token: nil, bootstrapToken: nil, password: nil)) } @Test @MainActor func `operator gateway requested event shows notification guidance when notifications off`() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined let appModel = NodeAppModel(notificationCenter: center) appModel._test_resetExecApprovalNotificationGuidanceSuppression() defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() } await appModel._test_handleOperatorGatewayServerEvent(EventFrame( type: "event", event: ExecApprovalNotificationBridge.requestedKind, payload: AnyCodable(["id": "approval-notifications-off"]), seq: nil, stateversion: nil)) let prompt = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt()) #expect(prompt.approvalId == "approval-notifications-off") } @Test @MainActor func `stale operator event cannot mutate approval UI after suspension`() async { let center = MockBootstrapNotificationCenter() let authorizationGate = NotificationAuthorizationGate() center.authorizationStatusHandler = { await authorizationGate.wait() } let appModel = NodeAppModel(notificationCenter: center) appModel._test_resetExecApprovalNotificationGuidanceSuppression() defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() } var routeIsCurrent = true let event = EventFrame( type: "event", event: ExecApprovalNotificationBridge.requestedKind, payload: AnyCodable(["id": "approval-stale-route"]), seq: nil, stateversion: nil) let handling = Task { @MainActor in await appModel._test_handleOperatorGatewayServerEvent( event, shouldContinue: { routeIsCurrent }) } let deadline = ContinuousClock().now.advanced(by: .seconds(2)) while await !(authorizationGate.hasStarted()), ContinuousClock().now < deadline { await Task.yield() } routeIsCurrent = false await authorizationGate.resume(returning: .denied) await handling.value #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) #expect(appModel._test_pendingExecApprovalPrompt() == nil) } @Test @MainActor func `suppressed operator gateway requested event does not show notification guidance`() async { let center = MockBootstrapNotificationCenter() center.status = .denied let appModel = NodeAppModel(notificationCenter: center) appModel._test_resetExecApprovalNotificationGuidanceSuppression() defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() } appModel.dismissNotificationPermissionGuidancePrompt(suppressFuture: true) await appModel._test_handleOperatorGatewayServerEvent(EventFrame( type: "event", event: ExecApprovalNotificationBridge.requestedKind, payload: AnyCodable(["id": "approval-suppressed"]), seq: nil, stateversion: nil)) #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) } @Test @MainActor func `operator gateway resolved event clears notification guidance prompt`() async throws { let center = MockBootstrapNotificationCenter() center.status = .denied let appModel = NodeAppModel(notificationCenter: center) appModel._test_resetExecApprovalNotificationGuidanceSuppression() defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() } await appModel._test_handleOperatorGatewayServerEvent(EventFrame( type: "event", event: ExecApprovalNotificationBridge.requestedKind, payload: AnyCodable(["id": "approval-guidance-resolved"]), seq: nil, stateversion: nil)) _ = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt()) await appModel._test_handleOperatorGatewayServerEvent(EventFrame( type: "event", event: ExecApprovalNotificationBridge.resolvedKind, payload: AnyCodable(["id": "approval-guidance-resolved"]), seq: nil, stateversion: nil)) #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) } @Test @MainActor func `handle invoke rejects background commands`() async { let appModel = NodeAppModel() appModel.setScenePhase(.background) let req = BridgeInvokeRequest(id: "bg", command: OpenClawCanvasCommand.present.rawValue) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .backgroundUnavailable) let talk = await appModel._test_handleInvoke(talkRequest(id: "bg-talk", command: .pttStart)) #expect(talk.ok == false) #expect(talk.error?.message.contains("/talk") == true) } @Test @MainActor func `handle invoke rejects camera when disabled`() async { let appModel = NodeAppModel() let req = BridgeInvokeRequest(id: "cam", command: OpenClawCameraCommand.snap.rawValue) let defaults = UserDefaults.standard let key = "camera.enabled" let previous = defaults.object(forKey: key) defaults.set(false, forKey: key) defer { if let previous { defaults.set(previous, forKey: key) } else { defaults.removeObject(forKey: key) } } let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .unavailable) #expect(res.error?.message.contains("CAMERA_DISABLED") == true) } @Test @MainActor func `cancelled camera invoke clears progress HUD`() async { let defaults = UserDefaults.standard let key = "camera.enabled" let previous = defaults.object(forKey: key) defaults.set(true, forKey: key) defer { if let previous { defaults.set(previous, forKey: key) } else { defaults.removeObject(forKey: key) } } let appModel = NodeAppModel(camera: CancellingCameraService()) let request = BridgeInvokeRequest(id: "cancelled-camera", command: OpenClawCameraCommand.snap.rawValue) let response = await appModel._test_handleInvoke(request) #expect(response.ok == false) #expect(response.error?.code == .unavailable) #expect(response.error?.message == "node invoke cancelled") #expect(appModel.cameraHUDText == nil) #expect(appModel.cameraHUDKind == nil) } @Test @MainActor func `older cancelled camera invoke preserves newer HUD`() async { let defaults = UserDefaults.standard let key = "camera.enabled" let previous = defaults.object(forKey: key) defaults.set(true, forKey: key) defer { if let previous { defaults.set(previous, forKey: key) } else { defaults.removeObject(forKey: key) } } let firstStarted = AsyncStream.makeStream() let secondStarted = AsyncStream.makeStream() let camera = OverlappingCameraService( firstStarted: firstStarted.continuation, secondStarted: secondStarted.continuation) let appModel = NodeAppModel(camera: camera) let firstTask = Task { await appModel._test_handleInvoke( BridgeInvokeRequest(id: "camera-first", command: OpenClawCameraCommand.snap.rawValue)) } for await _ in firstStarted.stream { break } let secondTask = Task { await appModel._test_handleInvoke( BridgeInvokeRequest(id: "camera-second", command: OpenClawCameraCommand.snap.rawValue)) } for await _ in secondStarted.stream { break } await camera.releaseFirst() let firstResponse = await firstTask.value #expect(firstResponse.error?.message == "node invoke cancelled") #expect(appModel.cameraHUDText == "Taking photo…") await camera.releaseSecond() let secondResponse = await secondTask.value #expect(secondResponse.ok) #expect(appModel.cameraHUDText == "Photo captured") } @Test @MainActor func `system notify returns unavailable when notifications off`() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined let appModel = NodeAppModel(notificationCenter: center) let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request") let paramsData = try JSONEncoder().encode(params) let req = BridgeInvokeRequest( id: "notify-off", command: OpenClawSystemCommand.notify.rawValue, paramsJSON: String(decoding: paramsData, as: UTF8.self)) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .unavailable) #expect(res.error?.message == "NOT_AUTHORIZED: notifications") #expect(center.addCalls == 0) } @Test @MainActor func `system notify schedules when notifications are already allowed`() async throws { let restorePreference = overrideNotificationServingPreference(true) defer { restorePreference() } let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request") let paramsData = try JSONEncoder().encode(params) let req = BridgeInvokeRequest( id: "notify-on", command: OpenClawSystemCommand.notify.rawValue, paramsJSON: String(decoding: paramsData, as: UTF8.self)) let res = await appModel._test_handleInvoke(req) #expect(res.ok) #expect(center.addCalls == 1) } @Test @MainActor func `system notify respects app notification opt out`() async throws { let restorePreference = overrideNotificationServingPreference(false) defer { restorePreference() } let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request") let paramsData = try JSONEncoder().encode(params) let req = BridgeInvokeRequest( id: "notify-disabled", command: OpenClawSystemCommand.notify.rawValue, paramsJSON: String(decoding: paramsData, as: UTF8.self)) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .unavailable) #expect(res.error?.message == "NOT_AUTHORIZED: notifications") #expect(center.addCalls == 0) } @Test @MainActor func `apns registration requires notification authorization and relay disclosure`() async { let restorePreference = overrideNotificationServingPreference(true) defer { restorePreference() } let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) PushEnrollmentConsent.reset() defer { PushEnrollmentConsent.reset() } #expect(await appModel._test_canPublishAPNsRegistration() == false) #expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false)) PushEnrollmentConsent.markDisclosureAccepted() center.status = .notDetermined #expect(await appModel._test_canPublishAPNsRegistration() == false) center.status = .authorized #expect(await appModel._test_canPublishAPNsRegistration()) UserDefaults.standard.set(false, forKey: NotificationServingPreference.storageKey) #expect(await appModel._test_canPublishAPNsRegistration() == false) } @Test @MainActor func `chat push without speech returns unavailable when notifications off`() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined let appModel = NodeAppModel(notificationCenter: center) let params = OpenClawChatPushParams(text: "Build finished", speak: false) let paramsData = try JSONEncoder().encode(params) let req = BridgeInvokeRequest( id: "chat-push-off", command: OpenClawChatCommand.push.rawValue, paramsJSON: String(decoding: paramsData, as: UTF8.self)) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .unavailable) #expect(res.error?.message == "NOT_AUTHORIZED: notifications") #expect(center.addCalls == 0) } @Test @MainActor func `chat push schedules when notifications are already allowed`() async throws { let restorePreference = overrideNotificationServingPreference(true) defer { restorePreference() } let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) let params = OpenClawChatPushParams(text: "Build finished", speak: false) let paramsData = try JSONEncoder().encode(params) let req = BridgeInvokeRequest( id: "chat-push-on", command: OpenClawChatCommand.push.rawValue, paramsJSON: String(decoding: paramsData, as: UTF8.self)) let res = await appModel._test_handleInvoke(req) #expect(res.ok) #expect(center.addCalls == 1) } @Test @MainActor func `handle invoke rejects invalid screen format`() async { let appModel = NodeAppModel() let params = OpenClawScreenRecordParams(format: "gif") let data = try? JSONEncoder().encode(params) let json = data.flatMap { String(data: $0, encoding: .utf8) } let req = BridgeInvokeRequest( id: "screen", command: OpenClawScreenCommand.record.rawValue, paramsJSON: json) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.message.contains("screen format must be mp4") == true) } @Test @MainActor func `handle invoke canvas commands update screen`() async throws { let appModel = NodeAppModel() let coordinator = try mountScreen(appModel.screen) defer { coordinator.teardown() } appModel.screen.navigate(to: "http://example.com") let present = BridgeInvokeRequest(id: "present", command: OpenClawCanvasCommand.present.rawValue) let presentRes = await appModel._test_handleInvoke(present) #expect(presentRes.ok == true) #expect(appModel.screen.urlString.isEmpty) // Loopback URLs are rejected (they are not meaningful for a remote gateway). let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") let navData = try JSONEncoder().encode(navigateParams) let navJSON = String(decoding: navData, as: UTF8.self) let navigate = BridgeInvokeRequest( id: "nav", command: OpenClawCanvasCommand.navigate.rawValue, paramsJSON: navJSON) let navRes = await appModel._test_handleInvoke(navigate) #expect(navRes.ok == true) #expect(appModel.screen.urlString == "http://example.com/") let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1") let evalData = try JSONEncoder().encode(evalParams) let evalJSON = String(decoding: evalData, as: UTF8.self) let eval = BridgeInvokeRequest( id: "eval", command: OpenClawCanvasCommand.evalJS.rawValue, paramsJSON: evalJSON) var evalRes = await appModel._test_handleInvoke(eval) let deadline = ContinuousClock().now.advanced(by: .seconds(3)) while evalRes.ok != true, ContinuousClock().now < deadline { try? await Task.sleep(nanoseconds: 100_000_000) evalRes = await appModel._test_handleInvoke(eval) } #expect(evalRes.ok == true) let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8)) let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] #expect(payload?["result"] as? String == "2") } @Test @MainActor func `pending foreground actions replay canvas navigate`() async throws { let appModel = NodeAppModel() let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") let navData = try JSONEncoder().encode(navigateParams) let navJSON = String(decoding: navData, as: UTF8.self) await appModel._test_applyPendingForegroundNodeActions([ ( id: "pending-nav-1", command: OpenClawCanvasCommand.navigate.rawValue, paramsJSON: navJSON), ]) #expect(appModel.screen.urlString == "http://example.com/") } @Test @MainActor func `pending foreground actions do not apply while backgrounded`() async throws { let appModel = NodeAppModel() appModel.setScenePhase(.background) let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") let navData = try JSONEncoder().encode(navigateParams) let navJSON = String(decoding: navData, as: UTF8.self) await appModel._test_applyPendingForegroundNodeActions([ ( id: "pending-nav-bg", command: OpenClawCanvasCommand.navigate.rawValue, paramsJSON: navJSON), ]) #expect(appModel.screen.urlString.isEmpty) } @Test @MainActor func `handle invoke A 2 UI commands fail when local host unavailable`() async throws { let appModel = NodeAppModel() let reset = BridgeInvokeRequest(id: "reset", command: OpenClawCanvasA2UICommand.reset.rawValue) let resetRes = await appModel._test_handleInvoke(reset) #expect(resetRes.ok == false) #expect(resetRes.error?.message.contains("A2UI_HOST_UNAVAILABLE") == true) let jsonl = "{\"beginRendering\":{}}" let pushParams = OpenClawCanvasA2UIPushJSONLParams(jsonl: jsonl) let pushData = try JSONEncoder().encode(pushParams) let pushJSON = String(decoding: pushData, as: UTF8.self) let push = BridgeInvokeRequest( id: "push", command: OpenClawCanvasA2UICommand.pushJSONL.rawValue, paramsJSON: pushJSON) let pushRes = await appModel._test_handleInvoke(push) #expect(pushRes.ok == false) #expect(pushRes.error?.message.contains("A2UI_HOST_UNAVAILABLE") == true) } @Test @MainActor func `handle invoke unknown command returns invalid request`() async { let appModel = NodeAppModel() let req = BridgeInvokeRequest(id: "unknown", command: "nope") let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .invalidRequest) } @Test @MainActor func `handle invoke watch status returns service snapshot`() async throws { let watchService = MockWatchMessagingService() watchService.currentStatus = WatchMessagingStatus( supported: true, paired: true, appInstalled: true, reachable: false, activationState: "inactive") let appModel = NodeAppModel(watchMessagingService: watchService) let req = BridgeInvokeRequest(id: "watch-status", command: OpenClawWatchCommand.status.rawValue) let res = await appModel._test_handleInvoke(req) #expect(res.ok == true) let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) let payload = try JSONDecoder().decode(OpenClawWatchStatusPayload.self, from: payloadData) #expect(payload.supported == true) #expect(payload.reachable == false) #expect(payload.activationState == "inactive") } @Test @MainActor func `watch status refresh publishes service snapshot`() async { let watchService = MockWatchMessagingService() let status = WatchMessagingStatus( supported: true, paired: true, appInstalled: true, reachable: false, activationState: "activated") watchService.currentStatus = status let appModel = NodeAppModel(watchMessagingService: watchService) await appModel.refreshWatchMessagingStatus() #expect(appModel.watchMessagingStatus == status) } @Test @MainActor func `watch status callback publishes reachability changes`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let status = WatchMessagingStatus( supported: true, paired: true, appInstalled: true, reachable: true, activationState: "activated") watchService.emitStatus(status) await waitForMainActorWork { appModel.watchMessagingStatus == status } #expect(appModel.watchMessagingStatus == status) } @Test @MainActor func `handle invoke watch notify routes to watch service`() async throws { let watchService = MockWatchMessagingService() watchService.nextSendResult = WatchNotificationSendResult( deliveredImmediately: false, queuedForDelivery: true, transport: "transferUserInfo") let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setConnectedGatewayID("gateway-watch-notify") let params = OpenClawWatchNotifyParams( title: "OpenClaw", body: "Meeting with Peter is at 4pm", priority: .timeSensitive) let paramsData = try JSONEncoder().encode(params) let paramsJSON = String(decoding: paramsData, as: UTF8.self) let req = BridgeInvokeRequest( id: "watch-notify", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) let res = await appModel._test_handleInvoke(req, gatewayStableID: "gateway-a") #expect(res.ok == true) #expect(watchService.lastSent?.params.title == "OpenClaw") #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") #expect(watchService.lastSent?.params.priority == .timeSensitive) #expect(watchService.lastSent?.gatewayStableID == "gateway-watch-notify") let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) #expect(payload.deliveredImmediately == false) #expect(payload.queuedForDelivery == true) #expect(payload.transport == "transferUserInfo") } @Test @MainActor func `watch reply codec preserves prompt gateway owner`() throws { let params = OpenClawWatchNotifyParams( title: "Approval", body: "Allow?", promptId: "prompt-a", sessionKey: "ios-a", gatewayStableID: "gateway-a") let notification = WatchMessagingPayloadCodec.encodeNotificationPayload( id: "notification-a", params: params, gatewayStableID: "gateway-a") #expect(notification["gatewayStableID"] as? String == "gateway-a") let reply = try #require(WatchMessagingPayloadCodec.parseQuickReplyPayload([ "type": OpenClawWatchPayloadType.reply.rawValue, "replyId": "reply-a", "promptId": "prompt-a", "actionId": "approve", "gatewayStableID": "gateway-a", ], transport: "sendMessage")) #expect(reply.gatewayStableID == "gateway-a") } @Test @MainActor func `watch exec approval codec preserves gateway owner`() throws { let approval = OpenClawWatchExecApprovalItem( id: "approval-a", gatewayStableID: "gateway-a", commandText: "echo safe", allowedDecisions: [.allowOnce, .deny]) let prompt = WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload( OpenClawWatchExecApprovalPromptMessage(approval: approval)) let encodedApproval = try #require(prompt["approval"] as? [String: Any]) #expect(encodedApproval["gatewayStableID"] as? String == "gateway-a") let reply = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([ "type": OpenClawWatchPayloadType.execApprovalResolve.rawValue, "replyId": "reply-a", "approvalId": "approval-a", "gatewayStableID": "gateway-a", "decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue, ], transport: "sendMessage")) #expect(reply.gatewayStableID == "gateway-a") let resolved = WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload( OpenClawWatchExecApprovalResolvedMessage( approvalId: "approval-a", gatewayStableID: "gateway-a")) let expired = WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload( OpenClawWatchExecApprovalExpiredMessage( approvalId: "approval-a", gatewayStableID: "gateway-a", reason: .notFound)) #expect(resolved["gatewayStableID"] as? String == "gateway-a") #expect(expired["gatewayStableID"] as? String == "gateway-a") } @Test @MainActor func `watch direct node setup codec carries opaque setup code`() { let payload = WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload( setupCode: "opaque-bootstrap-code") #expect(payload["type"] as? String == OpenClawWatchPayloadType.directNodeSetup.rawValue) #expect(payload["setupCode"] as? String == "opaque-bootstrap-code") #expect(payload["sentAtMs"] is Int64) #expect(payload["token"] == nil) #expect(payload["password"] == nil) } @Test @MainActor func `watch payload codec preserves 64 bit epoch milliseconds`() throws { let sentAtMs: Int64 = 1_725_000_000_123 let encodedTimestamp = NSNumber(value: sentAtMs) let reply = try #require(WatchMessagingPayloadCodec.parseQuickReplyPayload([ "type": OpenClawWatchPayloadType.reply.rawValue, "actionId": "approve", "sentAtMs": encodedTimestamp, ], transport: "sendMessage")) let resolution = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([ "type": OpenClawWatchPayloadType.execApprovalResolve.rawValue, "approvalId": "approval-a", "decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue, "sentAtMs": encodedTimestamp, ], transport: "sendMessage")) let approvalSnapshotRequest = try #require( WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, "sentAtMs": encodedTimestamp, ], transport: "sendMessage")) let appSnapshotRequest = try #require(WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload([ "type": OpenClawWatchPayloadType.appSnapshotRequest.rawValue, "sentAtMs": encodedTimestamp, ], transport: "sendMessage")) let appCommand = try #require(WatchMessagingPayloadCodec.parseAppCommandPayload([ "type": OpenClawWatchPayloadType.appCommand.rawValue, "command": OpenClawWatchAppCommand.refresh.rawValue, "sentAtMs": encodedTimestamp, ], transport: "sendMessage")) #expect(reply.sentAtMs == sentAtMs) #expect(resolution.sentAtMs == sentAtMs) #expect(approvalSnapshotRequest.sentAtMs == sentAtMs) #expect(appSnapshotRequest.sentAtMs == sentAtMs) #expect(appCommand.sentAtMs == sentAtMs) } @Test @MainActor func `watch application context retains app and approval snapshots`() throws { let appPayload = WatchMessagingPayloadCodec.encodeAppSnapshotPayload( OpenClawWatchAppSnapshotMessage( gatewayStatusText: "Connected", gatewayConnected: true, agentName: "Main", sessionKey: "main", gatewayStableID: "gateway-a", talkStatusText: "Off", talkEnabled: false, talkListening: false, talkSpeaking: false, pendingApprovalCount: 1, snapshotId: "app-a")) let approvalPayload = WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload( OpenClawWatchExecApprovalSnapshotMessage( approvals: [ OpenClawWatchExecApprovalItem( id: "approval-a", gatewayStableID: "gateway-a", commandText: "echo safe", allowedDecisions: [.allowOnce, .deny]), ], gatewayStableID: "gateway-a", snapshotId: "approval-a")) let appContext = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext( appPayload, merging: [:]) let combined = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext( approvalPayload, merging: appContext) #expect(combined["type"] as? String == OpenClawWatchPayloadType.execApprovalSnapshot.rawValue) let nestedApp = try #require( combined[OpenClawWatchPayloadType.appSnapshot.rawValue] as? [String: Any]) let nestedApprovals = try #require( combined[OpenClawWatchPayloadType.execApprovalSnapshot.rawValue] as? [String: Any]) #expect(nestedApp["gatewayStableID"] as? String == "gateway-a") #expect(nestedApp["snapshotId"] as? String == "app-a") #expect(nestedApprovals["snapshotId"] as? String == "approval-a") #expect(nestedApprovals["gatewayStableID"] as? String == "gateway-a") #expect((nestedApprovals["approvals"] as? [Any])?.count == 1) } @Test @MainActor func `handle invoke watch notify rejects empty message`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams(title: " ", body: "\n") let paramsData = try JSONEncoder().encode(params) let paramsJSON = String(decoding: paramsData, as: UTF8.self) let req = BridgeInvokeRequest( id: "watch-notify-empty", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .invalidRequest) #expect(watchService.lastSent == nil) } @Test @MainActor func `handle invoke watch notify adds default actions for prompt`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams( title: "Task", body: "Action needed", priority: .passive, promptId: "prompt-123") let paramsData = try JSONEncoder().encode(params) let paramsJSON = String(decoding: paramsData, as: UTF8.self) let req = BridgeInvokeRequest( id: "watch-notify-default-actions", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) let res = await appModel._test_handleInvoke(req) #expect(res.ok == true) #expect(watchService.lastSent?.params.risk == .low) let actionIDs = watchService.lastSent?.params.actions?.map(\.id) #expect(actionIDs == ["done", "snooze_10m", "open_phone", "escalate"]) } @Test @MainActor func `legacy watch reply binds to latest prompt owner`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setConnectedGatewayID("gateway-a") let params = OpenClawWatchNotifyParams( title: "Task", body: "Action needed", promptId: "prompt-legacy") let paramsJSON = try String(decoding: JSONEncoder().encode(params), as: UTF8.self) let request = BridgeInvokeRequest( id: "watch-notify-legacy-owner", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) #expect(await appModel._test_handleInvoke(request, gatewayStableID: "gateway-a").ok) watchService.emitReply(WatchQuickReplyEvent( replyId: "legacy-reply", promptId: "prompt-legacy", actionId: "done", actionLabel: "Done", sessionKey: nil, gatewayStableID: nil, note: nil, sentAtMs: 1234, transport: "transferUserInfo")) await Task.yield() #expect(appModel._test_queuedWatchReplyCount() == 1) } @Test @MainActor func `handle invoke watch notify adds approval defaults`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams( title: "Approval", body: "Allow command?", promptId: "prompt-approval", kind: "approval") let paramsData = try JSONEncoder().encode(params) let paramsJSON = String(decoding: paramsData, as: UTF8.self) let req = BridgeInvokeRequest( id: "watch-notify-approval-defaults", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) let res = await appModel._test_handleInvoke(req) #expect(res.ok == true) let actionIDs = watchService.lastSent?.params.actions?.map(\.id) #expect(actionIDs == ["approve", "decline", "open_phone", "escalate"]) #expect(watchService.lastSent?.params.actions?[1].style == "destructive") } @Test @MainActor func `handle invoke watch notify derives priority from risk and caps actions`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams( title: "Urgent", body: "Check now", risk: .high, actions: [ OpenClawWatchAction(id: "a1", label: "A1"), OpenClawWatchAction(id: "a2", label: "A2"), OpenClawWatchAction(id: "a3", label: "A3"), OpenClawWatchAction(id: "a4", label: "A4"), OpenClawWatchAction(id: "a5", label: "A5"), ]) let paramsData = try JSONEncoder().encode(params) let paramsJSON = String(decoding: paramsData, as: UTF8.self) let req = BridgeInvokeRequest( id: "watch-notify-derive-priority", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) let res = await appModel._test_handleInvoke(req) #expect(res.ok == true) #expect(watchService.lastSent?.params.priority == .timeSensitive) #expect(watchService.lastSent?.params.risk == .high) let actionIDs = watchService.lastSent?.params.actions?.map(\.id) #expect(actionIDs == ["a1", "a2", "a3", "a4"]) } @Test @MainActor func `handle invoke watch notify returns unavailable on delivery failure`() async throws { let watchService = MockWatchMessagingService() watchService.sendError = NSError( domain: "watch", code: 1, userInfo: [NSLocalizedDescriptionKey: "WATCH_UNAVAILABLE: no paired Apple Watch"]) let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams(title: "OpenClaw", body: "Delivery check") let paramsData = try JSONEncoder().encode(params) let paramsJSON = String(decoding: paramsData, as: UTF8.self) let req = BridgeInvokeRequest( id: "watch-notify-fail", command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) let res = await appModel._test_handleInvoke(req) #expect(res.ok == false) #expect(res.error?.code == .unavailable) #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) } @Test @MainActor func `watch reply queues when gateway offline`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setConnectedGatewayID("gateway-watch-reply") watchService.emitReply( WatchQuickReplyEvent( replyId: "reply-offline-1", promptId: "prompt-1", actionId: "approve", actionLabel: "Approve", sessionKey: "ios", gatewayStableID: "gateway-watch-reply", note: nil, sentAtMs: 1234, transport: "transferUserInfo")) await Task.yield() #expect(appModel._test_queuedWatchReplyCount() == 1) } @Test @MainActor func `watch message outbox restores queued reply after restart`() throws { let suiteName = "watch-reply-queue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let event = WatchAppCommandEvent( commandId: "reply-restore-1", command: .sendChat, sessionKey: "ios", gatewayStableID: "gateway-a", text: "Watch reply: Approve", sentAtMs: 1235, transport: "transferUserInfo", messageKind: .quickReply) let firstOutbox = WatchMessageOutbox(defaults: defaults) if case .queue = firstOutbox.ingest(event, isAvailable: false, gatewayStableID: "gateway-a") { } else { Issue.record("expected watch reply to queue") } let secondOutbox = WatchMessageOutbox(defaults: defaults) #expect(secondOutbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil) let restored = secondOutbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") #expect(restored == event) #expect(secondOutbox.queuedCount(kind: .quickReply) == 1) secondOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a") #expect(secondOutbox.queuedCount() == 0) } @Test @MainActor func `watch message outbox restores delivery tombstones and prompt routes`() throws { let suiteName = "watch-message-metadata-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { defaults.removePersistentDomain(forName: suiteName) } let event = WatchAppCommandEvent( commandId: "delivered-reply", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-a", text: "Delivered reply", sentAtMs: 1, transport: "sendMessage", messageKind: .quickReply) let firstOutbox = WatchMessageOutbox(defaults: defaults) firstOutbox.recordPromptRoute(promptID: "prompt-a", gatewayStableID: "gateway-a") _ = firstOutbox.ingest(event, isAvailable: true, gatewayStableID: "gateway-a") firstOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a") for index in 0..<140 { let pending = WatchAppCommandEvent( commandId: "pending-\(index)", command: .sendChat, sessionKey: "main", gatewayStableID: "gateway-a", text: "Pending \(index)", sentAtMs: Int64(index + 2), transport: "transferUserInfo") _ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a") } let restoredOutbox = WatchMessageOutbox(defaults: defaults) #expect(restoredOutbox.gatewayStableID(forPromptID: "prompt-a") == "gateway-a") if case .deduped = restoredOutbox.ingest( event, isAvailable: true, gatewayStableID: "gateway-a") { } else { Issue.record("expected delivered reply to remain deduped after restart") } } @Test @MainActor func `watch reply drops stale gateway target`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setConnectedGatewayID("gateway-current") watchService.emitReply( WatchQuickReplyEvent( replyId: "reply-stale-gateway", promptId: "prompt-stale", actionId: "approve", actionLabel: "Approve", sessionKey: "ios", gatewayStableID: "gateway-old", note: nil, sentAtMs: 1236, transport: "transferUserInfo")) await Task.yield() #expect(appModel._test_queuedWatchReplyCount() == 0) #expect(appModel.openChatRequestID == 0) } @Test @MainActor func `watch reply uses idempotent chat outbox`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel.enterAppleReviewDemoMode() appModel._test_recordWatchPromptRoute( promptID: "prompt-idempotent", gatewayStableID: AppleReviewDemoMode.gatewayID) let initialOpenChatRequestID = appModel.openChatRequestID let event = WatchQuickReplyEvent( replyId: "reply-idempotent", promptId: "prompt-idempotent", actionId: "approve", actionLabel: "Approve", sessionKey: "main", gatewayStableID: nil, note: nil, sentAtMs: 1237, transport: "sendMessage") watchService.emitReply(event) await waitForMainActorWork { appModel.openChatRequestID == initialOpenChatRequestID + 1 } watchService.emitReply(event) await waitForMainActorWork { appModel._test_queuedWatchReplyCount() == 0 } #expect(appModel.openChatRequestID == initialOpenChatRequestID + 1) #expect(appModel._test_queuedWatchReplyCount() == 0) } @Test @MainActor func `watch reply rejects legacy prompt without a gateway owner`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel.enterAppleReviewDemoMode() let initialOpenChatRequestID = appModel.openChatRequestID let event = WatchQuickReplyEvent( replyId: "reply-legacy-prompt", promptId: "prompt-from-previous-release", actionId: "approve", actionLabel: "Approve", sessionKey: "main", gatewayStableID: nil, note: nil, sentAtMs: 1238, transport: "sendMessage") watchService.emitReply(event) await Task.yield() watchService.emitReply(event) await Task.yield() #expect(appModel.openChatRequestID == initialOpenChatRequestID) #expect(appModel._test_queuedWatchReplyCount() == 0) } @Test @MainActor func `handle deep link sets error when not connected`() async throws { let appModel = NodeAppModel() let url = try #require(URL(string: "openclaw://agent?message=hello")) await appModel.handleDeepLink(url: url) #expect(appModel.screen.errorText?.contains("Gateway not connected") == true) } @Test @MainActor func `handle deep link rejects oversized message`() async throws { let appModel = NodeAppModel() let msg = String(repeating: "a", count: 20001) let url = try #require(URL(string: "openclaw://agent?message=\(msg)")) await appModel.handleDeepLink(url: url) #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } @Test @MainActor func `handle deep link requires confirmation when connected and unkeyed`() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) appModel._test_setAgentRequestHandler { _ in } let url = makeAgentDeepLinkURL(message: "hello from deep link") await appModel.handleDeepLink(url: url) #expect(appModel.pendingAgentDeepLinkPrompt != nil) #expect(appModel.openChatRequestID == 0) await appModel.approvePendingAgentDeepLinkPrompt() #expect(appModel.pendingAgentDeepLinkPrompt == nil) #expect(appModel.openChatRequestID == 1) #expect(appModel.screen.errorText == nil) } @Test @MainActor func `handle deep link coalesces prompt when rate limited`() async throws { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) await appModel.handleDeepLink(url: makeAgentDeepLinkURL(message: "first prompt")) let firstPrompt = try #require(appModel.pendingAgentDeepLinkPrompt) await appModel.handleDeepLink(url: makeAgentDeepLinkURL(message: "second prompt")) let coalescedPrompt = try #require(appModel.pendingAgentDeepLinkPrompt) #expect(coalescedPrompt.id != firstPrompt.id) #expect(coalescedPrompt.messagePreview.contains("second prompt")) } @Test @MainActor func `handle deep link strips delivery fields when unkeyed`() async throws { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) let url = makeAgentDeepLinkURL( message: "route this", deliver: true, to: "123456", channel: "telegram") await appModel.handleDeepLink(url: url) let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) #expect(prompt.request.deliver == false) #expect(prompt.request.to == nil) #expect(prompt.request.channel == nil) } @Test @MainActor func `handle deep link rejects long unkeyed message when connected`() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) let message = String(repeating: "x", count: 241) let url = makeAgentDeepLinkURL(message: message) await appModel.handleDeepLink(url: url) #expect(appModel.pendingAgentDeepLinkPrompt == nil) #expect(appModel.screen.errorText?.contains("blocked") == true) } @Test @MainActor func `handle deep link bypasses prompt with valid key`() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) appModel._test_setAgentRequestHandler { _ in } let key = NodeAppModel._test_currentDeepLinkKey() let url = makeAgentDeepLinkURL(message: "trusted request", key: key) await appModel.handleDeepLink(url: url) #expect(appModel.pendingAgentDeepLinkPrompt == nil) #expect(appModel.openChatRequestID == 1) #expect(appModel.screen.errorText == nil) } @Test @MainActor func `operator scopes use the active gateway token`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) defer { if let previousStateDir { setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) } else { unsetenv("OPENCLAW_STATE_DIR") } try? FileManager.default.removeItem(at: tempDir) } let appModel = NodeAppModel() defer { appModel.disconnectGateway() } let stableID = "manual|gateway.example.com|443" let authenticationOwnerID = stableID let config = try GatewayConnectConfig( url: #require(URL(string: "wss://127.0.0.1:1")), stableID: stableID, tls: nil, token: nil, bootstrapToken: nil, password: nil, nodeOptions: GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios", clientMode: "node", clientDisplayName: nil, deviceAuthGatewayID: authenticationOwnerID)) appModel.applyGatewayConnectConfig(config) let identity = DeviceIdentityStore.loadOrCreate() #expect(appModel.hasOperatorAdminScope == false) _ = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "operator", token: "operator-token", scopes: ["operator.read", "operator.admin", "operator.approvals"], gatewayID: authenticationOwnerID) appModel._test_refreshOperatorAdminScopeFromStore() #expect(appModel.hasOperatorAdminScope == true) #expect(appModel._test_shouldRequestStoredOperatorAdminScope(gatewayID: authenticationOwnerID)) #expect(appModel._test_shouldRequestStoredOperatorApprovalScope( gatewayID: authenticationOwnerID, forceTalkPermissionUpgradeRequest: true)) let otherStableID = "manual|other.example.com|443" #expect(!appModel._test_shouldRequestStoredOperatorAdminScope(gatewayID: otherStableID)) #expect(!appModel._test_shouldRequestStoredOperatorApprovalScope( gatewayID: otherStableID, forceTalkPermissionUpgradeRequest: true)) DeviceAuthStore.clearToken( deviceId: identity.deviceId, role: "operator", gatewayID: authenticationOwnerID) appModel._test_refreshOperatorAdminScopeFromStore() #expect(appModel.hasOperatorAdminScope == false) } @Test @MainActor func `send voice transcript throws when gateway offline`() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") } } @Test @MainActor func `canvas A 2 UI action dispatches status`() async { let appModel = NodeAppModel() let body: [String: Any] = [ "userAction": [ "name": "tap", "id": "action-1", "surfaceId": "main", "sourceComponentId": "button-1", "context": ["value": "ok"], ], ] await appModel._test_handleCanvasA2UIAction(body: body) #expect(appModel.screen.urlString.isEmpty) } }