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