Files
openclaw/apps/ios/Tests/NodeAppModelInvokeTests.swift
Peter Steinberger b4b1984fe7 feat(gateway): start a new chat session in a managed worktree (#100788)
Adds an additive worktree flag to sessions.create so any new chat can run in an
isolated managed worktree of the agent's git workspace, with the branch checked
out and .worktreeinclude provisioning applied. The session's spawnedCwd points at
the matching subdirectory inside the worktree so chat runs, CLI, and the file
browser execute there. agents.list gains workspaceGit (workspace or an ancestor
is a git checkout) to gate the affordance; web sidebar, iOS, and Android expose a
New-Chat-in-worktree action. Uses the method's operator.write scope, but the
.openclaw/worktree-setup.sh step runs only for operator.admin callers since it
executes repo code. Deleting the session, or leaving via a plain New Chat, clears
the cwd and lossless-removes the worktree; idle GC treats recent session activity
as worktree activity so an active session's checkout is never swept.

Live-verified end-to-end on a real gateway; follow-up to #100535 (issue #100534).
2026-07-06 10:23:41 +01:00

2996 lines
123 KiB
Swift

import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Testing
import UIKit
import UserNotifications
@testable import OpenClaw
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 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 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 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<NotificationAuthorizationStatus, Never>?
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<Void, Never>?
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
}
}
@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")
}
@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_isSuspendedForExternalAudio() == false)
appModel.voiceWake.stop()
}
@Test @MainActor func `overlapping PTT owners keep voice wake suspended until final release`() {
let appModel = NodeAppModel(talkMode: TalkModeManager(allowSimulatorCapture: true))
appModel.voiceWake.isEnabled = true
appModel.voiceWake.isListening = true
appModel.voiceWake.statusText = "Listening"
appModel._test_acquirePttVoiceWakeLease()
appModel._test_acquirePttVoiceWakeLease()
#expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == true)
appModel._test_releasePttVoiceWakeLease()
#expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == true)
appModel._test_releasePttVoiceWakeLease()
#expect(appModel.voiceWake._test_isSuspendedForExternalAudio() == false)
appModel.voiceWake.stop()
}
@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 = Int(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 = Int(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: 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: 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: Int(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: Int(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)
}
@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 `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 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 `apns registration requires disclosure and notification authorization`() async {
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) == false)
PushEnrollmentConsent.markDisclosureAccepted()
center.status = .notDetermined
#expect(await appModel._test_canPublishAPNsRegistration() == false)
center.status = .authorized
#expect(await appModel._test_canPublishAPNsRegistration())
}
@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 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 `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 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: 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)
}
}