iOS: wire node commands and incremental TTS

This commit is contained in:
Mariano Belinky
2026-02-01 12:21:10 +01:00
committed by Mariano Belinky
parent b7aac92ac4
commit 532b9653be
12 changed files with 1002 additions and 67 deletions
@@ -92,6 +92,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(commands.contains(OpenClawChatCommand.push.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
@@ -0,0 +1,60 @@
import Foundation
import Testing
@testable import OpenClaw
private actor Counter {
private var value = 0
func increment() {
value += 1
}
func get() -> Int {
value
}
func set(_ newValue: Int) {
value = newValue
}
}
@Suite struct GatewayHealthMonitorTests {
@Test @MainActor func triggersFailureAfterThreshold() async {
let failureCount = Counter()
let monitor = GatewayHealthMonitor(
config: .init(intervalSeconds: 0.001, timeoutSeconds: 0.0, maxFailures: 2))
monitor.start(
check: { false },
onFailure: { _ in
await failureCount.increment()
await monitor.stop()
})
try? await Task.sleep(nanoseconds: 60_000_000)
#expect(await failureCount.get() == 1)
}
@Test @MainActor func resetsFailuresAfterSuccess() async {
let failureCount = Counter()
let calls = Counter()
let monitor = GatewayHealthMonitor(
config: .init(intervalSeconds: 0.001, timeoutSeconds: 0.0, maxFailures: 2))
monitor.start(
check: {
await calls.increment()
let callCount = await calls.get()
if callCount >= 6 {
await monitor.stop()
}
return callCount % 2 == 0
},
onFailure: { _ in
await failureCount.increment()
})
try? await Task.sleep(nanoseconds: 60_000_000)
#expect(await failureCount.get() == 0)
}
}
@@ -448,6 +448,91 @@ private func decodePayload<T: Decodable>(_ json: String?, as type: T.Type) throw
#expect(request.content.body == "World")
}
@Test @MainActor func handleInvokeChatPushCreatesNotification() async throws {
let notifier = TestNotificationCenter(status: .authorized)
let deviceStatus = TestDeviceStatusService(
statusPayload: OpenClawDeviceStatusPayload(
battery: OpenClawBatteryStatusPayload(level: 0.5, state: .charging, lowPowerModeEnabled: false),
thermal: OpenClawThermalStatusPayload(state: .nominal),
storage: OpenClawStorageStatusPayload(totalBytes: 100, freeBytes: 50, usedBytes: 50),
network: OpenClawNetworkStatusPayload(
status: .satisfied,
isExpensive: false,
isConstrained: false,
interfaces: [.wifi]),
uptimeSeconds: 10),
infoPayload: OpenClawDeviceInfoPayload(
deviceName: "Test",
modelIdentifier: "Test1,1",
systemName: "iOS",
systemVersion: "1.0",
appVersion: "dev",
appBuild: "0",
locale: "en-US"))
let emptyContact = OpenClawContactPayload(
identifier: "c0",
displayName: "",
givenName: "",
familyName: "",
organizationName: "",
phoneNumbers: [],
emails: [])
let emptyEvent = OpenClawCalendarEventPayload(
identifier: "e0",
title: "Test",
startISO: "2024-01-01T00:00:00Z",
endISO: "2024-01-01T00:30:00Z",
isAllDay: false,
location: nil,
calendarTitle: nil)
let emptyReminder = OpenClawReminderPayload(
identifier: "r0",
title: "Test",
dueISO: nil,
completed: false,
listName: nil)
let appModel = makeTestAppModel(
notificationCenter: notifier,
deviceStatusService: deviceStatus,
photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])),
contactsService: TestContactsService(
searchPayload: OpenClawContactsSearchPayload(contacts: []),
addPayload: OpenClawContactsAddPayload(contact: emptyContact)),
calendarService: TestCalendarService(
eventsPayload: OpenClawCalendarEventsPayload(events: []),
addPayload: OpenClawCalendarAddPayload(event: emptyEvent)),
remindersService: TestRemindersService(
listPayload: OpenClawRemindersListPayload(reminders: []),
addPayload: OpenClawRemindersAddPayload(reminder: emptyReminder)),
motionService: TestMotionService(
activityPayload: OpenClawMotionActivityPayload(activities: []),
pedometerPayload: OpenClawPedometerPayload(
startISO: "2024-01-01T00:00:00Z",
endISO: "2024-01-01T01:00:00Z",
steps: nil,
distanceMeters: nil,
floorsAscended: nil,
floorsDescended: nil)))
let params = OpenClawChatPushParams(text: "Ping", speak: false)
let data = try JSONEncoder().encode(params)
let json = String(decoding: data, as: UTF8.self)
let req = BridgeInvokeRequest(
id: "chat-push",
command: OpenClawChatCommand.push.rawValue,
paramsJSON: json)
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == true)
#expect(notifier.addedRequests.count == 1)
let request = try #require(notifier.addedRequests.first)
#expect(request.content.title == "OpenClaw")
#expect(request.content.body == "Ping")
let payloadJSON = try #require(res.payloadJSON)
let decoded = try JSONDecoder().decode(OpenClawChatPushPayload.self, from: Data(payloadJSON.utf8))
#expect((decoded.messageId ?? "").isEmpty == false)
#expect(request.identifier == decoded.messageId)
}
@Test @MainActor func handleInvokeDeviceAndDataCommandsReturnPayloads() async throws {
let deviceStatusPayload = OpenClawDeviceStatusPayload(
battery: OpenClawBatteryStatusPayload(level: 0.25, state: .unplugged, lowPowerModeEnabled: false),
@@ -723,6 +808,28 @@ private func decodePayload<T: Decodable>(_ json: String?, as type: T.Type) throw
#expect(oncePayload.status == "offline")
}
@Test @MainActor func handleInvokePushToTalkOnceStopsOnFinalTranscript() async throws {
let talkMode = TalkModeManager(allowSimulatorCapture: true)
talkMode.updateGatewayConnected(false)
let appModel = makeTalkTestAppModel(talkMode: talkMode)
let onceReq = BridgeInvokeRequest(id: "ptt-once-final", command: OpenClawTalkCommand.pttOnce.rawValue)
let onceTask = Task { await appModel._test_handleInvoke(onceReq) }
for _ in 0..<5 where !talkMode.isPushToTalkActive {
await Task.yield()
}
#expect(talkMode.isPushToTalkActive == true)
await talkMode._test_handleTranscript("Hello final", isFinal: true)
let onceRes = await onceTask.value
#expect(onceRes.ok == true)
let oncePayload = try decodePayload(onceRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self)
#expect(oncePayload.transcript == "Hello final")
#expect(oncePayload.status == "offline")
}
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
let appModel = NodeAppModel()
let url = URL(string: "openclaw://agent?message=hello")!
@@ -0,0 +1,33 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite struct TalkModeIncrementalTests {
@Test @MainActor func incrementalSpeechSplitsOnBoundary() {
let talkMode = TalkModeManager(allowSimulatorCapture: true)
talkMode._test_incrementalReset()
let segments = talkMode._test_incrementalIngest("Hello world. Next", isFinal: false)
#expect(segments.count == 1)
#expect(segments.first == "Hello world.")
}
@Test @MainActor func incrementalSpeechSkipsDirectiveLine() {
let talkMode = TalkModeManager(allowSimulatorCapture: true)
talkMode._test_incrementalReset()
let segments = talkMode._test_incrementalIngest("{\"voice\":\"abc\"}\nHello.", isFinal: false)
#expect(segments.count == 1)
#expect(segments.first == "Hello.")
}
@Test @MainActor func incrementalSpeechIgnoresCodeBlocks() {
let talkMode = TalkModeManager(allowSimulatorCapture: true)
talkMode._test_incrementalReset()
let text = "Here is code:\n```js\nx=1\n```\nDone."
let segments = talkMode._test_incrementalIngest(text, isFinal: true)
#expect(segments.count == 1)
let value = segments.first ?? ""
#expect(value.contains("x=1") == false)
#expect(value.contains("Here is code") == true)
#expect(value.contains("Done.") == true)
}
}