mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 20:36:03 +00:00
fix(ios): persist queued Watch replies (#100372)
* fix(ios): persist queued watch replies Route Apple Watch quick replies through the durable, gateway-scoped Watch message outbox with restart-safe routing and deduplication. Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> * fix(ios): preserve pre-upgrade Watch replies Bind shipped notification prompts without routing metadata to the current Gateway once, while keeping explicit and persisted Gateway owners fail-closed. --------- Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
f607ba0691
commit
70f2bef6b4
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
- **Model aliases:** resolve provider-qualified aliases during session and chat-command model switches without collisions when providers share a display alias. (#100209) Thanks @sahilsatralkar.
|
||||
- **TUI new-session hooks:** create `/new` sessions through the shared Gateway lifecycle so command and session hooks receive the completed parent transcript in both Gateway and embedded modes, while preventing rollover during an active turn. (#100241, #49918) Thanks @BingqingLyu.
|
||||
- **TUI abort diagnostics:** show sanitized tool argument-validation summaries for aborted runs in both Gateway and local TUI modes without exposing raw model arguments. (#91002) Thanks @wsyjh8.
|
||||
- **iOS Watch replies:** persist queued quick replies in the gateway-scoped chat outbox and submit them through idempotent chat delivery, preventing losses, duplicates, and cross-gateway sends after reconnects. (#100031) Thanks @NianJiuZst.
|
||||
|
||||
## 2026.7.1
|
||||
|
||||
|
||||
@@ -10995,7 +10995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2319,
|
||||
"line": 2343,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Action required",
|
||||
"surface": "apple",
|
||||
@@ -11003,7 +11003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2319,
|
||||
"line": 2343,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval needed",
|
||||
"surface": "apple",
|
||||
@@ -11011,7 +11011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2652,
|
||||
"line": 2676,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting…",
|
||||
"surface": "apple",
|
||||
@@ -11019,7 +11019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2652,
|
||||
"line": 2676,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting…",
|
||||
"surface": "apple",
|
||||
@@ -11027,7 +11027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2656,
|
||||
"line": 2680,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting...",
|
||||
"surface": "apple",
|
||||
@@ -11035,7 +11035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2656,
|
||||
"line": 2680,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting...",
|
||||
"surface": "apple",
|
||||
@@ -11043,7 +11043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2964,
|
||||
"line": 2988,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connected",
|
||||
"surface": "apple",
|
||||
@@ -11051,7 +11051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2964,
|
||||
"line": 2988,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -50,6 +50,11 @@ private struct ExecApprovalGatewayEventPayload: Decodable {
|
||||
var id: String
|
||||
}
|
||||
|
||||
private struct NodeEventRequestPayload: Encodable {
|
||||
var event: String
|
||||
var payloadJSON: String
|
||||
}
|
||||
|
||||
/// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -87,6 +92,7 @@ private enum IOSDeepLinkAgentPolicy {
|
||||
// swiftlint:disable type_body_length file_length
|
||||
final class NodeAppModel {
|
||||
private nonisolated static let watchChatPreviewItemLimit = 5
|
||||
private nonisolated static let watchMessageMaxImmediateRetryAttempts = 3
|
||||
|
||||
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
@@ -122,12 +128,19 @@ final class NodeAppModel {
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private enum WatchMessageSendOutcome {
|
||||
case sent
|
||||
case retry
|
||||
case discard
|
||||
}
|
||||
|
||||
private struct PersistedWatchExecApprovalBridgeState: Codable {
|
||||
var approvals: [ExecApprovalPrompt]
|
||||
var pendingApprovalIDs: [String]?
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
||||
private nonisolated static let agentRequestNodeEventTimeoutSeconds = 8
|
||||
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
|
||||
"notifications.execApprovalGuidance.suppressed"
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
|
||||
@@ -225,6 +238,9 @@ final class NodeAppModel {
|
||||
private let remindersService: any RemindersServicing
|
||||
private let motionService: any MotionServicing
|
||||
private let watchMessagingService: any WatchMessagingServicing
|
||||
#if DEBUG
|
||||
@ObservationIgnored private var testAgentRequestHandler: ((AgentDeepLink) async throws -> Void)?
|
||||
#endif
|
||||
private var pttVoiceWakeSuspended = false
|
||||
private var talkVoiceWakeSuspended = false
|
||||
private var backgroundVoiceWakeSuspended = false
|
||||
@@ -238,8 +254,10 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
@ObservationIgnored private let watchChatCoordinator = WatchChatCoordinator()
|
||||
@ObservationIgnored private let watchMessageOutbox = WatchMessageOutbox()
|
||||
@ObservationIgnored private var watchMessageFlushInFlight = false
|
||||
@ObservationIgnored private var watchMessageRetryAttempts: [String: Int] = [:]
|
||||
@ObservationIgnored private var watchMessageRetryTask: Task<Void, Never>?
|
||||
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
|
||||
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
|
||||
private var pendingWatchExecApprovalRecoveryIDs: [String] = []
|
||||
@@ -1904,15 +1922,21 @@ extension NodeAppModel {
|
||||
message: "INVALID_REQUEST: empty watch notification"))
|
||||
}
|
||||
do {
|
||||
let gatewayStableID = self.currentWatchChatGatewayStableID()
|
||||
self.watchMessageOutbox.recordPromptRoute(
|
||||
promptID: normalizedParams.promptId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
let result = try await self.watchMessagingService.sendNotification(
|
||||
id: req.id,
|
||||
params: normalizedParams)
|
||||
params: normalizedParams,
|
||||
gatewayStableID: gatewayStableID)
|
||||
if result.queuedForDelivery || !result.deliveredImmediately {
|
||||
let invokeID = req.id
|
||||
Task { @MainActor in
|
||||
await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded(
|
||||
invokeID: invokeID,
|
||||
params: normalizedParams,
|
||||
gatewayStableID: gatewayStableID,
|
||||
sendResult: result)
|
||||
}
|
||||
}
|
||||
@@ -2971,7 +2995,7 @@ extension NodeAppModel {
|
||||
return
|
||||
}
|
||||
Task { [weak self] in
|
||||
await self?.flushQueuedWatchChatsIfAvailable()
|
||||
await self?.flushQueuedWatchMessagesIfAvailable()
|
||||
guard changed else { return }
|
||||
await self?.syncWatchAppSnapshot(reason: "operator_online")
|
||||
}
|
||||
@@ -3180,7 +3204,6 @@ extension NodeAppModel {
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.flushQueuedWatchRepliesIfConnected()
|
||||
await self.syncWatchAppSnapshot(reason: "node_connected", includeChat: true)
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
|
||||
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
|
||||
@@ -3263,53 +3286,61 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
switch await self.watchReplyCoordinator.ingest(event, isGatewayConnected: self.isGatewayConnected()) {
|
||||
case .dropMissingFields:
|
||||
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionID = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !replyID.isEmpty, !actionID.isEmpty else {
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
case let .deduped(replyId):
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
case let .queue(replyId, actionId):
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
|
||||
case .forward:
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
let payloadGatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let currentGatewayID = self.currentWatchChatGatewayStableID()
|
||||
let routedGatewayID = self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId) ?? ""
|
||||
let sourceGatewayID: String
|
||||
if !payloadGatewayID.isEmpty {
|
||||
sourceGatewayID = payloadGatewayID
|
||||
} else if !routedGatewayID.isEmpty {
|
||||
sourceGatewayID = routedGatewayID
|
||||
} else if let currentGatewayID {
|
||||
// Shipped prompts predate gateway routing metadata and cannot be migrated after delivery.
|
||||
// Bind that prompt once; explicit or persisted owners never use this fallback.
|
||||
sourceGatewayID = currentGatewayID
|
||||
self.watchMessageOutbox.recordPromptRoute(
|
||||
promptID: event.promptId,
|
||||
gatewayStableID: currentGatewayID)
|
||||
} else {
|
||||
sourceGatewayID = ""
|
||||
}
|
||||
if !sourceGatewayID.isEmpty, let currentGatewayID, currentGatewayID != sourceGatewayID {
|
||||
self.watchReplyLogger.info("watch reply dropped: stale gateway target")
|
||||
return
|
||||
}
|
||||
guard !sourceGatewayID.isEmpty else {
|
||||
self.watchReplyLogger.info("watch reply dropped: unresolved gateway target")
|
||||
return
|
||||
}
|
||||
let gatewayStableID = sourceGatewayID
|
||||
|
||||
private func flushQueuedWatchRepliesIfConnected() async {
|
||||
for event in await self.watchReplyCoordinator.drainIfConnected(self.isGatewayConnected()) {
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
}
|
||||
let message = WatchAppCommandEvent(
|
||||
commandId: replyID,
|
||||
command: .sendChat,
|
||||
sessionKey: event.sessionKey,
|
||||
gatewayStableID: gatewayStableID,
|
||||
text: Self.makeWatchReplyAgentMessage(event),
|
||||
sentAtMs: event.sentAtMs,
|
||||
transport: event.transport,
|
||||
messageKind: .quickReply)
|
||||
let needsReconnect = !self.isWatchMessageSendAvailable()
|
||||
await self.handleWatchMessage(message)
|
||||
guard needsReconnect else { return }
|
||||
|
||||
private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async {
|
||||
let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey
|
||||
let message = Self.makeWatchReplyAgentMessage(event)
|
||||
let link = AgentDeepLink(
|
||||
message: message,
|
||||
sessionKey: effectiveSessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: event.replyId)
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
let forwardedMessage =
|
||||
"watch reply forwarded replyId=\(event.replyId) "
|
||||
+ "action=\(event.actionId)"
|
||||
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
let failedMessage =
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.watchReplyCoordinator.requeueFront(event)
|
||||
let connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12000,
|
||||
reason: "watch_reply")
|
||||
guard connected, self.currentWatchChatGatewayStableID() == gatewayStableID else {
|
||||
self.watchReplyLogger.info("watch reply remains queued: gateway target unavailable")
|
||||
return
|
||||
}
|
||||
await self.flushQueuedWatchMessagesIfAvailable()
|
||||
}
|
||||
|
||||
private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
|
||||
@@ -3783,53 +3814,92 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async {
|
||||
guard self.watchChatCommandTargetsCurrentGateway(event) else {
|
||||
guard self.watchMessageTargetsCurrentGateway(event) else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: stale gateway target")
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true)
|
||||
return
|
||||
}
|
||||
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event)
|
||||
switch self.watchChatCoordinator.ingest(
|
||||
await self.handleWatchMessage(event)
|
||||
}
|
||||
|
||||
private func handleWatchMessage(_ event: WatchAppCommandEvent) async {
|
||||
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event)
|
||||
let isAvailable = self.isWatchMessageSendAvailable()
|
||||
if isAvailable, !self.watchMessageTargetsCurrentGateway(event) {
|
||||
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
|
||||
return
|
||||
}
|
||||
switch self.watchMessageOutbox.ingest(
|
||||
event,
|
||||
isChatAvailable: self.isWatchChatAvailableForSend(),
|
||||
isAvailable: isAvailable,
|
||||
gatewayStableID: eventGatewayID)
|
||||
{
|
||||
case .dropMissingFields:
|
||||
GatewayDiagnostics.log("watch chat send skipped: missing commandId/text")
|
||||
GatewayDiagnostics.log("watch message send skipped: missing id/text")
|
||||
case .dropMissingTarget:
|
||||
GatewayDiagnostics.log("watch chat send skipped: missing gateway target")
|
||||
case let .deduped(commandId):
|
||||
GatewayDiagnostics.log("watch chat send deduped commandId=\(commandId)")
|
||||
case let .queue(commandId):
|
||||
GatewayDiagnostics.log("watch chat send queued commandId=\(commandId)")
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
|
||||
case .forward:
|
||||
_ = await self.forwardWatchChatMessage(event, requeueOnFailure: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func flushQueuedWatchChatsIfAvailable() async {
|
||||
let gatewayStableID = self.currentWatchChatGatewayStableID()
|
||||
while let event = self.watchChatCoordinator.nextQueuedCommand(
|
||||
isChatAvailable: self.isWatchChatAvailableForSend(),
|
||||
gatewayStableID: gatewayStableID)
|
||||
{
|
||||
guard self.watchChatCommandTargetsCurrentGateway(event) else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: stale queued gateway target")
|
||||
self.watchChatCoordinator.removeQueuedCommand(
|
||||
commandId: event.commandId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
continue
|
||||
GatewayDiagnostics.log("watch message send skipped: missing gateway target")
|
||||
case let .deduped(messageID):
|
||||
GatewayDiagnostics.log("watch message send deduped id=\(messageID)")
|
||||
case let .queue(messageID):
|
||||
GatewayDiagnostics.log("watch message send queued id=\(messageID)")
|
||||
if self.watchMessageKind(event) == .chat {
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
|
||||
}
|
||||
case .forward:
|
||||
switch await self.forwardWatchMessage(event, requeueOnFailure: true) {
|
||||
case .sent, .discard:
|
||||
self.watchMessageOutbox.removeQueuedMessage(
|
||||
messageID: event.commandId,
|
||||
gatewayStableID: eventGatewayID)
|
||||
self.watchMessageRetryAttempts[event.commandId] = nil
|
||||
case .retry:
|
||||
self.scheduleWatchMessageRetry(messageID: event.commandId)
|
||||
}
|
||||
let sent = await self.forwardWatchChatMessage(event, requeueOnFailure: false)
|
||||
guard sent else { return }
|
||||
self.watchChatCoordinator.removeQueuedCommand(
|
||||
commandId: event.commandId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
}
|
||||
}
|
||||
|
||||
private func isWatchChatAvailableForSend() -> Bool {
|
||||
private func flushQueuedWatchMessagesIfAvailable() async {
|
||||
guard !self.watchMessageFlushInFlight else { return }
|
||||
self.watchMessageFlushInFlight = true
|
||||
defer { self.watchMessageFlushInFlight = false }
|
||||
guard let gatewayStableID = self.currentWatchChatGatewayStableID() else { return }
|
||||
while self.currentWatchChatGatewayStableID() == gatewayStableID {
|
||||
guard let event = self.watchMessageOutbox.nextQueuedMessage(
|
||||
isAvailable: self.isWatchMessageSendAvailable(),
|
||||
gatewayStableID: gatewayStableID)
|
||||
else { return }
|
||||
guard self.watchMessageTargetsCurrentGateway(event) else { return }
|
||||
switch await self.forwardWatchMessage(event, requeueOnFailure: false) {
|
||||
case .sent, .discard:
|
||||
self.watchMessageOutbox.removeQueuedMessage(
|
||||
messageID: event.commandId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
self.watchMessageRetryAttempts[event.commandId] = nil
|
||||
case .retry:
|
||||
self.scheduleWatchMessageRetry(messageID: event.commandId)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleWatchMessageRetry(messageID: String) {
|
||||
guard self.isWatchMessageSendAvailable(), self.watchMessageRetryTask == nil else { return }
|
||||
let attempt = (self.watchMessageRetryAttempts[messageID] ?? 0) + 1
|
||||
guard attempt <= Self.watchMessageMaxImmediateRetryAttempts else {
|
||||
GatewayDiagnostics.log("watch message retry deferred until reconnect id=\(messageID)")
|
||||
return
|
||||
}
|
||||
self.watchMessageRetryAttempts[messageID] = attempt
|
||||
let delayNanoseconds = UInt64(500 * (1 << (attempt - 1))) * 1_000_000
|
||||
self.watchMessageRetryTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: delayNanoseconds)
|
||||
guard let self else { return }
|
||||
self.watchMessageRetryTask = nil
|
||||
await self.flushQueuedWatchMessagesIfAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
private func isWatchMessageSendAvailable() -> Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
@@ -3837,32 +3907,45 @@ extension NodeAppModel {
|
||||
self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func normalizedWatchChatGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
|
||||
private func normalizedWatchMessageGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
|
||||
let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return gatewayStableID.isEmpty ? nil : gatewayStableID
|
||||
}
|
||||
|
||||
private func watchChatCommandTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
|
||||
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event) ?? ""
|
||||
private func watchMessageTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
|
||||
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event) ?? ""
|
||||
let currentGatewayID = self.currentWatchChatGatewayStableID() ?? ""
|
||||
guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false }
|
||||
return eventGatewayID == currentGatewayID
|
||||
}
|
||||
|
||||
private func forwardWatchChatMessage(
|
||||
private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind {
|
||||
event.messageKind ?? .chat
|
||||
}
|
||||
|
||||
private func forwardWatchMessage(
|
||||
_ event: WatchAppCommandEvent,
|
||||
requeueOnFailure: Bool) async -> Bool
|
||||
requeueOnFailure: Bool) async -> WatchMessageSendOutcome
|
||||
{
|
||||
guard self.watchMessageTargetsCurrentGateway(event) else {
|
||||
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
|
||||
return .retry
|
||||
}
|
||||
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !text.isEmpty else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: empty text")
|
||||
return true
|
||||
return .discard
|
||||
}
|
||||
|
||||
let messageKind = self.watchMessageKind(event)
|
||||
let fallbackSessionKey = messageKind == .quickReply ? self.mainSessionKey : self.chatSessionKey
|
||||
let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? event.sessionKey!
|
||||
: self.chatSessionKey
|
||||
self.focusChatSession(sessionKey)
|
||||
: fallbackSessionKey
|
||||
if messageKind == .chat {
|
||||
self.focusChatSession(sessionKey)
|
||||
}
|
||||
let thinking = messageKind == .quickReply ? "low" : "auto"
|
||||
|
||||
do {
|
||||
let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
@@ -3870,9 +3953,13 @@ extension NodeAppModel {
|
||||
let response = try await self.appleReviewDemoChatTransport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: text,
|
||||
thinking: "auto",
|
||||
thinking: thinking,
|
||||
idempotencyKey: event.commandId,
|
||||
attachments: [])
|
||||
if messageKind == .quickReply {
|
||||
await self.finishForwardedWatchMessage(event)
|
||||
return .sent
|
||||
}
|
||||
let history = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey)
|
||||
if let replyText = Self.watchChatReplyText(
|
||||
from: history.messages ?? [],
|
||||
@@ -3883,17 +3970,17 @@ extension NodeAppModel {
|
||||
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
|
||||
}
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
|
||||
return true
|
||||
return .sent
|
||||
}
|
||||
|
||||
guard self.isOperatorGatewayConnected else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected")
|
||||
if requeueOnFailure {
|
||||
self.watchChatCoordinator.requeueFront(
|
||||
self.watchMessageOutbox.requeueFront(
|
||||
event,
|
||||
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
|
||||
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
|
||||
}
|
||||
return false
|
||||
return .retry
|
||||
}
|
||||
|
||||
let transport = IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
@@ -3902,9 +3989,13 @@ extension NodeAppModel {
|
||||
let response = try await transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: text,
|
||||
thinking: "auto",
|
||||
thinking: thinking,
|
||||
idempotencyKey: event.commandId,
|
||||
attachments: [])
|
||||
if messageKind == .quickReply {
|
||||
await self.finishForwardedWatchMessage(event)
|
||||
return .sent
|
||||
}
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
|
||||
_ = await transport.waitForRunCompletion(
|
||||
runId: response.runId,
|
||||
@@ -3920,15 +4011,19 @@ extension NodeAppModel {
|
||||
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
|
||||
}
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
|
||||
return true
|
||||
return .sent
|
||||
} catch {
|
||||
GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)")
|
||||
if requeueOnFailure {
|
||||
self.watchChatCoordinator.requeueFront(
|
||||
event,
|
||||
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
|
||||
if Self.shouldDiscardFailedWatchMessage(error) {
|
||||
GatewayDiagnostics.log("watch message discarded after permanent send failure id=\(event.commandId)")
|
||||
return .discard
|
||||
}
|
||||
return false
|
||||
if requeueOnFailure {
|
||||
self.watchMessageOutbox.requeueFront(
|
||||
event,
|
||||
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
|
||||
}
|
||||
return .retry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3969,6 +4064,22 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func shouldDiscardFailedWatchMessage(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
guard gatewayError.code == "INVALID_REQUEST" else { return false }
|
||||
return !gatewayError.message.lowercased().hasSuffix("retry.")
|
||||
}
|
||||
|
||||
private func finishForwardedWatchMessage(_ event: WatchAppCommandEvent) async {
|
||||
if self.watchMessageKind(event) == .chat {
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
|
||||
return
|
||||
}
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply forwarded replyId=\(event.commandId, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
}
|
||||
|
||||
private func syncWatchAppSnapshot(reason: String, includeChat: Bool = false) async {
|
||||
let chatPreview = includeChat ? await self.makeWatchChatPreview() : nil
|
||||
let message = self.makeWatchAppSnapshot(chatPreview: chatPreview)
|
||||
@@ -5129,13 +5240,29 @@ extension NodeAppModel {
|
||||
])
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if let testAgentRequestHandler {
|
||||
try await testAgentRequestHandler(link)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
let data = try JSONEncoder().encode(link)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||
])
|
||||
}
|
||||
await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
let requestData = try JSONEncoder().encode(NodeEventRequestPayload(event: "agent.request", payloadJSON: json))
|
||||
guard let requestJSON = String(bytes: requestData, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request node event as UTF-8",
|
||||
])
|
||||
}
|
||||
_ = try await self.nodeGateway.request(
|
||||
method: "node.event",
|
||||
paramsJSON: requestJSON,
|
||||
timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds)
|
||||
}
|
||||
|
||||
private func isGatewayConnected() async -> Bool {
|
||||
@@ -5305,23 +5432,37 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
self.watchMessageOutbox.queuedCount(kind: .quickReply)
|
||||
}
|
||||
|
||||
func _test_queuedWatchChatCommandCount() -> Int {
|
||||
self.watchChatCoordinator.queuedCount
|
||||
self.watchMessageOutbox.queuedCount(kind: .chat)
|
||||
}
|
||||
|
||||
func _test_queuedWatchChatCommandIds() -> [String] {
|
||||
self.watchChatCoordinator.queuedCommandIds
|
||||
self.watchMessageOutbox.queuedMessageIDs(kind: .chat)
|
||||
}
|
||||
|
||||
func _test_recordWatchPromptRoute(promptID: String, gatewayStableID: String) {
|
||||
self.watchMessageOutbox.recordPromptRoute(
|
||||
promptID: promptID,
|
||||
gatewayStableID: gatewayStableID)
|
||||
}
|
||||
|
||||
func _test_setConnectedGatewayID(_ gatewayID: String?) {
|
||||
self.connectedGatewayID = gatewayID
|
||||
}
|
||||
|
||||
func _test_setAgentRequestHandler(_ handler: @escaping (AgentDeepLink) async throws -> Void) {
|
||||
self.testAgentRequestHandler = handler
|
||||
}
|
||||
|
||||
static func _test_resetPersistedWatchChatQueueState() {
|
||||
WatchChatCoordinator.resetPersistedQueue()
|
||||
WatchMessageOutbox.resetPersistedQueue()
|
||||
}
|
||||
|
||||
static func _test_resetPersistedWatchReplyQueueState() {
|
||||
WatchMessageOutbox.resetPersistedQueue()
|
||||
}
|
||||
|
||||
func _test_setGatewayConnected(_ connected: Bool) {
|
||||
@@ -5504,6 +5645,14 @@ extension NodeAppModel {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldDiscardFailedWatchMessage(
|
||||
code: String,
|
||||
message: String = "test") -> Bool
|
||||
{
|
||||
self.shouldDiscardFailedWatchMessage(
|
||||
GatewayResponseError(method: "chat.send", code: code, message: message, details: nil))
|
||||
}
|
||||
|
||||
static func _test_resetPersistedWatchExecApprovalBridgeState() {
|
||||
UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
|
||||
}
|
||||
|
||||
@@ -1,203 +1,230 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class WatchReplyCoordinator {
|
||||
enum Decision {
|
||||
case dropMissingFields
|
||||
case deduped(replyId: String)
|
||||
case queue(replyId: String, actionId: String)
|
||||
case forward
|
||||
}
|
||||
|
||||
private var queuedReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenReplyIds = Set<String>()
|
||||
|
||||
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
return .dropMissingFields
|
||||
}
|
||||
if self.seenReplyIds.contains(replyId) {
|
||||
return .deduped(replyId: replyId)
|
||||
}
|
||||
self.seenReplyIds.insert(replyId)
|
||||
if !isGatewayConnected {
|
||||
self.queuedReplies.append(event)
|
||||
return .queue(replyId: replyId, actionId: actionId)
|
||||
}
|
||||
return .forward
|
||||
}
|
||||
|
||||
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
|
||||
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
|
||||
let pending = self.queuedReplies
|
||||
self.queuedReplies.removeAll()
|
||||
return pending
|
||||
}
|
||||
|
||||
func requeueFront(_ event: WatchQuickReplyEvent) {
|
||||
self.queuedReplies.insert(event, at: 0)
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
self.queuedReplies.count
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchChatCoordinator {
|
||||
final class WatchMessageOutbox {
|
||||
enum Decision {
|
||||
case dropMissingFields
|
||||
case dropMissingTarget
|
||||
case deduped(commandId: String)
|
||||
case queue(commandId: String)
|
||||
case deduped(messageID: String)
|
||||
case queue(messageID: String)
|
||||
case forward
|
||||
}
|
||||
|
||||
// Keep the shipped chat key so upgrades retain messages already queued by the Watch.
|
||||
private static let persistedQueueKey = "watch.chat.command.queue.v1"
|
||||
private static let maxRecentCommandIds = 128
|
||||
private static let persistedMetadataKey = "watch.message.outbox.metadata.v1"
|
||||
private static let maxRecentMessageIDs = 128
|
||||
private static let maxPromptRoutes = 128
|
||||
|
||||
private struct QueuedCommand: Codable, Equatable {
|
||||
private struct QueuedMessage: Codable, Equatable {
|
||||
var gatewayStableID: String
|
||||
var event: WatchAppCommandEvent
|
||||
}
|
||||
|
||||
private struct PromptRoute: Codable, Equatable {
|
||||
var promptID: String
|
||||
var gatewayStableID: String
|
||||
}
|
||||
|
||||
private struct PersistedMetadata: Codable, Equatable {
|
||||
var recentMessageIDs: [String]
|
||||
var promptRoutes: [PromptRoute]
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var queuedCommands: [QueuedCommand] = []
|
||||
private var recentCommandIds: [String] = []
|
||||
private var seenCommandIds = Set<String>()
|
||||
private var queuedMessages: [QueuedMessage] = []
|
||||
private var recentMessageIDs: [String] = []
|
||||
private var seenMessageIDs = Set<String>()
|
||||
private var promptRoutes: [PromptRoute] = []
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.restoreMetadata()
|
||||
self.restoreQueue()
|
||||
}
|
||||
|
||||
func ingest(
|
||||
_ event: WatchAppCommandEvent,
|
||||
isChatAvailable: Bool,
|
||||
isAvailable: Bool,
|
||||
gatewayStableID: String?) -> Decision
|
||||
{
|
||||
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if commandId.isEmpty || text.isEmpty {
|
||||
if messageID.isEmpty || text.isEmpty {
|
||||
return .dropMissingFields
|
||||
}
|
||||
if self.seenCommandIds.contains(commandId) {
|
||||
return .deduped(commandId: commandId)
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty else { return .dropMissingTarget }
|
||||
if self.seenMessageIDs.contains(messageID) {
|
||||
return .deduped(messageID: messageID)
|
||||
}
|
||||
self.rememberRecentCommandId(commandId)
|
||||
if !isChatAvailable {
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty else { return .dropMissingTarget }
|
||||
self.queuedCommands.append(
|
||||
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)))
|
||||
self.rebuildSeenCommandIds()
|
||||
self.persistQueue()
|
||||
return .queue(commandId: commandId)
|
||||
}
|
||||
return .forward
|
||||
// Persist before network delivery; iOS may suspend a background callback at any await.
|
||||
self.queuedMessages.append(
|
||||
QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)))
|
||||
self.rebuildSeenMessageIDs()
|
||||
self.persistQueue()
|
||||
return isAvailable ? .forward : .queue(messageID: messageID)
|
||||
}
|
||||
|
||||
func nextQueuedCommand(isChatAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard isChatAvailable, !owner.isEmpty else { return nil }
|
||||
return self.queuedCommands.first { $0.gatewayStableID == owner }?.event
|
||||
func recordPromptRoute(promptID: String?, gatewayStableID: String?) {
|
||||
let promptID = promptID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { return }
|
||||
self.promptRoutes.removeAll { $0.promptID == promptID }
|
||||
self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID))
|
||||
if self.promptRoutes.count > Self.maxPromptRoutes {
|
||||
self.promptRoutes.removeFirst(self.promptRoutes.count - Self.maxPromptRoutes)
|
||||
}
|
||||
self.persistMetadata()
|
||||
}
|
||||
|
||||
func removeQueuedCommand(commandId: String, gatewayStableID: String?) {
|
||||
let commandId = commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
func gatewayStableID(forPromptID promptID: String) -> String? {
|
||||
let promptID = promptID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !promptID.isEmpty, promptID != "unknown" else { return nil }
|
||||
return self.promptRoutes.last { $0.promptID == promptID }?.gatewayStableID
|
||||
}
|
||||
|
||||
func nextQueuedMessage(isAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !commandId.isEmpty, !owner.isEmpty else { return }
|
||||
guard let index = self.queuedCommands.firstIndex(where: {
|
||||
$0.gatewayStableID == owner && $0.event.commandId == commandId
|
||||
guard isAvailable, !owner.isEmpty else { return nil }
|
||||
// Replies are time-sensitive; a retrying chat must not strand them behind it.
|
||||
if let reply = self.queuedMessages.first(where: {
|
||||
$0.gatewayStableID == owner && self.kind(of: $0.event) == .quickReply
|
||||
}) {
|
||||
return reply.event
|
||||
}
|
||||
return self.queuedMessages.first { $0.gatewayStableID == owner }?.event
|
||||
}
|
||||
|
||||
func removeQueuedMessage(messageID: String, gatewayStableID: String?) {
|
||||
let messageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !messageID.isEmpty, !owner.isEmpty else { return }
|
||||
guard let index = self.queuedMessages.firstIndex(where: {
|
||||
$0.gatewayStableID == owner && $0.event.commandId == messageID
|
||||
}) else { return }
|
||||
self.queuedCommands.remove(at: index)
|
||||
self.rememberRecentCommandId(commandId)
|
||||
self.queuedMessages.remove(at: index)
|
||||
self.rememberRecentMessageID(messageID)
|
||||
self.persistQueue()
|
||||
}
|
||||
|
||||
func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) {
|
||||
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty else { return }
|
||||
if !commandId.isEmpty {
|
||||
self.rememberRecentCommandId(commandId)
|
||||
self.queuedCommands.removeAll { $0.event.commandId == commandId }
|
||||
}
|
||||
self.queuedCommands.insert(
|
||||
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)),
|
||||
guard !messageID.isEmpty, !owner.isEmpty else { return }
|
||||
self.queuedMessages.removeAll { $0.event.commandId == messageID }
|
||||
self.queuedMessages.insert(
|
||||
QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)),
|
||||
at: 0)
|
||||
self.rebuildSeenCommandIds()
|
||||
self.rebuildSeenMessageIDs()
|
||||
self.persistQueue()
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
self.queuedCommands.count
|
||||
func queuedCount(kind: WatchMessageKind? = nil) -> Int {
|
||||
guard let kind else { return self.queuedMessages.count }
|
||||
return self.queuedMessages.count(where: { self.kind(of: $0.event) == kind })
|
||||
}
|
||||
|
||||
var queuedCommandIds: [String] {
|
||||
self.queuedCommands.map(\.event.commandId)
|
||||
func queuedMessageIDs(kind: WatchMessageKind? = nil) -> [String] {
|
||||
self.queuedMessages.compactMap { queued in
|
||||
guard kind == nil || self.kind(of: queued.event) == kind else { return nil }
|
||||
return queued.event.commandId
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreQueue() {
|
||||
guard let data = defaults.data(forKey: Self.persistedQueueKey),
|
||||
let persisted = try? JSONDecoder().decode([QueuedCommand].self, from: data)
|
||||
let persisted = try? JSONDecoder().decode([QueuedMessage].self, from: data)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
var seen: [String] = []
|
||||
var seenSet = Set<String>()
|
||||
self.queuedCommands = persisted.compactMap { queued in
|
||||
self.queuedMessages = persisted.compactMap { queued in
|
||||
let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let commandId = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let messageID = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty, !commandId.isEmpty, !text.isEmpty, seenSet.insert(commandId).inserted else {
|
||||
guard !owner.isEmpty, !messageID.isEmpty, !text.isEmpty, seenSet.insert(messageID).inserted else {
|
||||
return nil
|
||||
}
|
||||
seen.append(commandId)
|
||||
return QueuedCommand(gatewayStableID: owner, event: self.command(queued.event, taggedFor: owner))
|
||||
return QueuedMessage(gatewayStableID: owner, event: self.message(queued.event, taggedFor: owner))
|
||||
}
|
||||
self.recentCommandIds = Array(seen.suffix(Self.maxRecentCommandIds))
|
||||
self.rebuildSeenCommandIds()
|
||||
if self.queuedCommands.count != persisted.count {
|
||||
self.rebuildSeenMessageIDs()
|
||||
if self.queuedMessages.count != persisted.count {
|
||||
self.persistQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func rememberRecentCommandId(_ commandId: String) {
|
||||
guard !commandId.isEmpty else { return }
|
||||
self.recentCommandIds.removeAll { $0 == commandId }
|
||||
self.recentCommandIds.append(commandId)
|
||||
if self.recentCommandIds.count > Self.maxRecentCommandIds {
|
||||
self.recentCommandIds.removeFirst(self.recentCommandIds.count - Self.maxRecentCommandIds)
|
||||
private func rememberRecentMessageID(_ messageID: String) {
|
||||
guard !messageID.isEmpty else { return }
|
||||
self.recentMessageIDs.removeAll { $0 == messageID }
|
||||
self.recentMessageIDs.append(messageID)
|
||||
if self.recentMessageIDs.count > Self.maxRecentMessageIDs {
|
||||
self.recentMessageIDs.removeFirst(self.recentMessageIDs.count - Self.maxRecentMessageIDs)
|
||||
}
|
||||
self.rebuildSeenCommandIds()
|
||||
self.rebuildSeenMessageIDs()
|
||||
self.persistMetadata()
|
||||
}
|
||||
|
||||
private func rebuildSeenCommandIds() {
|
||||
var ids = Set(self.recentCommandIds)
|
||||
ids.formUnion(self.queuedCommands.map(\.event.commandId))
|
||||
self.seenCommandIds = ids
|
||||
private func restoreMetadata() {
|
||||
guard let data = self.defaults.data(forKey: Self.persistedMetadataKey),
|
||||
let metadata = try? JSONDecoder().decode(PersistedMetadata.self, from: data)
|
||||
else { return }
|
||||
|
||||
for rawMessageID in metadata.recentMessageIDs {
|
||||
let messageID = rawMessageID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !messageID.isEmpty else { continue }
|
||||
self.recentMessageIDs.removeAll { $0 == messageID }
|
||||
self.recentMessageIDs.append(messageID)
|
||||
}
|
||||
self.recentMessageIDs = Array(self.recentMessageIDs.suffix(Self.maxRecentMessageIDs))
|
||||
|
||||
for rawRoute in metadata.promptRoutes {
|
||||
let promptID = rawRoute.promptID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayStableID = rawRoute.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { continue }
|
||||
self.promptRoutes.removeAll { $0.promptID == promptID }
|
||||
self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID))
|
||||
}
|
||||
self.promptRoutes = Array(self.promptRoutes.suffix(Self.maxPromptRoutes))
|
||||
self.rebuildSeenMessageIDs()
|
||||
}
|
||||
|
||||
private func rebuildSeenMessageIDs() {
|
||||
var ids = Set(self.recentMessageIDs)
|
||||
ids.formUnion(self.queuedMessages.map(\.event.commandId))
|
||||
self.seenMessageIDs = ids
|
||||
}
|
||||
|
||||
private func persistQueue() {
|
||||
if self.queuedCommands.isEmpty {
|
||||
if self.queuedMessages.isEmpty {
|
||||
self.defaults.removeObject(forKey: Self.persistedQueueKey)
|
||||
return
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(queuedCommands) else { return }
|
||||
guard let data = try? JSONEncoder().encode(self.queuedMessages) else { return }
|
||||
self.defaults.set(data, forKey: Self.persistedQueueKey)
|
||||
}
|
||||
|
||||
private func command(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
|
||||
private func persistMetadata() {
|
||||
let metadata = PersistedMetadata(
|
||||
recentMessageIDs: self.recentMessageIDs,
|
||||
promptRoutes: self.promptRoutes)
|
||||
guard let data = try? JSONEncoder().encode(metadata) else { return }
|
||||
self.defaults.set(data, forKey: Self.persistedMetadataKey)
|
||||
}
|
||||
|
||||
private func message(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
|
||||
var tagged = event
|
||||
tagged.gatewayStableID = gatewayStableID
|
||||
return tagged
|
||||
}
|
||||
|
||||
private func kind(of event: WatchAppCommandEvent) -> WatchMessageKind {
|
||||
event.messageKind ?? .chat
|
||||
}
|
||||
|
||||
static func resetPersistedQueue(defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: self.persistedQueueKey)
|
||||
defaults.removeObject(forKey: self.persistedMetadataKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ private struct PendingWatchPromptAction {
|
||||
var actionId: String
|
||||
var actionLabel: String?
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
}
|
||||
|
||||
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
|
||||
@@ -63,7 +64,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
promptId: action.promptId,
|
||||
actionId: action.actionId,
|
||||
actionLabel: action.actionLabel,
|
||||
sessionKey: action.sessionKey)
|
||||
sessionKey: action.sessionKey,
|
||||
gatewayStableID: action.gatewayStableID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +278,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
|
||||
let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String
|
||||
let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String
|
||||
let gatewayStableID = userInfo[WatchPromptNotificationBridge.gatewayStableIDKey] as? String
|
||||
|
||||
switch response.actionIdentifier {
|
||||
case WatchPromptNotificationBridge.actionPrimaryIdentifier:
|
||||
@@ -287,7 +290,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey)
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID)
|
||||
case WatchPromptNotificationBridge.actionSecondaryIdentifier:
|
||||
let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -297,7 +301,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey)
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -322,7 +327,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey)
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalPrompt(
|
||||
@@ -342,7 +348,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
promptId: action.promptId,
|
||||
actionId: action.actionId,
|
||||
actionLabel: action.actionLabel,
|
||||
sessionKey: action.sessionKey)
|
||||
sessionKey: action.sessionKey,
|
||||
gatewayStableID: action.gatewayStableID)
|
||||
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
|
||||
}
|
||||
|
||||
@@ -407,6 +414,7 @@ enum WatchPromptNotificationBridge {
|
||||
static let typeValue = "watch.prompt"
|
||||
static let promptIDKey = "openclaw.watch.promptId"
|
||||
static let sessionKeyKey = "openclaw.watch.sessionKey"
|
||||
static let gatewayStableIDKey = "openclaw.watch.gatewayStableID"
|
||||
static let actionPrimaryIDKey = "openclaw.watch.action.primary.id"
|
||||
static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label"
|
||||
static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id"
|
||||
@@ -422,6 +430,7 @@ enum WatchPromptNotificationBridge {
|
||||
static func scheduleMirroredWatchPromptNotificationIfNeeded(
|
||||
invokeID: String,
|
||||
params: OpenClawWatchNotifyParams,
|
||||
gatewayStableID: String?,
|
||||
sendResult: WatchNotificationSendResult) async
|
||||
{
|
||||
guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return }
|
||||
@@ -461,6 +470,11 @@ enum WatchPromptNotificationBridge {
|
||||
if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty {
|
||||
userInfo[self.sessionKeyKey] = sessionKey
|
||||
}
|
||||
if let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!gatewayStableID.isEmpty
|
||||
{
|
||||
userInfo[self.gatewayStableIDKey] = gatewayStableID
|
||||
}
|
||||
for (index, action) in displayedActions.enumerated() {
|
||||
userInfo[self.actionIDKey(index: index)] = action.id
|
||||
userInfo[self.actionLabelKey(index: index)] = action.label
|
||||
@@ -594,13 +608,15 @@ extension NodeAppModel {
|
||||
promptId: String?,
|
||||
actionId: String,
|
||||
actionLabel: String?,
|
||||
sessionKey: String?) async
|
||||
sessionKey: String?,
|
||||
gatewayStableID: String?) async
|
||||
{
|
||||
let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedActionID.isEmpty else { return }
|
||||
|
||||
let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedGatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let event = WatchQuickReplyEvent(
|
||||
@@ -609,6 +625,7 @@ extension NodeAppModel {
|
||||
actionId: normalizedActionID,
|
||||
actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil,
|
||||
sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil,
|
||||
gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil,
|
||||
note: "source=ios.notification",
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
transport: "ios.notification")
|
||||
|
||||
@@ -76,17 +76,23 @@ struct WatchMessagingStatus: Equatable {
|
||||
var activationState: String
|
||||
}
|
||||
|
||||
struct WatchQuickReplyEvent: Equatable {
|
||||
struct WatchQuickReplyEvent: Codable, Equatable {
|
||||
var replyId: String
|
||||
var promptId: String
|
||||
var actionId: String
|
||||
var actionLabel: String?
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
var note: String?
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
enum WatchMessageKind: String, Codable, Equatable {
|
||||
case chat
|
||||
case quickReply
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveEvent: Equatable {
|
||||
var replyId: String
|
||||
var approvalId: String
|
||||
@@ -115,6 +121,7 @@ struct WatchAppCommandEvent: Codable, Equatable {
|
||||
var text: String?
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
var messageKind: WatchMessageKind? = nil
|
||||
}
|
||||
|
||||
struct WatchNotificationSendResult: Equatable {
|
||||
@@ -134,7 +141,8 @@ protocol WatchMessagingServicing: AnyObject, Sendable {
|
||||
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
params: OpenClawWatchNotifyParams,
|
||||
gatewayStableID: String?) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalPrompt(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalResolved(
|
||||
|
||||
@@ -15,7 +15,8 @@ enum WatchMessagingPayloadCodec {
|
||||
|
||||
static func encodeNotificationPayload(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) -> [String: Any]
|
||||
params: OpenClawWatchNotifyParams,
|
||||
gatewayStableID: String?) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.notify.rawValue,
|
||||
@@ -31,6 +32,9 @@ enum WatchMessagingPayloadCodec {
|
||||
if let sessionKey = nonEmpty(params.sessionKey) {
|
||||
payload["sessionKey"] = sessionKey
|
||||
}
|
||||
if let gatewayStableID = nonEmpty(gatewayStableID) {
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let kind = nonEmpty(params.kind) {
|
||||
payload["kind"] = kind
|
||||
}
|
||||
@@ -232,6 +236,7 @@ enum WatchMessagingPayloadCodec {
|
||||
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
||||
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
|
||||
let note = self.nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
@@ -241,6 +246,7 @@ enum WatchMessagingPayloadCodec {
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID,
|
||||
note: note,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
|
||||
@@ -117,9 +117,13 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
|
||||
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
params: OpenClawWatchNotifyParams,
|
||||
gatewayStableID: String?) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params)
|
||||
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(
|
||||
id: id,
|
||||
params: params,
|
||||
gatewayStableID: gatewayStableID)
|
||||
return try await self.transport.sendPayload(payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
var sendError: Error?
|
||||
var lastSent: (id: String, params: OpenClawWatchNotifyParams)?
|
||||
var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)?
|
||||
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
|
||||
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
|
||||
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
|
||||
@@ -144,8 +144,12 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
self.appCommandHandler = handler
|
||||
}
|
||||
|
||||
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
|
||||
self.lastSent = (id: id, params: params)
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams,
|
||||
gatewayStableID: String?) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSent = (id: id, params: params, gatewayStableID: gatewayStableID)
|
||||
if let sendError {
|
||||
throw sendError
|
||||
}
|
||||
@@ -1072,7 +1076,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
let coordinator = WatchChatCoordinator(defaults: defaults)
|
||||
let coordinator = WatchMessageOutbox(defaults: defaults)
|
||||
let first = WatchAppCommandEvent(
|
||||
commandId: "watch-send-chat-gateway-a-1",
|
||||
command: .sendChat,
|
||||
@@ -1090,32 +1094,32 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
sentAtMs: 132,
|
||||
transport: "sendMessage")
|
||||
|
||||
if case .queue = coordinator.ingest(first, isChatAvailable: false, gatewayStableID: "gateway-a") {
|
||||
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, isChatAvailable: false, gatewayStableID: "gateway-a") {
|
||||
if case .queue = coordinator.ingest(second, isAvailable: false, gatewayStableID: "gateway-a") {
|
||||
} else {
|
||||
Issue.record("expected second gateway A command to queue")
|
||||
}
|
||||
|
||||
#expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil)
|
||||
coordinator.removeQueuedCommand(
|
||||
commandId: "watch-send-chat-gateway-a-1",
|
||||
#expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil)
|
||||
coordinator.removeQueuedMessage(
|
||||
messageID: "watch-send-chat-gateway-a-1",
|
||||
gatewayStableID: "gateway-b")
|
||||
|
||||
#expect(
|
||||
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
"watch-send-chat-gateway-a-1")
|
||||
#expect(
|
||||
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
"watch-send-chat-gateway-a-1")
|
||||
|
||||
coordinator.removeQueuedCommand(
|
||||
commandId: "watch-send-chat-gateway-a-1",
|
||||
coordinator.removeQueuedMessage(
|
||||
messageID: "watch-send-chat-gateway-a-1",
|
||||
gatewayStableID: "gateway-a")
|
||||
#expect(
|
||||
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
"watch-send-chat-gateway-a-2")
|
||||
}
|
||||
|
||||
@@ -1127,7 +1131,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
let coordinator = WatchChatCoordinator(defaults: defaults)
|
||||
let coordinator = WatchMessageOutbox(defaults: defaults)
|
||||
let event = WatchAppCommandEvent(
|
||||
commandId: "watch-send-chat-retry-gateway-a",
|
||||
command: .sendChat,
|
||||
@@ -1139,12 +1143,50 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
|
||||
coordinator.requeueFront(event, gatewayStableID: event.gatewayStableID)
|
||||
|
||||
#expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil)
|
||||
#expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil)
|
||||
#expect(
|
||||
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
|
||||
"watch-send-chat-retry-gateway-a")
|
||||
}
|
||||
|
||||
@Test @MainActor func watchMessageOutboxPrioritizesRepliesOverQueuedChat() 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 watchMessageOutboxDiscardsPermanentGatewayFailures() {
|
||||
#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 watchChatRestoreBackfillsGatewayOwnerIntoLegacyQueuedEvent() throws {
|
||||
let suiteName = "watch-chat-restore-legacy-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
@@ -1153,26 +1195,26 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
[
|
||||
{
|
||||
"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 = WatchChatCoordinator(defaults: defaults)
|
||||
let restored = coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")
|
||||
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")
|
||||
@@ -1186,7 +1228,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
let coordinator = WatchChatCoordinator(defaults: defaults)
|
||||
let coordinator = WatchMessageOutbox(defaults: defaults)
|
||||
for index in 0..<140 {
|
||||
let event = WatchAppCommandEvent(
|
||||
commandId: "watch-forward-\(index)",
|
||||
@@ -1198,9 +1240,12 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
transport: "sendMessage")
|
||||
if case .forward = coordinator.ingest(
|
||||
event,
|
||||
isChatAvailable: true,
|
||||
isAvailable: true,
|
||||
gatewayStableID: "gateway-a")
|
||||
{
|
||||
coordinator.removeQueuedMessage(
|
||||
messageID: event.commandId,
|
||||
gatewayStableID: "gateway-a")
|
||||
} else {
|
||||
Issue.record("expected forwarded command \(index)")
|
||||
}
|
||||
@@ -1216,7 +1261,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
transport: "sendMessage")
|
||||
if case .forward = coordinator.ingest(
|
||||
oldestEvent,
|
||||
isChatAvailable: true,
|
||||
isAvailable: true,
|
||||
gatewayStableID: "gateway-a")
|
||||
{
|
||||
} else {
|
||||
@@ -1233,7 +1278,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
transport: "sendMessage")
|
||||
if case .deduped = coordinator.ingest(
|
||||
recentEvent,
|
||||
isChatAvailable: true,
|
||||
isAvailable: true,
|
||||
gatewayStableID: "gateway-a")
|
||||
{
|
||||
} else {
|
||||
@@ -1249,7 +1294,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
let coordinator = WatchChatCoordinator(defaults: defaults)
|
||||
let coordinator = WatchMessageOutbox(defaults: defaults)
|
||||
for index in 0..<140 {
|
||||
let event = WatchAppCommandEvent(
|
||||
commandId: "watch-queued-\(index)",
|
||||
@@ -1261,7 +1306,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
transport: "transferUserInfo")
|
||||
if case .queue = coordinator.ingest(
|
||||
event,
|
||||
isChatAvailable: false,
|
||||
isAvailable: false,
|
||||
gatewayStableID: "gateway-a")
|
||||
{
|
||||
} else {
|
||||
@@ -1269,8 +1314,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
}
|
||||
}
|
||||
|
||||
coordinator.removeQueuedCommand(
|
||||
commandId: "watch-queued-0",
|
||||
coordinator.removeQueuedMessage(
|
||||
messageID: "watch-queued-0",
|
||||
gatewayStableID: "gateway-a")
|
||||
|
||||
let duplicateDeliveredEvent = WatchAppCommandEvent(
|
||||
@@ -1283,7 +1328,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
transport: "transferUserInfo")
|
||||
if case .deduped = coordinator.ingest(
|
||||
duplicateDeliveredEvent,
|
||||
isChatAvailable: true,
|
||||
isAvailable: true,
|
||||
gatewayStableID: "gateway-a")
|
||||
{
|
||||
} else {
|
||||
@@ -1792,6 +1837,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
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",
|
||||
@@ -1808,6 +1854,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#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)
|
||||
@@ -1928,8 +1975,11 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
}
|
||||
|
||||
@Test @MainActor func watchReplyQueuesWhenGatewayOffline() 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",
|
||||
@@ -1937,6 +1987,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
actionId: "approve",
|
||||
actionLabel: "Approve",
|
||||
sessionKey: "ios",
|
||||
gatewayStableID: "gateway-watch-reply",
|
||||
note: nil,
|
||||
sentAtMs: 1234,
|
||||
transport: "transferUserInfo"))
|
||||
@@ -1944,6 +1995,162 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel._test_queuedWatchReplyCount() == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func watchMessageOutboxRestoresQueuedReplyAfterRestart() 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 watchMessageOutboxRestoresDeliveryTombstonesAndPromptRoutes() throws {
|
||||
let suiteName = "watch-message-metadata-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let event = WatchAppCommandEvent(
|
||||
commandId: "delivered-reply",
|
||||
command: .sendChat,
|
||||
sessionKey: "main",
|
||||
gatewayStableID: "gateway-a",
|
||||
text: "Delivered reply",
|
||||
sentAtMs: 1,
|
||||
transport: "sendMessage",
|
||||
messageKind: .quickReply)
|
||||
let firstOutbox = WatchMessageOutbox(defaults: defaults)
|
||||
firstOutbox.recordPromptRoute(promptID: "prompt-a", gatewayStableID: "gateway-a")
|
||||
_ = firstOutbox.ingest(event, isAvailable: true, gatewayStableID: "gateway-a")
|
||||
firstOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a")
|
||||
for index in 0..<140 {
|
||||
let pending = WatchAppCommandEvent(
|
||||
commandId: "pending-\(index)",
|
||||
command: .sendChat,
|
||||
sessionKey: "main",
|
||||
gatewayStableID: "gateway-a",
|
||||
text: "Pending \(index)",
|
||||
sentAtMs: index + 2,
|
||||
transport: "transferUserInfo")
|
||||
_ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a")
|
||||
}
|
||||
|
||||
let restoredOutbox = WatchMessageOutbox(defaults: defaults)
|
||||
#expect(restoredOutbox.gatewayStableID(forPromptID: "prompt-a") == "gateway-a")
|
||||
if case .deduped = restoredOutbox.ingest(
|
||||
event,
|
||||
isAvailable: true,
|
||||
gatewayStableID: "gateway-a")
|
||||
{
|
||||
} else {
|
||||
Issue.record("expected delivered reply to remain deduped after restart")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func watchReplyDropsStaleGatewayTarget() 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 watchReplyUsesIdempotentChatOutbox() 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 Task.yield()
|
||||
watchService.emitReply(event)
|
||||
await Task.yield()
|
||||
|
||||
#expect(appModel.openChatRequestID == initialOpenChatRequestID + 1)
|
||||
#expect(appModel._test_queuedWatchReplyCount() == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor func watchReplyBindsLegacyPromptToCurrentGateway() 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 + 1)
|
||||
#expect(appModel._test_queuedWatchReplyCount() == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let url = try #require(URL(string: "openclaw://agent?message=hello"))
|
||||
@@ -1962,6 +2169,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
@Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() 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)
|
||||
@@ -2017,6 +2225,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
@Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() 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)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ struct WatchReplyDraft {
|
||||
var actionId: String
|
||||
var actionLabel: String?
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
var note: String?
|
||||
var sentAtMs: Int
|
||||
}
|
||||
@@ -115,6 +116,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
{
|
||||
payload["sessionKey"] = sessionKey
|
||||
}
|
||||
if let gatewayStableID = draft.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!gatewayStableID.isEmpty
|
||||
{
|
||||
payload["gatewayStableID"] = gatewayStableID
|
||||
}
|
||||
if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
|
||||
payload["note"] = note
|
||||
}
|
||||
@@ -250,6 +256,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let sessionKey = (payload["sessionKey"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayStableID = (payload["gatewayStableID"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let kind = (payload["kind"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let details = (payload["details"] as? String)?
|
||||
@@ -266,6 +274,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
sentAtMs: sentAtMs,
|
||||
promptId: promptId,
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID,
|
||||
kind: kind,
|
||||
details: details,
|
||||
expiresAtMs: expiresAtMs,
|
||||
|
||||
@@ -154,6 +154,7 @@ struct WatchNotifyMessage {
|
||||
var sentAtMs: Int?
|
||||
var promptId: String?
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
var kind: String?
|
||||
var details: String?
|
||||
var expiresAtMs: Int?
|
||||
@@ -184,6 +185,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
var lastDeliveryKey: String?
|
||||
var promptId: String?
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
var kind: String?
|
||||
var details: String?
|
||||
var expiresAtMs: Int?
|
||||
@@ -213,6 +215,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
var updatedAt: Date?
|
||||
var promptId: String?
|
||||
var sessionKey: String?
|
||||
var gatewayStableID: String?
|
||||
var kind: String?
|
||||
var details: String?
|
||||
var expiresAtMs: Int?
|
||||
@@ -361,6 +364,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
self.updatedAt = Date()
|
||||
self.promptId = message.promptId
|
||||
self.sessionKey = message.sessionKey
|
||||
self.gatewayStableID = message.gatewayStableID
|
||||
self.kind = message.kind
|
||||
self.details = message.details
|
||||
self.expiresAtMs = message.expiresAtMs
|
||||
@@ -676,6 +680,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
self.lastDeliveryKey = state.lastDeliveryKey
|
||||
self.promptId = state.promptId
|
||||
self.sessionKey = state.sessionKey
|
||||
self.gatewayStableID = state.gatewayStableID
|
||||
self.kind = state.kind
|
||||
self.details = state.details
|
||||
self.expiresAtMs = state.expiresAtMs
|
||||
@@ -704,6 +709,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
lastDeliveryKey: lastDeliveryKey,
|
||||
promptId: promptId,
|
||||
sessionKey: sessionKey,
|
||||
gatewayStableID: gatewayStableID,
|
||||
kind: kind,
|
||||
details: details,
|
||||
expiresAtMs: expiresAtMs,
|
||||
@@ -761,6 +767,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
actionId: action.id,
|
||||
actionLabel: action.label,
|
||||
sessionKey: self.sessionKey,
|
||||
gatewayStableID: self.gatewayStableID,
|
||||
note: nil,
|
||||
sentAtMs: Self.nowMs())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user